diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3a2738a..2e14993 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/skills/worklog/SKILL.md b/skills/worklog/SKILL.md index 55aa22d..b54c5c3 100644 --- a/skills/worklog/SKILL.md +++ b/skills/worklog/SKILL.md @@ -37,7 +37,7 @@ Once a known mode is parsed: run preamble (per table), read `modes/.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 | @@ -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. diff --git a/skills/worklog/bin/_lint.py b/skills/worklog/bin/_lint.py index f3f1a9a..c6cbbbf 100755 --- a/skills/worklog/bin/_lint.py +++ b/skills/worklog/bin/_lint.py @@ -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). @@ -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) @@ -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() @@ -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( diff --git a/skills/worklog/bin/codex-surface-check.sh b/skills/worklog/bin/codex-surface-check.sh index 43782bf..58d059f 100755 --- a/skills/worklog/bin/codex-surface-check.sh +++ b/skills/worklog/bin/codex-surface-check.sh @@ -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) @@ -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 ;; @@ -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") diff --git a/skills/worklog/modes/init.md b/skills/worklog/modes/init.md index 900e07f..f6a007d 100644 --- a/skills/worklog/modes/init.md +++ b/skills/worklog/modes/init.md @@ -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 @@ -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" `. Its "Tracker-ready snippet" formats that +task's unchecked `## Next` items. Hydrate only that focused task: -Run `"$WORKLOG_BIN/context.sh" ` 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 diff --git a/skills/worklog/modes/registry.md b/skills/worklog/modes/registry.md new file mode 100644 index 0000000..205ef80 --- /dev/null +++ b/skills/worklog/modes/registry.md @@ -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. + + +- help +- init +- sync +- status +- context +- plan +- spawn +- export +- import +- lint +- project +- scrape-slack +- review + diff --git a/skills/worklog/tests/cache/test_stale_consumers.sh b/skills/worklog/tests/cache/test_stale_consumers.sh new file mode 100755 index 0000000..e1b1363 --- /dev/null +++ b/skills/worklog/tests/cache/test_stale_consumers.sh @@ -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" diff --git a/skills/worklog/tests/init/test_light_init.sh b/skills/worklog/tests/init/test_light_init.sh new file mode 100755 index 0000000..33ef9fb --- /dev/null +++ b/skills/worklog/tests/init/test_light_init.sh @@ -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" diff --git a/skills/worklog/tests/lint/test_file_scope.sh b/skills/worklog/tests/lint/test_file_scope.sh index 79bf88d..0546b59 100755 --- a/skills/worklog/tests/lint/test_file_scope.sh +++ b/skills/worklog/tests/lint/test_file_scope.sh @@ -80,4 +80,54 @@ if "$WORKLOG_BIN/lint.sh" --file=people/tester/active/missing-task.md >/tmp/work fi grep -q 'is not a tracked task file' /tmp/worklog-lint-missing.out -echo "ok: lint --file scope" +mkdir -p people/tester/archived +cat > people/tester/archived/good-task.md <<'EOF' +--- +slug: good-task +kind: impl +status: archived +project: sample +last_updated: 2026-06-12 +next_action: — +repos: [sample] +--- + +## Context +Invisible duplicate in an unsupported state directory. + +## Next +None. +EOF + +# Transcripts are supported auxiliary Markdown and intentionally share task slugs. +mkdir -p people/tester/transcripts +cat > people/tester/transcripts/good-task.md <<'EOF' +# Transcript + +Session evidence for good-task. +EOF + +layout_json="$("$WORKLOG_BIN/lint.sh" --format=json 2>/dev/null || true)" +printf '%s' "$layout_json" | python3 -c ' +import json, sys +d = json.load(sys.stdin) +errors = [ + error + for issue in d["issues"] + for error in issue["errors"] +] +assert any("unknown task state directory" in error and "archived" in error for error in errors), errors +assert any("duplicate slug" in error and "good-task" in error for error in errors), errors +assert not any("transcripts" in error for error in errors), errors +' + +# File-scoped lint remains scoped to the requested canonical task. +good_json="$("$WORKLOG_BIN/lint.sh" --file=people/tester/active/good-task.md --format=json)" +printf '%s' "$good_json" | python3 -c ' +import json, sys +d = json.load(sys.stdin) +assert d["total_files"] == 1, d +assert d["total_errors"] == 0, d +' + +echo "ok: lint --file scope and vault layout" diff --git a/skills/worklog/tests/surface/test_mode_registry.sh b/skills/worklog/tests/surface/test_mode_registry.sh new file mode 100755 index 0000000..5e65f9a --- /dev/null +++ b/skills/worklog/tests/surface/test_mode_registry.sh @@ -0,0 +1,36 @@ +#!/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/repo/people/tester/active" "$TMP/repo/people/tester/archive" +cp "$ROOT/templates/README.md" "$TMP/repo/README.md" +cp "$ROOT/templates/AGENTS.md" "$TMP/repo/AGENTS.md" +git -C "$TMP/repo" init -q + +WORKLOG_REPO="$TMP/repo" \ +WORKLOG_LDAP=tester \ +CODEX_SKILL_PATH="$ROOT/SKILL.md" \ +MODE_REGISTRY_PATH="$ROOT/modes/registry.md" \ +MODE_INIT_PATH="$ROOT/modes/init.md" \ + "$WORKLOG_BIN/codex-surface-check.sh" >/dev/null + +awk ' + /MODE_REGISTRY_END/ { print "- fixture-mode" } + { print } +' "$ROOT/modes/registry.md" > "$TMP/registry.md" + +if WORKLOG_REPO="$TMP/repo" \ + WORKLOG_LDAP=tester \ + CODEX_SKILL_PATH="$ROOT/SKILL.md" \ + MODE_REGISTRY_PATH="$TMP/registry.md" \ + MODE_INIT_PATH="$ROOT/modes/init.md" \ + "$WORKLOG_BIN/codex-surface-check.sh" >/dev/null 2>&1; then + echo "FAIL: checker ignored a registry-only mode" + exit 1 +fi + +echo "ok: command surfaces follow the packaged mode registry"