@mmerterden/multi-agent-pipeline 20.23.2 → 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.
- package/CHANGELOG.md +22 -0
- package/README.md +7 -6
- package/README.tr.md +6 -5
- package/docs/architecture.md +3 -3
- package/docs/ecosystem.md +5 -5
- package/docs/facts.json +3 -3
- package/install/catalog-history.json +1 -1
- package/install/templates/claude-hooks.json +14 -1
- package/manifest.json +75 -54
- package/package.json +1 -1
- package/pipeline/commands/multi-agent/analysis-jira/SKILL.md +4 -2
- package/pipeline/commands/multi-agent/postmortem/SKILL.md +78 -0
- package/pipeline/commands/multi-agent/setup/SKILL.md +1 -1
- package/pipeline/commands/multi-agent/sync/SKILL.md +3 -3
- package/pipeline/lib/analysis-jira-write.sh +70 -0
- package/pipeline/lib/decisions.mjs +130 -0
- package/pipeline/lib/plan-todos.sh +156 -33
- package/pipeline/lib/task-graph.mjs +446 -0
- package/pipeline/lib/tdd-evidence.mjs +579 -0
- package/pipeline/multi-agent-refs/analysis-template-corporate.md +2 -0
- package/pipeline/multi-agent-refs/analysis-template.md +4 -2
- package/pipeline/multi-agent-refs/cross-cli-contract.md +4 -4
- package/pipeline/multi-agent-refs/features/analysis-jira.md +42 -0
- package/pipeline/multi-agent-refs/features/autopilot-decisions.md +34 -0
- package/pipeline/multi-agent-refs/features/model-fallback.md +4 -2
- package/pipeline/multi-agent-refs/features/mutation-probe.md +58 -0
- package/pipeline/multi-agent-refs/features/plan-checks.md +67 -0
- package/pipeline/multi-agent-refs/features/plan-todos.md +40 -1
- package/pipeline/multi-agent-refs/features/rework-escalation.md +49 -0
- package/pipeline/multi-agent-refs/features/scope-check.md +1 -0
- package/pipeline/multi-agent-refs/help/en.md +1 -0
- package/pipeline/multi-agent-refs/help/tr.md +1 -0
- package/pipeline/multi-agent-refs/phases/phase-1-plan.md +2 -3
- package/pipeline/multi-agent-refs/phases/phase-2-dev.md +5 -7
- package/pipeline/multi-agent-refs/phases/phase-3-review.md +13 -11
- package/pipeline/multi-agent-refs/phases/phase-5-report.md +3 -3
- package/pipeline/multi-agent-refs/picker-contract.md +3 -2
- package/pipeline/multi-agent-refs/response-shape.md +65 -0
- package/pipeline/rules/response-shape.md +18 -0
- package/pipeline/schemas/agent-state.schema.json +31 -1
- package/pipeline/schemas/plan-todos.schema.json +101 -0
- package/pipeline/schemas/planning-output.schema.json +46 -0
- package/pipeline/schemas/prefs.schema.json +96 -0
- package/pipeline/scripts/_jsonl.mjs +151 -0
- package/pipeline/scripts/_plan-overlap.mjs +231 -0
- package/pipeline/scripts/_redact.mjs +124 -10
- package/pipeline/scripts/analysis-story-tree.mjs +153 -8
- package/pipeline/scripts/autopilot-publish.mjs +12 -1
- package/pipeline/scripts/cost-analyze.mjs +6 -24
- package/pipeline/scripts/decisions-log.mjs +118 -0
- package/pipeline/scripts/edit-syntax-gate.mjs +240 -0
- package/pipeline/scripts/edit-syntax-gate.sh +29 -0
- package/pipeline/scripts/find-polluter.mjs +281 -0
- package/pipeline/scripts/gate-ledger.mjs +46 -13
- package/pipeline/scripts/mutation-probe.mjs +484 -0
- package/pipeline/scripts/plan-complexity.mjs +259 -0
- package/pipeline/scripts/review-decision-gate.mjs +33 -1
- package/pipeline/scripts/rework-escalation.mjs +264 -0
- package/pipeline/scripts/test-strength.mjs +5 -3
- package/pipeline/scripts/transcript-index.mjs +950 -0
- package/pipeline/scripts/validate-planning.mjs +48 -6
- package/pipeline/skills/.skill-manifest.json +11 -7
- package/pipeline/skills/.skills-index.json +12 -1
- package/pipeline/skills/shared/README.md +4 -3
- package/pipeline/skills/shared/core/multi-agent-analysis-jira/SKILL.md +4 -2
- package/pipeline/skills/shared/core/multi-agent-postmortem/SKILL.md +66 -0
- package/pipeline/skills/shared/core/multi-agent-setup/SKILL.md +1 -1
- package/pipeline/skills/shared/core/multi-agent-sync/SKILL.md +3 -3
- package/pipeline/skills/shared/external/app-store-optimization/SKILL.md +15 -0
- package/pipeline/skills/shared/external/app-store-optimization/evals/evals.json +13 -0
- package/pipeline/skills/shared/external/debugging-strategies/SKILL.md +8 -0
- package/pipeline/skills/skills-index.md +2 -1
|
@@ -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 +
|
|
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
|
-
**
|
|
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,6 +34,13 @@
|
|
|
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]
|
|
39
46
|
# analysis-jira-write.sh --subtask-types --project KEY the site's sub-task type names, one per line
|
|
@@ -202,6 +209,40 @@ link_issues() { # link_issues <type> <fromKey> <toKey> <cloneLabel>
|
|
|
202
209
|
echo " linked $from <-> $to"
|
|
203
210
|
}
|
|
204
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
|
+
|
|
205
246
|
CREATED_KEY=""
|
|
206
247
|
create_issue() { # create_issue <label> <summary> <issuetype> <parentKey|""> <description> [extraFieldsJson]
|
|
207
248
|
local label="$1" summary="$2" itype="$3" parent="$4" desc="$5" extra="${6:-}" body key existing
|
|
@@ -290,6 +331,7 @@ while IFS= read -r node; do
|
|
|
290
331
|
continue
|
|
291
332
|
fi
|
|
292
333
|
skey="$CREATED_KEY"
|
|
334
|
+
[ -n "$skey" ] && STORY_KEYS+=("$s_label=$skey")
|
|
293
335
|
LINK_TYPE="$(jq -r '.prefsUsed.cloneLinkType // "Relates"' "$PLAN")"
|
|
294
336
|
while IFS= read -r clone; do
|
|
295
337
|
[ -n "$clone" ] || continue
|
|
@@ -319,4 +361,32 @@ while IFS= read -r node; do
|
|
|
319
361
|
done < <(printf '%s' "$node" | jq -c '.subtasks[]?')
|
|
320
362
|
done <<< "$NODES"
|
|
321
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
|
+
|
|
322
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
|
-
#
|
|
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
|
|
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,
|
|
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="
|
|
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
|
-
#
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
' "$
|
|
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 "$@" ;;
|