task-pipeline-skill 1.87.1 → 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 CHANGED
@@ -1,3 +1,55 @@
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
+
1
53
  ## v1.87.1 — the SessionEnd timeout no host gave
2
54
 
3
55
  Codex 0.157 prints `clamping SessionEnd hook timeout to 3s in …/task-pipeline/…/hooks.json`
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.87.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.87.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.87.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