@mmerterden/multi-agent-pipeline 20.23.2 → 20.25.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 (123) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/README.md +8 -6
  3. package/README.tr.md +7 -5
  4. package/docs/architecture.md +3 -3
  5. package/docs/ecosystem.md +5 -5
  6. package/docs/facts.json +7 -6
  7. package/docs/features.md +9 -4
  8. package/install/catalog-history.json +1 -1
  9. package/install/templates/claude-hooks.json +14 -1
  10. package/install/templates/codex-hooks.json +18 -0
  11. package/manifest.json +126 -98
  12. package/package.json +1 -1
  13. package/pipeline/agents/task-clarifier.md +1 -1
  14. package/pipeline/commands/multi-agent/analysis-jira/SKILL.md +4 -2
  15. package/pipeline/commands/multi-agent/deps/SKILL.md +61 -0
  16. package/pipeline/commands/multi-agent/model/SKILL.md +2 -2
  17. package/pipeline/commands/multi-agent/postmortem/SKILL.md +90 -0
  18. package/pipeline/commands/multi-agent/review/SKILL.md +5 -5
  19. package/pipeline/commands/multi-agent/route-on/SKILL.md +1 -1
  20. package/pipeline/commands/multi-agent/setup/SKILL.md +7 -5
  21. package/pipeline/commands/multi-agent/sync/SKILL.md +4 -4
  22. package/pipeline/commands/multi-agent/testflight-validation/SKILL.md +2 -3
  23. package/pipeline/contract/fixtures/runs-failed.json +1 -1
  24. package/pipeline/contract/fixtures/runs-old-schema.json +1 -1
  25. package/pipeline/contract/fixtures/runs-pr-opened-redacted.json +1 -1
  26. package/pipeline/contract/fixtures/runs-pr-opened.json +1 -1
  27. package/pipeline/contract/fixtures/runs-running.json +1 -1
  28. package/pipeline/lib/analysis-jira-write.sh +70 -0
  29. package/pipeline/lib/credential-inventory.sh +5 -5
  30. package/pipeline/lib/decisions.mjs +130 -0
  31. package/pipeline/lib/fetch-crashlytics.sh +6 -8
  32. package/pipeline/lib/model-rung.sh +1 -1
  33. package/pipeline/lib/plan-todos.sh +177 -37
  34. package/pipeline/lib/post-pr-review.sh +4 -6
  35. package/pipeline/lib/route-state.sh +1 -1
  36. package/pipeline/lib/shadow-git.sh +4 -5
  37. package/pipeline/lib/task-graph.mjs +446 -0
  38. package/pipeline/lib/tdd-evidence.mjs +579 -0
  39. package/pipeline/multi-agent-refs/analysis-template-corporate.md +2 -0
  40. package/pipeline/multi-agent-refs/analysis-template.md +4 -2
  41. package/pipeline/multi-agent-refs/cross-cli-contract.md +10 -11
  42. package/pipeline/multi-agent-refs/features/analysis-jira.md +42 -0
  43. package/pipeline/multi-agent-refs/features/autopilot-decisions.md +34 -0
  44. package/pipeline/multi-agent-refs/features/cost-analysis.md +4 -0
  45. package/pipeline/multi-agent-refs/features/dep-bump.md +176 -0
  46. package/pipeline/multi-agent-refs/features/model-fallback.md +21 -14
  47. package/pipeline/multi-agent-refs/features/mutation-probe.md +58 -0
  48. package/pipeline/multi-agent-refs/features/plan-checks.md +67 -0
  49. package/pipeline/multi-agent-refs/features/plan-todos.md +40 -1
  50. package/pipeline/multi-agent-refs/features/rework-escalation.md +49 -0
  51. package/pipeline/multi-agent-refs/features/scope-check.md +1 -0
  52. package/pipeline/multi-agent-refs/help/en.md +2 -0
  53. package/pipeline/multi-agent-refs/help/tr.md +2 -0
  54. package/pipeline/multi-agent-refs/phases/phase-0-init.md +1 -1
  55. package/pipeline/multi-agent-refs/phases/phase-1-plan.md +2 -3
  56. package/pipeline/multi-agent-refs/phases/phase-2-dev.md +7 -7
  57. package/pipeline/multi-agent-refs/phases/phase-3-review.md +18 -16
  58. package/pipeline/multi-agent-refs/phases/phase-5-report.md +3 -3
  59. package/pipeline/multi-agent-refs/picker-contract.md +3 -2
  60. package/pipeline/multi-agent-refs/response-shape.md +65 -0
  61. package/pipeline/multi-agent-refs/tracker-contract.md +6 -6
  62. package/pipeline/rules/response-shape.md +18 -0
  63. package/pipeline/schemas/agent-state.schema.json +36 -1
  64. package/pipeline/schemas/phases.json +4 -0
  65. package/pipeline/schemas/plan-todos.schema.json +104 -3
  66. package/pipeline/schemas/planning-output.schema.json +46 -0
  67. package/pipeline/schemas/prefs.schema.json +122 -3
  68. package/pipeline/scripts/_code-graph.mjs +2 -2
  69. package/pipeline/scripts/_jsonl.mjs +151 -0
  70. package/pipeline/scripts/_plan-overlap.mjs +231 -0
  71. package/pipeline/scripts/_redact.mjs +124 -10
  72. package/pipeline/scripts/_transcript-hosts.mjs +406 -0
  73. package/pipeline/scripts/analysis-story-tree.mjs +153 -8
  74. package/pipeline/scripts/autopilot-publish.mjs +12 -1
  75. package/pipeline/scripts/build-stack-plugins.mjs +3 -3
  76. package/pipeline/scripts/capture-flush.sh +7 -7
  77. package/pipeline/scripts/code-graph-rules/android.json +1 -1
  78. package/pipeline/scripts/cost-analyze.mjs +32 -43
  79. package/pipeline/scripts/cost-table.json +35 -19
  80. package/pipeline/scripts/decisions-log.mjs +118 -0
  81. package/pipeline/scripts/dep-bump.mjs +1556 -0
  82. package/pipeline/scripts/edit-syntax-gate.mjs +362 -0
  83. package/pipeline/scripts/edit-syntax-gate.sh +32 -0
  84. package/pipeline/scripts/find-polluter.mjs +281 -0
  85. package/pipeline/scripts/gate-ledger.mjs +46 -13
  86. package/pipeline/scripts/gc-abandoned.sh +2 -2
  87. package/pipeline/scripts/gen-mode-dispatch.mjs +3 -3
  88. package/pipeline/scripts/git-path.mjs +1 -2
  89. package/pipeline/scripts/host-hooks.mjs +265 -0
  90. package/pipeline/scripts/jira-attach.sh +1 -1
  91. package/pipeline/scripts/keychain.py +3 -3
  92. package/pipeline/scripts/mutation-probe.mjs +484 -0
  93. package/pipeline/scripts/output-quality-check.sh +1 -1
  94. package/pipeline/scripts/phase-tracker.sh +5 -6
  95. package/pipeline/scripts/phase0-exit-gate.mjs +3 -3
  96. package/pipeline/scripts/plan-complexity.mjs +259 -0
  97. package/pipeline/scripts/pre-push-check.sh +3 -6
  98. package/pipeline/scripts/review-decision-gate.mjs +33 -1
  99. package/pipeline/scripts/rework-escalation.mjs +264 -0
  100. package/pipeline/scripts/test-strength.mjs +5 -3
  101. package/pipeline/scripts/transcript-index.mjs +1131 -0
  102. package/pipeline/scripts/uninstall.mjs +90 -4
  103. package/pipeline/scripts/update-issue-progress.sh +4 -6
  104. package/pipeline/scripts/usage-report.mjs +2 -2
  105. package/pipeline/scripts/validate-planning.mjs +48 -6
  106. package/pipeline/skills/.skill-manifest.json +20 -12
  107. package/pipeline/skills/.skills-index.json +23 -1
  108. package/pipeline/skills/shared/README.md +5 -3
  109. package/pipeline/skills/shared/core/multi-agent/SKILL.md +6 -6
  110. package/pipeline/skills/shared/core/multi-agent-analysis-jira/SKILL.md +4 -2
  111. package/pipeline/skills/shared/core/multi-agent-deps/SKILL.md +60 -0
  112. package/pipeline/skills/shared/core/multi-agent-model/SKILL.md +2 -2
  113. package/pipeline/skills/shared/core/multi-agent-postmortem/SKILL.md +69 -0
  114. package/pipeline/skills/shared/core/multi-agent-review/SKILL.md +5 -5
  115. package/pipeline/skills/shared/core/multi-agent-route-on/SKILL.md +1 -1
  116. package/pipeline/skills/shared/core/multi-agent-setup/SKILL.md +7 -5
  117. package/pipeline/skills/shared/core/multi-agent-sync/SKILL.md +4 -4
  118. package/pipeline/skills/shared/core/multi-agent-testflight-validation/SKILL.md +2 -3
  119. package/pipeline/skills/shared/external/app-store-optimization/SKILL.md +15 -0
  120. package/pipeline/skills/shared/external/app-store-optimization/evals/evals.json +13 -0
  121. package/pipeline/skills/shared/external/debugging-strategies/SKILL.md +8 -0
  122. package/pipeline/skills/shared/external/ios-coding-standard/references/rules.yml +2 -2
  123. package/pipeline/skills/skills-index.md +3 -1
