@mmerterden/multi-agent-pipeline 20.23.1 → 20.24.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 (74) hide show
  1. package/CHANGELOG.md +30 -1
  2. package/README.md +7 -6
  3. package/README.tr.md +6 -5
  4. package/docs/architecture.md +3 -3
  5. package/docs/ecosystem.md +5 -5
  6. package/docs/facts.json +3 -3
  7. package/install/catalog-history.json +1 -1
  8. package/install/templates/claude-hooks.json +14 -1
  9. package/manifest.json +77 -56
  10. package/package.json +1 -1
  11. package/pipeline/commands/multi-agent/analysis-jira/SKILL.md +11 -2
  12. package/pipeline/commands/multi-agent/postmortem/SKILL.md +78 -0
  13. package/pipeline/commands/multi-agent/setup/SKILL.md +1 -1
  14. package/pipeline/commands/multi-agent/sync/SKILL.md +3 -3
  15. package/pipeline/lib/analysis-jira-write.sh +105 -22
  16. package/pipeline/lib/decisions.mjs +130 -0
  17. package/pipeline/lib/plan-todos.sh +156 -33
  18. package/pipeline/lib/task-graph.mjs +446 -0
  19. package/pipeline/lib/tdd-evidence.mjs +579 -0
  20. package/pipeline/multi-agent-refs/analysis-template-corporate.md +2 -0
  21. package/pipeline/multi-agent-refs/analysis-template.md +4 -2
  22. package/pipeline/multi-agent-refs/cross-cli-contract.md +4 -4
  23. package/pipeline/multi-agent-refs/features/analysis-jira.md +51 -3
  24. package/pipeline/multi-agent-refs/features/autopilot-decisions.md +34 -0
  25. package/pipeline/multi-agent-refs/features/model-fallback.md +4 -2
  26. package/pipeline/multi-agent-refs/features/mutation-probe.md +58 -0
  27. package/pipeline/multi-agent-refs/features/plan-checks.md +67 -0
  28. package/pipeline/multi-agent-refs/features/plan-todos.md +40 -1
  29. package/pipeline/multi-agent-refs/features/rework-escalation.md +49 -0
  30. package/pipeline/multi-agent-refs/features/scope-check.md +1 -0
  31. package/pipeline/multi-agent-refs/generate-issue.md +1 -1
  32. package/pipeline/multi-agent-refs/help/en.md +1 -0
  33. package/pipeline/multi-agent-refs/help/tr.md +1 -0
  34. package/pipeline/multi-agent-refs/phases/phase-1-plan.md +2 -3
  35. package/pipeline/multi-agent-refs/phases/phase-2-dev.md +5 -7
  36. package/pipeline/multi-agent-refs/phases/phase-3-review.md +13 -11
  37. package/pipeline/multi-agent-refs/phases/phase-5-report.md +3 -3
  38. package/pipeline/multi-agent-refs/picker-contract.md +3 -2
  39. package/pipeline/multi-agent-refs/response-shape.md +65 -0
  40. package/pipeline/rules/response-shape.md +18 -0
  41. package/pipeline/schemas/agent-state.schema.json +31 -1
  42. package/pipeline/schemas/plan-todos.schema.json +101 -0
  43. package/pipeline/schemas/planning-output.schema.json +46 -0
  44. package/pipeline/schemas/prefs.schema.json +117 -1
  45. package/pipeline/scripts/_jsonl.mjs +151 -0
  46. package/pipeline/scripts/_plan-overlap.mjs +231 -0
  47. package/pipeline/scripts/_redact.mjs +124 -10
  48. package/pipeline/scripts/analysis-story-body.mjs +26 -10
  49. package/pipeline/scripts/analysis-story-tree.mjs +210 -9
  50. package/pipeline/scripts/autopilot-publish.mjs +12 -1
  51. package/pipeline/scripts/cost-analyze.mjs +6 -24
  52. package/pipeline/scripts/decisions-log.mjs +118 -0
  53. package/pipeline/scripts/edit-syntax-gate.mjs +240 -0
  54. package/pipeline/scripts/edit-syntax-gate.sh +29 -0
  55. package/pipeline/scripts/find-polluter.mjs +281 -0
  56. package/pipeline/scripts/gate-ledger.mjs +46 -13
  57. package/pipeline/scripts/mutation-probe.mjs +484 -0
  58. package/pipeline/scripts/plan-complexity.mjs +259 -0
  59. package/pipeline/scripts/review-decision-gate.mjs +33 -1
  60. package/pipeline/scripts/rework-escalation.mjs +264 -0
  61. package/pipeline/scripts/test-strength.mjs +5 -3
  62. package/pipeline/scripts/transcript-index.mjs +950 -0
  63. package/pipeline/scripts/validate-planning.mjs +48 -6
  64. package/pipeline/skills/.skill-manifest.json +11 -7
  65. package/pipeline/skills/.skills-index.json +12 -1
  66. package/pipeline/skills/shared/README.md +4 -3
  67. package/pipeline/skills/shared/core/multi-agent-analysis-jira/SKILL.md +11 -2
  68. package/pipeline/skills/shared/core/multi-agent-postmortem/SKILL.md +66 -0
  69. package/pipeline/skills/shared/core/multi-agent-setup/SKILL.md +1 -1
  70. package/pipeline/skills/shared/core/multi-agent-sync/SKILL.md +3 -3
  71. package/pipeline/skills/shared/external/app-store-optimization/SKILL.md +15 -0
  72. package/pipeline/skills/shared/external/app-store-optimization/evals/evals.json +13 -0
  73. package/pipeline/skills/shared/external/debugging-strategies/SKILL.md +8 -0
  74. package/pipeline/skills/skills-index.md +2 -1
