@mmerterden/multi-agent-pipeline 20.11.1 → 20.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/README.md +1 -1
  3. package/README.tr.md +1 -1
  4. package/docs/facts.json +2 -2
  5. package/install/_common.mjs +1 -1
  6. package/install/_plugin-skills.mjs +13 -9
  7. package/install/catalog-history.json +1 -1
  8. package/install/claude.mjs +9 -0
  9. package/install/codex.mjs +12 -12
  10. package/manifest.json +74 -67
  11. package/package.json +5 -5
  12. package/pipeline/agents/code-reviewer.md +1 -1
  13. package/pipeline/commands/multi-agent/SKILL.md +1 -0
  14. package/pipeline/commands/multi-agent/analysis/SKILL.md +7 -3
  15. package/pipeline/commands/multi-agent/analysis-jira/SKILL.md +57 -7
  16. package/pipeline/commands/multi-agent/analysis-resolve/SKILL.md +2 -2
  17. package/pipeline/commands/multi-agent/autopilot/SKILL.md +6 -0
  18. package/pipeline/commands/multi-agent/autopilot-off/SKILL.md +1 -0
  19. package/pipeline/commands/multi-agent/doctor/SKILL.md +6 -0
  20. package/pipeline/commands/multi-agent/feedback/SKILL.md +2 -2
  21. package/pipeline/commands/multi-agent/forget/SKILL.md +1 -0
  22. package/pipeline/commands/multi-agent/garbage-collect/SKILL.md +1 -0
  23. package/pipeline/commands/multi-agent/kill/SKILL.md +1 -0
  24. package/pipeline/commands/multi-agent/model/SKILL.md +7 -0
  25. package/pipeline/commands/multi-agent/prune-logs/SKILL.md +1 -0
  26. package/pipeline/commands/multi-agent/prune-prompts/SKILL.md +1 -0
  27. package/pipeline/commands/multi-agent/review/SKILL.md +5 -0
  28. package/pipeline/commands/multi-agent/review-analysis/SKILL.md +7 -2
  29. package/pipeline/commands/multi-agent/sync/SKILL.md +5 -6
  30. package/pipeline/lib/agent-model-sync.mjs +95 -0
  31. package/pipeline/lib/analysis-jira-write.sh +81 -27
  32. package/pipeline/lib/analysis-quality.mjs +261 -0
  33. package/pipeline/lib/analysis-sections.mjs +105 -0
  34. package/pipeline/lib/jira-epic-link.sh +54 -0
  35. package/pipeline/lib/model-rung.sh +7 -0
  36. package/pipeline/multi-agent-refs/analysis/locked.md +4 -4
  37. package/pipeline/multi-agent-refs/analysis/redesign.md +3 -1
  38. package/pipeline/multi-agent-refs/analysis/render.md +20 -21
  39. package/pipeline/multi-agent-refs/analysis/resolve.md +1 -1
  40. package/pipeline/multi-agent-refs/analysis/review.md +16 -3
  41. package/pipeline/multi-agent-refs/analysis/synthesis.md +10 -2
  42. package/pipeline/multi-agent-refs/analysis-template-corporate.md +44 -9
  43. package/pipeline/multi-agent-refs/analysis-template.md +21 -14
  44. package/pipeline/multi-agent-refs/cross-cli-contract.md +4 -0
  45. package/pipeline/multi-agent-refs/features/analysis-jira.md +69 -19
  46. package/pipeline/multi-agent-refs/features/doctor.md +10 -0
  47. package/pipeline/multi-agent-refs/features/model-fallback.md +10 -0
  48. package/pipeline/multi-agent-refs/features/phone-api.md +1 -1
  49. package/pipeline/multi-agent-refs/features/unattended-security.md +2 -2
  50. package/pipeline/multi-agent-refs/features/usage-reporting.md +13 -0
  51. package/pipeline/schemas/analysis-spec.schema.json +22 -0
  52. package/pipeline/schemas/prefs.schema.json +22 -1
  53. package/pipeline/scripts/analysis-story-body.mjs +210 -0
  54. package/pipeline/scripts/analysis-story-tree.mjs +73 -12
  55. package/pipeline/scripts/analysis-tickets-writeback.mjs +101 -0
  56. package/pipeline/scripts/build-references.mjs +14 -1
  57. package/pipeline/scripts/confluence-readback.mjs +148 -0
  58. package/pipeline/scripts/doctor.mjs +48 -0
  59. package/pipeline/scripts/jira-wiki-escape.mjs +40 -0
  60. package/pipeline/scripts/migrate-prefs.mjs +14 -0
  61. package/pipeline/scripts/unattended_policy.py +12 -5
  62. package/pipeline/scripts/usage-report.mjs +84 -9
  63. package/pipeline/scripts/validate-analysis-doc.mjs +120 -70
  64. package/pipeline/skills/.skill-manifest.json +8 -8
  65. package/pipeline/skills/shared/core/multi-agent-analysis/SKILL.md +6 -1
  66. package/pipeline/skills/shared/core/multi-agent-analysis-jira/SKILL.md +56 -6
  67. package/pipeline/skills/shared/core/multi-agent-autopilot/SKILL.md +6 -0
  68. package/pipeline/skills/shared/core/multi-agent-doctor/SKILL.md +6 -0
  69. package/pipeline/skills/shared/core/multi-agent-review/SKILL.md +5 -0
  70. package/pipeline/skills/shared/core/multi-agent-review-analysis/SKILL.md +5 -0
  71. package/pipeline/skills/shared/core/multi-agent-setup/SKILL.md +1 -0
