task-pipeline-skill 1.93.3 → 1.95.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.
Files changed (69) hide show
  1. package/CHANGELOG.md +145 -0
  2. package/CONTRIBUTING.md +32 -0
  3. package/HOW-IT-WORKS.md +4 -3
  4. package/README.md +59 -15
  5. package/SKILL-CARD.md +2 -2
  6. package/bin/task-pipeline.js +64 -33
  7. package/cursor/rules/task-pipeline.mdc +10 -5
  8. package/evals/RESULTS.md +1 -1
  9. package/evals/task-pipeline.evals.json +16 -0
  10. package/package.json +3 -3
  11. package/plugins/task-pipeline/.claude-plugin/plugin.json +1 -1
  12. package/plugins/task-pipeline/agents/verifier.md +5 -2
  13. package/plugins/task-pipeline/commands/task-pipeline.md +25 -10
  14. package/plugins/task-pipeline/hooks/memory-nudge.sh +45 -3
  15. package/plugins/task-pipeline/hooks/release-gate.sh +1 -1
  16. package/plugins/task-pipeline/skills/evidence-docs/references/gates.md +6 -3
  17. package/plugins/task-pipeline/skills/evidence-docs/references/hooks.md +2 -1
  18. package/plugins/task-pipeline/skills/evidence-docs/references/retrospective.md +26 -1
  19. package/plugins/task-pipeline/skills/task-pipeline/SKILL.md +24 -20
  20. package/plugins/task-pipeline/skills/task-pipeline/graph.schema.json +89 -4
  21. package/plugins/task-pipeline/skills/task-pipeline/pipeline.schema.json +7 -0
  22. package/plugins/task-pipeline/skills/task-pipeline/references/artifacts.md +1 -1
  23. package/plugins/task-pipeline/skills/task-pipeline/references/backlog.md +25 -0
  24. package/plugins/task-pipeline/skills/task-pipeline/references/brainstorm.md +1 -1
  25. package/plugins/task-pipeline/skills/task-pipeline/references/browser.md +1 -1
  26. package/plugins/task-pipeline/skills/task-pipeline/references/certification.md +46 -2
  27. package/plugins/task-pipeline/skills/task-pipeline/references/companion-skills.md +3 -2
  28. package/plugins/task-pipeline/skills/task-pipeline/references/continuity.md +97 -26
  29. package/plugins/task-pipeline/skills/task-pipeline/references/deploy-targets.md +30 -4
  30. package/plugins/task-pipeline/skills/task-pipeline/references/doctrine-map.md +20 -2
  31. package/plugins/task-pipeline/skills/task-pipeline/references/gates.md +6 -3
  32. package/plugins/task-pipeline/skills/task-pipeline/references/grill.md +2 -2
  33. package/plugins/task-pipeline/skills/task-pipeline/references/hooks.md +2 -1
  34. package/plugins/task-pipeline/skills/task-pipeline/references/iteration.md +387 -0
  35. package/plugins/task-pipeline/skills/task-pipeline/references/knowledge-sources.md +1 -0
  36. package/plugins/task-pipeline/skills/task-pipeline/references/loop-guard.md +4 -1
  37. package/plugins/task-pipeline/skills/task-pipeline/references/portability.md +13 -1
  38. package/plugins/task-pipeline/skills/task-pipeline/references/retrospective.md +26 -1
  39. package/plugins/task-pipeline/skills/task-pipeline/references/spec.md +7 -5
  40. package/plugins/task-pipeline/skills/task-pipeline/references/stage-00-intake.md +158 -0
  41. package/plugins/task-pipeline/skills/task-pipeline/references/stage-01-docs-study.md +10 -0
  42. package/plugins/task-pipeline/skills/task-pipeline/references/stage-02-brainstorm.md +52 -0
  43. package/plugins/task-pipeline/skills/task-pipeline/references/stage-03-spec.md +66 -0
  44. package/plugins/task-pipeline/skills/task-pipeline/references/stage-04-plan.md +11 -0
  45. package/plugins/task-pipeline/skills/task-pipeline/references/stage-05-dev.md +55 -0
  46. package/plugins/task-pipeline/skills/task-pipeline/references/stage-06-tests.md +11 -0
  47. package/plugins/task-pipeline/skills/task-pipeline/references/stage-07-lint-deploy.md +15 -0
  48. package/plugins/task-pipeline/skills/task-pipeline/references/stage-08-post-deploy.md +29 -0
  49. package/plugins/task-pipeline/skills/task-pipeline/references/stage-09-docs-wiki.md +45 -0
  50. package/plugins/task-pipeline/skills/task-pipeline/references/stage-10-acceptance.md +91 -0
  51. package/plugins/task-pipeline/skills/task-pipeline/references/stages.md +60 -475
  52. package/plugins/task-pipeline/skills/task-pipeline/references/verification.md +1 -1
  53. package/plugins/task-pipeline/skills/task-pipeline/references/waves.md +76 -36
  54. package/plugins/task-pipeline/skills/task-pipeline/references/work-graph.md +54 -5
  55. package/plugins/task-pipeline/skills/task-pipeline/scripts/backlog_sources.py +54 -13
  56. package/plugins/task-pipeline/skills/task-pipeline/scripts/context_packets.py +3 -3
  57. package/plugins/task-pipeline/skills/task-pipeline/scripts/graph.py +205 -22
  58. package/plugins/task-pipeline/skills/task-pipeline/scripts/lessons.py +1137 -0
  59. package/plugins/task-pipeline/skills/task-pipeline/scripts/stage_checkpoint.py +264 -44
  60. package/plugins/task-pipeline/skills/task-pipeline/scripts/waves.py +546 -0
  61. package/plugins/task-pipeline/skills/task-pipeline/templates/README.md +6 -0
  62. package/plugins/task-pipeline/skills/task-pipeline/templates/backlog-sources.schema.json +96 -0
  63. package/plugins/task-pipeline/skills/task-pipeline/templates/canvas.md +97 -0
  64. package/plugins/task-pipeline/skills/task-pipeline/templates/dossier.md +82 -0
  65. package/plugins/task-pipeline/skills/task-pipeline/templates/inventory.schema.json +94 -0
  66. package/plugins/task-pipeline/skills/task-pipeline/templates/lesson.schema.json +221 -0
  67. package/plugins/task-pipeline/skills/task-pipeline/templates/questions.md +45 -0
  68. package/plugins/task-pipeline/skills/task-pipeline/templates/retro.md +4 -0
  69. package/plugins/task-pipeline/skills/task-pipeline/templates/run.md +2 -2
