Skip to content
Merged
6 changes: 4 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -66,11 +66,13 @@ jobs:
run: |
sudo apt-get update -qq
sudo apt-get install -y shellcheck jq
pip install --quiet ruff pyyaml
pip install --quiet 'ruff==0.15.12' pyyaml
- name: set git identity for fixture vault commits
run: |
git config --global user.email "ci@cheshirecode.test"
git config --global user.name "ci-fixture"
git config --global init.defaultBranch main
- name: run worklog-skill test mode
run: bash tests/run.sh worklog-skill
run: |
ruff --version
bash tests/run.sh worklog-skill
4 changes: 2 additions & 2 deletions skills/worklog/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ Once a known mode is parsed: run preamble (per table), read `modes/<mode>.md`, f

| Mode | Preamble | `references/protocol.md` | Reads AGENTS.md? | lessons.md? |
|---------|----------|--------------------------|------------------|-------------|
| init | `--full` | no | yes | quickref (limit=15) |
| init | default / `--light` → `--minimal`; explicit `--full` → `--full` | no | yes | quickref (limit=15) |
| sync | `--full` | only when creating or hand-editing a task | only for an edge case the reference does not answer | no |
| status | `--minimal` | no | no | no |
| context | `--minimal` | no | no | no |
Expand Down Expand Up @@ -76,7 +76,7 @@ Run helpers from a shell that has the target clone's environment loaded. Prefer
cd "$WORKLOG_REPO" && "$WORKLOG_BIN/preamble.sh" [--minimal|--full]
```

Emits `LDAP=`, `PROJECTS_DIR=`, `NAMESPACE=`, `PULL=` key/value lines plus a `### roster` block (top 15 active tasks by `last_updated`, one tab-separated line each). Internally handles: LDAP resolve (24h cached), namespace bootstrap, rate-limited `git pull` (5-min stamp), `.gitconfig.lock` cleanup, autosave-if-dirty.
Emits `LDAP=`, `PROJECTS_DIR=`, `NAMESPACE=`, `PULL=` key/value lines plus a `### roster` block (top 15 active tasks by `last_updated`, one tab-separated line each). Both paths resolve LDAP and inspect the namespace/roster. Full mode additionally handles the rate-limited pull and dirty-tree autosave.

Skip re-invocation within the same session — preamble.sh is idempotent but the tool turns aren't free.

Expand Down
65 changes: 63 additions & 2 deletions skills/worklog/bin/_lint.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
- `repos` is a list (or absent).
- Relations (`parent_slug`, `related[].slug`, `supersedes`, `superseded_by`,
`reopens`) resolve to real task files under people/*/{active,archive}/.
- Task Markdown lives only under people/*/{active,archive}/ and slugs are
globally unique across task-bearing state directories.
- `related[]` entries have a `note`.
- State/status consistency: files under archive/ should have
`status: archived` (warn if not).
Expand Down Expand Up @@ -78,6 +80,64 @@ def _collect(root: pathlib.Path) -> list[tuple[pathlib.Path, str]]:
return out


def _task_slug(path: pathlib.Path) -> str:
text = path.read_text()
match = FRONTMATTER_RE.match(text)
if match:
try:
frontmatter = yaml.safe_load(match.group(1)) or {}
except yaml.YAMLError:
frontmatter = {}
if isinstance(frontmatter, dict) and frontmatter.get("slug"):
return str(frontmatter["slug"])
return path.stem


def _layout_issues(root: pathlib.Path) -> list[dict[str, Any]]:
people = root / "people"
issues: list[dict[str, Any]] = []
task_paths: list[pathlib.Path] = []

for ldap_dir in sorted(people.glob("*")):
if not ldap_dir.is_dir():
continue
for state_dir in sorted(path for path in ldap_dir.iterdir() if path.is_dir()):
if state_dir.name in {"active", "archive"}:
task_paths.extend(sorted(state_dir.glob("*.md")))
continue
if state_dir.name == "transcripts":
continue
markdown = sorted(state_dir.rglob("*.md"))
task_paths.extend(markdown)
if markdown:
rel = state_dir.relative_to(root)
issues.append({
"file": str(rel),
"state": "layout",
"errors": [
f"unknown task state directory '{rel}' contains {len(markdown)} "
"Markdown task file(s); supported states: active, archive"
],
"warnings": [],
})

