task-pipeline-skill 1.50.0 → 1.52.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,102 @@
1
1
  # Changelog
2
2
 
3
+ ## v1.52.0 — the three moments the run's own record could not show
4
+
5
+ ### Added
6
+
7
+ - **`hooks/run-lifecycle.sh`** — one line shape for three events the ledger was
8
+ blind to:
9
+
10
+ ```
11
+ event: <compact|session-end|subagent> — <detail> — <ISO-8601>
12
+ ```
13
+
14
+ - `compact` marks the boundary **the ledger exists because of** —
15
+ `templates/run.md` says so in its own header, and until now that boundary was
16
+ the one thing the file could not show. A resumed run could not tell "the
17
+ context was compacted here" from "nothing happened".
18
+ - `session-end` marks a run whose session ended without reaching acceptance.
19
+ That is precisely what `/task-pipeline checkup` looks for, and it was
20
+ invisible: the ledger simply stopped, which is indistinguishable from a run
21
+ still in progress. A run that *did* reach acceptance is not filed as
22
+ abandoned, or the report fills with runs that closed exactly as intended.
23
+ - `subagent` records one finishing, so the `hand:` count has something to be
24
+ checked against other than itself.
25
+
26
+ **It never writes a `hand:` line, and that is not a shortcut.** That shape
27
+ carries `done`, `surfaced`, `decisions` and `amb` — judgements only the agent
28
+ holds. A hook filling them in would fabricate the evidence the line exists to
29
+ provide. It records what it can see and leaves the accounting to whoever can
30
+ account.
31
+
32
+ One shape rather than three: a ledger grammar is read by four documents and
33
+ several hooks, and every shape added is a shape each of them must learn.
34
+
35
+ - **`hooks/build-gate.sh`** — editing the product before the plan is agreed now
36
+ asks. Stage 5 is where code is written; editing during intake, docs, brainstorm,
37
+ spec or plan is the pipeline's discipline being skipped, and it is the skip
38
+ nobody notices because the work looks like progress.
39
+
40
+ `ask`, never `deny`: the routing boundary says a typo, a one-line fix or a
41
+ mechanical rename never went through the pipeline anyway, and no hook can tell a
42
+ typo from a feature. **The build stage is resolved by role, never by number** —
43
+ the same lesson v1.51.0 learned from the release gate, applied before it could
44
+ be repeated. **The pipeline's own artefacts are never gated**: `docs/`,
45
+ `.task-pipeline/`, README and CHANGELOG are what stages 0-4 are *for*.
46
+
47
+ Guards: 311 → **312**. Property checks: 9 → 9. The release-gate suite is 29 → 43
48
+ fixtures, all run as processes.
49
+
50
+ ## v1.51.0 — the gate stopped being keyed to a number, and stopped believing the agent
51
+
52
+ ### Fixed
53
+
54
+ - **v1.50.0's release gate blocked every release forever in any project whose flow
55
+ has no stage 6.** It matched `stage: 6` literally. A six-stage project with tests
56
+ green at stage 4 could never tag anything again — and this file's own
57
+ `progress.md` says the rail "is computed, never eleven" for exactly this reason:
58
+ a host project replaces the flow. A wrong rail misinforms; a wrong gate stops the
59
+ work. Reproduced against a six-stage project before it was fixed.
60
+
61
+ The tests stage is now resolved from `pipeline.json` — a stage whose `state` is
62
+ `tests`, or one declaring `gate.command` — and failing that from the ledger by
63
+ name. When it cannot be resolved the gate still refuses, because a run is in
64
+ flight and nothing in it reports a suite passing, but the reason now says how to
65
+ make the flow readable.
66
+
67
+ ### Added
68
+
69
+ - **`hooks/gate-observer.sh` — the observation the gate rests on.**
70
+ `stage: … verdict pass` is typed by the agent the release gate constrains, so on
71
+ its own the gate confirmed an assertion with the same assertion. The observer
72
+ records the **observed** exit code of the command the project declared, as a new
73
+ ledger line:
74
+
75
+ ```
76
+ gate: <stage id> — command "<cmd>" — exit <N> — <ISO-8601>
77
+ ```
78
+
79
+ and the release gate requires the claim and the observation to agree. It records
80
+ a red run as a red run — a hook that hid one would read as "the suite was never
81
+ run", which is the opposite of what happened. Only the declared command is
82
+ observed, compared on the normalised command line: `echo "npm test"` and
83
+ `npm test --watch` are not the project's gate, and treating them as one puts a
84
+ fabricated observation in the file the gate trusts.
85
+
86
+ **No `gate.command` declared → the gate degrades to the claim alone.** Stated
87
+ here rather than discovered.
88
+
89
+ **The LAST observation, not any of them.** "Some run of the suite was green" is
90
+ true of almost every repository that has ever been red, and a gate satisfied by
91
+ history rather than by current state is satisfied permanently. Found by running
92
+ the observer against this pipeline's own ledger, where an earlier green sat
93
+ above a later red and the gate waved it through.
94
+
95
+ Guards: 310 → **311**. Property checks: 9 → 9. The new guard disarms the
96
+ corroboration — it makes the gate accept the claim alone — and requires the suite
97
+ to notice; `test/negatives.py`'s floor moved with it in the same change. The
98
+ release-gate suite is 16 → 29 fixtures, all run as processes.
99
+
3
100
  ## v1.50.0 — the stage-7 gate stops being a sentence somebody reads
4
101
 
5
102
  ### Added
package/SKILL-CARD.md CHANGED
@@ -12,7 +12,7 @@ harmless.
12
12
  |---|---|