@@ -10,25 +10,31 @@ run goes on exactly as before, and the ledger says memory was unavailable.
10
10
  This script never talks to Observatory: the agent calls the tool. The script does the two
11
11
  deterministic halves around that call, so nothing about the checkpoint is left to recall:
12
12
 
13
- stage_checkpoint.py emit [--ledger PATH] [--project project:<slug>]
13
+ stage_checkpoint.py emit [--ledger PATH] [--project-root PATH] [--project project:<slug>]
14
14
  [--constraint TEXT ...] [--credential PROJECT/ENV/NAME ...]
15
- prints the tool's arguments as JSON, from the ledger's last `stage:` line
15
+ prints the tool's arguments as JSON, from the ledger and selected project profile
16
16
  stage_checkpoint.py record --answer FILE|- [--ledger PATH]
17
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
18
+ stage_checkpoint.py record --unavailable "<reason>" [--for-run] [--ledger PATH]
19
+ the tools are absent or failed: one ledger line, exit 0, the run continues.
20
+ --for-run says it once for the whole run: a marker in memory.json silences
21
+ hooks/memory-nudge.sh until a later `record --answer` clears it
20
22
  stage_checkpoint.py state [--ledger PATH]
21
23
  the workflow this run writes to, for `observatory_checkpoint_latest` after a resume
22
24
 
23
25
  What it keeps, and where:
24
26
 
25
- - The arguments carry no prose the ledger does not hold: the run's topic is the goal, the
27
+ - The ledger supplies the observed verdicts; the selected project `pipeline.json` supplies
28
+ stage order and names (bundled default only when project config is absent). The run's topic is the goal, the
26
29
  ledger's verdicts are `done`, the next stage is `open`, the operator's constraints come in
27
30
  as `--constraint`, keys are passed by NAME only (`--credential`), and the git checkout is
28
31
  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
32
+ - `idempotencyKey` is derived from the run's topic and the exact `stage:` line (also the
33
+ profile digest for a configured profile), so a retry
30
34
  of the same boundary replays the first answer, and a stage that runs again (a new line, a
31
35
  new time) is a new checkpoint.
