task-pipeline-skill 1.94.1 → 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 (37) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/CONTRIBUTING.md +32 -0
  3. package/HOW-IT-WORKS.md +4 -3
  4. package/README.md +18 -7
  5. package/SKILL-CARD.md +2 -2
  6. package/package.json +1 -1
  7. package/plugins/task-pipeline/.claude-plugin/plugin.json +1 -1
  8. package/plugins/task-pipeline/agents/verifier.md +1 -1
  9. package/plugins/task-pipeline/commands/task-pipeline.md +3 -2
  10. package/plugins/task-pipeline/hooks/release-gate.sh +1 -1
  11. package/plugins/task-pipeline/skills/task-pipeline/SKILL.md +11 -9
  12. package/plugins/task-pipeline/skills/task-pipeline/references/artifacts.md +1 -1
  13. package/plugins/task-pipeline/skills/task-pipeline/references/brainstorm.md +1 -1
  14. package/plugins/task-pipeline/skills/task-pipeline/references/browser.md +1 -1
  15. package/plugins/task-pipeline/skills/task-pipeline/references/certification.md +1 -1
  16. package/plugins/task-pipeline/skills/task-pipeline/references/companion-skills.md +3 -2
  17. package/plugins/task-pipeline/skills/task-pipeline/references/continuity.md +3 -1
  18. package/plugins/task-pipeline/skills/task-pipeline/references/doctrine-map.md +19 -2
  19. package/plugins/task-pipeline/skills/task-pipeline/references/grill.md +1 -1
  20. package/plugins/task-pipeline/skills/task-pipeline/references/portability.md +12 -1
  21. package/plugins/task-pipeline/skills/task-pipeline/references/spec.md +7 -5
  22. package/plugins/task-pipeline/skills/task-pipeline/references/stage-00-intake.md +158 -0
  23. package/plugins/task-pipeline/skills/task-pipeline/references/stage-01-docs-study.md +10 -0
  24. package/plugins/task-pipeline/skills/task-pipeline/references/stage-02-brainstorm.md +52 -0
  25. package/plugins/task-pipeline/skills/task-pipeline/references/stage-03-spec.md +66 -0
  26. package/plugins/task-pipeline/skills/task-pipeline/references/stage-04-plan.md +11 -0
  27. package/plugins/task-pipeline/skills/task-pipeline/references/stage-05-dev.md +55 -0
  28. package/plugins/task-pipeline/skills/task-pipeline/references/stage-06-tests.md +11 -0
  29. package/plugins/task-pipeline/skills/task-pipeline/references/stage-07-lint-deploy.md +15 -0
  30. package/plugins/task-pipeline/skills/task-pipeline/references/stage-08-post-deploy.md +29 -0
  31. package/plugins/task-pipeline/skills/task-pipeline/references/stage-09-docs-wiki.md +45 -0
  32. package/plugins/task-pipeline/skills/task-pipeline/references/stage-10-acceptance.md +91 -0
  33. package/plugins/task-pipeline/skills/task-pipeline/references/stages.md +43 -482
  34. package/plugins/task-pipeline/skills/task-pipeline/references/verification.md +1 -1
  35. package/plugins/task-pipeline/skills/task-pipeline/scripts/graph.py +1 -1
  36. package/plugins/task-pipeline/skills/task-pipeline/scripts/stage_checkpoint.py +156 -33
  37. package/plugins/task-pipeline/skills/task-pipeline/templates/run.md +2 -2
@@ -10,9 +10,9 @@ 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
18
  stage_checkpoint.py record --unavailable "<reason>" [--for-run] [--ledger PATH]
@@ -24,13 +24,17 @@ deterministic halves around that call, so nothing about the checkpoint is left t
24
24
 
25
25
  What it keeps, and where:
26
26
 
27
- - 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
28
29
  ledger's verdicts are `done`, the next stage is `open`, the operator's constraints come in
29
30
  as `--constraint`, keys are passed by NAME only (`--credential`), and the git checkout is
30
31
  an artifact (branch and a 12-character head). Observatory redacts what it stores as well.