@@ -431,7 +431,7 @@ All tokens are optional in the sense that every service can be answered with Ski
431
431
 
432
432
  ### Step 8 - Enforcement hooks (optional, Claude Code)
433
433
 
434
- Offer to merge `$HOME/.claude/templates/claude-hooks.json`: three `PreToolUse` gates that block on a non-zero exit (secret scan, agent-guard, read-size) plus three capture hooks that block nothing (`PreCompact`, `SessionEnd`, `SessionStart`). What each does: `$HOME/.claude/multi-agent-refs/picker-contract.md`.
434
+ Offer to merge `$HOME/.claude/templates/claude-hooks.json`: three `PreToolUse` gates that block on a non-zero exit (secret scan, agent-guard, read-size) plus four hooks that block nothing (`PreCompact`, `SessionEnd`, `SessionStart`, `PostToolUse`). What each does: `$HOME/.claude/multi-agent-refs/picker-contract.md`.
435
435
 
436
436
  - Ask (picker): "Install the pipeline's hooks into `~/.claude/settings.json`?" Default Yes.
437
437
  - On Yes, deep-merge EVERY event in the template's `hooks` object, not `PreToolUse` alone - merging one event silently drops the capture hooks, and a run killed before Phase 5 then loses its findings exactly as it did before they existed. Preserve existing hooks; never duplicate a matcher already calling the same script.
@@ -73,7 +73,7 @@ Run every step automatically:
73
73
  ```
74
74
  Step 0: DOCTOR doctor.mjs - exit 2 or 4 stops the sync
75
75
  Step 1.5: DETECT Compare timestamps, find stale targets
76
- Step 2: COPILOT Claude Code -> Copilot CLI (instructions + 64 sub-command skills)
76
+ Step 2: COPILOT Claude Code -> Copilot CLI (instructions + 65 sub-command skills)
77
77
  Step 2b: CODEX Claude Code -> Codex CLI (1 router skill + sub-command specs as refs + agent TOML)
78
78
  Step 3: REPO Claude Code -> pipeline repo (genericized, personal data scrub, bash -n on all sh)
79
79
  Step 3c: PLUGINS pipeline shared/external -> multi-agent-plugins marketplace (rebuild knowledge/,
@@ -241,14 +241,14 @@ same specs as reference files rather than as peer skills, via Step 2b - see
241
241
  |-------------|-------------|
242
242
  | `~/.claude/commands/multi-agent/{cmd}/SKILL.md` | `~/.copilot/skills/multi-agent-{cmd}/SKILL.md` |
243
243
 
244
- **64 commands are synced** (canonical inventory - must match `cross-cli-contract.md` section 1; drift = contract violation):
244
+ **65 commands are synced** (canonical inventory - must match `cross-cli-contract.md` section 1; drift = contract violation):
245
245
 
246
246
  ```
247
247
  analysis, analysis-jira, analysis-resolve, autopilot, autopilot-off, autopilot-on,
248
248
  autopilot-status, bug-bash, build-optimize, channels, complaint-analysis, create-jira,
249
249
  design-check, diff-explain, doctor, estimate, feedback, forget, garbage-collect, graph, help,
250
250
  ios-coding-standard, issue, jira, kill, language, log,
251
- manual-test, model, prune-logs, prune-prompts, purge, refactor, research, resume, review, review-analysis, review-issue, review-jira, route-off, route-on, route-status,
251
+ manual-test, model, postmortem, prune-logs, prune-prompts, purge, refactor, research, resume, review, review-analysis, review-issue, review-jira, route-off, route-on, route-status,
252
252
  routines, save, scaffold, scan, scenario-audit, search, security-review, serve, setup, stack, status, steer, store-ready, sync, test,
253
253
  test-accessibility, test-dark-mode, test-dynamic-type, test-screenshots,
254
254
  testflight-validation, uninstall, update
@@ -34,8 +34,16 @@
34
34
  # Jira has no backup path for fields other than description. Rewriting a body an
35
35
  # engineer has since edited would lose that edit with no way back.
36
36
  #
37
+ # A STORY DEPENDENCY IS A LINK, WRITTEN LAST
38
+ #
39
+ # A plan node's `dependsOn` names the stories it waits for. Once every story has
40
+ # a key, each edge becomes an issue link of `prefsUsed.dependencyLinkType`
41
+ # (default Blocks) from the prerequisite to the dependent, checked before it is
42
+ # sent so a re-run adds none.
43
+ #
37
44
  # Usage:
38
45
  # analysis-jira-write.sh --plan plan.json --project KEY [--dry-run] [--ledger FILE] [--epic KEY]
46
+ # analysis-jira-write.sh --subtask-types --project KEY the site's sub-task type names, one per line
39
47
  #
40
48
  # Exit: 0 written (or previewed), 3 the plan does not parse, 4 auth, 5 a Jira
41
49
  # call failed, 64 usage.
@@ -46,7 +54,7 @@ SELF_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
46
54
  # shellcheck source=/dev/null
47
55
  . "$SELF_DIR/_jira-auth.sh"
48
56
 
49
- PLAN=""; PROJECT=""; DRY=0; LEDGER=""; EPIC=""
57
+ PLAN=""; PROJECT=""; DRY=0; LEDGER=""; EPIC=""; LIST_TYPES=0
50
58
  while [ $# -gt 0 ]; do
51
59
  case "$1" in
52
60
  --plan) PLAN="${2:-}"; shift 2 ;;
@@ -54,13 +62,44 @@ while [ $# -gt 0 ]; do
54
62
  --ledger) LEDGER="${2:-}"; shift 2 ;;
55
63
  --dry-run) DRY=1; shift ;;
