@mmerterden/multi-agent-pipeline 16.14.0 → 16.16.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 +32 -0
- package/README.md +5 -4
- package/README.tr.md +5 -4
- package/docs/adr/0010-own-code-graph.md +6 -0
- package/docs/adr/0011-dormant-ci.md +87 -0
- package/docs/adr/README.md +1 -0
- package/docs/architecture.md +2 -2
- package/docs/ecosystem.md +5 -5
- package/install/_common.mjs +0 -1
- package/install/_dev-only-files.mjs +1 -0
- package/install/claude.mjs +14 -4
- package/install/index.mjs +8 -1
- package/package.json +6 -5
- package/pipeline/commands/multi-agent/help/SKILL.md +44 -39
- package/pipeline/commands/multi-agent/steer/SKILL.md +109 -0
- package/pipeline/commands/multi-agent/sync/SKILL.md +5 -7
- package/pipeline/multi-agent-refs/cross-cli-contract.md +4 -4
- package/pipeline/multi-agent-refs/phases/operations.md +23 -0
- package/pipeline/multi-agent-refs/phases/phase-0-init.md +4 -0
- package/pipeline/multi-agent-refs/phases/phase-7-report.md +1 -1
- package/pipeline/multi-agent-refs/phases.md +38 -0
- package/pipeline/preferences-template.json +2 -10
- package/pipeline/rules/outside-the-pipeline.md +1 -1
- package/pipeline/schemas/agent-state.schema.json +35 -0
- package/pipeline/schemas/analysis-spec.schema.json +8 -2
- package/pipeline/schemas/conventions-output.schema.json +2 -13
- package/pipeline/scripts/build-references.mjs +22 -6
- package/pipeline/scripts/code-graph-rules/android.json +4 -21
- package/pipeline/scripts/code-graph-rules/go.json +4 -19
- package/pipeline/scripts/code-graph-rules/node.json +4 -21
- package/pipeline/scripts/feedback-send.mjs +3 -1
- package/pipeline/scripts/gate-linux.sh +62 -0
- package/pipeline/scripts/localize-commands.mjs +1 -2
- package/pipeline/scripts/pre-push-check.sh +6 -4
- package/pipeline/scripts/usage-report.mjs +16 -14
- package/pipeline/scripts/validate-analysis-doc.mjs +46 -27
- package/pipeline/scripts/validate-analysis.mjs +7 -1
- package/pipeline/scripts/validate-complaint-doc.mjs +24 -8
- package/pipeline/scripts/write-state.mjs +71 -12
- package/pipeline/skills/shared/core/multi-agent-steer/SKILL.md +111 -0
- package/pipeline/skills/shared/core/multi-agent-sync/SKILL.md +4 -4
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# gate-linux.sh - run the ci-lite job on a real Ubuntu image, locally.
|
|
3
|
+
#
|
|
4
|
+
# The pre-push gate (pre-push-check.sh) is the primary one, but it runs on the
|
|
5
|
+
# maintainer's macOS workstation against an installed node_modules that has
|
|
6
|
+
# accumulated over months. Two things it cannot answer: does this tree work on
|
|
7
|
+
# Linux, and does `npm ci` from the lockfile into an empty tree still resolve.
|
|
8
|
+
# ci-lite.yml answers both, and has not executed since 2026-07-25.
|
|
9
|
+
#
|
|
10
|
+
# `act` runs that workflow against the same image GitHub would use, so the
|
|
11
|
+
# answer comes from the workflow definition rather than from a second script
|
|
12
|
+
# that would drift away from it.
|
|
13
|
+
#
|
|
14
|
+
# NOT part of `npm run gate` on purpose: act and Docker are not installed
|
|
15
|
+
# everywhere, and a required gate that cannot run is the failure ADR-0011
|
|
16
|
+
# exists to avoid. Run it before a release, or after touching install/,
|
|
17
|
+
# lib/credential-store.sh, or anything path-shaped.
|
|
18
|
+
#
|
|
19
|
+
# Needs network inside the container: the scorecard's dependency metrics ask
|
|
20
|
+
# the registry (advisories and signatures) and report an unreachable registry
|
|
21
|
+
# as a failure rather than a skip, on the grounds that a supply-chain check
|
|
22
|
+
# which goes green without reaching the registry is worse than no check.
|
|
23
|
+
#
|
|
24
|
+
# Exit codes: 0 = the job passed, 1 = the job failed, 2 = tooling missing
|
|
25
|
+
|
|
26
|
+
set -uo pipefail
|
|
27
|
+
|
|
28
|
+
REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
|
|
29
|
+
cd "$REPO_ROOT" || { echo "FAIL: can't cd to repo root" >&2; exit 2; }
|
|
30
|
+
|
|
31
|
+
if ! command -v act >/dev/null 2>&1; then
|
|
32
|
+
cat >&2 <<'MSG'
|
|
33
|
+
✗ act is not installed - this gate needs it to run the Ubuntu job locally.
|
|
34
|
+
|
|
35
|
+
brew install act # macOS
|
|
36
|
+
# and a running Docker daemon (Docker Desktop, colima, orbstack)
|
|
37
|
+
|
|
38
|
+
Why this is not bundled: it pulls a multi-gigabyte runner image. The rest of
|
|
39
|
+
the gate chain (npm run gate) has no such dependency and stays the default.
|
|
40
|
+
MSG
|
|
41
|
+
exit 2
|
|
42
|
+
fi
|
|
43
|
+
|
|
44
|
+
if ! docker info >/dev/null 2>&1; then
|
|
45
|
+
echo "✗ act is installed but no Docker daemon is reachable. Start Docker and retry." >&2
|
|
46
|
+
exit 2
|
|
47
|
+
fi
|
|
48
|
+
|
|
49
|
+
echo "→ Running ci-lite on ubuntu-22.04 via act (this pulls an image on first run)"
|
|
50
|
+
echo ""
|
|
51
|
+
|
|
52
|
+
if act workflow_dispatch \
|
|
53
|
+
-W .github/workflows/ci-lite.yml \
|
|
54
|
+
-P ubuntu-latest=catthehacker/ubuntu:act-22.04; then
|
|
55
|
+
echo ""
|
|
56
|
+
echo "✓ ci-lite passed on Linux"
|
|
57
|
+
exit 0
|
|
58
|
+
fi
|
|
59
|
+
|
|
60
|
+
echo "" >&2
|
|
61
|
+
echo "✗ ci-lite failed on Linux. A pass on macOS does not cover this." >&2
|
|
62
|
+
exit 1
|
|
@@ -116,8 +116,7 @@ export function localizeCommands(dir, mode) {
|
|
|
116
116
|
// `.split("/").pop()` never split a backslash path, so on Windows this was
|
|
117
117
|
// always false - `restore` exited 0 doing nothing and /multi-agent:sync then
|
|
118
118
|
// mirrored localized descriptions into the repo, the one thing it must prevent.
|
|
119
|
-
const invokedDirectly =
|
|
120
|
-
process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
|
|
119
|
+
const invokedDirectly = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
|
|
121
120
|
if (invokedDirectly) {
|
|
122
121
|
const args = process.argv.slice(2);
|
|
123
122
|
const mode = args[0];
|
|
@@ -3,10 +3,12 @@
|
|
|
3
3
|
#
|
|
4
4
|
# Purpose:
|
|
5
5
|
# The repo HAS workflows (test.yml, ci-lite.yml), but they have not executed
|
|
6
|
-
# since 2026-07-
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
6
|
+
# since 2026-07-02: every attempt after that day - the last on 2026-07-25 -
|
|
7
|
+
# finished in about four seconds with zero steps recorded, which is a
|
|
8
|
+
# runner/quota block rather than a test failure. Both are now dormant by
|
|
9
|
+
# declaration too (ADR-0011: the push triggers are commented out). So this
|
|
10
|
+
# hook is not a second opinion - it is the only thing standing between a
|
|
11
|
+
# broken commit and npm.
|
|
10
12
|
#
|
|
11
13
|
# It therefore runs the CANONICAL chain, `npm test`, rather than a hand-picked
|
|
12
14
|
# subset. The subset it used to run was missing eight of the eleven steps
|
|
@@ -69,8 +69,7 @@ function readJson(path) {
|
|
|
69
69
|
|
|
70
70
|
function resolveGlobalPrefs() {
|
|
71
71
|
const path =
|
|
72
|
-
process.env.MULTI_AGENT_PREFS ||
|
|
73
|
-
join(homedir(), ".claude", "multi-agent-preferences.json");
|
|
72
|
+
process.env.MULTI_AGENT_PREFS || join(homedir(), ".claude", "multi-agent-preferences.json");
|
|
74
73
|
return readJson(path)?.global ?? {};
|
|
75
74
|
}
|
|
76
75
|
|
|
@@ -130,9 +129,7 @@ function enabledPlugins(state) {
|
|
|
130
129
|
]) {
|
|
131
130
|
for (const name of readEnabledPluginNames(s)) names.add(name.split("@")[0]);
|
|
132
131
|
}
|
|
133
|
-
return Array.from(names)
|
|
134
|
-
.filter(Boolean)
|
|
135
|
-
.slice(0, 20);
|
|
132
|
+
return Array.from(names).filter(Boolean).slice(0, 20);
|
|
136
133
|
}
|
|
137
134
|
|
|
138
135
|
function integrationTags(state) {
|
|
@@ -146,8 +143,7 @@ function integrationTags(state) {
|
|
|
146
143
|
const tool = String(call?.tool || "");
|
|
147
144
|
// Server segment may contain hyphens (mcp__multi-agent-toolkit__ios_tap), so the
|
|
148
145
|
// class must accept them or "multi-agent-toolkit" collapses to "dev".
|
|
149
|
-
const m =
|
|
150
|
-
tool.match(/^mcp__([a-z0-9_-]+?)__/i) || tool.match(/^mcp__([a-z0-9_-]+)/i);
|
|
146
|
+
const m = tool.match(/^mcp__([a-z0-9_-]+?)__/i) || tool.match(/^mcp__([a-z0-9_-]+)/i);
|
|
151
147
|
if (m) apps.add(m[1].replace(/_/g, "-").toLowerCase());
|
|
152
148
|
}
|
|
153
149
|
return Array.from(apps).slice(0, 20);
|
|
@@ -345,7 +341,8 @@ function trackerSummary(state) {
|
|
|
345
341
|
const phs = [];
|
|
346
342
|
const failed = [];
|
|
347
343
|
const models = new Set();
|
|
348
|
-
let startedAt =
|
|
344
|
+
let startedAt =
|
|
345
|
+
typeof tracker?.started_at === "string" && tracker.started_at ? tracker.started_at : null;
|
|
349
346
|
let endedAt = null;
|
|
350
347
|
for (const p of list) {
|
|
351
348
|
const id = p?.id != null && String(p.id) !== "" ? String(p.id) : "?";
|
|
@@ -510,9 +507,7 @@ function buildEvent(state) {
|
|
|
510
507
|
ph: typeof state.currentPhase === "number" ? state.currentPhase : null,
|
|
511
508
|
st: state.status || null,
|
|
512
509
|
hr: state.haltReason || null,
|
|
513
|
-
ri: Array.isArray(state.reviewIterations)
|
|
514
|
-
? state.reviewIterations.length
|
|
515
|
-
: 0,
|
|
510
|
+
ri: Array.isArray(state.reviewIterations) ? state.reviewIterations.length : 0,
|
|
516
511
|
pr: Boolean(state.pr && (state.pr.url || state.pr.number)),
|
|
517
512
|
du: durationSec(state, spend),
|
|
518
513
|
tk: spend.tk,
|
|
@@ -612,10 +607,17 @@ async function main() {
|
|
|
612
607
|
}
|
|
613
608
|
|
|
614
609
|
// Windows-safe entry-point check: compare file URLs, never string-split a path.
|
|
615
|
-
const invokedDirectly =
|
|
616
|
-
process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
|
|
610
|
+
const invokedDirectly = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
|
|
617
611
|
if (invokedDirectly) {
|
|
618
612
|
main().catch(() => process.exit(0));
|
|
619
613
|
}
|
|
620
614
|
|
|
621
|
-
export {
|
|
615
|
+
export {
|
|
616
|
+
endpointAllowed,
|
|
617
|
+
integrationTags,
|
|
618
|
+
enabledPlugins,
|
|
619
|
+
readEnabledPluginNames,
|
|
620
|
+
trackerSummary,
|
|
621
|
+
buildEvent,
|
|
622
|
+
resolveUser,
|
|
623
|
+
};
|
|
@@ -46,9 +46,7 @@ import { readFileSync } from "node:fs";
|
|
|
46
46
|
// "mobile" and "web" are its platform values. "none" is the narrower case where the
|
|
47
47
|
// evidence describes no interface at all. All three are real values, not missing ones.
|
|
48
48
|
// "web" is the pre-"web" spelling, still accepted so older documents validate.
|
|
49
|
-
const KNOWN_PLATFORMS = new Set([
|
|
50
|
-
"ios", "android", "web", "backend", "mobile", "frontend", "none",
|
|
51
|
-
]);
|
|
49
|
+
const KNOWN_PLATFORMS = new Set(["ios", "android", "web", "backend", "mobile", "frontend", "none"]);
|
|
52
50
|
const REQUIRED_FM = ["feature", "platform", "language", "mode", "template_version"];
|
|
53
51
|
|
|
54
52
|
// Never-omitted sections (Locked 2), matched by bilingual title keyword so the
|
|
@@ -70,13 +68,19 @@ const REQUIRED_SECTIONS = [
|
|
|
70
68
|
const CORPORATE_SECTIONS = [
|
|
71
69
|
{ key: "purpose and scope", any: ["Amaç ve Kapsam", "Amac ve Kapsam", "Purpose and Scope"] },
|
|
72
70
|
{ key: "business analysis", any: ["İş Analizi", "Is Analizi", "Business Analysis"] },
|
|
73
|
-
{
|
|
71
|
+
{
|
|
72
|
+
key: "business requirements",
|
|
73
|
+
any: ["İş Gereksinimleri", "Is Gereksinimleri", "Business Requirements"],
|
|
74
|
+
},
|
|
74
75
|
{ key: "ai requirements", any: ["Yapay Zeka", "AI Requirements"] },
|
|
75
76
|
{ key: "use cases", any: ["Kullanım Senaryoları", "Kullanim Senaryolari", "Use Cases"] },
|
|
76
77
|
{ key: "hardware and infrastructure", any: ["Donanım", "Donanim", "Hardware"] },
|
|
77
78
|
{ key: "quality requirements", any: ["Kalite Gereksinimleri", "Quality Requirements"] },
|
|
78
79
|
{ key: "regulatory requirements", any: ["Regülasyonel", "Regulasyonel", "Regulatory"] },
|
|
79
|
-
{
|
|
80
|
+
{
|
|
81
|
+
key: "content requirements",
|
|
82
|
+
any: ["İçerik Gereksinimleri", "Icerik Gereksinimleri", "Content Requirements"],
|
|
83
|
+
},
|
|
80
84
|
{ key: "risks", any: ["Riskler", "Risks"] },
|
|
81
85
|
{ key: "references", any: ["Referanslar", "References"] },
|
|
82
86
|
];
|
|
@@ -226,9 +230,7 @@ function main() {
|
|
|
226
230
|
// defines AS-07, and each side is checked against the other.
|
|
227
231
|
if (profile === "corporate") {
|
|
228
232
|
const lines = text.split("\n");
|
|
229
|
-
const risksStart = lines.findIndex((l) =>
|
|
230
|
-
/^#{2,3}\s+\d+/.test(l) && /(Riskler|Risks)/.test(l),
|
|
231
|
-
);
|
|
233
|
+
const risksStart = lines.findIndex((l) => /^#{2,3}\s+\d+/.test(l) && /(Riskler|Risks)/.test(l));
|
|
232
234
|
let risksBlock = "";
|
|
233
235
|
if (risksStart !== -1) {
|
|
234
236
|
for (let i = risksStart + 1; i < lines.length; i++) {
|
|
@@ -237,9 +239,7 @@ function main() {
|
|
|
237
239
|
}
|
|
238
240
|
}
|
|
239
241
|
const AS_ID = /\bAS-(\d{2,3})\b/g;
|
|
240
|
-
const definedIds = new Set(
|
|
241
|
-
[...risksBlock.matchAll(AS_ID)].map((m) => `AS-${m[1]}`),
|
|
242
|
-
);
|
|
242
|
+
const definedIds = new Set([...risksBlock.matchAll(AS_ID)].map((m) => `AS-${m[1]}`));
|
|
243
243
|
const referencedIds = new Set();
|
|
244
244
|
for (let i = 0; i < lines.length; i++) {
|
|
245
245
|
if (risksStart !== -1 && i > risksStart) continue;
|
|
@@ -248,9 +248,7 @@ function main() {
|
|
|
248
248
|
// A gap that names no id cannot be traced to an owner, which is the whole
|
|
249
249
|
// point of admitting it.
|
|
250
250
|
if (!/\bAS-\d{2,3}\b/.test(lines[i])) {
|
|
251
|
-
errors.push(
|
|
252
|
-
`line ${i + 1}: EKLENECEK carries no AS-NN reference (Locked 33)`,
|
|
253
|
-
);
|
|
251
|
+
errors.push(`line ${i + 1}: EKLENECEK carries no AS-NN reference (Locked 33)`);
|
|
254
252
|
}
|
|
255
253
|
}
|
|
256
254
|
for (const id of referencedIds) {
|
|
@@ -264,9 +262,7 @@ function main() {
|
|
|
264
262
|
if (!referencedIds.has(id)) {
|
|
265
263
|
// An open question with no body reference is allowed only when the row
|
|
266
264
|
// says so; otherwise it is a question about nothing the document raises.
|
|
267
|
-
const row = risksBlock
|
|
268
|
-
.split("\n")
|
|
269
|
-
.find((l) => l.includes(id)) || "";
|
|
265
|
+
const row = risksBlock.split("\n").find((l) => l.includes(id)) || "";
|
|
270
266
|
if (!/(no body reference|govde referansi yok)/i.test(row)) {
|
|
271
267
|
errors.push(
|
|
272
268
|
`${id} is defined in Risks and Open Questions but never referenced in the body (Locked 33)`,
|
|
@@ -318,7 +314,9 @@ function main() {
|
|
|
318
314
|
|
|
319
315
|
for (const id of defined) {
|
|
320
316
|
if (!inMatrix.has(id)) {
|
|
321
|
-
errors.push(
|
|
317
|
+
errors.push(
|
|
318
|
+
`${id} is defined in the document but missing from the traceability matrix`,
|
|
319
|
+
);
|
|
322
320
|
}
|
|
323
321
|
}
|
|
324
322
|
for (const id of inMatrix) {
|
|
@@ -342,12 +340,23 @@ function main() {
|
|
|
342
340
|
let inMermaid = false;
|
|
343
341
|
for (let i = 0; i < mmLines.length; i++) {
|
|
344
342
|
const t = mmLines[i].trim();
|
|
345
|
-
if (/^```mermaid\s*$/.test(t)) {
|
|
346
|
-
|
|
343
|
+
if (/^```mermaid\s*$/.test(t)) {
|
|
344
|
+
inMermaid = true;
|
|
345
|
+
continue;
|
|
346
|
+
}
|
|
347
|
+
if (inMermaid && t === "```") {
|
|
348
|
+
inMermaid = false;
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
347
351
|
if (!inMermaid) continue;
|
|
348
352
|
const labels = [...mmLines[i].matchAll(/[[({|]([^\]})|]{3,})[\]})|]/g)].map((m) => m[1]);
|
|
349
353
|
for (const lbl of labels) {
|
|
350
|
-
if (
|
|
354
|
+
if (
|
|
355
|
+
/^[\x20-\x7E]+$/.test(lbl) &&
|
|
356
|
+
/\b(Hayir|Basarili|Basarisiz|Odeme|Iptal|Gecerli|Gecersiz|Secim|Dogrulama|Uyari|Baslangic|Bitis|Onay|Aciklama)\b/.test(
|
|
357
|
+
lbl,
|
|
358
|
+
)
|
|
359
|
+
) {
|
|
351
360
|
errors.push(
|
|
352
361
|
`WARN line ${i + 1}: diagram label "${lbl}" looks like Turkish flattened to ASCII (Locked 7)`,
|
|
353
362
|
);
|
|
@@ -356,7 +365,6 @@ function main() {
|
|
|
356
365
|
}
|
|
357
366
|
}
|
|
358
367
|
|
|
359
|
-
|
|
360
368
|
// 2b. Opt-in coverage sections must be present when the front-matter says so.
|
|
361
369
|
const uiTests = String(parsed?.fm?.ui_tests || "false").toLowerCase() === "true";
|
|
362
370
|
const a11yDepth = String(parsed?.fm?.a11y_depth || "basic").toLowerCase();
|
|
@@ -402,7 +410,9 @@ function main() {
|
|
|
402
410
|
if (parsed?.fm?.platform === "backend") {
|
|
403
411
|
warns.push(`${msg} - allowed for backend only when no contract testing is planned`);
|
|
404
412
|
} else {
|
|
405
|
-
errors.push(
|
|
413
|
+
errors.push(
|
|
414
|
+
`missing Section 15 Test Plan; Full mode dev reads it as the RED input (Locked 31)`,
|
|
415
|
+
);
|
|
406
416
|
}
|
|
407
417
|
} else {
|
|
408
418
|
const hasUnit =
|
|
@@ -469,7 +479,11 @@ function main() {
|
|
|
469
479
|
// The pre-existing occurrence-count heuristic below stays as the looser net.
|
|
470
480
|
if (mode === "full") {
|
|
471
481
|
const idRx = /BR-[a-z0-9]+(?:-[a-z0-9]+)*-\d+/gi;
|
|
472
|
-
const storyBody = sectionBody(allLines, [
|
|
482
|
+
const storyBody = sectionBody(allLines, [
|
|
483
|
+
"Kullanıcı Hikayeleri",
|
|
484
|
+
"Kullanici Hikayeleri",
|
|
485
|
+
"User Stories",
|
|
486
|
+
]);
|
|
473
487
|
const testBody = sectionBody(allLines, ["Test Planı", "Test Plani", "Test Plan"]);
|
|
474
488
|
if (storyBody && testBody) {
|
|
475
489
|
const defined = new Set((storyBody.join("\n").match(idRx) || []).map((x) => x.toUpperCase()));
|
|
@@ -556,13 +570,18 @@ function main() {
|
|
|
556
570
|
inVariant = true;
|
|
557
571
|
continue;
|
|
558
572
|
}
|
|
559
|
-
if (inVariant && /^#{1,3}\s/.test(line)) {
|
|
573
|
+
if (inVariant && /^#{1,3}\s/.test(line)) {
|
|
574
|
+
inVariant = false;
|
|
575
|
+
continue;
|
|
576
|
+
}
|
|
560
577
|
if (!inVariant || !/^\|/.test(line)) continue;
|
|
561
578
|
const cells = line.split("|").map((c) => c.trim());
|
|
562
|
-
if (cells.length < 7) continue;
|
|
579
|
+
if (cells.length < 7) continue; // not the 6-column body
|
|
563
580
|
if (/^-+$/.test(cells[1]) || /Bile\u015fen|Component/i.test(cells[1])) continue;
|
|
564
581
|
if (!cells[3] || !cells[4]) {
|
|
565
|
-
errors.push(
|
|
582
|
+
errors.push(
|
|
583
|
+
`variant row "${cells[1]}" is missing the all-values or used-here column (Section 6.X / Locked 29)`,
|
|
584
|
+
);
|
|
566
585
|
}
|
|
567
586
|
}
|
|
568
587
|
|
|
@@ -21,7 +21,13 @@ import { readFileSync } from "node:fs";
|
|
|
21
21
|
// output, a golden-task fixture or a resumed run written before the rename still
|
|
22
22
|
// validates; new output uses "web". Mirrors analysis-output.schema.json.
|
|
23
23
|
const ALLOWED_STACKS = new Set([
|
|
24
|
-
"ios",
|
|
24
|
+
"ios",
|
|
25
|
+
"android",
|
|
26
|
+
"backend",
|
|
27
|
+
"web",
|
|
28
|
+
"mobile",
|
|
29
|
+
"frontend",
|
|
30
|
+
"unknown",
|
|
25
31
|
]);
|
|
26
32
|
const ALLOWED_SEVERITIES = new Set(["low", "medium", "high"]);
|
|
27
33
|
|
|
@@ -46,14 +46,18 @@ const REQUIRED_FM = ["run_name", "generated_at", "language", "complaint_count",
|
|
|
46
46
|
const REQUIRED_SECTIONS = [
|
|
47
47
|
{ key: "summary", any: ["Summary", "Özet", "Ozet"] },
|
|
48
48
|
{ key: "triage table", any: ["Triage"] },
|
|
49
|
-
{
|
|
49
|
+
{
|
|
50
|
+
key: "complaint details",
|
|
51
|
+
any: ["Complaint Details", "Şikayet Detayları", "Sikayet Detaylari", "Detay"],
|
|
52
|
+
},
|
|
50
53
|
{ key: "open questions", any: ["Open Questions", "Açık Sorular", "Acik Sorular"] },
|
|
51
54
|
{ key: "methodology", any: ["Methodology", "Metodoloji"] },
|
|
52
55
|
{ key: "references", any: ["References", "Referanslar"] },
|
|
53
56
|
];
|
|
54
57
|
|
|
55
58
|
const ROUTING_KEYWORDS = ["Routing", "Yönlendirme", "Yonlendirme"];
|
|
56
|
-
const FIX_PLAN_RE =
|
|
59
|
+
const FIX_PLAN_RE =
|
|
60
|
+
/(Fix plan|Fix Plan|Geliştirme planı|Geliştirme Planı|Gelistirme plani|Gelistirme Plani)/;
|
|
57
61
|
|
|
58
62
|
// Cell-exact on purpose: a substring match would classify a row as `core`
|
|
59
63
|
// because its summary says "core-data" or "core team" before the verdict column.
|
|
@@ -164,7 +168,9 @@ function main() {
|
|
|
164
168
|
const triageScope = sectionBody(text, ["Triage"]) ?? text;
|
|
165
169
|
const rows = triageScope.split("\n").filter((l) => /^\s*\|\s*C-\d{2,}\s*\|/.test(l));
|
|
166
170
|
if (rows.length === 0) {
|
|
167
|
-
errors.push(
|
|
171
|
+
errors.push(
|
|
172
|
+
"no triage rows found (expected Triage-section rows with a C-NN id in the first cell)",
|
|
173
|
+
);
|
|
168
174
|
}
|
|
169
175
|
const coreIds = [];
|
|
170
176
|
const handoffIds = [];
|
|
@@ -173,7 +179,9 @@ function main() {
|
|
|
173
179
|
const cells = row.split("|").map((c) => c.trim());
|
|
174
180
|
const verdict = cells.find((c) => VERDICT_CELL_RE.test(c));
|
|
175
181
|
if (!verdict) {
|
|
176
|
-
errors.push(
|
|
182
|
+
errors.push(
|
|
183
|
+
`triage row ${id} has no valid verdict token (client:<layer> | bff:<layer> | core | insufficient-evidence)`,
|
|
184
|
+
);
|
|
177
185
|
} else if (verdict === "core" && !coreIds.includes(id)) {
|
|
178
186
|
coreIds.push(id);
|
|
179
187
|
} else if (/^(client|bff):/.test(verdict) && !handoffIds.includes(id)) {
|
|
@@ -185,7 +193,9 @@ function main() {
|
|
|
185
193
|
if (coreIds.length > 0) {
|
|
186
194
|
const routing = sectionBody(text, ROUTING_KEYWORDS);
|
|
187
195
|
if (!routing) {
|
|
188
|
-
errors.push(
|
|
196
|
+
errors.push(
|
|
197
|
+
`core verdict(s) ${coreIds.join(", ")} but no routing section (Yönlendirme / Routing)`,
|
|
198
|
+
);
|
|
189
199
|
} else {
|
|
190
200
|
for (const id of coreIds) {
|
|
191
201
|
if (!routing.includes(id)) {
|
|
@@ -200,7 +210,9 @@ function main() {
|
|
|
200
210
|
for (const id of handoffIds) {
|
|
201
211
|
const detail = sectionBody(text, [id]);
|
|
202
212
|
if (!detail || !FIX_PLAN_RE.test(detail)) {
|
|
203
|
-
errors.push(
|
|
213
|
+
errors.push(
|
|
214
|
+
`client/bff verdict ${id} has no fix-plan block (Fix plan / Geliştirme planı) in its detail section`,
|
|
215
|
+
);
|
|
204
216
|
}
|
|
205
217
|
}
|
|
206
218
|
|
|
@@ -223,10 +235,14 @@ function main() {
|
|
|
223
235
|
errors.push(`redaction leak: card-like digit run at line ${i + 1}`);
|
|
224
236
|
}
|
|
225
237
|
if (NATIONAL_ID_RE.test(bodyLines[i]) && !CARD_RE.test(bodyLines[i])) {
|
|
226
|
-
warns.push(
|
|
238
|
+
warns.push(
|
|
239
|
+
`possible redaction leak: bare 11-digit run at line ${i + 1} (national-id-like; ignore if it is a numeric trx id)`,
|
|
240
|
+
);
|
|
227
241
|
}
|
|
228
242
|
if (PNR_MIXED_RE.test(bodyLines[i]) || PNR_CONTEXT_RE.test(bodyLines[i])) {
|
|
229
|
-
warns.push(
|
|
243
|
+
warns.push(
|
|
244
|
+
`possible redaction leak: PNR-shaped token at line ${i + 1} (parse-complaints.sh should have redacted it)`,
|
|
245
|
+
);
|
|
230
246
|
}
|
|
231
247
|
}
|
|
232
248
|
|
|
@@ -25,8 +25,20 @@
|
|
|
25
25
|
*
|
|
26
26
|
* Tunables (env):
|
|
27
27
|
* WRITE_STATE_LOCK_TIMEOUT_MS acquire window before exit 2 (default 15000)
|
|
28
|
-
* WRITE_STATE_LOCK_STALE_MS age past which a lock
|
|
29
|
-
*
|
|
28
|
+
* WRITE_STATE_LOCK_STALE_MS age past which a lock with no readable PID is
|
|
29
|
+
* reclaimed (default 30000)
|
|
30
|
+
* WRITE_STATE_LOCK_ABANDON_MS ceiling past which even a lock that probes as
|
|
31
|
+
* alive is reclaimed, covering PID reuse
|
|
32
|
+
* (default: 10x STALE_MS)
|
|
33
|
+
* A dead holder (PID no longer alive) is reclaimed immediately regardless of
|
|
34
|
+
* age. A live holder keeps its lock: liveness outranks age. See
|
|
35
|
+
* lockIdentityIfStale() for why that order is load-bearing.
|
|
36
|
+
*
|
|
37
|
+
* Every successful write bumps `rev`, a counter that only ever increases. A
|
|
38
|
+
* reader that kept the `rev` it read can tell whether the record moved under
|
|
39
|
+
* it before writing back. The lock makes a single write atomic; it does not
|
|
40
|
+
* cover the gap between a phase reading state and writing it back, and
|
|
41
|
+
* `/multi-agent:steer` puts a deliberate second writer in that gap.
|
|
30
42
|
*/
|
|
31
43
|
|
|
32
44
|
import {
|
|
@@ -136,11 +148,29 @@ async function acquireLock(
|
|
|
136
148
|
}
|
|
137
149
|
|
|
138
150
|
/**
|
|
139
|
-
* A lock is stale when its owning PID is no longer alive
|
|
140
|
-
*
|
|
151
|
+
* A lock is stale when its owning PID is no longer alive. Age alone is not
|
|
152
|
+
* staleness.
|
|
153
|
+
*
|
|
154
|
+
* The order here is the whole point. Age used to be checked first and returned
|
|
155
|
+
* "stale" on its own, so a holder that was demonstrably ALIVE lost its lock the
|
|
156
|
+
* moment the file passed 30 seconds - and the next writer then deleted a live
|
|
157
|
+
* lock and overwrote the update behind it. Liveness is direct evidence;
|
|
158
|
+
* a timestamp is a guess about it, and a guess must not overrule the evidence.
|
|
159
|
+
*
|
|
160
|
+
* Age survives for the one case liveness cannot answer: PID reuse. A dead
|
|
161
|
+
* holder whose PID has been recycled by an unrelated process probes as alive
|
|
162
|
+
* forever, so a lock that is alive but older than `abandonMs` is reclaimed
|
|
163
|
+
* anyway. That ceiling is deliberately far above any real write (default ten
|
|
164
|
+
* times `staleMs`), because it is a last resort rather than a routine path.
|
|
165
|
+
*
|
|
166
|
+
* No periodic heartbeat: this writer holds the lock across one synchronous
|
|
167
|
+
* write and rename, so there is no window in which a timer could refresh the
|
|
168
|
+
* file - and refreshing a timestamp would be weaker evidence than the liveness
|
|
169
|
+
* probe already gives.
|
|
170
|
+
*
|
|
141
171
|
* @param {string} lockPath
|
|
142
172
|
* @param {number} staleMs
|
|
143
|
-
* @returns {
|
|
173
|
+
* @returns {{ino: number, mtimeMs: number} | null}
|
|
144
174
|
*/
|
|
145
175
|
function lockIdentityIfStale(lockPath, staleMs) {
|
|
146
176
|
let pid;
|
|
@@ -156,13 +186,17 @@ function lockIdentityIfStale(lockPath, staleMs) {
|
|
|
156
186
|
return null;
|
|
157
187
|
}
|
|
158
188
|
const identity = { ino, mtimeMs };
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
//
|
|
163
|
-
//
|
|
164
|
-
|
|
165
|
-
|
|
189
|
+
const abandonMs = Number(process.env.WRITE_STATE_LOCK_ABANDON_MS) || staleMs * 10;
|
|
190
|
+
const age = Date.now() - mtimeMs;
|
|
191
|
+
|
|
192
|
+
// PID-reuse ceiling. Only reached by a lock that still probes as alive after
|
|
193
|
+
// a length of time no legitimate write comes close to.
|
|
194
|
+
if (age > abandonMs) return identity;
|
|
195
|
+
|
|
196
|
+
// A lock with no readable PID cannot be probed, so age is the only evidence
|
|
197
|
+
// left for it. That is corruption rather than a live writer: the link-based
|
|
198
|
+
// acquire makes a lock observable only after its PID is inside.
|
|
199
|
+
if (!Number.isInteger(pid) || pid <= 0) return age > staleMs ? identity : null;
|
|
166
200
|
try {
|
|
167
201
|
process.kill(pid, 0); // probe liveness without signalling
|
|
168
202
|
return null; // holder alive
|
|
@@ -255,6 +289,31 @@ async function main() {
|
|
|
255
289
|
next = deepMerge(current, payload);
|
|
256
290
|
}
|
|
257
291
|
|
|
292
|
+
// A state document is an object. Anything else cannot carry `rev`, and
|
|
293
|
+
// assigning to a primitive throws a raw TypeError from inside the writer,
|
|
294
|
+
// which is a worse answer than saying so. `--replace` is where this
|
|
295
|
+
// reaches: the merge path can only ever produce an object.
|
|
296
|
+
if (typeof next !== "object" || next === null || Array.isArray(next)) {
|
|
297
|
+
throw new Error(
|
|
298
|
+
`state must be a JSON object, got ${Array.isArray(next) ? "array" : typeof next}`,
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// Monotonic revision, taken as the HIGHER of what the incoming document
|
|
303
|
+
// carries and what is on disk. A reader that writes back what it read
|
|
304
|
+
// carries a `rev` from before the record moved, and following it down
|
|
305
|
+
// would tell the next reader that nothing changed.
|
|
306
|
+
let priorRev = Number.isInteger(next.rev) ? next.rev : 0;
|
|
307
|
+
if (existsSync(path)) {
|
|
308
|
+
try {
|
|
309
|
+
const parsed = JSON.parse(readFileSync(path, "utf-8"));
|
|
310
|
+
if (Number.isInteger(parsed?.rev)) priorRev = Math.max(priorRev, parsed.rev);
|
|
311
|
+
} catch {
|
|
312
|
+
/* unreadable on disk - the merge path above already threw for that */
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
next.rev = priorRev + 1;
|
|
316
|
+
|
|
258
317
|
const tmp = `${dirname(path)}/.${basename(path)}.${process.pid}.tmp`;
|
|
259
318
|
writeFileSync(tmp, JSON.stringify(next, null, 2) + "\n");
|
|
260
319
|
renameSync(tmp, path); // atomic on POSIX
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: multi-agent-steer
|
|
3
|
+
language: en
|
|
4
|
+
description: "Queue an instruction for a task that is already running. It is applied at the next phase boundary, not mid-phase. Use when a run is going the wrong way and killing it would throw away good work."
|
|
5
|
+
user-invocable: true
|
|
6
|
+
argument-hint: "#id \"<instruction>\" - e.g. #3 \"the field is called web, not frontend\". With no instruction, you are asked for it."
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
# multi-agent steer - correct a run without stopping it
|
|
10
|
+
|
|
11
|
+
**Input**: $ARGUMENTS
|
|
12
|
+
|
|
13
|
+
Leave one instruction for a running task. The next phase reads it before it
|
|
14
|
+
starts work, applies it, and marks it consumed.
|
|
15
|
+
|
|
16
|
+
This exists because the alternative was losing the run. `kill` stops a task and
|
|
17
|
+
deletes its worktree; `resume` picks a stopped one back up. Neither helps while
|
|
18
|
+
a phase is in flight, so a correction that arrived mid-run - "that field is
|
|
19
|
+
called `web`, not `frontend`", "keep the analysis, drop the rest" - had nowhere
|
|
20
|
+
to go, and the run carried on in the wrong direction until it finished.
|
|
21
|
+
|
|
22
|
+
**Not a second prompt.** One instruction is queued at a time. Steering a task
|
|
23
|
+
that already has an unconsumed instruction replaces it, after showing you what
|
|
24
|
+
is being replaced.
|
|
25
|
+
|
|
26
|
+
## Steps
|
|
27
|
+
|
|
28
|
+
1. **Find the task** - parse `#N` or `{JIRA-KEY}-XXXXX` from the argument,
|
|
29
|
+
the same way `kill` and `resume` do. Locate its `agent-state.json`:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
find {repo}/.worktrees/ -name "agent-state.json" -maxdepth 2
|
|
33
|
+
find $HOME/.claude/logs/multi-agent -maxdepth 4 -name agent-state.json -path '*/artifacts/*'
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Not found → `ERR: no task #N. '/multi-agent:status' lists what is running.`
|
|
37
|
+
|
|
38
|
+
2. **Check it can still be steered** - read `status` and `currentPhase`:
|
|
39
|
+
|
|
40
|
+
| State | What to do |
|
|
41
|
+
|---|---|
|
|
42
|
+
| `in_progress` | Queue it. This is the case the command is for. |
|
|
43
|
+
| `paused` / `failed` | Say the task is not running, and that `/multi-agent:resume #N` will re-enter with the instruction applied at that phase's entry. Queue it. |
|
|
44
|
+
| `complete` | Refuse. Nothing will read it. Point at `/multi-agent` for a follow-up run. |
|
|
45
|
+
|
|
46
|
+
`currentPhase` is 7 and status is `in_progress` → warn that Phase 7 is the
|
|
47
|
+
last one, so an instruction queued now may never be consumed.
|
|
48
|
+
|
|
49
|
+
3. **Read the instruction** - from the argument, or ask for it when the
|
|
50
|
+
argument carries only an id. Verbatim, up to 4000 characters. Do not
|
|
51
|
+
summarize or rewrite it: the phase that consumes it needs the user's own
|
|
52
|
+
words, and a paraphrase is where the meaning goes.
|
|
53
|
+
|
|
54
|
+
4. **Show what will be queued, and ask**:
|
|
55
|
+
|
|
56
|
+
```
|
|
57
|
+
Steer #3 ({JIRA-KEY}-12345, Phase 3 Dev, in_progress)
|
|
58
|
+
|
|
59
|
+
"the field is called web, not frontend"
|
|
60
|
+
|
|
61
|
+
Applied at the entry to Phase 4. The current phase finishes as it is.
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Already carrying an unconsumed `pendingSteer` → print the old text above the
|
|
65
|
+
new one and ask whether to replace it.
|
|
66
|
+
|
|
67
|
+
5. **Write it** - through the state writer, never by editing the file, because
|
|
68
|
+
the running task is writing to it too. `$STATE_FILE` is the path from step 1
|
|
69
|
+
and `$INSTRUCTION` the text from step 3:
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
printf '{"pendingSteer":{"text":%s,"at":"%s","appliedAt":null,"appliedPhase":null}}' \
|
|
73
|
+
"$(node -e 'process.stdout.write(JSON.stringify(process.argv[1]))' "$INSTRUCTION")" \
|
|
74
|
+
"$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
|
75
|
+
| node $HOME/.claude/scripts/write-state.mjs "$STATE_FILE"
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
The instruction is JSON-encoded by `node -e`, not by hand: it is arbitrary
|
|
79
|
+
user text, and a quote or newline in it would otherwise produce invalid JSON
|
|
80
|
+
or, worse, a payload that merges into fields nobody meant to touch.
|
|
81
|
+
|
|
82
|
+
Exit 2 (lock timeout) → the task is mid-write. Retry once, then report it
|
|
83
|
+
rather than forcing the write.
|
|
84
|
+
|
|
85
|
+
6. **Confirm**: `🧭 Steer queued for #N - applies at the entry to Phase {N+1}`
|
|
86
|
+
|
|
87
|
+
## What the phase does with it
|
|
88
|
+
|
|
89
|
+
Phase entry, before any work: read a `pendingSteer` that has no `appliedAt`.
|
|
90
|
+
When present, apply it to that phase's context, set `appliedAt` and
|
|
91
|
+
`appliedPhase` - which is what stops it being read a second time - and write a
|
|
92
|
+
`Steer applied` line into `agent-log.md`. The record itself stays, so the run
|
|
93
|
+
keeps the trace of what was asked and when. The contract lives in
|
|
94
|
+
`$HOME/.claude/multi-agent-refs/phases.md` under "Phase entry - pending
|
|
95
|
+
steer" (the file has a second, unrelated "Phase entry" line inside the tracker
|
|
96
|
+
block).
|
|
97
|
+
|
|
98
|
+
Applied at entry rather than the moment it arrives, on purpose: a phase that
|
|
99
|
+
changes target halfway through throws away the work it already did, which is
|
|
100
|
+
the outcome this command exists to avoid.
|
|
101
|
+
|
|
102
|
+
An instruction that contradicts the plan is not silently obeyed. The phase says
|
|
103
|
+
what it is changing, and a contradiction that would invalidate an approved plan
|
|
104
|
+
halts for the user instead of quietly rewriting it.
|
|
105
|
+
|
|
106
|
+
**One limit worth knowing.** The reader is the phase contract, so a task started
|
|
107
|
+
by an install that predates it will not consume the field: the instruction is
|
|
108
|
+
written and nothing picks it up. There is no version stamp on a state file to
|
|
109
|
+
detect this from, so the honest advice is that steer applies to runs started
|
|
110
|
+
after the install carrying it. A run already in flight from an older install is
|
|
111
|
+
still a `kill` or a wait.
|