@tekyzinc/gsd-t 4.6.10 → 4.7.10

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.
@@ -13,6 +13,25 @@
13
13
  // context-brief, file-disjointness, verify-gate — all invoked from inside
14
14
  // stages via templates/workflows/_lib.js helpers.
15
15
  //
16
+ // M89: Stated-Claims→classify→research wiring (auto-research-contract v1.2.0 §6.5/§1/§2/§3/§4/§7).
17
+ // Domain workers embed the Stated-Claims snippet (§6.5) so agents tag load-bearing claims
18
+ // KNOWN|GUESSED. After domain workers, the Research phase iterates GUESSED entries through
19
+ // bin/gsd-t-research-gate.cjs: external → write §7 marker (status=uncited) → research agent
20
+ // (model:"fable") → cite → flip marker (status=cited). Internal → grep/Read; grep-empty →
21
+ // escalate to external research + cite + marker (§5.1 ambiguous escalation). Idempotent per §4.1
22
+ // (exact normalized claim-key match only). Wave is a pure composer and does NOT embed this wiring
23
+ // (M85 zero-model: invariant; research reaches wave via its execute sub-workflow).
24
+ //
25
+ // M90 §2 D4-T4: Architectural-trigger wiring (protocol-class path, R-ARCH-2 "everywhere" feed).
26
+ // After domain workers, compute the extend-existing-code signal from each domain's filesTouched:
27
+ // any file that already existed on disk before this execute run is extend-class (the task EXTENDED
28
+ // existing code rather than creating net-new). For each such domain, fire the arch trigger (R-ARCH-2)
29
+ // via the inline runCli helper. This is the NAMED PRODUCER: the signal is computed from real
30
+ // runtime inputs (filesTouched vs. on-disk existence), never from a seeded injection.
31
+ // Result is logged + stored in domainArchTriggerResults for verify gate (R-FAIL-2: if the trigger
32
+ // fires with provenByAdversaryOnly=true and the flag is unresolved, verify FAILS).
33
+ // Contract: unproven-assumption-doctrine-contract.md §2 (R-ARCH-2, protocol-class path).
34
+ //
16
35
  // Invocation contract:
17
36
  // The Workflow tool reads this file's `meta` block and runs the script
18
37
  // body. `args` is the JSON object passed in via Workflow({args}). Expected
@@ -26,11 +45,12 @@
26
45
  export const meta = {
27
46
  name: "gsd-t-execute",
28
47
  description:
29
- "Run a GSD-T execute phase: preflight → brief → parallel domain workers → integrate barrier → verify-gate",
48
+ "Run a GSD-T execute phase: preflight → brief → parallel domain workers → research (Stated-Claims) → integrate barrier → verify-gate",
30
49
  phases: [
31
50
  { title: "Preflight", detail: "gsd-t preflight + brief generation" },
32
51
  { title: "Disjointness", detail: "prove tasks are file-disjoint" },
33
52
  { title: "Domains", detail: "parallel domain workers" },
53
+ { title: "Research", detail: "classify GUESSED claims + cite external via web research (§6.5/§1/§2/§7)" },
34
54
  { title: "Integrate", detail: "cross-domain wire-up" },
35
55
  { title: "Verify-Gate", detail: "two-track verify-gate" },
36
56
  ],
@@ -49,9 +69,107 @@ const DOMAIN_RESULT_SCHEMA = {
49
69
  tasksDone: { type: "array", items: { type: "string" } },
50
70
  tasksBlocked: { type: "array", items: { type: "string" } },
51
71
  notes: { type: "string" },
72
+ // M89 §6.5 — Stated Claims: raw [GUESSED:*] lines emitted by the worker (§6.5 DETECT seam).
73
+ // Each entry is the full bullet text e.g. "[GUESSED:assumed] the create call returns a `url`".
74
+ // The Research phase iterates these through bin/gsd-t-research-gate.cjs.
75
+ statedClaims: { type: "array", items: { type: "string" } },
76
+ // Primary artifact path written by this worker (used by Research phase to write/flip §7 markers).
77
+ artifactPath: { type: "string" },
78
+ },
79
+ };
80
+
81
+ // M89 §7 — ENFORCE marker format helpers (normalize claim-key per §4.1)
82
+ // Normalize: lowercase, then collapse EVERY non-word run to a single space (cycle-2
83
+ // finding #1) so the key is marker-syntax-safe (no "="/"<"/">"/"-"); byte-identical
84
+ // across all 4 workflows (enforced by m89-normalize-claim-key-parity).
85
+ function normalizeClaimKey(claim) {
86
+ return claim.toLowerCase().replace(/[^\w]+/g, " ").trim();
87
+ }
88
+
89
+ // M89 §7 — marker templates
90
+ function uncitedMarker(key) {
91
+ return `<!-- auto-research-claim: class=external key=${key} status=uncited -->`;
92
+ }
93
+ function citedMarker(key) {
94
+ return `<!-- auto-research-claim: class=external key=${key} status=cited -->`;
95
+ }
96
+
97
+ // M89 §2 — research stage schema (StructuredOutput from research-subagent)
98
+ const RESEARCH_RESULT_SCHEMA = {
99
+ type: "object",
100
+ required: ["ok", "gapKey"],
101
+ additionalProperties: true,
102
+ properties: {
103
+ ok: { type: "boolean" },
104
+ gapKey: { type: "string" },
105
+ citedBlock: { type: "string" },
106
+ sourceUrls: { type: "array", items: { type: "string" } },
107
+ fetchDates: { type: "array", items: { type: "string" } },
108
+ reason: { type: "string" },
52
109
  },
53
110
  };