56
64
  --epic) EPIC="${2:-}"; shift 2 ;;
57
- *) echo "usage: analysis-jira-write.sh --plan FILE --project KEY [--dry-run] [--ledger FILE] [--epic KEY]" >&2; exit 64 ;;
65
+ --subtask-types) LIST_TYPES=1; shift ;;
66
+ *) echo "usage: analysis-jira-write.sh --plan FILE --project KEY [--dry-run] [--ledger FILE] [--epic KEY] | --subtask-types --project KEY" >&2; exit 64 ;;
58
67
  esac
59
68
  done
60
- [ -n "$PLAN" ] && [ -f "$PLAN" ] || { echo "ERR: --plan FILE is required and must exist" >&2; exit 64; }
61
69
  [ -n "$PROJECT" ] || { echo "ERR: --project KEY is required" >&2; exit 64; }
70
+ if [ "$LIST_TYPES" -eq 0 ] && { [ -z "$PLAN" ] || [ ! -f "$PLAN" ]; }; then
71
+ echo "ERR: --plan FILE is required and must exist" >&2; exit 64
72
+ fi
62
73
 
63
74
  command -v jq >/dev/null 2>&1 || { echo "ERR: jq is required" >&2; exit 3; }
75
+
76
+ # Discover rather than assume: the types this site marks subtask:true, under
77
+ # whatever names it gave them. Jira Data Center 9 answers only the per-project
78
+ # createmeta; older sites answer only the classic one. Read once per run.
79
+ SITE_SUBTASK_TYPES=""
80
+ SITE_SUBTASK_READ=0
81
+ site_subtask_types() {
82
+ SITE_SUBTASK_READ=1
83
+ SITE_SUBTASK_TYPES="$(jira_api GET "/rest/api/2/issue/createmeta/${PROJECT}/issuetypes?maxResults=100" 2>/dev/null \
84
+ | jq -r '.values[]? | select(.subtask == true) | .name' 2>/dev/null || echo "")"
85
+ [ -n "$SITE_SUBTASK_TYPES" ] || SITE_SUBTASK_TYPES="$(jira_api GET \
86
+ "/rest/api/2/issue/createmeta?projectKeys=${PROJECT}&expand=projects.issuetypes" 2>/dev/null \
87
+ | jq -r '.projects[]?.issuetypes[]? | select(.subtask == true) | .name' 2>/dev/null || echo "")"
88
+ local n
89
+ n="$(printf '%s' "$SITE_SUBTASK_TYPES" | grep -c . || true)"
90
+ if [ "${n:-0}" -gt 1 ]; then
91
+ echo " note ${PROJECT} has ${n} sub-task types ($(printf '%s' "$SITE_SUBTASK_TYPES" | paste -sd, - | sed 's/,/, /g')); using the first. Set issueTree.subtaskIssueType to a name, or to a {role: type} map, to choose." >&2
92
+ fi
93
+ }
94
+
95
+ if [ "$LIST_TYPES" -eq 1 ]; then
96
+ jira_auth_resolve || exit 4
97
+ site_subtask_types 2>/dev/null
98
+ [ -n "$SITE_SUBTASK_TYPES" ] || { echo "ERR: ${PROJECT} answered no sub-task types" >&2; exit 5; }
99
+ printf '%s\n' "$SITE_SUBTASK_TYPES"
100
+ exit 0
101
+ fi
102
+
64
103
  jq -e . "$PLAN" >/dev/null 2>&1 || { echo "ERR: the plan does not parse as JSON: $PLAN" >&2; exit 3; }
65
104
 
66
105
  DOC_ID="$(jq -r '.document.id // "unidentified"' "$PLAN")"
@@ -170,6 +209,40 @@ link_issues() { # link_issues <type> <fromKey> <toKey> <cloneLabel>
170
209
  echo " linked $from <-> $to"
171
210
  }
172
211
 
212
+ # A dependency link: the prerequisite is the link's source (the POST's
213
+ # inwardIssue), so the site reads "<prerequisite> blocks <dependent>" for the
214
+ # default Blocks type, and lists the link on the prerequisite under outwardIssue.
215
+ # Only a link of this type in this direction counts as already there: a Relates
216
+ # link a person added between the same two stories is not the dependency.
217
+ link_dependency() { # link_dependency <type> <prerequisiteKey> <dependentKey> <dependentLabel>
218
+ local ltype="$1" pre="$2" dep="$3" label="$4" linked
219
+ linked="$(jira_api GET "/rest/api/2/issue/${pre}?fields=issuelinks" 2>/dev/null \
220
+ | jq -r --arg t "$ltype" --arg d "$dep" \
221
+ '[.fields.issuelinks[]? | select(.type.name == $t and .outwardIssue.key == $d)] | length > 0' \
222
+ 2>/dev/null || echo false)"
223
+ if [ "$linked" = "true" ]; then
224
+ echo " linked $pre -> $dep ($ltype)"
225
+ return 0
226
+ fi
227
+ if ! printf '%s' "$(jq -cn --arg t "$ltype" --arg a "$pre" --arg b "$dep" \
228
+ '{type: {name: $t}, inwardIssue: {key: $a}, outwardIssue: {key: $b}}')" \
229
+ | jira_api POST "/rest/api/2/issueLink" --data @- >/dev/null; then
230
+ echo "ERR: could not link $pre to $dep" >&2
231
+ return 5
232
+ fi
233
+ ledger dependency "$label" "$pre"
234
+ echo " linked $pre -> $dep ($ltype)"
235
+ }
236
+
237
+ declare -a STORY_KEYS=()
238
+ story_key() { # story_key <label> -> prints the key this run created or found, or empty
239
+ local i
240
+ for i in "${STORY_KEYS[@]:-}"; do
241
+ case "$i" in "$1="*) printf '%s' "${i#*=}"; return 0 ;; esac
242
+ done
243
+ printf ''
244
+ }
245
+
173
246
  CREATED_KEY=""