paths_by_slug: dict[str, list[pathlib.Path]] = {}
for path in task_paths:
paths_by_slug.setdefault(_task_slug(path), []).append(path)
for slug, paths in sorted(paths_by_slug.items()):
if len(paths) < 2:
continue
rendered = ", ".join(str(path.relative_to(root)) for path in paths)
issues.append({
"file": "people",
"state": "layout",
"errors": [f"duplicate slug '{slug}' appears in: {rendered}"],
"warnings": [],
})

return issues


def _strict_yaml(raw: str) -> tuple[dict[str, Any] | None, str | None]:
try:
fm = yaml.safe_load(raw)
Expand Down Expand Up @@ -583,6 +643,7 @@ def main() -> None:
sys.exit(2)
else:
files = all_files
layout_report = [] if single_file else _layout_issues(root)
known_slugs: set[str] = set()
for path, _ in all_files:
text = path.read_text()
Expand Down Expand Up @@ -621,8 +682,8 @@ def main() -> None:
if missing and _apply_fix_related(path, missing):
fixed_files.append((str(path.relative_to(root)), missing))

report: list[dict[str, Any]] = []
total_errors = 0
report: list[dict[str, Any]] = list(layout_report)
total_errors = sum(len(issue["errors"]) for issue in layout_report)
total_warnings = 0
for path, state in files:
errors, warnings = _lint_file(
Expand Down
22 changes: 21 additions & 1 deletion skills/worklog/bin/codex-surface-check.sh
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,9 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(resolve_worklog_repo)" || exit 1
cd "$REPO_ROOT"

EXPECTED=(help init sync status context plan spawn export import lint project scrape-slack review)
CODEX_SKILL_PATH="${CODEX_SKILL_PATH:-$HOME/.codex/skills/worklog/SKILL.md}"
MODE_INIT_PATH="${MODE_INIT_PATH:-$SCRIPT_DIR/../modes/init.md}"
MODE_REGISTRY_PATH="${MODE_REGISTRY_PATH:-$SCRIPT_DIR/../modes/registry.md}"

case "${1:-}" in
-h|--help)
Expand All @@ -29,6 +29,8 @@ the same worklog command names.

Environment:
CODEX_SKILL_PATH=/path/to/SKILL.md override local Codex skill path
MODE_REGISTRY_PATH=/path/to/registry.md
override public mode registry
EOF
exit 0
;;
Expand All @@ -40,6 +42,24 @@ EOF
;;
esac

if [[ ! -f "$MODE_REGISTRY_PATH" ]]; then
echo "codex-surface-check: mode registry not found at $MODE_REGISTRY_PATH" >&2
exit 1
fi

