@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,406 @@
1
+ /**
2
+ * @file _transcript-hosts.mjs - Copilot CLI and Codex session logs, read as
3
+ * the Claude Code records transcript-index.mjs already understands.
4
+ *
5
+ * Each host writes a different JSONL:
6
+ *
7
+ * claude ~/.claude/projects/<cwd slug>/<session>.jsonl: `type` user /
8
+ * assistant / system, the message under `message`
9
+ * copilot ~/.copilot/session-state/<session>/events.jsonl: dotted event
10
+ * types (`session.start`, `assistant.message`,
11
+ * `tool.execution_complete`, ...), the body under `data`
12
+ * codex ~/.codex/sessions/YYYY/MM/DD/rollout-<time>-<session>.jsonl:
13
+ * `type` session_meta / response_item / event_msg / compacted /
14
+ * turn_context, the body under `payload`
15
+ *
16
+ * detectFormat() names the format from one record. createAdapter() returns a
17
+ * function that turns each record of a file into zero or more records in the
18
+ * Claude Code shape, so the indexer has one reading loop and every finding
19
+ * still cites the line of the file it came from. Two record kinds exist only
20
+ * here and are handled by the indexer directly:
21
+ *
22
+ * x-usage token use the host reports outside a message (Codex
23
+ * token_count, Copilot CLI's shutdown totals)
24
+ * a user record with `subagentStats`, carrying a background subagent's
25
+ * tokens and duration with its completion notice
26
+ *
27
+ * A tool result may carry `denied: true` when the host marks a refusal with a
28
+ * code rather than a message.
29
+ *
30
+ * @module pipeline/scripts/_transcript-hosts
31
+ */
32
+
33
+ const CODEX_TYPES = new Set([
34
+ "session_meta",
35
+ "response_item",
36
+ "event_msg",
37
+ "compacted",
38
+ "turn_context",
39
+ ]);
40
+
41
+ /** "claude", "copilot" or "codex" for one parsed record. */
42
+ export function detectFormat(rec) {
43
+ if (!rec || typeof rec !== "object") return "claude";
44
+ if (
45
+ typeof rec.type === "string" &&
46
+ rec.type.includes(".") &&
47
+ rec.data &&
48
+ typeof rec.data === "object"
49
+ )
50
+ return "copilot";
51
+ if (CODEX_TYPES.has(rec.type) && rec.payload && typeof rec.payload === "object") return "codex";
52
+ return "claude";
53
+ }
54
+
55
+ const asObject = (raw) => {
56
+ if (raw && typeof raw === "object" && !Array.isArray(raw)) return raw;
57
+ if (typeof raw !== "string") return {};
58
+ try {
59
+ const v = JSON.parse(raw);
60
+ return v && typeof v === "object" && !Array.isArray(v) ? v : { arguments: raw };
61
+ } catch {
62
+ return { arguments: raw };
63
+ }
64
+ };
65
+
66
+ const num = (v) => (Number.isFinite(Number(v)) ? Number(v) : 0);
67
+
68
+ /** A prompt as the indexer reads it: a leading slash command becomes a command record. */
69
+ function promptRecord(timestamp, text) {
70
+ const t = String(text ?? "");
71
+ const slash = t.trim().match(/^(\/[^\s<]+)/);
72
+ const content = slash ? `<command-name>${slash[1]}</command-name>` : t;
73
+ return { type: "user", timestamp, message: { role: "user", content } };
74
+ }
75
+
76
+ const interrupt = (timestamp, reason) => ({
77
+ type: "user",
78
+ timestamp,
79
+ message: {
80
+ role: "user",
81
+ content: `[Request interrupted by user]${reason ? ` (${reason})` : ""}`,
82
+ },
83
+ });
84
+
85
+ const toolResult = (timestamp, id, body, isError, extra = {}) => ({
86
+ type: "user",
87
+ timestamp,
88
+ message: {
89
+ role: "user",
90
+ content: [
91
+ {
92
+ type: "tool_result",
93
+ tool_use_id: id,
94
+ content: body,
95
+ is_error: isError,
96
+ ...(extra.denied ? { denied: true } : {}),
97
+ },
98
+ ],
99
+ },
100
+ ...(extra.toolUseResult !== undefined ? { toolUseResult: extra.toolUseResult } : {}),
101
+ });
102
+
103
+ const notice = (timestamp, id, status, stats) => ({
104
+ type: "user",
105
+ timestamp,
106
+ message: {
107
+ role: "user",
108
+ content: `<task-notification><task-id>${id}</task-id><status>${status}</status></task-notification>`,
109
+ },
110
+ subagentStats: stats,
111
+ });
112
+
113
+ // --- Copilot CLI -----------------------------------------------------------------
114
+
115
+ const COPILOT_SUBAGENT_TOOL = "task";
116
+ const COPILOT_DENIAL_CODES = new Set(["denied", "rejected"]);
117
+
118
+ function copilotAdapter() {
119
+ const toolNames = new Map();
120
+ const finished = new Map();
121
+ const counted = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
122
+ let shutdown = false;
123
+
124
+ const adapt = (rec) => {
125
+ const d = rec.data || {};
126
+ const ts = rec.timestamp;
127
+ switch (rec.type) {
128
+ case "user.message":
129
+ return [promptRecord(ts, d.content)];
130
+ case "assistant.message": {
131
+ const content = [];
132
+ if (typeof d.content === "string" && d.content.trim())
133
+ content.push({ type: "text", text: d.content });
134
+ for (const r of Array.isArray(d.toolRequests) ? d.toolRequests : []) {
135
+ if (!r || typeof r.name !== "string") continue;
136
+ if (r.toolCallId) toolNames.set(r.toolCallId, r.name);
137
+ content.push({
138
+ type: "tool_use",
139
+ id: r.toolCallId,
140
+ name: r.name,
141
+ input: asObject(r.arguments),
142
+ });
143
+ }
144
+ const out = num(d.outputTokens);
145
+ counted.output += out;
146
+ return [
147
+ {
148
+ type: "assistant",
149
+ timestamp: ts,
150
+ isSidechain: Boolean(d.parentToolCallId),
151
+ message: {
152
+ id: d.messageId,
153
+ role: "assistant",
154
+ content,
155
+ ...(d.outputTokens !== undefined ? { usage: { output_tokens: out } } : {}),
156
+ },
157
+ },
158
+ ];
159
+ }
160
+ case "tool.execution_complete": {
161
+ const ok = d.success !== false;
162
+ const body = ok
163
+ ? String(d.result?.content ?? "")
164
+ : String(d.error?.message ?? d.result?.content ?? "failed");
165
+ const denied = !ok && COPILOT_DENIAL_CODES.has(d.error?.code);
166
+ let toolUseResult;
167
+ if (toolNames.get(d.toolCallId) === COPILOT_SUBAGENT_TOOL) {
168
+ const stats = finished.get(d.toolCallId);
169
+ toolUseResult = stats
170
+ ? {
171
+ status: stats.status,
172
+ agentId: d.toolCallId,
173
+ totalTokens: stats.tokens,
174
+ totalDurationMs: stats.durationMs,
175
+ totalToolUseCount: stats.toolUses,
176
+ }
177
+ : { status: ok ? "async_launched" : "error", agentId: d.toolCallId };
178
+ }
179
+ return [toolResult(ts, d.toolCallId, body, !ok, { denied, toolUseResult })];
180
+ }
181
+ case "subagent.completed":
182
+ case "subagent.failed": {
183
+ const stats = {
184
+ status: rec.type === "subagent.failed" ? "failed" : "completed",
185
+ tokens: Number.isFinite(d.totalTokens) ? d.totalTokens : undefined,
186
+ durationMs: Number.isFinite(d.durationMs) ? d.durationMs : undefined,
187
+ toolUses: Number.isFinite(d.totalToolCalls) ? d.totalToolCalls : undefined,
188
+ };
189
+ finished.set(d.toolCallId, stats);
190
+ return [notice(ts, d.toolCallId, stats.status, stats)];
191
+ }
192
+ case "session.compaction_complete":
193
+ if (d.success === false)
194
+ return [{ type: "system", timestamp: ts, level: "error", content: "compaction failed" }];
195
+ return [
196
+ {
197
+ type: "system",
198
+ subtype: "compact_boundary",
199
+ timestamp: ts,
200
+ compactMetadata: { preTokens: d.preCompactionTokens },
201
+ },
202
+ ];
203
+ case "hook.end":
204
+ if (d.success !== false) return [];
205
+ return [
206
+ {
207
+ type: "system",
208
+ timestamp: ts,
209
+ hookErrors: [`${d.hookType || "hook"}: ${d.error?.message || "failed"}`],
210
+ },
211
+ ];
212
+ case "session.error":
213
+ return [
214
+ {
215
+ type: "system",
216
+ timestamp: ts,
217
+ level: "error",
218
+ content: `${d.errorType ? `${d.errorType}: ` : ""}${d.message || ""}`,
219
+ },
220
+ ];
221
+ case "abort":
222
+ return [interrupt(ts, d.reason)];
223
+ case "session.shutdown": {
224
+ shutdown = true;
225
+ const total = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
226
+ for (const m of Object.values(d.modelMetrics || {})) {
227
+ const u = m?.usage || {};
228
+ total.input += num(u.inputTokens);
229
+ total.output += num(u.outputTokens);
230
+ total.cacheRead += num(u.cacheReadTokens);
231
+ total.cacheWrite += num(u.cacheWriteTokens);
232
+ }
233
+ const add = {};
234
+ for (const k of Object.keys(total)) {
235
+ add[k] = Math.max(0, total[k] - counted[k]);
236
+ counted[k] += add[k];
237
+ }
238
+ return [
239
+ { type: "x-usage", timestamp: ts, usage: add, countsMessage: false, inTurn: false },
240
+ ];
241
+ }
242
+ default:
243
+ return [];
244
+ }
245
+ };
246
+ // Input and cache tokens arrive only in session.shutdown; without it the
247
+ // session's usage is its output tokens alone.
248
+ adapt.usagePartial = () => !shutdown;
249
+ return adapt;
250
+ }
251
+
252
+ // --- Codex -------------------------------------------------------------------------
253
+
254
+ const EXIT_CODE = /(?:^|\n)(?:Process exited with code|Exit code:?)\s*(-?\d+)/;
255
+ const WALL_TIME = /(?:^|\n)Wall time:\s*([\d.]+)\s*seconds?/;
256
+
257
+ function codexOutput(raw) {
258
+ if (typeof raw === "string") {
259
+ try {
260
+ const v = JSON.parse(raw);
261
+ if (v && typeof v === "object" && !Array.isArray(v) && ("output" in v || "metadata" in v)) {
262
+ const meta = v.metadata || {};
263
+ return {
264
+ text: String(v.output ?? ""),
265
+ exitCode: Number.isFinite(meta.exit_code) ? meta.exit_code : null,
266
+ durationMs: Number.isFinite(meta.duration_seconds)
267
+ ? Math.round(meta.duration_seconds * 1000)
268
+ : null,
269
+ };
270
+ }
271
+ } catch {
272
+ // Plain text output.
273
+ }
274
+ const code = raw.match(EXIT_CODE);
275
+ const wall = raw.match(WALL_TIME);
276
+ return {
277
+ text: raw,
278
+ exitCode: code ? Number(code[1]) : null,
279
+ durationMs: wall ? Math.round(Number(wall[1]) * 1000) : null,
280
+ };
281
+ }
282
+ if (Array.isArray(raw)) {
283
+ const text = raw
284
+ .map((b) => (typeof b === "string" ? b : typeof b?.text === "string" ? b.text : ""))
285
+ .filter(Boolean)
286
+ .join("\n");
287
+ return codexOutput(text);
288
+ }
289
+ if (raw && typeof raw === "object") return codexOutput(raw.content ?? JSON.stringify(raw));
290
+ return { text: "", exitCode: null, durationMs: null };
291
+ }
292
+
293
+ function codexInput(name, raw) {
294
+ const input = asObject(raw);
295
+ for (const k of ["command", "cmd"]) {
296
+ if (Array.isArray(input[k])) return { ...input, [k]: input[k].join(" ") };
297
+ }
298
+ return input;
299
+ }
300
+
301
+ function codexAdapter() {
302
+ let lastTotal = null;
303
+
304
+ const toolUse = (ts, id, name, input) => ({
305
+ type: "assistant",
306
+ timestamp: ts,
307
+ message: { role: "assistant", content: [{ type: "tool_use", id, name, input }] },
308
+ });
309
+
310
+ return (rec) => {
311
+ const p = rec.payload || {};
312
+ const ts = rec.timestamp;
313
+ if (rec.type === "compacted")
314
+ return [{ type: "system", subtype: "compact_boundary", timestamp: ts, compactMetadata: {} }];
315
+ if (rec.type === "event_msg") {
316
+ switch (p.type) {
317
+ case "user_message":
318
+ return [promptRecord(ts, p.message)];
319
+ case "token_count": {
320
+ const info = p.info;
321
+ const total = info?.total_token_usage?.total_tokens;
322
+ if (!info || total === lastTotal) return [];
323
+ lastTotal = total;
324
+ const u = info.last_token_usage || {};
325
+ const cached = num(u.cached_input_tokens);
326
+ return [
327
+ {
328
+ type: "x-usage",
329
+ timestamp: ts,
330
+ usage: {
331
+ input: Math.max(0, num(u.input_tokens) - cached),
332
+ output: num(u.output_tokens),
333
+ cacheRead: cached,
334
+ cacheWrite: num(u.cache_write_input_tokens),
335
+ },
336
+ countsMessage: true,
337
+ inTurn: true,
338
+ },
339
+ ];
340
+ }
341
+ case "error":
342
+ return [{ type: "system", timestamp: ts, level: "error", content: p.message || "" }];
343
+ case "turn_aborted":
344
+ return [interrupt(ts, p.reason)];
345
+ default:
346
+ return [];
347
+ }
348
+ }
349
+ if (rec.type !== "response_item") return [];
350
+ switch (p.type) {
351
+ case "message":
352
+ if (p.role !== "assistant") return [];
353
+ return [
354
+ {
355
+ type: "assistant",
356
+ timestamp: ts,
357
+ message: {
358
+ role: "assistant",
359
+ content: (Array.isArray(p.content) ? p.content : [])
360
+ .filter((b) => typeof b?.text === "string")
361
+ .map((b) => ({ type: "text", text: b.text })),
362
+ },
363
+ },
364
+ ];
365
+ case "function_call":
366
+ return [toolUse(ts, p.call_id, p.name, codexInput(p.name, p.arguments))];
367
+ case "custom_tool_call":
368
+ return [toolUse(ts, p.call_id, p.name, { input: String(p.input ?? "") })];
369
+ case "local_shell_call":
370
+ return [toolUse(ts, p.call_id, "local_shell", codexInput("local_shell", p.action || {}))];
371
+ case "web_search_call":
372
+ return [toolUse(ts, undefined, "web_search", { query: p.action?.query ?? "" })];
373
+ case "function_call_output":
374
+ case "custom_tool_call_output": {
375
+ const out = codexOutput(p.output);
376
+ const failed = (out.exitCode !== null && out.exitCode !== 0) || p.output?.success === false;
377
+ return [
378
+ toolResult(ts, p.call_id, out.text, failed, {
379
+ toolUseResult: out.durationMs !== null ? { durationMs: out.durationMs } : undefined,
380
+ }),
381
+ ];
382
+ }
383
+ default:
384
+ return [];
385
+ }
386
+ };
387
+ }
388
+
389
+ /**
390
+ * The record converter for `format`: a function from one parsed record to the
391
+ * Claude Code records it stands for (often none). A Copilot CLI converter also
392
+ * has `usagePartial()`, true while the session has written no shutdown record.
393
+ */
394
+ export function createAdapter(format) {
395
+ if (format === "copilot") return copilotAdapter();
396
+ if (format === "codex") return codexAdapter();
397
+ return (rec) => [rec];
398
+ }
399
+
400
+ /** The working directory a Copilot CLI or Codex session started in, from its opening record. */
401
+ export function sessionCwd(rec) {
402
+ const format = detectFormat(rec);
403
+ if (format === "copilot" && rec.type === "session.start") return rec.data?.context?.cwd ?? null;
404
+ if (format === "codex" && rec.type === "session_meta") return rec.payload?.cwd ?? null;
405
+ return null;
406
+ }
@@ -62,6 +62,7 @@ import { homedir } from "node:os";
62
62
  import { dirname, join } from "node:path";