54
111
 
112
+ // M89 §1 — classify a guessed claim via bin/gsd-t-research-gate.cjs (D1 envelope)
113
+ async function classifyClaim(projectDir, claimText, phaseName) {
114
+ return runCli(projectDir, "research-gate", ["classify", claimText, "--json"], "gsd-t-research-gate.cjs", "classify-claim", true, phaseName);
115
+ }
116
+
117
+ // M89 §4.1 — check if a cited marker already exists in artifact text (exact key match)
118
+ function isAlreadyCited(artifactText, claimKey) {
119
+ const needle = `auto-research-claim: class=external key=${claimKey} status=cited`;
120
+ return artifactText.includes(needle);
121
+ }
122
+
123
+ // M89 §5.1 — grep the repo for an internal claim via an agent Bash call
124
+ async function grepForClaim(projectDir, claimText, phaseName) {
125
+ const escaped = claimText.replace(/'/g, "'\\''").slice(0, 200);
126
+ const prompt = [
127
+ `Decide whether THIS repo's own code/contracts/tests CONFIRM the SPECIFIC claim — not merely`,
128
+ `share vocabulary with it (Red Team MEDIUM). Project: \`${projectDir}\`. Claim: "${escaped}"`,
129
+ `Run: \`grep -r --include="*.js" --include="*.ts" --include="*.md" --include="*.cjs" -l "${escaped.slice(0, 60)}" "${projectDir}" 2>/dev/null | head -5\``,
130
+ `Also try: \`grep -r -l "${escaped.slice(0, 40)}" "${projectDir}" 2>/dev/null | head -5\``,
131
+ `Then Read the candidates and judge: set found=true ONLY IF the repo content actually CONFIRMS`,
132
+ `the value/shape/behavior the claim asserts. Coincidental keyword overlap = found=false.`,
133
+ `If the claim is about an EXTERNAL system's behavior (3rd-party API, library, browser, protocol),`,
134
+ `grep CANNOT confirm it → found=false so it escalates to web research.`,
135
+ `Return JSON: { "found": <boolean>, "matches": ["<file>", ...] }. Do ONLY this. No other work.`,
136
+ ].join("\n");
137
+ const schema = { type: "object", required: ["found"], additionalProperties: true, properties: { found: { type: "boolean" }, matches: { type: "array", items: { type: "string" } } } };
138
+ const r = await agent(prompt, { label: "grep-internal-claim", model: "haiku", schema, phase: phaseName })
139
+ .catch(() => ({ found: false, matches: [] }));
140
+ return r || { found: false, matches: [] };
141
+ }
142
+
143
+ // M89 §1.1 — the AMBIGUOUS → LLM JUDGE. When the mechanical classifier finds no string
144
+ // fact (class:ambiguous), the LLM decides internal/external/uncertain in natural language.
145
+ // Bare model:"fable" (the research tier — NOT the ?? override form; "judge" is not a
146
+ // designated stage). Returns the verdict string; on any error → "uncertain" (fail toward
147
+ // research — never guess-internal). The caller treats external/uncertain as research.
148
+ const CLASSIFY_JUDGE_SCHEMA = {
149
+ type: "object", required: ["verdict"], additionalProperties: true,
150
+ properties: { verdict: { type: "string", enum: ["internal", "external", "uncertain"] }, reason: { type: "string" } },
151
+ };
152
+ async function judgeAmbiguous(claimText, phaseName) {
153
+ const prompt = [
154
+ `You are the M89 ambiguous-claim JUDGE (auto-research-contract §1.1). The mechanical`,
155
+ `string-fact classifier could not decide this GUESSED claim, so decide it in natural language.`,
156
+ ``,
157
+ `Claim: "${claimText}"`,
158
+ ``,
159
+ `- "internal" = about THIS repo's OWN code / contracts / schema / tests — grep/Read can confirm.`,
160
+ `- "external" = asserts an OUTSIDE system's behavior/shape/limit (3rd-party API, library, browser,`,
161
+ ` protocol, spec) and is unverified — needs web research.`,
162
+ `- "uncertain" = you cannot CONFIDENTLY place it as internal. Use freely — per M89 doctrine an`,
163
+ ` unverified claim is RESEARCHED, never guessed.`,
164
+ ``,
165
+ `Return JSON: { "verdict": "internal"|"external"|"uncertain", "reason": "<one line>" }.`,
166
+ `Do NOT modify files. Do NOT run web searches in THIS step — only decide the verdict.`,
167
+ ].join("\n");
168
+ const r = await agent(prompt, { label: "classify-judge", model: "fable", schema: CLASSIFY_JUDGE_SCHEMA, phase: phaseName })
169
+ .catch((e) => ({ verdict: "uncertain", reason: `judge error: ${e && e.message}` }));
170
+ return (r && r.verdict) || "uncertain";
171
+ }
172
+
55
173
  const INTEGRATE_RESULT_SCHEMA = {
56
174
  type: "object",
57
175
  required: ["status", "crossDomainEdits"],
@@ -163,6 +281,14 @@ const domainResults = await parallel(
163
281
  `- Update affected docs (progress.md Decision Log, architecture.md, contracts/, requirements.md, README.md) per the Document Ripple Completion Gate in CLAUDE.md — in the SAME commits as the code changes.`,
164
282
  `- If a task is blocked (dependency not met), record it in tasksBlocked and continue with the next.`,
165
283
  `- Return a JSON object matching the StructuredOutput schema. The "status" field is the OVERALL domain status: "complete" if all tasks done, "partial" if some done and some blocked, "blocked" if you couldn't start, "failed" on error.`,
284
+ ``,
285
+ `**M89 STATED CLAIMS — REQUIRED (auto-research-contract §6.5):**`,
286
+ `Read \`${projectDir}/templates/prompts/stated-claims-snippet.md\` for the full DETECT protocol.`,
287
+ `Before returning your StructuredOutput, emit a \`## Stated Claims\` section listing every`,
288
+ `load-bearing claim you relied on, tagged [KNOWN] or [GUESSED:assumed|unknown|stale].`,
289
+ `In your StructuredOutput JSON, include a "statedClaims" array: one entry per [GUESSED:*] line`,
290
+ `(the full bullet text). Also include "artifactPath" if you wrote a primary output artifact`,
291
+ `(the markdown or file path the Research phase should write §7 markers into).`,
166
292
  ].join("\n");
167
293
 
168
294
  try {
@@ -189,6 +315,243 @@ if (blocking.length) {
189
315
  return { status: "failed", reason: "domain-failed", domainResults };
190
316
  }
191
317
 
318
+ // ── M89 Research Phase — Stated-Claims→classify→cite (§6.5/§1/§2/§3/§4/§7) ──
319
+ phase("Research");
320
+
321
+ // Collect all GUESSED claims from all domain workers (§6.5 DETECT seam)
322
+ const allGuessedClaims = [];
323
+ for (const dr of domainResults.filter(Boolean)) {
324
+ if (Array.isArray(dr.statedClaims) && dr.statedClaims.length > 0) {
325
+ for (const claimLine of dr.statedClaims) {
326
+ // Parse "[GUESSED:type] claim text" → extract the claim text
327
+ const m = claimLine.match(/^\[GUESSED:[^\]]+\]\s*(.+)$/);
328
+ if (m) {
329
+ allGuessedClaims.push({ claimText: m[1].trim(), domain: dr.domain, artifactPath: dr.artifactPath || null, rawLine: claimLine });
330
+ }
331
+ }
332
+ }
333
+ }
334
+
335
+ if (allGuessedClaims.length === 0) {
336
+ log("Research: no GUESSED claims found — skipping classify+research loop");
337
+ } else {
338
+ log(`Research: ${allGuessedClaims.length} GUESSED claim(s) to classify`);
339
+
340
+ // Read artifact text for idempotency check (§4.1 — exact key match)
341
+ // Artifacts are read once per unique path via an agent Bash call
342
+ const artifactCache = {};
343
+ async function readArtifact(artifactPath) {
344
+ if (!artifactPath) return "";
345
+ if (artifactCache[artifactPath] !== undefined) return artifactCache[artifactPath];
346
+ const r = await agent(
347
+ `Read the file at \`${artifactPath}\` and return its full text content in a JSON object: { "text": "<file content>" }. If the file does not exist, return { "text": "" }.`,
348
+ { label: "read-artifact", model: "haiku", schema: { type: "object", required: ["text"], properties: { text: { type: "string" } }, additionalProperties: false }, phase: "Research" }
349
+ ).catch(() => ({ text: "" }));
350
+ const t = (r && r.text) || "";
351
+ artifactCache[artifactPath] = t;
352
+ return t;
353
+ }
354
+
355
+ // Write §7 marker and cited block to artifact via agent Bash (§7 marker lifecycle)
356
+ async function writeMarkerAndCite(artifactPath, claimKey, citedBlock) {
357
+ if (!artifactPath) return;
358
+ const marker = uncitedMarker(claimKey);
359
+ const flipped = citedMarker(claimKey);
360
+ const prompt = [
361
+ `You are performing a precise file edit. Path: \`${artifactPath}\`.`,
362
+ `Do the following in order:`,
363
+ `0. Ensure the parent directory exists (Bash: \`mkdir -p "$(dirname '${artifactPath.replace(/'/g, "'\\''")}')"\`) — the path may be a deterministic fallback artifact that does not exist yet.`,
364
+ `1. Read the file (use the Read tool). If it does not exist, treat its content as empty.`,
365
+ `2. If the file does not contain the line \`${marker}\`, APPEND this block to the end of the file:`,
366
+ ` \`\`\``,
367
+ ` ${marker}`,
368
+ ` ${citedBlock}`,
369
+ ` \`\`\``,
370
+ `3. If the file already contains \`${marker}\`, replace it with \`${flipped}\` and also append the cited block if not already present.`,
371
+ `4. If the file already contains \`${flipped}\`, do nothing (already cited — idempotent per §4.1).`,
372
+ `Use the Edit or Write tool to apply the change. Return JSON: { "done": true, "action": "appended|replaced|noop" }.`,
373
+ ].join("\n");
374
+ await agent(prompt, { label: "write-marker-cite", model: "haiku", schema: { type: "object", required: ["done"], properties: { done: { type: "boolean" }, action: { type: "string" } }, additionalProperties: true }, phase: "Research" })
375
+ .catch((e) => log(`write-marker-cite error: ${e && e.message}`));
376
+ // Invalidate cache so subsequent reads see the updated file
377
+ delete artifactCache[artifactPath];
378
+ }
379
+
380
+ async function writeUncitedMarker(artifactPath, claimKey) {
381
+ if (!artifactPath) return;
382
+ const marker = uncitedMarker(claimKey);
383
+ const safePath = artifactPath.replace(/'/g, "'\\''");
384
+ const prompt = [
385
+ `Ensure the parent directory exists, then append this line to the file \`${artifactPath}\` (only if not already present — exact string match):`,
386
+ `\`${marker}\``,
387
+ `Use Bash: \`mkdir -p "$(dirname '${safePath}')" && { grep -qF '${marker.replace(/'/g, "'\\''")}' '${safePath}' 2>/dev/null || echo '${marker.replace(/'/g, "'\\''")}' >> '${safePath}'; }\``,
388
+ `Return JSON: { "done": true }.`,
389
+ ].join("\n");
390
+ await agent(prompt, { label: "write-uncited-marker", model: "haiku", schema: { type: "object", required: ["done"], properties: { done: { type: "boolean" } }, additionalProperties: true }, phase: "Research" })
391
+ .catch((e) => log(`write-uncited-marker error: ${e && e.message}`));
392
+ delete artifactCache[artifactPath];
393
+ }
394
+
395
+ // Process each GUESSED claim sequentially (classify → route)
396
+ for (const { claimText, domain, artifactPath, rawLine } of allGuessedClaims) {
397
+ const claimKey = normalizeClaimKey(claimText);
398
+ log(`Research: classifying claim "${claimText.slice(0, 60)}" [domain=${domain}]`);
399
+
400
+ // FAIL-CLOSED (Red Team HIGH): artifactPath is worker-self-reported + optional. An EXTERNAL
401
+ // (or ambiguous→escalated-external) guess MUST still get its §7 uncited marker written
402
+ // SOMEWHERE, or the §7 ENFORCE gate finds nothing and the guess ships uncited+unresearched.
403
+ // So the external/escalation path writes to a DETERMINISTIC FALLBACK ARTIFACT when the worker
404
+ // reported no path. Internal/grep can still no-op safely.
405
+ const claimSlug = claimKey.replace(/\s+/g, "-").slice(0, 80) || "claim";
406
+ const externalArtifact = artifactPath || `${projectDir}/.gsd-t/research/${(domain || "domain")}-${claimSlug}.md`;
407
+
408
+ // §4.1 idempotency — skip if already cited (exact key match). Read the path actually
409
+ // WRITTEN (externalArtifact = real path OR the deterministic fallback) so a re-run does
410
+ // NOT re-research a claim already cited in the fallback artifact (Red Team MEDIUM).
411
+ const artifactText = await readArtifact(externalArtifact);
412
+ if (isAlreadyCited(artifactText, claimKey)) {
413
+ log(`Research: skip "${claimKey.slice(0, 50)}" — already cited (§4.1 idempotent)`);
414
+ continue;
415
+ }
416
+
417
+ // Classify the claim (D1 envelope)
418
+ const cls = await classifyClaim(projectDir, claimText, "Research");
419
+ const envelope = cls.envelope;
420
+ if (!envelope || !envelope.ok) {
421
+ log(`Research: classify error for "${claimText.slice(0, 50)}" — ${cls.stderr || JSON.stringify(envelope)}`);
422
+ continue;
423
+ }
424
+
425
+ // External-claim handler (§7 marker → research(fable) → cite → flip). Closure so the
426
+ // ambiguous→judge path reuses it for an "external"/"uncertain" verdict.
427
+ const doExternal = async () => {
428
+ log(`Research: external claim → write §7 marker + fable research for "${claimKey.slice(0, 50)}"${artifactPath ? "" : " (FALLBACK artifact — worker reported no path)"}`);
429
+ // §7: write uncited marker at classify time (to the real OR fallback artifact — fail-CLOSED)
430
+ await writeUncitedMarker(externalArtifact, claimKey);
431
+
432
+ // §2: research agent — bare "fable" tier literal (NOT the ??-override form; contract §2)
433
+ const researchPrompt = [
434
+ `Read \`${projectDir}/templates/prompts/research-subagent.md\` for the full research protocol.`,
435
+ `Your task: verify this external guessed claim via live web sources.`,
436
+ `Claim: "${claimText}"`,
437
+ `Gap-key (normalized): "${claimKey}"`,
438
+ `Emit a ## Verified Facts (auto-research) block with source URL + fetch date per the protocol. Append the trailer \`key: ${claimKey}\` on every fact line so the §7 gate matches by claim-key (Red Team MEDIUM #2).`,
439
+ `Return StructuredOutput JSON per the schema.`,
440
+ ].join("\n");
441
+ const researchResult = await agent(researchPrompt, {
442
+ label: "research",
443
+ model: "fable",
444
+ schema: RESEARCH_RESULT_SCHEMA,
445
+ phase: "Research",
446
+ }).catch((e) => ({ ok: false, gapKey: claimKey, reason: `research agent error: ${e && e.message}` }));
447
+
448
+ if (researchResult && researchResult.ok && researchResult.citedBlock) {
449
+ // §7: flip marker to cited + write cited block (real OR fallback artifact)
450
+ await writeMarkerAndCite(externalArtifact, claimKey, researchResult.citedBlock);
451
+ log(`Research: cited "${claimKey.slice(0, 50)}" — marker flipped to status=cited`);
452
+ } else {
453
+ log(`Research: research FAILED for "${claimKey.slice(0, 50)}" — marker stays uncited. Reason: ${(researchResult && researchResult.reason) || "unknown"}`);
454
+ }
455
+ };
456
+
457
+ // Internal-claim handler — grep/Read (A3); grep-empty → escalate to external (§5.1).
458
+ const doInternal = async () => {
459
+ log(`Research: internal claim → grep/Read for "${claimText.slice(0, 50)}"`);
460
+ const grepResult = await grepForClaim(projectDir, claimText, "Research");
461
+ if (grepResult.found) {
462
+ log(`Research: internal claim resolved by grep (${(grepResult.matches || []).join(", ").slice(0, 80)}) — no research needed`);
463
+ // No marker, no research stage (A3 — internal class never enters research)
464
+ } else {
465
+ // §5.1 escalation: grep-empty → escalate to external. Reuse doExternal() (same
466
+ // marker→research→cite→flip path, incl. the key: trailer) instead of duplicating it.
467
+ log(`Research: grep returned nothing — escalating ambiguous claim to external (§5.1): "${claimText.slice(0, 50)}"`);
468
+ await doExternal();
469
+ }
470
+ };
471
+
472
+ // ── Dispatch by the 3-result classifier verdict (v1.3.0) ──────────────────
473
+ if (envelope.class === "external") {
474
+ await doExternal();
475
+ } else if (envelope.class === "internal") {
476
+ await doInternal();
477
+ } else {
478
+ // class:AMBIGUOUS — no string fact; the LLM judge decides (NOT regex). internal→grep;
479
+ // external→research; UNCERTAIN→research (uncertain = verify, NEVER guess-internal).
480
+ log(`Research: ambiguous claim → LLM judge for "${claimText.slice(0, 50)}" (no string fact; when unsure, research)`);
481
+ const verdict = await judgeAmbiguous(claimText, "Research");
482
+ log(`Research: ambiguous claim "${claimKey.slice(0, 50)}" → judge verdict: ${verdict}`);
483
+ if (verdict === "internal") {
484
+ await doInternal();
485
+ } else {
486
+ if (verdict === "uncertain") log(`Research: judge UNCERTAIN → treating "${claimKey.slice(0, 50)}" as external → research (no silent guess)`);
487
+ await doExternal();
488
+ }
489
+ }
490
+ }
491
+ }
492
+
493
+ // ── M90 §2 D4-T4 — Arch-Trigger (protocol-class, extend-existing-code signal) ──
494
+ // For each domain, compute the extend-existing-code signal: any file in filesTouched that
495
+ // already existed on disk before this execute run → extend-class → fire the arch trigger.
496
+ // PRODUCER: signal is computed from real runtime inputs (filesTouched vs. on-disk check).
497
+ // The trigger is best-effort (non-blocking): log results for verify gate; don't halt execute.
498
+ const domainArchTriggerResults = [];
499
+ for (const dr of domainResults.filter(Boolean)) {
500
+ if (!Array.isArray(dr.filesTouched) || dr.filesTouched.length === 0) continue;
501
+ // Check which files existed on disk before this run (extend-class signal).
502
+ // agent()-Bash: check existence via `test -f <file>` for each touched file.
503
+ const touchedFiles = dr.filesTouched.slice(0, 10); // cap at 10 per domain
504
+ const checkResult = await agent(
505
+ [
506
+ `For the project at \`${projectDir}\`, check which of these files existed on disk BEFORE the current execute run.`,
507
+ `Files to check: ${JSON.stringify(touchedFiles)}`,
508
+ `For each file, run: \`test -f '${projectDir}/<file>' && echo EXISTS || echo ABSENT\` (substitute <file> with the actual path).`,
509
+ `Return JSON: { "existingFiles": ["<file>", ...] } — list ONLY files that exist (exit 0 from test -f).`,
510
+ `Do NOT modify any files. Read-only existence check.`,
511
+ ].join("\n"),
512
+ {
513
+ label: `arch-trigger-exist-check:${dr.domain}`,
514
+ phase: "Research",
515
+ model: "haiku",
516
+ schema: {
517
+ type: "object", required: ["existingFiles"],
518
+ properties: { existingFiles: { type: "array", items: { type: "string" } } },
519
+ additionalProperties: true,
520
+ },
521
+ }
522
+ ).catch(() => ({ existingFiles: [] }));
523
+
524
+ const existingFiles = Array.isArray(checkResult && checkResult.existingFiles) ? checkResult.existingFiles : [];
525
+ if (existingFiles.length === 0) continue; // no extend-class signal for this domain
526
+
527
+ // Signal confirmed: domain extended existing code → fire arch trigger (R-ARCH-2)
528
+ const triggerInput = JSON.stringify({
529
+ type: "extend-existing-code",
530
+ context: `domain:${dr.domain} extended existing files: ${existingFiles.slice(0, 3).join(", ")}`,
531
+ basis: `Execute domain ${dr.domain} edited existing file(s): ${existingFiles.slice(0, 3).join(", ")}`,
532
+ });
533
+ const triggerResult = await runCli(
534
+ projectDir,
535
+ "architectural-trigger",
536
+ ["trigger", triggerInput],
537
+ "gsd-t-architectural-trigger.cjs",
538
+ `arch-trigger:${dr.domain}`,
539
+ true,
540
+ "Research"
541
+ );
542
+ const triggerEnv = triggerResult.envelope || {};
543
+ log(`M90 arch-trigger ${dr.domain}: fired=${triggerEnv.fired} reason=${triggerEnv.reason || "?"} provenByAdversaryOnly=${triggerEnv.provenByAdversaryOnly || false}`);
544
+ domainArchTriggerResults.push({
545
+ domain: dr.domain,
546
+ existingFiles,
547
+ fired: triggerEnv.fired || false,
548
+ reason: triggerEnv.reason || null,
549
+ provenByAdversaryOnly: triggerEnv.provenByAdversaryOnly || false,
550
+ stopDirective: triggerEnv.stopDirective || false,
551
+ });
552
+ }
553
+ log(`M90 arch-trigger: ${domainArchTriggerResults.length} domain(s) fired extend-existing-code signal`);
554
+
192
555
  phase("Integrate");
193
556
  const integratePrompt = [
194
557
  `You are the integration agent. ${domainResults.length} domain workers have completed.`,
@@ -228,4 +591,6 @@ return {
228
591
  domainResults,
229
592
  integrate,
230
593
  verifyGate: vg.envelope,
594
+ // M90 §2: arch-trigger results (surfaced for verify gate R-FAIL-2 check)
595
+ archTriggerResults: domainArchTriggerResults,
231
596
  };