@@ -0,0 +1,579 @@
1
+ /**
2
+ * @file tdd-evidence.mjs - proof that a plan todo went RED before it went GREEN.
3
+ *
4
+ * Phase 2 tells the dev agent to write a failing test first. Without a record,
5
+ * "I did TDD" is a claim; with one it is two test runs: a RED run in which a
6
+ * test the todo names fails, then a GREEN run with no failures. This module
7
+ * reads those runs from the files test runners already write and turns them
8
+ * into the `tdd` record plan-todos.sh stores on the todo.
9
+ *
10
+ * Inputs it reads, by shape rather than by flag:
11
+ *
12
+ * JUnit XML a file or a directory of them (Gradle, Surefire, Jest/Vitest
13
+ * junit reporters, pytest --junitxml). Counted per <testcase>;
14
+ * <failure> and <error> are failures. A truncated report is
15
+ * refused rather than counted, because the cases it lost are
16
+ * the ones that were running when it broke.
17
+ * *.xcresult a bundle, read with `xcrun xcresulttool get test-results
18
+ * summary --compact`.
19
+ * JSON that summary exported to a file, so the record needs no Xcode.
20
+ * text anything test-summary.mjs counts; failing names are read
21
+ * from XCTest and Swift Testing lines (xcodebuild, swift test).
22
+ *
23
+ * Every parser returns {total, passed, failed, skipped, failing[], passing,
24
+ * format, source}; total counts skipped tests, failing[] names the failed
25
+ * ones, passing[] names the passed ones, or is null when the format lists
26
+ * only failures (the xcresult summary, a log with no per-test lines).
27
+ *
28
+ * RED and GREEN must be the same tests. When RED named its failing tests and
29
+ * the GREEN results name their passing ones, each RED test has to pass in
30
+ * GREEN; when either side carries no names, GREEN has to run at least as many
31
+ * tests as RED. Results written before the todo's startedAt are refused for
32
+ * both runs. `--tests` on results that name no failing test cannot be checked:
33
+ * enforce refuses it (exit 2, use JUnit XML), warn and off record the RED run
34
+ * with `red.testsUnverifiable` and warn on stderr.
35
+ *
36
+ * Outcomes on a todo: `red` (RED recorded), `verified` (RED then GREEN),
37
+ * `already-passing` (the behaviour existed; the passing run is both RED and
38
+ * GREEN, with a reason), `exempt` (docs / chore todo, component task, no test
39
+ * target, test policy none; with a reason) and `unverified` (completed in warn
40
+ * mode without the evidence). Phase 3 notes the unverified todos and the
41
+ * exemptions a todo declared for itself (`--no-tests`, `selfDeclared`).
42
+ *
43
+ * CLI (exit 0 ok, 1 the evidence does not hold, 2 usage or unreadable input):
44
+ * tdd-evidence.mjs parse <results>
45
+ * tdd-evidence.mjs red --state <f> --todo <id> --results <r> [--tests a,b] [--already-passing <reason>] [--mode off|warn|enforce]
46
+ * tdd-evidence.mjs green --state <f> --todo <id> --results <r>
47
+ * tdd-evidence.mjs check --state <f> --todo <id> --mode off|warn|enforce [--no-tests <reason>]
48
+ * tdd-evidence.mjs unverified --state <f>
49
+ *
50
+ * red / green / check print JSON and never write the state: plan-todos.sh owns
51
+ * that file and merges the record in.
52
+ *
53
+ * Zero runtime dependencies (ADR-0004).
54
+ *
55
+ * @module pipeline/lib/tdd-evidence
56
+ */
57
+
58
+ import { spawnSync } from "node:child_process";
59
+ import { readdirSync, readFileSync, statSync } from "node:fs";
60
+ import { basename, join } from "node:path";
61
+
62
+ import { fromXcresultJson, summarize } from "../scripts/test-summary.mjs";
63
+ import { invokedDirectly } from "./invoked-directly.mjs";
64
+
65
+ export const MODES = ["off", "warn", "enforce"];
66
+ const EXEMPT_TYPES = new Set(["docs", "chore"]);
67
+ const VERIFIED = new Set(["verified", "already-passing"]);
68
+
69
+ class Unreadable extends Error {}
70
+ class Refused extends Error {}
71
+
72
+ // ---------------------------------------------------------------------------
73
+ // Parsing
74
+
75
+ const ENTITIES = { lt: "<", gt: ">", amp: "&", quot: '"', apos: "'" };
76
+
77
+ function decode(s) {
78
+ return s.replace(/&(#x[0-9a-f]+|#\d+|lt|gt|amp|quot|apos);/gi, (_, e) => {
79
+ if (e[0] !== "#") return ENTITIES[e.toLowerCase()];
80
+ const code = e[1] === "x" || e[1] === "X" ? parseInt(e.slice(2), 16) : parseInt(e.slice(1), 10);
81
+ return String.fromCodePoint(code);
82
+ });
83
+ }
84
+
85
+ function attrs(text) {
86
+ const out = {};
87
+ for (const m of text.matchAll(/([\w:.-]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g)) {
88
+ out[m[1]] = decode(m[2] ?? m[3]);
89
+ }
90
+ return out;
91
+ }
92
+
93
+ const ATTRS = `((?:[^>"'/]|/(?!>)|"[^"]*"|'[^']*')*)`;
94
+
95
+ /**
96
+ * Parse one JUnit XML document.
97
+ *
98
+ * @param {string} xml
99
+ * @returns {object|null} null when the text is not a JUnit report.
100
+ */
101
+ export function parseJunit(xml, source = null) {
102
+ const text = xml.replace(/<!\[CDATA\[[\s\S]*?\]\]>/g, "").replace(/<!--[\s\S]*?-->/g, "");
103
+ if (!/<testsuites?\b/.test(text)) return null;
104
+ const where = source ? ` (${source})` : "";
105
+
106
+ const opens = [...text.matchAll(new RegExp(`<testsuites?\\b${ATTRS}(/?)>`, "g"))].filter(
107
+ (m) => m[2] !== "/",
108
+ ).length;
109
+ const closes = (text.match(/<\/testsuites?>/g) ?? []).length;
110
+ const caseTags = (text.match(/<testcase\b/g) ?? []).length;
111
+ const cases = [
112
+ ...text.matchAll(new RegExp(`<testcase\\b${ATTRS}(?:/>|>([\\s\\S]*?)</testcase>)`, "g")),
113
+ ];
114
+ if (
115
+ opens !== closes ||
116
+ cases.length !== caseTags ||
117
+ cases.some((m) => /<testcase\b/.test(m[2] ?? ""))
118
+ ) {
119
+ throw new Unreadable(`malformed JUnit XML${where}: unclosed testsuite or testcase element`);
120
+ }
121
+
122
+ const r = { total: 0, passed: 0, failed: 0, skipped: 0, failing: [], passing: [] };
123
+ for (const m of cases) {
124
+ const a = attrs(m[1]);
125
+ const body = m[2] ?? "";
126
+ const id = a.classname ? `${a.classname}.${a.name ?? ""}` : (a.name ?? "");
127
+ r.total++;
128
+ if (/<(?:failure|error)\b/.test(body)) {
129
+ r.failed++;
130
+ r.failing.push(id);
131
+ } else if (/<skipped\b/.test(body)) r.skipped++;
132
+ else {
133
+ r.passed++;
134
+ r.passing.push(id);
135
+ }
136
+ }
137
+ if (r.total === 0) {
138
+ const declared = [...text.matchAll(/<testsuite\b[^>]*\btests="(\d+)"/g)].reduce(
139
+ (n, m) => n + Number(m[1]),
140
+ 0,
141
+ );
142
+ if (declared > 0) {
143
+ throw new Unreadable(
144
+ `JUnit XML${where}: suites declare ${declared} tests but list no testcase`,
145
+ );
146
+ }
147
+ }
148
+ return { ...r, format: "junit-xml", source };
149
+ }
150
+
151
+ function xmlUnder(dir, depth = 0, out = []) {
152
+ if (depth > 6) return out;
153
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
154
+ const full = join(dir, e.name);
155
+ if (e.isDirectory()) xmlUnder(full, depth + 1, out);
156
+ else if (e.name.endsWith(".xml")) out.push(full);
157
+ }
158
+ return out.sort();
159
+ }
160
+
161
+ function junitDir(dir) {
162
+ const sum = { total: 0, passed: 0, failed: 0, skipped: 0, failing: [], passing: [] };
163
+ let read = 0;
164
+ for (const f of xmlUnder(dir)) {
165
+ const r = parseJunit(readFileSync(f, "utf8"), f);
166
+ if (!r) continue;
167
+ read++;
168
+ for (const k of ["total", "passed", "failed", "skipped"]) sum[k] += r[k];
169
+ sum.failing.push(...r.failing);
170
+ sum.passing.push(...r.passing);
171
+ }
172
+ if (read === 0) throw new Unreadable(`no JUnit XML report under ${dir}`);
173
+ return { ...sum, format: "junit-xml", source: dir };
174
+ }
175
+
176
+ /**
177
+ * Read an `xcresulttool get test-results summary` document.
178
+ *
179
+ * Counts come from test-summary.mjs so both read the summary the same way;
180
+ * the failing names come from `testFailures[]`, one per test.
181
+ */
182
+ export function parseXcresultJson(obj, source = null) {
183
+ const c = fromXcresultJson(obj, source);
184
+ if (c.executed === null) throw new Unreadable(`${source ?? "xcresult JSON"}: ${c.reason}`);
185
+ const failing = [
186
+ ...new Set(
187
+ (obj.testFailures ?? []).map((f) =>
188
+ [f.targetName, f.testIdentifierString ?? f.testName].filter(Boolean).join("/"),
189
+ ),
190
+ ),
191
+ ];
192
+ return {
193
+ total: c.passed + c.failed + c.skipped,
194
+ passed: c.passed,
195
+ failed: c.failed,
196
+ skipped: c.skipped,
197
+ failing,
198
+ passing: null,
199
+ format: "xcresult",
200
+ source,
201
+ };
202
+ }
203
+
204
+ function xcresultBundle(bundle, { xcrun = "xcrun", timeoutMs = 60000 } = {}) {
205
+ const res = spawnSync(
206
+ xcrun,
207
+ ["xcresulttool", "get", "test-results", "summary", "--path", bundle, "--compact"],
208
+ { encoding: "utf8", timeout: timeoutMs },
209
+ );
210
+ if (res.error) {
211
+ throw new Unreadable(`xcrun unavailable: ${res.error.code || res.error.message}`);
212
+ }
213
+ if (res.status !== 0) {
214
+ throw new Unreadable(`xcresulttool exited ${res.status}: ${res.stderr.trim().slice(0, 200)}`);
215
+ }
216
+ let obj;
217
+ try {
218
+ obj = JSON.parse(res.stdout);
219
+ } catch {
220
+ throw new Unreadable("xcresulttool printed no JSON");
221
+ }
222
+ return parseXcresultJson(obj, bundle);
223
+ }
224
+
225
+ function xctestId(name) {
226
+ const objc = /^-\[(\S+) (\S+)\]$/.exec(name);
227
+ return objc ? `${objc[1]}/${objc[2]}` : name;
228
+ }
229
+
230
+ function testLines(text, verdict) {
231
+ const ids = new Set();
232
+ for (const m of text.matchAll(new RegExp(`^Test [Cc]ase '([^']+)' ${verdict}\\b`, "gm"))) {
233
+ ids.add(xctestId(m[1]));
234
+ }
235
+ for (const m of text.matchAll(
236
+ new RegExp(`^\\S+ Test (?!run with )(.+?) ${verdict}(?: after|\\.|$)`, "gm"),
237
+ )) {
238
+ ids.add(m[1]);
239
+ }
240
+ return [...ids];
241
+ }
242
+
243
+ /**
244
+ * Runner text: counts from test-summary.mjs, test names from XCTest and
245
+ * Swift Testing result lines. Other runners count but name nothing.
246
+ */
247
+ export function parseText(text, source = null) {
248
+ const c = summarize(text, { source });
249
+ if (c.executed === null) throw new Unreadable(c.reason);
250
+ const failing = testLines(text, "failed");
251
+ const passed = testLines(text, "passed");
252
+ const perTest = failing.length > 0 || passed.length > 0 || /^Test [Cc]ase '/m.test(text);
253
+ return {
254
+ total: c.executed + c.skipped,
255
+ passed: c.passed,
256
+ failed: c.failed,
257
+ skipped: c.skipped,
258
+ failing,
259
+ passing: perTest ? passed : null,
260
+ format: c.parser,
261
+ source,
262
+ };
263
+ }
264
+
265
+ /**
266
+ * Read test results from a path, whatever the runner wrote.
267
+ *
268
+ * @param {string} path
269
+ * @param {{xcrun?: string}} [opts]
270
+ * @throws {Error} when the input cannot be read as test results.
271
+ */
272
+ export function parseResults(path, opts = {}) {
273
+ let st;
274
+ try {
275
+ st = statSync(path);
276
+ } catch {
277
+ throw new Unreadable(`no test results at ${path}`);
278
+ }
279
+ if (st.isDirectory()) {
280
+ return basename(path).endsWith(".xcresult") ? xcresultBundle(path, opts) : junitDir(path);
281
+ }
282
+ const text = readFileSync(path, "utf8");
283
+ if (/^\s*\{/.test(text)) {
284
+ let obj;
285
+ try {
286
+ obj = JSON.parse(text);
287
+ } catch (err) {
288
+ throw new Unreadable(`${path}: not valid JSON (${err.message})`);
289
+ }
290
+ return parseXcresultJson(obj, path);
291
+ }
292
+ return parseJunit(text, path) ?? parseText(text, path);
293
+ }
294
+
295
+ function newestMtime(path, depth = 0) {
296
+ const st = statSync(path);
297
+ let newest = st.mtimeMs;
298
+ if (st.isDirectory() && depth <= 6) {
299
+ for (const e of readdirSync(path))
300
+ newest = Math.max(newest, newestMtime(join(path, e), depth + 1));
301
+ }
302
+ return newest;
303
+ }
304
+
305
+ /**
306
+ * Refuse results written before the todo started: a run from before the
307
+ * todo's work is not evidence about it. A directory counts by its newest file.
308
+ *
309
+ * @param {string} path
310
+ * @param {string|undefined} startedAt the todo's ISO start time
311
+ */
312
+ export function resultsFresh(path, startedAt) {
313
+ if (!startedAt) return;
314
+ const started = Date.parse(startedAt);
315
+ if (Number.isNaN(started)) return;
316
+ let written;
317
+ try {
318
+ written = newestMtime(path);
319
+ } catch {
320
+ throw new Unreadable(`no test results at ${path}`);
321
+ }
322
+ if (written < started) {
323
+ throw new Refused(
324
+ `${path} was written at ${new Date(written).toISOString()}, older than the todo's start (${startedAt}); run the tests again`,
325
+ );
326
+ }
327
+ }
328
+
329
+ // ---------------------------------------------------------------------------
330
+ // Decisions
331
+
332
+ const strip = (s) => s.replace(/\(\)$/, "");
333
+
334
+ /**
335
+ * Does a failing test id name the test a todo declared? An exact match, or the
336
+ * declared name as the trailing segment of a qualified id.
337
+ */
338
+ export function touches(id, name) {
339
+ const a = strip(id);
340
+ const b = strip(name);
341
+ if (!b) return false;
342
+ if (a === b) return true;
343
+ if (!a.endsWith(b)) return false;
344
+ return /[./: #>]$/.test(a.slice(0, a.length - b.length));
345
+ }
346
+
347
+ /**
348
+ * The RED record for a todo.
349
+ *
350
+ * @param {object} summary parseResults() output
351
+ * @param {{tests?: string[], alreadyPassing?: string, mode?: string, now: string}} opts
352
+ */
353
+ export function redRecord(summary, { tests = [], alreadyPassing = "", mode = "warn", now }) {
354
+ if (summary.total === 0) throw new Refused("the RED run executed no tests");
355
+ if (alreadyPassing) {
356
+ if (summary.failed > 0) {
357
+ throw new Refused(
358
+ `already-passing needs a run with no failures; this one has ${summary.failed}`,
359
+ );
360
+ }
361
+ return {
362
+ red: { at: now, failed: 0, total: summary.total, tests: [] },
363
+ green: { at: now, failed: 0, total: summary.total },
364
+ outcome: "already-passing",
365
+ reason: alreadyPassing,
366
+ };
367
+ }
368
+ if (summary.failed === 0) {
369
+ throw new Refused(
370
+ "the RED run has no failing test; a test that passes before the change proves nothing (use --already-passing <reason> when the behaviour already exists)",
371
+ );
372
+ }
373
+ if (tests.length > 0 && summary.failing.length === 0) {
374
+ if (mode === "enforce") {
375
+ throw new Unreadable(
376
+ `--tests ${tests.join(", ")}: the results name no failing test, so the named test cannot be checked; record RED from JUnit XML (or an xcresult / XCTest / Swift Testing log)`,
377
+ );
378
+ }
379
+ return {
380
+ red: {
381
+ at: now,
382
+ failed: summary.failed,
383
+ total: summary.total,
384
+ tests: [],
385
+ testsUnverifiable: true,
386
+ },
387
+ outcome: "red",
388
+ };
389
+ }
390
+ let named = summary.failing;
391
+ if (tests.length > 0) {
392
+ named = summary.failing.filter((id) => tests.some((t) => touches(id, t)));
393
+ if (named.length === 0) {
394
+ throw new Refused(
395
+ `none of the failing tests is one this todo names (${tests.join(", ")}); failing: ${summary.failing.join(", ")}`,
396
+ );
397
+ }
398
+ }
399
+ return {
400
+ red: { at: now, failed: summary.failed, total: summary.total, tests: named },
401
+ outcome: "red",
402
+ };
403
+ }
404
+
405
+ /**
406
+ * The GREEN record, on top of the todo's RED record.
407
+ *
408
+ * @param {object} summary
409
+ * @param {{prior: object|null, testPolicy?: string, now: string}} opts
410
+ */
411
+ export function greenRecord(summary, { prior, testPolicy, now }) {
412
+ if (!prior?.red && testPolicy !== "tests-after") {
413
+ throw new Refused("no RED run recorded for this todo; record RED first");
414
+ }
415
+ if (summary.total === 0) throw new Refused("the GREEN run executed no tests");
416
+ if (summary.failed > 0) {
417
+ const names = summary.failing.length ? `: ${summary.failing.join(", ")}` : "";
418
+ throw new Refused(`the GREEN run still has ${summary.failed} failing test(s)${names}`);
419
+ }
420
+ const redTests = prior?.red?.tests ?? [];
421
+ if (redTests.length > 0 && Array.isArray(summary.passing)) {
422
+ const missing = redTests.filter((t) => !summary.passing.some((p) => touches(p, t)));
423
+ if (missing.length > 0) {
424
+ throw new Refused(
425
+ `RED test(s) ${missing.join(", ")} not among the passing tests of the GREEN run; GREEN must run them`,
426
+ );
427
+ }
428
+ } else if (prior?.red && summary.total < prior.red.total) {
429
+ throw new Refused(
430
+ `the GREEN run ran ${summary.total} test(s), fewer than the ${prior.red.total} RED ran; run at least the RED suite`,
431
+ );
432
+ }
433
+ const base = { ...prior };
434
+ if (base.outcome !== "already-passing") {
435
+ base.outcome = "verified";
436
+ delete base.reason;
437
+ }
438
+ return { ...base, green: { at: now, failed: 0, total: summary.total } };
439
+ }
440
+
441
+ function exemption(todo, { taskType, testPolicy, noTests }) {
442
+ if (noTests) return { reason: `no test target: ${noTests}`, selfDeclared: true };
443
+ if (EXEMPT_TYPES.has(todo.type)) return { reason: `todo type ${todo.type}` };
444
+ if (taskType === "component") {
445
+ return { reason: "component task: the component skill owns its tests" };
446
+ }
447
+ if (testPolicy === "none") return { reason: "project test policy: none" };
448
+ return null;
449
+ }
450
+
451
+ /**
452
+ * Whether `plan-todos.sh complete` may close a todo, and the record it leaves.
453
+ *
454
+ * @returns {{action: "allow"|"warn"|"refuse", tdd?: object, message?: string}}
455
+ */
456
+ export function completion(todo, { mode, taskType, testPolicy, noTests } = {}) {
457
+ if (mode === "off") return { action: "allow", tdd: todo.tdd };
458
+ const prior = todo.tdd ?? {};
459
+ const exempt = exemption(todo, { taskType, testPolicy, noTests });
460
+ if (exempt) {
461
+ const tdd = { ...prior, outcome: "exempt", ...exempt };
462
+ if (!exempt.selfDeclared) delete tdd.selfDeclared;
463
+ return { action: "allow", tdd };
464
+ }
465
+ if (VERIFIED.has(prior.outcome) && prior.green?.failed === 0) {
466
+ return { action: "allow", tdd: prior };
467
+ }
468
+ const reason =
469
+ !prior.red && testPolicy !== "tests-after" ? "no RED run recorded" : "no GREEN run recorded";
470
+ const message = `todo ${todo.id}: ${reason} (plan-todos.sh tdd-red / tdd-green)`;
471
+ if (mode === "enforce") return { action: "refuse", message };
472
+ return { action: "warn", message, tdd: { ...prior, outcome: "unverified", reason } };
473
+ }
474
+
475
+ /**
476
+ * Completed todos the Phase 3 review notes: evidence unverified, or an
477
+ * exemption the todo declared for itself (`--no-tests`). Exemptions the todo
478
+ * type, the task type or the test policy grant are not listed.
479
+ */
480
+ export function unverifiedTodos(state) {
481
+ return (state?.plan?.todos ?? [])
482
+ .filter((t) => t.status === "completed")
483
+ .map((t) => {
484
+ if (t.tdd?.outcome === "unverified") return { kind: "unverified", t };
485
+ if (t.tdd?.outcome === "exempt" && t.tdd.selfDeclared === true)
486
+ return { kind: "self-exempt", t };
487
+ return null;
488
+ })
489
+ .filter(Boolean)
490
+ .map(({ kind, t }) => ({ id: t.id, task: t.task, kind, reason: t.tdd.reason ?? "" }));
491
+ }
492
+
493
+ // ---------------------------------------------------------------------------
494
+ // CLI
495
+
496
+ function flags(argv) {
497
+ const out = { _: [] };
498
+ for (let i = 0; i < argv.length; i++) {
499
+ const a = argv[i];
500
+ if (!a.startsWith("--")) {
501
+ out._.push(a);
502
+ continue;
503
+ }
504
+ const v = argv[i + 1];
505
+ if (v === undefined) throw new Unreadable(`${a} needs a value`);
506
+ if (Object.hasOwn(out, a.slice(2))) throw new Unreadable(`${a} given more than once`);
507
+ out[a.slice(2)] = v;
508
+ i++;
509
+ }
510
+ return out;
511
+ }
512
+
513
+ function loadTodo(f) {
514
+ if (!f.state || !f.todo) throw new Unreadable("--state and --todo are required");
515
+ const state = JSON.parse(readFileSync(f.state, "utf8"));
516
+ const todo = (state.plan?.todos ?? []).find((t) => t.id === f.todo);
517
+ if (!todo) throw new Unreadable(`todo '${f.todo}' not found`);
518
+ return { state, todo };
519
+ }
520
+
521
+ function main(argv) {
522
+ const [cmd, ...rest] = argv;
523
+ const now = new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
524
+ const print = (o) => process.stdout.write(`${JSON.stringify(o)}\n`);
525
+ try {
526
+ const f = flags(rest);
527
+ if (cmd === "parse") {
528
+ if (!f._[0]) throw new Unreadable("parse needs a results path");
529
+ print(parseResults(f._[0]));
530
+ } else if (cmd === "red" || cmd === "green") {
531
+ const { state, todo } = loadTodo(f);
532
+ if (!f.results) throw new Unreadable("--results is required");
533
+ const summary = parseResults(f.results);
534
+ resultsFresh(f.results, todo.startedAt);
535
+ const record =
536
+ cmd === "red"
537
+ ? redRecord(summary, {
538
+ tests: (f.tests ?? "").split(",").filter(Boolean),
539
+ alreadyPassing: f["already-passing"] ?? "",
540
+ mode: f.mode,
541
+ now,
542
+ })
543
+ : greenRecord(summary, { prior: todo.tdd ?? null, testPolicy: state.testPolicy, now });
544
+ if (record.red?.testsUnverifiable) {
545
+ process.stderr.write(
546
+ `tdd-evidence: warning: ${f.results} names no failing test, so --tests ${f.tests} is recorded unchecked (red.testsUnverifiable); JUnit XML names them\n`,
547
+ );
548
+ }
549
+ print(record);
550
+ } else if (cmd === "check") {
551
+ const { state, todo } = loadTodo(f);
552
+ print(
553
+ completion(todo, {
554
+ mode: f.mode,
555
+ taskType: state.taskType,
556
+ testPolicy: state.testPolicy,
557
+ noTests: f["no-tests"],
558
+ }),
559
+ );
560
+ } else if (cmd === "unverified") {
561
+ if (!f.state) throw new Unreadable("--state is required");
562
+ const state = JSON.parse(readFileSync(f.state, "utf8"));
563
+ for (const t of unverifiedTodos(state)) {
564
+ const reason = t.kind === "self-exempt" ? `self-declared exemption: ${t.reason}` : t.reason;
565
+ process.stdout.write(`${t.id}\t${t.task}\t${reason}\n`);
566
+ }
567
+ } else {
568
+ throw new Unreadable(
569
+ `unknown subcommand '${cmd ?? ""}' (parse | red | green | check | unverified)`,
570
+ );
571
+ }
572
+ return 0;
573
+ } catch (err) {
574
+ process.stderr.write(`tdd-evidence: ${err.message}\n`);
575
+ return err instanceof Refused ? 1 : 2;
576
+ }
577
+ }
578
+
579
+ if (invokedDirectly(import.meta.url)) process.exitCode = main(process.argv.slice(2));
@@ -295,6 +295,8 @@ Every `FG` sentence starts with `Sistem,` in Turkish output and `The system shal
295
295
 
296
296
  Every `FG` carries both a source `UC` and a source `IG`. An FG with no source IG is either an unrecorded business requirement or an invented feature; both are findings, not rows.
297
297
 
298
+ Optional: an `FG` that must wait for another carries `Depends on: FG-NN[, ...]` in its own row, in English whatever the output language; `/multi-agent:analysis-jira` links the stories in that order.
299
+
298
300
  ### 5.(N+4) Servis Detayları
299
301
 
300
302
  One table per service. Contracts come from the live specification, never from prose (Locked 17: every status code the endpoint returns is listed, with an example body).
@@ -263,7 +263,7 @@ Then <expected outcome>
263
263
 
264
264
  Locked 30 - the traceability spine. Every business rule is deterministic and testable (same input always yields the same outcome) and carries a stable id `BR-<slug>-NN`.
265
265
 
266
- **The rule statement itself is written in EARS**, not free prose. EARS (Easy Approach to Requirements Syntax, IEEE RE'09) constrains the sentence to a fixed clause order and a small keyword set, which is what removes the ambiguity a prose rule carries. Five patterns, use the one that fits:
266
+ **The rule statement itself is written in EARS**, not free prose. EARS (Easy Approach to Requirements Syntax, IEEE RE'09) constrains the sentence to a fixed clause order and a small keyword set, which removes the ambiguity of a prose rule. Five patterns, use the one that fits:
267
267
 
268
268
  | Pattern | Shape | Use when |
269
269
  |---|---|---|
@@ -273,7 +273,7 @@ Locked 30 - the traceability spine. Every business rule is deterministic and tes
273
273
  | Unwanted | `IF <unwanted condition> THEN THE SYSTEM SHALL <behaviour>` | error and edge handling |
274
274
  | Optional | `WHERE <feature is included> THE SYSTEM SHALL <behaviour>` | applies only to some configurations |
275
275
 
276
- EARS states the rule; Gherkin states how you check it. Each rule still maps to at least one acceptance criterion written Given / When / Then. Each acceptance criterion is then realized as unit-test scenarios in Section 15.1. Section 15 renders whenever there is evidence for it, and the traceability gate applies to whatever was rendered. Rules come from the spec / Confluence / Jira / annotations, never invented; a rule with no source cites `[label TBD - see Open Questions]` and adds a Section 20 row.
276
+ EARS states the rule; Gherkin states how you check it. Each rule maps to >=1 Given / When / Then acceptance criterion, each realized as unit-test scenarios in Section 15.1. Section 15 renders whenever evidence exists; the traceability gate applies to whatever rendered. Rules come from the spec / Confluence / Jira / annotations, never invented; a rule with no source cites `[label TBD - see Open Questions]` and adds a Section 20 row.
277
277
 
278
278
  | BR ID | Kural / Rule (deterministic, testable) | Kaynak / Source | Kabul kriteri / Acceptance criterion (Given/When/Then) |
279
279
  |---|---|---|---|
@@ -283,6 +283,8 @@ EARS states the rule; Gherkin states how you check it. Each rule still maps to a
283
283
 
284
284
  Each scenario in 4.1-4.3 cites the matching `BR-<slug>-NN` id (and, when a server code applies, the Section 9.4 code). Plain-prose user stories without Given/When/Then are rejected by the renderer.
285
285
 
286
+ Optional: a row may add `Depends on: BR-<slug>-NN` (`features/analysis-jira.md`).
287
+
286
288
  ### 4.5 Mevcut Davranış / Current Behaviour (OPTIONAL)
287
289
 
288
290
  `redesign: true` only. Locked 36; contract and checks in `analysis/redesign.md`. Evidence and certainty are derived from `repoEvidence`, never graded by the writer.
@@ -1,7 +1,7 @@
1
1
  # Cross-CLI Contract (Claude Code · Copilot CLI · Codex CLI)
2
2
 
3
3
  <!-- toc -->
4
- - [1. Command Inventory (64 commands)](#1-command-inventory-64-commands)
4
+ - [1. Command Inventory (66 commands)](#1-command-inventory-66-commands)
5
5
  - [2. Canonical Placeholder Vocabulary](#2-canonical-placeholder-vocabulary)
6
6
  - [2.6 Intentional structural divergence - thin dispatcher vs inlined orchestrator](#26-intentional-structural-divergence---thin-dispatcher-vs-inlined-orchestrator)
7
7
  - [2.7 One command, three different meanings: `/multi-agent:model`](#27-one-command-three-different-meanings-multi-agentmodel)
@@ -20,14 +20,14 @@
20
20
 
21
21
  ---
22
22
 
23
- ## 1. Command Inventory (64 commands)
23
+ ## 1. Command Inventory (66 commands)
24
24
 
25
25
  ```
26
26
  analysis, analysis-jira, analysis-resolve, autopilot, autopilot-off,
27
27
  autopilot-on, autopilot-status, bug-bash, build-optimize, channels, complaint-analysis,
28
- create-jira, design-check, diff-explain, doctor, estimate, feedback, forget,
28
+ create-jira, deps, design-check, diff-explain, doctor, estimate, feedback, forget,
29
29
  garbage-collect, graph, help, ios-coding-standard, issue, jira, kill,
30
- language, log, manual-test, model, prune-logs,
30
+ language, log, manual-test, model, postmortem, prune-logs,
31
31
  prune-prompts, purge, refactor, research, resume, review,
32
32
  review-analysis, review-issue, review-jira, route-off, route-on,
33
33
  route-status, routines, save, scaffold, scan, scenario-audit, search,
@@ -40,9 +40,9 @@ Categories:
40
40
 
41
41
  - **Interactive pickers** (single-purpose, not modes): `jira`, `issue`
42
42
  - **Issue generator** (one-shot, no worktree, asks type Task/Bug/Story, hard approval gate before create): `create-jira`
43
- - **Pipeline entries**: `autopilot` (plus the bare `/multi-agent` in the dispatcher). The phase set is a property of the command, and every mode runs its whole set.
43
+ - **Pipeline entries**: `autopilot`, `deps` (plus the bare `/multi-agent` in the dispatcher). The phase set is a property of the command, and every mode runs its whole set.
44
44
  - **Tail path** (the pipeline tail over work already on a branch, no run behind it): `resume`
45
- - **Ops commands** (one-shot, no worktree): `status`, `log`, `kill`, `steer`, `purge`, `uninstall`, `resume`, `review`, `review-jira`, `review-issue`, `analysis`, `analysis-resolve`, `research`, `scaffold`, `complaint-analysis`, `bug-bash`, `build-optimize`, `channels`, `scan`, `search`, `diff-explain`, `garbage-collect`, `graph`, `prune-logs`, `prune-prompts`, `serve`
45
+ - **Ops commands** (one-shot, no worktree): `status`, `log`, `kill`, `steer`, `purge`, `uninstall`, `resume`, `review`, `review-jira`, `review-issue`, `analysis`, `analysis-resolve`, `research`, `scaffold`, `complaint-analysis`, `bug-bash`, `build-optimize`, `channels`, `scan`, `search`, `diff-explain`, `garbage-collect`, `graph`, `postmortem`, `prune-logs`, `prune-prompts`, `serve`
46
46
  - **Local audits** (worktree only to build; no commit, push, PR or channels): `design-check`, `testflight-validation`, `ios-coding-standard`. `testflight-validation` additionally never invokes `altool --upload-app` - a validation run must not be able to ship a build by accident.
47
47
  - **Meta-ops**: `setup`, `sync`, `update`, `help`, `refactor`, `test`, `stack`, `manual-test`, `language`
48
48
  - **Routines** (user-defined routine registry; the routines they create are local-only and never synced): `save`, `routines`, `forget`
@@ -262,8 +262,8 @@ that quietly no-ops is worse than one that says it cannot act.
262
262
 
263
263
  | Host | What `model on|off` does | Why |
264
264
  |---|---|---|
265
- | Claude Code | live switch: flips `modelFallback.fableEnabled` and realigns `costBudget.pricingModel` | the only host where the `fable` rung is Fable 5 |
266
- | Copilot CLI | writes the preference, changes no dispatch | Fable 5 is not offered there; its personas never sat on this rung |
265
+ | Claude Code | live switch: flips `modelFallback.fableEnabled` and realigns `costBudget.pricingModel` | the only host where the `fable` rung is Fable 5.1 |
266
+ | Copilot CLI | writes the preference, changes no dispatch | Fable is not offered there; its personas never sat on this rung |
267
267
  | Codex CLI | writes the preference, changes no dispatch, **deliberately** | the `fable` rung on Codex means `gpt-5.6 @ xhigh` - a different model on a different account. A knob named after an Anthropic model must not silently retune a Codex run |
268
268
 
269
269
  `model-rung.sh` prints which of the three it is on every invocation. The rule it
@@ -440,9 +440,8 @@ what keeps a secret off argv and what writes the audit entry.
440
440
  something that is not a timestamp.
441
441
  - `sed -E`, never `-r`; `sed -i ''` when editing in place.
442
442
 
443
- Clipboard callers may use `pbpaste` / `pbcopy` directly; the three-way
444
- `command -v` guard they used to carry was for hosts this package no longer
445
- installs on.
443
+ Clipboard callers may use `pbpaste` / `pbcopy` directly; a `command -v` guard
444
+ for other clipboard tools would serve hosts this package does not install on.
446
445
 
447
446
  ---
448
447