13
13
  | **Purpose** | Runs a substantial task through ten gated delivery stages — intake grill, docs study, brainstorm, spec, plan, subagent build, tests, lint/deploy, post-deploy, docs+registers, acceptance — refusing to advance until each gate passes |
14
14
  | **Owner** | ssheleg ([github.com/ssheleg/task-pipeline](https://github.com/ssheleg/task-pipeline)) |
15
- | **Version** | 1.50.0 |
15
+ | **Version** | 1.52.0 |
16
16
  | **Surface** | Claude Code (filesystem skill + plugin) and the vercel `skills` CLI. **Not** uploaded to the Skills API; custom Skills do not sync across surfaces |
17
17
  | **Dependencies** | None required. Optional: `context7` (MCP), `figma` (MCP), super-ux, agent-sync, graphify, obsidian-wiki. Every stage's doctrine ships in-repo; the one conditional requirement is super-ux for the stage-3 UX track on a user-facing task |
18
18
  | **Evaluation status** | Suite authored, 5 categories. One recorded run, **self-observed by the author**; **zero blind runs on zero of three models** — the split, and the numbers, live in [`evals/RESULTS.md`](evals/RESULTS.md) and are computed by `evals/run.py` |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "task-pipeline-skill",
3
- "version": "1.50.0",
3
+ "version": "1.52.0",
4
4
  "description": "Full-cycle delivery pipeline for coding agents: a mandatory built-in intake grill, then 10 gated stages (docs, brainstorm+decompose, spec, plan, build, tests, lint/deploy, post-deploy, docs/wiki, acceptance). Every stage's doctrine ships inside the skill — no companion plugin required. This package is the installer CLI.",
5
5
  "bin": {
6
6
  "task-pipeline": "bin/task-pipeline.js"
@@ -2,7 +2,7 @@
2
2
  "name": "task-pipeline",
3
3
  "displayName": "Task Pipeline",
4
4
  "description": "Runs a substantial task through a mandatory built-in intake grill, then 10 gated stages (docs, brainstorm+decompose, spec, plan, subagent build, tests, lint/deploy, post-deploy, docs/wiki, acceptance). Every stage's doctrine is built into the skill — no companion plugin required — with typed auto/manual gates, a frozen requirement spine that closes with evidence, a work board and a verification ledger that outlive a run, an exposure line naming what shipped unconfirmed, a progress rail computed from the project's own config, a loop guard whose review ceiling measures rather than stops, and stage-3 tracks for what a product does, how it sounds and how it looks. Two modes need no task: `checkup` (what is unverified) and `setup` (audit existing docs). Retro insights can publish upstream as issues, opt-in and redacted.",
5
- "version": "1.50.0",
5
+ "version": "1.52.0",
6
6
  "author": {
7
7
  "name": "ssheleg",
8
8
  "url": "https://x.com/sshlg93"
@@ -0,0 +1,107 @@
1
+ #!/usr/bin/env bash
2
+ # PreToolUse — editing the product before the plan is agreed.
3
+ #
4
+ # `stages.md` says no stage advances until its gate passes, and stage 5 is where
5
+ # code gets written. Editing the product during intake, docs, brainstorm, spec or
6
+ # plan is the pipeline's own discipline being skipped — and it is the skip nobody
7
+ # notices, because the work looks like progress.
8
+ #
9
+ # **`ask`, never `deny`, and the reason is this file's own doctrine.** The routing
10
+ # boundary says a typo, a one-line edit or a mechanical rename does not go through
11
+ # the pipeline, and no hook can tell a typo from a feature. A refusal here would
12
+ # fight the honest cases daily and be removed inside a week; a question answered
13
+ # once costs a keystroke.
14
+ #
15
+ # **The build stage is resolved by ROLE, never by number.** v1.50.0 matched
16
+ # `stage: 6` literally and blocked every release in a six-stage project; the same
17
+ # mistake here would put a prompt in front of every edit in any project whose flow
18
+ # is numbered differently. `pipeline.json` → a stage whose `state` is `build`, else
19
+ # one whose name says build. Unresolvable → silence, because a question nobody can
20
+ # act on is worse than none.
21
+ #
22
+ # **The pipeline's own artefacts are never gated.** Stages 0-4 exist to WRITE
23
+ # things — the brief, the spec, the plan, the ledger. A gate that asked about those
24
+ # would fire on the very work it is protecting.
25
+ set -uo pipefail
26
+
27
+ input=$(cat 2>/dev/null || true)
28
+ project="${CLAUDE_PROJECT_DIR:-$PWD}"
29
+ ledger="$project/.task-pipeline/run.md"
30
+ [ -f "$ledger" ] || exit 0
31
+
32
+ HOOK_INPUT="$input" python3 - "$ledger" "$project" <<'PY' 2>/dev/null || exit 0
33
+ import json, os, re, sys
34
+
35
+ ledger, project = sys.argv[1], sys.argv[2]
36
+ try:
37
+ data = json.loads(os.environ.get("HOOK_INPUT", ""))
38
+ except Exception:
39
+ raise SystemExit(0)
40
+
41
+ ti = data.get("tool_input") or {}
42
+ path = ti.get("file_path") or ti.get("notebook_path") or ""
43
+ if not path:
44
+ raise SystemExit(0)
45
+
46
+ rel = os.path.relpath(path, project) if os.path.isabs(path) else path
47
+ rel = rel.replace(os.sep, "/")
48
+ # The run writes these; gating them would fire on the work stages 0-4 are for.
49
+ if rel.startswith("..") or re.match(r"^(docs/|\.task-pipeline/|\.claude/|CHANGELOG\.md|README\.md)", rel):
50
+ raise SystemExit(0)
51
+
52
+ try:
53
+ text = open(ledger, encoding="utf-8").read()
54
+ except Exception:
55
+ raise SystemExit(0)
56
+
57
+ stage_lines = [l.strip() for l in text.splitlines() if l.strip().startswith("stage:")]
58
+ if not stage_lines:
59
+ raise SystemExit(0)
60
+
61
+
62
+ def build_stage_id():
63
+ """By role, never by number — the mistake v1.50.0 shipped in the release gate."""
64
+ try:
65
+ cfg = json.load(open(os.path.join(project, "pipeline.json"), encoding="utf-8"))
66
+ for s in cfg.get("stages") or []:
67
+ if isinstance(s, dict) and s.get("state") == "build":
68
+ return str(s.get("id"))
69
+ except Exception:
70
+ pass
71
+ for l in stage_lines:
72
+ m = re.match(r"stage:\s*(\S+)\s+([^—]*)", l)
73
+ if m and re.search(r"build|dev\b", m.group(2), re.I):
74
+ return m.group(1)
75
+ return None
76
+
77
+
78
+ build_id = build_stage_id()
79
+ if build_id is None:
80
+ raise SystemExit(0) # unresolvable: a question nobody can act on
81
+
82
+ # Has the run entered the build stage at all? Entering is enough — the gate is
83
+ # about editing BEFORE the plan is agreed, not about the build's own verdict.
84
+ entered = any(re.match(r"stage:\s*%s\b" % re.escape(build_id), l) for l in stage_lines)
85
+ if entered:
86
+ raise SystemExit(0)
87
+
88
+ last = stage_lines[-1]
89
+ m = re.match(r"stage:\s*(\S+)\s+([^—]*)", last)
90
+ now = "%s %s" % (m.group(1), m.group(2).strip()) if m else "an early stage"
91
+
92
+ print(json.dumps({"hookSpecificOutput": {
93
+ "hookEventName": "PreToolUse",
94
+ "permissionDecision": "ask",
95
+ "permissionDecisionReason":
96
+ "This run is at stage %s and has not entered the build stage (%s). Editing "
97
+ "the product before the plan is agreed is the pipeline's own discipline "
98
+ "being skipped — and it is the skip nobody notices, because the work looks "
99
+ "like progress.\n\n"
100
+ "Allow if this is a typo, a one-line fix or a mechanical rename, which the "
101
+ "routing boundary says never went through the pipeline anyway. Otherwise "
102
+ "finish the plan and record stage %s in %s.\n\n"
103
+ "The pipeline's own artefacts — docs/, .task-pipeline/, README, CHANGELOG — "
104
+ "are never gated." % (now, build_id, build_id, ledger),
105
+ }}))
106
+ PY
107
+ exit 0
@@ -0,0 +1,88 @@
1
+ #!/usr/bin/env bash
2
+ # PostToolUse — record what the gate command actually did.
3
+ #
4
+ # `stage: 6 tests — gate manual — verdict pass` is typed by the agent. The stage-7
5
+ # release gate reads that line, so on its own it corroborates a claim with the same
6
+ # claim, made by the party it constrains. Nothing in the pipeline observed the
7
+ # suite; it observed a sentence about the suite.
8
+ #
9
+ # This appends the observation:
10
+ #
11
+ # gate: <stage id> — command "<cmd>" — exit <N> — <ISO-8601>
12
+ #
13
+ # and `release-gate.sh` then requires the claim and the observation to agree.
14
+ #
15
+ # **Only a command the project declared.** `pipeline.json` → the stage whose
16
+ # `gate.command` is set. Nothing is guessed from the shape of a command line: a
17
+ # heuristic over strings the environment also produces is `learned.md` rule 15,
18
+ # and a gate built on one would record `npm test` from a `README` example.
19
+ #
20
+ # **It records, it never judges.** A failing run is written down as a failing run.
21
+ # A hook that quietly declined to record a red result would be worse than no hook,
22
+ # because the absence would read as "the suite was never run".
23
+ #
24
+ # Silent everywhere else: no ledger, no declaration, no match — exit 0 having done
25
+ # nothing, so enabling this plugin changes nothing in a repository that is not
26
+ # running a pipeline.
27
+ set -uo pipefail
28
+
29
+ input=$(cat 2>/dev/null || true)
30
+ project="${CLAUDE_PROJECT_DIR:-$PWD}"
31
+ ledger="$project/.task-pipeline/run.md"
32
+ [ -f "$ledger" ] || exit 0
33
+
34
+ HOOK_INPUT="$input" python3 - "$ledger" "$project" <<'PY' 2>/dev/null || true
35
+ import json, os, re, sys, datetime
36
+
37
+ ledger, project = sys.argv[1], sys.argv[2]
38
+ try:
39
+ data = json.loads(os.environ.get("HOOK_INPUT", ""))
40
+ except Exception:
41
+ raise SystemExit(0)
42
+
43
+ cmd = ((data.get("tool_input") or {}).get("command") or "").strip()
44
+ if not cmd:
45
+ raise SystemExit(0)
46
+
47
+ try:
48
+ cfg = json.load(open(os.path.join(project, "pipeline.json"), encoding="utf-8"))
49
+ except Exception:
50
+ raise SystemExit(0)
51
+
52
+ target = None
53
+ for s in cfg.get("stages") or []:
54
+ if isinstance(s, dict) and (s.get("gate") or {}).get("command"):
55
+ target = (str(s.get("id")), (s.get("gate") or {})["command"].strip())
56
+ break
57
+ if not target:
58
+ raise SystemExit(0)
59
+
60
+ stage_id, declared = target
61
+
62
+ # Compared on the normalised command line, not on a substring: `echo "npm test"`
63
+ # and `npm test --watch` are not the project's gate, and treating them as one puts
64
+ # a fabricated observation in the ledger the release gate trusts.
65
+ def norm(s):
66
+ return " ".join(s.split())
67
+
68
+ if norm(cmd) != norm(declared):
69
+ raise SystemExit(0)
70
+
71
+ # PostToolUse fires on success; PostToolUseFailure carries the error. Both are
72
+ # wired to this script, and `error` present means the command did not exit 0.
73
+ failed = bool(data.get("error")) or data.get("hook_event_name") == "PostToolUseFailure"
74
+ out = data.get("tool_output") or {}
75
+ if isinstance(out, dict) and out.get("exit_code") is not None:
76
+ code = int(out["exit_code"])
77
+ else:
78
+ code = 1 if failed else 0
79
+
80
+ stamp = datetime.datetime.now(datetime.timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
81
+ line = 'gate: %s — command "%s" — exit %d — %s\n' % (stage_id, norm(declared), code, stamp)
82
+
83
+ # Append-only, like every other line in this file. A ledger that is rewritten is a
84
+ # ledger whose history can be edited to say the suite passed.
85
+ with open(ledger, "a", encoding="utf-8") as fh:
86
+ fh.write(line)
87
+ PY
88
+ exit 0
@@ -1,5 +1,5 @@
1
1
  {
2
- "description": "task-pipeline — the stage-7 gate, enforced at agent time. Every hook exits 0 immediately when the project has no .task-pipeline/run.md, so installing the plugin globally changes nothing in a repository that is not running a pipeline.",
2
+ "description": "task-pipeline — the gates and the observations they rest on. Every hook exits 0 immediately when the project has no .task-pipeline/run.md, so installing the plugin globally changes nothing in a repository that is not running a pipeline.",
3
3
  "hooks": {
4
4
  "PreToolUse": [
5
5
  {
@@ -9,10 +9,83 @@
9
9
  "type": "command",
10
10
  "shell": "bash",
11
11
  "command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/release-gate.sh\"",
12
- "statusMessage": "stage 7: has stage 6 passed?",
12
+ "statusMessage": "has the tests gate passed?",
13
13
  "timeout": 20
14
14
  }
15
15
  ]
16
+ },
17
+ {
18
+ "matcher": "Edit|Write|MultiEdit|NotebookEdit",
19
+ "hooks": [
20
+ {
21
+ "type": "command",
22
+ "shell": "bash",
23
+ "command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/build-gate.sh\"",
24
+ "timeout": 20
25
+ }
26
+ ]
27
+ }
28
+ ],
29
+ "PostToolUse": [
30
+ {
31
+ "matcher": "Bash",
32
+ "hooks": [
33
+ {
34
+ "type": "command",
35
+ "shell": "bash",
36
+ "command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/gate-observer.sh\"",
37
+ "timeout": 20
38
+ }
39
+ ]
40
+ }
41
+ ],
42
+ "PostToolUseFailure": [
43
+ {
44
+ "matcher": "Bash",
45
+ "hooks": [
46
+ {
47
+ "type": "command",
48
+ "shell": "bash",
49
+ "command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/gate-observer.sh\"",
50
+ "timeout": 20
51
+ }
52
+ ]
53
+ }
54
+ ],
55
+ "PreCompact": [
56
+ {
57
+ "hooks": [
58
+ {
59
+ "type": "command",
60
+ "shell": "bash",
61
+ "command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/run-lifecycle.sh\"",
62
+ "timeout": 15
63
+ }
64
+ ]
65
+ }
66
+ ],
67
+ "SubagentStop": [
68
+ {
69
+ "hooks": [
70
+ {
71
+ "type": "command",
72
+ "shell": "bash",
73
+ "command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/run-lifecycle.sh\"",
74
+ "timeout": 15
75
+ }
76
+ ]
77
+ }
78
+ ],
79
+ "SessionEnd": [
80
+ {
81
+ "hooks": [
82
+ {
83
+ "type": "command",
84
+ "shell": "bash",
85
+ "command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/run-lifecycle.sh\"",
86
+ "timeout": 10
87
+ }
88
+ ]
16
89
  }
17
90
  ]
18
91
  }
@@ -2,9 +2,9 @@
2
2
  # PreToolUse — the stage-7 gate, made mechanical.
3
3
  #
4
4
  # `stages.md` already says a release does not leave stage 7 until the full suite
5
- # is green at stage 6. Until now that was a sentence an agent reads and a person
6
- # hopes was obeyed; the tag is public before anybody can check. This refuses the
7
- # irreversible act while the run's own ledger records no passing stage 6.
5
+ # is green at the tests stage. Until now that was a sentence an agent reads and a
6
+ # person hopes was obeyed; the tag is public before anybody can check. This
7
+ # refuses the irreversible act while the run's own ledger says otherwise.
8
8
  #
9
9
  # THREE DELIBERATE NARROWNESSES, each one the difference between a gate people
10
10
  # keep and a gate people rip out:
@@ -15,8 +15,28 @@
15
15
  # 2. Only in a project that is running a pipeline. No `.task-pipeline/run.md`
16
16
  # means exit 0 before anything else is read, so installing this plugin
17
17
  # changes nothing anywhere else.
18
- # 3. Only what the ledger SAYS. Nothing here reruns a suite or believes a
19
- # claim; `progress.md` makes the ledger append-only, and this reads it.
18
+ # 3. Only what the ledger SAYS, plus what a hook OBSERVED. Nothing here reruns a
19
+ # suite.
20
+ #
21
+ # WHICH STAGE IS THE TESTS STAGE IS NOT A CONSTANT, and v1.50.0 shipped it as one.
22
+ # It matched `stage: 6` literally, so a project whose flow has six stages — tests
23
+ # at stage 4, everything green — could never tag anything again. The pipeline's own
24
+ # `progress.md` says the rail "is computed, never eleven" because a host project
25
+ # replaces the flow; a gate keyed to a stage number is the same error with worse
26
+ # consequences, because a wrong rail misinforms and a wrong gate stops the work.
27
+ # The stage is now resolved from `pipeline.json` (a stage whose `state` is `tests`,
28
+ # or one declaring `gate.command`), and failing that from the ledger by name. When
29
+ # it cannot be resolved at all the gate still REFUSES — a run is in flight and
30
+ # nothing in it reports a suite passing, and "we could not tell, so we let it go"
31
+ # is exactly what a release gate exists to refuse — but the reason says how to make
32
+ # the flow readable, because a refusal with no next step is one that gets removed.
33
+ #
34
+ # AND THE CLAIM IS CORROBORATED. `stage: … verdict pass` is typed by the agent this
35
+ # gate constrains, so on its own the gate confirms an assertion with itself. Where
36
+ # the stage declares `gate.command`, `hooks/gate-observer.sh` records the OBSERVED
37
+ # exit code of that command as a `gate:` line, and both must agree. Declare no
38
+ # command and the gate degrades to the claim alone — stated here rather than
39
+ # discovered.
20
40
  #
21
41
  # Exit 2 blocks the call and shows stderr as the reason. Any other non-zero code
22
42
  # is NON-blocking in Claude Code, so an internal failure exits 2 as well: a
@@ -33,10 +53,10 @@ decide() {
33
53
  # stdin for `python3 -`, so a script fed that way can never also read the hook's
34
54
  # JSON from there. Watched failing — the gate allowed every release, silently,
35
55
  # because `sys.stdin.read()` came back empty and an empty payload is a skip.
36
- HOOK_INPUT="$input" python3 - "$ledger" <<'PY'
56
+ HOOK_INPUT="$input" python3 - "$ledger" "$project" <<'PY'
37
57
  import json, shlex, sys, os, re
38
58
 
39
- ledger = sys.argv[1]
59
+ ledger, project = sys.argv[1], sys.argv[2]
40
60
  raw = os.environ.get("HOOK_INPUT", "")
41
61
  try:
42
62
  data = json.loads(raw)
@@ -49,6 +69,7 @@ try:
49
69
  except ValueError:
50
70
  tokens = cmd.split()
51
71
 
72
+
52
73
  def outward(tokens):
53
74
  """Is this an act that other people can see the moment it succeeds?"""
54
75
  low = [t.lower() for t in tokens]
@@ -69,7 +90,6 @@ def outward(tokens):
69
90
  sub = rest[j]
70
91
  args = rest[j + 1:]
71
92
  if sub == "tag" and not any(a in ("-d", "--delete", "-l", "--list") for a in args):
72
- # A bare `git tag` lists; a tag with a name creates one.
73
93
  if any(not a.startswith("-") for a in args):
74
94
  return "git tag"
75
95
  if sub == "push":
@@ -83,6 +103,7 @@ def outward(tokens):
83
103
  return "npm publish"
84
104
  return None
85
105
 
106
+
86
107
  act = outward(tokens)
87
108
  if not act:
88
109
  print("skip"); raise SystemExit(0)
@@ -97,17 +118,76 @@ except Exception:
97
118
  # gate cannot tell. Fail closed, and say which file to look at.
98
119
  print("block\t%s\tthe run ledger could not be read" % act); raise SystemExit(0)
99
120
 
100
- passed = False
101
- for line in text.splitlines():
102
- line = line.strip()
103
- if not line.startswith("stage:"):
104
- continue
105
- m = re.match(r"stage:\s*6\b", line)
106
- if m and re.search(r"verdict\s+pass", line):
107
- passed = True
121
+ stage_lines = [l.strip() for l in text.splitlines() if l.strip().startswith("stage:")]
122
+
123
+
124
+ def declared_test_stage():
125
+ """The tests stage, from the project's own flow. `(id, command)` or None."""
126
+ try:
127
+ cfg = json.load(open(os.path.join(project, "pipeline.json"), encoding="utf-8"))
128
+ except Exception:
129
+ return None
130
+ for s in cfg.get("stages") or []:
131
+ if not isinstance(s, dict):
132
+ continue
133
+ gate = s.get("gate") or {}
134
+ if s.get("state") == "tests" or gate.get("command"):
135
+ return (str(s.get("id")), gate.get("command"))
136
+ return None
137
+
138
+
139
+ def ledger_test_stage():
140
+ """Failing a declaration, the stage the ledger itself calls the tests one."""
141
+ for l in stage_lines:
142
+ m = re.match(r"stage:\s*(\S+)\s+([^—]*)", l)
143
+ if m and re.search(r"test", m.group(2), re.I):
144
+ return (m.group(1), None)
145
+ return None
146
+
147
+
148
+ found = declared_test_stage() or ledger_test_stage()
149
+ if not found:
150
+ # A run is in flight and NOTHING in it reports a suite passing. Blocking is
151
+ # right — the alternative reads as "we could not tell, so we let it go", which
152
+ # is what a release gate exists to refuse. The reason says how to be readable.
153
+ print("block\t%s\tno stage in this run reports the suite passing; declare "
154
+ "the tests stage as `\"state\": \"tests\"` in pipeline.json, or record "
155
+ "it in the ledger with `test` in its name" % act)
156
+ raise SystemExit(0)
157
+
158
+ stage_id, command = found
159
+
160
+ claimed_at = None
161
+ for i, l in enumerate(stage_lines):
162
+ if re.match(r"stage:\s*%s\b" % re.escape(stage_id), l) and re.search(r"verdict\s+pass", l):
163
+ claimed_at = i
108
164
  break
109
165
 
110
- print("ok\t%s" % act if passed else "block\t%s\tno `stage: 6 … verdict pass` line in the ledger" % act)
166
+ if claimed_at is None:
167
+ print("block\t%s\tno `stage: %s … verdict pass` line in the ledger" % (act, stage_id))
168
+ raise SystemExit(0)
169
+
170
+ # The claim is the agent's. Where the project declared the command, an observation
171
+ # by a hook must agree with it — otherwise the gate corroborates an assertion with
172
+ # the same assertion.
173
+ if command:
174
+ observed = [l.strip() for l in text.splitlines()
175
+ if re.match(r"gate:\s*%s\b" % re.escape(stage_id), l.strip())]
176
+ if not observed:
177
+ print("block\t%s\tthe ledger claims stage %s passed, and no hook observed "
178
+ "`%s` running — the claim is the agent's own" % (act, stage_id, command))
179
+ raise SystemExit(0)
180
+ # THE LAST observation, not any of them. "Some run of the suite was green" is
181
+ # true of almost every repository that has ever been red, and a gate satisfied
182
+ # by history rather than by the current state is satisfied permanently. Found
183
+ # by running the observer against this pipeline's own ledger, where an earlier
184
+ # green sat above a later red and the gate waved it through.
185
+ if not re.search(r"—\s*exit\s+0\b", observed[-1]):
186
+ print("block\t%s\tthe most recent observed run of `%s` did not exit 0"
187
+ % (act, command))
188
+ raise SystemExit(0)
189
+
190
+ print("ok\t%s" % act)
111
191
  PY
112
192
  }
113
193
 
@@ -129,11 +209,11 @@ case "$state" in
129
209
  skip|ok) exit 0 ;;
130
210
  block)
131
211
  cat >&2 <<EOF
132
- task-pipeline: \`$act\` is an outward, irreversible act and stage 6 has not passed
133
- in this run — $why.
212
+ task-pipeline: \`$act\` is an outward, irreversible act and the tests gate has not
213
+ passed in this run — $why.
134
214
 
135
215
  The tag is public the moment it lands; reading the verdict afterwards is not a
136
- gate. Run the full suite, record stage 6 in $ledger, then release.
216
+ gate. Run the full suite, record the tests stage in $ledger, then release.
137
217
 
138
218
  To release deliberately without the pipeline, remove the ledger or say
139
219
  «без пайплайна» and take the route by hand.
@@ -0,0 +1,89 @@
1
+ #!/usr/bin/env bash
2
+ # PreCompact · SessionEnd · SubagentStop — the three moments the run's own record
3
+ # cannot see, written down as they happen.
4
+ #
5
+ # One line shape for all three:
6
+ #
7
+ # event: <kind> — <detail> — <ISO-8601>
8
+ #
9
+ # **Why one shape and not three.** A ledger grammar is read by four documents and
10
+ # two hooks; every shape added is a shape each of them must learn. These three are
11
+ # the same kind of fact — something happened to the RUN rather than to a stage —
12
+ # and a `kind` field costs one word against three grammars.
13
+ #
14
+ # WHAT EACH ONE IS FOR
15
+ #
16
+ # compact — the ledger exists because compaction happens; `templates/run.md`
17
+ # says so in its own header. Until now the boundary itself was the
18
+ # one event the file could not show, so a resumed run could not
19
+ # tell "the context was compacted here" from "nothing happened".
20
+ # session-end — a run whose session ended without reaching acceptance is
21
+ # ABANDONED, and abandoned runs are exactly what
22
+ # `/task-pipeline checkup` exists to surface. Before this they
23
+ # were invisible: the ledger simply stopped, which looks identical
24
+ # to a run still in progress.
25
+ # subagent — stage 5 dispatches implementers as subagents, and the `hand:`
26
+ # line counts them. Both sides of that count are written by the
27
+ # same agent from the same memory, so the audit comparing them
28
+ # compares a number with itself.
29
+ #
30
+ # **It does NOT write `hand:` lines, and that is not a shortcut.** That shape
31
+ # carries `done`, `surfaced`, `decisions` and `amb` — judgements only the agent
32
+ # holds. A hook filling them in would be fabricating the very evidence the line
33
+ # exists to provide. So it records what it can actually see (a subagent of this
34
+ # type stopped) and leaves the accounting to whoever can account.
35
+ #
36
+ # Silent with no ledger, and never blocking: none of these events should ever cost
37
+ # a session. `SessionEnd` hooks share a 1.5-second budget, so this appends one line
38
+ # and exits.
39
+ set -uo pipefail
40
+
41
+ input=$(cat 2>/dev/null || true)
42
+ project="${CLAUDE_PROJECT_DIR:-$PWD}"
43
+ ledger="$project/.task-pipeline/run.md"
44
+ [ -f "$ledger" ] || exit 0
45
+
46
+ HOOK_INPUT="$input" python3 - "$ledger" <<'PY' 2>/dev/null || true
47
+ import json, os, sys, datetime, re
48
+
49
+ ledger = sys.argv[1]
50
+ try:
51
+ data = json.loads(os.environ.get("HOOK_INPUT", ""))
52
+ except Exception:
53
+ raise SystemExit(0)
54
+
55
+ event = data.get("hook_event_name")
56
+
57
+
58
+ def clean(text, limit=90):
59
+ """One line, no separators that would break the ledger's own grammar."""
60
+ s = re.sub(r"\s+", " ", str(text or "")).replace("—", "-").strip()
61
+ return s[:limit]
62
+
63
+
64
+ if event == "PreCompact":
65
+ kind, detail = "compact", clean(data.get("trigger") or "unknown")
66
+ elif event == "SessionEnd":
67
+ # A run that reached acceptance is finished, not abandoned. Recording an end
68
+ # for it would fill `checkup` with runs that closed exactly as intended.
69
+ try:
70
+ text = open(ledger, encoding="utf-8").read()
71
+ except Exception:
72
+ raise SystemExit(0)
73
+ stages = [l.strip() for l in text.splitlines() if l.strip().startswith("stage:")]
74
+ closed = any(re.search(r"verdict\s+pass", l) and re.search(r"accept", l, re.I) for l in stages)
75
+ if closed:
76
+ raise SystemExit(0)
77
+ kind = "session-end"
78
+ detail = "%s - run not closed, no acceptance recorded" % clean(data.get("reason") or "other", 40)
79
+ elif event == "SubagentStop":
80
+ kind = "subagent"
81
+ detail = clean(data.get("agent_type") or "unknown", 40)
82
+ else:
83
+ raise SystemExit(0)
84
+
85
+ stamp = datetime.datetime.now(datetime.timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
86
+ with open(ledger, "a", encoding="utf-8") as fh:
87
+ fh.write('event: %s — %s — %s\n' % (kind, detail, stamp))
88
+ PY
89
+ exit 0
@@ -106,7 +106,8 @@
106
106
  ],
107
107
  "gate": {
108
108
  "type": "auto",
109
- "check": "full suite green (not just new tests); new/changed code covered including failure paths; no skip/xfail smuggling a red suite past the gate; tests assert real behavior, not mock behavior"
109
+ "check": "full suite green (not just new tests); new/changed code covered including failure paths; no skip/xfail smuggling a red suite past the gate; tests assert real behavior, not mock behavior",
110
+ "command": "npm test"
110
111
  }
111
112
  },
112
113
  {
@@ -210,10 +210,27 @@ than a detail to omit.
210
210
 
211
211
  ## The one this skill ships
212
212
 
213
- Since v1.50.0 the plugin carries a hook of its own
214
- `hooks/release-gate.sh`, wired at `PreToolUse` on `Bash`. It refuses an
215
- **outward, irreversible act** (`git tag`, a tag push, `gh release create`,
216
- `npm publish`) while the run ledger records no `stage: 6 … verdict pass`.
213
+ Since v1.50.0 the plugin carries hooks of its own. `hooks/release-gate.sh`
214
+ (`PreToolUse` on `Bash`) refuses an **outward, irreversible act** — `git tag`, a
215
+ tag push, `gh release create`, `npm publish` while the run says the suite has not
216
+ passed. `hooks/gate-observer.sh` (`PostToolUse` and `PostToolUseFailure`) records
217
+ what the declared gate command actually did.
218
+
219
+ **Two lessons from v1.50.0, both shipped as defects and both fixed in v1.51.0.**
220
+
221
+ *A gate keyed to a stage NUMBER is the rail's mistake with worse consequences.*
222
+ The first version matched `stage: 6` literally. `progress.md` says the rail "is
223
+ computed, never eleven" because a host project replaces the flow — and a project
224
+ whose flow has six stages, tests green at stage 4, could never tag anything again.
225
+ A wrong rail misinforms; a wrong gate stops the work. The stage is resolved from
226
+ `pipeline.json` (`state: "tests"`, or a stage declaring `gate.command`), and
227
+ failing that from the ledger by name.
228
+
229
+ *A gate that reads a claim written by the party it constrains confirms an
230
+ assertion with itself.* `stage: … verdict pass` is typed by the agent. Where the
231
+ stage declares `gate.command`, the observer writes the **observed** exit code as a
232
+ `gate:` line and the release gate requires both. Declare no command and it
233
+ degrades to the claim alone — which is stated here rather than discovered.
217
234
 
218
235
  It is the worked example above, made real, and its three narrownesses are the
219
236
  reusable part:
@@ -30,6 +30,8 @@ maintains them and the next run reads them as current.
30
30
  - Every number is borrowed
31
31
  - Absent is a word, never a zero
32
32
  - The `holds:` line — what the run is still holding
33
+ - The observation beside the claim
34
+ - The run's own lifecycle
33
35
  - The run ledger this reads from
34
36
  - Rationalizations
35
37
 
@@ -317,6 +319,38 @@ Three things make the line worth writing rather than a habit:
317
319
  Doctrine, including the eight classes and what must **not** be torn down:
318
320
  [`residue.md`](residue.md).
319
321
 
322
+ ## The observation beside the claim
323
+
324
+ A `stage:` verdict is written by the agent. Since v1.51.0 the ledger also carries
325
+
326
+ ```
327
+ gate: <stage id> — command "<cmd>" — exit <N> — <ISO-8601>
328
+ ```
329
+
330
+ written by `hooks/gate-observer.sh` and by nothing else: the **observed** exit code
331
+ of the command the project declared in `pipeline.json` → the tests stage's
332
+ `gate.command`. The rail does not read it — a glyph still comes from the verdict —
333
+ but the stage-7 release gate requires the claim and the observation to agree.
334
+
335
+ The reason is the one this whole file is about, arriving one level down. A rail
336
+ written from memory is a summary that is confidently wrong exactly when it matters;
337
+ a gate that reads a verdict typed by the agent it constrains is the same shape
338
+ again, and it looks like enforcement while being a mirror.
339
+
340
+ ## The run's own lifecycle
341
+
342
+ Three moments the rail cannot show, recorded by `hooks/run-lifecycle.sh` as
343
+
344
+ ```
345
+ event: <compact|session-end|subagent> — <detail> — <ISO-8601>
346
+ ```
347
+
348
+ The rail reads none of them; `checkup` reads `session-end`, which is how an
349
+ abandoned run stops being invisible. Before this the ledger simply stopped at
350
+ whatever stage the session died on — and a stopped ledger is indistinguishable
351
+ from a run still in progress, which is the exact shape *absent is a word, never a
352
+ zero* exists to refuse.
353
+
320
354
  ## The run ledger this reads from
321
355
 
322
356
  `.task-pipeline/run.md`, seeded at stage 0 from
@@ -11,9 +11,16 @@
11
11
 
12
12
  Run: `<topic>` · started `<YYYY-MM-DD>` · module map: `<path or "none">`
13
13
 
14
+ ## Contents
15
+
16
+ - Lines
17
+ - Log
18
+ - Why it is not the build ledger
19
+ - What closes it, and what survives it
20
+
14
21
  ## Lines
15
22
 
16
- Append-only. Four shapes, and nothing else belongs here:
23
+ Append-only. These shapes, and nothing else belongs here (the list is the count — a written one drifts, and this one already had):
17
24
 
18
25
  ```
19
26
  stage: <id> <name> — gate <auto|manual> — verdict <pass|fail|skip> — <ISO-8601>
@@ -21,11 +28,34 @@ iter: <N> — item <B-NNN or task id> — closed at gate <stage id>
21
28
  touch: <file> — pass <N> (<stage|round|module>) — reason: <finding id / gate item>
22
29
  hand: <N|10> — task "<quoted>" — done <n> — surfaced <n> — decisions <n> — amb <n> (<ids or "— no register">)
23
30
  holds: <stage id> — <n> (<class: what, owner>; … or "none") — enumerated <n>/8 classes, <unlooked: classes not enumerable>
31
+ gate: <stage id> — command "<cmd>" — exit <N> — <ISO-8601>
32
+ event: <compact|session-end|subagent> — <detail> — <ISO-8601>
24
33
  ```
25
34
 
26
35
  - **`stage:`** — written when a gate **returns**, not when the stage is entered. The
27
36
  rail's `✓` is derived from this line and from nothing else; a glyph set from memory
28
37
  is a summary that is confidently wrong exactly when it matters.
38
+ - **`gate:`** — written by `hooks/gate-observer.sh`, never by an agent. It is the
39
+ only line here that records what a command **did** rather than what somebody
40
+ concluded: the exit code of the stage's declared `gate.command`, observed. The
41
+ `stage:` line above it is the agent's claim, and the release gate requires the
42
+ two to agree — without this, a gate reads a claim written by the party it
43
+ constrains and confirms an assertion with itself. Absent where the project
44
+ declares no command, and the release gate then degrades to the claim alone.
45
+
46
+ - **`event:`** — written by `hooks/run-lifecycle.sh`, the three moments this file
47
+ otherwise cannot show. `compact` marks the boundary the ledger exists *because
48
+ of* — without it a resumed run cannot tell a compaction from nothing happening.
49
+ `session-end` marks a run whose session ended without reaching acceptance, which
50
+ is what `/task-pipeline checkup` looks for and what was previously invisible: the
51
+ ledger simply stopped, and a stopped ledger looks exactly like a run still in
52
+ progress. `subagent` records one finishing, so the `hand:` count below has
53
+ something to be checked against other than itself.
54
+
55
+ **It never writes a `hand:` line.** That shape carries `done`, `surfaced`,
56
+ `decisions` and `amb` — judgements only the agent holds, and a hook filling them
57
+ in would fabricate the evidence the line exists to provide.
58
+
29
59
  - **`iter:`** — one line per iteration closed. The progress line's counter is
30
60
  `grep -c '^iter:'`, never a number anyone remembers.
31
61
  - **`hand:`** — one per hand-back, at an iteration's close and at stage 10
@@ -53,6 +83,10 @@ stage: 1 Docs study — gate auto — verdict pass — 2026-08-10T11:31Z
53
83
  touch: src/export.ts — pass 1 (stage 5) — reason: TASK-3
54
84
  touch: src/export.ts — pass 2 (stage 5) — reason: F-014
55
85
  touch: src/export.ts — pass 3 (stage 5) — reason: F-014
86
+ event: compact — auto — 2026-08-10T11:58Z
87
+ event: subagent — general-purpose — 2026-08-10T12:00Z
88
+ gate: 6 — command "npm test" — exit 0 — 2026-08-10T12:02Z
89
+ stage: 6 Tests — gate manual — verdict pass — 2026-08-10T12:03Z
56
90
  hand: 3 — task "add CSV export to the orders table" — done 2 — surfaced 1 — decisions 1 — amb 2 (OQ-0007, ledger row 4)
57
91
  holds: 5 — 2 (worktree: build-csv-export, this run; container: pg-test, this run) — enumerated 8/8 classes
58
92
  holds: 10 — none — enumerated 7/8 classes, unlooked: containers (no docker on this host)