task-pipeline-skill 1.93.2 → 1.94.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.
Files changed (49) hide show
  1. package/CHANGELOG.md +151 -1
  2. package/README.md +42 -9
  3. package/SKILL-CARD.md +1 -1
  4. package/bin/task-pipeline.js +64 -33
  5. package/cursor/rules/task-pipeline.mdc +10 -5
  6. package/evals/RESULTS.md +1 -1
  7. package/evals/task-pipeline.evals.json +16 -0
  8. package/package.json +3 -3
  9. package/plugins/task-pipeline/.claude-plugin/plugin.json +1 -1
  10. package/plugins/task-pipeline/agents/verifier.md +4 -1
  11. package/plugins/task-pipeline/commands/task-pipeline.md +22 -8
  12. package/plugins/task-pipeline/hooks/memory-nudge.sh +45 -3
  13. package/plugins/task-pipeline/skills/evidence-docs/references/gates.md +6 -3
  14. package/plugins/task-pipeline/skills/evidence-docs/references/hooks.md +2 -1
  15. package/plugins/task-pipeline/skills/evidence-docs/references/retrospective.md +26 -1
  16. package/plugins/task-pipeline/skills/task-pipeline/SKILL.md +13 -11
  17. package/plugins/task-pipeline/skills/task-pipeline/graph.schema.json +89 -4
  18. package/plugins/task-pipeline/skills/task-pipeline/pipeline.schema.json +7 -0
  19. package/plugins/task-pipeline/skills/task-pipeline/references/backlog.md +25 -0
  20. package/plugins/task-pipeline/skills/task-pipeline/references/certification.md +45 -1
  21. package/plugins/task-pipeline/skills/task-pipeline/references/continuity.md +95 -26
  22. package/plugins/task-pipeline/skills/task-pipeline/references/deploy-targets.md +30 -4
  23. package/plugins/task-pipeline/skills/task-pipeline/references/doctrine-map.md +1 -0
  24. package/plugins/task-pipeline/skills/task-pipeline/references/gates.md +6 -3
  25. package/plugins/task-pipeline/skills/task-pipeline/references/grill.md +1 -1
  26. package/plugins/task-pipeline/skills/task-pipeline/references/hooks.md +2 -1
  27. package/plugins/task-pipeline/skills/task-pipeline/references/iteration.md +387 -0
  28. package/plugins/task-pipeline/skills/task-pipeline/references/knowledge-sources.md +1 -0
  29. package/plugins/task-pipeline/skills/task-pipeline/references/loop-guard.md +4 -1
  30. package/plugins/task-pipeline/skills/task-pipeline/references/portability.md +1 -0
  31. package/plugins/task-pipeline/skills/task-pipeline/references/retrospective.md +26 -1
  32. package/plugins/task-pipeline/skills/task-pipeline/references/stages.md +35 -11
  33. package/plugins/task-pipeline/skills/task-pipeline/references/waves.md +80 -36
  34. package/plugins/task-pipeline/skills/task-pipeline/references/work-graph.md +54 -5
  35. package/plugins/task-pipeline/skills/task-pipeline/scripts/backlog_sources.py +212 -0
  36. package/plugins/task-pipeline/skills/task-pipeline/scripts/context_packets.py +3 -3
  37. package/plugins/task-pipeline/skills/task-pipeline/scripts/graph.py +205 -22
  38. package/plugins/task-pipeline/skills/task-pipeline/scripts/lessons.py +1137 -0
  39. package/plugins/task-pipeline/skills/task-pipeline/scripts/stage_checkpoint.py +120 -15
  40. package/plugins/task-pipeline/skills/task-pipeline/scripts/waves.py +546 -0
  41. package/plugins/task-pipeline/skills/task-pipeline/templates/README.md +6 -0
  42. package/plugins/task-pipeline/skills/task-pipeline/templates/backlog-sources.schema.json +96 -0
  43. package/plugins/task-pipeline/skills/task-pipeline/templates/canvas.md +97 -0
  44. package/plugins/task-pipeline/skills/task-pipeline/templates/dossier.md +82 -0
  45. package/plugins/task-pipeline/skills/task-pipeline/templates/inventory.schema.json +94 -0
  46. package/plugins/task-pipeline/skills/task-pipeline/templates/lesson.schema.json +221 -0
  47. package/plugins/task-pipeline/skills/task-pipeline/templates/questions.md +45 -0
  48. package/plugins/task-pipeline/skills/task-pipeline/templates/retro.md +4 -0
  49. package/plugins/task-pipeline/skills/task-pipeline/templates/run.md +2 -2