EXPECTED=()
while IFS= read -r cmd; do
[[ -n "$cmd" ]] && EXPECTED+=("$cmd")
done < <(
sed -n '/MODE_REGISTRY_BEGIN/,/MODE_REGISTRY_END/ {
s/^- \([a-z0-9-][a-z0-9-]*\)$/\1/p
}' "$MODE_REGISTRY_PATH"
)
if [[ ${#EXPECTED[@]} -eq 0 ]]; then
echo "codex-surface-check: mode registry is empty or malformed" >&2
exit 1
fi

SURFACES=("README.md" "AGENTS.md")
if [[ -f "$CODEX_SKILL_PATH" ]]; then
SURFACES+=("$CODEX_SKILL_PATH")
Expand Down
27 changes: 20 additions & 7 deletions skills/worklog/modes/init.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
# Mode: `init`

**Routing contract:** Default and `--light` use `preamble.sh --minimal`; only
explicit `--full` uses `preamble.sh --full`. Select the preamble from the user
flag before reading this mode. Light init must not pull, autosave, commit, push,
or rebuild caches.

Onboard a session. Light by default; escalates to a full external scan when drift is detected or the user explicitly asks.

## Detection — light vs. full
Expand Down Expand Up @@ -48,17 +53,25 @@ drift:
ready — which task?
```

## Tracker hydration (MUST, on every init)
## Tracker hydration (after focus selection)

The active-task list is orientation, not an instruction to materialize every
durable task in an ephemeral tracker.

After printing the active-task list and before asking "which task?", **hydrate the in-session tracker** for any active task with ≥3 unchecked items in its `## Next` section. Per AGENTS.md § In-session progress visibility:
Do not hydrate `TaskCreate` or `update_plan` before the user selects a task.

- **Claude Code:** invoke `TaskCreate` for each unchecked `- [ ]` item under `## Next`. Use the slug as the task's `metadata.slug` so the tracker entry maps back to its source task file.
- **OpenAI Codex CLI:** emit an initial `update_plan` populated from the same `## Next` items.
- **Cursor:** populate the canvas todo card / Plan Mode entries.
After the user selects or resumes a slug, run
`"$WORKLOG_BIN/context.sh" <slug>`. Its "Tracker-ready snippet" formats that
task's unchecked `## Next` items. Hydrate only that focused task:

Run `"$WORKLOG_BIN/context.sh" <slug>` for any focused task — its output's "Tracker-ready snippet" section formats each unchecked item ready to paste/exec.
- **Claude Code:** invoke `TaskCreate` for its unchecked items, using the slug
as `metadata.slug`.
- **OpenAI Codex CLI:** emit `update_plan` for the same focused items.
- **Cursor:** populate the focused canvas todo card / Plan Mode entries.

Skip hydration only when every active task is at ≤2 unchecked items (single-step or trivial). Don't wait for the user to ask. Drift evidence: 2026-04-27 review session ran ~30 multi-step commits with zero `TaskCreate` invocations despite the system reminder firing repeatedly (`docs/lessons.md` 2026-04 entry).
Skip hydration when the selected task has at most two unchecked items. The
durable task files remain the multi-task backlog; the tracker mirrors only the
work this session intends to execute.

## Full path

Expand Down
21 changes: 21 additions & 0 deletions skills/worklog/modes/registry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Public mode registry

One public command per line, in menu order. `codex-surface-check.sh` consumes
the marked block; keep behavior and prose in `SKILL.md` and the selected mode
file.

<!-- MODE_REGISTRY_BEGIN -->
- help
- init
- sync
- status
- context
- plan
- spawn
- export
- import
- lint
- project
- scrape-slack
- review
<!-- MODE_REGISTRY_END -->
83 changes: 83 additions & 0 deletions skills/worklog/tests/cache/test_stale_consumers.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
#!/usr/bin/env bash
set -euo pipefail

ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
WORKLOG_BIN="$ROOT/bin"
TMP="$(mktemp -d)"
trap 'rm -rf "$TMP"' EXIT

mkdir -p "$TMP/people/tester/active" "$TMP/people/tester/archive"
git -C "$TMP" init -q
git -C "$TMP" config user.email tester@example.com
git -C "$TMP" config user.name Tester

cat > "$TMP/people/tester/active/cache-task.md" <<'EOF'
---
slug: cache-task
kind: investigation
status: draft
project: sample
last_updated: 2026-07-25
next_action: Read the original task state
repos: [sample]
---

## Context
Derived-view invalidation fixture.

## Next
- [ ] Read the original task state
EOF

WORKLOG_REPO="$TMP" WORKLOG_LDAP=tester \
"$WORKLOG_BIN/search.sh" --list --status=draft \
| grep -Eq '^active +draft +cache-task$'
[[ -s "$TMP/.cache/index.jsonl" ]]

sleep 1
python3 - "$TMP/people/tester/active/cache-task.md" <<'PY'
from pathlib import Path
import sys

path = Path(sys.argv[1])
text = path.read_text()
text = text.replace("status: draft", "status: in-progress")
text = text.replace(
"Read the original task state",
"Use the updated raw task state",
)
path.write_text(text)
PY

WORKLOG_REPO="$TMP" WORKLOG_LDAP=tester \
"$WORKLOG_BIN/search.sh" --list --status=in-progress \
| grep -Eq '^active +in-progress +cache-task$'
if WORKLOG_REPO="$TMP" WORKLOG_LDAP=tester \
"$WORKLOG_BIN/search.sh" --list --status=draft 2>/dev/null | grep -q 'cache-task'; then
echo "FAIL: stale index returned the old task status"
exit 1
fi

WORKLOG_REPO="$TMP" WORKLOG_LDAP=tester \
"$WORKLOG_BIN/compact-kernels.sh" >/dev/null
touch -t 202001010000 \
"$TMP/.cache/compact-kernels.md" \
"$TMP/.cache/compact-kernels.json"

preamble="$(
WORKLOG_REPO="$TMP" WORKLOG_LDAP=tester \
"$WORKLOG_BIN/preamble.sh" --minimal
)"
grep -q 'roster-health: stale' <<< "$preamble"
if grep -q 'Use the updated raw task state' <<< "$preamble"; then
echo "FAIL: stale kernel content leaked into the preamble roster"
exit 1
fi

context="$(
WORKLOG_REPO="$TMP" WORKLOG_LDAP=tester \
"$WORKLOG_BIN/context.sh" cache-task
)"
grep -q 'Use the updated raw task state' <<< "$context"

echo "ok: stale index rebuilds and stale kernels fall through to raw context"
64 changes: 64 additions & 0 deletions skills/worklog/tests/init/test_light_init.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
#!/usr/bin/env bash
set -euo pipefail

ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
WORKLOG_BIN="$ROOT/bin"
SKILL_ROOT="$ROOT/SKILL.md"
INIT_MODE="$ROOT/modes/init.md"
TMP="$(mktemp -d)"
trap 'rm -rf "$TMP"' EXIT

grep -Fq 'default / `--light` → `--minimal`' "$SKILL_ROOT"
grep -Fq 'explicit `--full` → `--full`' "$SKILL_ROOT"
grep -Fq 'Default and `--light` use `preamble.sh --minimal`' "$INIT_MODE"
grep -Fq 'Do not hydrate `TaskCreate` or `update_plan` before the user selects a task.' "$INIT_MODE"
if grep -Fq 'for any active task with ≥3 unchecked items' "$INIT_MODE"; then
echo "FAIL: init still hydrates every multi-step active task"
exit 1
fi

mkdir -p "$TMP/people/tester/active" "$TMP/people/tester/archive" "$TMP/.cache"
git -C "$TMP" init -q
git -C "$TMP" config user.email tester@example.com
git -C "$TMP" config user.name Tester

cat > "$TMP/people/tester/active/light-init.md" <<'EOF'
---
slug: light-init
kind: investigation
status: in-progress
project: sample
last_updated: 2026-07-25
next_action: Verify light init
repos: [sample]
---

## Context
Dirty-clone light-init fixture.

## Next
Verify light init.
EOF

printf '[]\n' > "$TMP/.cache/compact-kernels.json"
printf 'keep\n' > "$TMP/.cache/preamble-pull-stamp"
git -C "$TMP" add people .cache
git -C "$TMP" commit -qm "fixture"
printf '\ndirty\n' >> "$TMP/people/tester/active/light-init.md"

before_status="$(git -C "$TMP" status --porcelain)"
before_kernel_mtime="$(stat -f %m "$TMP/.cache/compact-kernels.json" 2>/dev/null || stat -c %Y "$TMP/.cache/compact-kernels.json")"
before_pull_mtime="$(stat -f %m "$TMP/.cache/preamble-pull-stamp" 2>/dev/null || stat -c %Y "$TMP/.cache/preamble-pull-stamp")"

WORKLOG_REPO="$TMP" WORKLOG_LDAP=tester \
"$WORKLOG_BIN/preamble.sh" --minimal >/dev/null

after_status="$(git -C "$TMP" status --porcelain)"
after_kernel_mtime="$(stat -f %m "$TMP/.cache/compact-kernels.json" 2>/dev/null || stat -c %Y "$TMP/.cache/compact-kernels.json")"
after_pull_mtime="$(stat -f %m "$TMP/.cache/preamble-pull-stamp" 2>/dev/null || stat -c %Y "$TMP/.cache/preamble-pull-stamp")"

[[ "$after_status" == "$before_status" ]]
[[ "$after_kernel_mtime" == "$before_kernel_mtime" ]]
[[ "$after_pull_mtime" == "$before_pull_mtime" ]]

echo "ok: default/light init routes to non-mutating minimal preamble"
Loading
Loading