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.
- package/CHANGELOG.md +145 -0
- package/CONTRIBUTING.md +32 -0
- package/HOW-IT-WORKS.md +4 -3
- package/README.md +59 -15
- package/SKILL-CARD.md +2 -2
- package/bin/task-pipeline.js +64 -33
- package/cursor/rules/task-pipeline.mdc +10 -5
- package/evals/RESULTS.md +1 -1
- package/evals/task-pipeline.evals.json +16 -0
- package/package.json +3 -3
- package/plugins/task-pipeline/.claude-plugin/plugin.json +1 -1
- package/plugins/task-pipeline/agents/verifier.md +5 -2
- package/plugins/task-pipeline/commands/task-pipeline.md +25 -10
- package/plugins/task-pipeline/hooks/memory-nudge.sh +45 -3
- package/plugins/task-pipeline/hooks/release-gate.sh +1 -1
- package/plugins/task-pipeline/skills/evidence-docs/references/gates.md +6 -3
- package/plugins/task-pipeline/skills/evidence-docs/references/hooks.md +2 -1
- package/plugins/task-pipeline/skills/evidence-docs/references/retrospective.md +26 -1
- package/plugins/task-pipeline/skills/task-pipeline/SKILL.md +24 -20
- package/plugins/task-pipeline/skills/task-pipeline/graph.schema.json +89 -4
- package/plugins/task-pipeline/skills/task-pipeline/pipeline.schema.json +7 -0
- package/plugins/task-pipeline/skills/task-pipeline/references/artifacts.md +1 -1
- package/plugins/task-pipeline/skills/task-pipeline/references/backlog.md +25 -0
- package/plugins/task-pipeline/skills/task-pipeline/references/brainstorm.md +1 -1
- package/plugins/task-pipeline/skills/task-pipeline/references/browser.md +1 -1
- package/plugins/task-pipeline/skills/task-pipeline/references/certification.md +46 -2
- package/plugins/task-pipeline/skills/task-pipeline/references/companion-skills.md +3 -2
- package/plugins/task-pipeline/skills/task-pipeline/references/continuity.md +97 -26
- package/plugins/task-pipeline/skills/task-pipeline/references/deploy-targets.md +30 -4
- package/plugins/task-pipeline/skills/task-pipeline/references/doctrine-map.md +20 -2
- package/plugins/task-pipeline/skills/task-pipeline/references/gates.md +6 -3
- package/plugins/task-pipeline/skills/task-pipeline/references/grill.md +2 -2
- package/plugins/task-pipeline/skills/task-pipeline/references/hooks.md +2 -1
- package/plugins/task-pipeline/skills/task-pipeline/references/iteration.md +387 -0
- package/plugins/task-pipeline/skills/task-pipeline/references/knowledge-sources.md +1 -0
- package/plugins/task-pipeline/skills/task-pipeline/references/loop-guard.md +4 -1
- package/plugins/task-pipeline/skills/task-pipeline/references/portability.md +13 -1
- package/plugins/task-pipeline/skills/task-pipeline/references/retrospective.md +26 -1
- package/plugins/task-pipeline/skills/task-pipeline/references/spec.md +7 -5
- package/plugins/task-pipeline/skills/task-pipeline/references/stage-00-intake.md +158 -0
- package/plugins/task-pipeline/skills/task-pipeline/references/stage-01-docs-study.md +10 -0
- package/plugins/task-pipeline/skills/task-pipeline/references/stage-02-brainstorm.md +52 -0
- package/plugins/task-pipeline/skills/task-pipeline/references/stage-03-spec.md +66 -0
- package/plugins/task-pipeline/skills/task-pipeline/references/stage-04-plan.md +11 -0
- package/plugins/task-pipeline/skills/task-pipeline/references/stage-05-dev.md +55 -0
- package/plugins/task-pipeline/skills/task-pipeline/references/stage-06-tests.md +11 -0
- package/plugins/task-pipeline/skills/task-pipeline/references/stage-07-lint-deploy.md +15 -0
- package/plugins/task-pipeline/skills/task-pipeline/references/stage-08-post-deploy.md +29 -0
- package/plugins/task-pipeline/skills/task-pipeline/references/stage-09-docs-wiki.md +45 -0
- package/plugins/task-pipeline/skills/task-pipeline/references/stage-10-acceptance.md +91 -0
- package/plugins/task-pipeline/skills/task-pipeline/references/stages.md +60 -475
- package/plugins/task-pipeline/skills/task-pipeline/references/verification.md +1 -1
- package/plugins/task-pipeline/skills/task-pipeline/references/waves.md +76 -36
- package/plugins/task-pipeline/skills/task-pipeline/references/work-graph.md +54 -5
- package/plugins/task-pipeline/skills/task-pipeline/scripts/backlog_sources.py +54 -13
- package/plugins/task-pipeline/skills/task-pipeline/scripts/context_packets.py +3 -3
- package/plugins/task-pipeline/skills/task-pipeline/scripts/graph.py +205 -22
- package/plugins/task-pipeline/skills/task-pipeline/scripts/lessons.py +1137 -0
- package/plugins/task-pipeline/skills/task-pipeline/scripts/stage_checkpoint.py +264 -44
- package/plugins/task-pipeline/skills/task-pipeline/scripts/waves.py +546 -0
- package/plugins/task-pipeline/skills/task-pipeline/templates/README.md +6 -0
- package/plugins/task-pipeline/skills/task-pipeline/templates/backlog-sources.schema.json +96 -0
- package/plugins/task-pipeline/skills/task-pipeline/templates/canvas.md +97 -0
- package/plugins/task-pipeline/skills/task-pipeline/templates/dossier.md +82 -0
- package/plugins/task-pipeline/skills/task-pipeline/templates/inventory.schema.json +94 -0
- package/plugins/task-pipeline/skills/task-pipeline/templates/lesson.schema.json +221 -0
- package/plugins/task-pipeline/skills/task-pipeline/templates/questions.md +45 -0
- package/plugins/task-pipeline/skills/task-pipeline/templates/retro.md +4 -0
- 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
|
|
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
|
|
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
|
|
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
|
|
81
|
-
"""
|
|
82
|
-
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
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
|
-
|
|
131
|
-
|
|
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
|
-
|
|
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
|
-
|
|
179
|
-
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
|
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(
|
|
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":
|
|
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
|
|
220
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|