@@ -15,8 +15,10 @@ deterministic halves around that call, so nothing about the checkpoint is left t
15
15
  prints the tool's arguments as JSON, from the ledger's last `stage:` line
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
 
@@ -45,6 +47,7 @@ Ledger lines it appends, all of the existing `event:` shape:
45
47
  event: memory — checkpoint <stepId> rev <n> <workflowId> — <ISO-8601>
46
48
  event: memory — refused <error> (kept as <id>) — <ISO-8601>
47
49
  event: memory — unavailable — <reason> — <ISO-8601>
50
+ event: memory — unavailable for this run — <reason> — <ISO-8601>
48
51
 
49
52
  Exit codes: 0 done (including "unavailable", which is a state, not a failure); 2 the ledger
50
53
  or the answer cannot be read.
@@ -71,6 +74,10 @@ TOPIC = re.compile(r"^Run:\s*`([^`]+)`")
71
74
  # passes a surface took is measured at the boundary rather than remembered at the end.
72
75
  REVIEW = re.compile(r"^review:\s*(\d+)\s+—\s+surface\s+(.+?)\s+—\s+rounds\s+(\d+)\s+—\s+(\S+)\s+—\s+\S+\s*$")
73
76
  STATUS = {"pass": "done", "skip": "done", "fail": "blocked"}
77
+ # What `handoff` counts for its idempotency key: the memory events this script appended
78
+ # (checkpoint, refused, handoff offered) — never `unavailable`, which records no state change.
79
+ MEMORY_EVENT = re.compile(r"^event:\s*memory\s+—\s")
80
+ MEMORY_UNAVAILABLE = re.compile(r"^event:\s*memory\s+—\s+unavailable\b")
74
81
 
75
82
 
76
83
  def _stages() -> dict[int, str]:
@@ -99,16 +106,44 @@ def _reviews(path: pathlib.Path) -> dict[int, list[str]]:
99
106
  return out
100
107
 
101
108
 
109
+ def run_line(lines: list[str]) -> str | None:
110
+ """The run's `Run:` line — the first one naming a real topic, not the template's.
111
+
112
+ ONE helper for everything that asks "which run is this": `_read_ledger` takes the topic
113
+ from it, and `run_key` hashes it for the run-level memory marker and for
114
+ `hooks/memory-nudge.sh`'s once-per-run pointer, which imports this function."""
115
+ for line in lines:
116
+ m = TOPIC.match(line)
117
+ if m and "<topic>" not in m.group(1):
118
+ return line.strip()
119
+ return None
120
+
121
+
122
+ STARTED = re.compile(r"started\s*`([^`]+)`")
123
+
124
+
125
+ def run_key(lines: list[str]) -> str | None:
126
+ """16 hex of the run's topic and start date — not the whole `Run:` line, whose module-map
127
+ field an agent may fill in mid-run. The same topic started on another day is another run;
128
+ a same-day restart is told apart by `marker_for_this_run`'s ledger-line test."""
129
+ line = run_line(lines)
130
+ if not line:
131
+ return None
132
+ topic = TOPIC.match(line).group(1)
133
+ started = STARTED.search(line)
134
+ ident = f"{topic}\n{started.group(1) if started else ''}"
135
+ return hashlib.sha256(ident.encode()).hexdigest()[:16]
136
+
137
+
102
138
  def _read_ledger(path: pathlib.Path) -> tuple[str, list[tuple]]:
103
139
  try:
104
140
  text = path.read_text(encoding="utf-8")
105
141
  except OSError as exc:
106
142
  raise SystemExit(f"stage_checkpoint: the ledger {path} cannot be read ({type(exc).__name__})")
107
- topic, stages = None, []
108
- for line in text.splitlines():
109
- m = TOPIC.match(line)
110
- if m and topic is None and "<topic>" not in m.group(1):
111
- topic = m.group(1)
143
+ lines = text.splitlines()
144
+ rl = run_line(lines)
145
+ topic, stages = (TOPIC.match(rl).group(1) if rl else None), []
146
+ for line in lines:
112
147
  s = STAGE.match(line)
