task-pipeline-skill 1.87.0 → 1.88.1
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 +80 -0
- package/SKILL-CARD.md +1 -1
- package/package.json +3 -3
- package/plugins/task-pipeline/.claude-plugin/plugin.json +1 -1
- package/plugins/task-pipeline/hooks/hooks.json +1 -1
- package/plugins/task-pipeline/skills/task-pipeline/references/continuity.md +17 -0
- package/plugins/task-pipeline/skills/task-pipeline/references/progress.md +5 -1
- package/plugins/task-pipeline/skills/task-pipeline/scripts/stage_checkpoint.py +272 -0
- package/plugins/task-pipeline/skills/task-pipeline/templates/run.md +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,83 @@
|
|
|
1
|
+
## v1.88.1 — v1.88.0's payload, released with its no-stamp declaration
|
|
2
|
+
|
|
3
|
+
The same payload as `v1.88.0`: the stage-boundary checkpoint writer, `scripts/stage_checkpoint.py`
|
|
4
|
+
(PB-137 N-024). `release.yml` refused the `v1.88.0` tag's tree because that version was named
|
|
5
|
+
nowhere in `docs/evidence/retro.md` → *Releases that carry no stamp*. Nothing published under
|
|
6
|
+
it. This release names both versions there first, and its tag was cut locally before
|
|
7
|
+
`npm run test:all` ran against its tree (`R-010`).
|
|
8
|
+
|
|
9
|
+
## v1.88.0 — a stage boundary leaves a checkpoint the next executor can continue from
|
|
10
|
+
|
|
11
|
+
The run ledger survives a compaction. It does not survive a quota that ran out on another
|
|
12
|
+
account, or a run continued by a different agent. When the host has Project Observatory's
|
|
13
|
+
memory tools (`observatory_checkpoint_write`, or `memory.checkpoint.write` under
|
|
14
|
+
memory/0.1), every gate that returns now also writes a workflow checkpoint. Without the
|
|
15
|
+
tools, nothing changes except one ledger line. This is PB-137 N-024 of the agent-memory
|
|
16
|
+
program.
|
|
17
|
+
|
|
18
|
+
- **`scripts/stage_checkpoint.py`** ships in the bundle and needs only the standard library.
|
|
19
|
+
It never talks to Observatory itself: `emit` builds the tool's arguments from the ledger,
|
|
20
|
+
and `record` keeps the answer.
|
|
21
|
+
- `emit` takes the run's topic as the goal, the verdicts as `done`, and the next stage
|
|
22
|
+
(named from `pipeline.example.json`) as `open`. Operator constraints and key *names*
|
|
23
|
+
are given once and carried to every later boundary.
|
|
24
|
+
- The idempotency key comes from the exact `stage:` line, so a retry replays and a stage
|
|
25
|
+
that runs again is a new checkpoint.
|
|
26
|
+
- `record` keeps `workflowId` and `leaseId` in `.task-pipeline/memory.json` (0600,
|
|
27
|
+
git-ignored). On a `LeaseLost` refusal it drops the token, so a stale writer stops
|
|
28
|
+
after one refusal.
|
|
29
|
+
- `record --unavailable` appends one `event: memory — unavailable` line and exits 0.
|
|
30
|
+
- **`references/continuity.md` → Part 3** says when and how to write the checkpoint, and
|
|
31
|
+
what it is not: a second ledger, or the narrative.
|
|
32
|
+
- **`event:` gains the `memory` kind** in `templates/run.md` and `references/progress.md`,
|
|
33
|
+
written by the script rather than the hook.
|
|
34
|
+
- **`test/stage_checkpoint_test.py`** (39 cases), run by `npm test` and `test:all`:
|
|
35
|
+
- a boundary built from the ledger;
|
|
36
|
+
- retry versus repeat;
|
|
37
|
+
- continuation across boundaries, with the token absent from every output;
|
|
38
|
+
- provider loss;
|
|
39
|
+
- a stale writer and its successor;
|
|
40
|
+
- credentials by name only;
|
|
41
|
+
- a failed gate stays open and acceptance closes;
|
|
42
|
+
- no ledger.
|
|
43
|
+
- **A live receipt against the Observatory engine** (main after PR #159, 7 of 7):
|
|
44
|
+
- the first write starts a workflow;
|
|
45
|
+
- a retry replays;
|
|
46
|
+
- `memory.checkpoint.write` continues it, with the constraints carried;
|
|
47
|
+
- after a handoff the old writer is refused `LeaseLost` and drops its token;
|
|
48
|
+
- no token reaches the ledger.
|
|
49
|
+
|
|
50
|
+
It also found the one defect fixed before release: constraints were lost after the
|
|
51
|
+
first boundary.
|
|
52
|
+
|
|
53
|
+
## v1.87.1 — the SessionEnd timeout no host gave
|
|
54
|
+
|
|
55
|
+
Codex 0.157 prints `clamping SessionEnd hook timeout to 3s in …/task-pipeline/…/hooks.json`
|
|
56
|
+
at every session start. `hooks.json` declared 10 s for `run-lifecycle.sh`; Codex clamps a
|
|
57
|
+
SessionEnd handler to 3 s, and Claude Code sizes its SessionEnd wait from the largest
|
|
58
|
+
handler timeout. The script already fitted: it appends one line, and its own header states
|
|
59
|
+
the 1.5-second budget. So the number was the only defect, and a warning on every Codex
|
|
60
|
+
start the only cost.
|
|
61
|
+
|
|
62
|
+
Guards: 429 → **430**.
|
|
63
|
+
|
|
64
|
+
- **`hooks/hooks.json` declares `timeout: 3`** for SessionEnd. `test/validate.py` now reads
|
|
65
|
+
the plugin's OWN hooks file, which it never checked before (only the template), and holds
|
|
66
|
+
it and `templates/hooks.example.json` to a 3 s SessionEnd cap. The new negative step
|
|
67
|
+
plants the shipped 10 through `test/plant_sessionend_timeout.py`, which asserts that the
|
|
68
|
+
plant landed.
|
|
69
|
+
- **`validate.yml` gets room again, 512 419 → 506 050 bytes.** The new step on its own
|
|
70
|
+
pushed the workflow past GitHub's 512 000-byte limit, where it stays `active` and never
|
|
71
|
+
runs. The largest inline plant (*a release MENTIONED but not declared*, 7.9 kB) moved
|
|
72
|
+
verbatim into `test/plant_gap_mention.py`, with the same exit-9 skip. It is watched
|
|
73
|
+
rejecting its planted defect through `test/negatives.py -k MENTIONED`.
|
|
74
|
+
- **The dormancy guard reads the script a step calls.** Moving that plant took its `SKIP:`
|
|
75
|
+
branch out of the step text, and `anchors.py` read only the step. So it stopped calling
|
|
76
|
+
the step skip-capable, and deleting its `# dormant-when:` passed. `test:all` caught this
|
|
77
|
+
through *a plant that can decline to run and never says when*. `Step.skip_capable` now
|
|
78
|
+
also follows `python3 test/plant_*.py` into the file. A new `anchors_test.py` case is red
|
|
79
|
+
without the fix.
|
|
80
|
+
|
|
1
81
|
## v1.87.0 — stage 4 stops handing over a plan the next agent cannot execute
|
|
2
82
|
|
|
3
83
|
The operator's finding, 2026-09-13: **agents change between sessions, and whatever a
|
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.88.1 |
|
|
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, and **one of two browser channels** — `playwright` (CLI or MCP) or `chrome-devtools` (MCP); either satisfies the browser step and neither is required. 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,13 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "task-pipeline-skill",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.88.1",
|
|
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"
|
|
7
7
|
},
|
|
8
8
|
"scripts": {
|
|
9
|
-
"test": "python3 test/validate.py && python3 test/graph_test.py && python3 test/project_audit_test.py && python3 test/packet_schema_test.py && python3 test/browser_claims_test.py && npm run test:audit-regressions",
|
|
10
|
-
"test:all": "python3 test/validate.py && python3 test/graph_test.py && python3 test/project_audit_test.py && python3 test/packet_schema_test.py && python3 test/browser_claims_test.py && python3 test/negatives.py && npm run test:certify && npm run test:exposure && npm run test:probe && npm run test:anchors && npm run test:runner && npm run test:hooks && npm run test:artifacts && npm run test:docs && npm run test:audit-regressions",
|
|
9
|
+
"test": "python3 test/validate.py && python3 test/graph_test.py && python3 test/project_audit_test.py && python3 test/packet_schema_test.py && python3 test/browser_claims_test.py && python3 test/stage_checkpoint_test.py && npm run test:audit-regressions",
|
|
10
|
+
"test:all": "python3 test/validate.py && python3 test/graph_test.py && python3 test/project_audit_test.py && python3 test/packet_schema_test.py && python3 test/browser_claims_test.py && python3 test/stage_checkpoint_test.py && python3 test/negatives.py && npm run test:certify && npm run test:exposure && npm run test:probe && npm run test:anchors && npm run test:runner && npm run test:hooks && npm run test:artifacts && npm run test:docs && npm run test:audit-regressions",
|
|
11
11
|
"test:negatives": "python3 test/negatives.py",
|
|
12
12
|
"test:exposure": "python3 test/exposure_test.py",
|
|
13
13
|
"test:probe": "python3 test/probe.py --self-test",
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"name": "task-pipeline",
|
|
4
4
|
"displayName": "Task Pipeline",
|
|
5
5
|
"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/judgment/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.",
|
|
6
|
-
"version": "1.
|
|
6
|
+
"version": "1.88.1",
|
|
7
7
|
"author": {
|
|
8
8
|
"name": "ssheleg",
|
|
9
9
|
"url": "https://x.com/sshlg93"
|
|
@@ -20,6 +20,7 @@ almost no window left, loses the middle of it, and re-derives what it already di
|
|
|
20
20
|
- The evidence rule
|
|
21
21
|
- What happens at the signal
|
|
22
22
|
- The flush is not a new document
|
|
23
|
+
- Part 3 — workflow memory at the stage boundary
|
|
23
24
|
- Rationalizations
|
|
24
25
|
|
|
25
26
|
## The limit, before the capability
|
|
@@ -305,6 +306,22 @@ copy of the truth, it is written once, nobody updates it, and the next run reads
|
|
|
305
306
|
it as current. The artifacts above are read by later stages anyway; making them
|
|
306
307
|
right costs nothing extra and pays twice.
|
|
307
308
|
|
|
309
|
+
## Part 3 — workflow memory at the stage boundary
|
|
310
|
+
|
|
311
|
+
The ledger survives a compaction. It does not survive a quota that ran out on another
|
|
312
|
+
account, or a run picked up by a different agent on another machine. When the host has
|
|
313
|
+
Project Observatory's memory tools, **every gate that returns also writes a workflow
|
|
314
|
+
checkpoint**, and the next executor continues from the last finished stage.
|
|
315
|
+
|
|
316
|
+
- **When.** After the `stage:` line is appended, every time a gate returns, whatever the verdict. A failed gate's checkpoint says `blocked` and keeps the stage open. Acceptance closes the workflow.
|
|
317
|
+
- **How.** `scripts/stage_checkpoint.py emit` prints the tool's arguments, built from the ledger alone: the topic, the verdicts, the next stage, the operator's constraints and key names. Pass them to `observatory_checkpoint_write`, or to `memory.checkpoint.write` under memory/0.1. Then hand the answer to `stage_checkpoint.py record --answer -`.
|
|
318
|
+
- **The constraints.** Give the brief's restrictive rules once, as `--constraint`. They are carried to every later boundary, and a successor reads them first.
|
|
319
|
+
- **Keys.** Pass keys by name, as `--credential PROJECT/ENV/NAME`, never as a value.
|
|
320
|
+
- **Without the tools.** `stage_checkpoint.py record --unavailable "<why>"` appends one `event: memory — unavailable` line, and the run continues exactly as before. Missing memory is a state the ledger names, not a failure.
|
|
321
|
+
- **A refusal.** `LeaseLost` means another executor holds the workflow now. The script drops the token. Read the workflow (`observatory_checkpoint_latest` with `stage_checkpoint.py state`'s id) before doing anything else. A stale writer stops after one refusal.
|
|
322
|
+
- **The lease token.** It lives in `.task-pipeline/memory.json` (mode 0600, git-ignored) and never in the ledger, a reply or a commit.
|
|
323
|
+
- **What this is not.** Not a second ledger and not the narrative. The checkpoint is the work's state for the next executor. The wiki and the ledger keep everything else.
|
|
324
|
+
|
|
308
325
|
## Rationalizations
|
|
309
326
|
|
|
310
327
|
| Excuse | Reality |
|
|
@@ -396,9 +396,13 @@ again, and it looks like enforcement while being a mirror.
|
|
|
396
396
|
Three moments the rail cannot show, recorded by `hooks/run-lifecycle.sh` as
|
|
397
397
|
|
|
398
398
|
```
|
|
399
|
-
event: <compact|session-end|subagent> — <detail> — <ISO-8601>
|
|
399
|
+
event: <compact|session-end|subagent|memory> — <detail> — <ISO-8601>
|
|
400
400
|
```
|
|
401
401
|
|
|
402
|
+
The fourth kind, `memory`, is appended by `scripts/stage_checkpoint.py` rather than
|
|
403
|
+
by the hook. It records a workflow checkpoint written at a stage boundary, a refusal,
|
|
404
|
+
or that memory was unavailable ([`continuity.md`](continuity.md) → *Part 3*).
|
|
405
|
+
|
|
402
406
|
The rail reads none of them; `checkup` reads `session-end`, which is how an
|
|
403
407
|
abandoned run stops being invisible. Before this the ledger simply stopped at
|
|
404
408
|
whatever stage the session died on — and a stopped ledger is indistinguishable
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""A workflow checkpoint at every stage boundary, built from the run ledger.
|
|
3
|
+
|
|
4
|
+
When the host has Project Observatory's memory tools (`observatory_checkpoint_write`, or
|
|
5
|
+
`memory.checkpoint.write` under memory/0.1), a run writes one checkpoint each time a gate
|
|
6
|
+
returns. A session that runs out of quota, is compacted or is replaced by another agent then
|
|
7
|
+
continues from the last finished stage instead of re-deriving it. Without those tools the
|
|
8
|
+
run goes on exactly as before, and the ledger says memory was unavailable.
|
|
9
|
+
|
|
10
|
+
This script never talks to Observatory: the agent calls the tool. The script does the two
|
|
11
|
+
deterministic halves around that call, so nothing about the checkpoint is left to recall:
|
|
12
|
+
|
|
13
|
+
stage_checkpoint.py emit [--ledger PATH] [--project project:<slug>]
|
|
14
|
+
[--constraint TEXT ...] [--credential PROJECT/ENV/NAME ...]
|
|
15
|
+
prints the tool's arguments as JSON, from the ledger's last `stage:` line
|
|
16
|
+
stage_checkpoint.py record --answer FILE|- [--ledger PATH]
|
|
17
|
+
reads the tool's answer and keeps `workflowId` and `leaseId` for the next boundary
|
|
18
|
+
stage_checkpoint.py record --unavailable "<reason>" [--ledger PATH]
|
|
19
|
+
the tools are absent or failed: one ledger line, exit 0, the run continues
|
|
20
|
+
stage_checkpoint.py state [--ledger PATH]
|
|
21
|
+
the workflow this run writes to, for `observatory_checkpoint_latest` after a resume
|
|
22
|
+
|
|
23
|
+
What it keeps, and where:
|
|
24
|
+
|
|
25
|
+
- The arguments carry no prose the ledger does not hold: the run's topic is the goal, the
|
|
26
|
+
ledger's verdicts are `done`, the next stage is `open`, the operator's constraints come in
|
|
27
|
+
as `--constraint`, keys are passed by NAME only (`--credential`), and the git checkout is
|
|
28
|
+
an artifact (branch and a 12-character head). Observatory redacts what it stores as well.
|
|
29
|
+
- `idempotencyKey` is derived from the run's topic and the exact `stage:` line, so a retry
|
|
30
|
+
of the same boundary replays the first answer, and a stage that runs again (a new line, a
|
|
31
|
+
new time) is a new checkpoint.
|
|
32
|
+
- `workflowId` and `leaseId` live in `.task-pipeline/memory.json`, mode 0600, beside the
|
|
33
|
+
ledger in the git-ignored run directory, with the constraints and key names given so far:
|
|
34
|
+
they are stated once and carried to every later boundary. The lease token is a write right: it is never
|
|
35
|
+
printed, never appended to the ledger, never put in a commit.
|
|
36
|
+
- A refused write (`LeaseLost`: another executor holds the workflow now) is recorded with
|
|
37
|
+
the episode Observatory kept (`keptAs`), the token is dropped, and the run is told to read
|
|
38
|
+
the workflow before writing again. A stale writer therefore stops after one refusal.
|
|
39
|
+
|
|
40
|
+
Ledger lines it appends, all of the existing `event:` shape:
|
|
41
|
+
|
|
42
|
+
event: memory — checkpoint <stepId> rev <n> <workflowId> — <ISO-8601>
|
|
43
|
+
event: memory — refused <error> (kept as <id>) — <ISO-8601>
|
|
44
|
+
event: memory — unavailable — <reason> — <ISO-8601>
|
|
45
|
+
|
|
46
|
+
Exit codes: 0 done (including "unavailable", which is a state, not a failure); 2 the ledger
|
|
47
|
+
or the answer cannot be read.
|
|
48
|
+
"""
|
|
49
|
+
from __future__ import annotations
|
|
50
|
+
|
|
51
|
+
import argparse
|
|
52
|
+
import hashlib
|
|
53
|
+
import json
|
|
54
|
+
import os
|
|
55
|
+
import pathlib
|
|
56
|
+
import re
|
|
57
|
+
import subprocess
|
|
58
|
+
import sys
|
|
59
|
+
from datetime import datetime, timezone
|
|
60
|
+
|
|
61
|
+
DEFAULT_LEDGER = ".task-pipeline/run.md"
|
|
62
|
+
STATE_NAME = "memory.json"
|
|
63
|
+
OWNER = "agent:task-pipeline"
|
|
64
|
+
STAGE = re.compile(r"^stage:\s*(\d+)\s+(.+?)\s+—\s+gate\s+(\S+)\s+—\s+verdict\s+(\S+)\s+—\s+(\S+)\s*$")
|
|
65
|
+
TOPIC = re.compile(r"^Run:\s*`([^`]+)`")
|
|
66
|
+
STATUS = {"pass": "done", "skip": "done", "fail": "blocked"}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _stages() -> dict[int, str]:
|
|
70
|
+
"""The stage names by id, from the bundle's own `pipeline.example.json` — the file the
|
|
71
|
+
validator compares with `references/stages.md`, so this list cannot drift from it."""
|
|
72
|
+
path = pathlib.Path(__file__).resolve().parents[1] / "pipeline.example.json"
|
|
73
|
+
try:
|
|
74
|
+
doc = json.loads(path.read_text(encoding="utf-8"))
|
|
75
|
+
return {int(s["id"]): str(s["name"]) for s in doc["stages"]}
|
|
76
|
+
except (OSError, ValueError, KeyError, TypeError):
|
|
77
|
+
return {}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _now() -> str:
|
|
81
|
+
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _read_ledger(path: pathlib.Path) -> tuple[str, list[tuple]]:
|
|
85
|
+
try:
|
|
86
|
+
text = path.read_text(encoding="utf-8")
|
|
87
|
+
except OSError as exc:
|
|
88
|
+
raise SystemExit(f"stage_checkpoint: the ledger {path} cannot be read ({type(exc).__name__})")
|
|
89
|
+
topic, stages = None, []
|
|
90
|
+
for line in text.splitlines():
|
|
91
|
+
m = TOPIC.match(line)
|
|
92
|
+
if m and topic is None and "<topic>" not in m.group(1):
|
|
93
|
+
topic = m.group(1)
|
|
94
|
+
s = STAGE.match(line)
|
|
95
|
+
if s:
|
|
96
|
+
stages.append((int(s.group(1)), s.group(2), s.group(3), s.group(4), s.group(5), line))
|
|
97
|
+
if not topic:
|
|
98
|
+
raise SystemExit("stage_checkpoint: the ledger names no run (`Run: `<topic>`` line)")
|
|
99
|
+
return topic, stages
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _state_path(ledger: pathlib.Path) -> pathlib.Path:
|
|
103
|
+
return ledger.parent / STATE_NAME
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _load_state(ledger: pathlib.Path) -> dict:
|
|
107
|
+
try:
|
|
108
|
+
return json.loads(_state_path(ledger).read_text(encoding="utf-8"))
|
|
109
|
+
except (OSError, ValueError):
|
|
110
|
+
return {}
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _save_state(ledger: pathlib.Path, state: dict) -> None:
|
|
114
|
+
path = _state_path(ledger)
|
|
115
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
116
|
+
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
117
|
+
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
118
|
+
json.dump(state, f)
|
|
119
|
+
os.chmod(path, 0o600)
|
|
120
|
+
ignore = path.parent / ".gitignore"
|
|
121
|
+
lines = ignore.read_text(encoding="utf-8").splitlines() if ignore.exists() else []
|
|
122
|
+
if STATE_NAME not in lines:
|
|
123
|
+
ignore.write_text("\n".join([*lines, STATE_NAME]) + "\n", encoding="utf-8")
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _append(ledger: pathlib.Path, line: str) -> None:
|
|
127
|
+
ledger.parent.mkdir(parents=True, exist_ok=True)
|
|
128
|
+
with ledger.open("a", encoding="utf-8") as f:
|
|
129
|
+
f.write(line + "\n")
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _git(root: pathlib.Path, *args: str) -> str | None:
|
|
133
|
+
try:
|
|
134
|
+
r = subprocess.run(["git", *args], cwd=root, capture_output=True, text=True, timeout=10)
|
|
135
|
+
except (OSError, subprocess.SubprocessError):
|
|
136
|
+
return None
|
|
137
|
+
return r.stdout.strip() if r.returncode == 0 and r.stdout.strip() else None
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _project(root: pathlib.Path, given: str | None) -> str:
|
|
141
|
+
if given:
|
|
142
|
+
return given
|
|
143
|
+
top = _git(root, "rev-parse", "--show-toplevel")
|
|
144
|
+
name = pathlib.Path(top or root).name.lower()
|
|
145
|
+
slug = re.sub(r"[^a-z0-9._-]+", "-", name).strip("-") or "unnamed"
|
|
146
|
+
return f"project:{slug}"
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def emit(ledger: pathlib.Path, project: str | None, constraints: list[str],
|
|
150
|
+
credentials: list[str]) -> dict:
|
|
151
|
+
topic, stages = _read_ledger(ledger)
|
|
152
|
+
names = _stages()
|
|
153
|
+
if not stages:
|
|
154
|
+
raise SystemExit("stage_checkpoint: no `stage:` line yet — a checkpoint follows a gate")
|
|
155
|
+
sid, name, gate, verdict, at, line = stages[-1]
|
|
156
|
+
root = ledger.parent.parent if ledger.parent.name == ".task-pipeline" else ledger.parent
|
|
157
|
+
state = _load_state(ledger)
|
|
158
|
+
# CONSTRAINTS AND KEYS OUTLIVE THE BOUNDARY THEY WERE GIVEN AT. The operator states them
|
|
159
|
+
# once; a later boundary that dropped them would hand a successor an empty list — the
|
|
160
|
+
# one thing a successor must never act without (found by the live receipt, 2026-10-05).
|
|
161
|
+
constraints = list(dict.fromkeys([*state.get("constraints", []), *constraints]))
|
|
162
|
+
credentials = list(dict.fromkeys([*state.get("credentials", []), *credentials]))
|
|
163
|
+
done = [{"step_id": f"stage-{s[0]}", "result": f"{s[1]}: gate {s[2]}, verdict {s[3]} at {s[4]}",
|
|
164
|
+
"evidence": [f"ledger: {ledger.name}"]} for s in stages if s[3] in ("pass", "skip")]
|
|
165
|
+
nxt = sid + 1 if verdict in ("pass", "skip") else sid
|
|
166
|
+
last = max(names) if names else 10
|
|
167
|
+
open_steps = [] if sid >= last and verdict == "pass" else [{
|
|
168
|
+
"step_id": f"stage-{nxt}",
|
|
169
|
+
"next_action": (f"enter stage {nxt} {names.get(nxt, '')}".strip() if nxt != sid
|
|
170
|
+
else f"repair stage {sid} {name}: its gate failed")}]
|
|
171
|
+
creds = []
|
|
172
|
+
for c in credentials:
|
|
173
|
+
parts = c.split("/")
|
|
174
|
+
if len(parts) != 3 or not all(parts):
|
|
175
|
+
raise SystemExit("stage_checkpoint: --credential is PROJECT/ENV/NAME, a name never a value")
|
|
176
|
+
creds.append({"project": parts[0], "env": parts[1], "name": parts[2]})
|
|
177
|
+
artifacts = []
|
|
178
|
+
branch, head = _git(root, "rev-parse", "--abbrev-ref", "HEAD"), _git(root, "rev-parse", "HEAD")
|
|
179
|
+
if head:
|
|
180
|
+
artifacts.append({"kind": "git", "path": str(root), "branch": branch or "", "head": head[:12]})
|
|
181
|
+
body = {"goal": topic,
|
|
182
|
+
"plan": [{"step_id": f"stage-{i}", "title": names[i]} for i in sorted(names)],
|
|
183
|
+
"done": done, "open": open_steps, "constraints": constraints,
|
|
184
|
+
"artifacts": artifacts, "notes": f"task-pipeline stage boundary; run ledger {ledger}"}
|
|
185
|
+
if creds:
|
|
186
|
+
body["credentials"] = creds
|
|
187
|
+
args = {"owner": OWNER,
|
|
188
|
+
"idempotencyKey": "tp-" + hashlib.sha256(f"{topic}\n{line}".encode()).hexdigest()[:32],
|
|
189
|
+
"stepId": f"stage-{sid}", "status": STATUS.get(verdict, "in_progress"), "body": body,
|
|
190
|
+
"close": sid >= last and verdict == "pass"}
|
|
191
|
+
if state.get("workflowId") and state.get("leaseId"):
|
|
192
|
+
args["workflowId"], args["leaseId"] = state["workflowId"], state["leaseId"]
|
|
193
|
+
else:
|
|
194
|
+
args["projectId"] = _project(root, project)
|
|
195
|
+
if constraints != state.get("constraints", []) or credentials != state.get("credentials", []):
|
|
196
|
+
_save_state(ledger, {**state, "constraints": constraints, "credentials": credentials})
|
|
197
|
+
return args
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def record(ledger: pathlib.Path, answer_text: str | None, unavailable: str | None) -> str:
|
|
201
|
+
if unavailable is not None:
|
|
202
|
+
reason = " ".join(unavailable.split())[:200] or "no reason given"
|
|
203
|
+
line = f"event: memory — unavailable — {reason} — {_now()}"
|
|
204
|
+
_append(ledger, line)
|
|
205
|
+
return line
|
|
206
|
+
try:
|
|
207
|
+
answer = json.loads(answer_text or "")
|
|
208
|
+
except ValueError:
|
|
209
|
+
raise SystemExit("stage_checkpoint: the answer is not JSON")
|
|
210
|
+
if not isinstance(answer, dict):
|
|
211
|
+
raise SystemExit("stage_checkpoint: the answer is not an object")
|
|
212
|
+
state = _load_state(ledger)
|
|
213
|
+
if answer.get("error"):
|
|
214
|
+
kept = answer.get("keptAs")
|
|
215
|
+
if answer["error"] in ("LeaseLost", "WorkflowClosed"):
|
|
216
|
+
# A stale writer stops here: the token is dropped and the next boundary starts
|
|
217
|
+
# by reading the workflow, not by writing to it again.
|
|
218
|
+
state.pop("leaseId", None)
|
|
219
|
+
state["stale"] = answer["error"]
|
|
220
|
+
_save_state(ledger, state)
|
|
221
|
+
line = (f"event: memory — refused {answer['error']}"
|
|
222
|
+
f"{f' (kept as {kept})' if kept else ''} — {_now()}")
|
|
223
|
+
_append(ledger, line)
|
|
224
|
+
return line
|
|
225
|
+
wid = answer.get("workflowId") or state.get("workflowId")
|
|
226
|
+
lease = answer.get("leaseId") or state.get("leaseId")
|
|
227
|
+
if not wid:
|
|
228
|
+
raise SystemExit("stage_checkpoint: the answer names no workflowId")
|
|
229
|
+
_save_state(ledger, {**{k: v for k, v in state.items() if k in ("constraints", "credentials")},
|
|
230
|
+
"workflowId": wid, **({"leaseId": lease} if lease else {})})
|
|
231
|
+
rev = (answer.get("checkpoint") or {}).get("revision") or answer.get("revision") or "?"
|
|
232
|
+
step = answer.get("stepId") or "?"
|
|
233
|
+
line = f"event: memory — checkpoint {step} rev {rev} {wid} — {_now()}"
|
|
234
|
+
_append(ledger, line)
|
|
235
|
+
return line
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def main(argv: list[str]) -> int:
|
|
239
|
+
p = argparse.ArgumentParser(prog="stage_checkpoint.py",
|
|
240
|
+
description="A workflow checkpoint at every stage boundary.")
|
|
241
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
|
242
|
+
e = sub.add_parser("emit", help="print the checkpoint tool's arguments")
|
|
243
|
+
e.add_argument("--ledger", default=DEFAULT_LEDGER)
|
|
244
|
+
e.add_argument("--project", help="project:<slug>; default the checkout's folder name")
|
|
245
|
+
e.add_argument("--constraint", action="append", default=[], help="repeatable")
|
|
246
|
+
e.add_argument("--credential", action="append", default=[], help="PROJECT/ENV/NAME, repeatable")
|
|
247
|
+
r = sub.add_parser("record", help="keep the answer, or record that memory is unavailable")
|
|
248
|
+
r.add_argument("--ledger", default=DEFAULT_LEDGER)
|
|
249
|
+
g = r.add_mutually_exclusive_group(required=True)
|
|
250
|
+
g.add_argument("--answer", help="a file with the tool's JSON answer, or - for stdin")
|
|
251
|
+
g.add_argument("--unavailable", help="why the tools could not be used")
|
|
252
|
+
s = sub.add_parser("state", help="the workflow this run writes to; never the token")
|
|
253
|
+
s.add_argument("--ledger", default=DEFAULT_LEDGER)
|
|
254
|
+
a = p.parse_args(argv)
|
|
255
|
+
ledger = pathlib.Path(a.ledger)
|
|
256
|
+
if a.cmd == "emit":
|
|
257
|
+
print(json.dumps(emit(ledger, a.project, a.constraint, a.credential), ensure_ascii=False))
|
|
258
|
+
return 0
|
|
259
|
+
if a.cmd == "state":
|
|
260
|
+
st = _load_state(ledger)
|
|
261
|
+
print(json.dumps({"workflowId": st.get("workflowId"), "holdsLease": bool(st.get("leaseId")),
|
|
262
|
+
"stale": st.get("stale")}))
|
|
263
|
+
return 0
|
|
264
|
+
text = None
|
|
265
|
+
if a.answer is not None:
|
|
266
|
+
text = sys.stdin.read() if a.answer == "-" else pathlib.Path(a.answer).read_text(encoding="utf-8")
|
|
267
|
+
print(record(ledger, text, a.unavailable))
|
|
268
|
+
return 0
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
if __name__ == "__main__":
|
|
272
|
+
sys.exit(main(sys.argv[1:]))
|
|
@@ -69,7 +69,7 @@ hand: <N|10> — task "<quoted>" — done <n> — surfaced <n> — decisions <n
|
|
|
69
69
|
scope <commit>/<env>/<REQ ids> — unverified <n|none-in-scope> (<what, or the literal>)
|
|
70
70
|
holds: <stage id> — <n> (<class: what, owner>; … or "none") — enumerated <n>/8 classes, <unlooked: classes not enumerable>
|
|
71
71
|
gate: <stage id> — command "<cmd>" — exit <N> — <ISO-8601>
|
|
72
|
-
event: <compact|session-end|subagent> — <detail> — <ISO-8601>
|
|
72
|
+
event: <compact|session-end|subagent|memory> — <detail> — <ISO-8601>
|
|
73
73
|
read: references/<file>.md # hook-appended, deduped, UNATTESTED (no writer field)
|
|
74
74
|
```
|
|
75
75
|
|