36
+ - The first emit saves a run-scoped profile binding before returning arguments. A changed
37
+ profile/run or unreadable saved state refuses instead of switching an existing workflow.
32
38
  - `workflowId` and `leaseId` live in `.task-pipeline/memory.json`, mode 0600, beside the
33
39
  ledger in the git-ignored run directory, with the constraints and key names given so far:
34
40
  they are stated once and carried to every later boundary. The lease token is a write right: it is never
@@ -45,6 +51,7 @@ Ledger lines it appends, all of the existing `event:` shape:
45
51
  event: memory — checkpoint <stepId> rev <n> <workflowId> — <ISO-8601>
46
52
  event: memory — refused <error> (kept as <id>) — <ISO-8601>
47
53
  event: memory — unavailable — <reason> — <ISO-8601>
54
+ event: memory — unavailable for this run — <reason> — <ISO-8601>
48
55
 
49
56
  Exit codes: 0 done (including "unavailable", which is a state, not a failure); 2 the ledger
50
57
  or the answer cannot be read.
@@ -57,6 +64,7 @@ import json
57
64
  import os
58
65
  import pathlib
59
66
  import re
67
+ import stat
60
68
  import subprocess
61
69
  import sys
62
70
  from datetime import datetime, timezone
@@ -77,15 +85,70 @@ MEMORY_EVENT = re.compile(r"^event:\s*memory\s+—\s")
77
85
  MEMORY_UNAVAILABLE = re.compile(r"^event:\s*memory\s+—\s+unavailable\b")
78
86
 
79
87
 
80
- def _stages() -> dict[int, str]:
81
- """The stage names by id, from the bundle's own `pipeline.example.json` — the file the
82
- validator compares with `references/stages.md`, so this list cannot drift from it."""
83
- path = pathlib.Path(__file__).resolve().parents[1] / "pipeline.example.json"
88
+ def _profile(root: pathlib.Path) -> tuple[list[dict], dict]:
89
+ """Admit the selected profile for the numeric checkpoint ledger, not execute gates."""
90
+ path = root / "pipeline.json"
84
91
  try:
92
+ path.lstat()
93
+ configured = True
94
+ except FileNotFoundError:
95
+ configured = False
96
+ except OSError:
97
+ raise SystemExit("stage_checkpoint: selected profile cannot be read")
98
+ if not configured:
99
+ path = pathlib.Path(__file__).resolve().parents[1] / "pipeline.example.json"
100
+ try:
101
+ if not stat.S_ISREG(path.stat().st_mode):
102
+ raise SystemExit("stage_checkpoint: selected profile is not a regular file")
85
103
  doc = json.loads(path.read_text(encoding="utf-8"))