174
247
  create_issue() { # create_issue <label> <summary> <issuetype> <parentKey|""> <description> [extraFieldsJson]
175
248
  local label="$1" summary="$2" itype="$3" parent="$4" desc="$5" extra="${6:-}" body key existing
@@ -239,25 +312,6 @@ if [ -n "${EPIC:-}" ]; then
239
312
  fi
240
313
  fi
241
314
 
242
- # Discover rather than assume: the types this site marks subtask:true, under
243
- # whatever names it gave them. Jira Data Center 9 answers only the per-project
244
- # createmeta; older sites answer only the classic one. Read once per run.
245
- SITE_SUBTASK_TYPES=""
246
- SITE_SUBTASK_READ=0
247
- site_subtask_types() {
248
- SITE_SUBTASK_READ=1
249
- SITE_SUBTASK_TYPES="$(jira_api GET "/rest/api/2/issue/createmeta/${PROJECT}/issuetypes?maxResults=100" 2>/dev/null \
250
- | jq -r '.values[]? | select(.subtask == true) | .name' 2>/dev/null || echo "")"
251
- [ -n "$SITE_SUBTASK_TYPES" ] || SITE_SUBTASK_TYPES="$(jira_api GET \
252
- "/rest/api/2/issue/createmeta?projectKeys=${PROJECT}&expand=projects.issuetypes" 2>/dev/null \
253
- | jq -r '.projects[]?.issuetypes[]? | select(.subtask == true) | .name' 2>/dev/null || echo "")"
254
- local n
255
- n="$(printf '%s' "$SITE_SUBTASK_TYPES" | grep -c . || true)"
256
- if [ "${n:-0}" -gt 1 ]; then
257
- echo " note ${PROJECT} has ${n} sub-task types ($(printf '%s' "$SITE_SUBTASK_TYPES" | paste -sd, - | sed 's/,/, /g')); using the first. Set issueTree.subtaskIssueType to a name, or to a {role: type} map, to choose." >&2
258
- fi
259
- }
260
-
261
315
  RC=0
262
316
  NODES="$(jq -c '.nodes[]' "$PLAN")"
263
317
  while IFS= read -r node; do
@@ -277,6 +331,7 @@ while IFS= read -r node; do
277
331
  continue
278
332
  fi
279
333
  skey="$CREATED_KEY"
334
+ [ -n "$skey" ] && STORY_KEYS+=("$s_label=$skey")
280
335
  LINK_TYPE="$(jq -r '.prefsUsed.cloneLinkType // "Relates"' "$PLAN")"
281
336
  while IFS= read -r clone; do
282
337
  [ -n "$clone" ] || continue
@@ -306,4 +361,32 @@ while IFS= read -r node; do
306
361
  done < <(printf '%s' "$node" | jq -c '.subtasks[]?')
307
362
  done <<< "$NODES"
308
363
 
