@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.
- package/CHANGELOG.md +46 -0
- package/README.md +8 -6
- package/README.tr.md +7 -5
- package/docs/architecture.md +3 -3
- package/docs/ecosystem.md +5 -5
- package/docs/facts.json +7 -6
- package/docs/features.md +9 -4
- package/install/catalog-history.json +1 -1
- package/install/templates/claude-hooks.json +14 -1
- package/install/templates/codex-hooks.json +18 -0
- package/manifest.json +126 -98
- package/package.json +1 -1
- package/pipeline/agents/task-clarifier.md +1 -1
- package/pipeline/commands/multi-agent/analysis-jira/SKILL.md +4 -2
- package/pipeline/commands/multi-agent/deps/SKILL.md +61 -0
- package/pipeline/commands/multi-agent/model/SKILL.md +2 -2
- package/pipeline/commands/multi-agent/postmortem/SKILL.md +90 -0
- package/pipeline/commands/multi-agent/review/SKILL.md +5 -5
- package/pipeline/commands/multi-agent/route-on/SKILL.md +1 -1
- package/pipeline/commands/multi-agent/setup/SKILL.md +7 -5
- package/pipeline/commands/multi-agent/sync/SKILL.md +4 -4
- package/pipeline/commands/multi-agent/testflight-validation/SKILL.md +2 -3
- package/pipeline/contract/fixtures/runs-failed.json +1 -1
- package/pipeline/contract/fixtures/runs-old-schema.json +1 -1
- package/pipeline/contract/fixtures/runs-pr-opened-redacted.json +1 -1
- package/pipeline/contract/fixtures/runs-pr-opened.json +1 -1
- package/pipeline/contract/fixtures/runs-running.json +1 -1
- package/pipeline/lib/analysis-jira-write.sh +70 -0
- package/pipeline/lib/credential-inventory.sh +5 -5
- package/pipeline/lib/decisions.mjs +130 -0
- package/pipeline/lib/fetch-crashlytics.sh +6 -8
- package/pipeline/lib/model-rung.sh +1 -1
- package/pipeline/lib/plan-todos.sh +177 -37
- package/pipeline/lib/post-pr-review.sh +4 -6
- package/pipeline/lib/route-state.sh +1 -1
- package/pipeline/lib/shadow-git.sh +4 -5
- 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 +10 -11
- 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/cost-analysis.md +4 -0
- package/pipeline/multi-agent-refs/features/dep-bump.md +176 -0
- package/pipeline/multi-agent-refs/features/model-fallback.md +21 -14
- 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 +2 -0
- package/pipeline/multi-agent-refs/help/tr.md +2 -0
- package/pipeline/multi-agent-refs/phases/phase-0-init.md +1 -1
- package/pipeline/multi-agent-refs/phases/phase-1-plan.md +2 -3
- package/pipeline/multi-agent-refs/phases/phase-2-dev.md +7 -7
- package/pipeline/multi-agent-refs/phases/phase-3-review.md +18 -16
- 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/multi-agent-refs/tracker-contract.md +6 -6
- package/pipeline/rules/response-shape.md +18 -0
- package/pipeline/schemas/agent-state.schema.json +36 -1
- package/pipeline/schemas/phases.json +4 -0
- package/pipeline/schemas/plan-todos.schema.json +104 -3
- package/pipeline/schemas/planning-output.schema.json +46 -0
- package/pipeline/schemas/prefs.schema.json +122 -3
- package/pipeline/scripts/_code-graph.mjs +2 -2
- 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/_transcript-hosts.mjs +406 -0
- package/pipeline/scripts/analysis-story-tree.mjs +153 -8
- package/pipeline/scripts/autopilot-publish.mjs +12 -1
- package/pipeline/scripts/build-stack-plugins.mjs +3 -3
- package/pipeline/scripts/capture-flush.sh +7 -7
- package/pipeline/scripts/code-graph-rules/android.json +1 -1
- package/pipeline/scripts/cost-analyze.mjs +32 -43
- package/pipeline/scripts/cost-table.json +35 -19
- package/pipeline/scripts/decisions-log.mjs +118 -0
- package/pipeline/scripts/dep-bump.mjs +1556 -0
- package/pipeline/scripts/edit-syntax-gate.mjs +362 -0
- package/pipeline/scripts/edit-syntax-gate.sh +32 -0
- package/pipeline/scripts/find-polluter.mjs +281 -0
- package/pipeline/scripts/gate-ledger.mjs +46 -13
- package/pipeline/scripts/gc-abandoned.sh +2 -2
- package/pipeline/scripts/gen-mode-dispatch.mjs +3 -3
- package/pipeline/scripts/git-path.mjs +1 -2
- package/pipeline/scripts/host-hooks.mjs +265 -0
- package/pipeline/scripts/jira-attach.sh +1 -1
- package/pipeline/scripts/keychain.py +3 -3
- package/pipeline/scripts/mutation-probe.mjs +484 -0
- package/pipeline/scripts/output-quality-check.sh +1 -1
- package/pipeline/scripts/phase-tracker.sh +5 -6
- package/pipeline/scripts/phase0-exit-gate.mjs +3 -3
- package/pipeline/scripts/plan-complexity.mjs +259 -0
- package/pipeline/scripts/pre-push-check.sh +3 -6
- 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 +1131 -0
- package/pipeline/scripts/uninstall.mjs +90 -4
- package/pipeline/scripts/update-issue-progress.sh +4 -6
- package/pipeline/scripts/usage-report.mjs +2 -2
- package/pipeline/scripts/validate-planning.mjs +48 -6
- package/pipeline/skills/.skill-manifest.json +20 -12
- package/pipeline/skills/.skills-index.json +23 -1
- package/pipeline/skills/shared/README.md +5 -3
- package/pipeline/skills/shared/core/multi-agent/SKILL.md +6 -6
- package/pipeline/skills/shared/core/multi-agent-analysis-jira/SKILL.md +4 -2
- package/pipeline/skills/shared/core/multi-agent-deps/SKILL.md +60 -0
- package/pipeline/skills/shared/core/multi-agent-model/SKILL.md +2 -2
- package/pipeline/skills/shared/core/multi-agent-postmortem/SKILL.md +69 -0
- package/pipeline/skills/shared/core/multi-agent-review/SKILL.md +5 -5
- package/pipeline/skills/shared/core/multi-agent-route-on/SKILL.md +1 -1
- package/pipeline/skills/shared/core/multi-agent-setup/SKILL.md +7 -5
- package/pipeline/skills/shared/core/multi-agent-sync/SKILL.md +4 -4
- package/pipeline/skills/shared/core/multi-agent-testflight-validation/SKILL.md +2 -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/shared/external/ios-coding-standard/references/rules.yml +2 -2
- 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(
|
|
272
|
-
const fm = frontMatter(
|
|
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
|
-
|
|
453
|
-
|
|
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(
|
|
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
|
-
|
|
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`
|
|
181
|
-
// android-architecture and swift-architecture,
|
|
182
|
-
//
|
|
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.
|