@mmerterden/multi-agent-pipeline 14.2.2 → 15.0.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 +76 -6
- package/README.md +15 -8
- package/README.tr.md +15 -8
- package/docs/FIGMA_PIPELINE.md +3 -3
- package/docs/adr/0006-skills-core-external-split.md +1 -1
- package/docs/adr/0009-claude-stack-skills-plugin-only.md +31 -0
- package/docs/adr/README.md +1 -0
- package/docs/architecture.md +7 -7
- package/docs/ecosystem.md +28 -28
- package/docs/features.md +5 -5
- package/index.js +2 -0
- package/install/_codex-agents.mjs +11 -2
- package/install/_common.mjs +65 -1
- package/install/_dev-only-files.mjs +0 -1
- package/install/_platform-filter.mjs +73 -7
- package/install/_plugin-skills.mjs +19 -8
- package/install/claude.mjs +144 -59
- package/install/codex.mjs +28 -3
- package/install/copilot.mjs +36 -11
- package/install/index.mjs +6 -2
- package/install/templates/codex-instructions.md +1 -1
- package/install/templates/copilot-instructions.md +3 -3
- package/package.json +1 -2
- package/pipeline/commands/multi-agent/SKILL.md +2 -0
- package/pipeline/commands/multi-agent/analysis/SKILL.md +3 -3
- package/pipeline/commands/multi-agent/analysis-resolve/SKILL.md +2 -2
- package/pipeline/commands/multi-agent/build-optimize/SKILL.md +9 -9
- package/pipeline/commands/multi-agent/channels/SKILL.md +1 -1
- package/pipeline/commands/multi-agent/complaint-analysis/SKILL.md +186 -0
- package/pipeline/commands/multi-agent/dev/SKILL.md +1 -1
- package/pipeline/commands/multi-agent/dev-autopilot/SKILL.md +1 -1
- package/pipeline/commands/multi-agent/dev-local/SKILL.md +1 -1
- package/pipeline/commands/multi-agent/dev-local-autopilot/SKILL.md +1 -1
- package/pipeline/commands/multi-agent/garbage-collect/SKILL.md +1 -1
- package/pipeline/commands/multi-agent/help/SKILL.md +19 -4
- package/pipeline/commands/multi-agent/ios-coding-standard/SKILL.md +2 -2
- package/pipeline/commands/multi-agent/jira/SKILL.md +1 -1
- package/pipeline/commands/multi-agent/prune-prompts/SKILL.md +81 -0
- package/pipeline/commands/multi-agent/resume/SKILL.md +1 -1
- package/pipeline/commands/multi-agent/{ship → resume-local}/SKILL.md +8 -8
- package/pipeline/commands/multi-agent/setup/SKILL.md +5 -5
- package/pipeline/commands/multi-agent/stack/SKILL.md +55 -43
- package/pipeline/commands/multi-agent/store-ready/SKILL.md +3 -3
- package/pipeline/commands/multi-agent/sync/SKILL.md +18 -11
- package/pipeline/commands/multi-agent/testflight-validation/SKILL.md +1 -1
- package/pipeline/commands/multi-agent/uninstall/SKILL.md +2 -0
- package/pipeline/commands/multi-agent/update/SKILL.md +1 -1
- package/pipeline/lib/issue-fetcher.sh +1 -1
- package/pipeline/lib/parse-complaints.sh +306 -0
- package/pipeline/multi-agent-refs/channels/wiki.md +3 -3
- package/pipeline/multi-agent-refs/complaint-analysis-template.md +99 -0
- package/pipeline/multi-agent-refs/component-dispatch.md +6 -6
- package/pipeline/multi-agent-refs/cross-cli-contract.md +16 -16
- package/pipeline/multi-agent-refs/features/external-context-injection.md +1 -1
- package/pipeline/multi-agent-refs/features/stack-skill-routing.md +5 -5
- package/pipeline/multi-agent-refs/generate-issue.md +1 -1
- package/pipeline/multi-agent-refs/phases/modes.md +1 -1
- package/pipeline/multi-agent-refs/phases/phase-0-init.md +1 -1
- package/pipeline/multi-agent-refs/phases/phase-1-analysis.md +7 -7
- package/pipeline/multi-agent-refs/phases/phase-2-planning.md +5 -5
- package/pipeline/multi-agent-refs/phases/phase-3-dev.md +3 -3
- package/pipeline/multi-agent-refs/phases/phase-4-review.md +12 -12
- package/pipeline/multi-agent-refs/phases/phase-5-test.md +1 -1
- package/pipeline/multi-agent-refs/tracker-contract.md +1 -1
- package/pipeline/multi-agent-refs/wiki-capture.md +2 -2
- package/pipeline/preferences-template.json +13 -5
- package/pipeline/rules/figma-pipeline.md +2 -2
- package/pipeline/schemas/agent-state.schema.json +1 -1
- package/pipeline/schemas/complaint-analysis-spec.schema.json +216 -0
- package/pipeline/schemas/migrations/prefs-2.5.0-to-2.6.0.mjs +46 -0
- package/pipeline/schemas/prefs.schema.json +276 -66
- package/pipeline/schemas/token-budget.json +2 -2
- package/pipeline/scripts/_stack-routing.mjs +79 -0
- package/pipeline/scripts/audit-log-rotate.sh +4 -1
- package/pipeline/scripts/build-skills-index.mjs +11 -0
- package/pipeline/scripts/build-stack-plugins.mjs +28 -60
- package/pipeline/scripts/check-derived-drift.mjs +52 -28
- package/pipeline/scripts/gc-worktrees.sh +4 -1
- package/pipeline/scripts/gen-skills-index.mjs +1 -1
- package/pipeline/scripts/match-skills.mjs +8 -2
- package/pipeline/scripts/migrate-prefs.mjs +28 -20
- package/pipeline/scripts/phase-tracker.sh +13 -5
- package/pipeline/scripts/phase0-exit-gate.mjs +3 -2
- package/pipeline/scripts/run-aggregator.mjs +7 -2
- package/pipeline/scripts/scan-agent-config.sh +1 -1
- package/pipeline/scripts/skill-conformance.mjs +165 -30
- package/pipeline/scripts/smoke-cross-cli-behavior.sh +1 -1
- package/pipeline/scripts/test-gap-rules/android.json +25 -0
- package/pipeline/scripts/test-gap-rules/ios.json +34 -0
- package/pipeline/scripts/test-gap-rules/node.json +29 -0
- package/pipeline/scripts/test-gap-rules/python.json +25 -0
- package/pipeline/scripts/uninstall.mjs +158 -11
- package/pipeline/scripts/validate-complaint-doc.mjs +229 -0
- package/pipeline/scripts/validate-reviewer.mjs +9 -3
- package/pipeline/skills/.skill-manifest.json +156 -108
- package/pipeline/skills/.skills-index.json +449 -12
- package/pipeline/skills/shared/README.md +14 -10
- package/pipeline/skills/shared/core/multi-agent-analysis-resolve/SKILL.md +1 -1
- package/pipeline/skills/shared/core/multi-agent-build-optimize/SKILL.md +1 -1
- package/pipeline/skills/shared/core/multi-agent-complaint-analysis/SKILL.md +49 -0
- package/pipeline/skills/shared/core/multi-agent-dev/SKILL.md +1 -1
- package/pipeline/skills/shared/core/multi-agent-dev-autopilot/SKILL.md +1 -1
- package/pipeline/skills/shared/core/multi-agent-dev-local/SKILL.md +1 -1
- package/pipeline/skills/shared/core/multi-agent-dev-local-autopilot/SKILL.md +1 -1
- package/pipeline/skills/shared/core/multi-agent-ios-coding-standard/SKILL.md +2 -2
- package/pipeline/skills/shared/core/multi-agent-prune-prompts/SKILL.md +83 -0
- package/pipeline/skills/shared/core/{multi-agent-ship → multi-agent-resume-local}/SKILL.md +6 -6
- package/pipeline/skills/shared/core/multi-agent-stack/SKILL.md +79 -22
- package/pipeline/skills/shared/core/multi-agent-store-ready/SKILL.md +1 -1
- package/pipeline/skills/shared/core/multi-agent-sync/SKILL.md +8 -8
- package/pipeline/skills/shared/core/multi-agent-testflight-validation/SKILL.md +1 -1
- package/pipeline/skills/shared/external/ios-coding-standard/modules/_TEMPLATE.yml +2 -2
- package/pipeline/skills/shared/external/ios-coding-standard/references/rules.yml +368 -33
- package/pipeline/skills/shared/external/ios-coding-standard/references/swiftlint.draft.yml +1 -2
- package/pipeline/skills/shared/external/ios-coding-standard/scripts/check_structure.py +765 -0
- package/pipeline/skills/shared/external/ios-module-structure/SKILL.md +75 -0
- package/pipeline/skills/shared/external/ios-module-structure/modules/_TEMPLATE.yml +131 -0
- package/pipeline/skills/shared/external/ios-module-structure/references/rules.yml +559 -0
- package/pipeline/skills/shared/external/ios-module-structure/scripts/check_structure.py +765 -0
- package/pipeline/skills/shared/external/localization-reuse-map/example-mapping.json +53 -10
- package/pipeline/skills/shared/external/localization-reuse-map/reference/sources-and-recipes.md +4 -3
- package/pipeline/skills/skills-index.md +7 -4
|
@@ -35,9 +35,20 @@
|
|
|
35
35
|
* @module pipeline/scripts/runtime/uninstall
|
|
36
36
|
*/
|
|
37
37
|
|
|
38
|
-
import {
|
|
38
|
+
import {
|
|
39
|
+
chmodSync,
|
|
40
|
+
existsSync,
|
|
41
|
+
lstatSync,
|
|
42
|
+
readdirSync,
|
|
43
|
+
readFileSync,
|
|
44
|
+
realpathSync,
|
|
45
|
+
renameSync,
|
|
46
|
+
rmSync,
|
|
47
|
+
statSync,
|
|
48
|
+
writeFileSync,
|
|
49
|
+
} from "fs";
|
|
39
50
|
import { execFileSync } from "child_process";
|
|
40
|
-
import { join } from "path";
|
|
51
|
+
import { join, dirname } from "path";
|
|
41
52
|
import { homedir } from "os";
|
|
42
53
|
import { pathToFileURL } from "url";
|
|
43
54
|
import { createInterface } from "readline";
|
|
@@ -100,6 +111,43 @@ const adapterTarget = (() => {
|
|
|
100
111
|
return raw ? raw.slice("--target=".length) : process.cwd();
|
|
101
112
|
})();
|
|
102
113
|
|
|
114
|
+
/**
|
|
115
|
+
* tmp + rename, preserving a symlinked target and the existing file mode.
|
|
116
|
+
*
|
|
117
|
+
* Duplicated from install/_common.mjs rather than imported: this script runs
|
|
118
|
+
* standalone from ~/.claude/scripts, where the install tree does not exist.
|
|
119
|
+
* A plain writeFileSync could leave the host's settings.json truncated on a
|
|
120
|
+
* crash; a naive rename would replace a dotfiles symlink with a regular file
|
|
121
|
+
* and reset a 0600 file to 0644.
|
|
122
|
+
*
|
|
123
|
+
* @param {string} path
|
|
124
|
+
* @param {string} content
|
|
125
|
+
*/
|
|
126
|
+
function atomicWrite(path, content) {
|
|
127
|
+
let target = path;
|
|
128
|
+
try {
|
|
129
|
+
if (lstatSync(path).isSymbolicLink()) target = realpathSync(path);
|
|
130
|
+
} catch {
|
|
131
|
+
// Missing file: nothing to preserve.
|
|
132
|
+
}
|
|
133
|
+
let mode;
|
|
134
|
+
try {
|
|
135
|
+
mode = statSync(target).mode & 0o777;
|
|
136
|
+
} catch {
|
|
137
|
+
mode = undefined;
|
|
138
|
+
}
|
|
139
|
+
const tmp = `${target}.tmp-${process.pid}`;
|
|
140
|
+
writeFileSync(tmp, content);
|
|
141
|
+
if (mode !== undefined) {
|
|
142
|
+
try {
|
|
143
|
+
chmodSync(tmp, mode);
|
|
144
|
+
} catch {
|
|
145
|
+
// Best effort: a failed chmod must not lose the write.
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
renameSync(tmp, target);
|
|
149
|
+
}
|
|
150
|
+
|
|
103
151
|
/**
|
|
104
152
|
* Report a planned action. Always logged; in --dry-run mode, no side effects
|
|
105
153
|
* are performed.
|
|
@@ -197,10 +245,7 @@ function deregisterCodexMcpServer() {
|
|
|
197
245
|
*/
|
|
198
246
|
function deregisterClaudeMcpServer() {
|
|
199
247
|
const localBin = join(homedir(), ".local", "bin", "claude");
|
|
200
|
-
deregisterMcpServer("claude", existsSync(localBin) ? localBin : undefined, [
|
|
201
|
-
"--scope",
|
|
202
|
-
"user",
|
|
203
|
-
]);
|
|
248
|
+
deregisterMcpServer("claude", existsSync(localBin) ? localBin : undefined, ["--scope", "user"]);
|
|
204
249
|
}
|
|
205
250
|
|
|
206
251
|
/**
|
|
@@ -220,6 +265,104 @@ function deregisterCopilotMcpServer() {
|
|
|
220
265
|
* @param {string} parent
|
|
221
266
|
* @param {(name: string) => boolean} predicate
|
|
222
267
|
*/
|
|
268
|
+
/**
|
|
269
|
+
* Remove the pipeline's command namespace while preserving user-authored
|
|
270
|
+
* local-only alias wrappers (frontmatter `local-only: true`). Those wrappers
|
|
271
|
+
* exist ONLY under the host dir - install/claude.mjs snapshots them across its
|
|
272
|
+
* wipe for the same reason - so removing the namespace wholesale would destroy
|
|
273
|
+
* content a reinstall can never bring back.
|
|
274
|
+
* @param {string} cmdDir - e.g. ~/.claude/commands/multi-agent
|
|
275
|
+
* @returns {number} preserved wrapper dirs
|
|
276
|
+
*/
|
|
277
|
+
function rmCommandsPreservingLocalOnly(cmdDir) {
|
|
278
|
+
// An install interrupted between its wipe and its restore leaves wrappers in
|
|
279
|
+
// a hidden stash beside the namespace. Adopt them back before deciding what
|
|
280
|
+
// to preserve, or the "local-only wrappers preserved" promise below strands
|
|
281
|
+
// them in a dot-directory the user never sees.
|
|
282
|
+
const stashDir = join(dirname(cmdDir), ".multi-agent-wrapper-stash");
|
|
283
|
+
if (existsSync(stashDir) && !dryRun) {
|
|
284
|
+
for (const name of readdirSync(stashDir)) {
|
|
285
|
+
const to = join(cmdDir, name);
|
|
286
|
+
if (existsSync(to)) continue;
|
|
287
|
+
try {
|
|
288
|
+
renameSync(join(stashDir, name), to);
|
|
289
|
+
console.log(` recovered stashed wrapper from an interrupted install: ${name}`);
|
|
290
|
+
} catch {
|
|
291
|
+
/* best effort - a failed recovery must not block the uninstall */
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
rmIfExists(stashDir);
|
|
295
|
+
}
|
|
296
|
+
if (!existsSync(cmdDir)) return 0;
|
|
297
|
+
const isLocalOnly = (dir) => {
|
|
298
|
+
const skill = join(dir, "SKILL.md");
|
|
299
|
+
try {
|
|
300
|
+
return existsSync(skill) && /^local-only:\s*true\s*$/m.test(readFileSync(skill, "utf-8"));
|
|
301
|
+
} catch {
|
|
302
|
+
return false;
|
|
303
|
+
}
|
|
304
|
+
};
|
|
305
|
+
const preserved = readdirSync(cmdDir, { withFileTypes: true })
|
|
306
|
+
.filter((e) => e.isDirectory() && isLocalOnly(join(cmdDir, e.name)))
|
|
307
|
+
.map((e) => e.name);
|
|
308
|
+
if (preserved.length === 0) {
|
|
309
|
+
rmIfExists(cmdDir);
|
|
310
|
+
return 0;
|
|
311
|
+
}
|
|
312
|
+
for (const entry of readdirSync(cmdDir)) {
|
|
313
|
+
if (preserved.includes(entry)) continue;
|
|
314
|
+
rmIfExists(join(cmdDir, entry));
|
|
315
|
+
}
|
|
316
|
+
console.log(
|
|
317
|
+
` preserved ${preserved.length} local-only alias wrapper(s): ${preserved.join(", ")}`,
|
|
318
|
+
);
|
|
319
|
+
return preserved.length;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** Written by install/_platform-filter.mjs beside the skills it delivered. */
|
|
323
|
+
const EXTERNAL_SKILLS_MANIFEST = ".external-skills-manifest.json";
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Remove the external skill catalog THIS install delivered, read from the
|
|
327
|
+
* manifest the installer wrote beside it.
|
|
328
|
+
*
|
|
329
|
+
* Deliberately not derived from `.skills-index.json`: that file is the shipped
|
|
330
|
+
* catalog copied verbatim, so it lists platform-filtered skills that were never
|
|
331
|
+
* installed, and it says nothing about which package version's tree is on disk.
|
|
332
|
+
* Either gap would delete a user-authored skill dir that happens to share a
|
|
333
|
+
* catalog name. Same contract as `.plugin-skills-manifest.json`.
|
|
334
|
+
*
|
|
335
|
+
* No manifest (a pre-manifest install) means ours and theirs are
|
|
336
|
+
* indistinguishable, so nothing is removed and the user is told why.
|
|
337
|
+
*
|
|
338
|
+
* @param {string} skillsDir
|
|
339
|
+
* @returns {number} dirs removed
|
|
340
|
+
*/
|
|
341
|
+
function rmExternalDeliveredSkills(skillsDir) {
|
|
342
|
+
if (!existsSync(skillsDir)) return 0;
|
|
343
|
+
const manifestPath = join(skillsDir, EXTERNAL_SKILLS_MANIFEST);
|
|
344
|
+
let names = null;
|
|
345
|
+
if (existsSync(manifestPath)) {
|
|
346
|
+
try {
|
|
347
|
+
const parsed = JSON.parse(readFileSync(manifestPath, "utf-8"));
|
|
348
|
+
if (Array.isArray(parsed)) names = new Set(parsed);
|
|
349
|
+
} catch {
|
|
350
|
+
names = null;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
if (!names) {
|
|
354
|
+
console.log(
|
|
355
|
+
` note: no ${EXTERNAL_SKILLS_MANIFEST} under ${skillsDir} - external skill dirs left in ` +
|
|
356
|
+
`place (cannot tell pipeline-delivered from user-authored; re-run install once, then uninstall)`,
|
|
357
|
+
);
|
|
358
|
+
return 0;
|
|
359
|
+
}
|
|
360
|
+
let n = rmMatchingDirs(skillsDir, (name) => names.has(name));
|
|
361
|
+
n += rmMatchingFiles(skillsDir, (name) => /^NOTICE-.*\.md$/.test(name));
|
|
362
|
+
rmIfExists(manifestPath);
|
|
363
|
+
return n;
|
|
364
|
+
}
|
|
365
|
+
|
|
223
366
|
function rmMatchingDirs(parent, predicate) {
|
|
224
367
|
if (!existsSync(parent)) return 0;
|
|
225
368
|
let count = 0;
|
|
@@ -364,7 +507,7 @@ function stripManagedBlock(filePath) {
|
|
|
364
507
|
rmSync(filePath, { force: true });
|
|
365
508
|
console.log(` removed (was pipeline-only): ${filePath}`);
|
|
366
509
|
} else {
|
|
367
|
-
|
|
510
|
+
atomicWrite(filePath, remaining + "\n");
|
|
368
511
|
console.log(` cleaned pipeline section: ${filePath}`);
|
|
369
512
|
}
|
|
370
513
|
return true;
|
|
@@ -381,7 +524,7 @@ function stripManagedBlock(filePath) {
|
|
|
381
524
|
rmSync(filePath, { force: true });
|
|
382
525
|
console.log(` removed (was pipeline-only): ${filePath}`);
|
|
383
526
|
} else {
|
|
384
|
-
|
|
527
|
+
atomicWrite(filePath, cleaned + "\n");
|
|
385
528
|
console.log(` cleaned pipeline block from: ${filePath}`);
|
|
386
529
|
}
|
|
387
530
|
return true;
|
|
@@ -424,7 +567,8 @@ function cleanClaudeSettings(settingsPath) {
|
|
|
424
567
|
(h.matcher === "Bash" || h.matcher === "Bash(git commit:*)") &&
|
|
425
568
|
h.hooks?.some(
|
|
426
569
|
(sub) =>
|
|
427
|
-
sub.command?.includes("pre-commit-check.sh") ||
|
|
570
|
+
sub.command?.includes("pre-commit-check.sh") ||
|
|
571
|
+
sub.command?.includes("agent-guard.sh"),
|
|
428
572
|
)
|
|
429
573
|
),
|
|
430
574
|
);
|
|
@@ -437,7 +581,7 @@ function cleanClaudeSettings(settingsPath) {
|
|
|
437
581
|
if (dryRun) {
|
|
438
582
|
report("would update", settingsPath);
|
|
439
583
|
} else {
|
|
440
|
-
|
|
584
|
+
atomicWrite(settingsPath, JSON.stringify(settings, null, 2) + "\n");
|
|
441
585
|
console.log(` cleaned pipeline hook + env from: ${settingsPath}`);
|
|
442
586
|
}
|
|
443
587
|
}
|
|
@@ -497,6 +641,7 @@ export async function main() {
|
|
|
497
641
|
);
|
|
498
642
|
console.log(" - ~/.claude/CLAUDE.md (your customizations)");
|
|
499
643
|
console.log(" - ~/.claude/rules/ (user-owned; installed write-if-missing, never overwritten)");
|
|
644
|
+
console.log(" - Local-only alias wrappers under commands/multi-agent/ (user-authored)");
|
|
500
645
|
if (!allData) console.log(" - ~/.claude/multi-agent-preferences.json (your settings)");
|
|
501
646
|
console.log(" - Your own content in copilot-instructions.md above the pipeline section");
|
|
502
647
|
console.log("");
|
|
@@ -511,7 +656,7 @@ export async function main() {
|
|
|
511
656
|
console.log(" [Claude Code] Removing...");
|
|
512
657
|
deregisterClaudeMcpServer();
|
|
513
658
|
const CLAUDE = join(HOME, ".claude");
|
|
514
|
-
|
|
659
|
+
rmCommandsPreservingLocalOnly(join(CLAUDE, "commands", "multi-agent"));
|
|
515
660
|
rmIfExists(join(CLAUDE, "scripts"));
|
|
516
661
|
// Pipeline-managed trees the installer lays down alongside scripts/.
|
|
517
662
|
rmIfExists(join(CLAUDE, "multi-agent-refs"));
|
|
@@ -538,6 +683,7 @@ export async function main() {
|
|
|
538
683
|
name === "figma-to-component",
|
|
539
684
|
);
|
|
540
685
|
n += rmMatchingDirs(skills, (name) => PIPELINE_CORE_SKILL_DIRS.includes(name));
|
|
686
|
+
n += rmExternalDeliveredSkills(skills);
|
|
541
687
|
if (n > 0) console.log(` removed ${n} skill dir(s) under ${skills}`);
|
|
542
688
|
rmIfExists(join(skills, ".skills-index.json"));
|
|
543
689
|
rmIfExists(join(skills, "skills-index.md"));
|
|
@@ -573,6 +719,7 @@ export async function main() {
|
|
|
573
719
|
);
|
|
574
720
|
n += rmMatchingDirs(skills, (name) => PIPELINE_CORE_SKILL_DIRS.includes(name));
|
|
575
721
|
n += rmPluginDeliveredSkills(skills);
|
|
722
|
+
n += rmExternalDeliveredSkills(skills);
|
|
576
723
|
if (n > 0) console.log(` removed ${n} skill dir(s) under ${skills}`);
|
|
577
724
|
rmIfExists(join(skills, ".skills-index.json"));
|
|
578
725
|
rmIfExists(join(skills, "skills-index.md"));
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// validate-complaint-doc.mjs - deterministic validator for an EMITTED
|
|
3
|
+
// /multi-agent:complaint-analysis report (complaints/<run-name>.md).
|
|
4
|
+
//
|
|
5
|
+
// The complaint-analysis SKILL states its "fails the dispatch gate" invariants
|
|
6
|
+
// as prose; this turns the mechanically checkable ones into a real gate so a
|
|
7
|
+
// malformed or PII-leaking report is caught before it reaches Confluence,
|
|
8
|
+
// Jira, or a repo working tree.
|
|
9
|
+
//
|
|
10
|
+
// Zero deps. Reads one markdown file (path arg or STDIN).
|
|
11
|
+
//
|
|
12
|
+
// Checks (ERROR = blocking exit 1; WARN = advisory, still exit 0 unless --strict):
|
|
13
|
+
// - Front-matter block with required keys (run_name, generated_at, language,
|
|
14
|
+
// complaint_count, graylog_degraded); language in {tr, en}.
|
|
15
|
+
// - Required sections present by bilingual title keyword: Summary, Triage,
|
|
16
|
+
// Complaint Details, Open Questions, Methodology, References.
|
|
17
|
+
// - Every triage-table row (a table row carrying a C-NN id) has a valid
|
|
18
|
+
// verdict token: client:<layer> | bff:<layer> | core | insufficient-evidence.
|
|
19
|
+
// Verdict tokens stay English in both languages (payload vocabulary).
|
|
20
|
+
// - Every core verdict has a matching entry in the routing section
|
|
21
|
+
// (Yönlendirme / Routing), and that section exists when any core row does.
|
|
22
|
+
// - Every client/bff verdict has a fix-plan marker (Fix plan / Geliştirme
|
|
23
|
+
// planı) in the complaint-details section (development handoff, Locked 13).
|
|
24
|
+
// - Humanizer punctuation policy: no em-dash / en-dash / ellipsis /
|
|
25
|
+
// section-sign / curly quotes anywhere.
|
|
26
|
+
// - Redaction leak scan: an email address or a 13-19 digit card-like run is
|
|
27
|
+
// an ERROR; a bare 11-digit run is a WARN (could be a numeric trx id).
|
|
28
|
+
//
|
|
29
|
+
// Usage:
|
|
30
|
+
// node validate-complaint-doc.mjs complaints/complaints-20260810.md
|
|
31
|
+
// cat report.md | node validate-complaint-doc.mjs -
|
|
32
|
+
// node validate-complaint-doc.mjs report.md --strict # WARN also fails
|
|
33
|
+
//
|
|
34
|
+
// Exit: 0 valid, 1 invalid (or WARN under --strict), 64 usage error.
|
|
35
|
+
|
|
36
|
+
import { readFileSync } from "node:fs";
|
|
37
|
+
|
|
38
|
+
const REQUIRED_FM = ["run_name", "generated_at", "language", "complaint_count", "graylog_degraded"];
|
|
39
|
+
|
|
40
|
+
const REQUIRED_SECTIONS = [
|
|
41
|
+
{ key: "summary", any: ["Summary", "Özet", "Ozet"] },
|
|
42
|
+
{ key: "triage table", any: ["Triage"] },
|
|
43
|
+
{ key: "complaint details", any: ["Complaint Details", "Şikayet Detayları", "Sikayet Detaylari", "Detay"] },
|
|
44
|
+
{ key: "open questions", any: ["Open Questions", "Açık Sorular", "Acik Sorular"] },
|
|
45
|
+
{ key: "methodology", any: ["Methodology", "Metodoloji"] },
|
|
46
|
+
{ key: "references", any: ["References", "Referanslar"] },
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
const ROUTING_KEYWORDS = ["Routing", "Yönlendirme", "Yonlendirme"];
|
|
50
|
+
const FIX_PLAN_RE = /(Fix plan|Fix Plan|Geliştirme planı|Geliştirme Planı|Gelistirme plani|Gelistirme Plani)/;
|
|
51
|
+
|
|
52
|
+
const VERDICT_RE =
|
|
53
|
+
/\b(client:(ios|android|web)|bff:(mobile-bff|web-bff)|core|insufficient-evidence)\b/;
|
|
54
|
+
|
|
55
|
+
const BANNED_PUNCT = [
|
|
56
|
+
{ ch: "—", name: "em-dash" },
|
|
57
|
+
{ ch: "–", name: "en-dash" },
|
|
58
|
+
{ ch: "…", name: "ellipsis" },
|
|
59
|
+
{ ch: "§", name: "section-sign" },
|
|
60
|
+
{ ch: "“", name: "curly-double-open" },
|
|
61
|
+
{ ch: "”", name: "curly-double-close" },
|
|
62
|
+
{ ch: "‘", name: "curly-single-open" },
|
|
63
|
+
{ ch: "’", name: "curly-single-close" },
|
|
64
|
+
];
|
|
65
|
+
|
|
66
|
+
const EMAIL_RE = /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/;
|
|
67
|
+
const CARD_RE = /\b\d(?:[ -]?\d){12,18}\b/;
|
|
68
|
+
const NATIONAL_ID_RE = /\b\d{11}\b/;
|
|
69
|
+
|
|
70
|
+
function readInput() {
|
|
71
|
+
const args = process.argv.slice(2).filter((a) => a !== "--strict");
|
|
72
|
+
const arg = args[0];
|
|
73
|
+
if (!arg) {
|
|
74
|
+
console.error("usage: validate-complaint-doc.mjs <path|-> [--strict]");
|
|
75
|
+
process.exit(64);
|
|
76
|
+
}
|
|
77
|
+
if (arg === "-") return readFileSync(0, "utf-8");
|
|
78
|
+
return readFileSync(arg, "utf-8");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function parseFrontMatter(text) {
|
|
82
|
+
const lines = text.split("\n");
|
|
83
|
+
if (lines[0].trim() !== "---") return null;
|
|
84
|
+
const end = lines.indexOf("---", 1);
|
|
85
|
+
if (end < 0) return null;
|
|
86
|
+
const fm = {};
|
|
87
|
+
for (let i = 1; i < end; i++) {
|
|
88
|
+
const m = lines[i].match(/^([A-Za-z_][A-Za-z0-9_]*):\s*(.*)$/);
|
|
89
|
+
if (m) fm[m[1]] = m[2].trim();
|
|
90
|
+
}
|
|
91
|
+
return { fm, bodyStart: end + 1 };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// The routing section body: from the heading whose title contains a routing
|
|
95
|
+
// keyword to the next heading of the same or shallower depth.
|
|
96
|
+
function sectionBody(text, keywords) {
|
|
97
|
+
const lines = text.split("\n");
|
|
98
|
+
let start = -1;
|
|
99
|
+
let depth = 0;
|
|
100
|
+
for (let i = 0; i < lines.length; i++) {
|
|
101
|
+
const m = lines[i].match(/^(#{1,3})\s+(.*)$/);
|
|
102
|
+
if (!m) continue;
|
|
103
|
+
if (start < 0 && keywords.some((kw) => m[2].includes(kw))) {
|
|
104
|
+
start = i;
|
|
105
|
+
depth = m[1].length;
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
if (start >= 0 && m[1].length <= depth) {
|
|
109
|
+
return lines.slice(start, i).join("\n");
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return start >= 0 ? lines.slice(start).join("\n") : null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function main() {
|
|
116
|
+
const text = readInput();
|
|
117
|
+
const strict = process.argv.includes("--strict");
|
|
118
|
+
const errors = [];
|
|
119
|
+
const warns = [];
|
|
120
|
+
|
|
121
|
+
// 1. Front-matter
|
|
122
|
+
const parsed = parseFrontMatter(text);
|
|
123
|
+
if (!parsed) {
|
|
124
|
+
errors.push("missing YAML front-matter block (--- ... ---) at the top");
|
|
125
|
+
} else {
|
|
126
|
+
for (const k of REQUIRED_FM) {
|
|
127
|
+
if (!parsed.fm[k]) errors.push(`front-matter missing required key: ${k}`);
|
|
128
|
+
}
|
|
129
|
+
const lang = parsed.fm.language;
|
|
130
|
+
if (lang && lang !== "tr" && lang !== "en") {
|
|
131
|
+
errors.push(`front-matter language must be tr|en, got: ${lang}`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// 2. Required sections (by heading keyword, bilingual)
|
|
136
|
+
const headings = text
|
|
137
|
+
.split("\n")
|
|
138
|
+
.filter((l) => /^#{1,3}\s/.test(l))
|
|
139
|
+
.map((l) => l.replace(/^#{1,3}\s/, "").trim());
|
|
140
|
+
const headingBlob = headings.join("\n");
|
|
141
|
+
for (const sec of REQUIRED_SECTIONS) {
|
|
142
|
+
if (!sec.any.some((kw) => headingBlob.includes(kw))) {
|
|
143
|
+
errors.push(`missing required section: ${sec.key}`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// 3. Triage rows: every table row carrying a complaint id needs a verdict.
|
|
148
|
+
const rows = text.split("\n").filter((l) => /^\s*\|.*\bC-\d{2,}\b/.test(l));
|
|
149
|
+
if (rows.length === 0) {
|
|
150
|
+
errors.push("no triage rows found (expected table rows carrying C-NN complaint ids)");
|
|
151
|
+
}
|
|
152
|
+
const coreIds = [];
|
|
153
|
+
const handoffIds = [];
|
|
154
|
+
for (const row of rows) {
|
|
155
|
+
const id = (row.match(/\bC-\d{2,}\b/) || [])[0];
|
|
156
|
+
const verdict = row.match(VERDICT_RE);
|
|
157
|
+
if (!verdict) {
|
|
158
|
+
errors.push(`triage row ${id} has no valid verdict token (client:<layer> | bff:<layer> | core | insufficient-evidence)`);
|
|
159
|
+
} else if (verdict[0] === "core" && !coreIds.includes(id)) {
|
|
160
|
+
coreIds.push(id);
|
|
161
|
+
} else if (/^(client|bff):/.test(verdict[0]) && !handoffIds.includes(id)) {
|
|
162
|
+
handoffIds.push(id);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// 4. Core verdicts require the routing section, one entry per complaint.
|
|
167
|
+
if (coreIds.length > 0) {
|
|
168
|
+
const routing = sectionBody(text, ROUTING_KEYWORDS);
|
|
169
|
+
if (!routing) {
|
|
170
|
+
errors.push(`core verdict(s) ${coreIds.join(", ")} but no routing section (Yönlendirme / Routing)`);
|
|
171
|
+
} else {
|
|
172
|
+
for (const id of coreIds) {
|
|
173
|
+
if (!routing.includes(id)) {
|
|
174
|
+
errors.push(`core verdict ${id} has no entry in the routing section`);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// 4b. Client/bff verdicts require a fix-plan block under their C-NN detail
|
|
181
|
+
// subsection (development handoff, Locked 13).
|
|
182
|
+
for (const id of handoffIds) {
|
|
183
|
+
const detail = sectionBody(text, [id]);
|
|
184
|
+
if (!detail || !FIX_PLAN_RE.test(detail)) {
|
|
185
|
+
errors.push(`client/bff verdict ${id} has no fix-plan block (Fix plan / Geliştirme planı) in its detail section`);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// 5. Humanizer punctuation
|
|
190
|
+
const bodyLines = text.split("\n");
|
|
191
|
+
for (let i = 0; i < bodyLines.length; i++) {
|
|
192
|
+
for (const b of BANNED_PUNCT) {
|
|
193
|
+
if (bodyLines[i].includes(b.ch)) {
|
|
194
|
+
errors.push(`banned punctuation ${b.name} at line ${i + 1} (humanizer policy)`);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// 6. Redaction leak scan
|
|
200
|
+
for (let i = 0; i < bodyLines.length; i++) {
|
|
201
|
+
if (EMAIL_RE.test(bodyLines[i])) {
|
|
202
|
+
errors.push(`redaction leak: email address at line ${i + 1}`);
|
|
203
|
+
}
|
|
204
|
+
if (CARD_RE.test(bodyLines[i])) {
|
|
205
|
+
errors.push(`redaction leak: card-like digit run at line ${i + 1}`);
|
|
206
|
+
}
|
|
207
|
+
if (NATIONAL_ID_RE.test(bodyLines[i]) && !CARD_RE.test(bodyLines[i])) {
|
|
208
|
+
warns.push(`possible redaction leak: bare 11-digit run at line ${i + 1} (national-id-like; ignore if it is a numeric trx id)`);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Report
|
|
213
|
+
for (const w of warns) console.error(`WARN: ${w}`);
|
|
214
|
+
for (const e of errors) console.error(`ERROR: ${e}`);
|
|
215
|
+
if (errors.length > 0) {
|
|
216
|
+
console.error(
|
|
217
|
+
`validate-complaint-doc: FAIL (${errors.length} error(s), ${warns.length} warning(s))`,
|
|
218
|
+
);
|
|
219
|
+
process.exit(1);
|
|
220
|
+
}
|
|
221
|
+
if (strict && warns.length > 0) {
|
|
222
|
+
console.error(`validate-complaint-doc: FAIL under --strict (${warns.length} warning(s))`);
|
|
223
|
+
process.exit(1);
|
|
224
|
+
}
|
|
225
|
+
console.log(`validate-complaint-doc: OK (${warns.length} warning(s))`);
|
|
226
|
+
process.exit(0);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
main();
|
|
@@ -127,8 +127,13 @@ function validateConformance(parsed, selectedIds, errors) {
|
|
|
127
127
|
`${label} (${id}): verdict "conformant" needs the file it was checked in - a verdict with no evidence is an assertion`,
|
|
128
128
|
);
|
|
129
129
|
}
|
|
130
|
-
if (
|
|
131
|
-
|
|
130
|
+
if (
|
|
131
|
+
row.verdict === "not-applicable" &&
|
|
132
|
+
(typeof row.reason !== "string" || row.reason.length < 4)
|
|
133
|
+
) {
|
|
134
|
+
errors.push(
|
|
135
|
+
`${label} (${id}): verdict "not-applicable" needs a reason naming why the rule cannot bind`,
|
|
136
|
+
);
|
|
132
137
|
}
|
|
133
138
|
if (row.verdict === "violated") {
|
|
134
139
|
const matched = (parsed.findings ?? []).some((f) => f?.ruleId === id);
|
|
@@ -141,7 +146,8 @@ function validateConformance(parsed, selectedIds, errors) {
|
|
|
141
146
|
});
|
|
142
147
|
|
|
143
148
|
for (const [id, count] of seen) {
|
|
144
|
-
if (count > 1)
|
|
149
|
+
if (count > 1)
|
|
150
|
+
errors.push(`conformance: ruleId "${id}" appears ${count} times (expected exactly once)`);
|
|
145
151
|
}
|
|
146
152
|
const missing = [...selectedIds].filter((id) => !seen.has(id));
|
|
147
153
|
if (missing.length > 0) {
|