113
148
  if s:
114
149
  stages.append((int(s.group(1)), s.group(2), s.group(3), s.group(4), s.group(5), line))
@@ -117,6 +152,30 @@ def _read_ledger(path: pathlib.Path) -> tuple[str, list[tuple]]:
117
152
  return topic, stages
118
153
 
119
154
 
155
+ class UsageError(Exception):
156
+ """A call the script cannot act on as given: exit 2, nothing written."""
157
+
158
+
159
+ def marker_for_this_run(ledger: pathlib.Path, state: dict) -> dict | None:
160
+ """The run-level "memory unavailable" marker, only when it was written for THIS run.
161
+
162
+ Two tests, both needed: the run key (topic and start date) matches, AND the marker's own
163
+ `unavailable for this run` line is still in the ledger. A ledger reseeded at stage 0 the
164
+ same day carries an identical `Run:` line but not that line, so a restart never inherits
165
+ the previous run's silence (verification round 3)."""
166
+ gone = (state.get("memory") or {}).get("unavailable") or {}
167
+ if not gone:
168
+ return None
169
+ try:
170
+ lines = ledger.read_text(encoding="utf-8").splitlines()
171
+ except OSError:
172
+ return None
173
+ key = run_key(lines)
174
+ if key is None or gone.get("run") != key or gone.get("ledger_line") not in lines:
175
+ return None
176
+ return gone
177
+
178
+
120
179
  def _state_path(ledger: pathlib.Path) -> pathlib.Path:
121
180
  return ledger.parent / STATE_NAME
122
181
 