31
- - `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
32
34
  of the same boundary replays the first answer, and a stage that runs again (a new line, a
33
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.
34
38
  - `workflowId` and `leaseId` live in `.task-pipeline/memory.json`, mode 0600, beside the
35
39
  ledger in the git-ignored run directory, with the constraints and key names given so far:
36
40
  they are stated once and carried to every later boundary. The lease token is a write right: it is never
@@ -60,6 +64,7 @@ import json
60
64
  import os
61
65
  import pathlib
62
66
  import re
67
+ import stat
63
68
  import subprocess
64
69
  import sys
65
70
  from datetime import datetime, timezone
@@ -80,15 +85,70 @@ MEMORY_EVENT = re.compile(r"^event:\s*memory\s+—\s")
80
85
  MEMORY_UNAVAILABLE = re.compile(r"^event:\s*memory\s+—\s+unavailable\b")
81
86
 
82
87
 
83
- def _stages() -> dict[int, str]:
84
- """The stage names by id, from the bundle's own `pipeline.example.json` — the file the
85
- validator compares with `references/stages.md`, so this list cannot drift from it."""
86
- 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"
87
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")
88
103
  doc = json.loads(path.read_text(encoding="utf-8"))
89
- return {int(s["id"]): str(s["name"]) for s in doc["stages"]}
90
- except (OSError, ValueError, KeyError, TypeError):
91
- 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()
92
152
 
93
153
 
94
154
  def _now() -> str:
@@ -145,6 +205,8 @@ def _read_ledger(path: pathlib.Path) -> tuple[str, list[tuple]]:
145
205
  topic, stages = (TOPIC.match(rl).group(1) if rl else None), []
146
206
  for line in lines:
147
207
  s = STAGE.match(line)
208
+ if line.startswith("stage:") and not s:
209
+ raise SystemExit("stage_checkpoint: malformed stage ledger line")
148
210
  if s:
149
211
  stages.append((int(s.group(1)), s.group(2), s.group(3), s.group(4), s.group(5), line))
150
212
  if not topic:
@@ -176,14 +238,48 @@ def marker_for_this_run(ledger: pathlib.Path, state: dict) -> dict | None:
176
238
  return gone
177
239
 
178
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
+
179
257
  def _state_path(ledger: pathlib.Path) -> pathlib.Path:
180
- 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
181
270
 
182
271
 
183
- def _load_state(ledger: pathlib.Path) -> dict:
272
+ def _load_state(ledger: pathlib.Path, strict: bool = False) -> dict:
184
273
  try:
185
- return json.loads(_state_path(ledger).read_text(encoding="utf-8"))
186
- 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")
187
283
  return {}
188
284
 
189
285
 
@@ -224,14 +320,28 @@ def _project(root: pathlib.Path, given: str | None) -> str:
224
320
 
225
321
 
226
322
  def emit(ledger: pathlib.Path, project: str | None, constraints: list[str],
227
- credentials: list[str]) -> dict:
323
+ credentials: list[str], project_root: str | None = None) -> dict:
324
+ ledger = _canonical_ledger(ledger)
228
325
  topic, stages = _read_ledger(ledger)
229
- names = _stages()
326
+ root = _root(ledger, project_root)
327
+ profile, identity = _profile(root)
328
+ names = {s["id"]: s["name"] for s in profile}
230
329
  if not stages:
231
330
  raise SystemExit("stage_checkpoint: no `stage:` line yet — a checkpoint follows a gate")
232
331
  sid, name, gate, verdict, at, line = stages[-1]
233
- root = ledger.parent.parent if ledger.parent.name == ".task-pipeline" else ledger.parent
234
- 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")
235
345
  # CONSTRAINTS AND KEYS OUTLIVE THE BOUNDARY THEY WERE GIVEN AT. The operator states them
236
346
  # once; a later boundary that dropped them would hand a successor an empty list — the
237
347
  # one thing a successor must never act without (found by the live receipt, 2026-10-05).
@@ -241,12 +351,18 @@ def emit(ledger: pathlib.Path, project: str | None, constraints: list[str],
241
351
  done = [{"step_id": f"stage-{s[0]}", "result": f"{s[1]}: gate {s[2]}, verdict {s[3]} at {s[4]}",
242
352
  "evidence": [f"ledger: {ledger.name}", *reviews.get(s[0], [])]}
243
353
  for s in stages if s[3] in ("pass", "skip")]
244
- nxt = sid + 1 if verdict in ("pass", "skip") else sid
245
- last = max(names) if names else 10
246
- open_steps = [] if sid >= last and verdict == "pass" else [{
247
- "step_id": f"stage-{nxt}",
248
- "next_action": (f"enter stage {nxt} {names.get(nxt, '')}".strip() if nxt != sid
249
- 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}]
250
366
  creds = []
251
367
  for c in credentials:
252
368
  parts = c.split("/")
@@ -258,21 +374,26 @@ def emit(ledger: pathlib.Path, project: str | None, constraints: list[str],
258
374
  if head:
259
375
  artifacts.append({"kind": "git", "path": str(root), "branch": branch or "", "head": head[:12]})
260
376
  body = {"goal": topic,
261
- "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],
262
378
  "done": done, "open": open_steps, "constraints": constraints,
263
379
  "artifacts": artifacts, "notes": f"task-pipeline stage boundary; run ledger {ledger}"}
264
380
  if creds:
265
381
  body["credentials"] = creds
382
+ key = f"{topic}\n{line}"
383
+ if identity["source"] != "bundled-default":
384
+ key += "\n" + identity["digest"]
266
385
  args = {"owner": OWNER,
267
- "idempotencyKey": "tp-" + hashlib.sha256(f"{topic}\n{line}".encode()).hexdigest()[:32],
386
+ "idempotencyKey": "tp-" + hashlib.sha256(key.encode()).hexdigest()[:32],
268
387
  "stepId": f"stage-{sid}", "status": STATUS.get(verdict, "in_progress"), "body": body,
269
- "close": sid >= last and verdict == "pass"}
388
+ "close": closed}
270
389
  if state.get("workflowId") and state.get("leaseId"):
271
390
  args["workflowId"], args["leaseId"] = state["workflowId"], state["leaseId"]
272
391
  else:
273
392
  args["projectId"] = _project(root, project)
274
- if constraints != state.get("constraints", []) or credentials != state.get("credentials", []):
275
- _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})
276
397
  return args
277
398
 
278
399
 
@@ -344,7 +465,7 @@ def record(ledger: pathlib.Path, answer_text: str | None, unavailable: str | Non
344
465
  lease = answer.get("leaseId") or state.get("leaseId")
345
466
  if not wid:
346
467
  raise SystemExit("stage_checkpoint: the answer names no workflowId")
347
- _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")},
348
469
  "workflowId": wid, **({"leaseId": lease} if lease else {})})
349
470
  rev = (answer.get("checkpoint") or {}).get("revision") or answer.get("revision") or "?"
350
471
  step = answer.get("stepId") or "?"
@@ -406,6 +527,7 @@ def main(argv: list[str]) -> int:
406
527
  sub = p.add_subparsers(dest="cmd", required=True)
407
528
  e = sub.add_parser("emit", help="print the checkpoint tool's arguments")
408
529
  e.add_argument("--ledger", default=DEFAULT_LEDGER)
530
+ e.add_argument("--project-root", help="explicit project root for a relocated ledger")
409
531
  e.add_argument("--project", help="project:<slug>; default the checkout's folder name")
410
532
  e.add_argument("--constraint", action="append", default=[], help="repeatable")
411
533
  e.add_argument("--credential", action="append", default=[], help="PROJECT/ENV/NAME, repeatable")
@@ -430,9 +552,10 @@ def main(argv: list[str]) -> int:
430
552
  ac.add_argument("--handoff-id", required=True)
431
553
  ac.add_argument("--session")
432
554
  a = p.parse_args(argv)
433
- 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
434
557
  if a.cmd == "emit":
435
- 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))
436
559
  return 0
437
560
  if a.cmd == "handoff":
438
561
  print(json.dumps(handoff(ledger, a.to_provider, a.to_model, a.reason, a.offer_ttl),
@@ -20,7 +20,7 @@ Run: `<topic>` · started `<YYYY-MM-DD>` · module map: `<path or "none">`
20
20
 
21
21
  ## `read:` — which doctrine this run actually opened
22
22
 
23
- The bundle is 41 reference files and nothing recorded which of them a run read, so **a
23
+ The bundle is 52 reference files and nothing recorded which of them a run read, so **a
24
24
  skipped file and a read one were indistinguishable** — the class every guard in this
25
25
  pipeline exists to catch, left standing over the doctrine itself.
26
26
 
@@ -48,7 +48,7 @@ same claim one level down.
48
48
  |---|---|---|
49
49
  | `unmeasured — no run ledger` | there is no ledger | nothing to read from |
50
50
  | `unmeasured — the ledger carries no read: lines` | the hook is absent, **or** the run opened no doctrine | two opposite facts, and the ledger cannot separate them, so neither is claimed |
51
- | `N of 41 reference files read — unattested`, then each unread one | the hook is installed and fired | the count alone says there is a gap, not where — and `unattested` says the ledger cannot name who wrote the lines |
51
+ | `N of 52 reference files read — unattested`, then each unread one | the hook is installed and fired | the count alone says there is a gap, not where — and `unattested` says the ledger cannot name who wrote the lines |
52
52
 
53
53
  **It is a disclosure: no floor, no direction, never a target.** A run that needs four files
54
54
  and reads four is not worse than one that reads thirty — and the moment the number becomes