@mmerterden/multi-agent-pipeline 16.4.0 → 16.6.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.
@@ -821,6 +821,47 @@
821
821
  "default": false,
822
822
  "description": "v6.1.0+ - Phase 4 Step 2.5 rebuttal round. When reviewers disagree (mixed blocker/approved verdict), each reviewer is re-prompted with the others' opposing arguments for one additional round before triage. Lifts signal quality on ambiguous findings at ~1\u00d7 Step 2 token cost. Off by default - flip for security-critical or release-branch reviews."
823
823
  },
824
+ "analysisProfiles": {
825
+ "type": "array",
826
+ "description": "v16.6+ - which analysis standards the /multi-agent:analysis Step 1b picker offers (Locked 32). Listing one value auto-resolves the step instead of asking a question whose answer is already settled. Omitted means both are offered.",
827
+ "items": {
828
+ "type": "string",
829
+ "enum": ["global", "corporate"]
830
+ },
831
+ "default": ["global", "corporate"],
832
+ "uniqueItems": true,
833
+ "minItems": 1
834
+ },
835
+ "analysisProfile": {
836
+ "type": "object",
837
+ "additionalProperties": false,
838
+ "description": "v16.6+ - per-profile deployment bindings. These are site configuration, not part of the shipped template: an unconfigured run still renders the full document and asks for its destination at Phase 3.5 like any other run. Keys are deliberately generic so no organisation's space, page or tooling names live in the repo.",
839
+ "properties": {
840
+ "corporate": {
841
+ "type": "object",
842
+ "additionalProperties": false,
843
+ "description": "Bindings for the corporate profile: where its documents are published and which house terms they use.",
844
+ "properties": {
845
+ "confluenceSpaceKey": {
846
+ "type": "string",
847
+ "description": "Space the analysis page is created in when the Phase 3.5 picker chooses Confluence."
848
+ },
849
+ "confluenceParentPageId": {
850
+ "type": "string",
851
+ "description": "Parent page the analysis is filed under."
852
+ },
853
+ "titleFormat": {
854
+ "type": "string",
855
+ "description": "Page-title pattern, e.g. \"{prefix}{module}_{flow}_{suffix}\". Placeholders are resolved at emit time."
856
+ },
857
+ "titlePrefix": {
858
+ "type": "string",
859
+ "description": "Prefix that marks pipeline-authored pages so they stay distinguishable from hand-written ones."
860
+ }
861
+ }
862
+ }
863
+ }
864
+ },
824
865
  "updateCheck": {
825
866
  "type": "object",
826
867
  "additionalProperties": false,
@@ -840,8 +881,8 @@
840
881
  },
841
882
  "autoUpdate": {
842
883
  "type": "boolean",
843
- "default": false,
844
- "description": "When true, skip the question and run the update flow automatically before the run starts (interactive AND autopilot). Off by default - self-modifying ~/.claude without asking is a surprise."
884
+ "default": true,
885
+ "description": "Run the update flow automatically before the run starts, in interactive modes and autopilot alike, instead of asking. ON by default since v16.5.0: the alternative was telling every user to run a command, and the Supported Version Gate already halts an install below the required floor - so the real choice was never 'update or not', it was 'update, or stop and be told to update'. Set false to be asked once per ttlHours instead; updateCheck.enabled: false silences the check entirely (it does not disable the required floor). Already-loaded phase docs finish the current run on the old version either way; the update takes full effect on the next run."
845
886
  }
846
887
  }
847
888
  },