@@ -99,6 +99,7 @@ const CHECK_IDS = [
99
99
  "mcp-surface",
100
100
  "disk-space",
101
101
  "worktree-residue",
102
+ "usage-report",
102
103
  ];
103
104
 
104
105
  // Only under `--profile server`. Kept in their own list so `--list-checks`
@@ -883,6 +884,52 @@ function checkMcpSurface() {
883
884
  );
884
885
  }
885
886
 
887
+ // usage-report.mjs runs detached with its output discarded, so the record it
888
+ // writes after every send attempt is the one place a broken reporting path
889
+ // becomes visible.
890
+ const USAGE_STEPS = {
891
+ "no-login": () => `run node ${join(HERE, "usage-identity.mjs")} --refresh`,
892
+ "no-token": () => `run node ${join(HERE, "usage-register.mjs")}`,
893
+ "endpoint-not-allowed": () => "set usageLog.endpoint to an https address",
894
+ rejected: () => `run node ${join(HERE, "usage-register.mjs")}`,
895
+ "not-accepted": () => `run node ${join(HERE, "usage-identity.mjs")} --refresh`,
896
+ redirected: (r) =>
897
+ r.location
898
+ ? `set usageLog.endpoint to the address on ${r.location} it redirects to`
899
+ : `run node ${join(HERE, "migrate-prefs.mjs")}`,
900
+ failed: () => "re-run a phase once the reporting endpoint is reachable",
901
+ };
902
+
903
+ function checkUsageReport() {
904
+ const prefs = readJson(join(CLAUDE, "multi-agent-preferences.json"));
905
+ const usage = prefs?.global?.usageLog;
906
+ if (usage?.enabled !== true || usage?.optOut === true) {
907
+ skip("usage-report", "usage reporting is off");
908
+ return;
909
+ }
910
+ const last = readJson(join(CLAUDE, "logs", "multi-agent", "usage-last-send.json"));
911
+ if (!last || typeof last.outcome !== "string") {
912
+ skip("usage-report", "no report has been sent from this machine yet");
913
+ return;
914
+ }
915
+ if (last.outcome === "accepted") {
916
+ ok("usage-report");
917
+ return;
918
+ }
919
+ const key = last.outcome === "skipped" ? last.reason : last.outcome;
920
+ const stepFor = USAGE_STEPS[key] ?? USAGE_STEPS.failed;
921
+ const detail = [last.status, last.reason, last.location && `to ${last.location}`]
922
+ .filter(Boolean)
923
+ .join(", ");
924
+ const when = last.t ? ` at ${last.t}` : "";
925
+ report(
926
+ "usage-report",
927
+ "WARN",
928
+ `the last usage report${last.id ? ` for ${last.id}` : ""} was ${last.outcome}${detail ? ` (${detail})` : ""}${when}, so runs are missing from the usage panel`,
929
+ stepFor(last),
930
+ );
931
+ }
932
+
886
933
  function checkDiskSpace() {
887
934
  let freeGb = null;
888
935
  try {
@@ -1175,6 +1222,7 @@ function main() {
1175
1222
  checkMcpSurface();
1176
1223
  checkDiskSpace();
1177
1224
  checkWorktreeResidue();
1225
+ checkUsageReport();
1178
1226
  if (PROFILE === "server") checkServerProfile(resolved);
1179
1227
 
1180
1228
  const blocked = results.filter((r) => r.severity === "BLOCK");
@@ -72,6 +72,46 @@ export function findUnescaped(text) {
72
72
  return hits;
73
73
  }
74
74
 
75
+ // Every character that opens wiki markup: macros {..}, links and mentions [..],
76
+ // table cells |, images !..!, and inline formatting * _ ^ ~ + - #.
77
+ const MARKUP = /[\\{}[\]|!*_^~+\-#]/g;
78
+
79
+ /**
80
+ * Escape plain text taken from a document so Jira renders it literally. The
81
+ * generator writes its own markup around the result; nothing inside the result
82
+ * can open a macro, a mention, an image, a link or formatting.
83
+ *
84
+ * @param {string} text
85
+ * @returns {string}
86
+ */
87
+ export function escapeJiraWiki(text) {
88
+ const escaped = String(text ?? "")
89
+ .replace(MARKUP, (c) => `\\${c}`)
90
+ .replace(/^(h[1-6]|bq)\./gm, "$1\\.");
91
+ return escapeJiraEmoticons(escaped);
92
+ }
93
+
94
+ /**
95
+ * A wiki link to an http(s) URL, or the escaped label alone for any other
96
+ * scheme. The URL is percent-encoded where a character would end the link.
97
+ *
98
+ * @param {string} label
99
+ * @param {string} url
100
+ * @returns {string}
101
+ */
102
+ export function wikiLink(label, url) {
103
+ const text = escapeJiraWiki(label);
104
+ let u;
105
+ try {
106
+ u = new URL(url);
107
+ } catch {
108
+ return text;
109
+ }
110
+ if (u.protocol !== "https:" && u.protocol !== "http:") return text;
111
+ const safe = u.href.replace(/[|[\]{}!\s]/g, (c) => encodeURIComponent(c));
112
+ return `[${text}|${safe}]`;
113
+ }
114
+
75
115
  async function readStdin() {
76
116
  const chunks = [];
77
117
  for await (const chunk of process.stdin) chunks.push(chunk);
@@ -15,6 +15,7 @@ import path from "node:path";
15
15
  import os from "node:os";
16
16
  import { fileURLToPath } from "node:url";
17
17
  import { runMain } from "../lib/fatal.mjs";
18
+ import { canonicalEndpoint } from "../lib/usage-endpoint.mjs";
18
19
  import { lockTimeoutFrom, withFileLockSync, writeJsonAtomicSync } from "../lib/json-file-lock.mjs";
19
20
 
20
21
  const DEFAULT_FILE = path.join(os.homedir(), ".claude", "multi-agent-preferences.json");
@@ -445,9 +446,22 @@ function migrate(prefs) {
445
446
  // reportContent / wikiScope structure. Idempotent - safe to re-run.
446
447
  migrateReportChannels(out, changes);
447
448
 
449
+ // 13. usageLog.endpoint on the platform host: it answers every request with a
450
+ // redirect, and the usage clients never follow one with a credential attached.
451
+ migrateUsageEndpoint(out, changes);
452
+
448
453
  return { out, changes, alreadyMigrated: changes.length === 0 };
449
454
  }
450
455
 
456
+ function migrateUsageEndpoint(out, changes) {
457
+ const usage = out.global.usageLog;
458
+ if (!usage || typeof usage !== "object" || typeof usage.endpoint !== "string") return;
459
+ const next = canonicalEndpoint(usage.endpoint);
460
+ if (next === usage.endpoint) return;
461
+ changes.push(`usageLog.endpoint: ${usage.endpoint} → ${next}`);
462
+ usage.endpoint = next;
463
+ }
464
+
451
465
  // v5.7+ sub-migration: legacy jiraCommentDefault / confluenceDefault / wikiDefault booleans
452
466
  // → new reportChannels {pr,jira,confluence,wiki} object + wikiScope array. Also initializes
453
467
  // reportContent + autopilotReportTimeoutSeconds with schema defaults. Old fields are removed
@@ -570,15 +570,22 @@ def _run_base_sha(env=None):
570
570
  return sha if re.match(r"^[0-9a-f]{7,40}$", sha) else None
571
571
 
572
572
 
573
+ _TOP_CACHE = {}
574
+
575
+
573
576
  def _worktree_top(d):
577
+ if d in _TOP_CACHE:
578
+ return _TOP_CACHE[d]
574
579
  try:
575
580
  import subprocess
576
581
 
577
582
  r = subprocess.run(["git", "-C", d, "rev-parse", "--show-toplevel"],
578
583
  capture_output=True, text=True, timeout=3)
579
- return os.path.realpath(r.stdout.strip()) if r.returncode == 0 and r.stdout.strip() else None
584
+ top = os.path.realpath(r.stdout.strip()) if r.returncode == 0 and r.stdout.strip() else None
580
585
  except Exception:
581
- return None
586
+ top = None
587
+ _TOP_CACHE[d] = top
588
+ return top
582
589
 
583
590
 
584
591
  def _tracked_clean(path, env=None, cwd=None):
@@ -609,11 +616,11 @@ def _tracked_clean(path, env=None, cwd=None):
609
616
 
610
617
  # The file's path relative to the worktree root, so the base lookup
611
618
  # names it correctly and a file outside this worktree is rejected.
612
- top = g("rev-parse", "--show-toplevel")
619
+ top = _worktree_top(d)
613
620
  ls = g("ls-files", "--error-unmatch", "--", name)
614
621
  st = g("status", "--porcelain", "--untracked-files=all", "--", name)
615
- in_worktree = top.returncode == 0 and bool(top.stdout.strip())
616
- rel = os.path.relpath(p, os.path.realpath(top.stdout.strip())) if in_worktree else None
622
+ in_worktree = top is not None
623
+ rel = os.path.relpath(p, top) if in_worktree else None
617
624
  at_base = (
618
625
  g("cat-file", "-e", f"{base}:{rel}").returncode == 0
619
626
  and g("diff", "--quiet", base, "--", name).returncode == 0
@@ -27,7 +27,15 @@
27
27
  * @module pipeline/scripts/usage-report
28
28
  */
29
29
 
30
- import { readFileSync, existsSync, readdirSync, appendFileSync } from "fs";
30
+ import {
31
+ readFileSync,
32
+ existsSync,
33
+ readdirSync,
34
+ appendFileSync,
35
+ mkdirSync,
36
+ renameSync,
37
+ writeFileSync,
38
+ } from "fs";
31
39
  import { canonicalEndpoint } from "../lib/usage-endpoint.mjs";
32
40
  import { homedir, platform } from "os";
33
41
  import { dirname, join } from "path";
@@ -709,27 +717,78 @@ function buildEvent(state, { event = null, login = null } = {}) {
709
717
  };
710
718
  }
711
719
 
720
+ function hostOf(url) {
721
+ try {
722
+ return new URL(url).hostname;
723
+ } catch {
724
+ return null;
725
+ }
726
+ }
727
+
728
+ /**
729
+ * The outcome of one POST. A redirect is never followed, because the ingest
730
+ * token would ride along to wherever it points; its target host is reported
731
+ * instead, so a moved endpoint is visible rather than silent.
732
+ */
712
733
  async function post(endpoint, token, event) {
713
734
  const ctrl = new AbortController();
714
735
  const timer = setTimeout(() => ctrl.abort(), TIMEOUT_MS);
715
736
  try {
716
- await fetch(endpoint, {
737
+ const res = await fetch(endpoint, {
717
738
  method: "POST",
718
739
  headers: {
719
740
  "Content-Type": "application/json",
720
741
  "X-Usage-Token": token,
721
742
  },
722
743
  body: JSON.stringify(event),
723
- redirect: "error",
744
+ redirect: "manual",
724
745
  signal: ctrl.signal,
725
746
  });
726
- } catch {
727
- /* never break the run on a telemetry failure */
747
+ if (res.status >= 300 && res.status < 400) {
748
+ return {
749
+ outcome: "redirected",
750
+ status: res.status,
751
+ location: hostOf(res.headers.get("location") || ""),
752
+ };
753
+ }
754
+ if (!res.ok) return { outcome: "rejected", status: res.status };
755
+ let body = null;
756
+ try {
757
+ body = await res.json();
758
+ } catch {
759
+ /* a 2xx without a JSON body is still a delivery */
760
+ }
761
+ return { outcome: body?.accepted === false ? "not-accepted" : "accepted", status: res.status };
762
+ } catch (err) {
763
+ const reason = ctrl.signal.aborted ? "timeout" : (err?.cause?.code ?? err?.name ?? "error");
764
+ return { outcome: "failed", reason: codeOf(String(reason), 40) };
728
765
  } finally {
729
766
  clearTimeout(timer);
730
767
  }
731
768
  }
732
769
 
770
+ export const LAST_SEND_FILE = "usage-last-send.json";
771
+
772
+ /**
773
+ * Overwrites the record of the most recent send attempt. The emitter runs
774
+ * detached with its output discarded, so this file is the only place a broken
775
+ * reporting path shows up; doctor reads it. It carries no token and no path.
776
+ */
777
+ function recordSend(entry) {
778
+ const dir = join(homedir(), ".claude", "logs", "multi-agent");
779
+ const target = join(dir, LAST_SEND_FILE);
780
+ const tmp = `${target}.${process.pid}.tmp`;
781
+ try {
782
+ mkdirSync(dir, { recursive: true });
783
+ writeFileSync(tmp, JSON.stringify({ t: new Date().toISOString(), ...entry }) + "\n", {
784
+ mode: 0o600,
785
+ });
786
+ renameSync(tmp, target);
787
+ } catch {
788
+ /* logs dir unwritable - never break the run */
789
+ }
790
+ }
791
+
733
792
  // The write-only ingest token rides in a header; sending it over http would
734
793
  // leak it in cleartext, contradicting the setup contract ("transmitted only
735
794
  // over TLS"). Accept https anywhere, and http ONLY for loopback (local dev).
@@ -759,10 +818,16 @@ async function main() {
759
818
  if (!args.dryRun) {
760
819
  if (prefs.optOut === true) return;
761
820
  if (prefs.enabled !== true) return;
762
- if (!endpointAllowed(endpoint)) return;
821
+ if (!endpointAllowed(endpoint)) {
822
+ recordSend({ id: args.taskId, outcome: "skipped", reason: "endpoint-not-allowed" });
823
+ return;
824
+ }
763
825
  }
764
826
  const token = args.dryRun ? "" : resolveToken();
765
- if (!args.dryRun && !token) return;
827
+ if (!args.dryRun && !token) {
828
+ recordSend({ id: args.taskId, outcome: "skipped", reason: "no-token" });
829
+ return;
830
+ }
766
831
 
767
832
  const statePath = resolveStatePath(args);
768
833
  let state = statePath ? readJson(statePath) : null;
@@ -782,7 +847,10 @@ async function main() {
782
847
  const who = resolveReportingLogin();
783
848
  if (!who.login) {
784
849
  process.stderr.write(`usage-report: not sent - ${who.reason}\n`);
785
- if (!args.dryRun) return;
850
+ if (!args.dryRun) {
851
+ recordSend({ id: runId(state), ev: kind, outcome: "skipped", reason: "no-login" });
852
+ return;
853
+ }
786
854
  }
787
855
 
788
856
  const event = buildEvent(state, { event: kind, login: who.login });
@@ -793,7 +861,14 @@ async function main() {
793
861
  }
794
862
 
795
863
  if (event.st !== "in_progress") appendErrorLedger(event);
796
- await post(endpoint, token, event);
864
+ const result = await post(endpoint, token, event);
865
+ recordSend({ id: event.id, ev: event.ev, host: hostOf(endpoint), ...result });
866
+ if (result.outcome !== "accepted") {
867
+ const detail = result.status ?? result.reason;
868
+ const where = result.location ? ` to ${result.location}` : "";
869
+ const verb = result.outcome === "not-accepted" ? "not accepted" : result.outcome;
870
+ process.stderr.write(`usage-report: ${verb}${where} (${detail}) by ${hostOf(endpoint)}\n`);
871
+ }
797
872
  }
798
873
 
799
874
  // Entry-point check: compare file URLs, never string-split a path.
@@ -42,6 +42,15 @@
42
42
 
43
43
  import { readFileSync } from "node:fs";
44
44
  import { runMain } from "../lib/fatal.mjs";
45
+ import { redesignSections, sectionBody, tableDataRows } from "../lib/analysis-sections.mjs";
46
+ import {
47
+ businessLayerTechnical,
48
+ citedUndefined,
49
+ designFrameUnmapped,
50
+ exceptionFlow,
51
+ missingInputsFinal,
52
+ scopeOverlap,
53
+ } from "../lib/analysis-quality.mjs";
45
54
 
46
55
  // A repo-less run (Locked 34) still splits by channel, derived from the evidence:
47
56
  // "mobile" and "web" are its platform values. "none" is the narrower case where the
@@ -51,7 +60,7 @@ const KNOWN_PLATFORMS = new Set(["ios", "android", "web", "backend", "mobile", "
51
60
  const REQUIRED_FM = ["feature", "platform", "language", "mode", "template_version"];
52
61
 
53
62
  // Never-omitted sections (Locked 2), matched by bilingual title keyword so the
54
- // re-flowed section numbering does not matter.
63
+ // profile's own numbering does not matter.
55
64
  const REQUIRED_SECTIONS = [
56
65
  { key: "summary", any: ["Summary", "Özet", "Ozet"] },
57
66
  { key: "goals", any: ["Goals", "Hedefler"] },
@@ -108,6 +117,43 @@ const BANNED_PUNCT = [
108
117
  // that blocked a correctly closed document. English has the same trap in
109
118
  // "reopened" and "opened".
110
119
  const OPEN_STATUS = ["açık", "acik", "open", "girdi bekleniyor", "pending input"];
120
+ // Words that make a requirement untestable because they carry no threshold.
121
+ // Matched as whole words in either language, case-insensitively.
122
+ const VAGUE_WORDS = [
123
+ "appropriate",
124
+ "appropriately",
125
+ "as needed",
126
+ "as appropriate",
127
+ "user-friendly",
128
+ "fast",
129
+ "quickly",
130
+ "etc.",
131
+ "and/or",
132
+ "various",
133
+ "if possible",
134
+ "reasonable",
135
+ "uygun şekilde",
136
+ "gerektiğinde",
137
+ "gerekirse",
138
+ "hızlı",
139
+ "hızlıca",
140
+ "vb.",
141
+ "vs.",
142
+ "çeşitli",
143
+ "mümkünse",
144
+ "kullanıcı dostu",
145
+ "makul",
146
+ ];
147
+
148
+ function vagueWords(text) {
149
+ const found = [];
150
+ for (const w of VAGUE_WORDS) {
151
+ const esc = w.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&");
152
+ if (new RegExp(`(?<![\\p{L}\\p{N}])${esc}(?![\\p{L}\\p{N}])`, "iu").test(text)) found.push(w);
153
+ }
154
+ return found;
155
+ }
156
+
111
157
  function isOpenStatus(cell) {
112
158
  return String(cell)
113
159
  .split("/")
@@ -141,51 +187,6 @@ function parseFrontMatter(text) {
141
187
  return { fm, bodyStart: end + 1 };
142
188
  }
143
189
 
144
- function sectionBody(lines, keywords, level = 2) {
145
- // Opened by a heading at exactly `level`; closed by the next heading at that
146
- // level OR ANY SHALLOWER one. A sub-section that is the last of its parent has
147
- // no sibling after it, so terminating only on equal depth runs the body into
148
- // the following section and audits its rows as if they belonged here.
149
- const open = new RegExp(`^#{${level}}\\s+\\d+(\\.\\d+)*\\.?\\s`);
150
- const close = new RegExp(`^#{1,${level}}\\s`);
151
- let start = -1;
152
- for (let i = 0; i < lines.length; i++) {
153
- if (open.test(lines[i]) && keywords.some((k) => lines[i].includes(k))) {
154
- start = i;
155
- break;
156
- }
157
- }
158
- if (start < 0) return null;
159
- let end = lines.length;
160
- for (let i = start + 1; i < lines.length; i++) {
161
- if (close.test(lines[i])) {
162
- end = i;
163
- break;
164
- }
165
- }
166
- return lines.slice(start + 1, end);
167
- }
168
-
169
- // Data rows are the pipe-rows that follow a table's separator row, which is the
170
- // only shape that distinguishes them from the header without guessing at cell text.
171
- function tableDataRows(bodyLines) {
172
- const rows = [];
173
- let afterSeparator = false;
174
- for (const line of bodyLines) {
175
- const t = line.trim();
176
- if (!t.startsWith("|")) {
177
- afterSeparator = false;
178
- continue;
179
- }
180
- if (/^\|[\s\-:|]+\|$/.test(t)) {
181
- afterSeparator = true;
182
- continue;
183
- }
184
- if (afterSeparator) rows.push(t);
185
- }
186
- return rows;
187
- }
188
-
189
190
  // The report registry. `--report` prints one line per check, and the reason a
190
191
  // check did not run is part of the answer: the traceability matrix is corporate
191
192
  // only, so on a global document it never executes - and until this existed the
@@ -763,29 +764,42 @@ function main() {
763
764
 
764
765
  mark("rule-to-test");
765
766
 
767
+ // 2g-2. Vague wording. A requirement two readers can pass differently is not
768
+ // testable, and these words carry no threshold. Only id-bearing requirement
769
+ // rows are read; summary prose may speak loosely. Code spans are skipped.
770
+ {
771
+ const ROW_ID = /\b(BR-[a-z0-9]+(?:-[a-z0-9]+)*-\d+|IG-\d{2,3}|FG-\d{2,3})\b/i;
772
+ for (const line of allLines) {
773
+ const t = line.trim();
774
+ if (!t.startsWith("|")) continue;
775
+ const id = t.match(ROW_ID);
776
+ if (!id) continue;
777
+ const words = vagueWords(t.replace(/`[^`]*`/g, ""));
778
+ if (words.length) {
779
+ warns.push(
780
+ `vague wording in ${id[1]}: ${words.map((w) => `"${w}"`).join(", ")} - state a threshold, a list or a condition instead`,
781
+ );
782
+ }
783
+ }
784
+ mark("vague-wording");
785
+ }
786
+
766
787
  // 2h. Redesign mode (Locked 36). Eight checks, live only when the front-matter
767
788
  // opts in. The contract they enforce is analysis/redesign.md, which loads only
768
789
  // on a redesign run; what is here is the enforcement, not the explanation.
769
790
  const redesign = String(parsed?.fm?.redesign || "false").toLowerCase() === "true";
770
- const cbBody = sectionBody(
771
- allLines,
772
- ["Mevcut Davranış", "Mevcut Davranis", "Current Behaviour", "Current Behavior"],
773
- 3,
774
- );
775
- const epBody = sectionBody(
776
- allLines,
777
- ["Endpoint Eşlemesi", "Endpoint Eslemesi", "Endpoint Mapping"],
778
- 3,
779
- );
780
- const dlBody = sectionBody(allLines, ["Fark Listesi", "Difference List"], 3);
791
+ const rd = redesignSections(allLines, profile);
792
+ const cbBody = rd.cb.body;
793
+ const epBody = rd.ep.body;
794
+ const dlBody = rd.dl.body;
781
795
  if (redesign) {
782
796
  // 1. The sections themselves. Without this every row check below passes over
783
797
  // nothing, which is the specific defect that shows a gate green on an empty
784
798
  // document - the same shape the corporate matrix check guards against.
785
799
  for (const [body, name] of [
786
- [cbBody, "4.5 Current Behaviour"],
787
- [epBody, "4.6 Endpoint Mapping"],
788
- [dlBody, "9.5 Difference List"],
800
+ [cbBody, rd.cb.label],
801
+ [epBody, rd.ep.label],
802
+ [dlBody, rd.dl.label],
789
803
  ]) {
790
804
  if (!body) errors.push(`redesign: true but Section ${name} is missing (Locked 36)`);
791
805
  }
@@ -866,7 +880,7 @@ function main() {
866
880
  const cellCount = (line) =>
867
881
  line.trim().replace(/^\|/, "").replace(/\|$/, "").split("|").length;
868
882
  if (!header) {
869
- errors.push("Section 4.6 has no v1/v2 header row (Locked 36)");
883
+ errors.push(`Section ${rd.ep.label} has no v1/v2 header row (Locked 36)`);
870
884
  } else {
871
885
  const cols = cellCount(header);
872
886
  for (const row of tableDataRows(epBody)) {
@@ -883,8 +897,8 @@ function main() {
883
897
  //
884
898
  // The heading match alone is not enough to say that. sectionBody tests the
885
899
  // keyword as a raw substring of the heading line - deliberately, since Locked
886
- // 2 re-flows section numbers and the corporate profile numbers them
887
- // differently - so an unrelated `### 4.9 Notes on the old Current Behaviour
900
+ // 2 drops sections and the corporate profile numbers them differently - so
901
+ // an unrelated `### 4.9 Notes on the old Current Behaviour
888
902
  // audit process` matched, and Phase 3 runs --strict, which turns this warning
889
903
  // into a blocked document. The artefact itself is the honest signal: a
890
904
  // rendered current-behaviour table always carries `CB-<slug>-NN` ids, and a
@@ -900,6 +914,43 @@ function main() {
900
914
  mark("redesign-artifacts", "not a redesign");
901
915
  }
902
916
 
917
+ // 2e. Document quality (analysis-quality.mjs): each check returns its own
918
+ // errors and warnings, so it is testable without this file.
919
+ {
920
+ const isFinal = (parsed?.fm?.status || "draft").toLowerCase() === "final";
921
+ const corporate = profile === "corporate";
922
+ const run = (id, check, skipped) => {
923
+ if (!skipped) {
924
+ const r = check();
925
+ errors.push(...r.errors);
926
+ warns.push(...r.warns);
927
+ }
928
+ mark(id, skipped);
929
+ };
930
+ run(
931
+ "exception-flow",
932
+ () => exceptionFlow(allLines, { final: isFinal }),
933
+ corporate ? undefined : "global profile",
934
+ );
935
+ run("scope-overlap", () => scopeOverlap(allLines));
936
+ run("cited-undefined", () => citedUndefined(allLines));
937
+ run(
938
+ "design-frame-unmapped",
939
+ () => designFrameUnmapped(allLines),
940
+ corporate ? undefined : "global profile",
941
+ );
942
+ run(
943
+ "business-layer-technical",
944
+ () => businessLayerTechnical(allLines),
945
+ corporate ? "corporate profile" : undefined,
946
+ );
947
+ run(
948
+ "missing-inputs",
949
+ () => missingInputsFinal(allLines, { final: isFinal }),
950
+ isFinal ? undefined : "draft document",
951
+ );
952
+ }
953
+
903
954
  // 3. Humanizer punctuation. Scope mirrors the humanizer pass exactly
904
955
  // (analysis/render.md): front-matter, fenced code blocks, table rows and
905
956
  // URLs are exempt there, so flagging them here would raise an ERROR that no
@@ -937,11 +988,10 @@ function main() {
937
988
 
938
989
  // 4b. Locked 2's numbering half.
939
990
  //
940
- // It is NOT "numbering re-flows 1..N", which could not hold: Locked 30
941
- // threads ids across the document by section
942
- // NUMBER ("Section 15.1 scenario", "Section 4.4", "Section 3/5 layout cells"),
943
- // so a re-flowed document sends every one of those references to the wrong
944
- // section. No emitted document ever re-flowed. The rule was the wrong half.
991
+ // Rendered sections keep their canonical template numbers and may leave
992
+ // gaps, because Locked 30 threads ids across the document by section NUMBER
993
+ // ("Section 15.1 scenario", "Section 4.4", "Section 3/5 layout cells"), and
994
+ // renumbering would send every one of those references to the wrong section.
945
995
  //
946
996
  // What holds instead: a rendered section keeps its canonical template number,
947
997
  // so gaps are correct and `1, 2, 4, 9, 13, 14, 21` is a valid document. Two
@@ -1018,8 +1068,8 @@ function main() {
1018
1068
  // audited - "used subset" means nothing without the set it is a subset of.
1019
1069
  let inVariant = false;
1020
1070
  for (const line of text.split("\n")) {
1021
- // Keyed on the heading text, not the number: Locked 2 re-flows numbering, so
1022
- // a literal "6.X" match silently stops running the moment the section moves.
1071
+ // Keyed on the heading text, not the number: the corporate profile numbers
1072
+ // this section differently, so a literal "6.X" match would skip it there.
1023
1073
  if (/^#{2,3}\s+\d+(\.[0-9X]+)*\.?\s/.test(line) && /Varyant|Variant/i.test(line)) {
1024
1074
  inVariant = true;
1025
1075
  continue;
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "schemaVersion": "1.0.0",
3
- "generatedAt": "2026-09-29T13:31:43Z",
3
+ "generatedAt": "2026-10-01T20:47:44Z",
4
4
  "skillCount": 218,
5
5
  "entries": [
6
6
  {
@@ -13,7 +13,7 @@
13
13
  },
14
14
  {
15
15
  "path": "shared/core/multi-agent-analysis-jira/SKILL.md",
16
- "sha256": "8c5916a32156bc4c75f97cac1b57dbfe9e49f16027178128d12e50f6e326ae12"
16
+ "sha256": "130543d1017115de351a1175b6b179778642ba0b3207034b54114b39d0546407"
17
17
  },
18
18
  {
19
19
  "path": "shared/core/multi-agent-analysis-resolve/SKILL.md",
@@ -21,7 +21,7 @@
21
21
  },
22
22
  {
23
23
  "path": "shared/core/multi-agent-analysis/SKILL.md",
24
- "sha256": "fff7f2c2d4aa769a5c08beb776a848f91caf485c1d84dd5a4a3571ff02c35e0e"
24
+ "sha256": "a7cf677df00433583d5e2e1578223d1e1797a1bd4d07ff0d1d8596d94baa013a"
25
25
  },
26
26
  {
27
27
  "path": "shared/core/multi-agent-autopilot-off/SKILL.md",
@@ -37,7 +37,7 @@
37
37
  },
38
38
  {
39
39
  "path": "shared/core/multi-agent-autopilot/SKILL.md",
40
- "sha256": "426158ddbb5e8d6e594cd2e02aceb197e1769d692602dfbd2a368a6707081bd3"
40
+ "sha256": "a9731771fbc1f45260f214f069b976eff16e7b25e2f1375b0d9a213575271bc6"
41
41
  },
42
42
  {
43
43
  "path": "shared/core/multi-agent-build-optimize/SKILL.md",
@@ -65,7 +65,7 @@
65
65
  },
66
66
  {
67
67
  "path": "shared/core/multi-agent-doctor/SKILL.md",
68
- "sha256": "1b1450ead2c6ee6ff64a8cfd4fcefeba1084a46f2a2e5d8a8a1077b7ec3f4130"
68
+ "sha256": "2f6222cdef9c6b2b4e2e330ba0acee33ab0fb797029bb16c50bbb244be5fcbe6"
69
69
  },
70
70
  {
71
71
  "path": "shared/core/multi-agent-feedback/SKILL.md",
@@ -145,7 +145,7 @@
145
145
  },
146
146
  {
147
147
  "path": "shared/core/multi-agent-review-analysis/SKILL.md",
148
- "sha256": "f55d3b28266001d150d0438e92f8bb5dfa9cdce517892cf12fe94beee8440c07"
148
+ "sha256": "5f90d2da227e1ffb7fedd00c3a0d094bb0e0e32a26aa25a7ec503ff6e4865503"
149
149
  },
150
150
  {
151
151
  "path": "shared/core/multi-agent-review-issue/SKILL.md",
@@ -157,7 +157,7 @@
157
157
  },
158
158
  {
159
159
  "path": "shared/core/multi-agent-review/SKILL.md",
160
- "sha256": "23b38684ac14c65522ff3f1889ae2f8bab89f10a62db1d7f01f6b46228136157"
160
+ "sha256": "f1bad66314a55f35b7ac056a2f97e44c30702bebfbacc886b8efe7887eec5685"
161
161
  },
162
162
  {
163
163
  "path": "shared/core/multi-agent-route-off/SKILL.md",
@@ -201,7 +201,7 @@
201
201
  },
202
202
  {
203
203
  "path": "shared/core/multi-agent-setup/SKILL.md",
204
- "sha256": "f73388e0a64ba8a1606e3364441b078f3df6f7e99e589fb9bc8b93269a2733b2"
204
+ "sha256": "38fdbe979ab2da87dcee55d1ce988cbda5bab92f5f751f448e2e64b18113c50e"
205
205
  },
206
206
  {
207
207
  "path": "shared/core/multi-agent-stack/SKILL.md",