364
+ # Dependencies, once every story has a key. An edge inside a strongly connected
365
+ # component (`onCycle`, set by the planner from task-graph.mjs) is not linked:
366
+ # a cycle has no order to record, and the plan already reported it.
367
+ DEP_TYPE="$(jq -r '.prefsUsed.dependencyLinkType // "Blocks"' "$PLAN")"
368
+ EDGES="$(jq -r '(.nodes | map({(.label): .title}) | add // {}) as $t
369
+ | .dependencies.edges[]? | .prerequisite as $p | .dependent as $d
370
+ | [$p, $d, $t[$p], $t[$d], (if .onCycle == true then "cycle" else "" end)]
371
+ | map(. // "" | tostring | gsub("[\n\r\u001f]"; " "))
372
+ | join("\u001f")' "$PLAN")"
373
+ # \x1f, unlike a tab, is not IFS whitespace, so an empty field stays a field.
374
+ while IFS=$'\x1f' read -r p_label d_label p_title d_title on_cycle; do
375
+ [ -n "$p_label" ] || continue
376
+ if [ "$on_cycle" = "cycle" ]; then
377
+ echo " skipped link $p_label ($p_title) -> $d_label ($d_title): the two stories are on a dependency cycle"
378
+ continue
379
+ fi
380
+ if [ "$DRY" -eq 1 ]; then
381
+ echo " link $DEP_TYPE $p_label ($p_title) -> $d_label ($d_title) (prerequisite -> dependent)"
382
+ continue
383
+ fi
384
+ p_key="$(story_key "$p_label")"; d_key="$(story_key "$d_label")"
385
+ if [ -z "$p_key" ] || [ -z "$d_key" ]; then
386
+ echo " skipped link $p_label ($p_title) -> $d_label ($d_title): one of the stories was not created" >&2
387
+ continue
388
+ fi
389
+ link_dependency "$DEP_TYPE" "$p_key" "$d_key" "$d_label" || RC=5
390
+ done <<< "$EDGES"
391
+
309
392
  exit "$RC"
@@ -0,0 +1,130 @@
1
+ /**
2
+ * decisions.mjs - the record of what an autopilot run decided in place of a
3
+ * person, and its rendering.
4
+ *
5
+ * An attended run asks: a picker, a park, a waived finding, a model change are
6
+ * each a question with an answer someone gave. An autopilot run answers them
7
+ * itself. `state.decisions[]` holds one entry per such answer, so the person
8
+ * who reads the PR or the Phase 5 report sees every place the run chose for
9
+ * them, why, and what it costs if the choice was wrong:
10
+ *
11
+ * { what, why, costIfWrong, at, phase }
12
+ *
13
+ * The entries are written from text the run composed (gate reasons, finding
14
+ * locations, picker labels), so every field is passed through redact.mjs
15
+ * before it is capped, and the rendered section again before it reaches any
16
+ * outward text. The caller still runs the
17
+ * outbound gate over the whole body it publishes; redaction here only keeps a
18
+ * decision's wording from being the reason a publish is refused.
19
+ *
20
+ * Pure functions; the writers that hold the state lock are gate-ledger.mjs
21
+ * (park) and scripts/decisions-log.mjs (everything else).
22
+ *
23
+ * @module pipeline/lib/decisions
24
+ */
25
+
26
+ import { redact } from "./redact.mjs";
27
+
28
+ export const DECISIONS_HEADING = "## Decisions made on your behalf";
29
+
30
+ const FIELD_MAX = { what: 200, why: 500, costIfWrong: 300 };
31
+ const MIN_PHASE = 0;
32
+ const MAX_PHASE = 5;
33
+ // Room for the heading's blank line and one "... N more" line.
34
+ const MIN_ROOM = 48;
35
+
36
+ /**
37
+ * `value` on one line, redacted, then capped at `max`. Redaction comes first:
38
+ * a token cut at the boundary is too short for its rule to recognise, and its
39
+ * prefix would survive.
40
+ */
41
+ function oneLine(value, max) {
42
+ const text = redact(
43
+ String(value ?? "")
44
+ .replace(/\s+/g, " ")
45
+ .trim(),
46
+ );
47
+ return text.length > max ? `${text.slice(0, max - 3)}...` : text;
48
+ }
49
+
50
+ /**
51
+ * A validated entry. Throws when a field is missing: an entry that cannot say
52
+ * what was decided, why, or what being wrong costs is not worth showing.
53
+ *
54
+ * @param {{what: string, why: string, costIfWrong: string, phase: number, at?: string}} input
55
+ * @returns {{what: string, why: string, costIfWrong: string, at: string, phase: number}}
56
+ */
57
+ export function decisionEntry({ what, why, costIfWrong, phase, at } = {}) {
58
+ const out = {
59
+ what: oneLine(what, FIELD_MAX.what),
60
+ why: oneLine(why, FIELD_MAX.why),
61
+ costIfWrong: oneLine(costIfWrong, FIELD_MAX.costIfWrong),
62
+ };
63
+ for (const [k, v] of Object.entries(out)) {
64
+ if (!v) throw new Error(`a decision needs a non-empty ${k}`);
65
+ }
66
+ const n = Number(phase);
67
+ if (!Number.isInteger(n) || n < MIN_PHASE || n > MAX_PHASE) {
68
+ throw new Error(`a decision needs a phase between ${MIN_PHASE} and ${MAX_PHASE}`);
69
+ }
70
+ const stamp = typeof at === "string" && !Number.isNaN(Date.parse(at)) ? at : null;
71
+ return { ...out, at: stamp || new Date().toISOString(), phase: n };
72
+ }
73
+
74
+ /** The state with `entry` appended to `decisions[]`; `state` is not changed. */
75
+ export function withDecision(state, entry) {
76
+ const list = Array.isArray(state?.decisions) ? state.decisions : [];
77
+ return { ...state, decisions: [...list, entry] };
78
+ }
79
+
80
+ function line(d) {
81
+ const phase = Number.isInteger(d?.phase) ? `Phase ${d.phase}` : "Phase ?";
82
+ return redact(
83
+ `- **${phase}** - ${oneLine(d?.what, FIELD_MAX.what)}. Why: ${oneLine(d?.why, FIELD_MAX.why)}. If wrong: ${oneLine(d?.costIfWrong, FIELD_MAX.costIfWrong)}.`,
84
+ );
85
+ }
86
+
87
+ /**
88
+ * The markdown section, or "" for an empty list: a run that decided nothing
89
+ * for anyone adds nothing to the report.
90
+ *
91
+ * @param {object[]|undefined} decisions
92
+ * @param {{maxChars?: number}} [opts] trim to this many characters, oldest first kept
93
+ */
94
+ export function renderDecisions(decisions, { maxChars = Infinity } = {}) {
95
+ const list = Array.isArray(decisions) ? decisions.filter((d) => d && typeof d === "object") : [];
96
+ if (!list.length) return "";
97
+ const head = `${DECISIONS_HEADING}\n\n`;
98
+ const lines = [];
99
+ let size = head.length;
100
+ for (let i = 0; i < list.length; i++) {
101
+ const l = `${line(list[i])}\n`;
102
+ const rest = list.length - i - 1;
103
+ const tail = rest ? `- ... ${rest + 1} more in the run report\n`.length : 0;
104
+ if (size + l.length + tail > maxChars) {
105
+ lines.push(`- ... ${list.length - i} more in the run report\n`);
106
+ break;
107
+ }
108
+ lines.push(l);
109
+ size += l.length;
110
+ }
111
+ return head + lines.join("");
112
+ }
113
+
114
+ /**
115
+ * `body` with the decisions section appended, once. A body that already holds
116
+ * the heading is returned unchanged; `maxChars` bounds the whole result.
117
+ *
118
+ * @param {string} body
119
+ * @param {object[]|undefined} decisions
120
+ * @param {{maxChars?: number}} [opts]
121
+ */
122
+ export function appendDecisionsSection(body, decisions, { maxChars = Infinity } = {}) {
123
+ const text = typeof body === "string" ? body : "";
124
+ if (text.includes(DECISIONS_HEADING)) return text;
125
+ const sep = text.endsWith("\n") ? "\n" : "\n\n";
126
+ const room = maxChars - text.length - sep.length;
127
+ const section = renderDecisions(decisions, { maxChars: room });
128
+ if (!section || room < DECISIONS_HEADING.length + MIN_ROOM) return text;
129
+ return `${text}${sep}${section}`;
130
+ }
@@ -14,10 +14,23 @@
14
14
  # init <task-id> [title] Initialize with empty todos[] (Phase 2 usually pipes the JSON instead)
15
15
  # set <task-id> <plan-json> Replace the whole plan from a JSON blob on stdin or arg
16
16
  # start <task-id> <todo-id> Mark in_progress, set startedAt; refuses if deps unsatisfied
17
- # complete <task-id> <todo-id> [notes] Mark completed, set completedAt
17
+ # or the todo awaits re-plan confirmation
18
+ # complete <task-id> <todo-id> [notes] [--no-tests <reason>]
19
+ # Mark completed, set completedAt; refuses what start refuses
20
+ # (unsatisfied deps on a pending todo, re-plan unconfirmed); checks TDD evidence per
21
+ # prefs.global.tddEvidence.mode (off | warn | enforce, default warn)
22
+ # tdd-red <task-id> <todo-id> <results> [--tests a,b] [--already-passing <reason>]
23
+ # Record the RED run (a named test fails) from JUnit XML / xcresult / log
24
+ # (under enforce, --tests needs results that name failing tests)
25
+ # tdd-green <task-id> <todo-id> <results> Record the GREEN run (no failures, RED's tests among them)
18
26
  # fail <task-id> <todo-id> <reason> Mark failed
19
27
  # skip <task-id> <todo-id> <reason> Mark skipped (counts as satisfied for dependents)
20
- # next <task-id> Print next pending todo (deps-respecting); empty if none
28
+ # next <task-id> Print the next ready todo (deps-respecting, ranked by what it unblocks); empty if none, blocked todos named on stderr
29
+ # drift <task-id> <todo-id> [--files <path|->] [--not-done N] [--reason <text>]
30
+ # The todo ended off-plan (files outside its files[],
31
+ # planned items not done, or a stated reason): flag its
32
+ # pending transitive dependents needsReplan; prints JSON
33
+ # confirm-replan <task-id> <todo-id> [note] Confirm a flagged todo's plan still holds, so it can start
21
34
  # list <task-id> Render Markdown checklist
22
35
  # status <task-id> Print one-line summary: "2/5 done, 1 in progress"
23
36
  # show <task-id> Print the full plan JSON
@@ -59,7 +72,7 @@ CMD="${1:-}"
59
72
  shift || true
60
73
 
61
74
  usage() {
62
- sed -n '2,30p' "$0" >&2
75
+ sed -n '2,37p' "$0" >&2
63
76
  exit 64
64
77
  }
65
78
 
@@ -115,12 +128,8 @@ do_set() {
115
128
  if [ -z "$plan_blob" ] || [ "$plan_blob" = "-" ]; then
116
129
  plan_blob=$(cat)
117
130
  fi
118
- # A planning-output document is accepted directly and converted here.
119
- #
120
- # The conversion used to live as a jq blob inside phase-1-plan.md, which
121
- # made the mapping from `tasks[]` to `todos[]` a thing two files defined - and
122
- # the phase doc was the copy nothing tested. Accepting both shapes costs four
123
- # lines and removes the second definition.
131
+ # A planning-output document is accepted directly and converted here, so the
132
+ # mapping from `tasks[]` to `todos[]` has one tested definition.
124
133
  if jq -e '.tasks and (.todos | not)' <<<"$plan_blob" >/dev/null 2>&1; then
125
134
  plan_blob=$(jq '{
126
135
  title: (.summary // .title // "plan"),
@@ -129,7 +138,9 @@ do_set() {
129
138
  task: (.title // .subject // ""),
130
139
  status: "pending",
131
140
  deps: (.dependsOn // .blockedBy // [])
132
- } ]
141
+ } + (if (.files | type) == "array" then {files: .files} else {} end)
142
+ + (if (.creates | type) == "array" then {creates: .creates} else {} end)
143
+ + (if .type then {type: .type} else {} end) ]
133
144
  }' <<<"$plan_blob")
134
145
  fi
135
146
 
@@ -169,6 +180,22 @@ deps_satisfied() {
169
180
  ' <<<"$state" >/dev/null 2>&1
170
181
  }
171
182
 
183
+ refuse_unconfirmed_replan() {
184
+ # $1 state JSON, $2 task_id, $3 todo_id. A todo planned on top of one that
185
+ # drifted waits for a person to confirm its plan: exits 2 until confirm-replan.
186
+ local replan
187
+ replan=$(jq -r --arg id "$3" '
188
+ (.plan.todos // []) | map(select(.id == $id))[0].needsReplan
189
+ | select(. != null and (.confirmedAt // "") == "")
190
+ | "\(.because)\t\(.reason // "")"
191
+ ' <<<"$1")
192
+ if [ -n "$replan" ]; then
193
+ printf "plan-todos: '%s' awaits re-plan confirmation: %s drifted (%s). Check its plan, then: plan-todos.sh confirm-replan %s %s\n" \
194
+ "$3" "${replan%%$'\t'*}" "${replan#*$'\t'}" "$2" "$3" >&2
195
+ exit 2
196
+ fi
197
+ }
198
+
172
199
  do_start() {
173
200
  local task_id="${1:-}"; local todo_id="${2:-}"
174
201
  [ -z "$task_id" ] || [ -z "$todo_id" ] && { echo "usage: start <task-id> <todo-id>" >&2; exit 64; }
@@ -184,6 +211,7 @@ do_start() {
184
211
  echo "plan-todos: deps for '$todo_id' not satisfied (still pending/in_progress/failed)" >&2
185
212
  exit 2
186
213
  fi
214
+ refuse_unconfirmed_replan "$state" "$task_id" "$todo_id"
187
215
  local now; now=$(iso_now)
188
216
  local new
189
217
  new=$(jq --arg id "$todo_id" --arg t "$now" '
@@ -202,18 +230,85 @@ require_todo() {
202
230
  fi
203
231
  }
204
232
 
233
+ TDD_EVIDENCE="$_MA_RP_HERE/tdd-evidence.mjs"
234
+
235
+ tdd_mode() {
236
+ local prefs="${MULTI_AGENT_PREFS:-$HOME/.claude/multi-agent-preferences.json}" mode
237
+ mode=$(jq -r '.global.tddEvidence.mode // "warn"' "$prefs" 2>/dev/null) || mode=warn
238
+ case "$mode" in off|warn|enforce) printf '%s' "$mode" ;; *) printf 'warn' ;; esac
239
+ }
240
+
241
+ write_tdd() {
242
+ # $1 state path, $2 todo_id, $3 tdd record JSON
243
+ local new
244
+ new=$(jq --arg id "$2" --argjson r "$3" '
245
+ .plan.todos |= map(if .id == $id then .tdd = $r else . end)
246
+ ' "$1")
247
+ write_state "$1" "$new"
248
+ }
249
+
250
+ do_tdd_run() {
251
+ # $1 red|green, then <task-id> <todo-id> <results> [flags passed to tdd-evidence.mjs]
252
+ local kind="$1"; shift
253
+ local task_id="${1:-}"; local todo_id="${2:-}"; local results="${3:-}"
254
+ [ -z "$task_id" ] || [ -z "$todo_id" ] || [ -z "$results" ] && { echo "usage: tdd-$kind <task-id> <todo-id> <results-file> [flags]" >&2; exit 64; }
255
+ shift 3
256
+ # Only the caller-owned flags pass through; --state, --todo, --results and
257
+ # --mode come from the pipeline, so a trailing copy cannot override them.
258
+ local extra=()
259
+ while [ $# -gt 0 ]; do
260
+ case "$1" in
261
+ --tests|--already-passing)
262
+ [ $# -ge 2 ] || { echo "tdd-$kind: $1 needs a value" >&2; exit 64; }
263
+ extra+=("$1" "$2"); shift 2 ;;
264
+ *) echo "tdd-$kind: unsupported flag '$1' (only --tests and --already-passing)" >&2; exit 64 ;;
265
+ esac
266
+ done
267
+ local state_path; state_path=$(resolve_state "$task_id")
268
+ require_todo "$state_path" "$todo_id"
269
+ local record mode_flag=()
270
+ [ "$kind" = "red" ] && mode_flag=(--mode "$(tdd_mode)")
271
+ record=$(node "$TDD_EVIDENCE" "$kind" --state "$state_path" --todo "$todo_id" --results "$results" ${mode_flag[@]+"${mode_flag[@]}"} ${extra[@]+"${extra[@]}"}) || exit 2
272
+ write_tdd "$state_path" "$todo_id" "$record"
273
+ }
274
+
205
275
  do_complete() {
206
- local task_id="${1:-}"; local todo_id="${2:-}"; local notes="${3:-}"
207
- [ -z "$task_id" ] || [ -z "$todo_id" ] && { echo "usage: complete <task-id> <todo-id> [notes]" >&2; exit 64; }
276
+ local task_id="${1:-}"; local todo_id="${2:-}"; local notes="" no_tests=""
277
+ [ -z "$task_id" ] || [ -z "$todo_id" ] && { echo "usage: complete <task-id> <todo-id> [notes] [--no-tests <reason>]" >&2; exit 64; }
278
+ shift 2
279
+ while [ $# -gt 0 ]; do
280
+ case "$1" in
281
+ --no-tests) no_tests="${2:-}"; [ -z "$no_tests" ] && { echo "complete: --no-tests needs a reason" >&2; exit 64; }; shift 2 ;;
282
+ *) notes="$1"; shift ;;
283
+ esac
284
+ done
208
285
  local state_path; state_path=$(resolve_state "$task_id")
209
286
  require_todo "$state_path" "$todo_id"
287
+ local state; state=$(cat "$state_path")
288
+ refuse_unconfirmed_replan "$state" "$task_id" "$todo_id"
289
+ # A todo that never started skipped the deps check start makes.
290
+ if [ "$(jq -r --arg id "$todo_id" '(.plan.todos // []) | map(select(.id == $id))[0].status // "pending"' <<<"$state")" = "pending" ] \
291
+ && ! deps_satisfied "$state" "$todo_id"; then
292
+ echo "plan-todos: deps for '$todo_id' not satisfied (still pending/in_progress/failed); it cannot complete before they do" >&2
293
+ exit 2
294
+ fi
295
+ local verdict action
296
+ verdict=$(node "$TDD_EVIDENCE" check --state "$state_path" --todo "$todo_id" --mode "$(tdd_mode)" \
297
+ ${no_tests:+--no-tests "$no_tests"}) || exit 2
298
+ action=$(jq -r '.action' <<<"$verdict")
299
+ if [ "$action" = "refuse" ]; then
300
+ echo "plan-todos: $(jq -r '.message' <<<"$verdict") - tddEvidence.mode is enforce" >&2
301
+ exit 2
302
+ fi
303
+ [ "$action" = "warn" ] && echo "plan-todos: warning: $(jq -r '.message' <<<"$verdict"); recorded as unverified" >&2
210
304
  local now; now=$(iso_now)
211
305
  local new
212
- new=$(jq --arg id "$todo_id" --arg t "$now" --arg n "$notes" '
306
+ new=$(jq --arg id "$todo_id" --arg t "$now" --arg n "$notes" --argjson v "$verdict" '
213
307
  .plan.todos |= map(
214
308
  if .id == $id then
215
309
  .status = "completed" | .completedAt = $t
216
310
  | if ($n != "") then .notes = $n else . end
311
+ | if ($v.tdd != null) then .tdd = $v.tdd else . end
217
312
  else . end
218
313
  )
219
314
  ' "$state_path")
@@ -254,25 +349,47 @@ do_next() {
254
349
  local task_id="${1:-}"
255
350
  [ -z "$task_id" ] && { echo "usage: next <task-id>" >&2; exit 64; }
256
351
  local state_path; state_path=$(resolve_state "$task_id")
257
- # Pick the first pending todo whose deps are all completed|skipped.
258
- jq -r '
259
- (.plan.todos // []) as $all
260
- | $all
261
- | map(select(.status == "pending"))
262
- | map(
263
- . as $t
264
- | select(
265
- (.deps // [])
266
- | all(
267
- . as $d
268
- | $all | map(select(.id == $d))[0] // null
269
- | . != null and (.status == "completed" or .status == "skipped")
270
- )
271
- )
272
- )
273
- | (.[0] // empty)
274
- | if . == null or . == "" then "" else (.id // "") + "\t" + (.task // "") end
275
- ' "$state_path"
352
+ # Ready set ranked by the work each todo unblocks; blocked todos are named on stderr.
353
+ node "$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)/task-graph.mjs" next "$state_path" --task-id "$task_id"
354
+ }
355
+
356
+ do_drift() {
357
+ local task_id="${1:-}"; local todo_id="${2:-}"
358
+ [ -z "$task_id" ] || [ -z "$todo_id" ] && { echo "usage: drift <task-id> <todo-id> [--files <path|->] [--not-done N] [--reason <text>]" >&2; exit 64; }
359
+ shift 2
360
+ local state_path; state_path=$(resolve_state "$task_id")
361
+ require_todo "$state_path" "$todo_id"
362
+ local result rc=0
363
+ result=$(node "$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)/task-graph.mjs" drift "$state_path" "$todo_id" "$@") || rc=$?
364
+ [ "$rc" -eq 0 ] || exit "$rc"
365
+ if [ "$(jq -r '.drifted' <<<"$result")" = "true" ]; then
366
+ local new
367
+ new=$(jq --argjson r "$result" '.plan.todos = $r.todos' "$state_path")
368
+ write_state "$state_path" "$new"
369
+ fi
370
+ jq -c 'del(.todos)' <<<"$result"
371
+ }
372
+
373
+ do_confirm_replan() {
374
+ local task_id="${1:-}"; local todo_id="${2:-}"; local note="${3:-}"
375
+ [ -z "$task_id" ] || [ -z "$todo_id" ] && { echo "usage: confirm-replan <task-id> <todo-id> [note]" >&2; exit 64; }
376
+ local state_path; state_path=$(resolve_state "$task_id")
377
+ require_todo "$state_path" "$todo_id"
378
+ if ! jq -e --arg id "$todo_id" '(.plan.todos // []) | map(select(.id == $id))[0].needsReplan | type == "object"' "$state_path" >/dev/null 2>&1; then
379
+ echo "plan-todos: '$todo_id' has no re-plan flag to confirm" >&2
380
+ exit 2
381
+ fi
382
+ local now; now=$(iso_now)
383
+ local new
384
+ new=$(jq --arg id "$todo_id" --arg t "$now" --arg n "$note" '
385
+ .plan.todos |= map(
386
+ if .id == $id then
387
+ .needsReplan.confirmedAt = $t
388
+ | if ($n != "") then .needsReplan.note = $n else . end
389
+ else . end
390
+ )
391
+ ' "$state_path")
392
+ write_state "$state_path" "$new"
276
393
  }
277
394
 
278
395
  do_list() {
@@ -294,7 +411,9 @@ do_list() {
294
411
  | ($marks[.status] // "- [ ]") + " **" + (.id // "") + "** " + (.task // "")
295
412
  + (if (.notes // "") != "" then " _(" + .notes + ")_" else "" end)
296
413
  + (if (.failureReason // "") != "" then " _**failed:** " + .failureReason + "_" else "" end)
297
- + (if (.skipReason // "") != "" then " _**skipped:** " + .skipReason + "_" else "" end))
414
+ + (if (.skipReason // "") != "" then " _**skipped:** " + .skipReason + "_" else "" end)
415
+ + (if (.needsReplan // null) != null and (.needsReplan.confirmedAt // "") == ""
416
+ then " _**needs re-plan:** " + (.needsReplan.because // "?") + " drifted_" else "" end))
298
417
  )
299
418
  | .[]
300
419
  ' "$state_path"
@@ -332,9 +451,13 @@ case "$CMD" in
332
451
  set) do_set "$@" ;;
333
452
  start) do_start "$@" ;;
334
453
  complete) do_complete "$@" ;;
454
+ tdd-red) do_tdd_run red "$@" ;;
455
+ tdd-green) do_tdd_run green "$@" ;;
335
456
  fail) do_fail "$@" ;;
336
457
  skip) do_skip "$@" ;;
337
458
  next) do_next "$@" ;;
459
+ drift) do_drift "$@" ;;
460
+ confirm-replan) do_confirm_replan "$@" ;;
338
461
  list) do_list "$@" ;;
339
462
  status) do_status "$@" ;;
340
463
  show) do_show "$@" ;;