86
- return {int(s["id"]): str(s["name"]) for s in doc["stages"]}
87
- except (OSError, ValueError, KeyError, TypeError):
88
- return {}
104
+ except (OSError, ValueError, UnicodeError):
105
+ raise SystemExit("stage_checkpoint: selected profile cannot be read as JSON")
106
+ if not isinstance(doc, dict) or not isinstance(doc.get("stages"), list) or not doc["stages"]:
107
+ raise SystemExit("stage_checkpoint: selected profile needs a nonempty stages array")
108
+ mandatory = doc.get("must_understand", [])
109
+ if not isinstance(mandatory, list) or mandatory:
110
+ raise SystemExit("stage_checkpoint: unsupported mandatory profile capabilities")
111
+ stages, ids, states = [], set(), set()
112
+ for ordinal, stage in enumerate(doc["stages"]):
113
+ if not isinstance(stage, dict):
114
+ raise SystemExit("stage_checkpoint: malformed selected stage")
115
+ sid, state = stage.get("id", ordinal), stage.get("state")
116
+ name = stage.get("name", state)
117
+ if isinstance(name, str) and not name.strip():
118
+ name = state # optional human label may be empty; stable state still identifies it
119
+ skills, gate = stage.get("skills"), stage.get("gate")
120
+ if (type(sid) is not int or sid < 0 or sid in ids or
121
+ not isinstance(state, str) or not state.strip() or state in states or
122
+ not isinstance(name, str) or not name.strip() or "\n" in name or "\r" in name or
123
+ not isinstance(skills, list) or not skills or
124
+ any(not isinstance(v, str) or not v.strip() for v in skills) or
125
+ not isinstance(gate, dict) or gate.get("type") not in ("auto", "manual", "judgment") or
126
+ not isinstance(gate.get("check"), str) or not gate["check"].strip() or
127
+ (gate["type"] == "judgment" and
128
+ (not isinstance(gate.get("judge"), str) or not gate["judge"].strip()))):
129
+ raise SystemExit("stage_checkpoint: malformed or ambiguous selected stage")
130
+ ids.add(sid)
131
+ states.add(state)
132
+ stages.append({"id": sid, "name": name, "gate": gate["type"]})
133
+ semantic = {k: doc[k] for k in ("stages", "kernel", "must_understand") if k in doc}
134
+ identity = {"root": str(root), "source": "pipeline.json" if configured else "bundled-default",
135
+ "digest": hashlib.sha256(json.dumps(semantic, sort_keys=True,
136
+ ensure_ascii=False).encode()).hexdigest()}
137
+ return stages, identity
138
+
139
+
140
+ def _root(ledger: pathlib.Path, given: str | None) -> pathlib.Path:
141
+ if given:
142
+ root = pathlib.Path(given).resolve()
143
+ if not root.is_dir() or not ledger.is_relative_to(root):
144
+ raise SystemExit("stage_checkpoint: --project-root must contain the ledger")
145
+ return root
146
+ if ledger.parent.name == ".task-pipeline":
147
+ return ledger.parent.parent
148
+ git_root = _git(ledger.parent, "rev-parse", "--show-toplevel")
149
+ if not git_root:
150
+ raise SystemExit("stage_checkpoint: relocated ledger requires --project-root")
151
+ return pathlib.Path(git_root).resolve()
89
152
 
90
153
 
91
154
  def _now() -> str:
@@ -103,17 +166,47 @@ def _reviews(path: pathlib.Path) -> dict[int, list[str]]:
103
166
  return out
104
167
 
105
168
 
169
+ def run_line(lines: list[str]) -> str | None:
170
+ """The run's `Run:` line — the first one naming a real topic, not the template's.
171
+
172
+ ONE helper for everything that asks "which run is this": `_read_ledger` takes the topic
173
+ from it, and `run_key` hashes it for the run-level memory marker and for
174
+ `hooks/memory-nudge.sh`'s once-per-run pointer, which imports this function."""
175
+ for line in lines:
176
+ m = TOPIC.match(line)
177
+ if m and "<topic>" not in m.group(1):
178
+ return line.strip()
179
+ return None
180
+
181
+
182
+ STARTED = re.compile(r"started\s*`([^`]+)`")
183
+
184
+
185
+ def run_key(lines: list[str]) -> str | None:
186
+ """16 hex of the run's topic and start date — not the whole `Run:` line, whose module-map
187
+ field an agent may fill in mid-run. The same topic started on another day is another run;
188
+ a same-day restart is told apart by `marker_for_this_run`'s ledger-line test."""
189
+ line = run_line(lines)
190
+ if not line:
191
+ return None
192
+ topic = TOPIC.match(line).group(1)
193
+ started = STARTED.search(line)
194
+ ident = f"{topic}\n{started.group(1) if started else ''}"
195
+ return hashlib.sha256(ident.encode()).hexdigest()[:16]
196
+
197
+
106
198
  def _read_ledger(path: pathlib.Path) -> tuple[str, list[tuple]]:
107
199
  try:
108
200
  text = path.read_text(encoding="utf-8")
109
201
  except OSError as exc:
110
202
  raise SystemExit(f"stage_checkpoint: the ledger {path} cannot be read ({type(exc).__name__})")
111
- topic, stages = None, []
112
- for line in text.splitlines():
113
- m = TOPIC.match(line)
114
- if m and topic is None and "<topic>" not in m.group(1):
115
- topic = m.group(1)
203
+ lines = text.splitlines()
204
+ rl = run_line(lines)
205
+ topic, stages = (TOPIC.match(rl).group(1) if rl else None), []
206
+ for line in lines:
116
207
  s = STAGE.match(line)
208
+ if line.startswith("stage:") and not s:
209
+ raise SystemExit("stage_checkpoint: malformed stage ledger line")
117
210
  if s:
118
211
  stages.append((int(s.group(1)), s.group(2), s.group(3), s.group(4), s.group(5), line))
119
212
  if not topic:
@@ -121,14 +214,72 @@ def _read_ledger(path: pathlib.Path) -> tuple[str, list[tuple]]:
121
214
  return topic, stages
122
215
 
123
216
 
217
+ class UsageError(Exception):
218
+ """A call the script cannot act on as given: exit 2, nothing written."""
219
+
220
+
221
+ def marker_for_this_run(ledger: pathlib.Path, state: dict) -> dict | None:
222
+ """The run-level "memory unavailable" marker, only when it was written for THIS run.
223
+
224
+ Two tests, both needed: the run key (topic and start date) matches, AND the marker's own
225
+ `unavailable for this run` line is still in the ledger. A ledger reseeded at stage 0 the
226
+ same day carries an identical `Run:` line but not that line, so a restart never inherits
227
+ the previous run's silence (verification round 3)."""
228
+ gone = (state.get("memory") or {}).get("unavailable") or {}
229
+ if not gone:
230
+ return None
231
+ try:
232
+ lines = ledger.read_text(encoding="utf-8").splitlines()
233
+ except OSError:
234
+ return None
235
+ key = run_key(lines)
236
+ if key is None or gone.get("run") != key or gone.get("ledger_line") not in lines:
237
+ return None
238
+ return gone
239
+
240
+
241
+ def _canonical_ledger(ledger: pathlib.Path) -> pathlib.Path:
242
+ # A file alias can put a prior record beside the link while emit read its target.
243
+ # Refuse it rather than choosing one of two potentially live workflow receipts.
244
+ try:
245
+ mode = ledger.lstat().st_mode
246
+ except FileNotFoundError:
247
+ return ledger.resolve()
248
+ except OSError:
249
+ raise SystemExit("stage_checkpoint: ledger cannot be read")
250
+ if stat.S_ISLNK(mode):
251
+ raise SystemExit("stage_checkpoint: ledger file alias is unsupported; reconcile and use the real ledger path")
252
+ if not stat.S_ISREG(mode):
253
+ raise SystemExit("stage_checkpoint: ledger is not a regular file")
254
+ return ledger.resolve()
255
+
256
+
124
257
  def _state_path(ledger: pathlib.Path) -> pathlib.Path:
125
- return ledger.parent / STATE_NAME
258
+ path = _canonical_ledger(ledger).parent / STATE_NAME
259
+ try:
260
+ mode = path.lstat().st_mode
261
+ except FileNotFoundError:
262
+ return path
263
+ except OSError:
264
+ raise SystemExit("stage_checkpoint: memory state cannot be read")
265
+ if stat.S_ISLNK(mode):
266
+ raise SystemExit("stage_checkpoint: memory state link is unsafe; reconcile it before continuing")
267
+ if not stat.S_ISREG(mode):
268
+ raise SystemExit("stage_checkpoint: memory state is not a regular file")
269
+ return path
126
270
 
127
271
 
128
- def _load_state(ledger: pathlib.Path) -> dict:
272
+ def _load_state(ledger: pathlib.Path, strict: bool = False) -> dict:
129
273
  try:
130
- return json.loads(_state_path(ledger).read_text(encoding="utf-8"))
131
- except (OSError, ValueError):
274
+ state = json.loads(_state_path(ledger).read_text(encoding="utf-8"))
275
+ if not isinstance(state, dict):
276
+ raise ValueError("state is not an object")
277
+ return state
278
+ except FileNotFoundError:
279
+ return {}
280
+ except (OSError, ValueError, UnicodeError):
281
+ if strict:
282
+ raise SystemExit("stage_checkpoint: memory state cannot be read")
132
283
  return {}
133
284
 
134
285
 
@@ -169,14 +320,28 @@ def _project(root: pathlib.Path, given: str | None) -> str:
169
320
 
170
321
 
171
322
  def emit(ledger: pathlib.Path, project: str | None, constraints: list[str],
172
- credentials: list[str]) -> dict:
323
+ credentials: list[str], project_root: str | None = None) -> dict:
324
+ ledger = _canonical_ledger(ledger)
173
325
  topic, stages = _read_ledger(ledger)
174
- names = _stages()
326
+ root = _root(ledger, project_root)
327
+ profile, identity = _profile(root)
328
+ names = {s["id"]: s["name"] for s in profile}
175
329
  if not stages:
176
330
  raise SystemExit("stage_checkpoint: no `stage:` line yet — a checkpoint follows a gate")