@@ -217,10 +276,36 @@ def emit(ledger: pathlib.Path, project: str | None, constraints: list[str],
217
276
  return args
218
277
 
219
278
 
220
- def record(ledger: pathlib.Path, answer_text: str | None, unavailable: str | None) -> str:
279
+ def record(ledger: pathlib.Path, answer_text: str | None, unavailable: str | None,
280
+ for_run: bool = False) -> str:
221
281
  if unavailable is not None:
222
282
  reason = " ".join(unavailable.split())[:200] or "no reason given"
223
- line = f"event: memory — unavailable — {reason} — {_now()}"
283
+ if for_run:
284
+ # ONE marker for the run (CHANGELOG, Unreleased). Without it a run on a machine with no
285
+ # Observatory paid one nudge and one ledger line per gate — about eleven of
286
+ # each — to restate a fact that does not change between gates.
287
+ # The marker carries the run key (topic and start date, hashed): the hook and
288
+ # `state` honour it only while the ledger names the same run AND still carries this
289
+ # line, so a marker left on disk — even after a same-day reseed — cannot silence
290
+ # the next run.
291
+ try:
292
+ key = run_key(ledger.read_text(encoding="utf-8").splitlines())
293
+ except OSError:
294
+ key = None
295
+ if key is None:
296
+ raise UsageError("--for-run needs the ledger's `Run:` line: the marker is "
297
+ "keyed to the run, and a ledger that names no run has none")
298
+ line = f"event: memory — unavailable for this run — {reason} — {_now()}"
299
+ state = _load_state(ledger)
300
+ state["memory"] = {"unavailable": {"reason": reason, "at": _now(), "ledger_line": line,
301
+ "run": key}}
302
+ _save_state(ledger, state)
303
+ else:
304
+ line = f"event: memory — unavailable — {reason} — {_now()}"
305
+ state = _load_state(ledger)
306
+ if state.get("memory") and marker_for_this_run(ledger, state) is None:
307
+ state.pop("memory") # another run's marker: cleared, not inherited
308
+ _save_state(ledger, state)
224
309
  _append(ledger, line)
225
310
  return line
226
311
  try:
@@ -230,6 +315,9 @@ def record(ledger: pathlib.Path, answer_text: str | None, unavailable: str | Non
230
315
  if not isinstance(answer, dict):
231
316
  raise SystemExit("stage_checkpoint: the answer is not an object")
232
317
  state = _load_state(ledger)
318
+ # Any answer — a checkpoint or a refusal — means the tools answered: the run-level
319
+ # "unavailable" marker no longer holds, and the nudge comes back.
320
+ cleared = state.pop("memory", None) is not None
233
321
  if answer.get("error"):
234
322
  kept = answer.get("keptAs")
235
323
  if answer["error"] in ("LeaseLost", "WorkflowClosed"):
@@ -240,9 +328,13 @@ def record(ledger: pathlib.Path, answer_text: str | None, unavailable: str | Non
240
328
  _save_state(ledger, state)
241
329
  line = (f"event: memory — refused {answer['error']}"
242
330
  f"{f' (kept as {kept})' if kept else ''} — {_now()}")
331
+ if cleared:
332
+ _save_state(ledger, state)
243
333
  _append(ledger, line)
244
334
  return line
245
335
  if answer.get("handoffId") and not answer.get("leaseId"):
336
+ if cleared:
337
+ _save_state(ledger, state)
246
338
  # An offer: the lease stays with this run until the successor accepts.
247
339
  line = f"event: memory — handoff offered {answer['handoffId']} — {_now()}"
248
340
  _append(ledger, line)
@@ -276,11 +368,15 @@ def handoff(ledger: pathlib.Path, provider: str, model: str | None, reason: str,
276
368
  "(emit → observatory_checkpoint_write → record)")
277
369
  to = {"provider": provider, **({"model": model} if model else {})}
278
370
  # Observatory digests the whole request, TTL included, so the TTL is in the key (another
279
- # TTL is another offer, not an idempotency conflict). So is the ledger's length: once an
280
- # offer is recorded the ledger grows, and offering again after it lapsed is a new offer —
281
- # the same key would replay the first, expired answer. A retry before recording repeats.
371
+ # TTL is another offer, not an idempotency conflict). So is the count of memory events the
372
+ # ledger holds: once an offer (or a checkpoint) is recorded that count grows, and offering
373
+ # again after it lapsed is a new offer — the same key would replay the first, expired
374
+ # answer. ONLY memory events count. The whole ledger length did, until 1.93.3: a `gate:`
375
+ # line from hooks/gate-observer.sh or a `record --unavailable` line between two retries
376
+ # made the retry a NEW offer, and Observatory replaces an unaccepted older offer with it.
282
377
  try:
283
- seen = len(ledger.read_text(encoding="utf-8").splitlines())
378
+ seen = sum(1 for ln in ledger.read_text(encoding="utf-8").splitlines()
379
+ if MEMORY_EVENT.match(ln) and not MEMORY_UNAVAILABLE.match(ln))
284
380
  except OSError:
285
381
  seen = 0
286
382
  key = hashlib.sha256(f"{state['workflowId']}\n{json.dumps(to, sort_keys=True)}\n{reason}"
@@ -318,6 +414,8 @@ def main(argv: list[str]) -> int:
318
414
  g = r.add_mutually_exclusive_group(required=True)
319
415
  g.add_argument("--answer", help="a file with the tool's JSON answer, or - for stdin")
320
416
  g.add_argument("--unavailable", help="why the tools could not be used")
417
+ r.add_argument("--for-run", action="store_true",
418
+ help="with --unavailable: once for the whole run; silences the nudge until an answer")
321
419
  s = sub.add_parser("state", help="the workflow this run writes to; never the token")
322
420
  s.add_argument("--ledger", default=DEFAULT_LEDGER)
323
421
  h = sub.add_parser("handoff", help="observatory_handoff_create arguments: a PLANNED executor change")
@@ -346,12 +444,19 @@ def main(argv: list[str]) -> int:
346
444
  if a.cmd == "state":
347
445
  st = _load_state(ledger)
348
446
  print(json.dumps({"workflowId": st.get("workflowId"), "holdsLease": bool(st.get("leaseId")),
349
- "stale": st.get("stale")}))
447
+ "stale": st.get("stale"),
448
+ "memoryUnavailable": (marker_for_this_run(ledger, st) or {}).get("reason")}))
350
449
  return 0
351
450
  text = None
352
451
  if a.answer is not None:
353
452
  text = sys.stdin.read() if a.answer == "-" else pathlib.Path(a.answer).read_text(encoding="utf-8")
354
- print(record(ledger, text, a.unavailable))
453
+ if a.for_run and a.unavailable is None:
454
+ p.error("--for-run goes with --unavailable")
455
+ try:
456
+ print(record(ledger, text, a.unavailable, a.for_run))
457
+ except UsageError as exc:
458
+ print(f"stage_checkpoint: {exc}", file=sys.stderr)
459
+ return 2
355
460
  return 0
356
461
 
357
462