63
63
  import { hostCommand } from "../lib/user-facing.mjs";
64
64
  import { lockTimeoutFrom, updateJsonFileSync } from "../lib/json-file-lock.mjs";
65
+ import { analyzeGraph, cyclicComponentOf } from "../lib/task-graph.mjs";
65
66
  import { resolveConstitution } from "./constitution.mjs";
66
67
  import { gateContext, recordQuietly } from "./gate-ledger.mjs";
67
68
  import {
@@ -170,6 +171,7 @@ export function identityLabel(prefix, docId, sourceIds) {
170
171
  * issueTree.cloneLinkType the link type between a story and its clones
171
172
  * issueTree.storyPanels story section panel colours, or false for none
172
173
  * issueTree.subtaskRolesDeclined the person chose no sub-tasks; do not ask again
174
+ * issueTree.dependencyLinkType the link type written for a story dependency
173
175
  */
174
176
  const ISSUE_TREE_SETTINGS = [
175
177
  "issueTree.enabled",
@@ -187,6 +189,7 @@ const ISSUE_TREE_SETTINGS = [
187
189
  "issueTree.cloneLinkType",
188
190
  "issueTree.storyPanels",
189
191
  "issueTree.subtaskRolesDeclined",
192
+ "issueTree.dependencyLinkType",
190
193
  ];
191
194
 
192
195
  function readPrefs() {
@@ -216,6 +219,100 @@ export function subtaskTypeFor(setting, role) {
216
219
  return null;
217
220
  }
218
221
 
222
+ const DEPENDS_ON = /\bdepends on:([^|\n]*)/gi;
223
+
224
+ /**
225
+ * The `Depends on: <ids>` clauses of a document's requirement rows.
226
+ *
227
+ * A clause belongs to the table row it sits in, in any cell, so a trailing
228
+ * column under any header works and so does a note appended to a cell. The
229
+ * row's subject is the first requirement id left once the clause is removed.
230
+ * Clauses outside a table row are not read: without a row there is no subject.
231
+ *
232
+ * `stripped` is the text with every read clause removed. Ids are defined by
233
+ * where they appear, so a reference left in place would define the id it
234
+ * points at, and a dependency on a requirement nobody wrote would plan a story
235
+ * for it instead of being reported.
236
+ *
237
+ * @returns {{deps: Map<string, Set<string>>, stripped: string}}
238
+ */
239
+ export function dependencyClauses(text, corporate) {
240
+ const atom = corporate ? ATOM_CORP : ATOM_GLOBAL;
241
+ const deps = new Map();
242
+ const lines = text.split("\n");
243
+ for (const [i, line] of lines.entries()) {
244
+ if (!/^\s*\|/.test(line) || !/\bdepends on:/i.test(line)) continue;
245
+ const refs = [];
246
+ const rest = line.replace(DEPENDS_ON, (clause, ids) => {
247
+ const found = ids.match(atom);
248
+ if (!found) return clause;
249
+ refs.push(...found.map((x) => x.toUpperCase()));
250
+ return "";
251
+ });
252
+ const subject = (rest.match(atom) || [])[0]?.toUpperCase();
253
+ if (!subject || !refs.length) continue;
254
+ lines[i] = rest;
255
+ for (const r of refs) {
256
+ if (r === subject) continue;
257
+ if (!deps.has(subject)) deps.set(subject, new Set());
258
+ deps.get(subject).add(r);
259
+ }
260
+ }
261
+ return { deps, stripped: lines.join("\n") };
262
+ }
263
+
264
+ /**
265
+ * Requirement dependencies, read as story-to-story edges and checked as a
266
+ * graph. A story depends on another when one of its source ids depends on a
267
+ * source id of the other; two ids of one story give no edge. A dependency on
268
+ * an id no story covers, and a cycle between stories, are problems: the plan
269
+ * reports them and is not `ok`. An edge whose two stories sit in one strongly
270
+ * connected component carries `onCycle: true`; such an edge has no order to
271
+ * record, so it is never linked and never counted as a write.
272
+ */
273
+ function storyDependencies(stories, reqDeps) {
274
+ const coverers = new Map();
275
+ for (const s of stories)
276
+ for (const id of s.sourceIds) {
277
+ if (!coverers.has(id)) coverers.set(id, []);
278
+ coverers.get(id).push(s);
279
+ }
280
+ const edges = new Map();
281
+ const missing = [];
282
+ for (const s of stories) {
283
+ for (const id of s.sourceIds) {
284
+ for (const dep of reqDeps.get(id) || []) {
285
+ const targets = coverers.get(dep);
286
+ if (!targets) {
287
+ missing.push({ id, dependsOn: dep });
288
+ continue;
289
+ }
290
+ for (const t of targets) {
291
+ if (t === s) continue;
292
+ const key = `${s.label}>${t.label}`;
293
+ if (!edges.has(key))
294
+ edges.set(key, { dependent: s.label, prerequisite: t.label, via: [] });
295
+ const via = `${dep} -> ${id}`;
296
+ if (!edges.get(key).via.includes(via)) edges.get(key).via.push(via);
297
+ }
298
+ }
299
+ }
300
+ }
301
+ for (const s of stories) {
302
+ const on = [...edges.values()].filter((e) => e.dependent === s.label);
303
+ if (on.length) s.dependsOn = on.map((e) => e.prerequisite);
304
+ }
305
+ const graph = stories.map((s) => ({ id: s.label, deps: s.dependsOn || [], status: "pending" }));
306
+ const { cycles } = analyzeGraph(graph);
307
+ const component = cyclicComponentOf(graph);
308
+ for (const e of edges.values()) {
309
+ const c = component.get(e.dependent);
310
+ if (c !== undefined && c === component.get(e.prerequisite)) e.onCycle = true;
311
+ }
312
+ const unique = [...new Map(missing.map((m) => [`${m.id}>${m.dependsOn}`, m])).values()];
313
+ return { edges: [...edges.values()], cycles, missing: unique };
314
+ }
315
+
219
316
  /** Every open placeholder that makes a document unfit to become work. */
220
317
  export function openItems(text) {
221
318
  const found = [];
@@ -268,10 +365,11 @@ export function coverageOf(defined, nodes, corporate = false) {
268
365
  };
269
366
  }
270
367
 
271
- export function plan(text, prefs, { pageUrl = null } = {}) {
272
- const fm = frontMatter(text);
368
+ export function plan(source, prefs, { pageUrl = null } = {}) {
369
+ const fm = frontMatter(source);
273
370
  const profile = (fm.profile || "global").toLowerCase();
274
371
  const corporate = profile === "corporate";
372
+ const { deps: reqDeps, stripped: text } = dependencyClauses(source, corporate);
275
373
  const secs = sections(text);
276
374
 
277
375
  const atomRe = corporate ? ATOM_CORP : ATOM_GLOBAL;
@@ -423,6 +521,30 @@ export function plan(text, prefs, { pageUrl = null } = {}) {
423
521
  }
424
522
 
425
523
  const coverage = coverageOf(defined, stories, corporate);
524
+ const dependencies = reqDeps.size ? storyDependencies(stories, reqDeps) : null;
525
+ if (dependencies) {
526
+ const titleOf = new Map(stories.map((s) => [s.label, s.group]));
527
+ const linked = new Set(dependencies.edges.map((e) => `${e.dependent}>${e.prerequisite}`));
528
+ const isLoop = (c) => c.every((l, i) => linked.has(`${l}>${c[(i + 1) % c.length]}`));
529
+ const problems = [
530
+ ...dependencies.cycles.map((c) =>
531
+ isLoop(c)
532
+ ? `dependency cycle: ${[c[0], ...[...c].reverse()].map((l) => titleOf.get(l)).join(" -> ")}`
533
+ : `dependency cycle among: ${c.map((l) => titleOf.get(l)).join(", ")}`,
534
+ ),
535
+ ...dependencies.missing.map(
536
+ (m) => `${m.id} depends on ${m.dependsOn}, which no story covers`,
537
+ ),
538
+ ];
539
+ if (problems.length) {
540
+ coverage.dependencyProblems = problems;
541
+ if (coverage.verdict === "ok") coverage.verdict = "incomplete";
542
+ if (coverage.verdict === "incomplete" && !coverage.reason) {
543
+ coverage.reason = `${problems.length} dependency problem(s): ${problems.join("; ")}`;
544
+ }
545
+ }
546
+ }
547
+ const linkType = prefs.dependencyLinkType ?? "Blocks";
426
548
 
427
549
  return {
428
550
  document: {
@@ -447,12 +569,15 @@ export function plan(text, prefs, { pageUrl = null } = {}) {
447
569
  cloneLinkType: prefs.cloneLinkType ?? "Relates",
448
570
  storyPanels: prefs.storyPanels ?? null,
449
571
  subtaskRolesDeclined: prefs.subtaskRolesDeclined === true,
572
+ ...(dependencies || prefs.dependencyLinkType !== undefined
573
+ ? { dependencyLinkType: linkType }
574
+ : {}),
450
575
  },
451
576
  nodes: stories,
452
- writeCount: stories.reduce(
453
- (n, s) => n + 1 + s.subtasks.length + 2 * (s.clones?.length || 0),
454
- 0,
455
- ),
577
+ ...(dependencies ? { dependencies } : {}),
578
+ writeCount:
579
+ stories.reduce((n, s) => n + 1 + s.subtasks.length + 2 * (s.clones?.length || 0), 0) +
580
+ (dependencies?.edges.filter((e) => !e.onCycle).length || 0),
456
581
  };
457
582
  }
458
583
 
@@ -461,7 +586,9 @@ export function plan(text, prefs, { pageUrl = null } = {}) {
461
586
  *
462
587
  * @returns {object} a spec-consistency-gate result
463
588
  */
464
- function consistencyOf(text, nodes) {
589
+ function consistencyOf(source, nodes) {
590
+ const corporate = (frontMatter(source).profile || "").toLowerCase() === "corporate";
591
+ const text = dependencyClauses(source, corporate).stripped;
465
592
  const constitution = resolveConstitution({
466
593
  repo: dirname(file),
467
594
  feature: frontMatter(text).feature || null,
@@ -606,12 +733,18 @@ async function main() {
606
733
  );
607
734
  if (c.uncovered.length) process.stdout.write(` not in any story: ${c.uncovered.join(", ")}\n`);
608
735
  if (c.invented.length) process.stdout.write(` cited but undefined: ${c.invented.join(", ")}\n`);
736
+ for (const p of c.dependencyProblems || []) process.stdout.write(` ${p}\n`);
609
737
  const k = result.consistency;
610
738
  process.stdout.write(
611
739
  `consistency: ${k.verdict} (${k.advisory ? "advisory, " : ""}${consistencyDetail(k)})\n`,
612
740
  );
613
741
  for (const line of describe(k)) process.stdout.write(`${line}\n`);
614
- process.stdout.write(`\n${result.writeCount} issue(s) would be created:\n`);
742
+ const linkCount =
743
+ result.nodes.reduce((n, s) => n + (s.clones?.length || 0), 0) +
744
+ (result.dependencies?.edges.filter((e) => !e.onCycle).length || 0);
745
+ process.stdout.write(
746
+ `\n${result.writeCount - linkCount} issue(s) and ${linkCount} link(s) would be created:\n`,
747
+ );
615
748
  for (const s of result.nodes) {
616
749
  process.stdout.write(` ${result.prefsUsed.storyIssueType} ${s.title}\n`);
617
750
  process.stdout.write(
@@ -635,6 +768,18 @@ async function main() {
635
768
  );
636
769
  }
637
770
  }
771
+ const d = result.dependencies;
772
+ if (d) {
773
+ const group = new Map(result.nodes.map((n) => [n.label, n.group]));
774
+ process.stdout.write(
775
+ `\ndependencies: ${d.edges.length} ${result.prefsUsed.dependencyLinkType} link(s), the prerequisite first:\n`,
776
+ );
777
+ for (const e of d.edges) {
778
+ process.stdout.write(
779
+ ` ${group.get(e.prerequisite)} -> ${group.get(e.dependent)} (${e.via.join(", ")}) [${e.prerequisite} -> ${e.dependent}]${e.onCycle ? " not linked: on a dependency cycle" : ""}\n`,
780
+ );
781
+ }
782
+ }
638
783
  // Every field's provenance, so a wrong setting is visible here rather than in
639
784
  // Jira after the writes have happened.
640
785
  process.stdout.write("\nfrom prefs.global.issueTree:\n");
@@ -13,7 +13,9 @@
13
13
  * 1. The request, the state and the test log are read as bounded regular
14
14
  * files (lib/regular-file.mjs): a FIFO, a device or an oversized file is
15
15
  * refused, never read. The request is read once and validated in process
16
- * (pr-request.mjs validatePrRequest) on those same bytes.
16
+ * (pr-request.mjs validatePrRequest) on those same bytes. The state's
17
+ * `decisions[]` is appended to the body as one redacted section
18
+ * (lib/decisions.mjs), so the PR lists what the run chose unasked.
17
19
  * 2. The repository comes from the runner's own config (the queue item's
18
20
  * `nameWithOwner`), never from the request. A request naming another
19
21
  * repository is refused, not redirected. The base is the configured
@@ -104,6 +106,7 @@ import {
104
106
  import { tmpdir } from "node:os";
105
107
  import { dirname, isAbsolute, join } from "node:path";
106
108
 
109
+ import { appendDecisionsSection } from "../lib/decisions.mjs";
107
110
  import { invokedDirectly } from "../lib/invoked-directly.mjs";
108
111
  import { scan } from "../lib/outbound-gate.mjs";
109
112
  import { prRequestPath, prRequestsDir, prVerdictPath } from "../lib/pr-request-location.mjs";
@@ -123,6 +126,7 @@ const GIT_TIMEOUT_MS = 60 * 1000;
123
126
  const NET_TIMEOUT_MS = 5 * 60 * 1000;
124
127
  // A valid request is at most a 256-character title and a 65536-character body.
125
128
  const REQUEST_MAX_BYTES = 1024 * 1024;
129
+ const BODY_MAX_CHARS = 65536;
126
130
  const STATE_MAX_BYTES = 16 * 1024 * 1024;
127
131
  const LOG_MAX_BYTES = 64 * 1024 * 1024;
128
132
  const VERIFY_TIMEOUT_SEC = 30 * 60;
@@ -1268,6 +1272,13 @@ export async function publish({ sessionId, statePath, item, config, env = proces
1268
1272
  }
1269
1273
 
1270
1274
  req = readRequest(reqPath);
1275
+ // What the run decided in place of a person goes into the PR it opens. The
1276
+ // section is redacted when rendered and passes the outbound gate below with
1277
+ // the rest of the body.
1278
+ req = {
1279
+ ...req,
1280
+ body: appendDecisionsSection(req.body, state.decisions, { maxChars: BODY_MAX_CHARS }),
1281
+ };
1271
1282
 
1272
1283
  const repoCfg = (config?.repos || []).find((r) => r && r.nameWithOwner === repo);
1273
1284
  if (!repo || !repoCfg)
@@ -177,9 +177,9 @@ for (const skill of allSkills) {
177
177
  // --- 2b. routing audit ---------------------------------------------------
178
178
  // The loop above adds a skill to EVERY plugin whose pattern matches, so a bare
179
179
  // substring alternative that also reads as a suffix of another stack's skill
180
- // name ships that skill into two plugins. `architecture` did that to
181
- // android-architecture and swift-architecture, which is how ai-backend-toolkit
182
- // came to carry Compose and SwiftUI guidance.
180
+ // name ships that skill into two plugins. A bare `architecture` matches
181
+ // android-architecture and swift-architecture, and would put Compose and
182
+ // SwiftUI guidance into ai-backend-toolkit.
183
183
  //
184
184
  // --check-routing runs this audit alone. It reads nothing but the authoring
185
185
  // source in this repo, so CI can gate it without a checkout of the plugins repo.