177
331
  sid, name, gate, verdict, at, line = stages[-1]
178
- root = ledger.parent.parent if ledger.parent.name == ".task-pipeline" else ledger.parent
179
- state = _load_state(ledger)
332
+ state = _load_state(ledger, strict=True)
333
+ run = run_key(ledger.read_text(encoding="utf-8").splitlines())
334
+ binding = {**identity, "run": run}
335
+ previous = state.get("profile")
336
+ if previous is None and state.get("workflowId") and identity["source"] != "bundled-default":
337
+ raise SystemExit("stage_checkpoint: legacy workflow has no selected-profile binding; reconcile before emitting")
338
+ if previous is not None and previous != binding:
339
+ raise SystemExit("stage_checkpoint: selected profile or run changed; reconcile the saved workflow before emitting")
340
+ for observed in stages:
341
+ selected = next((s for s in profile if s["id"] == observed[0]), None)
342
+ if selected is None or (identity["source"] != "bundled-default" and
343
+ (selected["name"] != observed[1] or selected["gate"] != observed[2])):
344
+ raise SystemExit("stage_checkpoint: ledger stage does not match the selected profile")
180
345
  # CONSTRAINTS AND KEYS OUTLIVE THE BOUNDARY THEY WERE GIVEN AT. The operator states them
181
346
  # once; a later boundary that dropped them would hand a successor an empty list — the
182
347
  # one thing a successor must never act without (found by the live receipt, 2026-10-05).
