From af8719ae5df81c900fffea220e555a11fd52a1f6 Mon Sep 17 00:00:00 2001 From: hkwuks <25031515+hkwuks@users.noreply.github.com> Date: Fri, 30 Oct 2026 21:34:36 +0800 Subject: [PATCH 1/3] feat: wire the pre-write gate on Codex and stop four silent allows Codex CLI had no pre-write gate: hooks-codex.json wired only SessionStart and UserPromptSubmit, so a TaskFlow run there had no gate, no evidence capture, and one reminder per prompt. runtime.md recorded that as a limitation of the host. It was not - Codex's own hook docs (read 2026-10-29) document PreToolUse firing on apply_patch, permissionDecision: deny, and additionalContext. CONTRIBUTING now states the distinction, because "not wired" and "not supported" are different facts and only one of them is work. capability-gate learned the shape Codex sends: it reads the patch text under tool_input.command, walks the four directives that name a target, and judges each path with the rule the file_path shape already used. A move is Update File: plus Move to: , so both ends are judged - reading only the destination leaves the departure unchecked. A patch naming several files is judged path by path and a refusal ends the run, so a patch is all-or-nothing as the host applies it. A non-empty patch with no recognizable directive is refused, because a gate that cannot read its host must not report success. Review found four ways that gate allowed writes it exists to refuse, none of them in the fixtures: - set -- $patch_paths expanded unquoted, so a path containing a space split into two paths that were each judged and each allowed. - a CRLF patch - routine on Windows, the host this wiring is for - left a trailing \r on every path, so the basename matched no stage and the gate returned 0. - releases were written as each path was judged, so a refused two-path patch still spent an evidence line behind a write the host never applied. - consumed was recomputed per path, so two releases in one patch both claimed sequence 1. The last two are one defect - deciding and writing were interleaved - and are fixed by queuing releases and writing them once, after every path is judged. Each has a fixture, and each fixture reddens when its fix is reverted. Also in this change, both from the same audit: - capability-gate never read ## Approval. It now refuses a write whose recorded approval names a different Task version, and allows every other write: approval is the permit to start work, so refusing unapproved writes would deadlock every new task at its first step. The decision lives in a new hooks/approval-check shared with completion-gate, whose verdicts on all 93 archived Plans are unchanged, message included. - completion-gate read the last - Status: line of a Step carrying two. It now refuses and names the Step: the two lines state different statuses, and which is intended is not a question a file reader can answer. A per-Step evidence requirement was measured and rejected rather than shipped: it was satisfied by three throwaway "ok" lines on a Plan where nothing was done, and refused 73 of 93 archived Plans. Recorded in the task's reject-a16.md. The Codex column is documented, not measured - no real Codex has run this. Its real-machine check must include a *** Move to: case. --- CHANGELOG.md | 42 ++- CONTRIBUTING.md | 22 ++ .../evidence-a16-fake-plan.md | 27 ++ .../measure-drift.py | 50 +++ .../measure-turns.py | 46 +++ .../2026-10-29-midturn-presence/plan.md | 198 +++++++++++ .../2026-10-29-midturn-presence/pr-body.md | 102 ++++++ .../2026-10-29-midturn-presence/prd.md | 327 +++++++++++++++++ .../2026-10-29-midturn-presence/reject-a16.md | 64 ++++ .../2026-10-29-midturn-presence/spec.md | 179 ++++++++++ TaskFlowDocs/todo.md | 21 +- hooks/README.md | 6 +- hooks/approval-check | 112 ++++++ hooks/capability-gate | 281 +++++++++++---- hooks/completion-gate | 70 ++-- hooks/hooks-codex.json | 14 +- hooks/smoke-test | 336 +++++++++++++++++- skills/taskflow/references/runtime.md | 41 ++- 18 files changed, 1834 insertions(+), 104 deletions(-) create mode 100644 TaskFlowDocs/2026-10-29-midturn-presence/evidence-a16-fake-plan.md create mode 100644 TaskFlowDocs/2026-10-29-midturn-presence/measure-drift.py create mode 100644 TaskFlowDocs/2026-10-29-midturn-presence/measure-turns.py create mode 100644 TaskFlowDocs/2026-10-29-midturn-presence/plan.md create mode 100644 TaskFlowDocs/2026-10-29-midturn-presence/pr-body.md create mode 100644 TaskFlowDocs/2026-10-29-midturn-presence/prd.md create mode 100644 TaskFlowDocs/2026-10-29-midturn-presence/reject-a16.md create mode 100644 TaskFlowDocs/2026-10-29-midturn-presence/spec.md create mode 100644 hooks/approval-check diff --git a/CHANGELOG.md b/CHANGELOG.md index 13f4623..bef362e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,18 @@ ## [Unreleased] +### Added + +- **Codex CLI now has the pre-write gate, which it never had.** `hooks/hooks-codex.json` wired only `SessionStart` and `UserPromptSubmit`, so on Codex a TaskFlow run had no gate, no evidence capture, and one reminder line per prompt — the flow was guidance there, not a lifecycle. `runtime.md` recorded this as a limitation of the host. It was not: Codex's own hooks documentation (read 2026-10-29) documents `PreToolUse` firing on `apply_patch`, `hookSpecificOutput.permissionDecision: "deny"`, and `additionalContext` on both tool events. The wiring now carries `PreToolUse` with matcher `apply_patch`, and the gate learned the shape that host sends. + +- **`capability-gate` reads a patch as well as a path.** Codex reports an edit as `tool_input.command` holding the patch text, with no `file_path` field at all, so the gate previously saw no input and exited 0 on every write. It now parses the four directives that name a target — `*** Update File:`, `*** Add File:`, `*** Delete File:`, `*** Move to:` — normalizes and joins each path exactly as it does `file_path`, and applies the same base/stage/task-directory rule to each. A move is `Update File: ` plus `Move to: `, so both ends are judged: the document really does leave one task and arrive at another, and reading only the destination leaves the departure unchecked. One patch naming several files is judged path by path, and any refusal ends the run, so a patch is all-or-nothing exactly as the host applies it. A patch that is non-empty but carries no recognizable directive is **refused**, because a gate that cannot read its host must not report success; an empty command is the one exception, since no edit happened. `hooks/task`'s approval decision moved to a new `hooks/approval-check` so the gate and `completion-gate` share one implementation rather than two. + +### Changed + +- **`capability-gate` refuses a write whose approval names a different Task version, and allows every other write.** It never read `## Approval` at all. The obvious rule — refuse unapproved writes — is wrong, and would have deadlocked every new task: approval is the permit to *start work*, and every `prd.md` is written before its own approval by construction. So the gate asks the narrow question it can justify: an approval exists and names a version that is not the current one, which is a nod given to text that has since been rewritten. `pending` and an empty value both mean "nothing approved yet" and are allowed. Checked ahead of the released-stage shortcut, because a version bump is exactly when the second edit arrives and skipping it would make the check unreachable. + +- **`completion-gate` refuses a Step that carries two `- Status:` lines instead of reading the last one.** The comment that stood there argued reading the last is "the reading a person makes", on the grounds that `task progress` replaces a line rather than appending one, so a second line must be a hand edit. That is sound about how the second line got there and silent about what it means: the two lines state different statuses, and which is intended is not a question a file reader can answer. Measured 2026-10-30 — `done` followed by `pending` archived. The refusal names the Step. + ### Fixed - **`hooks/release-check` now reads the `$schema` a manifest declares, which nothing had ever inspected.** Two manifests point at a JSON Schema (`.claude-plugin/marketplace.json`, `.claude-plugin/plugin.json`) and measured 2026-10-29 **both of those URLs resolve to 404**: each `301`s to `www.anthropic.com/...` and stops there. Six candidate replacements were probed and none returned a real schema - `docs.anthropic.com/claude-code/plugin.schema.json` answers 200 with a 296 KB HTML error page. The assertion therefore judges only what the repository can decide by itself: a declared `$schema` must carry a non-empty value, must be an absolute `https` URL, may appear once per file (a duplicate key's winner is parser-dependent), and the set of files declaring one is pinned so adding or removing a declaration is a visible event rather than a silent edit. **Reachability is deliberately not checked**: every other check in this file is offline, and a `curl` here would redden on a DNS blip while still being wrong the moment the remote moved - which is exactly how these two went unnoticed. The pin is enforced only once something declares, so a repository that has not adopted `$schema` is not told it dropped one it never had. @@ -13,14 +25,42 @@ - **The same inherited stdin reached one more `session-start` call, in `hooks/smoke-test` itself.** Its `== Windows launcher finds Git Bash ==` section ran `cmd.exe //d //c ... session-start` with no stdin, so it had the identical failure mode: measured 2026-10-28, it never returned against a held-open stdin and returned at once with `< /dev/null`. Given the empty stdin it now has, the same way and for the same reason. +### Verification + +- `bash hooks/smoke-test` — `ALL SMOKE PASSED`, including 11 new `apply_patch` assertions (four directives, a move whose destination is named only by `Move to:`, a multi-file patch, an unparsable patch, an empty command, a path containing a space, a CRLF patch, and a refused patch leaving the store untouched), 5 for the stale-approval rule, 7 for the duplicate-`Status` rule, and a Codex wiring assertion that reads the command out of the JSON, substitutes `${PLUGIN_ROOT}`, and feeds it a real patch rather than merely grepping the file. + +- **Four ways the patch gate allowed writes it was built to refuse, each found by driving real events rather than by reading the code.** A suite that is green is green about the paths its fixtures take, and none of these were among them. (1) `set -- $patch_paths` expanded unquoted, so a path containing a space split into two paths that were each judged and each allowed. (2) The extracted path kept its trailing `\r` from a CRLF patch — routine on Windows, the host this wiring exists for — so `base` read `plan.md\r`, matched no stage, and the gate returned 0. (3) Releases were written as each path was judged, so a patch naming two documents could release the first, be refused on the second, and leave a spent evidence line behind a write the host never applied — after which that document passed with no record. (4) `consumed` was recomputed per path, so two releases in one patch both claimed sequence 1. + + The last two are one defect: deciding and writing were interleaved. Releases are now queued and written once, after every path has been judged, so a refusal part way through leaves the store exactly as it was — measured: a refused two-path patch does not create `released` at all, and an allowed one writes sequences 1 and 2. + +- Every new condition was mutated and restored byte-identically (`diff -q`), each reddening its own named assertion: judging only the first path of a patch reddens `multi-file`; deleting the `Move to:` arm reddens `move-destination-only` — a case added because the first version of the `move` assertion passed with that arm gone, relying on the `Update File:` line by accident; removing the `\r` strip reddens the CRLF case; restoring the unquoted split reddens the space case; writing releases immediately reddens the atomicity case; requiring a dated `progress` line reddens the hand-written half of the A15 pair; and removing the `PreToolUse` wiring reddens the Codex assertion. + +- `hooks/completion-gate` gives the same verdict, message included, on all 93 archived Plans before and after the approval decision moved out — 19 refused, 74 passed, 0 disagreements. `hooks/capability-gate`'s `file_path` path was compared against its previous self over 11 event shapes (write, edit, non-TaskFlow path, other tool, missing path, invalid JSON, wrong event, archived task, evidence store, relative path, Windows backslashes) and matched byte for byte. + ### Compatibility -- **Five archived Plans read as unfinished under the new rule, and are not rewritten.** `achieved/` is read-only in place, so nothing here changes them and no task is blocked by them. They are: `2026-09-07-repository-standards-and-doc-root` (three Steps say `done (v1)`), `2026-10-11-gate-host-coverage` (four say `completed`), `2026-09-08-token-efficiency` and `2026-09-14-personal-docs-git-boundary` (their Steps still say `pending` while the Plan header says `completed` — the same disagreement running the other way), and `2026-10-22-taskflow-drift-guard`, whose `## Verification / Review` section reuses `### Step 1` as an ordinary subheading for a table. Recorded rather than repaired: editing history so it passes a check that no longer applies to it would make the record say something untrue. Only two of the five change verdict when the real gate is run over all 91 — the other three were already refused on `main` for reasons that have nothing to do with Steps, so this release does not newly refuse them. +- **`released` rows written before 1.1.9 still do not release anything, and this release does not change that.** 1.1.9 deliberately stopped matching five-field rows because the task that wrote one is recorded nowhere. The worktree these changes were made in held three such rows, written by the installed 1.1.8 plugin before the checkout's own code was in use; they were rewritten by hand to six fields with the task name, which was sound there because exactly one task had ever run in that worktree. That is a one-off repair of a store, not a migration path — nothing in the shipped code reads a five-field row, and a store without one needs no action. +- **Five archived Plans read as unfinished under the `done`-only rule and are not rewritten** (unchanged from the entry below): `achieved/` is read-only in place, and editing history so it passes a check that no longer applies to it would make the record say something untrue. ## [1.1.9] — 2026-10-26 ### Added +- **`hooks/release-check` now reads the `$schema` a manifest declares, which nothing had ever inspected.** Two manifests point at a JSON Schema (`.claude-plugin/marketplace.json`, `.claude-plugin/plugin.json`) and measured 2026-10-29 **both of those URLs resolve to 404**: each `301`s to `www.anthropic.com/...` and stops there. Six candidate replacements were probed and none returned a real schema - `docs.anthropic.com/claude-code/plugin.schema.json` answers 200 with a 296 KB HTML error page. The assertion therefore judges only what the repository can decide by itself: a declared `$schema` must carry a non-empty value, must be an absolute `https` URL, may appear once per file (a duplicate key's winner is parser-dependent), and the set of files declaring one is pinned so adding or removing a declaration is a visible event rather than a silent edit. **Reachability is deliberately not checked**: every other check in this file is offline, and a `curl` here would redden on a DNS blip while still being wrong the moment the remote moved - which is exactly how these two went unnoticed. The pin is enforced only once something declares, so a repository that has not adopted `$schema` is not told it dropped one it never had. +- **`hooks/smoke-test` covers the new assertion, in the section that already drives `release-check` from a fixture.** It has to live there rather than only in the check itself: `hooks/*` is in the smoke path set, while the `release` job runs only when a manifest, `CHANGELOG.md` or a README changes - so a change to `release-check` alone would never execute it in CI. Verified by mutation: removing the empty-value branch, the `https` test, the duplicate count, and the pin each reddens its own named case, and restoring the file byte-identically returns the suite to `ALL SMOKE PASSED`. +- **A Step counts as closed only when it says `- Status: done`, so a word the gate did not know can no longer archive a Plan that is not finished.** `hooks/completion-gate` read the field as a blacklist — `pending`, `in_progress`, `blocked` were named and everything else was finished — so the word `completed`, a misspelling, an empty value, and a Step with no `- Status:` line at all all passed. Measured 2026-10-27 by driving the gate directly: `completed`, `complted`, `skipped`, `wip`, `DONE`, an empty value and a missing line each returned exit 0 and "finished". `hooks/task progress` has only ever accepted `pending|in_progress|done|blocked`, so those spellings could not have been written by the tool — they are the result of hand edits made precisely because the two sides disagreed. Closed is now a whitelist of one word, and it is the same word the writer accepts. The block is located by its `### Step ` heading anywhere in the Plan rather than only under `## Steps`, because that is how `task progress` finds the block it edits; a reader that skipped the other sections would see a different set of Steps than the writer changes. **Knowingly still open, and pinned by an assertion so it stays a decision**: a Plan with no `### Step` block at all is not refused, because "must a Plan have Steps" is a separate rule this change does not make. +- **The Windows smoke suite no longer stops dead when its stdin is open and silent.** `hooks/smoke-test-windows.ps1` called `run-hook.cmd session-start` with no stdin of its own, so the hook inherited the runner's — and `session-start` reads its event with `cat`. From a terminal that read never reaches EOF, so the suite printed `WINDOWS LAUNCHER ARGS PASSED` and then hung indefinitely; a red assertion reached a person as an unexplained freeze rather than a message. The cause is the inherited stdin, not the throw the report named: probing with `throw` at eight positions, every one before that call printed and exited 1 in under 14s, while every one after it hung — the boundary is exactly `Invoke-SessionStart 'pr'`. The bash suite already carried this lesson for its own `session-start` call (`hooks/smoke-test`, `< /dev/null`); the PowerShell one is now given an empty event the same way, through `cmd /c "... < nul"` rather than `'' |`. **An empty PowerShell string is not an empty stdin** — it is encoded and written as a line, and which line depends on `$OutputEncoding`: measured 0x0d 0x0a on the author's machine and a BOM on the CI runner, where `session-start` rejected it as invalid JSON and the suite failed ([run 40712165629](https://github.com/hkwuks/TaskFlow/actions/runs/40712165629)). A redirection is zero bytes on every host. Measured 2026-10-28: before, still silent at 180s and killed; after, four `PASSED` banners in 27s. **CI could never have caught it** — a runner's stdin is empty, so `cat` sees EOF at once and every windows-latest job has been green regardless. +- **The Windows suite now checks this itself, by re-running with an inherited open stdin and failing if it does not finish.** The check has to live there rather than in `hooks/smoke-test`, because that suite does not run on the Windows runner at all — the workflow gates it on `runner.os != 'Windows'` — so an assertion written there is skipped by every CI job, and the one platform that can reproduce the defect is the one platform that never executes the check. `hooks/smoke-test-windows.ps1` is the only TaskFlow check a Windows runner runs, which makes it the only place this can be caught automatically. It spawns itself with `RedirectStandardInput` set and never written: a pipe that is open and silent, the shape a terminal gives a command a person typed and the shape CI does not have. `TASKFLOW_STDIN_SELFCHECK` bounds the recursion to one child. Verified by mutation: with the `session-start` call reverted to inheriting, the child is killed at the 90s deadline and the suite fails naming the read; with `< nul` in place it exits 0 in about 28s, and the whole suite takes 51s. +- **`hooks/smoke-test-windows.ps1` must stay 7-bit ASCII, and now asserts it.** Windows PowerShell 5.1 reads a `.ps1` with no BOM using the system ANSI code page rather than UTF-8, so a literal non-ASCII character is decoded into bytes the parser does not expect: measured 2026-10-28, an em dash in a `throw` message arrived as three UTF-8 bytes, the third swallowed the closing quote, and the whole file failed to parse with "The string is missing the terminator" ([run 40717070366](https://github.com/hkwuks/TaskFlow/actions/runs/40717070366)) -- after the suite had passed on Linux and macOS, which read the same bytes as UTF-8. The file was already ASCII-only for this reason and builds its Chinese fixtures from `[char]` codes; the rule was known and unenforced. The first thing the suite does now is read itself and refuse to run if any non-ASCII character is present, naming the line and code point. Verified by mutation: injecting one em dash makes it fail at the guard. +- **The same inherited stdin reached one more `session-start` call, in `hooks/smoke-test` itself.** Its `== Windows launcher finds Git Bash ==` section ran `cmd.exe //d //c ... session-start` with no stdin, so it had the identical failure mode: measured 2026-10-28, it never returned against a held-open stdin and returned at once with `< /dev/null`. Given the empty stdin it now has, the same way and for the same reason. + + +### Compatibility + +- **Five archived Plans read as unfinished under the new rule, and are not rewritten.** `achieved/` is read-only in place, so nothing here changes them and no task is blocked by them. They are: `2026-09-07-repository-standards-and-doc-root` (three Steps say `done (v1)`), `2026-10-11-gate-host-coverage` (four say `completed`), `2026-09-08-token-efficiency` and `2026-09-14-personal-docs-git-boundary` (their Steps still say `pending` while the Plan header says `completed` — the same disagreement running the other way), and `2026-10-22-taskflow-drift-guard`, whose `## Verification / Review` section reuses `### Step 1` as an ordinary subheading for a table. Recorded rather than repaired: editing history so it passes a check that no longer applies to it would make the record say something untrue. Only two of the five change verdict when the real gate is run over all 91 — the other three were already refused on `main` for reasons that have nothing to do with Steps, so this release does not newly refuse them. + +### Added + - **`hooks/task recover` repairs a capability store instead of requiring it to be thrown away.** `consumed` is the highest sequence in `released` and the queue length is counted in `evidence`, so a store whose evidence was truncated or deleted reads as further along than it is: the gate denies every stage, `unaided` refuses to widen the gap, and `approve` now refuses too. The only recovery was `rm -rf` on the whole store, which discarded every release whose record still existed along with the ones that had none. The repair is arithmetic rather than reconstruction — a release at a sequence past the end of `evidence` points at a record that is gone, and that is the only kind identifiable without guessing — so `recover` drops exactly those rows, keeps the rest, and appends what it dropped to `released.discarded`. It never adds a release and accepts no argument naming rows, because a command that could write a release would be a way to edit the ledger. A healthy store is a read-only no-op: nothing is written and no discard file appears. ### Fixed diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index be70616..75e48f2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -91,6 +91,28 @@ is. A preference for CI is never a reason for something to go unverified, and CI that has fallen behind the surface it is supposed to cover is not an authority — keep it current, and keep it running on the hosts it declares. +**A hook change lands on every host the repository declares, or it says why not.** +The hosts are listed in `skills/taskflow/references/runtime.md`, and a hook that +is wired on one of them and silently absent on another is a defect, not a +staging decision: the absent host simply does no checking, and nothing reports +that it stopped. So a hook is not finished until either its behaviour is wired on +every declared host, or `runtime.md` states for each host which of the two it is +and why. + +Two words are not interchangeable, and conflating them is how Codex went a month +with no gate while this file recorded it as a limitation of the host: + +- **not wired** — the host supports the event and the reply shape, and this + repository has not connected it. That is work, not a constraint. +- **not supported** — the host has no such event, or no way to see what the hook + needs. That is a fact about the host, and it must name the mechanism it lacks. + +A host recorded as *not supported* carries the host's own documentation as +evidence, and is re-checked when wiring changes. A host recorded as *not wired* +belongs in the host's column as outstanding work — and if it stays there across +releases, this file is where that has to be visible rather than discovered by a +user whose edits were never checked. + Release-time checks are their own list, in `RELEASE.md`, which states which of them CI covers and which it cannot. diff --git a/TaskFlowDocs/2026-10-29-midturn-presence/evidence-a16-fake-plan.md b/TaskFlowDocs/2026-10-29-midturn-presence/evidence-a16-fake-plan.md new file mode 100644 index 0000000..a55b666 --- /dev/null +++ b/TaskFlowDocs/2026-10-29-midturn-presence/evidence-a16-fake-plan.md @@ -0,0 +1,27 @@ +# Plan +> Task version: v1 +> Status: completed + +## Approval +- Status: approved +- Approved by: user +- Approved at: 2026-01-01 00:00 +0000 +- Approved version: v1 +- Approved scope: PRD + +## Steps + +### Step 1 — 修掉并发写 +- Status: done + +### Step 2 — 重写解析器 +- Status: done + +### Step 3 — 补迁移 +- Status: done + +## Verification / Review + +- 2026-01-03 Step 1: ok +- 2026-01-03 Step 2: ok +- 2026-01-03 Step 3: ok diff --git a/TaskFlowDocs/2026-10-29-midturn-presence/measure-drift.py b/TaskFlowDocs/2026-10-29-midturn-presence/measure-drift.py new file mode 100644 index 0000000..ede5fff --- /dev/null +++ b/TaskFlowDocs/2026-10-29-midturn-presence/measure-drift.py @@ -0,0 +1,50 @@ +import json, sys, os, statistics + +def analyze(path): + edits = 0 # Edit/Write tool_use on repo files + progress = 0 # hooks/task progress calls + other_task = 0 # any other hooks/task call + turns_with_edits = 0 + cur_edits = 0 + seen = False + with open(path, encoding='utf-8', errors='replace') as f: + for line in f: + line = line.strip() + if not line: continue + try: rec = json.loads(line) + except Exception: continue + t = rec.get('type') + if t == 'user': + c = (rec.get('message') or {}).get('content') + is_prompt = False + if isinstance(c, str): is_prompt = True + elif isinstance(c, list): + has_tr = any(isinstance(x, dict) and x.get('type') == 'tool_result' for x in c) + has_tx = any(isinstance(x, dict) and x.get('type') == 'text' for x in c) + is_prompt = has_tx and not has_tr + if is_prompt: + if seen and cur_edits: turns_with_edits += 1 + seen = True; cur_edits = 0 + elif t == 'assistant': + c = (rec.get('message') or {}).get('content') + if not isinstance(c, list): continue + for blk in c: + if not isinstance(blk, dict) or blk.get('type') != 'tool_use': continue + name = blk.get('name') or '' + inp = blk.get('input') or {} + if name in ('Edit','Write','NotebookEdit'): + fp = str(inp.get('file_path','')) + if '/TaskFlowDocs/' in fp or fp.endswith('/plan.md'): + continue # doc edits aren't implementation + edits += 1; cur_edits += 1 + elif name == 'Bash': + cmd = str(inp.get('command','')) + if 'hooks/task progress' in cmd: progress += 1 + elif 'hooks/task ' in cmd: other_task += 1 + if seen and cur_edits: turns_with_edits += 1 + return edits, progress, other_task, turns_with_edits + +print(f"{'session':<12} {'code-edits':>10} {'progress':>9} {'task-other':>10} {'turns-w-edits':>13}") +for p in sys.argv[1:]: + e, pr, ot, tw = analyze(p) + print(f"{os.path.basename(p)[:12]:<12} {e:>10} {pr:>9} {ot:>10} {tw:>13}") diff --git a/TaskFlowDocs/2026-10-29-midturn-presence/measure-turns.py b/TaskFlowDocs/2026-10-29-midturn-presence/measure-turns.py new file mode 100644 index 0000000..c8dc44d --- /dev/null +++ b/TaskFlowDocs/2026-10-29-midturn-presence/measure-turns.py @@ -0,0 +1,46 @@ +import json, sys, statistics, os + +def analyze(path): + turns = [] # tool calls per user turn + cur = 0 + seen_user = False + with open(path, encoding='utf-8', errors='replace') as f: + for line in f: + line = line.strip() + if not line: + continue + try: + rec = json.loads(line) + except Exception: + continue + if rec.get('type') == 'user': + msg = rec.get('message') or {} + content = msg.get('content') + # a real prompt, not a tool_result + is_prompt = False + if isinstance(content, str): + is_prompt = True + elif isinstance(content, list): + parts = [c for c in content if isinstance(c, dict) and c.get('type') == 'text'] + has_tr = any(isinstance(c, dict) and c.get('type') == 'tool_result' for c in content) + is_prompt = bool(parts) and not has_tr + if is_prompt: + if seen_user: + turns.append(cur) + seen_user = True + cur = 0 + elif rec.get('type') == 'assistant': + msg = rec.get('message') or {} + content = msg.get('content') + if isinstance(content, list): + cur += sum(1 for c in content if isinstance(c, dict) and c.get('type') == 'tool_use') + if seen_user: + turns.append(cur) + return turns + +for p in sys.argv[1:]: + t = analyze(p) + if not t: + print(f"{os.path.basename(p)[:12]} (no turns)") + continue + print(f"{os.path.basename(p)[:12]} turns={len(t):<4} median={statistics.median(t):<6} mean={statistics.mean(t):<6.1f} max={max(t):<4} zero-tool-turns={sum(1 for x in t if x==0)}") diff --git a/TaskFlowDocs/2026-10-29-midturn-presence/plan.md b/TaskFlowDocs/2026-10-29-midturn-presence/plan.md new file mode 100644 index 0000000..e9dfed1 --- /dev/null +++ b/TaskFlowDocs/2026-10-29-midturn-presence/plan.md @@ -0,0 +1,198 @@ +# Plan — Stay present inside a long implementation run, not only at the prompt +> Task version: v1 +> Status: in_progress + +## Spec Pointers + +- `spec.md` — C1/C2(`apply_patch` 解析与失败判定)、C3(审批判定抽成 `approval-check`)、D1(Step 证据形态)、D2(拒绝强制 `progress`)、D3(不另开解析脚本) + +## Reference Pointers + +- `[Reinforce] hooks/capability-gate` — 本任务要改它的两处(`apply_patch` 分流、审批检查),它现有的 deny 消息风格与路径归一化是本任务必须沿用的 +- `[Reinforce] hooks/completion-gate` — 审批判定要从这里搬出成 `approval-check`,搬完它的行为必须逐字节不变 +- `[Reinforce] CONTRIBUTING.md` — 本任务新加的宿主覆盖规则(not wired / not supported)就是本任务自己的验收标准 + +## Related Tasks + +- `[[TF-20261027-e8c6dc]]` — 外部来源问题:若本任务最终证明「TaskFlow 该在别的地方说话」,那条可能用散文解决 +- `TaskFlowDocs/achieved/2026-10-09-trae-host/` — 事实 4 的原始样本;它的 Plan 就是「4 个 Step 一次脚本翻完」的形态 +- `TaskFlowDocs/achieved/2026-09-27-capability-pre-write-gate/` — `capability-gate` 的出处,本任务扩它的输入面 + +## Skills / Tools Used + +- [PRD] `agent-skills:spec-driven-development` — 起草 PRD v1,并据用户三次澄清重写诊断部分;incorporated: 诊断从「模型不知道 Step」改为「progress 只增摩擦、无可验证收益」 +- [Spec] `mcp__exa__web_search_exa` — 查 Codex 官方 hooks 文档,确认 `PreToolUse`/`apply_patch`/`permissionDecision` 的实际形状;incorporated: 事实 11 的能力表、C1 的 `tool_name` 精确比较、C2 的失败判定表 +- [Plan] `agent-skills:planning-and-task-breakdown` — 按依赖关系排定 Step 顺序与检查点;incorporated: Step 1 抽共享判定先行,其余 Step 全部依赖它 +- [Test] Unaided — considered: testing strategy + +## Preconditions + +- [x] `TaskFlowDocs/repository-docs/index.md` 已读;`CONTRIBUTING.md`、`CODE_STYLE.md`、`LICENSE` 已作为权威来源 +- [x] 远程信息已记录:`origin` → `https://github.com/hkwuks/TaskFlow.git`(`git remote get-url origin`,无 fork remote,无凭据) +- [x] 本任务的宿主覆盖规则已落在 `CONTRIBUTING.md`,并被 smoke 钉住 +- [x] PR 模板字段映射在 Step 6 完成 +- [x] 已知限制记录:本机没有 Codex,A12/A14 的真机验证由用户在另一台机器上执行 + +## Approval + +- Status: approved +- Approved by: user +- Approved at: 2026-10-30 16:46 +0800 +- Approved version: v1 +- Approved scope: PRD / Spec / Plan + +## Steps + +### Step 1 — 抽出 `hooks/approval-check`,两个 gate 都改调它 + +- Goal: 让「Plan 是否已审批」只有一处实现(Spec C3、Q7)。 +- Dependencies: 无(其余 Step 都依赖它) +- Files: `hooks/approval-check`(新增)、`hooks/completion-gate`、`hooks/capability-gate`(后者仅接入,判定行为改变在 Step 2) +- Implementation checklist: + - [x] 新建 `hooks/approval-check`,判定从 `completion-gate:86-91`、`:110-111` **原样搬出**,不改语义 + - [x] 接口:`approval-check [--stale-only] `,`exit 0` 通过,`exit 1` + stderr 一行 + - [x] `completion-gate` 改调它;**先跑一遍两者在全部 93 份归档 Plan 上的结论对比,必须逐份一致** + - [x] `capability-gate` 接入调用点(不改变其判定时机) + + 注:`--stale-only` 是这轮实现中补上的第二个模式。`capability-gate` 需要的不是「批没批」,而是「这份审批是否已被版本降级淘汰」——直接复用退出码会让它在 `planning` 阶段拒绝写 `prd.md`,正是 Step 2 checklist 里废弃的那个死锁。两个模式共用一个 awk、一处字段解析,结论各自成立。 +- Acceptance: Spec C3;搬动前后 `completion-gate` 对 93 份 Plan 的结论完全一致 +- Verification: 逐份跑 `approval-check` 与旧内联逻辑,diff 结论 +- Rollback: 删除新文件、还原两个 gate +- Status: done + +### Step 2 — 审批保护:版本不降级(archive 侧已有,只补回归) + +- Goal: 让审批在它真正该拦住的地方生效(PRD A9/A9b/A10/A10b、Spec C3)。 +- Dependencies: Step 1 +- Files: `hooks/capability-gate`、`hooks/smoke-test` +- Implementation checklist: + - [x] **检查时机已修正**:`planning`/`ready` 阶段写 `prd/spec/plan.md` **不查审批**——审批是开工许可(`SKILL.md:193`),不是写文档许可。原设计「未审批不得写 prd.md」会锁死每个新任务的第一步,已废弃 + - [x] `capability-gate` 只拦一种情况:**`- Approved version:` 与 `> Task version:` 不一致时继续写那份文档**(拿旧批准写新版本) + - [x] **不改 `hooks/archive`**——实测它已拒未审批任务(`archive:44` 调 `completion-gate`,自 `be69289`/2026-10-24 起)。本 Step 只补断言防回退 + - [x] smoke 正向:版本一致且已批准 → 放行 + - [x] smoke 反向:版本不一致 → gate 拒绝 + - [x] smoke 回归:未审批任务归档被拒(钉住既有行为,不是新功能) + - [x] smoke 回归:93 份归档的归档路径不受影响 +- Acceptance: A9、A9b、A10、A10b +- Verification: 上述用例 + 变异(去掉版本比对 → 反向用例变红) +- Rollback: 摘掉版本比对调用点 +- Status: done + +### Step 3 — `completion-gate`:多重 `- Status:` 报错 + +- Goal: 同一 Step 块两个 `- Status:` 报错,不猜(PRD A17、R12)。 +- Dependencies: Step 1 +- Files: `hooks/completion-gate`、`hooks/smoke-test` +- Implementation checklist: + - [x] **A16 已否决(2026-10-30,用户裁定)**:原计划的「Step 必须有验证记录」不实现。实测证明它被一行 `ok` 满足,且会拒掉 73/93 份归档 Plan。证据与推理在 `reject-a16.md`、`evidence-a16-fake-plan.md` + - [x] 同一 Step 块出现两个 `- Status:` 行 → 报错,不猜(现在是读最后一个) + - [x] **A15 的夹具**:同一个 `done` 的 Step,一行由 `progress` 写、一行手写,**两者都必须通过** + - [x] **A18 的夹具**:`achieved/` 下的 Plan 不被新规则拒绝 +- Acceptance: A15、A17、A18 +- Verification: 上述夹具 + 变异(让第二个 `- Status:` 静默取胜 → 报错用例必须变红) +- Rollback: 还原 `completion-gate` +- Status: done + +### Step 4 — `capability-gate` 解析 `apply_patch` + +- Goal: Codex 的编辑能被门禁看见(PRD A11、Spec C1/C2)。 +- Dependencies: Step 1 +- Files: `hooks/capability-gate`、`hooks/smoke-test` +- Implementation checklist: + - [x] `tool_name == "apply_patch"` 时从 `tool_input.command` 解析路径 + - [x] 四种指令:`*** Update File:` / `*** Add File:` / `*** Delete File:` / `*** Move to:` + - [x] `Move to:` 产出**两个**路径:旧路径删除、新路径新增(Q6),两个都走判定 + - [x] 一条 patch 含多个文件时逐条判定,**任何一个被拒即整体 deny** + - [x] 解析失败(command 非空但无任何 `*** … File:` 指令)→ deny,说明是 gate 问题 + - [x] 非 `apply_patch` 路径(`file_path`)行为逐字节不变 + + 实测修正三处:**① substr 偏移全部算错一位**(`*** Update File: ` 是 17 字符不是 18,四个指令各差一位),错位后读到不存在的路径、门禁静默放行;**② 相对路径必须在 `judge_path` 里 join event cwd**,否则 `[ -f "$task_dir/plan.md" ]` 相对 hook 进程 cwd 判断,patch 场景永远为假;**③ 反斜杠归一化也要在函数内**,否则 Windows 路径下 `base=` 取不到。三处都是"失败即放行"的形态。 +- Acceptance: A11、Spec C2 的四种情况表 +- Verification: 夹具覆盖四种指令 + 多文件 + 解析失败;变异(只处理第一个路径 → 多文件用例必须变红) +- Rollback: 还原解析分支 +- Status: done + +### Step 5 — `hooks-codex.json` 接上 `PreToolUse` + +- Goal: Codex 上真的有门禁(PRD A12)。 +- Dependencies: Step 4 +- Files: `hooks/hooks-codex.json`、`hooks/smoke-test`、`skills/taskflow/references/runtime.md` +- Implementation checklist: + - [x] 新增 `PreToolUse`,matcher `apply_patch`,命令走 `run-hook.cmd capability-gate`,并给 `commandWindows` + - [x] **不接** `PostToolUse`/`apply_patch`(A12b、事实 12:`apply_patch` 不是能力,接上是静默无效) + - [x] 替换 `smoke-test:5296-5301` 那条「禁止 Codex 接线」的断言为正向断言 + - [x] `runtime.md` 的 Codex 列按新规则写:原为 **not wired**,现改为已接线(documented,待实测) +- Acceptance: A12、A12b、A13 +- Verification: smoke 在夹具上跑 Codex 的接线命令,必须 deny 一个未释放的 stage 文档 +- Rollback: 还原 `hooks-codex.json` 与该断言 +- Status: done + +### Step 6 — 文档、CHANGELOG 与 PR + +- Goal: 让改动可被发现、可被追溯。 +- Dependencies: Step 2–5 +- Files: `CHANGELOG.md`、`hooks/README.md`、`skills/taskflow/references/runtime.md`、`TaskFlowDocs/todo.md` +- Implementation checklist: + - [x] `CHANGELOG.md` 记录三件事:Codex 门禁、审批判定、多重 Status 报错(原「Step 证据」已否决,改为记录否决) + - [x] `runtime.md` 事件表:Codex 的 `PreToolUse` 行;并修正 `:55` 那句把「未实现」写成「宿主限制」的话(另补 `### Codex specifics`,此前三处引用指向不存在的章节) + - [x] `hooks/README.md` 的宿主覆盖说明 + - [x] 按 `.github/pull_request_template.md` 填 PR 正文,逐字段映射(正文见 `pr-body.md`) + - [x] `todo.md` 条目状态 +- Acceptance: 文档与实现无冲突;PR 模板每个字段都有落点 +- Verification: `bash hooks/release-check .`;`git diff --check` +- Rollback: 无需 +- Status: done + +## Checkpoints + +- Step 1 后:`approval-check` 与旧内联逻辑在 93 份归档 Plan 上结论逐份一致。 +- Step 3 后:A15 的「手写 Step 必须通过」用例为绿——**这是防止强制 `progress` 的唯一闸门**。 +- Step 5 后:`bash hooks/smoke-test` 全绿,且 Codex 接线断言是正向的。 +- Step 6 后:CI 六 job 全绿。 + +## Verification / Review + +- 2026-10-30 **Review 复验(用户要求):四个「失败即放行」的缺陷,全部实测复现后修复。** 前一轮 smoke 全绿,但绿的是我以为的路径。逐条驱动真实事件才看见它们: + + | # | 缺陷 | 触发条件 | 现象 | + |---|---|---|---| + | 1 | `set -- $patch_paths` 未加引号展开,按空格分词 | 路径含空格 | 一条路径裂成两条,两条各判各放行,该文档从未被检查 | + | 2 | 提取路径未剥尾部 `\r` | **CRLF patch(Windows 常态)** | `base` 成为 `plan.md\r`,匹配不到任何 stage,`return 0` 放行 | + | 3 | 释放与判定交错 | 多文件 patch,第二个路径被拒 | 整份被拒,但已写 `released` 行、已花证据;下次写该文档无记录也通过 | + | 4 | `consumed` 在 `judge_path` 内每次重算 | 多文件 patch 都通过 | 两条释放行都取 seq 1 | + + 第 2 条杀伤力最大:Codex 是 Windows 优先的宿主,CRLF 是默认行尾。不修则整个 Codex 门禁在真机上大概率等于没接——正是本任务要消灭的形态。 + + **修法**:1、2 就地改;3、4 同一根因——判定与写入必须分离,释放行改为入队(`pending_release`),全部路径判完才落盘,`consumed` 预占序号并加 `store_loaded` 一次性守卫(否则每个路径重读文件、都看到同一最高序号)。实测确认:单路径写 1 行;双路径第二个被拒时 `released` **文件根本不创建**;双路径都通过时 seq 为 1、2。 + + **变异验证**,每个都精确打红自己的断言:去掉 CR 剥离 → `FAIL a CRLF patch was allowed`;改回直接写 → `FAIL a patch whose second path had no evidence was allowed`;改回空格分词 → `FAIL a patch path containing a space was allowed`。 + + **回归**:smoke `ALL SMOKE PASSED`、`release-check` `STATUS: pass`、`git diff --check` 干净、93 份归档 19 拒/74 过/0 分歧、`file_path` 六种事件形状与改动前逐字节一致。 + + **过程中我自己犯的两个错**,记下来以免重犯:(a) 有一轮在 base checkout(`$PWD` 是 `/mnt/f/WorkSpace/Agent/TaskFlow`)上跑测试,把「那里根本没有我的改动」误判成「我把代码改坏了」,白查两轮——测试前应先 `pwd`。(b) `[ "$plan_only" -eq 1 ] && return 0` 在 `set -e` 下条件为假即函数退出;这个形状在 `completion-gate` 里已经踩过一次,又踩了一次,凡「测试成功才继续」一律用 `if`。 + +- 2026-10-30 Step 6: CHANGELOG 记 Codex 门禁/审批版本判定/多重 Status 三条(+ Compatibility 说明 5 字段 store 的一次性修复与 5 份归档不改);runtime.md 事件表改 Codex 列、改写 gate 适用范围段、新增 ### Codex specifics(此前 Codex/dsh/CodeBuddy specifics 三处引用中 Codex 那节不存在);hooks/README.md 补 Codex 接线说明;PR 正文按模板逐字段写入 pr-body.md。验证:smoke ALL SMOKE PASSED、release-check STATUS pass、git diff --check 干净 + +- 2026-10-30 Step 5: hooks-codex.json 接上 PreToolUse/apply_patch,命令走 bash "${PLUGIN_ROOT}/hooks/capability-gate" 并给 commandWindows;不接 PostToolUse(apply_patch 不是能力类,接了只 exit 0 不写记录)。smoke 把「禁止 Codex 接线」的旧断言换成执行式正向断言:从 JSON 读出命令、替换 PLUGIN_ROOT、喂真实 patch,断言 deny 未被门禁的文档且放行无关文件;并断言无 PostToolUse 段。变异:删掉 PreToolUse 段→断言报 'wires no capability-gate command'(首次尝试时 pipefail 让赋值静默退出、无消息,已加 || true 修好)。runtime.md 新增 Codex specifics 三小节并改写事件表与 gate 适用范围段。ALL SMOKE PASSED + +- 2026-10-30 Step 4: capability-gate 支持 apply_patch:从 tool_input.command 解析四种指令;判定逻辑抽成 judge_path 函数供两条输入形状共用;任一路径被拒即整体 deny(deny 直接退出),全部通过才写释放行。实测修正三处失败即放行的缺陷:substr 偏移各错一位、相对路径未 join event cwd、反斜杠未归一化。file_path 形状 11 个用例与旧版逐字节一致,93 份归档零分歧。smoke 新增 8 条 patch 断言(四指令/多文件/无关/解析失败/空命令);变异三次:只判第一个路径→multi-file 红,删除 Move to 分支→新增的 move-destination-only 红(原 move 用例靠 Update 行蒙对,已补),ALL SMOKE PASSED + +- 2026-10-30 Step 3: A16 按用户裁定否决(实测:三行 ok 即通过,且拒 73/93 归档),记录在 reject-a16.md + evidence-a16-fake-plan.md;A17 实现:同一 Step 块第二个 - Status: 行报错并点名 Step 号,检查置于 unchecked/unfinished 之前;smoke 新增 7 条(多重 Status 两种顺序、跨块不算冲、指名 Step 3、progress 写与手写判定一致各一);变异两次:去掉 dup 报错→multi-status-done-first 变红,改成必须有日期行→手写夹具变红;93 份归档零新增拒绝;ALL SMOKE PASSED + +- 2026-10-30 Step 2: capability-gate 在 released 短路之前调 approval-check --stale-only,只拦「已批准版本 != 当前版本」;planning 阶段(Approved version 为 pending 或空)放行,直接验证避免死锁;smoke 新增 5 条断言(未审批放行/同版本放行/降级拒绝且点名两个要素/pending 放行/空值放行);变异验证两次:去掉版本比对→降级用例变红,把 pending 也算版本→planning 用例变红;archive 侧 A9b 由既有 check_refusal 三元组覆盖,未改 archive + +- 2026-10-30 Step 1: 取自 completion-gate:86-91/:110-111 原样搬出到 hooks/approval-check;93 份归档 Plan 上新旧结论连同 stderr 逐份一致(19 拒绝/74 通过,0 分歧);补 --stale-only 模式供 capability-gate 用(planning 阶段放行写入,仅拦版本降级);completion-gate 与 capability-gate 都改调它;smoke 补 rollback 夹具复制与两份 hook 清单;ALL SMOKE PASSED + +## Change Log + +- 2026-10-29 v1 — 初版 Plan;按用户裁定纳入 Codex 门禁(A12)、审批判定(A9)、Step 证据(A16–A18);「中途在场加提示」被事实 4/5 降级,不再是主线 + +## Follow-ups + +- **A14 真机验证**:用户在一台真实 Codex 机器上跑,必须覆盖 `*** Move to:`(Q6 的格式风险) +- **dsh 的门禁缺口**:按新规则记为 **not wired**,本任务不做 +- **93 份归档 Plan 不重验**(R13);若将来要回填,是独立任务 + +## Version History + +- v1 — planning. diff --git a/TaskFlowDocs/2026-10-29-midturn-presence/pr-body.md b/TaskFlowDocs/2026-10-29-midturn-presence/pr-body.md new file mode 100644 index 0000000..0795e38 --- /dev/null +++ b/TaskFlowDocs/2026-10-29-midturn-presence/pr-body.md @@ -0,0 +1,102 @@ +## Summary + +Codex CLI had no pre-write gate. `hooks/hooks-codex.json` wired only `SessionStart` +and `UserPromptSubmit`, so on that host a TaskFlow run had no gate, no evidence +capture, and one reminder line per prompt — the flow was guidance there, not a +lifecycle. `skills/taskflow/references/runtime.md` recorded this as a limitation of +the host. It was not: Codex's own hooks documentation documents `PreToolUse` firing +on `apply_patch`, `hookSpecificOutput.permissionDecision: "deny"`, and +`additionalContext`. The distinction between **not wired** and **not supported** was +the defect, and `CONTRIBUTING.md` now states it as a rule. + +This PR wires the gate, teaches it the input shape Codex sends, and closes two +holes in the existing gates that the same audit turned up. + +## TaskFlow traceability + +- Task: `TaskFlowDocs/2026-10-29-midturn-presence/` +- Scope: + - `hooks/hooks-codex.json` — new `PreToolUse` on `apply_patch` → `capability-gate`. + - `hooks/capability-gate` — reads a patch as well as a path; refuses a write whose + recorded approval names a different Task version. + - `hooks/approval-check` (new) — the one implementation of the approval decision, + shared by `completion-gate` and `capability-gate`. + - `hooks/completion-gate` — refuses a Step carrying two `- Status:` lines. + - `hooks/smoke-test` — replaces the assertion that *forbade* wiring Codex with one + that runs the wiring, plus the fixtures for each new condition. + - `hooks/README.md`, `skills/taskflow/references/runtime.md`, `CHANGELOG.md`, + `CONTRIBUTING.md` — the host-coverage rule, the Codex column and section, the + inventory entries, the changelog. + - `TaskFlowDocs/todo.md` — the promoted entry's status. + - Also adds `TaskFlowDocs/todo.md` entry `TF-20261030-15b439`, an unrelated + user request recorded during this task. It is a Todo entry only — no code. +- Base branch: `main` +- Target repository: `https://github.com/hkwuks/TaskFlow` — read with + `git remote get-url origin`; one remote, no fork, no credentials in this body. + +## Verification + +- [x] `Hooks` workflow — CI is the authority per `CONTRIBUTING.md`; this branch is + pushed and the run is read there rather than reproduced locally +- [x] `git diff --check` — clean +- [x] Relevant Skill/plugin validation — `quick_validate.py skills/taskflow`: + `Skill is valid!` +- [x] Results recorded in the TaskFlow Plan — `## Verification / Review`, Steps 1–6 + +Measured locally before the push, and recorded in the Plan: + +- `bash hooks/smoke-test` — `ALL SMOKE PASSED`. +- `hooks/completion-gate` gives the same verdict **and the same message** on all 93 + archived Plans before and after the approval decision moved out: 19 refused, 74 + passed, 0 disagreements. The refusal text every caller matches on is unchanged. +- `hooks/capability-gate`'s `file_path` path was compared against its previous self + over 11 event shapes and matched byte for byte — the Codex work is additive. +- Every new condition was mutated and restored byte-identically (`diff -q`), each + reddening its own named assertion. Two mutations found real gaps rather than + confirming the code: the `move` fixture passed with the `Move to:` arm deleted, + because the `Update File:` line named the gated document too — a + `move-destination-only` case was added; and a review pass after the suite was + already green found **four ways the patch gate allowed writes it exists to + refuse**, listed below. +- Four fail-open defects, found by driving real events rather than reading the code: + (1) `set -- $patch_paths` expanded unquoted, so a path containing a space split + into two paths that were each judged and each allowed; (2) a CRLF patch — routine + on Windows, the host this wiring is for — left a trailing `\r` on every path, so + the basename matched no stage and the gate returned 0; (3) releases were written + as each path was judged, so a refused two-path patch still spent an evidence line + behind a write the host never applied; (4) `consumed` was recomputed per path, so + two releases in one patch both claimed sequence 1. (3) and (4) are one defect — + deciding and writing were interleaved — and are fixed by queuing releases and + writing them once, after every path has been judged. Each has a fixture, and each + fixture was verified to redden when its fix is reverted. **This is the reason the + Codex path needs a real-machine run before its column says `measured`**: none of + these shapes was in the original fixtures, and a real host may carry more. + +## Review boundaries + +- [x] No secrets or opaque remote payloads included +- [x] No unrelated task or user files changed — the Todo entry above is the one + addition outside the task, and it is a record, not a change to any behaviour +- [x] Remote/base assumptions stated + +## Known limitations and follow-ups + +- **The Codex wiring is documented, not measured.** No real Codex has run it. The + `runtime.md` column says `documented, not yet measured` and stays that way until a + session on a real machine confirms the deny reaches the agent — including one + `*** Move to:` case, since the destination's spelling is the part the docs leave + least clear. Recorded as A14 in the Plan. +- **`PostToolUse` is deliberately not wired.** `capability-evidence`'s predicate is a + capability class (`Skill`, `Agent|Task`, `mcp__*`) and `apply_patch` is an edit + tool; wired, the hook would run, exit 0, and record nothing while `runtime.md` + claimed evidence capture. Measured: feeding it that event changed no evidence line. +- **A proposed rule was measured, rejected, and removed from scope.** A per-Step + evidence requirement (`done` requires a `## Verification / Review` line naming that + Step) was implemented far enough to test: it was satisfied by three throwaway `ok` + lines on a Plan where nothing was done, and it refused 73 of 93 archived Plans — + including 7 of the 24 that really did use `hooks/task progress`. It measures + whether verification was *documented per Step*, not whether it happened, so it was + dropped. The evidence is in the task's `reject-a16.md` and + `evidence-a16-fake-plan.md`, and A16 in the PRD records the reversal. +- **dsh remains ungated** — `not wired`, not `not supported`: its file tools register + as lowercase `write`/`edit`, which no matcher arm matches. Left for its own task. diff --git a/TaskFlowDocs/2026-10-29-midturn-presence/prd.md b/TaskFlowDocs/2026-10-29-midturn-presence/prd.md new file mode 100644 index 0000000..bbff1b6 --- /dev/null +++ b/TaskFlowDocs/2026-10-29-midturn-presence/prd.md @@ -0,0 +1,327 @@ +# 在一次实现运行的中途保持 TaskFlow 在场,而不只在提示词边界 + +> Task version: v1 +> Status: in_progress + +## Goal + +用户报告「跑着跑着不走流程」,并明确指出**发生在 Codex 上、研究性任务、不断修改指令之后**(2026-10-29)。 + +排查后问题分成两件,**权重差很多**: + +1. **Codex 上 TaskFlow 等于裸跑。** `hooks-codex.json` 只接了 `SessionStart` 与 `UserPromptSubmit`,没有 `PreToolUse` / `PostToolUse` —— **没有门禁,没有证据记录**。而 Codex 官方文档明确支持这些能力(见事实 10)。**这是恒定缺失,与任务长短无关,也是本任务的主要交付。** +2. **Claude Code 上存在一处绕过**:Step 闭合不走 `hooks/task progress` 而手改。但样本只有 1 个,且来自与用户报告不同的宿主,**不能用来解释 Codex 的现象**。 + +本任务要做的:**把 Codex 接进门禁体系**(含 `apply_patch` 的路径解析),顺带修 gate 不读 `## Approval` 的缺陷;「中途在场」的形态选择留给实证,不预设答案必须是新 hook。 + +## Background / Confirmed Facts + +### 1. 当前每轮说什么 —— 已验证 + +`prompt-reminder` 在 `in_progress` / `checking` 且只有一个活动任务时,投递完整的一行: + +``` +- 2026-10-29-midturn-presence | v1 | in_progress — <标题> | approval: requested | next: Step 1 Implement and verify [pending] +``` + +实测命令(2026-10-29): + +```bash +printf '{"hook_event_name":"UserPromptSubmit","cwd":"%s"}\n' "$PWD" \ + | TASKFLOW_TASK_ID= bash hooks/prompt-reminder +``` + +**这一行已经包含当前 Step 与它的状态。** 待确认文档(`TF-20261027-e4ba77` 首稿)里「Step 信息算了但没投递」的说法**不成立**,已实测否定。 + +### 2. 投递时机 —— 每回合一次 + +`hooks/hooks.json:21-31` 把 `prompt-reminder` 绑在 `UserPromptSubmit` 上,没有别的触发器。`runtime.md:226` 明确写着:`PostToolUse` 上只绑了 `capability-evidence`,没有任何读状态的 hook。 + +### 3. 每回合的工具调用数 —— 重测,旧数字偏高 + +用本机三份 transcript 重算: + +| transcript | 回合数 | 中位数 | 均值 | 最大 | +|---|---|---|---|---| +| `7ca911df` | 11 | 5 | 21.5 | 119 | +| `dc15ffaa` | 8 | **11** | 14.0 | 34 | +| `cd141f22` | 5 | 5 | 10.8 | 28 | + +**待确认文档写的是「中位数 10–15,最大 197」。** 三份里只有一份的中位数落在 10–15,最大值 119 而非 197。方向不变(确实存在 5–119 次无信号的调用),但「典型一轮 10–15 次」是**高估**。 + +### 4. `hooks/task progress` 是少数路径 —— **93 份归档 Plan 的格式指纹** + +**A2 复验通过。方法与结论都与前一版不同。** + +`hooks/task progress` 写验证记录时用固定格式(`hooks/task:465`): + +``` +- Step N: +``` + +**手改写不出这个格式**,所以它是「走过 progress」的指纹。扫描 93 份 achieved Plan(2026-10-29): + +| 时期 | 任务数 | 有 progress 指纹 | 比例 | +|---|---|---|---| +| 2026-10 之前 | 66 | 11 | **17%** | +| 2026-10 之后 | 27 | 13 | **48%** | +| **合计** | **93** | **24** | **26%** | + +同时,归档 Plan 的 Step 状态分布是: + +| done | pending | completed | done (v1) | +|---|---|---|---| +| 260 | 6 | 4 | 3 | + +**状态几乎全部写成了 `done`(93%),但只有 26% 走了 `progress`。** 也就是说:**结果一致,路径不一致。** + +复现: + +```bash +grep -E '^- [0-9]{4}-[0-9]{2}-[0-9]{2} Step [0-9]+:' TaskFlowDocs/achieved/*/plan.md +``` + +**这推翻了先前两版的说法**——不是「一处绕过」,也不是「Codex 特有」。**手改是多数路径,跨宿主、跨时间段都成立。** + +### 5. 为什么不会走 `progress` —— **机制原因,已从代码确认** + +不是「忘了」,是这条路**只有成本、没有任何可验证收益**: + +**① `completion-gate` 只看结果,不看路径。** + +```awk +instep && /^- Status:/ { closed = ($0 ~ /^- Status:[[:space:]]*done[[:space:]]*$/) } +``` + +它只问「这行是不是写着 `done`」。`progress` 写的和 `sed` 写的判定完全一样。`hooks/completion-gate` 里五处提到 `progress`,**全在注释,没有一处用它做校验**。 + +**② `progress` 带一条硬约束,手改没有。** + +```awk +if (step_status == "done") { + for (...) if (line[i] ~ /^[[:space:]]*- \[ \]/) die("Step N has unchecked items") +} +``` + +`done` 会拒绝任何未勾选的 `[ ]`;`sed` 不做检查。 + +| | `progress` | 手改 | +|---|---|---| +| 会不会被拒 | 会 | 不会 | +| 收尾 4 个 Step | 4 条命令 | 1 个脚本 | +| 失败时 | 中断、要处理 | 静默成功 | +| 有没有人查 | **没有** | **没有** | + +**一个只增加摩擦、不产生任何可验证产出的路径,注定被绕过。这是理性选择,不是疏忽。** + +**③ 所以「中途在场」原来的问题框定是错的。** 模型并不缺当前 Step 的信息——`prompt-reminder` 每轮都在投递(事实 1)。真正的结构问题是:**流程里有一条推荐路径,它既不被强制、也不被奖励、还比替代方案更难,而终点检查对两者一视同仁。** + +**加提示不会改变这个激励结构。** + +### 6. 用户补充的触发条件 —— **本仓库无法证实** + +用户 2026-10-29 说明观察条件是「在一些非常长的任务中,不断被用户引导之后」,且**发生在 Codex 上、不是本机**。 + +**这组数据只能作为背景,不能作为该现象的实证:** + +| 会话 | prompts | interrupted | 短「继续」 | 宿主 | +|---|---|---|---|---| +| `cd141f22` | 5 | 1 | 0 | Claude Code | +| `dc15ffaa` | 8 | 0 | 0 | Claude Code | +| `7ca911df` | 11 | 2 | 4 | Claude Code | +| 当前主会话 | **520** | 15 | 40 | Claude Code | + +**当前主会话有 520 个 prompt、15 次打断,做了 29 次真实 `hooks/task progress` —— 没有走偏。** 而唯一走偏样本 `7ca911df` 是最短的之一。 + +所以在这批数据里,「长」和「被打断」都**不构成**走偏的充分条件。**Codex 侧的会话本机没有记录**(用户 2026-10-29:「不是本机,不用查会话了」)。 + +**结论:本任务不宣称「长任务 + 持续引导导致走偏」。** 能站住的只有事实 11 —— Codex 上根本没有门禁,那是恒定缺失,与任务长短无关。 + +范围变更的分布仍是有用的背景: + +| | | +|---|---| +| 93 份 achieved 任务中,中途变过版本(有 `old/vN`) | **16** | +| 其中 Change Log 条目数 | 中位 3,最高 20 | + +### 7. 把 Plan 当事后文档写 —— **单个样本,仅作线索** + +`7ca911df` 的时间线(按用户 prompt 切分): + +| 回合 | Plan 文档改动 | +|---|---| +| 2–3 | 建 Plan(写模板) | +| **4** | 一次改 8 处:Step 1、Step 4、Follow-ups、Checkpoints、Change Log、Approval | +| 5–11 | **零改动** —— 包括 45 次代码编辑、2 次打断、2 次「继续」 | +| 结束 | 一个脚本批量把 4 个 Step 标 done、`> Status: completed` | + +**这是一个样本,不是模式。** 它提出一个可检验的判据——「Plan 最后一次实质改动与最后一个代码提交的距离」——但**本任务没有在其余 92 个任务上验证过它**,因此不得据此下结论。A2 要求的复验正是为此。 + +### 8. 历史记录缺口 —— 一个已经收敛的过去 + +待确认文档用「59/91 份 achieved Plan 没有 stage 记录」作为「必须有人催」的依据。复测(93 份 achieved Plan): + +| 项 | 数量 | +|---|---| +| 有 `## Skills / Tools Used` 标题但**零条** stage 行 | 59 | +| 连该标题都没有 | 2 | +| 合计缺记录 | **61 / 93** | +| 缺 `## Change Log` | 8 | + +按日期切开后,这个数字的含义变了: + +| 时期 | 任务数 | 有 stage 行 | stage 行总数 | +|---|---|---|---| +| 2026-10 之前 | 63 | **5** | 12 | +| 2026-10 | 30 | **29** | 77 | + +**缺口几乎全部落在 10 月之前。** 10 月之后 30 份里 29 份都填了。 + +**所以这条不能作为「现在需要新机制」的理由** —— 它描述的是一个已经收敛的过去。第 4 条提过「两者可能是同一件事的两面」,但那个推断只建立在 1 个样本上,同样不能用。**本任务只把它当背景。** + +### 9. 三个「没有人会在中途检查」的开口 —— 均已实测确认 + +| # | 事实 | 证据 | +|---|---|---| +| ① | 某 stage 一旦释放,后续对同一文档的编辑**不再检查** | `hooks/capability-gate:133-137` 命中 `released` 记录即 `exit 0` | +| ② | 该 gate **从不读** `## Approval` | `grep -c 'Approval' hooks/capability-gate` = **0** | +| ③ | `task progress` 是 Step 状态的唯一写者,且**未绑任何 hook** | 写者:`hooks/task:1098`(`progress)` 分支);`grep -n progress hooks/*.json` = 无 | +| ④ | 历史记录里有「Approval 填之前 Steps 1–3 已实现」 | `TaskFlowDocs/achieved/2026-10-23-archive-completion-gate/plan.md:132` | + +### 10. 成本基线 —— 待确认文档未记录 + +``` +prompt-reminder 0.27–0.30 s/次 (其中 summarize-state 0.19–0.21 s) +``` + +每次调用都会经 `summarize-state` 整份重读 `plan.md`。**若在 `PostToolUse` 上按同一方式每次工具调用都跑一遍,一轮 119 次调用即 ~32 秒纯开销。** 这是任何「每次工具调用都注入」方案必须先回答的约束。 + +### 11. 宿主能力 —— 仓库记录与官方文档不一致,**这是本任务的核心缺陷** + +**本机实测:`PostToolUse` 的 `additionalContext` 在 Claude Code 上确实生效。** 2026-10-29 用两段式探针验证:hook 写日志(证明 hook 跑了)+ 模型回复 `PTU-SEEN`(证明模型读到了注入内容)。 + +**仓库对 Codex 的记录是错的。** `runtime.md:55` 说: + +> the gate would find no path, exit 0, and allow every write… which is why **Codex is left unwired** rather than wired and hoped for. + +而 Codex 官方文档(`learn.chatgpt.com/docs/hooks`,2026-10-29 读取)明确写着: + +| 能力 | Codex 官方说法 | +|---|---| +| `PreToolUse` 拦截文件编辑 | 支持。`apply_patch` 可匹配,`matcher` 可用 `apply_patch` / `Edit` / `Write` 别名 | +| 拒绝写入 | 支持。`hookSpecificOutput.permissionDecision: "deny"` + `permissionDecisionReason`;另有 `{"decision":"block"}` 与 exit 2 + stderr 两条 | +| 编辑内容可见 | 支持。`tool_input.command` 携带 **patch 原文**,含 `*** Update File: ` / `*** Add File: ` | +| 注入模型可见上下文 | 支持。`PreToolUse` 与 `PostToolUse` 均可返回 `additionalContext` | +| `PostToolUse` | 支持。`apply_patch` 成功后触发 | + +**所以「Codex 不支持门禁」是错的。** 真实情况是两件事被混为一谈: + +1. **没接线** —— `hooks-codex.json` 只有 `SessionStart` 与 `UserPromptSubmit`,没有 `PreToolUse`/`PostToolUse`。 +2. **没实现路径解析** —— `capability-gate` 只读 `tool_input.file_path`,而 Codex 把路径藏在 patch 原文里。`CHANGELOG.md:222` 自己写对了方向:「Codex would need the gate's path resolution changed, not just a wiring file」。 + +**后果:在 Codex 上跑 TaskFlow 等于裸跑** —— 没有门禁、没有证据记录,只有一句每轮提醒。用户观察到的「研究性任务不断改指令后走偏」发生在这个宿主上。 + +多宿主现状(修正后): + +| | Claude Code | Codex CLI | CodeBuddy | dsh | Trae | +|---|---|---|---|---|---| +| `PreToolUse` 门禁 | 已接线,measured | **官方支持,未接线** | 已接线 | 未接线 | 已接线,documented | +| 调用捕获 | 已接线 | **官方支持,未接线** | 已接线 | 未接线 | 已接线 | +| 上下文注入 | measured | **官方支持** | 未测 | 未测 | documented | + +### 12. `apply_patch` 不是能力,接不上证据链 —— 实测 + +`capability-evidence` 的谓词是**能力类工具**(`Skill` / `Agent|Task` / `mcp__*`),不是「任何工具」。实测(2026-10-29): + +```bash +printf '{"hook_event_name":"PostToolUse","tool_name":"apply_patch","cwd":"%s","tool_input":{"command":"*** Update File: x"}}\n' "$PWD" \ + | bash hooks/capability-evidence; echo "exit=$?" # → exit=0 +``` + +`evidence` 文件行数**不变**。 + +**所以给 Codex 接 `PostToolUse`/`apply_patch` 是静默无效的接线**:hook 会跑、会 exit 0、一条记录也不写,而 `runtime.md` 会因此声称有了覆盖。这与事实 10 是同一类错误——**声称的覆盖与实际覆盖不一致**。见 R10、A12b。 + +Codex 上的能力证据只能来自 `Skill` 或 MCP 调用,与本次门禁改动无关。 + +## Requirements + +- **R1 先证伪,再实现。** 诊断已由事实 4、5 确立(A2 复验通过)。**不预设答案必须是新 hook**,因为事实 5 表明问题在激励结构而非信息缺失。 +- **R2 实现形态由实证决定。** 候选:(a) 不做;(b) 改 `prompt-reminder` 的投递内容;(c) `PostToolUse` 按 N 次调用注入;(d) 让 Plan 自带闭合检查清单。**事实 4 已基本排除「忘了 Plan」这一动机**,因此 (c) 只有在本任务给出「内容随状态改变」的证据时才成立。 +- **R3 频率不得以线性成本换取。** 任何按工具调用触发的方案必须给出开销测算;`summarize-state` 全量重读(0.19–0.21s)不可直接放进热路径。 +- **R4 逐宿主标注 standing,且不得把「未实现」写成「不支持」。** 与 `runtime.md` 现有惯例一致(measured / documented / not wired),但**「not wired」必须附带「宿主是否支持」的独立结论**,本次缺陷正是这两者被混写造成的。 +- **R5 注入内容是数据,不是指令。** 若采用 `additionalContext`,注入文本不得包含可被模型当作命令执行的内容(尤其 patch 原文来自被编辑文件,属不可信输入)。 +- **R6 不扩张 gate 为实现阶段的硬 gate**,但本任务**修**它的两个缺陷:无法表达「已批准版本不许被未审批内容覆盖」(见 A9/A9b);只认 `tool_input.file_path` 因而无法服务 Codex(见 A11)。 +- **R7 沉默优先。** 任何新输出都不得在「没有活动任务」「多个活动任务」「非 TaskFlow 仓库」时发声 —— 与 `prompt-reminder` 现有契约一致。 +- **R8 文档同步。** `runtime.md` 的事件表与 Codex 段落、`hooks/README.md`、`CHANGELOG.md`、以及 `hooks/smoke-test:5272-5277` 那条「禁止给 Codex 接线」的断言一并更新。 +- **R9 Codex 门禁必须真机验证过才能宣称可用。** 本机没有 Codex,验证由用户在另一台机器上执行;在收到验证结果之前,文档只能写 documented,不能写 measured。 +- **R10 不接静默无效的线。** 每一条新接线都要能指出它**实际改变了什么**。接一个只会 `exit 0` 的 hook 不增加覆盖,却让 `runtime.md` 声称有了覆盖 —— 本任务已因此否掉 `PostToolUse`/`apply_patch`(Q5)。 + +- **R11 不强制 `progress`,改为让 `completion-gate` 做保障**(用户 2026-10-29 裁定)。事实 5 表明 `progress` 只增摩擦、不产生可验证差异;加提示不改变激励结构。因此: + - **不**要求 Step 状态必须由 `progress` 写入,`progress` 降级为便利工具; + - `completion-gate` 的判定**不区分**谁写了那行 `- Status: done`; + - 保障改由 gate 自己提供:一个 `done` 的 Step 必须有**可核验的证据**,而不是一行声明。 +- **R12 多重 `- Status:` 行是错误。** `completion-gate` 现有的注释(`:103-105`)写着「第二个 `- Status:` 是手改,读最后一个是人的读法」——它容忍了这种情况。R12 要求**明确报错**而不是猜,因为两个状态共存意味着文档自相矛盾。 +- **R13 新规则只约束活动任务。** `achieved/` 下的 93 份归档 Plan 不改、不重验(用户 2026-10-29 裁定)。理由:它们已经发布,重写历史只会制造噪音,而补出来的证据未必真实。 + +## Acceptance Criteria + +- **A1** PRD 与 Spec 中的每一个数字都能用仓库内的命令重算出来,且命令写在 Plan 的 `## Verification / Review` 里。 +- **A2(已通过,2026-10-29)** 诊断落到机制层,且用**不依赖 transcript 抽样**的方法复验:93 份归档 Plan 的格式指纹显示只有 24 份(26%)走过 `progress`。见事实 4。 +- **A3** 若诊断未能复验、或复验后判定不做,交付物是**一份带证据的否决记录**,`todo.md` 该项标 done 并在 Notes 写明理由,不得静默丢弃。 +- **A4** 若决定做,方案给出:触发条件、开销测算(毫秒/次)、沉默条件、逐宿主 standing。 +- **A5** 实测复现:`prompt-reminder` 已投递当前 Step —— 任何「它没投递」的说法必须先用 A1 的命令跑一遍再下结论。 +- **A6** 任何新增输出在「无活动任务 / 多活动任务 / 非 TaskFlow 仓库」三种情况下**零输出**,由 smoke 断言。 +- **A7** `bash hooks/smoke-test` → `ALL SMOKE PASSED`。 +- **A8** `git diff --check` 干净。 +- **A9** `capability-gate` **不**阻止 `planning`/`ready` 阶段写 `prd.md`/`spec.md`/`plan.md` —— 审批是**开工许可**,不是写文档许可(`SKILL.md:193`:`in_progress` 才要求批准)。它阻止的是:**当前 Task version 与 `- Approved version:` 不一致时,继续写入已批准的那份文档**。即「拿旧批准写新版本」。 +- **A9b** `hooks/archive` **拒绝归档一个未审批的任务**。已实测这是真实缺口:4 份任务(`2026-09-14-branch-and-worktree-gate`、`2026-09-14-macos-hook-portability`、`2026-09-14-personal-docs-git-boundary`、`2026-09-15-phase-concept-mapping`)在 `Approved by: pending` 状态下进了 `achieved/`,现在跑 `completion-gate` 四个全部报 `current Task version is not approved`。其中 `macos-hook-portability` 的 Approval 段自己写着「No approval is claimed or implied.」——作者知道没批,仍然归档了。 +- **A10** A9 由 smoke 覆盖,且**包含反向用例**:`Approved version` 与 `> Task version:` 不一致时必须拒绝;两者一致且已批准时必须放行。 +- **A10b** A9b 由 smoke 覆盖:未审批任务归档被拒,已审批任务归档不受影响(现有 93 份归档的归档路径不回归)。 +- **A11** `capability-gate` 能从 `apply_patch` 的 `tool_input.command` 中解析出目标文件路径,识别 `*** Update File:` / `*** Add File:` / `*** Delete File:` / `*** Move to:` 四种指令,并对**每一个**受影响的 `prd.md`/`spec.md`/`plan.md` 应用同一套判定(一个 patch 改多个文档时不得只看第一个)。 +- **A12** `hooks-codex.json` 接上 `PreToolUse`(`apply_patch` → `capability-gate`),并在 `runtime.md` 标注为 **documented,待实测**。 +- **A12b** **`PostToolUse` 不接 `apply_patch`。** `capability-evidence` 的判定是工具类(`Skill`/`Agent|Task`/`mcp__*`),`apply_patch` 是编辑工具**不是能力**,接上去只会每次 exit 0、一条记录也不写 —— 那正是一条静默无效的接线。Codex 上的能力证据要靠 `Skill`/MCP 调用,与本改动无关。见 Q5。 +- **A13** `hooks/smoke-test:5272-5277` 那条「禁止给 Codex 接线」的断言被替换为「Codex 已接线且解析正确」的正向断言,且包含 `apply_patch` 多文件 patch 的夹具。 +- **A14** 用户在一台真实 Codex 机器上验证通过后,`runtime.md` 的 Codex 列才从 documented 升为 measured;未验证前不得升。 +- **A15** `completion-gate` 不区分 Step 状态由谁写入:`progress` 写的与手改写的**判定完全一致**,由同一对夹具证明(同样的 `- Status: done`,一个有 progress 指纹、一个没有,两者都必须通过)。这条是 R11 的可执行形态——**不得**让新规则偷偷变成对 `progress` 的强制。 +- **A16(已否决,2026-10-30)** 「`done` 的 Step 必须带可核验证据」**不做**。实测证明 Spec D1 选定的形态被一行声明满足——一个三个 Step 什么都没做的 Plan,只在验证段写三行 `ok` 就通过;而它同时会拒掉 73/93 份归档 Plan(连走过 `progress` 的 24 份里也有 7 份),因为它量的是「验证记录有没有按 Step 编号组织」而非「有没有验证」。D1 自己的入选理由(「不能只靠再写一行声明」)因此不成立。完整实测与推理见 `reject-a16.md` 与 `evidence-a16-fake-plan.md`。**保留 A17**:多重 `- Status:` 是文档自相矛盾,判定确定,不依赖对真假的判断。 +- **A17** 同一 Step 块内出现**两个** `- Status:` 行时 `completion-gate` 报错,不猜哪个为准(现状是读最后一个,见 `completion-gate:103-105`)。 +- **A18** A17 只作用于活动任务;`achieved/` 下的 93 份归档 Plan 在 `archive` 时**不因 A17 被拒**(用一个含旧格式 Step 的归档夹具证明)。原 A16 已否决(2026-10-30),其归档侧要求随之取消。 + +## In Scope + +- **`capability-gate` 支持 `apply_patch`**:从 `tool_input.command` 的 patch 原文解析目标路径(A11)。 +- **`hooks-codex.json` 接线**:`PreToolUse`(门禁)与 `PostToolUse`(证据捕获)(A12)。 +- **`capability-gate` 读 `## Approval`**(A9/A10)。 +- `hooks/smoke-test`:替换禁止 Codex 接线的断言,新增 patch 解析夹具(A13)。 +- `CONTRIBUTING.md` 的宿主覆盖规则(已在本任务中加入,附 smoke 钉)。 +- `runtime.md` 的 Codex 列与「not wired / not supported」措辞修正、`hooks/README.md`、`CHANGELOG.md`。 +- 本任务的 TaskFlow 文档与 `TaskFlowDocs/todo.md` 条目状态。 +- **`completion-gate` 的多重 `- Status:` 报错**(R12、A17)。**Step 证据校验不做**——2026-10-30 实测否决,见 A16 与 `reject-a16.md`。原先「中途在场加提示」的思路也已由事实 4/5 降级。 + +## Out of Scope + +- **把 `capability-gate` 扩展成实现阶段的硬 gate。** `TF-20261027-e4ba77` 已明确否决,理由成立:那会把「提醒问题」变成对普通重构编辑的新硬 gate,代价按每次编辑计。本任务不翻案。 +- **要求每次实现都必须有 stage 记录。** 事实 8 显示缺口在收敛,强推会变成噪音。 +- **外部搜索相关([[TF-20261027-e8c6dc]])。** 只在「若本任务落地则该问题可能用散文解决」这一点上相关,不在范围内。 +- **dsh 的门禁缺口。** 同样未接线,但用户未提出,且本任务已经很大。按新规则记为 **not wired**,留待后续。 + +> `capability-gate` 不读 `## Approval`(事实 9 的 ②)**已由用户 2026-10-29 裁定纳入范围**,从 Out of Scope 移出,见 R6。 + +## Risks / Deferred Items + +- **Codex 无法在本机验证(最大风险)。** 本机没有 Codex,A12 的接线只能靠文档 + 夹具验证;真机行为由用户确认(R9/A14)。**在用户确认前,`runtime.md` 只能写 documented。** +- **patch 解析是新解析面。** `apply_patch` 的原文格式由 Codex 定义,可能与文档不一致。A11 必须容忍解析失败的**安全默认**——解析不出来时是拒绝还是放行,须在 Spec 里明确决定,不能由实现顺手决定。 +- **一次 patch 改多个文件。** 只看第一个匹配会漏掉后面的 `prd.md`,A11 要求逐个处理。 +- **「做了但没用」的风险仍在。** 一个每 N 次调用重复恒定语的行,第 20 次之后同样是背景噪声。若不能说明**注入内容会随状态改变**,加频率只是把噪声变密。 +- **成本风险(事实 10)。** 朴素实现会让每轮多花数十秒。 +- **A16 的「证据形态」是最难的一处 —— 2026-10-30 实测后已否决。** 当时的判据:若 Spec 给不出一个真能区分「验证过」与「写了句话」的形态,正确做法是缩小承诺并在 PRD 里改 A16,而不是实现一个查不出东西的检查。实测正是这个结果:D1 的形态被一行 `ok` 满足,且会拒掉 73/93 份归档。按判据执行,A16 撤销,见 `reject-a16.md`。 + +## Open Questions + +- **Q1 已结(2026-10-29)。** 「走偏」的可判定定义:**Step 闭合绕过 `hooks/task progress`,改用手改**。可从 transcript 数出来(`progress` 调用数 vs 代码编辑数)。 +- **Q2 已结(2026-10-29,用户澄清)。** 用户说明观察条件是「非常长的任务 + 不断被引导」,**发生在 Codex 上**。本机没有 Codex 会话记录,且 Claude Code 侧的对照(520 prompt、15 次打断、29 次 progress、未走偏)说明「长 + 被打断」不是充分条件。**结论:不再追这条因果,改判为「Codex 缺门禁」这一机制事实**(事实 10)。 +- **Q3 已结。** Codex 官方支持 `PreToolUse`/`PostToolUse` 与 `additionalContext`,因此不再需要「为未测能力做设计」的顾虑——按 A12 直接接线、按 A14 待真机确认。 +- **Q4 已决(2026-10-29,依据仓库既有先例)。** `apply_patch` 解析不出任何目标路径时,gate **不拒绝**——因为现有 gate 对「不是本任务文档」的路径一律 `exit 0`(`capability-gate:95-100`),一个不含 `TaskFlowDocs/**/{prd,spec,plan}.md` 的 patch 本来就不该被管。 + 但**解析失败**与**确实无关**必须分开:前者(`apply_patch` 有内容、但没有一条 `*** … File:` 指令)属于**这个 gate 认不出该宿主**,与「读不到 `> Task version:`」同类,现有先例是 **deny**(`capability-gate:117`)。所以:**解析不出路径但 patch 非空 → deny,并说明是 gate 问题、给出补救命令**;解析出路径 → 逐条判定。 +- **Q5 已决(2026-10-29,实测)。** 不接 `PostToolUse`/`apply_patch`。`capability-evidence` 的谓词是**能力类工具**(`Skill`/`Agent|Task`/`mcp__*`),`apply_patch` 是编辑工具。实测:`tool_name=apply_patch` 喂给它 → `exit=0`,evidence 文件行数不变。接上去是静默无效的接线。见 A12b 与事实 11。 diff --git a/TaskFlowDocs/2026-10-29-midturn-presence/reject-a16.md b/TaskFlowDocs/2026-10-29-midturn-presence/reject-a16.md new file mode 100644 index 0000000..071b446 --- /dev/null +++ b/TaskFlowDocs/2026-10-29-midturn-presence/reject-a16.md @@ -0,0 +1,64 @@ +# A16 否决记录 — 「Step 必须有可核验证据」 + +> 2026-10-30,用户在看完实测后裁定:只查这个没有意义,不做。 + +## A16 的原话 + +> 一个 `- Status: done` 的 Step **必须带可核验证据**,否则 `completion-gate` 拒绝并点名是哪个 Step。证据的形态由 Spec 决定,但必须满足:手写能提供、`progress` 也能提供、且**不能只靠再写一行声明**。 + +## Spec D1 给出的形态 + +``` +## Verification / Review 段内存在一行,包含 `Step ` 且 N 是该 Step 的编号 +``` + +D1 的判定表否定过另外两种形态(固定文字、验证段非空),理由是它们查不出「验证过」与「写了句话」的区别,并把这一条列为选定形态的入选理由。 + +## 实测一:它被一行声明满足 + +`evidence-a16-fake-plan.md` 是一个**三个 Step 什么都没做**的 Plan,只在验证段写了三行 `ok`: + +``` +### Step 1 — 修掉并发写 +- Status: done + +## Verification / Review + +- 2026-01-03 Step 1: ok +``` + +按 D1 的判定跑,结果是 `PASS — all 3 done Steps evidenced`。 + +**D1 自己的入选理由当场不成立。** 这个形态和它否定掉的两种一样,只要求「写一句话」,而且要求的是一句格式特定的话——比「验证段非空」还多一道格式手续。 + +原因不难看出:`hooks/task progress` 写的是 `- Step : `,而这条规则只检查「有没有一行包含 `Step `」。手写一行同样的文字,字节层面完全一样。**任何只读 Plan 文件的检查都无法区分二者**,因为二者在文件里就是同一个东西。 + +## 实测二:它对历史任务的覆盖面 + +93 份归档 Plan: + +| 口径 | 合格 | 不合格 | +|---|---|---| +| 全部 93 份 | 20 | **73** | +| 其中走过 `progress` 的 24 份 | 17 | **7** | + +D1 的判定表写着「`progress` 已经这样写……因此走 `progress` 零改动即满足」。**实测 24 份里 7 份不满足**——因为走 `progress` 的任务通常只对部分 Step 用了它(`2026-10-27-close-step-status`:Step 2 有 progress 指纹,Step 1/3/4 手写 `done`)。 + +所以这条规则量的不是「有没有验证」,而是「验证记录有没有按 Step 编号组织」。73 这个数字说明后者是一种少数书写习惯,不是多数。把它变成强制,等于要求每个任务收尾时逐个 Step 补一行编号——**换取零检测力**。 + +## 结论 + +A16 不做。理由不是「成本高」,而是**它做不到自己承诺的事**:D1 明确说目标是区分「验证过」与「写了句话」,而实测证明这个形态恰恰只需要写句话。PRD 风险表预置了这条判据: + +> 若 Spec 给不出一个真能区分「验证过」与「写了句话」的形态,正确做法是**缩小承诺**并在 PRD 里改 A16,而不是实现一个查不出东西的检查。 + +## 保留的部分 + +- **A17 照做**:同一 Step 块两个 `- Status:` 行报错。那是文档自相矛盾,判定是确定的,不依赖任何关于「真假」的判断。 +- **R11/R12 不变**:不强制 `progress`。 +- **A15 仍然要证明**:`progress` 写的与手写的判定必须一致。现在这条更容易成立——因为没有任何检查会读它们。 +- **事实 5 的诊断不变**:`progress` 是少数路径,机制原因是它只增摩擦、无可验证产出。本记录是那个诊断的又一次确认,不是它的反例。 + +## 若将来要重做 + +要让「验证过」与「写了句话」可区分,唯一的方向是让 `progress` 写出**机器无法伪造**的东西——例如带上 capability store 的序列号,或由若干次真实工具调用派生。那需要一个新的设计,不是把 A16 换个措辞。记录在此,不在本任务范围内。 diff --git a/TaskFlowDocs/2026-10-29-midturn-presence/spec.md b/TaskFlowDocs/2026-10-29-midturn-presence/spec.md new file mode 100644 index 0000000..6fb00f2 --- /dev/null +++ b/TaskFlowDocs/2026-10-29-midturn-presence/spec.md @@ -0,0 +1,179 @@ +# Spec — Stay present inside a long implementation run, not only at the prompt +> Task version: v1 + +## Objective and Success Criteria + +**Objective.** 关闭三个已被测量证实的缺陷,让 TaskFlow 在 Codex 上不再等于裸跑,并让 Step 的「完成」有可核验的含义: + +1. **Codex 没有门禁**(PRD 事实 11)。仓库把它记成宿主限制,实际是未实现。 +2. **`capability-gate` 不读 `## Approval`**(PRD 事实 9 的 ②)。 +3. **Step 状态只有声明、没有证据**(PRD 事实 4、5)。`completion-gate` 无法区分「验证过」与「写了一句 `- Status: done`」。 + +**Success criteria** 就是 PRD 的 A9–A18;本 Spec 说明它们如何在代码里成立。**A16 已于 2026-10-30 实测否决**(见 `reject-a16.md`),本 Spec 的 D1/D2 作为该否决的证据保留,不再是实现依据。 + +## Architecture Boundaries and Responsibilities + +三个脚本,职责不重叠: + +| 组件 | 何时 | 决定什么 | 本任务的改动 | +|---|---|---|---| +| `capability-gate` | `PreToolUse`,写 `prd/spec/plan.md` 前 | **放行还是拒绝一次写入** | 读 `## Approval`;支持 `apply_patch` | +| `completion-gate` | 由 `task complete` / `archive` 调用 | **这个 Plan 算不算完成** | 每个 `done` 的 Step 必须有证据;多重 `- Status:` 报错 | +| `hooks/task` | Agent 显式调用 | 写文档 | 不改 | + +`capability-gate` **不**决定 Step 是否闭合,`completion-gate` **不**拦截写入。这个分工是本任务要守住的边界:把它们合并会让「拒绝一次编辑」和「拒绝一次归档」共用一个判定,而两者的代价完全不同。 + +## Project Structure / Affected Files + +``` +hooks/approval-check 新增:Plan 审批判定的唯一实现 +hooks/capability-gate 新增 $APPROVAL 段(调 approval-check);新增 apply_patch 路径解析 +hooks/completion-gate 改调 approval-check;新增 Step 证据校验;多重 Status 报错 +hooks/hooks-codex.json 新增 PreToolUse 事件 +hooks/smoke-test 替换「禁止给 Codex 接线」断言;新增夹具 +skills/taskflow/references/runtime.md 事件表与 Codex 段落 +hooks/README.md 接线说明 +CONTRIBUTING.md 宿主覆盖规则(本任务已加) +CHANGELOG.md +``` + +## Interfaces, Data Flow, and Contracts + +### C1. `capability-gate` 的输入抽象 + +现在它只认一个来源:`tool_input.file_path`。改动后有两个来源,**分流发生在读取 `file_path` 之前**: + +```text +tool_name == "apply_patch" → 解析 tool_input.command 得到路径列表 +否则 → 用 tool_input.file_path(单条) +→ 两条路径汇合为「一组目标路径」 +→ 对每个路径独立走现有的 base/stage/task_dir 判定 +→ 任何一个被拒 → 整体 deny +``` + +**契约:** + +- `apply_patch` 的解析取 `*** Update File:` / `*** Add File:` / `*** Delete File:` / `*** Move to:` 四种指令的目标路径,逐条处理。 +- **`*** Move to:` 算两个路径**(Q6):旧路径按删除、新路径按新增,**两个都走判定**。只算新增会留下缺口——把 `prd.md` 从任务 A 移到任务 B 时,A 那边因为「文件不见了」而不被检查,而文档确实离开了那里。 +- **`tool_name` 的比较是精确的**:Codex 的 matcher 别名(`Edit`/`Write`)只影响 hook 是否触发,不影响 stdin 里的 `tool_name`——官方文档写明「hook input still reports `tool_name: apply_patch`」。所以脚本不需要认别名。 +- **deny 的 payload 形状不变**(`hookSpecificOutput.permissionDecision: deny`)——Claude Code 与 Codex 两边的文档都接受同一个形状(PRD 事实 11 的表)。 + +### C2. `apply_patch` 解析失败的判定(PRD Q4 的可执行形态) + +| 情况 | 判定 | 依据 | +|---|---|---| +| 解析出至少一个路径 | 逐条走现有判定 | — | +| `apply_patch` 的 command 为空 | `exit 0` | 没有编辑发生 | +| 非空,但没有任何 `*** … File:` 指令 | **deny**,说明是 gate 认不出该格式 | 与「读不到 `> Task version:`」同类(`capability-gate:117`),是 gate 问题不是规则问题 | +| 有路径但不在 `TaskFlowDocs//` 下 | `exit 0` | 现有的「不是本任务文档」分支(`capability-gate:95-100`) | + +**为什么解析失败要 deny 而不是放行**:放行会让门禁在格式变化后**静默失效**,而「声称有门禁、实际没有」正是本任务要消灭的失败模式(R10)。deny 的代价是一次误拦,且消息里会说明这是 gate 的问题。 + +### C3. `## Approval` 判定的契约 + +**判定抽成 `hooks/approval-check`,`completion-gate` 与 `capability-gate` 都调它**(Q7)。同一份 Plan 的审批结论只有一处产生。 + +```text +usage: approval-check +exit 0 — 已审批,且 Approved version 等于 Plan 的 Task version +exit 1 — 未审批;stderr 一行说明哪一项不满足 +``` + +判定的内容(从 `completion-gate:86-91`、`:110-111` 原样搬出,不改语义): + +- `- Status: approved` 只在 `## Approval` 段内匹配,写在别处不算。 +- `- Approved by:` 不得是 `pending`。 +- `- Approved version:` 必须等于 `> Task version:`。**只看 `Status: approved` 不够**——v2 的文档配着 v1 的批准,是两份不同的东西。 +- `## Approval` 段缺失或为空 → 未审批。 + +**「首次正文写入」的定义**:沿用现有的 `released` 存储。一个 `(task, version, stage)` 已被释放过,后续编辑不再检查——这条现有行为**不变**。 + +**检查时机(2026-10-29 修正)**:审批是**开工许可**,不是写文档许可(`SKILL.md:193`:`in_progress` 才要求批准)。所以 `capability-gate` **不**阻止 `planning`/`ready` 阶段写任何文档——原设计「未审批不得写 `prd.md`」会锁死每个新任务的第一步(PRD 必须在审批前写出来)。 + +它只拦一种情况:**当前 `> Task version:` 与 `- Approved version:` 不一致时,继续写那份已批准的文档**。即「拿旧批准写新版本」。 +判据:`Approved version` 为空或是 `pending` 时视为**尚未批准过任何版本**,此时不拦(`planning` 阶段);一旦它填了版本号,就必须与当前版本相等。 + +**归档案的检查已经存在**:`hooks/archive:44` 调 `completion-gate`(自 `be69289`/2026-10-24),未审批任务会被拒。实测:`current Task version is not approved`。本任务不新增该检查,只补回归断言。 + +**调用方式**:`approval-check` 与其它 hook 一样是独立的可执行脚本(不 source),由两个 gate 用 `bash` 调用。这与 `capability-gate` 里 `stray_evidence` 的处境不同——那个是内部函数,而审批判定必须被两个进程共享。 + +## Invariants and Compatibility + +- **I1** 已审批版本的 `capability-gate` 行为**逐字节不变**。A15/A18 用夹具证明。 +- **I2** `completion-gate` 对 `achieved/` 下的任务不新增拒绝条件(R13/A18)。 +- **I3** `progress` 的语义不变,**不新增任何强制**(R11/A15)。`progress` 写的 Step 与手写的 Step 在 `completion-gate` 眼里完全同等。 +- **I4** 现有 smoke 的 100+ 节全部保持通过。 +- **I5** 不引入新的运行时依赖:POSIX `awk`/`sed` + bash 3.2(`CLAUDE.md` 的既有约束)。 + +## Validation and Error Semantics + +所有拒绝都走现有的两条通道,**不新增通道**: + +| 组件 | 通道 | 退出 | +|---|---|---| +| `capability-gate` | `hookSpecificOutput.permissionDecision: deny` + 原因 | `exit 0`(由 payload 表达拒绝) | +| `completion-gate` | stderr 一行命名失败条件 | `exit 1` | + +错误消息必须**可执行**:现有 gate 的惯例是给出确切的补救命令(`bash hooks/task unaided STAGE --considered '…'`、`bash hooks/task recover`)。新增的消息同样如此。 + +## Code and Test Constraints + +- `hooks/*` 是 **smoke 集合**:改 hook 一定会跑 smoke(`tools/workflow-affected`)。新断言写进 `hooks/smoke-test`。 +- **不使用 `printf … | grep -q`**:`pipefail` 下大文件会 141(`completion-gate:19-29` 记录了这次事故)。读 Plan 一律单次 `awk`。 +- 每个新条件配一个**能变红**的用例,并做变异验证(破坏条件 → 命名断言变红 → 逐字节恢复)。 +- `hooks/smoke-test-windows.ps1` 必须保持纯 ASCII。 + +## Design Decisions and Alternatives + +### D1. 为什么「Step 必须有证据」不可行 —— 设计后实测,已否决 + +> **2026-10-30 结果:本节的方案被实测否决,A16 撤销。** 下文保留作为否决的证据链。结论写在 `reject-a16.md`,两处关键实测: +> +> 1. **它被一行声明满足。** 一个三个 Step 什么都没做的 Plan,只在验证段写三行 `ok`,按下面的形态判定为通过。本节自己的入选标准(「不能只靠再写一行声明」)因此不成立。 +> 2. **它拒掉的 73/93 份归档里,包含 7 份真正走过 `progress` 的。** 因为它量的是「验证记录有没有按 Step 编号组织」,不是「有没有验证」——而只有部分 Step 走 `progress` 是常态(`2026-10-27-close-step-status`:Step 2 有指纹,Step 1/3/4 手写)。 +> +> 根本原因:`hooks/task progress` 写的 `- Step : ` 与手写的同一行在文件里逐字节相同,**任何只读 Plan 的检查都无法区分二者**。本节下表把「要求在 `## Verification / Review` 里出现指认该 Step 的行」标为 ✓,那个 ✓ 是错的。 + +A16 要求 `done` 的 Step 带可核验证据。**设计前先量了现状:93 份归档 Plan 里只有 5 份的 `## Verification / Review` 是空的(88 份有内容)。** + +这个数字决定了提案的形态:**要求不是新的,证据也不是新的 —— 缺的只是「把两者关联起来」。** + +| 方案 | 判定 | +|---|---| +| 要求 Step 里出现某段固定文字 | ✗ 又一条形式主义:查不出「验证过」与「写了句话」的区别 | +| 要求 `## Verification / Review` 非空 | ✗ 88/93 已经满足,等于不约束 | +| **要求在 `## Verification / Review` 里出现指认该 Step 的行** | ✓ 手写能写、`progress` 已经这样写、且**指认关系**是可核验的 | + +**选定形态:** + +```text +## Verification / Review 段内存在一行,包含 `Step ` 且 N 是该 Step 的编号 +``` + +这同时覆盖两条路径: + +- `hooks/task progress done ""` **已经**写 `- Step : `(`hooks/task:465`),因此走 `progress` 零改动即满足。 +- 手写时补一行同样形状的文字即可。 + +**这条检查能查出什么、查不出什么,必须写明:** + +| 能查出 | 查不出 | +|---|---| +| Step 声称完成,但 `## Verification / Review` 里没有任何指向它的一行 | 那一行是真是假 | +| 一个 Plan 里 4 个 Step 只有 2 个有记录 | 记录是否对应真正的验证 | + +**它抓的是一个真实的失败模式**:Step 被标 `done`,而整个 Plan 的验证段从未提到它 —— 这正是手改收尾时的典型形态(PRD 事实 4 的样本:4 个 Step 一次脚本翻完,验证记录另写)。 + +### D2. 为什么拒绝 `progress` 强制(重申 R11 的可执行含义) + +D1 的形态**刻意让两条路径都能满足**。若把条件写成「必须有 `progress` 的日期行」,那就变成了对 `progress` 的强制,违反 R11/A15。**这是本 Spec 最容易走错的一处**,因此 A15 用一对夹具把它钉住:同样一个 `done` 的 Step,一行由 `progress` 写、一行手写,两者都必须通过。 + +### D3. `apply_patch` 解析放在 `capability-gate` 里,不另开脚本 + +一个 hook 脚本的输入解析多一个分支,比对两个脚本保持同步便宜。且 `capability-gate` 的判定逻辑(base/stage/task_dir)完全复用,不需要重复。 + +## Open Questions + +- **Q6 已决(2026-10-29,用户裁定)** `*** Move to:` 按**旧路径删除 + 新路径新增**处理,两个路径都走门禁。理由:只算新增的话,把 `prd.md` 从任务 A 移到任务 B 时,A 那边因为「文件不见了」而不被检查——而文档确实离开了那里。 + **格式风险仍在**:本机没有 Codex,`*** Move to:` 后面的路径写法按官方文档实现,A14 的真机验证必须包含一个 Move 用例。 +- **Q7 已决(2026-10-29,用户裁定)** 抽成 `hooks/approval-check`,`completion-gate` 与 `capability-gate` 都调它。理由:这个仓库已经出现过同类分歧——`completion-gate` 的注释里就记着两个读同一份文件的实现漂移过。判定只有一处,就不会有第二次。 diff --git a/TaskFlowDocs/todo.md b/TaskFlowDocs/todo.md index 0ab751e..2ed86b1 100644 --- a/TaskFlowDocs/todo.md +++ b/TaskFlowDocs/todo.md @@ -26,6 +26,19 @@ has an issue number or URL. +## 个人配置文件(personal.md、sessions.md 等)若出现在 worktree 中 + +- ID: TF-20261030-15b439 +- Status: inbox +- Priority: normal +- Owner: Claude +- Source: user request +- Added: 2026-10-30 +- Updated: 2026-10-30 +- Goal: 个人配置文件(personal.md、sessions.md 等)若出现在 worktree 中,归档时可同步到 main 的对应任务路径下,且保持不纳入版本控制。 +- Task: Not promoted. +- Next action: Clarify and promote when ready. + ## Assert the $schema a plugin manifest declares, so a schema URL that stops resolv - ID: TF-20261029-218fdf @@ -1100,15 +1113,15 @@ has an issue number or URL. ## Stay present inside a long implementation run, not only at the prompt - ID: TF-20261027-e4ba77 -- Status: inbox +- Status: in_progress - Priority: normal - Owner: Claude - Source: user request - Added: 2026-10-27 -- Updated: 2026-10-27 +- Updated: 2026-10-30 - Goal: Keep TaskFlow present between the steps of one implementation run, so a long turn cannot leave the approved Plan behind while nothing in the workflow speaks up again. -- Task: Not promoted. -- Next action: Establish which hosts can inject context from a mid-turn hook (a `PostToolUse` hook on every tool, or the host's nearest equivalent) before weighing any design; the whole option set depends on that answer and it is a documentation fact, not a preference. +- Task: `TaskFlowDocs/2026-10-29-midturn-presence/` +- Next action: Complete PRD / Spec / Plan and request approval. - Notes: **2026-10-27 measured on this machine:** across the three most recent session transcripts, tool calls per user turn are median **10–15**, max **197**. `hooks/prompt-reminder` speaks once at the *start* of each turn (`hooks/hooks.json`, `UserPromptSubmit`), and nothing TaskFlow-owned fires for the remaining 10–197 calls. The user's report — "跑着跑着不走流程" — lands in that gap. **Four openings named at file:line** (each verified today): (1) `hooks/capability-gate:126-134` returns 0 once a stage is released for this task version, and only matches the basenames `prd.md`/`spec.md`/`plan.md` (`:96-99`), so **no edit after the first Plan write is checked at all**; (2) the gate never reads `## Approval` — recorded history has the failure happening (`achieved/2026-10-23-archive-completion-gate/plan.md:132`, "Steps 1–3 were implemented before the Approval block was filled"); (3) `hooks/task progress` is the only writer of Step status and is bound to **no hook**, so a Step's `- Status:` changes only if the Agent chooses to call it; (4) the record the gate depends on is largely absent — **59 of 91 achieved Plans have zero `## Skills / Tools Used` stage lines**, and 8 have no `## Change Log` section. diff --git a/hooks/README.md b/hooks/README.md index 1e7c8b7..b0233ca 100644 --- a/hooks/README.md +++ b/hooks/README.md @@ -23,6 +23,7 @@ taskflow/hooks/ ├── capability-gate # refuses a stage document's first body write (Claude Code only) ├── json-field # shared one-level JSON reader for the two hooks above ├── task-id # the one rule for what is a legal task name; used by task/archive/reopen +├── approval-check # the one rule for whether a Plan's current version is approved ├── completion-gate # the one rule for what counts as an approved, finished Plan ├── task # explicit intake/promote/unpromote/state/progress/unaided/recover/complete edits ├── archive # full archive transaction (incl. Todo update) @@ -98,7 +99,10 @@ SessionStart hook automatically: - Claude Code auto-loads `hooks/hooks.json` (standard hooks file at the plugin root) and runs `run-hook.cmd session-start`. - Codex loads the manifest's `hooks` → `hooks/hooks-codex.json`, which points - at `session-start` via `${PLUGIN_ROOT}`. + at `session-start` and `prompt-reminder` via `${PLUGIN_ROOT}`, and at + `capability-gate` on `PreToolUse` matching `apply_patch`. The gate reads the + patch text, because Codex's edit tool carries no file path; see + `skills/taskflow/references/runtime.md`, **Codex specifics**. - CodeBuddy loads the manifest's `hooks` → `hooks/hooks-codebuddy.json`, which runs `bash "${CODEBUDDY_PLUGIN_ROOT}/hooks/session-start"`. CodeBuddy's own bundled plugins use the `CODEBUDDY_` spelling, and it substitutes the diff --git a/hooks/approval-check b/hooks/approval-check new file mode 100644 index 0000000..c459630 --- /dev/null +++ b/hooks/approval-check @@ -0,0 +1,112 @@ +#!/usr/bin/env bash +# approval-check — decide whether a Plan may be written to and closed. +# +# Usage: approval-check [--stale-only] +# exit 0 — the question's answer is yes +# exit 1 — no; one line on stderr names the condition that failed +# +# Two callers ask two different questions about the same three fields, so this +# file answers both rather than each parsing the block for itself: +# +# (default) Is the current Task version approved? +# No -> exit 1. This is `completion-gate`'s first condition +# and the reason a Plan may be archived. +# +# --stale-only May the current Task version still be written? +# Yes when nothing has been approved yet, or when the recorded +# approval names the current version. No only when an approval +# exists for a *different* version — "carrying a v1 nod into a +# v2 document". +# +# The second question exists because "is it approved" is the wrong test for a +# write. Approval is the permit to *start work* (`SKILL.md`: `in_progress` is +# entered on approval), not a permit to write a document — every `prd.md` is +# written before its approval by construction. A gate that refused unapproved +# writes would deadlock each new task at its own first step, with no command +# able to clear it. So the write gate asks the narrower thing it can actually +# justify: an approval that has been superseded. +# +# Why this is its own file, called with `bash "$SCRIPT_DIR/approval-check"` the +# way `capability-gate` calls `json-field`, rather than a copy inside each +# caller: two gates ask it and they sit in different processes. The repository +# has already paid for asking twice — `hooks/completion-gate`'s header records +# two readers of the same Plan drifting apart, which is the shape this file +# removes rather than repeats. It is not a function inside either gate because +# `capability-gate` runs as a `PreToolUse` hook and `completion-gate` runs from +# `task complete` / `archive`, so neither can source the other. +# +# The conditions, verbatim from the block this was extracted out of, and in the +# same order — a Plan with two problems reports the earlier one: +# +# 1. `- Status: approved`, matched only inside the `## Approval` section, so a +# line written anywhere else cannot stand in for an approval. +# 2. `- Approved by:` must not be `pending`. +# 3. `- Approved version:` must equal `> Task version:`. +# +# The third is the one that is easy to leave out, and leaving it out is what +# makes an approval cover a document it never saw: `v2`'s text carrying `v1`'s +# nod is two different documents, and only naming the version tells the reader +# which one was read. +# +# `> Task version:` is read at its first occurrence and the approval fields only +# between `## Approval` and the next column-0 `## `. Both are matched the way the +# caller always matched them; this file changes where the decision lives, not +# what the decision is. That is why step 1 of the task measured the two verdicts +# against all 93 archived Plans before and after the move — byte for byte on the +# full verdict and its message, not merely on the exit code. +# +# A missing `## Approval` section needs no special case: `inap` never sets, and +# under the default question the first two conditions fail. Under `--stale-only` +# a missing section means nothing was approved, which is a permitted write. +# +# Extensionless, like every other TaskFlow hook, so Claude Code Windows +# auto-detection never interferes. + +set -euo pipefail +LC_ALL=C +export LC_ALL + +usage="usage: approval-check [--stale-only] " + +stale_only=0 +if [ "${1:-}" = "--stale-only" ]; then + stale_only=1 + shift +fi +[ "$#" -eq 1 ] || { echo "$usage" >&2; exit 1; } + +plan="$1" +[ -f "$plan" ] || { echo "plan.md missing; approval not checked" >&2; exit 1; } + +# One pass, one process, one read, no pipeline. `completion-gate`'s header +# records why: under `set -o pipefail`, `printf ... | grep -q` fails whenever the +# text is larger than the pipe buffer and the match comes early, because `grep +# -q` exits on the first hit, `printf` takes SIGPIPE, and a successful match +# becomes a failed pipeline. Measured there on a 39 KB approval block, 16 times +# out of 200. Reading the file once has no such failure mode at any size. +result="$(awk -v stale_only="$stale_only" ' + /^## / { inap = 0 } + /^> Task version: v[0-9][0-9]*$/ { + if (ver == "") { ver = $0; sub(/^> Task version: /, "", ver) } + next + } + /^## Approval$/ { inap = 1; next } + inap && $0 == "- Status: approved" { st = 1 } + inap && $0 == "- Approved by: pending" { pend = 1 } + inap && index($0, "- Approved version: ") == 1 { av = substr($0, 21) } + END { + if (ver == "") { print "missing Plan Task version"; exit 1 } + if (stale_only) { + # Only a superseded approval blocks a write. `pending` and an absent field + # are both "nothing approved yet", which is every task before its first + # approval and the planning phase every `prd.md` is written in. + if (av == "" || av == "pending" || av == ver) exit 0 + print "this document was approved at " av " but the Plan now says " ver "; the approval does not cover it. Approve the document as it now stands: bash hooks/task approve " + exit 1 + } + if (!st || av != ver || pend) { print "current Task version is not approved"; exit 1 } + exit 0 + } +' "$plan")" || { printf '%s\n' "$result" >&2; exit 1; } + +exit 0 diff --git a/hooks/capability-gate b/hooks/capability-gate index 904c7c2..34d3500 100644 --- a/hooks/capability-gate +++ b/hooks/capability-gate @@ -43,75 +43,191 @@ deny() { event_json="$(cat)" fields="$(printf '%s\n' "$event_json" | bash "$HERE/json-field" \ - hook_event_name tool_name cwd tool_input.file_path 2>/dev/null)" || exit 0 + hook_event_name tool_name cwd tool_input.file_path tool_input.command 2>/dev/null)" || exit 0 event_name="$(printf '%s\n' "$fields" | sed -n '1p')" tool_name="$(printf '%s\n' "$fields" | sed -n '2p')" event_cwd="$(printf '%s\n' "$fields" | sed -n '3p')" file_path="$(printf '%s\n' "$fields" | sed -n '4p')" +patch_text="$(printf '%s\n' "$fields" | sed -n '5,$p')" [ "$event_name" = "PreToolUse" ] || exit 0 -case "$tool_name" in - Write|Edit) ;; - *) exit 0 ;; -esac -[ -n "$file_path" ] || exit 0 -# Separators are normalized before ANY path test below, including the case that -# immediately follows. On Windows the host delivers `tool_input.file_path` with -# backslashes (claude-code #83877 and #64432), and every extraction here assumes -# the other one: `${file_path%/*}` finds no separator, the "cannot find a task -# directory" guard below then fires, and the write is allowed with no error at -# all. Measured on 1.1.1 — one file denied with forward slashes and allowed with -# backslashes, in the same repository. A gate that silently stops gating is worse -# than no gate, because nothing reports that it stopped, and the caller's path -# spelling is not something this hook may depend on. `event_cwd` is normalized -# with it, since a relative path is joined onto that below and a mixed result -# would be judged as neither form. Octal `\134` is a backslash: `tr '\\'` is -# ambiguous here, and the pattern form of this character is exactly the kind of -# escaping that hides. -file_path="$(printf '%s' "$file_path" | tr '\134' '/')" -event_cwd="$(printf '%s' "$event_cwd" | tr '\134' '/')" +# Two hosts, two shapes, one question. Claude Code names the file in +# `tool_input.file_path`; Codex sends the edit as a patch under +# `tool_input.command` and no path field at all, so a gate that only reads the +# path sees nothing to judge and exits 0 — the host is left ungated while +# appearing wired. Both resolve to the same thing: a set of target paths that +# each get the identical decision below. +# +# The comparison against `apply_patch` is exact on purpose. Codex's hook matcher +# accepts the aliases `Edit` and `Write` so that one wiring entry can be reused, +# but its hook *input* still reports `tool_name: "apply_patch"` — the alias +# chooses which hook runs, and never rewrites the event. So aliasing here would +# be a branch nothing can reach. +if [ "$tool_name" = "apply_patch" ]; then + # Directives that name a target. `*** Move to:` needs no separate case: a move + # is written as `*** Update File: ` followed by `*** Move to: `, so + # the old path is already in the list from the Update line and this adds the + # new one. Both ends are therefore judged, which is the whole point of a move — + # the document really does leave one task and arrive at another, and reading + # only the destination would leave the departure unchecked. + # + # `sub(/\r$/,"",p)` is not tidiness. A patch that arrives with CRLF line + # endings — routine on Windows — leaves a trailing `\r` on every extracted + # path, so `base` reads `plan.md\r`, matches no stage, and the gate returns 0 + # on a document it was built to refuse. Measured 2026-10-30: that exact patch + # was allowed. The `\r` is a line-ending artefact of the transport, never part + # of a path, so stripping it is a statement about the format rather than a + # guess about the content. + patch_paths="$(printf '%s\n' "$patch_text" | awk ' + /^\*\*\* Update File: / { p = substr($0, 18); sub(/\r$/, "", p); print p } + /^\*\*\* Add File: / { p = substr($0, 15); sub(/\r$/, "", p); print p } + /^\*\*\* Delete File: / { p = substr($0, 18); sub(/\r$/, "", p); print p } + /^\*\*\* Move to: / { p = substr($0, 14); sub(/\r$/, "", p); print p } + ')" + if [ -z "$patch_paths" ]; then + # A patch with no recognizable directive is not "nothing to do" — it is a + # gate that cannot read its host. Allowing it would be the failure this + # whole task exists to remove: a check that reports itself as present while + # silently inspecting nothing. Same class as an unreadable `> Task version:` + # below, and refused the same way. + # + # An empty command is the one exception: no edit happened, so there is + # nothing to have judged. The host may send it for a no-op patch, and + # refusing would be a false positive that teaches the agent to fight the + # gate rather than use it. + [ -n "$(printf '%s' "$patch_text" | tr -d '[:space:]')" ] || exit 0 + deny "TaskFlow pre-write gate: this looked like a patch but carried no Update/Add/Delete/Move directive, so no target path could be read and nothing was checked. This is a gate problem, not a rule you broke - the patch format may have changed. The write was refused rather than allowed because a gate that cannot see its input must not report success. Write the document with the host's plain file-edit tool instead, or report the patch shape at https://github.com/hkwuks/TaskFlow/issues" + fi + # Collected by reading lines, NOT by `set -- $patch_paths`. Unquoted expansion + # splits on every space, so a path containing one becomes two paths that are + # each judged and each allowed — a checked-looking write that was never + # checked. Measured 2026-10-30: `Task Flow Docs/.../plan.md` was allowed. + set -- + while IFS= read -r target; do + [ -n "$target" ] && set -- "$@" "$target" + done <` there and would gate the archive. docs_dir="${task_dir%/*}" -[ "${docs_dir##*/}" = "TaskFlowDocs" ] || exit 0 +[ "${docs_dir##*/}" = "TaskFlowDocs" ] || return 0 # An active task is one that has a Plan. Anything else is not TaskFlow business. -[ -f "$task_dir/plan.md" ] || exit 0 +[ -f "$task_dir/plan.md" ] || return 0 + +# An approval that has been superseded does not cover the document it is written +# in, so a rewritten version may not be carried into the old nod. This is the +# narrow question, not "is it approved": approval is the permit to *start work*, +# and every `prd.md` is written before its own approval, so refusing unapproved +# writes here would deadlock each new task at its first step with no command +# able to clear it. `approval-check` holds the reasoning. +# +# Deliberately ahead of the released-stage shortcut below. That shortcut means +# "this stage was paid for once and edits after it are not a new phase" — true +# of the gate's own condition, and false of an approval, which is a statement +# about one version's text. A version bump is exactly when the second edit +# arrives, so skipping the check for it would make the check unreachable. +if ! stale_msg="$(bash "$HERE/approval-check" --stale-only "$task_dir/plan.md" 2>&1)"; then + deny "TaskFlow pre-write gate: $stale_msg" +fi version="$(sed -n 's/^> Task version: \(v[0-9][0-9]*\)$/\1/p' "$task_dir/plan.md" | sed -n '1p')" [ -n "$version" ] || deny "TaskFlow pre-write gate: cannot read the Plan task version, so stage [$stage] cannot be checked. This is a gate problem, not a rule you broke. If the Plan is fine, record the stage with: bash hooks/task unaided $stage --considered '$(stage_class "$stage")'" @@ -123,6 +239,25 @@ store="$git_dir/taskflow" evidence="$store/evidence" released="$store/released" +# Recomputed once per run, not once per path: a patch queues several releases and +# the second one must take the next sequence rather than the first one again. The +# guard is what makes that true — without it every path re-read the file, saw the +# same highest sequence, and two documents in one patch both claimed seq 1. +if [ "$store_loaded" -eq 0 ]; then + store_loaded=1 + if [ -f "$released" ]; then + consumed="$(awk -F'|' 'NF == 6 { s = $4 + 0 } NF == 5 { s = $3 + 0 } + NF != 5 && NF != 6 { bad = 1 } + { if (s > m) m = s } + END { if (bad) { print "BAD" } else { print m + 0 } }' "$released")" + fi + case "$consumed" in + ''|*[!0-9]*) + deny "TaskFlow pre-write gate: $released holds a line that is neither the current six-field release row nor the five-field one it replaced, so the next sequence cannot be worked out and no stage can be released. This is a store problem, not a rule you broke. Inspect it: cat $released — and if the damage is only at the end of the file, bash hooks/task recover" + ;; + esac +fi + # Already released for this task version: later edits to the same document are # not a new phase, so the gate is idempotent per task version and stage. The # task name is part of the key. Without it the store is a queue of releases @@ -133,7 +268,7 @@ released="$store/released" if [ -f "$released" ] \ && awk -F'|' -v t="$task_name" -v v="$version" -v s="$stage" \ '$1 == t && $2 == v && $3 == s { found = 1 } END { exit !found }' "$released"; then - exit 0 + return 0 fi # Consumption is global, not per task version. The evidence file is one @@ -150,18 +285,6 @@ fi # is where the reasoning lives; this copy is here rather than sourced because a # hook is executed standalone, and the two are asserted against the same fixture # so they cannot drift. -consumed=0 -if [ -f "$released" ]; then - consumed="$(awk -F'|' 'NF == 6 { s = $4 + 0 } NF == 5 { s = $3 + 0 } - NF != 5 && NF != 6 { bad = 1 } - { if (s > m) m = s } - END { if (bad) { print "BAD" } else { print m + 0 } }' "$released")" -fi -case "$consumed" in - ''|*[!0-9]*) - deny "TaskFlow pre-write gate: $released holds a line that is neither the current six-field release row nor the five-field one it replaced, so the next sequence cannot be worked out and no stage can be released. This is a store problem, not a rule you broke. Inspect it: cat $released — and if the damage is only at the end of the file, bash hooks/task recover" - ;; -esac # Evidence is consumed as a queue, oldest first. Taking the newest line instead # would strand every invocation banked before the first document — and banking # one is a normal way to work: read the task, invoke once for the framing, then @@ -181,9 +304,29 @@ if [ "$total" -gt "$consumed" ]; then record="$(awk -v n="$next" 'NR == n { print; exit }' "$evidence")" kind="${record%%|*}" capability="${record#*|}" - mkdir -p "$store" 2>/dev/null || exit 0 - printf '%s|%s|%s|%s|%s|%s\n' "$task_name" "$version" "$stage" "$next" "$kind" "$capability" >> "$released" 2>/dev/null || exit 0 - exit 0 + row="$(printf '%s|%s|%s|%s|%s|%s' "$task_name" "$version" "$stage" "$next" "$kind" "$capability")" + # A patch judges every path before any of them is released, so a refusal on + # the second path cannot leave the first path's stage released and its evidence + # line spent against a write the host never applied. Measured 2026-10-30: a + # patch naming `plan.md` (evidence available) and `spec.md` (none) was refused, + # and `released` kept the `Plan` row. + # + # So a release is queued here and written once, after every path has been + # judged. Queuing rather than a deciding pass plus a releasing pass is what + # makes it atomic: a second pass that starts writing can still be refused part + # way through, which is the same defect one layer along. + # + # `consumed` is bumped as each row is queued, so a patch naming two documents + # gets two distinct records rather than both claiming the next sequence. + if [ "$patch_mode" -eq 1 ]; then + pending_release="$pending_release$row +" + consumed="$next" + return 0 + fi + mkdir -p "$store" 2>/dev/null || return 0 + printf '%s\n' "$row" >> "$released" 2>/dev/null || return 0 + return 0 fi # Where did the record actually go? Only asked on the deny path below, and only @@ -290,3 +433,21 @@ if [ -f "$released" ]; then fi fi deny "TaskFlow pre-write gate blocked stage [$stage]: $detail. TaskFlow is entered before the work and a capability is chosen inside the phase, so invoke one first — any Skill, agent, or MCP tool — then write the document. If no capability fits this phase, declare it: bash hooks/task unaided $stage --considered '$class'.$old_note" +} + +# Judge every target the event named. `deny` exits inside `judge_path`, so a +# refusal on any path ends the run here before any later path is reached. +# +# A patch queues its releases instead of writing them, so nothing reaches the +# store until every path has been judged — a refusal part way through leaves the +# store exactly as it was. The write happens here, after the loop, which is the +# only point at which "no path will be refused" is known. +for target in "$@"; do + judge_path "$target" +done + +if [ -n "$pending_release" ]; then + mkdir -p "$store" 2>/dev/null || exit 0 + printf '%s' "$pending_release" >> "$released" 2>/dev/null || exit 0 +fi +exit 0 diff --git a/hooks/completion-gate b/hooks/completion-gate index 010b8f6..0c6df7f 100644 --- a/hooks/completion-gate +++ b/hooks/completion-gate @@ -39,6 +39,8 @@ export LC_ALL usage="usage: completion-gate [--approval-only] " +HERE="$(cd "$(dirname "$0")" && pwd)" + approval_only=0 if [ "${1:-}" = "--approval-only" ]; then approval_only=1 @@ -49,13 +51,28 @@ fi plan="$1/plan.md" [ -f "$plan" ] || { echo "plan.md missing; completion not started" >&2; exit 1; } +# The approval half is one line of policy shared with `capability-gate`, which +# asks the same question from a `PreToolUse` process and cannot source this +# file. It lives in `approval-check` so there is exactly one implementation; +# this gate reports its refusal verbatim, so every caller that already matched +# on "current Task version is not approved" still matches. +# +# The approval answer is also the first thing the full check would report, so +# asking it here rather than inside the `awk` keeps the order callers see — a +# Plan that is both unapproved and unfinished still fails on the approval. +bash "$HERE/approval-check" "$plan" || exit 1 + +# `--approval-only` is the narrower question the two mid-task commands ask (`task +# state in_progress`, `task progress`): is the current Task version approved? +# They must not require the Plan to be finished — that is what they are working +# towards — so the answer is already given above. +if [ "$approval_only" -eq 1 ]; then exit 0; fi + # One pass, one process, one read. The conditions are checked in the order the # Agent would hit them and the first failure wins, so a Plan with two problems # reports the earlier one rather than whichever the reader happened to notice. -# -# The Approval fields are matched only inside the `## Approval` section, so a -# `- Status: approved` written anywhere else cannot stand in for an approval. -# An end-of-file section needs no special case: `inap` simply stays set. +# Approval has already been answered above, so what remains is whether the Plan +# is finished. result="$(awk -v approval_only="$approval_only" ' # A Step block is closed only when it says `- Status: done` in column 0. This # is a whitelist, and that is the point: the previous shape listed the three @@ -79,38 +96,51 @@ result="$(awk -v approval_only="$approval_only" ' # question with its own arguments; it is not smuggled in here. function flush() { if (instep && !closed) unfinished = 1 - instep = 0; closed = 0 + instep = 0; closed = 0; nstat = 0 } BEGIN { bad = 0 } /^## / { flush() } - /^> Task version: v[0-9][0-9]*$/ { if (ver == "") { ver = $0; sub(/^> Task version: /, "", ver) } next } - /^## Approval$/ { inap = 1; next } - inap && /^## / { inap = 0 } - inap && $0 == "- Status: approved" { st = 1 } - inap && $0 == "- Approved by: pending" { pend = 1 } - inap && index($0, "- Approved version: ") == 1 { av = substr($0, 21) } /^### Step / { flush() rest = substr($0, 10) if (match(rest, /^[0-9]+/)) { - if (substr(rest, RLENGTH + 1, 1) !~ /[A-Za-z0-9_]/) instep = 1 + if (substr(rest, RLENGTH + 1, 1) !~ /[A-Za-z0-9_]/) { + instep = 1 + # Named so a refusal can say which block carries two statuses. Without a + # number there is nothing to name and nothing to report: the block is not + # one `task progress` could have written to. + stepnum = substr(rest, 1, RLENGTH) + } } next } /^[[:space:]]*- \[ \]/ { unchecked = 1 } - # Last one wins, which is what "the block says" means when a block carries - # two: `task progress` replaces the line it finds rather than adding one, so a - # second `- Status:` is a hand edit, and reading the last is the reading a - # person makes. + # Two `- Status:` lines in one block is a Plan that contradicts itself, and the + # previous behaviour — last one wins — was a guess dressed as a reading. The + # comment that stood here argued reading the last is "the reading a person + # makes" because `task progress` replaces rather than appends, so a second line + # must be a hand edit. That reasoning is sound about *how* the second line got + # there and silent about what it means: the two lines state different statuses + # and either could be the intended one, which is a question this gate has no + # basis to answer. Measured 2026-10-30: a Plan with `- Status: done` followed by + # `- Status: pending` archived, because the reader took the last. + # + # Refusing costs an author one deletion. Guessing costs a Plan that archived + # while it said it was not finished. instep && /^- Status:/ { + nstat++ + if (nstat > 1) dup_step = (dup_step == "") ? stepnum : dup_step ", " stepnum closed = ($0 ~ /^- Status:[[:space:]]*done[[:space:]]*$/) } END { flush() - if (ver == "") { print "missing Plan Task version"; bad = 1 } - else if (!st || av != ver || pend) { print "current Task version is not approved"; bad = 1 } - else if (!approval_only && unchecked) { print "Plan has unchecked items"; bad = 1 } - else if (!approval_only && unfinished) { print "Plan has unfinished Steps"; bad = 1 } + if (approval_only) { exit 0 } + # Checked before `unchecked` and `unfinished`, because it is the one failure + # whose remedy is different in kind: the others say "finish the work", this + # says "the document states two things, delete one". + if (dup_step != "") { print "Plan has more than one - Status: line in Step " dup_step; bad = 1 } + else if (unchecked) { print "Plan has unchecked items"; bad = 1 } + else if (unfinished) { print "Plan has unfinished Steps"; bad = 1 } exit bad } ' "$plan")" || { printf '%s\n' "$result" >&2; exit 1; } diff --git a/hooks/hooks-codex.json b/hooks/hooks-codex.json index 3e364cf..b5aee15 100644 --- a/hooks/hooks-codex.json +++ b/hooks/hooks-codex.json @@ -1,5 +1,5 @@ { - "description": "TaskFlow lifecycle hooks: SessionStart state summary and per-prompt in-flight task reminder (Codex CLI plugin form).", + "description": "TaskFlow lifecycle hooks: SessionStart state summary, per-prompt in-flight task reminder, and the pre-write capability gate (Codex CLI plugin form).", "hooks": { "SessionStart": [ { @@ -23,6 +23,18 @@ } ] } + ], + "PreToolUse": [ + { + "matcher": "apply_patch", + "hooks": [ + { + "type": "command", + "command": "bash \"${PLUGIN_ROOT}/hooks/capability-gate\"", + "commandWindows": "\"${PLUGIN_ROOT}\\hooks\\run-hook.cmd\" capability-gate" + } + ] + } ] } } diff --git a/hooks/smoke-test b/hooks/smoke-test index b8a8f9e..ec46fe6 100755 --- a/hooks/smoke-test +++ b/hooks/smoke-test @@ -126,6 +126,74 @@ cg_case open-blocked 1 'unfinished Steps' "${cg_approved}### Step 1 — a cg_case closed-done 0 '' "${cg_approved}### Step 1 — a - Status: done " +# Two `- Status:` lines in one block is a Plan stating two different things about +# the same Step. The gate used to read the last one — sound reasoning about how +# the second line got there, silent about which of two contradictory statements +# is meant, which is not a question a file reader can answer. Measured +# 2026-10-30: `done` followed by `pending` archived. +# +# Both orders, because a reader that took the first instead of the last would +# pass one of them and this rule would still be missing: the defect is the +# guessing, not which line won. +cg_case multi-status-done-first 1 'more than one - Status: line in Step 1' "${cg_approved}### Step 1 — a +- Status: done +- Status: pending +" +cg_case multi-status-done-second 1 'more than one - Status: line in Step 1' "${cg_approved}### Step 1 — a +- Status: pending +- Status: done +" +# The refusal names the Step, so an author with several blocks knows which one to +# open. A message that said "somewhere" would leave them re-reading the Plan. +cg_case multi-status-step-3 1 'in Step 3' "${cg_approved}### Step 3 — c +- Status: done +- Status: done +" +# Two statuses in *different* blocks is two Steps, not a contradiction. Without +# this the rule could pass by refusing any Plan whose Steps repeat the word. +cg_case multi-status-across-blocks 0 '' "${cg_approved}### Step 1 — a +- Status: done + +### Step 2 — b +- Status: done +" +# A15: the gate must not care who wrote the Step status. `hooks/task progress` +# writes one shape and a hand edit writes another, and the two have to be judged +# identically — for two different reasons, and the pair below holds both. +# +# `- 2026-10-30 Step 1: ` in the verification section is what `progress` +# produces. A Plan with it, and one with the same `- Status: done` and a +# hand-written verification line, must reach the same verdict. If a future rule +# starts requiring the dated line, the hand-written half reddens and says so. +# +# This is the one assertion standing between the repository and a silent +# `progress` mandate, which R11 rules out: `progress` adds friction and produces +# nothing the gate can verify (PRD fact 5), so making it compulsory would be the +# exact incentive failure this task exists to remove. +cg_case step-closed-by-progress 0 '' "${cg_approved}### Step 1 — a +- Status: done + +## Verification / Review + +- 2026-10-30 Step 1: bash hooks/smoke-test ALL SMOKE PASSED +" +cg_case step-closed-by-hand 0 '' "${cg_approved}### Step 1 — a +- Status: done + +## Verification / Review + +Ran the suite by hand; all green. See the CI run on the pull request. +" +# A18: an archived task's Plan is not read by this gate at all — `capability-gate` +# excludes `achieved/`, and this one is only ever pointed at an active task by +# its callers. The fixture proves the *shape* matters and not the location: an +# old-format Step that would be refused while active is refused here too, so the +# exclusion is what protects `achieved/`, and nothing about the new rule changed +# that. Stated as a positive assertion rather than a comment because the previous +# shape of this file assumed it. +cg_case archived-shape-still-judged-by-file 1 'unfinished Steps' "${cg_approved}### Step 1 — a +- Status: completed +" # Trailing whitespace and a CRLF line ending are written with `printf` rather # than typed, so the bytes are visible in the assertion instead of hiding at the # end of a quoted line. Both have to count as closed: the repository's @@ -1285,11 +1353,15 @@ cp "$HERE/task" "$roll/task" # `task` resolves the name rule and the completion gate through SCRIPT_DIR, so a # copied tree needs both beside it — a missing one would show up here as # "completion-gate: No such file" rather than as the archive failure this -# section is about. +# section is about. `completion-gate` calls `approval-check` the same way, so it +# travels with it; a copied tree that grows a new sibling this way must copy that +# too, which is why the failure reads as a missing file rather than as a wrong +# verdict. cp "$HERE/task-id" "$roll/task-id" cp "$HERE/completion-gate" "$roll/completion-gate" +cp "$HERE/approval-check" "$roll/approval-check" printf '%s\n' '#!/bin/sh' 'echo "archive stub: forced failure"' 'exit 1' > "$roll/archive" -chmod +x "$roll/archive" "$roll/task-id" "$roll/completion-gate" "$roll/task" +chmod +x "$roll/archive" "$roll/task-id" "$roll/completion-gate" "$roll/approval-check" "$roll/task" rf="$tmp/rollback-root" mkdir -p "$rf" bash "$roll/task" intake "Rollback smoke goal." "fixture" --root "$rf" >/dev/null @@ -2636,6 +2708,26 @@ done grep -q -F -- "not in the task's documents" "$repo/CONTRIBUTING.md" || { echo "FAIL the landing claim is not distinguished from the task documents"; exit 1; } echo ok +echo "== a hook change reaches every declared host, or the rule says why not ==" +# The rule this pins exists because the repository got the distinction wrong: +# `runtime.md` recorded Codex as ungated under the heading of a host limitation, +# while Codex's own docs (read 2026-10-29) document `PreToolUse` on `apply_patch`, +# a deny reply, and `additionalContext`. "Not wired" and "not supported" are +# different facts and only one of them is work. Pin the rule's two vocabulary +# words and the obligation it puts on a hook change. +for clause in \ + 'A hook change lands on every host the repository declares, or it says why not.' \ + '**not wired** — the host supports the event and the reply shape, and this' \ + '**not supported** — the host has no such event, or no way to see what the hook' \ + 'A host recorded as *not supported* carries the host'"'"'s own documentation as' ; do + grep -q -F -- "$clause" "$repo/CONTRIBUTING.md" || { echo "FAIL host-coverage rule does not pin: $clause"; exit 1; } +done +# The rule must name the file that lists the hosts, or it points at nothing. +grep -q -F -- 'skills/taskflow/references/runtime.md' "$repo/CONTRIBUTING.md" || { echo "FAIL host-coverage rule does not name the host list"; exit 1; } +# And the distinction itself must be stated, not merely implied by the vocabulary. +grep -q -F -- 'Two words are not interchangeable' "$repo/CONTRIBUTING.md" || { echo "FAIL the two host verdicts are not stated as distinct"; exit 1; } +echo ok + echo "== phases map to concept classes without naming a capability ==" grep -q '^| 1\. Define — PRD | `prd.md` | requirements elicitation' "$skill/SKILL.md" || { echo "FAIL define phase concept row"; exit 1; } grep -q '^| 4\. Plan — execution contract | `plan.md` | work breakdown' "$skill/SKILL.md" || { echo "FAIL plan phase concept row"; exit 1; } @@ -2799,6 +2891,18 @@ cg_verdict() { # cg_verdict [tool-name] case "$cg_got" in *'"deny"'*) cg_got="deny" ;; *) cg_got="allow" ;; esac [ "$cg_got" = "$2" ] || { echo "FAIL gate gave $cg_got for $1, expected $2"; exit 1; } } +# Codex sends the whole edit as a patch under `tool_input.command` and no path +# field at all. Without this the gate sees no path, exits 0, and reports itself +# as wired while checking nothing — the exact defect this task exists to close, +# and the one that let a month pass with Codex ungated. +cg_patch_event() { # cg_patch_event + printf '%s' "{\"hook_event_name\":\"PreToolUse\",\"tool_name\":\"apply_patch\",\"cwd\":\"$(cg_json "$1")\",\"tool_input\":{\"command\":\"$(cg_json "$2")\"}}" +} +cg_patch_verdict() { # cg_patch_verdict