@@ -0,0 +1,368 @@
1
+ #!/usr/bin/env node
2
+ // build-references.mjs - deterministic Section 21 References table for an
3
+ // /multi-agent:analysis document (Locked 34).
4
+ //
5
+ // The references table used to be prose the model filled in. That lists what an
6
+ // author remembers consulting, which is a different set from what the run
7
+ // actually fetched: sources that failed to load vanish silently, scope decisions
8
+ // made in conversation never appear, and a Figma link with no node id points at
9
+ // whatever the file looks like today. This turns the table into a projection of
10
+ // `state.analysisSpec.evidence.*` so the two cannot disagree.
11
+ //
12
+ // Zero deps. Two modes:
13
+ //
14
+ // build (default) state JSON -> markdown table on stdout
15
+ // --check <doc.md> state JSON + emitted doc -> coverage gate, exit 1 on drift
16
+ //
17
+ // Usage:
18
+ // node build-references.mjs state.json [--lang tr|en]
19
+ // node build-references.mjs state.json --check analysis/Feature-ios.md
20
+ // cat state.json | node build-references.mjs - --lang en
21
+ //
22
+ // Exit codes: 0 ok, 1 coverage failure, 2 usage / parse error.
23
+
24
+ import { readFileSync } from "node:fs";
25
+
26
+ const HEADERS = {
27
+ tr: ["Tür", "Kaynak", "URL / Yol", "Sürüm / Ref", "Rol", "Erişim", "Notlar"],
28
+ en: ["Type", "Source", "URL / Path", "Version / Ref", "Role", "Access", "Notes"],
29
+ };
30
+
31
+ const OK = { tr: "ok", en: "ok" };
32
+ const FAILED = { tr: "erişilemedi", en: "not reachable" };
33
+ const NONE = "-";
34
+
35
+ function argOf(argv, flag) {
36
+ const i = argv.indexOf(flag);
37
+ return i === -1 ? null : argv[i + 1] ?? null;
38
+ }
39
+
40
+ function readState(path) {
41
+ const raw = path === "-" ? readFileSync(0, "utf8") : readFileSync(path, "utf8");
42
+ const parsed = JSON.parse(raw);
43
+ // Accept either the full task state or the analysisSpec alone.
44
+ return parsed.analysisSpec ?? parsed;
45
+ }
46
+
47
+ // A cell that would break the pipe table, or read as an empty column, is worse
48
+ // than a visibly absent value: the reader cannot tell a missing cell from a
49
+ // cell that was never meant to have content.
50
+ function cell(value) {
51
+ if (value === null || value === undefined) return NONE;
52
+ const text = String(value).replace(/\|/g, "\\|").replace(/\r?\n/g, " ").trim();
53
+ return text === "" ? NONE : text;
54
+ }
55
+
56
+ function accessCell(ok, reason, lang) {
57
+ if (ok) return OK[lang];
58
+ return reason ? `${FAILED[lang]} (${cell(reason)})` : FAILED[lang];
59
+ }
60
+
61
+ // The identity of a source for coverage purposes. Two rows describing the same
62
+ // URL are the same source; a row with no locator falls back to its label so a
63
+ // free-text note still has something to match on.
64
+ function keyOf(url, fallback) {
65
+ const value = url && String(url).trim() !== "" && url !== NONE ? url : fallback;
66
+ return value ? String(value).trim() : null;
67
+ }
68
+
69
+ function rowsFrom(spec, lang) {
70
+ const ev = spec.evidence ?? {};
71
+ const rows = [];
72
+ const push = (type, source, url, ref, role, access, notes) => {
73
+ rows.push({
74
+ type,
75
+ cells: [cell(type), cell(source), cell(url), cell(ref), cell(role), access, cell(notes)],
76
+ key: keyOf(url, source),
77
+ });
78
+ };
79
+
80
+ for (const f of ev.figma ?? []) {
81
+ const ref = f.nodeId ? `node-id=${f.nodeId}` : f.fileKey ? `fileKey=${f.fileKey}` : NONE;
82
+ const frames = Array.isArray(f.frames) ? f.frames.length : 0;
83
+ const tier = f.tier ? ` (Tier ${f.tier})` : "";
84
+ push(
85
+ "Figma",
86
+ f.frames?.[0]?.name ?? f.fileKey ?? "design",
87
+ f.url,
88
+ ref,
89
+ lang === "tr" ? "UI tasarım" : "UI design",
90
+ OK[lang] + tier,
91
+ frames ? (lang === "tr" ? `${frames} frame` : `${frames} frames`) : NONE,
92
+ );
93
+ }
94
+
95
+ for (const c of ev.confluence ?? []) {
96
+ const ref = [c.pageId ? `pageId=${c.pageId}` : null, c.version ? `v${c.version}` : null]
97
+ .filter(Boolean)
98
+ .join(" ");
99
+ push(
100
+ "Confluence",
101
+ c.title ?? c.pageId,
102
+ c.url,
103
+ ref || NONE,
104
+ c.embeddedApiTable
105
+ ? lang === "tr" ? "API kontratı" : "API contract"
106
+ : lang === "tr" ? "özellik spesifikasyonu" : "feature spec",
107
+ OK[lang],
108
+ NONE,
109
+ );
110
+ }
111
+
112
+ for (const j of ev.jira ?? []) {
113
+ push("Jira", j.id, j.url ?? NONE, NONE, lang === "tr" ? "ticket" : "ticket", OK[lang], j.summary);
114
+ }
115
+
116
+ for (const s of ev.swagger ?? []) {
117
+ const n = Array.isArray(s.endpoints) ? s.endpoints.length : 0;
118
+ push(
119
+ "Swagger",
120
+ s.title ?? s.url,
121
+ s.url,
122
+ s.version ?? NONE,
123
+ lang === "tr" ? "API kontratı" : "API contract",
124
+ OK[lang],
125
+ n ? (lang === "tr" ? `${n} endpoint` : `${n} endpoints`) : NONE,
126
+ );
127
+ }
128
+
129
+ for (const r of ev.repo ?? []) {
130
+ push(
131
+ "Repo",
132
+ r.repoName,
133
+ r.path ?? r.repoName,
134
+ r.commit ?? r.sha ?? NONE,
135
+ lang === "tr" ? "mevcut kod" : "existing implementation",
136
+ OK[lang],
137
+ r.reuseSummary,
138
+ );
139
+ }
140
+
141
+ for (const s of ev.standards ?? []) {
142
+ push(
143
+ "Standards",
144
+ s.title ?? s.source,
145
+ s.source,
146
+ s.version ?? NONE,
147
+ s.binding ? "binding" : lang === "tr" ? "referans" : "reference",
148
+ OK[lang],
149
+ s.kind,
150
+ );
151
+ }
152
+
153
+ for (const f of ev.firebase ?? []) {
154
+ const n = Array.isArray(f.events) ? f.events.length : 0;
155
+ push(
156
+ "Firebase",
157
+ f.kind ?? "events",
158
+ f.source,
159
+ NONE,
160
+ lang === "tr" ? "referans" : "reference only",
161
+ OK[lang],
162
+ n ? (lang === "tr" ? `${n} event` : `${n} events`) : NONE,
163
+ );
164
+ }
165
+
166
+ for (const d of ev.documents ?? []) {
167
+ push(
168
+ lang === "tr" ? "Doküman" : "Document",
169
+ d.title ?? d.path,
170
+ d.path,
171
+ d.format ?? NONE,
172
+ d.binding ? "binding" : lang === "tr" ? "kapsam dokümanı" : "scope document",
173
+ accessCell(d.fetched !== false, d.fetched === false ? d.reason : null, lang),
174
+ NONE,
175
+ );
176
+ }
177
+
178
+ for (const o of ev.outside ?? []) {
179
+ push(
180
+ lang === "tr" ? "Dış kaynak" : "External",
181
+ o.source,
182
+ o.url ?? NONE,
183
+ o.citation ?? NONE,
184
+ lang === "tr" ? "referans" : "reference",
185
+ OK[lang],
186
+ o.claim,
187
+ );
188
+ }
189
+
190
+ for (const s of ev.signals ?? []) {
191
+ push(
192
+ lang === "tr" ? "Sinyal" : "Signal",
193
+ s.source,
194
+ s.url ?? NONE,
195
+ s.observedAt ?? NONE,
196
+ lang === "tr" ? "referans" : "reference",
197
+ OK[lang],
198
+ s.claim,
199
+ );
200
+ }
201
+
202
+ // Statements the user made in conversation that no fetched source carries.
203
+ // These settle scope as often as a document does, and a document that drops
204
+ // them loses the reason it excluded something.
205
+ for (const t of ev.freeText ?? []) {
206
+ push(
207
+ lang === "tr" ? "Serbest metin" : "Free text",
208
+ t.label ?? (lang === "tr" ? "kullanıcı notu" : "user note"),
209
+ NONE,
210
+ NONE,
211
+ t.role ?? (lang === "tr" ? "kapsam kararı" : "scope decision"),
212
+ NONE,
213
+ t.text ? `"${t.text}"` : NONE,
214
+ );
215
+ }
216
+
217
+ // A declared source that could not be fetched still gets a row. Dropping it
218
+ // reads to the next person as a source that never existed, which is the
219
+ // opposite of what happened.
220
+ for (const e of ev.fetchErrors ?? []) {
221
+ push(
222
+ e.type ?? (lang === "tr" ? "Kaynak" : "Source"),
223
+ e.title ?? e.url,
224
+ e.url,
225
+ NONE,
226
+ lang === "tr" ? "getirilemedi" : "not fetched",
227
+ accessCell(false, e.reason, lang),
228
+ NONE,
229
+ );
230
+ }
231
+
232
+ return rows;
233
+ }
234
+
235
+ function renderTable(rows, lang) {
236
+ const head = HEADERS[lang];
237
+ const lines = [`| ${head.join(" | ")} |`, `|${head.map(() => "---").join("|")}|`];
238
+ for (const r of rows) lines.push(`| ${r.cells.join(" | ")} |`);
239
+ return lines.join("\n");
240
+ }
241
+
242
+ // The References table in an emitted document: from the Section 21 heading to
243
+ // the next heading of the same level or the end of the file.
244
+ function extractReferencesBlock(doc) {
245
+ const lines = doc.split(/\r?\n/);
246
+ const start = lines.findIndex((l) => /^##\s+\d+\.\s+(Referanslar|References)\b/.test(l));
247
+ if (start === -1) return null;
248
+ const out = [];
249
+ for (let i = start + 1; i < lines.length; i++) {
250
+ if (/^##\s+\d+\./.test(lines[i])) break;
251
+ out.push(lines[i]);
252
+ }
253
+ return out.join("\n");
254
+ }
255
+
256
+ // The header row and the separator row carry no source to verify. Matching them
257
+ // against the evidence record is how a correct table gets reported as carrying
258
+ // an invented reference.
259
+ const HEADER_CELLS = new Set(
260
+ Object.values(HEADERS)
261
+ .flat()
262
+ .map((h) => h.toLowerCase()),
263
+ );
264
+
265
+ function tableLocators(block) {
266
+ const found = [];
267
+ for (const line of block.split(/\r?\n/)) {
268
+ if (!line.trim().startsWith("|")) continue;
269
+ const cells = line.split("|").slice(1, -1).map((c) => c.trim());
270
+ if (cells.length < 3) continue;
271
+ if (/^:?-{3,}:?$/.test(cells[0])) continue;
272
+ const locator = cells[2];
273
+ const source = cells[1];
274
+ if (!locator) continue;
275
+ if (HEADER_CELLS.has(locator.toLowerCase()) && HEADER_CELLS.has(cells[0].toLowerCase())) continue;
276
+ found.push({ locator, source, raw: line });
277
+ }
278
+ return found;
279
+ }
280
+
281
+ function check(spec, docPath, lang) {
282
+ const doc = readFileSync(docPath, "utf8");
283
+ const block = extractReferencesBlock(doc);
284
+ const problems = [];
285
+
286
+ if (block === null) {
287
+ problems.push("ERROR: no References section found; Section 21 is never omitted (Locked 21)");
288
+ return problems;
289
+ }
290
+
291
+ const expected = rowsFrom(spec, lang).filter((r) => r.key !== null);
292
+ const rendered = tableLocators(block);
293
+ const renderedText = block;
294
+
295
+ for (const row of expected) {
296
+ if (!renderedText.includes(row.key)) {
297
+ problems.push(
298
+ `ERROR: ${row.type} source consumed by the run but absent from References: ${row.key}`,
299
+ );
300
+ }
301
+ }
302
+
303
+ const expectedKeys = new Set(expected.map((r) => r.key));
304
+ for (const r of rendered) {
305
+ // Header rows and placeholder cells carry no locator to verify.
306
+ if (r.locator === NONE || r.locator === "" || /^<.*>$/.test(r.locator)) continue;
307
+ const known = [...expectedKeys].some((k) => r.locator.includes(k) || k.includes(r.locator));
308
+ if (!known) {
309
+ problems.push(
310
+ `ERROR: References row has no matching evidence entry (invented reference): ${r.locator}`,
311
+ );
312
+ }
313
+ }
314
+
315
+ return problems;
316
+ }
317
+
318
+ function main() {
319
+ const argv = process.argv.slice(2);
320
+ const statePath = argv[0];
321
+ if (!statePath || statePath.startsWith("--")) {
322
+ process.stderr.write(
323
+ "usage: build-references.mjs <state.json|-> [--lang tr|en] [--check <doc.md>]\n",
324
+ );
325
+ process.exit(2);
326
+ }
327
+
328
+ let spec;
329
+ try {
330
+ spec = readState(statePath);
331
+ } catch (err) {
332
+ process.stderr.write(`ERROR: cannot read state: ${err.message}\n`);
333
+ process.exit(2);
334
+ }
335
+
336
+ const lang = argOf(argv, "--lang") ?? spec.language ?? "tr";
337
+ if (!HEADERS[lang]) {
338
+ process.stderr.write(`ERROR: unknown language "${lang}"; expected tr or en\n`);
339
+ process.exit(2);
340
+ }
341
+
342
+ const docPath = argOf(argv, "--check");
343
+ if (docPath) {
344
+ const problems = check(spec, docPath, lang);
345
+ if (problems.length) {
346
+ for (const p of problems) process.stderr.write(`${p}\n`);
347
+ process.stderr.write(`\n${problems.length} references coverage failure(s)\n`);
348
+ process.exit(1);
349
+ }
350
+ process.stdout.write("references coverage ok\n");
351
+ process.exit(0);
352
+ }
353
+
354
+ const rows = rowsFrom(spec, lang);
355
+ if (rows.length === 0) {
356
+ // An analysis with no sources at all is possible (a pure free-text run) but
357
+ // it should say so rather than emit an empty table with a header.
358
+ process.stdout.write(
359
+ lang === "tr"
360
+ ? "Bu koşuda getirilen kaynak yok.\n"
361
+ : "No sources were fetched for this run.\n",
362
+ );
363
+ process.exit(0);
364
+ }
365
+ process.stdout.write(`${renderTable(rows, lang)}\n`);
366
+ }
367
+
368
+ main();
@@ -42,7 +42,9 @@
42
42
 
43
43
  import { readFileSync } from "node:fs";
44
44
 
45
- const KNOWN_PLATFORMS = new Set(["ios", "android", "backend", "frontend"]);
45
+ // "none" is the stack-optional render (Locked 35): a document produced before
46
+ // any repo was chosen. It is a real platform value, not a missing one.
47
+ const KNOWN_PLATFORMS = new Set(["ios", "android", "backend", "frontend", "none"]);
46
48
  const REQUIRED_FM = ["feature", "platform", "language", "mode", "template_version"];
47
49
 
48
50
  // Never-omitted sections (Locked 2), matched by bilingual title keyword so the
@@ -58,6 +60,26 @@ const REQUIRED_SECTIONS = [
58
60
  { key: "references", any: ["References", "Referanslar"] },
59
61
  ];
60
62
 
63
+ // Corporate profile backbone (Locked 33). These render even with zero evidence,
64
+ // carrying N/A or EKLENECEK, so their absence is a structural failure rather
65
+ // than a legitimate omission.
66
+ const CORPORATE_SECTIONS = [
67
+ { key: "purpose and scope", any: ["Amaç ve Kapsam", "Amac ve Kapsam", "Purpose and Scope"] },
68
+ { key: "business analysis", any: ["İş Analizi", "Is Analizi", "Business Analysis"] },
69
+ { key: "business requirements", any: ["İş Gereksinimleri", "Is Gereksinimleri", "Business Requirements"] },
70
+ { key: "ai requirements", any: ["Yapay Zeka", "AI Requirements"] },
71
+ { key: "use cases", any: ["Kullanım Senaryoları", "Kullanim Senaryolari", "Use Cases"] },
72
+ { key: "hardware and infrastructure", any: ["Donanım", "Donanim", "Hardware"] },
73
+ { key: "quality requirements", any: ["Kalite Gereksinimleri", "Quality Requirements"] },
74
+ { key: "regulatory requirements", any: ["Regülasyonel", "Regulasyonel", "Regulatory"] },
75
+ { key: "content requirements", any: ["İçerik Gereksinimleri", "Icerik Gereksinimleri", "Content Requirements"] },
76
+ { key: "risks", any: ["Riskler", "Risks"] },
77
+ { key: "references", any: ["Referanslar", "References"] },
78
+ ];
79
+
80
+ // Sections that need a target repository. Dropped by the stack-optional render.
81
+ const REPO_DEPENDENT_KEYS = new Set(["architecture", "files to add"]);
82
+
61
83
  // Humanizer punctuation policy (Locked 7) - banned codepoints.
62
84
  const BANNED_PUNCT = [
63
85
  { ch: "—", name: "em-dash" },
@@ -163,6 +185,11 @@ function main() {
163
185
  }
164
186
  }
165
187
  const mode = parsed?.fm?.mode || "full";
188
+ const profile = (parsed?.fm?.profile || "global").toLowerCase();
189
+ if (profile !== "global" && profile !== "corporate") {
190
+ errors.push(`front-matter profile must be global|corporate, got: ${profile}`);
191
+ }
192
+ const noPlatform = (parsed?.fm?.platform || "") === "none";
166
193
 
167
194
  // 2. Never-omitted sections (by heading keyword)
168
195
  // Numbered section headings only. Layer headings (`# Bölüm B - Teknik Analiz`)
@@ -173,9 +200,94 @@ function main() {
173
200
  .filter((l) => /^#{2,3}\s+\d+(\.\d+)*\.?\s/.test(l))
174
201
  .map((l) => l.replace(/^#{2,3}\s/, "").trim());
175
202
  const headingBlob = headings.join("\n");
176
- for (const sec of REQUIRED_SECTIONS) {
203
+ const requiredSections = (profile === "corporate" ? CORPORATE_SECTIONS : REQUIRED_SECTIONS)
204
+ // The development layer is legitimately absent when no repo was selected,
205
+ // so requiring it there would fail every stack-optional document.
206
+ .filter((sec) => !(noPlatform && REPO_DEPENDENT_KEYS.has(sec.key)));
207
+ const rule = profile === "corporate" ? "Locked 33" : "Locked 2";
208
+ for (const sec of requiredSections) {
177
209
  if (!sec.any.some((kw) => headingBlob.includes(kw))) {
178
- errors.push(`missing required section (Locked 2): ${sec.key}`);
210
+ errors.push(`missing required section (${rule}): ${sec.key}`);
211
+ }
212
+ }
213
+
214
+ // 2a. Corporate profile: every EKLENECEK owes an open question.
215
+ //
216
+ // A placeholder with nobody assigned to resolve it is how "to be filled in"
217
+ // reaches production. The pairing is what makes the backbone guarantee worth
218
+ // having: the section renders, AND the gap it admits to is tracked.
219
+ if (profile === "corporate") {
220
+ const lines = text.split("\n");
221
+ const risksStart = lines.findIndex((l) =>
222
+ /^#{2,3}\s+\d+/.test(l) && /(Riskler|Risks)/.test(l),
223
+ );
224
+ let risksBlock = "";
225
+ if (risksStart !== -1) {
226
+ for (let i = risksStart + 1; i < lines.length; i++) {
227
+ if (/^#{2,3}\s+\d+/.test(lines[i])) break;
228
+ risksBlock += `${lines[i]}\n`;
229
+ }
230
+ }
231
+ let currentSection = null;
232
+ const unpaired = new Set();
233
+ for (let i = 0; i < lines.length; i++) {
234
+ const m = lines[i].match(/^#{2,4}\s+(\d+(?:\.\d+)*)\.?\s/);
235
+ if (m) currentSection = m[1];
236
+ if (!lines[i].includes("EKLENECEK")) continue;
237
+ if (risksStart !== -1 && i > risksStart) continue;
238
+ if (!currentSection) continue;
239
+ // The number has to stand alone: substring matching would let section "2"
240
+ // be satisfied by a "2.3" in some other row, and "9" by a "19". The
241
+ // trailing guard rejects a following digit and a following ".<digit>",
242
+ // but NOT a sentence-ending period - "etkilediği bölüm 9." is a real
243
+ // pairing, and rejecting it would block a correct document.
244
+ const token = new RegExp(
245
+ `(^|[^\\d.])${currentSection.replace(/\./g, "\\.")}(?!\\d|\\.\\d)`,
246
+ "m",
247
+ );
248
+ if (!token.test(risksBlock)) unpaired.add(currentSection);
249
+ }
250
+ for (const sec of unpaired) {
251
+ errors.push(
252
+ `section ${sec} carries EKLENECEK but no Risks and Open Questions row references it (Locked 33)`,
253
+ );
254
+ }
255
+
256
+ // The traceability matrix is the most expensive thing in the document to get
257
+ // wrong, because every downstream reader trusts it instead of re-deriving
258
+ // the IG -> UC -> FG chain themselves. A matrix that merely exists is not
259
+ // the claim worth making; a matrix that agrees with the sections is.
260
+ const MATRIX_HEADING = /(İzlenebilirlik Matrisi|Izlenebilirlik Matrisi|Traceability Matrix)/;
261
+ if (mode === "full") {
262
+ const matrixStart = lines.findIndex((l) => /^#{2,4}\s/.test(l) && MATRIX_HEADING.test(l));
263
+ if (matrixStart === -1) {
264
+ errors.push("corporate Full mode requires the requirement traceability matrix (Locked 33)");
265
+ } else {
266
+ let matrixBlock = "";
267
+ for (let i = matrixStart + 1; i < lines.length; i++) {
268
+ if (/^#{2,4}\s/.test(lines[i])) break;
269
+ matrixBlock += `${lines[i]}\n`;
270
+ }
271
+ // Everything outside the matrix is where ids are defined and used.
272
+ const outside = lines.slice(0, matrixStart).join("\n");
273
+
274
+ for (const prefix of ["IG", "UC", "FG"]) {
275
+ const idRe = new RegExp(`\\b${prefix}-\\d{2,3}\\b`, "g");
276
+ const defined = new Set(outside.match(idRe) || []);
277
+ const inMatrix = new Set(matrixBlock.match(idRe) || []);
278
+
279
+ for (const id of defined) {
280
+ if (!inMatrix.has(id)) {
281
+ errors.push(`${id} is defined in the document but missing from the traceability matrix`);
282
+ }
283
+ }
284
+ for (const id of inMatrix) {
285
+ if (!defined.has(id)) {
286
+ errors.push(`${id} appears in the traceability matrix but is defined nowhere else`);
287
+ }
288
+ }
289
+ }
290
+ }
179
291
  }
180
292
  }
181
293
 
@@ -206,9 +318,13 @@ function main() {
206
318
  }
207
319
  }
208
320
 
209
- // 2c. Section 15 is Phase 3's RED input (phase-3-dev.md step 5b): the tests
321
+ // 2c. The Test Plan is Phase 3's RED input (phase-3-dev.md step 5b): the tests
210
322
  // written first come from it. Full mode without it hands dev an empty matrix.
211
- if (mode === "full") {
323
+ // It is Section 15 in the global profile and Section 19 in the corporate one;
324
+ // the check matches on the title, so only the stack-optional case needs an
325
+ // exemption - there the whole development layer is legitimately absent
326
+ // (Locked 35) and there is no repo for a test to run against yet.
327
+ if (mode === "full" && !noPlatform) {
212
328
  const hasTestPlan = ["Test Planı", "Test Plani", "Test Plan"].some((kw) =>
213
329
  headingBlob.includes(kw),
214
330
  );
@@ -244,7 +360,9 @@ function main() {
244
360
  const hasMermaid = /^\s*```mermaid/m.test(text);
245
361
  if (hasFlowSection && !hasMermaid) {
246
362
  errors.push("Section 3 is rendered but carries no mermaid block");
247
- } else if (!hasFlowSection && mode === "full") {
363
+ } else if (!hasFlowSection && mode === "full" && profile === "global") {
364
+ // The corporate profile numbers Section 3 as Business Requirements and puts
365
+ // its diagrams under 5.(N+1), so "no Section 3 flow chart" is meaningless there.
248
366
  warns.push(
249
367
  "no Section 3 flow chart; legitimate only for a single screen with a single service and a simple rule",
250
368
  );
@@ -319,8 +437,11 @@ function main() {
319
437
  }
320
438
  }
321
439
 
322
- // 4. Placeholder whole-section bodies (Locked 2)
323
- if (/^\s*(TBD|Not applicable|N\/A)\s*$/im.test(text)) {
440
+ // 4. Placeholder whole-section bodies (Locked 2).
441
+ // Not a defect in the corporate profile: there N/A and EKLENECEK are how a
442
+ // backbone section reports "considered, nothing to state" (Locked 33), and the
443
+ // EKLENECEK-to-open-question pairing above is what keeps them honest.
444
+ if (profile === "global" && /^\s*(TBD|Not applicable|N\/A)\s*$/im.test(text)) {
324
445
  warns.push(
325
446
  'a line is a bare "TBD"/"Not applicable"/"N/A" body; omit the section instead (Locked 2)',
326
447
  );
@@ -335,7 +456,7 @@ function main() {
335
456
  const key = id.toUpperCase();
336
457
  counts.set(key, (counts.get(key) || 0) + 1);
337
458
  }
338
- if (counts.size === 0) {
459
+ if (counts.size === 0 && profile === "global") {
339
460
  warns.push("Full mode but no BR-<slug>-NN business-rule ids found (Section 4.4 / Locked 31)");
340
461
  }
341
462
  for (const [id, n] of counts) {