@@ -186,12 +351,18 @@ def emit(ledger: pathlib.Path, project: str | None, constraints: list[str],
186
351
  done = [{"step_id": f"stage-{s[0]}", "result": f"{s[1]}: gate {s[2]}, verdict {s[3]} at {s[4]}",
187
352
  "evidence": [f"ledger: {ledger.name}", *reviews.get(s[0], [])]}
188
353
  for s in stages if s[3] in ("pass", "skip")]
189
- nxt = sid + 1 if verdict in ("pass", "skip") else sid
190
- last = max(names) if names else 10
191
- open_steps = [] if sid >= last and verdict == "pass" else [{
192
- "step_id": f"stage-{nxt}",
193
- "next_action": (f"enter stage {nxt} {names.get(nxt, '')}".strip() if nxt != sid
194
- else f"repair stage {sid} {name}: its gate failed")}]
354
+ position = next(i for i, s in enumerate(profile) if s["id"] == sid)
355
+ terminal = position == len(profile) - 1
356
+ closed = terminal and verdict == "pass"
357
+ if closed:
358
+ open_steps = []
359
+ elif verdict in ("pass", "skip") and not terminal:
360
+ nxt = profile[position + 1]["id"]
361
+ open_steps = [{"step_id": f"stage-{nxt}", "next_action": f"enter stage {nxt} {names[nxt]}"}]
362
+ else:
363
+ action = (f"confirm stage {sid} {name}: final stage was skipped" if verdict == "skip"
364
+ else f"repair stage {sid} {name}: its gate did not pass")
365
+ open_steps = [{"step_id": f"stage-{sid}", "next_action": action}]
195
366
  creds = []
196
367
  for c in credentials:
197
368
  parts = c.split("/")
@@ -203,28 +374,59 @@ def emit(ledger: pathlib.Path, project: str | None, constraints: list[str],
203
374
  if head:
204
375
  artifacts.append({"kind": "git", "path": str(root), "branch": branch or "", "head": head[:12]})
205
376
  body = {"goal": topic,
206
- "plan": [{"step_id": f"stage-{i}", "title": names[i]} for i in sorted(names)],
377
+ "plan": [{"step_id": f"stage-{i}", "title": names[i]} for i in names],
207
378
  "done": done, "open": open_steps, "constraints": constraints,
208
379
  "artifacts": artifacts, "notes": f"task-pipeline stage boundary; run ledger {ledger}"}
209
380
  if creds:
210
381
  body["credentials"] = creds
382
+ key = f"{topic}\n{line}"
383
+ if identity["source"] != "bundled-default":
384
+ key += "\n" + identity["digest"]
211
385
  args = {"owner": OWNER,
212
- "idempotencyKey": "tp-" + hashlib.sha256(f"{topic}\n{line}".encode()).hexdigest()[:32],
386
+ "idempotencyKey": "tp-" + hashlib.sha256(key.encode()).hexdigest()[:32],
213
387
  "stepId": f"stage-{sid}", "status": STATUS.get(verdict, "in_progress"), "body": body,
214
- "close": sid >= last and verdict == "pass"}
388
+ "close": closed}
215
389
  if state.get("workflowId") and state.get("leaseId"):
216
390
  args["workflowId"], args["leaseId"] = state["workflowId"], state["leaseId"]
217
391
  else:
218
392
  args["projectId"] = _project(root, project)
219
- if constraints != state.get("constraints", []) or credentials != state.get("credentials", []):
220
- _save_state(ledger, {**state, "constraints": constraints, "credentials": credentials})
393
+ if (previous != binding or constraints != state.get("constraints", []) or
394
+ credentials != state.get("credentials", [])):
395
+ _save_state(ledger, {**state, "constraints": constraints, "credentials": credentials,
396
+ "profile": binding})
221
397
  return args
222
398
 
223
399
 
224
- def record(ledger: pathlib.Path, answer_text: str | None, unavailable: str | None) -> str:
400
+ def record(ledger: pathlib.Path, answer_text: str | None, unavailable: str | None,
401
+ for_run: bool = False) -> str:
225
402
  if unavailable is not None:
226
403
  reason = " ".join(unavailable.split())[:200] or "no reason given"
227
- line = f"event: memory — unavailable — {reason} — {_now()}"
404
+ if for_run:
405
+ # ONE marker for the run (CHANGELOG, Unreleased). Without it a run on a machine with no
406
+ # Observatory paid one nudge and one ledger line per gate — about eleven of
407
+ # each — to restate a fact that does not change between gates.
408
+ # The marker carries the run key (topic and start date, hashed): the hook and
409
+ # `state` honour it only while the ledger names the same run AND still carries this
410
+ # line, so a marker left on disk — even after a same-day reseed — cannot silence
411
+ # the next run.
412
+ try:
413
+ key = run_key(ledger.read_text(encoding="utf-8").splitlines())
414
+ except OSError:
415
+ key = None
416
+ if key is None:
417
+ raise UsageError("--for-run needs the ledger's `Run:` line: the marker is "
418
+ "keyed to the run, and a ledger that names no run has none")
419
+ line = f"event: memory — unavailable for this run — {reason} — {_now()}"
420
+ state = _load_state(ledger)
421
+ state["memory"] = {"unavailable": {"reason": reason, "at": _now(), "ledger_line": line,
422
+ "run": key}}
423
+ _save_state(ledger, state)
424
+ else:
425
+ line = f"event: memory — unavailable — {reason} — {_now()}"
426
+ state = _load_state(ledger)
427
+ if state.get("memory") and marker_for_this_run(ledger, state) is None:
428
+ state.pop("memory") # another run's marker: cleared, not inherited
429
+ _save_state(ledger, state)
228
430
  _append(ledger, line)
229
431
  return line
230
432
  try:
@@ -234,6 +436,9 @@ def record(ledger: pathlib.Path, answer_text: str | None, unavailable: str | Non
234
436
  if not isinstance(answer, dict):
235
437
  raise SystemExit("stage_checkpoint: the answer is not an object")
236
438
  state = _load_state(ledger)
439
+ # Any answer — a checkpoint or a refusal — means the tools answered: the run-level
440
+ # "unavailable" marker no longer holds, and the nudge comes back.
441
+ cleared = state.pop("memory", None) is not None
237
442
  if answer.get("error"):
238
443
  kept = answer.get("keptAs")
239
444
  if answer["error"] in ("LeaseLost", "WorkflowClosed"):
@@ -244,9 +449,13 @@ def record(ledger: pathlib.Path, answer_text: str | None, unavailable: str | Non
244
449
  _save_state(ledger, state)
245
450
  line = (f"event: memory — refused {answer['error']}"
246
451
  f"{f' (kept as {kept})' if kept else ''} — {_now()}")
452
+ if cleared:
453
+ _save_state(ledger, state)
247
454
  _append(ledger, line)
248
455
  return line
249
456
  if answer.get("handoffId") and not answer.get("leaseId"):
457
+ if cleared:
458
+ _save_state(ledger, state)
250
459
  # An offer: the lease stays with this run until the successor accepts.
251
460
  line = f"event: memory — handoff offered {answer['handoffId']} — {_now()}"
252
461
  _append(ledger, line)
@@ -256,7 +465,7 @@ def record(ledger: pathlib.Path, answer_text: str | None, unavailable: str | Non
256
465
  lease = answer.get("leaseId") or state.get("leaseId")
257
466
  if not wid:
258
467
  raise SystemExit("stage_checkpoint: the answer names no workflowId")
259
- _save_state(ledger, {**{k: v for k, v in state.items() if k in ("constraints", "credentials")},
468
+ _save_state(ledger, {**{k: v for k, v in state.items() if k in ("constraints", "credentials", "profile")},
260
469
  "workflowId": wid, **({"leaseId": lease} if lease else {})})
261
470
  rev = (answer.get("checkpoint") or {}).get("revision") or answer.get("revision") or "?"
262
471
  step = answer.get("stepId") or "?"
@@ -318,6 +527,7 @@ def main(argv: list[str]) -> int:
318
527
  sub = p.add_subparsers(dest="cmd", required=True)
319
528
  e = sub.add_parser("emit", help="print the checkpoint tool's arguments")
320
529
  e.add_argument("--ledger", default=DEFAULT_LEDGER)
530
+ e.add_argument("--project-root", help="explicit project root for a relocated ledger")
321
531
  e.add_argument("--project", help="project:<slug>; default the checkout's folder name")
322
532
  e.add_argument("--constraint", action="append", default=[], help="repeatable")
323
533
  e.add_argument("--credential", action="append", default=[], help="PROJECT/ENV/NAME, repeatable")
@@ -326,6 +536,8 @@ def main(argv: list[str]) -> int:
326
536
  g = r.add_mutually_exclusive_group(required=True)
327
537
  g.add_argument("--answer", help="a file with the tool's JSON answer, or - for stdin")
328
538
  g.add_argument("--unavailable", help="why the tools could not be used")
539
+ r.add_argument("--for-run", action="store_true",
540
+ help="with --unavailable: once for the whole run; silences the nudge until an answer")
329
541
  s = sub.add_parser("state", help="the workflow this run writes to; never the token")
330
542
  s.add_argument("--ledger", default=DEFAULT_LEDGER)
331
543
  h = sub.add_parser("handoff", help="observatory_handoff_create arguments: a PLANNED executor change")
@@ -340,9 +552,10 @@ def main(argv: list[str]) -> int:
340
552
  ac.add_argument("--handoff-id", required=True)
341
553
  ac.add_argument("--session")
342
554
  a = p.parse_args(argv)
343
- ledger = pathlib.Path(a.ledger)
555
+ ledger = _canonical_ledger(pathlib.Path(a.ledger))
556
+ _state_path(ledger) # every command refuses unsafe aliases before producing tool arguments
344
557
  if a.cmd == "emit":
345
- print(json.dumps(emit(ledger, a.project, a.constraint, a.credential), ensure_ascii=False))
558
+ print(json.dumps(emit(ledger, a.project, a.constraint, a.credential, a.project_root), ensure_ascii=False))
346
559
  return 0
347
560
  if a.cmd == "handoff":
348
561
  print(json.dumps(handoff(ledger, a.to_provider, a.to_model, a.reason, a.offer_ttl),
@@ -354,12 +567,19 @@ def main(argv: list[str]) -> int:
354
567
  if a.cmd == "state":
355
568
  st = _load_state(ledger)
356
569
  print(json.dumps({"workflowId": st.get("workflowId"), "holdsLease": bool(st.get("leaseId")),
357
- "stale": st.get("stale")}))
570
+ "stale": st.get("stale"),
571
+ "memoryUnavailable": (marker_for_this_run(ledger, st) or {}).get("reason")}))
358
572
  return 0
359
573
  text = None
360
574
  if a.answer is not None:
361
575
  text = sys.stdin.read() if a.answer == "-" else pathlib.Path(a.answer).read_text(encoding="utf-8")
362
- print(record(ledger, text, a.unavailable))
576
+ if a.for_run and a.unavailable is None:
577
+ p.error("--for-run goes with --unavailable")
578
+ try:
579
+ print(record(ledger, text, a.unavailable, a.for_run))
580
+ except UsageError as exc:
581
+ print(f"stage_checkpoint: {exc}", file=sys.stderr)
582
+ return 2
363
583
  return 0
364
584
 
365
585