task-pipeline-skill 1.49.2 → 1.51.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 +102 -0
- package/SKILL-CARD.md +1 -1
- package/package.json +3 -2
- package/plugins/task-pipeline/.claude-plugin/plugin.json +1 -1
- package/plugins/task-pipeline/hooks/gate-observer.sh +88 -0
- package/plugins/task-pipeline/hooks/hooks.json +45 -0
- package/plugins/task-pipeline/hooks/release-gate.sh +223 -0
- package/plugins/task-pipeline/skills/task-pipeline/pipeline.example.json +2 -1
- package/plugins/task-pipeline/skills/task-pipeline/references/hooks.md +45 -0
- package/plugins/task-pipeline/skills/task-pipeline/references/progress.md +19 -0
- package/plugins/task-pipeline/skills/task-pipeline/templates/run.md +19 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,107 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## v1.51.0 — the gate stopped being keyed to a number, and stopped believing the agent
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
|
|
7
|
+
- **v1.50.0's release gate blocked every release forever in any project whose flow
|
|
8
|
+
has no stage 6.** It matched `stage: 6` literally. A six-stage project with tests
|
|
9
|
+
green at stage 4 could never tag anything again — and this file's own
|
|
10
|
+
`progress.md` says the rail "is computed, never eleven" for exactly this reason:
|
|
11
|
+
a host project replaces the flow. A wrong rail misinforms; a wrong gate stops the
|
|
12
|
+
work. Reproduced against a six-stage project before it was fixed.
|
|
13
|
+
|
|
14
|
+
The tests stage is now resolved from `pipeline.json` — a stage whose `state` is
|
|
15
|
+
`tests`, or one declaring `gate.command` — and failing that from the ledger by
|
|
16
|
+
name. When it cannot be resolved the gate still refuses, because a run is in
|
|
17
|
+
flight and nothing in it reports a suite passing, but the reason now says how to
|
|
18
|
+
make the flow readable.
|
|
19
|
+
|
|
20
|
+
### Added
|
|
21
|
+
|
|
22
|
+
- **`hooks/gate-observer.sh` — the observation the gate rests on.**
|
|
23
|
+
`stage: … verdict pass` is typed by the agent the release gate constrains, so on
|
|
24
|
+
its own the gate confirmed an assertion with the same assertion. The observer
|
|
25
|
+
records the **observed** exit code of the command the project declared, as a new
|
|
26
|
+
ledger line:
|
|
27
|
+
|
|
28
|
+
```
|
|
29
|
+
gate: <stage id> — command "<cmd>" — exit <N> — <ISO-8601>
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
and the release gate requires the claim and the observation to agree. It records
|
|
33
|
+
a red run as a red run — a hook that hid one would read as "the suite was never
|
|
34
|
+
run", which is the opposite of what happened. Only the declared command is
|
|
35
|
+
observed, compared on the normalised command line: `echo "npm test"` and
|
|
36
|
+
`npm test --watch` are not the project's gate, and treating them as one puts a
|
|
37
|
+
fabricated observation in the file the gate trusts.
|
|
38
|
+
|
|
39
|
+
**No `gate.command` declared → the gate degrades to the claim alone.** Stated
|
|
40
|
+
here rather than discovered.
|
|
41
|
+
|
|
42
|
+
**The LAST observation, not any of them.** "Some run of the suite was green" is
|
|
43
|
+
true of almost every repository that has ever been red, and a gate satisfied by
|
|
44
|
+
history rather than by current state is satisfied permanently. Found by running
|
|
45
|
+
the observer against this pipeline's own ledger, where an earlier green sat
|
|
46
|
+
above a later red and the gate waved it through.
|
|
47
|
+
|
|
48
|
+
Guards: 310 → **311**. Property checks: 9 → 9. The new guard disarms the
|
|
49
|
+
corroboration — it makes the gate accept the claim alone — and requires the suite
|
|
50
|
+
to notice; `test/negatives.py`'s floor moved with it in the same change. The
|
|
51
|
+
release-gate suite is 16 → 29 fixtures, all run as processes.
|
|
52
|
+
|
|
53
|
+
## v1.50.0 — the stage-7 gate stops being a sentence somebody reads
|
|
54
|
+
|
|
55
|
+
### Added
|
|
56
|
+
|
|
57
|
+
- **`plugins/task-pipeline/hooks/release-gate.sh` — the stage-7 rule, enforced at
|
|
58
|
+
agent time.** `stages.md` has always said a release does not leave stage 7 until
|
|
59
|
+
the full suite is green at stage 6. That was a sentence an agent reads and a
|
|
60
|
+
person hopes was obeyed, checked — when it was checked — after the tag was
|
|
61
|
+
already public. A `PreToolUse` hook now refuses `git tag`, a tag push,
|
|
62
|
+
`gh release create` and `npm publish` while the run ledger records no
|
|
63
|
+
`stage: 6 … verdict pass`.
|
|
64
|
+
|
|
65
|
+
Three narrownesses are the whole design, and each is the difference between a
|
|
66
|
+
gate people keep and a gate people rip out:
|
|
67
|
+
|
|
68
|
+
1. **Only outward acts.** Ordinary commits are how stage 5 works; gating them
|
|
69
|
+
would fight the pipeline's own build loop and be gone within a day.
|
|
70
|
+
2. **Only where a pipeline runs.** No `.task-pipeline/run.md` means exit 0
|
|
71
|
+
before anything else is read, so enabling the plugin changes nothing in any
|
|
72
|
+
other repository on the machine.
|
|
73
|
+
3. **Only what the ledger says.** Nothing reruns a suite or believes a claim —
|
|
74
|
+
`progress.md` already makes the ledger append-only, and this reads it.
|
|
75
|
+
|
|
76
|
+
The refusal names the act, the ledger to record the stage in, and the opt-out.
|
|
77
|
+
A refusal with no next step is how an operator learns to remove a gate.
|
|
78
|
+
|
|
79
|
+
Fail-closed by construction: an internal failure exits 2 as well, because every
|
|
80
|
+
non-zero code other than 2 is non-blocking in Claude Code, and a crashing gate
|
|
81
|
+
that fails open is worse than no gate — it reads as one.
|
|
82
|
+
|
|
83
|
+
- **`test/release_gate_test.py`** — 16 fixtures, run as a process with real JSON on
|
|
84
|
+
stdin, wired into `npm run test:all` and CI. Eight of them were watched failing:
|
|
85
|
+
the first implementation fed its own python source to `python3 -` through a
|
|
86
|
+
heredoc **and** tried to read the payload from stdin, so `sys.stdin.read()` came
|
|
87
|
+
back empty, every release was classified as "not a release", and the gate
|
|
88
|
+
allowed everything while looking installed. The payload now travels in the
|
|
89
|
+
environment.
|
|
90
|
+
|
|
91
|
+
### Notes
|
|
92
|
+
|
|
93
|
+
- The hook lives in the plugin's `hooks/hooks.json` rather than in `SKILL.md`
|
|
94
|
+
front matter. Front-matter hooks are scoped to a skill's activation; a release
|
|
95
|
+
gate has to hold for the whole run, across turns where the skill is not the
|
|
96
|
+
thing being invoked. `agent-sync` already enforces its leases from the same
|
|
97
|
+
channel, so this is the family's proven path rather than a new one.
|
|
98
|
+
|
|
99
|
+
Guards: 309 → **310**. Property checks: 9 → 9. The new guard is the negative
|
|
100
|
+
self-test that disarms the release gate — it blanks the payload handoff, which is
|
|
101
|
+
how the gate was really broken for its first eight fixtures — and requires the
|
|
102
|
+
suite to notice. `test/negatives.py`'s floor moved with it in the same change,
|
|
103
|
+
because a floor below the count cannot notice losing the difference.
|
|
104
|
+
|
|
3
105
|
## v1.49.2 — four stray table rows, and a list that promised three and delivered two
|
|
4
106
|
|
|
5
107
|
### Fixed
|
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.
|
|
15
|
+
| **Version** | 1.51.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.
|
|
3
|
+
"version": "1.51.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"
|
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
"test": "python3 test/validate.py",
|
|
10
10
|
"test:negatives": "python3 test/negatives.py",
|
|
11
11
|
"test:probe": "python3 test/probe.py --self-test",
|
|
12
|
-
"test:all": "python3 test/validate.py && python3 test/negatives.py && npm run test:probe"
|
|
12
|
+
"test:all": "python3 test/validate.py && python3 test/negatives.py && npm run test:probe && npm run test:hooks",
|
|
13
|
+
"test:hooks": "python3 test/release_gate_test.py"
|
|
13
14
|
},
|
|
14
15
|
"files": [
|
|
15
16
|
"bin",
|
|
@@ -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.
|
|
5
|
+
"version": "1.51.0",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "ssheleg",
|
|
8
8
|
"url": "https://x.com/sshlg93"
|
|
@@ -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
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"description": "task-pipeline — the stage-7 gate and the observation it rests 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
|
+
"hooks": {
|
|
4
|
+
"PreToolUse": [
|
|
5
|
+
{
|
|
6
|
+
"matcher": "Bash",
|
|
7
|
+
"hooks": [
|
|
8
|
+
{
|
|
9
|
+
"type": "command",
|
|
10
|
+
"shell": "bash",
|
|
11
|
+
"command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/release-gate.sh\"",
|
|
12
|
+
"statusMessage": "has the tests gate passed?",
|
|
13
|
+
"timeout": 20
|
|
14
|
+
}
|
|
15
|
+
]
|
|
16
|
+
}
|
|
17
|
+
],
|
|
18
|
+
"PostToolUse": [
|
|
19
|
+
{
|
|
20
|
+
"matcher": "Bash",
|
|
21
|
+
"hooks": [
|
|
22
|
+
{
|
|
23
|
+
"type": "command",
|
|
24
|
+
"shell": "bash",
|
|
25
|
+
"command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/gate-observer.sh\"",
|
|
26
|
+
"timeout": 20
|
|
27
|
+
}
|
|
28
|
+
]
|
|
29
|
+
}
|
|
30
|
+
],
|
|
31
|
+
"PostToolUseFailure": [
|
|
32
|
+
{
|
|
33
|
+
"matcher": "Bash",
|
|
34
|
+
"hooks": [
|
|
35
|
+
{
|
|
36
|
+
"type": "command",
|
|
37
|
+
"shell": "bash",
|
|
38
|
+
"command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/gate-observer.sh\"",
|
|
39
|
+
"timeout": 20
|
|
40
|
+
}
|
|
41
|
+
]
|
|
42
|
+
}
|
|
43
|
+
]
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# PreToolUse — the stage-7 gate, made mechanical.
|
|
3
|
+
#
|
|
4
|
+
# `stages.md` already says a release does not leave stage 7 until the full suite
|
|
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
|
+
#
|
|
9
|
+
# THREE DELIBERATE NARROWNESSES, each one the difference between a gate people
|
|
10
|
+
# keep and a gate people rip out:
|
|
11
|
+
#
|
|
12
|
+
# 1. Only OUTWARD acts. `git tag`, a tag push, `gh release create`, `npm
|
|
13
|
+
# publish`. Ordinary commits are how stage 5 works — gating them would fight
|
|
14
|
+
# the pipeline's own build loop and be gone within a day.
|
|
15
|
+
# 2. Only in a project that is running a pipeline. No `.task-pipeline/run.md`
|
|
16
|
+
# means exit 0 before anything else is read, so installing this plugin
|
|
17
|
+
# changes nothing anywhere else.
|
|
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.
|
|
40
|
+
#
|
|
41
|
+
# Exit 2 blocks the call and shows stderr as the reason. Any other non-zero code
|
|
42
|
+
# is NON-blocking in Claude Code, so an internal failure exits 2 as well: a
|
|
43
|
+
# crashing gate that fails open is worse than no gate, because it reads as one.
|
|
44
|
+
set -uo pipefail
|
|
45
|
+
|
|
46
|
+
input=$(cat 2>/dev/null || true)
|
|
47
|
+
|
|
48
|
+
project="${CLAUDE_PROJECT_DIR:-$PWD}"
|
|
49
|
+
ledger="$project/.task-pipeline/run.md"
|
|
50
|
+
|
|
51
|
+
decide() {
|
|
52
|
+
# The payload travels in the environment, NOT on stdin: the heredoc below IS
|
|
53
|
+
# stdin for `python3 -`, so a script fed that way can never also read the hook's
|
|
54
|
+
# JSON from there. Watched failing — the gate allowed every release, silently,
|
|
55
|
+
# because `sys.stdin.read()` came back empty and an empty payload is a skip.
|
|
56
|
+
HOOK_INPUT="$input" python3 - "$ledger" "$project" <<'PY'
|
|
57
|
+
import json, shlex, sys, os, re
|
|
58
|
+
|
|
59
|
+
ledger, project = sys.argv[1], sys.argv[2]
|
|
60
|
+
raw = os.environ.get("HOOK_INPUT", "")
|
|
61
|
+
try:
|
|
62
|
+
data = json.loads(raw)
|
|
63
|
+
except Exception:
|
|
64
|
+
print("skip"); raise SystemExit(0)
|
|
65
|
+
|
|
66
|
+
cmd = ((data.get("tool_input") or {}).get("command") or "")
|
|
67
|
+
try:
|
|
68
|
+
tokens = shlex.split(cmd)
|
|
69
|
+
except ValueError:
|
|
70
|
+
tokens = cmd.split()
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def outward(tokens):
|
|
74
|
+
"""Is this an act that other people can see the moment it succeeds?"""
|
|
75
|
+
low = [t.lower() for t in tokens]
|
|
76
|
+
for i, t in enumerate(low):
|
|
77
|
+
rest = low[i + 1:]
|
|
78
|
+
# `git -C dir -c k=v tag v1` — the subcommand is the first token after
|
|
79
|
+
# git that is not a flag or a flag's value. Tokenised rather than matched
|
|
80
|
+
# as a substring: `git log --grep=tag` must not count.
|
|
81
|
+
if t.endswith("git"):
|
|
82
|
+
j = 0
|
|
83
|
+
while j < len(rest):
|
|
84
|
+
if rest[j] in ("-C", "-c", "--git-dir", "--work-tree"):
|
|
85
|
+
j += 2; continue
|
|
86
|
+
if rest[j].startswith("-"):
|
|
87
|
+
j += 1; continue
|
|
88
|
+
break
|
|
89
|
+
if j < len(rest):
|
|
90
|
+
sub = rest[j]
|
|
91
|
+
args = rest[j + 1:]
|
|
92
|
+
if sub == "tag" and not any(a in ("-d", "--delete", "-l", "--list") for a in args):
|
|
93
|
+
if any(not a.startswith("-") for a in args):
|
|
94
|
+
return "git tag"
|
|
95
|
+
if sub == "push":
|
|
96
|
+
if any(a in ("--tags", "--follow-tags") for a in args):
|
|
97
|
+
return "git push --tags"
|
|
98
|
+
if any(a.startswith("refs/tags/") or re.fullmatch(r"v?\d+\.\d+\.\d+.*", a) for a in args):
|
|
99
|
+
return "git push <tag>"
|
|
100
|
+
if t.endswith("gh") and rest[:2] == ["release", "create"]:
|
|
101
|
+
return "gh release create"
|
|
102
|
+
if t.endswith("npm") and "publish" in rest:
|
|
103
|
+
return "npm publish"
|
|
104
|
+
return None
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
act = outward(tokens)
|
|
108
|
+
if not act:
|
|
109
|
+
print("skip"); raise SystemExit(0)
|
|
110
|
+
|
|
111
|
+
if not os.path.exists(ledger):
|
|
112
|
+
print("skip"); raise SystemExit(0)
|
|
113
|
+
|
|
114
|
+
try:
|
|
115
|
+
text = open(ledger, encoding="utf-8").read()
|
|
116
|
+
except Exception:
|
|
117
|
+
# The ledger exists and cannot be read: this project IS governed and the
|
|
118
|
+
# gate cannot tell. Fail closed, and say which file to look at.
|
|
119
|
+
print("block\t%s\tthe run ledger could not be read" % act); raise SystemExit(0)
|
|
120
|
+
|
|
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
|
|
164
|
+
break
|
|
165
|
+
|
|
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)
|
|
191
|
+
PY
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
verdict=$(decide 2>/dev/null)
|
|
195
|
+
status=$?
|
|
196
|
+
|
|
197
|
+
# A gate that cannot reach its own decision must not wave the release through.
|
|
198
|
+
if [ $status -ne 0 ] || [ -z "$verdict" ]; then
|
|
199
|
+
[ -f "$ledger" ] || exit 0
|
|
200
|
+
echo "task-pipeline: the stage-7 gate could not run (python3 missing or failed), and this project has a run in flight ($ledger). Refusing an outward act rather than failing open." >&2
|
|
201
|
+
exit 2
|
|
202
|
+
fi
|
|
203
|
+
|
|
204
|
+
state=$(printf '%s' "$verdict" | cut -f1)
|
|
205
|
+
act=$(printf '%s' "$verdict" | cut -f2)
|
|
206
|
+
why=$(printf '%s' "$verdict" | cut -f3)
|
|
207
|
+
|
|
208
|
+
case "$state" in
|
|
209
|
+
skip|ok) exit 0 ;;
|
|
210
|
+
block)
|
|
211
|
+
cat >&2 <<EOF
|
|
212
|
+
task-pipeline: \`$act\` is an outward, irreversible act and the tests gate has not
|
|
213
|
+
passed in this run — $why.
|
|
214
|
+
|
|
215
|
+
The tag is public the moment it lands; reading the verdict afterwards is not a
|
|
216
|
+
gate. Run the full suite, record the tests stage in $ledger, then release.
|
|
217
|
+
|
|
218
|
+
To release deliberately without the pipeline, remove the ledger or say
|
|
219
|
+
«без пайплайна» and take the route by hand.
|
|
220
|
+
EOF
|
|
221
|
+
exit 2 ;;
|
|
222
|
+
*) exit 0 ;;
|
|
223
|
+
esac
|
|
@@ -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
|
{
|
|
@@ -17,6 +17,7 @@ that acts *while the agent is working* rather than after the commit.
|
|
|
17
17
|
- A worked example
|
|
18
18
|
- Debugging
|
|
19
19
|
- Removing them
|
|
20
|
+
- The one this skill ships
|
|
20
21
|
- Leases are not reimplemented here
|
|
21
22
|
- Rationalizations
|
|
22
23
|
|
|
@@ -207,6 +208,50 @@ hooks enforced is still available as a command and still stated in the doctrine
|
|
|
207
208
|
and the run is **`ungated`** from then on, which is a thing to say out loud rather
|
|
208
209
|
than a detail to omit.
|
|
209
210
|
|
|
211
|
+
## The one this skill ships
|
|
212
|
+
|
|
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.
|
|
234
|
+
|
|
235
|
+
It is the worked example above, made real, and its three narrownesses are the
|
|
236
|
+
reusable part:
|
|
237
|
+
|
|
238
|
+
| Narrowness | Why it is not a smaller feature |
|
|
239
|
+
|---|---|
|
|
240
|
+
| Only outward acts, never ordinary commits | stage 5 commits per task by design; a gate that fights the build loop is removed within a day |
|
|
241
|
+
| Silent where no `.task-pipeline/run.md` exists | enabling the plugin must change nothing in a repository that runs no pipeline |
|
|
242
|
+
| Reads the ledger, never reruns a suite | `progress.md` already makes the ledger append-only; a second source of truth about "did stage 6 pass" is the failure this file warns about below |
|
|
243
|
+
|
|
244
|
+
**Fail-closed, deliberately.** Every non-zero exit code other than `2` is
|
|
245
|
+
non-blocking, so an internal failure exits `2` as well. A crashing gate that fails
|
|
246
|
+
open is worse than no gate: it reads as one.
|
|
247
|
+
|
|
248
|
+
**The defect worth remembering** — the first implementation fed its own python
|
|
249
|
+
source to `python3 -` through a heredoc *and* read the payload from stdin. The
|
|
250
|
+
heredoc **is** stdin, so the payload came back empty, every act classified as "not
|
|
251
|
+
a release", and the gate allowed everything while looking installed. Eight fixtures
|
|
252
|
+
caught it. A hook that cannot see its own input is indistinguishable, from the
|
|
253
|
+
outside, from a hook that approves.
|
|
254
|
+
|
|
210
255
|
## Leases are not reimplemented here
|
|
211
256
|
|
|
212
257
|
Guarded registers and lease arbitration belong to a coordination tool
|
|
@@ -30,6 +30,7 @@ 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
|
|
33
34
|
- The run ledger this reads from
|
|
34
35
|
- Rationalizations
|
|
35
36
|
|
|
@@ -317,6 +318,24 @@ Three things make the line worth writing rather than a habit:
|
|
|
317
318
|
Doctrine, including the eight classes and what must **not** be torn down:
|
|
318
319
|
[`residue.md`](residue.md).
|
|
319
320
|
|
|
321
|
+
## The observation beside the claim
|
|
322
|
+
|
|
323
|
+
A `stage:` verdict is written by the agent. Since v1.51.0 the ledger also carries
|
|
324
|
+
|
|
325
|
+
```
|
|
326
|
+
gate: <stage id> — command "<cmd>" — exit <N> — <ISO-8601>
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
written by `hooks/gate-observer.sh` and by nothing else: the **observed** exit code
|
|
330
|
+
of the command the project declared in `pipeline.json` → the tests stage's
|
|
331
|
+
`gate.command`. The rail does not read it — a glyph still comes from the verdict —
|
|
332
|
+
but the stage-7 release gate requires the claim and the observation to agree.
|
|
333
|
+
|
|
334
|
+
The reason is the one this whole file is about, arriving one level down. A rail
|
|
335
|
+
written from memory is a summary that is confidently wrong exactly when it matters;
|
|
336
|
+
a gate that reads a verdict typed by the agent it constrains is the same shape
|
|
337
|
+
again, and it looks like enforcement while being a mirror.
|
|
338
|
+
|
|
320
339
|
## The run ledger this reads from
|
|
321
340
|
|
|
322
341
|
`.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.
|
|
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,20 @@ 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>
|
|
24
32
|
```
|
|
25
33
|
|
|
26
34
|
- **`stage:`** — written when a gate **returns**, not when the stage is entered. The
|
|
27
35
|
rail's `✓` is derived from this line and from nothing else; a glyph set from memory
|
|
28
36
|
is a summary that is confidently wrong exactly when it matters.
|
|
37
|
+
- **`gate:`** — written by `hooks/gate-observer.sh`, never by an agent. It is the
|
|
38
|
+
only line here that records what a command **did** rather than what somebody
|
|
39
|
+
concluded: the exit code of the stage's declared `gate.command`, observed. The
|
|
40
|
+
`stage:` line above it is the agent's claim, and the release gate requires the
|
|
41
|
+
two to agree — without this, a gate reads a claim written by the party it
|
|
42
|
+
constrains and confirms an assertion with itself. Absent where the project
|
|
43
|
+
declares no command, and the release gate then degrades to the claim alone.
|
|
44
|
+
|
|
29
45
|
- **`iter:`** — one line per iteration closed. The progress line's counter is
|
|
30
46
|
`grep -c '^iter:'`, never a number anyone remembers.
|
|
31
47
|
- **`hand:`** — one per hand-back, at an iteration's close and at stage 10
|
|
@@ -53,6 +69,8 @@ stage: 1 Docs study — gate auto — verdict pass — 2026-08-10T11:31Z
|
|
|
53
69
|
touch: src/export.ts — pass 1 (stage 5) — reason: TASK-3
|
|
54
70
|
touch: src/export.ts — pass 2 (stage 5) — reason: F-014
|
|
55
71
|
touch: src/export.ts — pass 3 (stage 5) — reason: F-014
|
|
72
|
+
gate: 6 — command "npm test" — exit 0 — 2026-08-10T12:02Z
|
|
73
|
+
stage: 6 Tests — gate manual — verdict pass — 2026-08-10T12:03Z
|
|
56
74
|
hand: 3 — task "add CSV export to the orders table" — done 2 — surfaced 1 — decisions 1 — amb 2 (OQ-0007, ledger row 4)
|
|
57
75
|
holds: 5 — 2 (worktree: build-csv-export, this run; container: pg-test, this run) — enumerated 8/8 classes
|
|
58
76
|
holds: 10 — none — enumerated 7/8 classes, unlooked: containers (no docker on this host)
|