@tekyzinc/gsd-t 4.6.11 → 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.
@@ -22,6 +22,15 @@
22
22
  //
23
23
  // M82 Competition Mode (generate-and-judge — the GENERATIVE dual of the
24
24
  // orthogonal validation triad). Contract: competition-mode-contract.md v1.0.0.
25
+ //
26
+ // M90 §2 D4-T4 (competition arm): Divergence-path (R-ARCH-1) wired into the competition arm.
27
+ // After Judge selects the winner, the N producers' proposals are fed to the divergence-sampling
28
+ // path (R-ARCH-1) via the inline runCli helper. HONESTY CLAUSE: Self-MoA samples of ONE model
29
+ // (temperature/seed-varied) may NOT diverge like fresh-context saga cases the threshold was
30
+ // tuned on. That mismatch is RECORDED by the instrumentation sink; the result is logged as
31
+ // EXPERIMENTAL+MEASURED (never claimed as proof). If the real competition feed cannot
32
+ // meaningfully exercise the divergence formula, the result documents that fact.
33
+ // Contract: unproven-assumption-doctrine-contract.md §2 (R-ARCH-1, divergence path, competition-arm-only).
25
34
  // - Eligible phases: partition, milestone, discuss, design-decompose (pre-contract,
26
35
  // wide-solution-space). INELIGIBLE: plan/impact/prd/doc-ripple (narrow / one
27
36
  // right answer) — competition there is wasted, so a competition arg is ignored.
@@ -32,6 +41,24 @@
32
41
  // immune to LLM-judge bias). Other phases use a blind+shuffled+rubric judge whose
33
42
  // numeric selection is finalized deterministically by competition-judge --kind
34
43
  // generic.
44
+ //
45
+ // M89 Stated-Claims → classify (3-result) → judge/research + §7 ENFORCE marker:
46
+ // Every eligible upper phase (plan, pre-mortem, partition, discuss, milestone, impact)
47
+ // embeds the Stated-Claims snippet so the agent emits a ## Stated Claims list tagging
48
+ // load-bearing claims KNOWN | GUESSED(type). The wiring (runStatedClaimsPipeline)
49
+ // iterates each [GUESSED:*] entry through the D1 classifier, which is a MECHANICAL
50
+ // STRING-FACT FILTER returning internal | external | AMBIGUOUS (v1.3.0). On
51
+ // class:external write a §7 status=uncited marker, run the research agent (bare
52
+ // model:"fable"), write a ## Verified Facts (auto-research) block, flip the marker to
53
+ // status=cited. On class:internal: grep first; if grep empty, escalate to external
54
+ // (§5.1). On class:AMBIGUOUS (the regex has no string fact — semantic placement is the
55
+ // LLM's call, NOT regex's): run a small LLM JUDGE (model:"fable") that decides
56
+ // internal/external/uncertain in natural language. internal→grep; external→research;
57
+ // UNCERTAIN→treat as external→research (uncertain = verify, NEVER guess-internal — a
58
+ // silent miss is the one unacceptable outcome). Idempotent: an already-cited marker
59
+ // (same claim-key) skips re-research. The classifier never guesses a default — when it
60
+ // isn't a string fact, the LLM decides, and when the LLM isn't sure, we research.
61
+ // Contract: auto-research-contract.md v1.2.0 §1/§2/§3/§4/§6.5/§7.
35
62
 
36
63
  export const meta = {
37
64
  name: "gsd-t-phase",
@@ -139,6 +166,368 @@ async function runCompetitionJudge(projectDir, spec, label = "judge", phaseNameO
139
166
  return { ok: !!env.ok, winner: env.winner != null ? env.winner : null, ranked: env.ranked || [] };
140
167
  }
141
168
 
169
+ // ── M89 Stated-Claims pipeline ──────────────────────────────────────────────
170
+ // Research-eligible upper stages: these are the phases whose prompt embeds the
171
+ // Stated-Claims snippet (§6.5) and whose artifacts are scanned post-agent.
172
+ // prd/design-decompose/doc-ripple are excluded (no load-bearing external claims).
173
+ const RESEARCH_ELIGIBLE_PHASES = new Set(["plan", "partition", "discuss", "milestone", "impact"]);
174
+
175
+ // §7 ENFORCE marker format — machine-readable HTML comment written at classify time.
176
+ // claim-key = deterministic normalization: lowercase, then COLLAPSE EVERY non-word run
177
+ // to a single space (cycle-2 finding #1 — CRITICAL). This makes the key marker-syntax
178
+ // SAFE: it can never contain "=", "<", ">", "-", so a claim that literally embeds
179
+ // "status=cited"/"class="/"key="/"<!--"/"-->" cannot poison the marker grammar or
180
+ // collide with a distinct claim's key. (Subsumes the old whitespace-collapse + edge-strip.)
181
+ function normalizeClaimKey(claim) {
182
+ return claim.toLowerCase().replace(/[^\w]+/g, " ").trim();
183
+ }
184
+
185
+ // The Stated-Claims snippet Read instruction (D2 deliverable, §6.5 DETECT seam).
186
+ // Each eligible stage prompt embeds this instruction so the agent emits ## Stated Claims.
187
+ const STATED_CLAIMS_INSTRUCTION = `MANDATORY (auto-research-contract §6.5): After your main work, emit a \`## Stated Claims\` section listing EVERY load-bearing claim you relied on, tagged as:
188
+ - [KNOWN] <claim you have verified or is repo-internal-evident>
189
+ - [GUESSED:unknown] <claim you lack the fact for>
190
+ - [GUESSED:assumed] <claim asserting an unverified external shape/value/behavior>
191
+ - [GUESSED:stale] <external/time-varying fact that may have aged>
192
+ This list is machine-parsed by the wiring (D3). Tags are case-sensitive. An untagged claim is an acknowledged miss — the more you tag, the more the system verifies. Also FIRST read your Stated-Claims protocol: templates/prompts/stated-claims-snippet.md`;
193
+
194
+ // Structured output schema for the Stated-Claims processor agent
195
+ const STATED_CLAIMS_EXTRACT_SCHEMA = {
196
+ type: "object",
197
+ required: ["guessedClaims"],
198
+ additionalProperties: true,
199
+ properties: {
200
+ guessedClaims: {
201
+ type: "array",
202
+ items: { type: "string" },
203
+ description: "List of raw claim texts from [GUESSED:*] lines in the ## Stated Claims section",
204
+ },
205
+ knownClaims: { type: "array", items: { type: "string" } },
206
+ artifactPaths: { type: "array", items: { type: "string" } },
207
+ statedClaimsSectionFound: { type: "boolean" },
208
+ },
209
+ };
210
+
211
+ const GREP_RESULT_SCHEMA = {
212
+ type: "object",
213
+ required: ["found"],
214
+ additionalProperties: true,
215
+ properties: {
216
+ found: { type: "boolean" },
217
+ excerpt: { type: "string" },
218
+ },
219
+ };
220
+
221
+ const RESEARCH_RESULT_SCHEMA = {
222
+ type: "object",
223
+ required: ["ok"],
224
+ additionalProperties: true,
225
+ properties: {
226
+ ok: { type: "boolean" },
227
+ gapKey: { type: "string" },
228
+ citedBlock: { type: "string" },
229
+ sourceUrls: { type: "array", items: { type: "string" } },
230
+ fetchDates: { type: "array", items: { type: "string" } },
231
+ reason: { type: "string" },
232
+ },
233
+ };
234
+
235
+ const MARKER_WRITE_SCHEMA = {
236
+ type: "object",
237
+ required: ["ok"],
238
+ additionalProperties: true,
239
+ properties: {
240
+ ok: { type: "boolean" },
241
+ artifactPath: { type: "string" },
242
+ marker: { type: "string" },
243
+ },
244
+ };
245
+
246
+ // M89 §1.1 — the AMBIGUOUS → LLM JUDGE schema. When the mechanical classifier finds no
247
+ // decisive STRING FACT (class:ambiguous), this judge applies the known/internal/external
248
+ // test in natural language and returns one of three verdicts. CRITICAL: "uncertain" is a
249
+ // first-class verdict — the wiring treats it as EXTERNAL (research), never guess-internal.
250
+ const CLASSIFY_JUDGE_SCHEMA = {
251
+ type: "object",
252
+ required: ["verdict"],
253
+ additionalProperties: true,
254
+ properties: {
255
+ verdict: { type: "string", enum: ["internal", "external", "uncertain"] },
256
+ reason: { type: "string" },
257
+ },
258
+ };
259
+
260
+ /**
261
+ * M89 §7 Stated-Claims pipeline: classify → marker-write → research/grep → cite → flip.
262
+ * Called after the phase agent runs on any research-eligible phase.
263
+ *
264
+ * @param {string} projectDir
265
+ * @param {string} phaseName - the current phase (e.g. "plan", "partition")
266
+ * @param {object} phaseResult - the agent's result (contains artifacts[] + summary)
267
+ * @param {string} statedClaimsContext - the agent's stdout/return text (may contain ## Stated Claims)
268
+ * @returns {object} - { ok, processed, errors }
269
+ */
270
+ async function runStatedClaimsPipeline(projectDir, phaseName, phaseResult, statedClaimsContext) {
271
+ const processed = [];
272
+ const errors = [];
273
+
274
+ // Step 1: Extract [GUESSED:*] claims from the phase artifact / stated-claims section.
275
+ // The phase agent is instructed to emit ## Stated Claims in its output; we extract it.
276
+ const extractResult = await agent(
277
+ [
278
+ `You are a Stated-Claims extractor for the "${phaseName}" phase. Your ONLY job is to parse the [GUESSED:*] entries from a ## Stated Claims section and return them as a JSON list.`,
279
+ ``,
280
+ `Context (the phase agent's output / summary):`,
281
+ "~~~",
282
+ statedClaimsContext || "(phase agent returned no text — check phaseResult.summary)",
283
+ "~~~",
284
+ ``,
285
+ `Phase artifacts written (read these if needed to find ## Stated Claims):`,
286
+ (phaseResult && phaseResult.artifacts || []).map((a) => `- ${a}`).join("\n") || "(none reported)",
287
+ ``,
288
+ `Task:`,
289
+ `1. Look for a section starting with \`## Stated Claims\` in the context above OR in any artifact file.`,
290
+ `2. Extract EVERY line starting with \`- [GUESSED:\` and return its claim text (the text AFTER the tag, stripped of the tag prefix).`,
291
+ `3. Also note \`statedClaimsSectionFound\` (true if you found the heading).`,
292
+ `4. Return JSON per the schema. "guessedClaims" is the list of claim texts (NOT the tags themselves).`,
293
+ `If no ## Stated Claims section was found, return { "guessedClaims": [], "statedClaimsSectionFound": false }.`,
294
+ ].join("\n"),
295
+ { label: "stated-claims-extract", phase: "Phase", schema: STATED_CLAIMS_EXTRACT_SCHEMA, model: "haiku" }
296
+ ).catch(() => ({ guessedClaims: [], statedClaimsSectionFound: false }));
297
+
298
+ const guessedClaims = Array.isArray(extractResult && extractResult.guessedClaims)
299
+ ? extractResult.guessedClaims.filter((c) => typeof c === "string" && c.trim().length > 0)
300
+ : [];
301
+
302
+ if (!extractResult || !extractResult.statedClaimsSectionFound) {
303
+ log(`m89: ${phaseName}: no ## Stated Claims section found — acknowledged miss (§6.5 best-effort DETECT)`);
304
+ } else {
305
+ log(`m89: ${phaseName}: ${guessedClaims.length} [GUESSED:*] claim(s) found in ## Stated Claims`);
306
+ }
307
+
308
+ if (guessedClaims.length === 0) {
309
+ return { ok: true, processed, errors };
310
+ }
311
+
312
+ // Determine the primary artifact path to write markers/facts into.
313
+ // We use the first artifact reported (or a temp path if none).
314
+ const artifactPaths = Array.isArray(extractResult.artifactPaths) && extractResult.artifactPaths.length > 0
315
+ ? extractResult.artifactPaths
316
+ : (phaseResult && phaseResult.artifacts ? phaseResult.artifacts : []);
317
+ const primaryArtifact = artifactPaths[0] || null;
318
+
319
+ // Step 2: For each GUESSED claim, classify → route → process.
320
+ // Sequential (not parallel) to avoid concurrent artifact writes racing each other.
321
+ for (const claimText of guessedClaims) {
322
+ const claimKey = normalizeClaimKey(claimText);
323
+ log(`m89: classifying claim: "${claimText.slice(0, 80)}"`);
324
+
325
+ // FAIL-CLOSED (Red Team HIGH): the §7 marker for an EXTERNAL guess MUST always be written
326
+ // SOMEWHERE so the §7 ENFORCE gate has something to fail on. artifactPath is agent-self-
327
+ // reported + optional; a worker that reports none must NOT silently skip the marker (that
328
+ // would ship an external guess uncited+unresearched — the exact invariant M89 enforces).
329
+ // So the external/escalation path writes to a DETERMINISTIC FALLBACK ARTIFACT when no
330
+ // artifact was reported. Internal/grep can still no-op safely (no marker on the internal path).
331
+ const claimSlug = claimKey.replace(/\s+/g, "-").slice(0, 80) || "claim";
332
+ const externalArtifact = primaryArtifact || `${projectDir}/.gsd-t/research/phase-${phaseName}-${claimSlug}.md`;
333
+
334
+ // § 4.1 Idempotency check: if the artifact already has a status=cited marker for this exact
335
+ // claim-key, skip (no re-research). Check the path actually WRITTEN (externalArtifact = real
336
+ // primaryArtifact OR the deterministic fallback) so a re-run does not re-research a claim
337
+ // already cited in the fallback artifact (Red Team MEDIUM).
338
+ {
339
+ const idempotencyCheck = await agent(
340
+ [
341
+ `Check if the file at "${externalArtifact}" already contains an auto-research-claim marker with status=cited for the claim-key "${claimKey}".`,
342
+ ``,
343
+ `Search for an HTML comment matching:`,
344
+ ` <!-- auto-research-claim: class=external key=${claimKey} status=cited -->`,
345
+ ``,
346
+ `Return JSON: { "alreadyCited": true } if found, { "alreadyCited": false } if not found or file does not exist.`,
347
+ `Do NOT modify any files. Read-only check.`,
348
+ ].join("\n"),
349
+ { label: "idempotency-check", phase: "Phase", schema: { type: "object", required: ["alreadyCited"], properties: { alreadyCited: { type: "boolean" } } }, model: "haiku" }
350
+ ).catch(() => ({ alreadyCited: false }));
351
+
352
+ if (idempotencyCheck && idempotencyCheck.alreadyCited) {
353
+ log(`m89: claim "${claimKey}" already cited (§4.1 idempotency skip — exact claim-key match)`);
354
+ processed.push({ claimKey, action: "skipped-already-cited" });
355
+ continue;
356
+ }
357
+ }
358
+
359
+ // Classify the claim via the D1 classifier (--json for parity with the worker workflows).
360
+ const classifyResult = await runCli(
361
+ projectDir, "research-gate", ["classify", claimText, "--json"], "gsd-t-research-gate.cjs",
362
+ "classify-claim", true, "Phase"
363
+ );
364
+ const envelope = classifyResult.envelope || {};
365
+ const claimClass = envelope.class;
366
+ const claimRoute = envelope.route;
367
+
368
+ if (!classifyResult.ok || !claimClass) {
369
+ log(`m89: classify failed for claim "${claimKey}": ${envelope.error || classifyResult.stderr || "no envelope"}`);
370
+ errors.push({ claimKey, error: `classify failed: ${envelope.error || "no envelope"}` });
371
+ continue;
372
+ }
373
+
374
+ log(`m89: claim "${claimKey}" → class:${claimClass} route:${claimRoute} — ${envelope.reason || ""}`);
375
+
376
+ // External-claim handler (§7 marker → research(fable) → cite → flip). Closure so the
377
+ // ambiguous→judge path can reuse it for an "external"/"uncertain" verdict.
378
+ const doExternal = async () => {
379
+ // §7: Write status=uncited marker into the (real OR fallback) artifact — ALWAYS written
380
+ // so the §7 gate can fail on it (fail-CLOSED, Red Team HIGH).
381
+ const marker = `<!-- auto-research-claim: class=external key=${claimKey} status=uncited -->`;
382
+ await agent(
383
+ [
384
+ `Create the parent directory if needed, then append the following HTML comment marker on a NEW LINE at the END of the file at "${externalArtifact}" (create the file if it does not exist):`,
385
+ ``,
386
+ marker,
387
+ ``,
388
+ `Do NOT modify any other content. Return JSON: { "ok": true, "artifactPath": "${externalArtifact}", "marker": "<the marker you appended>" }`,
389
+ ].join("\n"),
390
+ { label: "marker-write-uncited", phase: "Phase", schema: MARKER_WRITE_SCHEMA, model: "haiku" }
391
+ ).catch((e) => ({ ok: false, error: String(e && e.message) }));
392
+ log(`m89: wrote status=uncited marker for claim "${claimKey}" into ${externalArtifact}${primaryArtifact ? "" : " (FALLBACK artifact — worker reported no path)"}`);
393
+
394
+ // §2: Run the research agent (bare model: "fable" — not the ?? override form).
395
+ // The research agent reads its own protocol from research-subagent.md.
396
+ log(`m89: running research agent for external claim "${claimKey}"`);
397
+ const researchResult = await agent(
398
+ [
399
+ `You are the auto-research agent (auto-research-contract §2). Your SOLE job is to verify ONE external guessed claim via live web sources.`,
400
+ ``,
401
+ `FIRST read your protocol via the Read tool: templates/prompts/research-subagent.md (in the installed @tekyzinc/gsd-t package, or this project's copy). Follow it exactly.`,
402
+ ``,
403
+ `Claim to verify: "${claimText}"`,
404
+ `Normalized claim-key: "${claimKey}"`,
405
+ ``,
406
+ `Use WebSearch + WebFetch to find authoritative sources. Emit a ## Verified Facts (auto-research) block per §3 format. On every fact line append the trailer \`key: ${claimKey}\` so the §7 gate matches by claim-key (Red Team MEDIUM #2).`,
407
+ `Return JSON per the schema with ok:true and citedBlock (the full markdown block) on success, or ok:false and reason on STAGE-FAILURE.`,
408
+ ].join("\n"),
409
+ { label: "research-stage", phase: "Phase", schema: RESEARCH_RESULT_SCHEMA, model: "fable" }
410
+ ).catch((e) => ({ ok: false, gapKey: claimKey, reason: `research agent error: ${e && e.message}` }));
411
+
412
+ if (researchResult && researchResult.ok && researchResult.citedBlock) {
413
+ // §3: Write the cited Verified-Facts block into the (real OR fallback) artifact before the gate re-runs.
414
+ await agent(
415
+ [
416
+ `Append the following cited Verified-Facts block on a NEW LINE at the END of the file at "${externalArtifact}":`,
417
+ ``,
418
+ researchResult.citedBlock,
419
+ ``,
420
+ `Then FIND and REPLACE the marker:`,
421
+ ` <!-- auto-research-claim: class=external key=${claimKey} status=uncited -->`,
422
+ `with:`,
423
+ ` <!-- auto-research-claim: class=external key=${claimKey} status=cited -->`,
424
+ ``,
425
+ `(This flips the §7 marker from uncited → cited — same claim-key, exact string replace.)`,
426
+ `Return JSON: { "ok": true }`,
427
+ ].join("\n"),
428
+ { label: "cite-write-and-flip", phase: "Phase", schema: { type: "object", required: ["ok"], properties: { ok: { type: "boolean" } } }, model: "haiku" }
429
+ ).catch(() => null);
430
+ log(`m89: cited block written + marker flipped to status=cited for claim "${claimKey}"`);
431
+ processed.push({ claimKey, action: "cited", class: "external" });
432
+ } else {
433
+ const reason = (researchResult && researchResult.reason) || "research stage returned no cited block";
434
+ log(`m89: research STAGE-FAILURE for claim "${claimKey}": ${reason} — marker stays status=uncited`);
435
+ errors.push({ claimKey, error: `research stage failure: ${reason}`, action: "research-failed" });
436
+ processed.push({ claimKey, action: "research-failed", class: "external", reason });
437
+ }
438
+ };
439
+
440
+ // Internal-claim handler — grep first; escalate to external if grep empty (§5.1).
441
+ // Closure so the ambiguous→judge path can reuse it for an "internal" verdict.
442
+ const doInternal = async () => {
443
+ log(`m89: internal claim "${claimKey}" — running grep/Read`);
444
+ const grepResult = await agent(
445
+ [
446
+ `You are an internal-claim resolver for the project at "${projectDir}".`,
447
+ ``,
448
+ `Internal claim: "${claimText}"`,
449
+ ``,
450
+ `Task: Use grep and/or Read to decide whether this repo's OWN code / contracts / tests`,
451
+ `CONFIRM THE SPECIFIC CLAIM — not merely share vocabulary with it (Red Team MEDIUM).`,
452
+ `1. grep/Read the project for content relevant to the claim.`,
453
+ `2. Set found=true ONLY IF the repo content you read actually CONFIRMS the specific claim`,
454
+ ` (the value/shape/behavior the claim asserts is borne out by this repo's code/contract/test).`,
455
+ ` Coincidental keyword overlap — a file that merely MENTIONS the same words but does not`,
456
+ ` establish the claim — is found=false.`,
457
+ `3. If the claim is about an EXTERNAL system's behavior/limit/return-shape (a third-party API,`,
458
+ ` library, browser, or protocol), grep CANNOT confirm it — set found=false so it escalates`,
459
+ ` to web research. A repo file that calls that external system is NOT confirmation of the`,
460
+ ` external system's behavior.`,
461
+ `4. Otherwise set found=false.`,
462
+ ``,
463
+ `Return JSON: { "found": true|false, "excerpt": "<up to 200 chars of the CONFIRMING repo content, or empty>" }`,
464
+ `Do NOT run any web searches. Do NOT write any files. Grep and Read only.`,
465
+ ].join("\n"),
466
+ { label: "internal-grep", phase: "Phase", schema: GREP_RESULT_SCHEMA, model: "haiku" }
467
+ ).catch(() => ({ found: false, excerpt: "" }));
468
+
469
+ if (grepResult && grepResult.found) {
470
+ log(`m89: internal claim "${claimKey}" resolved via grep (no web, no marker)`);
471
+ processed.push({ claimKey, action: "resolved-grep", class: "internal", excerpt: grepResult.excerpt });
472
+ } else {
473
+ // §5.1 Escalate to external: grep returned nothing. Reuse doExternal() (same
474
+ // marker→research→cite→flip path, incl. the key: trailer + fallback artifact)
475
+ // instead of duplicating it (matches execute/quick/debug).
476
+ log(`m89: internal claim "${claimKey}" grep EMPTY → escalating to external (§5.1)`);
477
+ await doExternal();
478
+ }
479
+ };
480
+
481
+ // ── Dispatch by the 3-result classifier verdict ──────────────────────────
482
+ if (claimClass === "external") {
483
+ await doExternal();
484
+ } else if (claimClass === "internal") {
485
+ await doInternal();
486
+ } else {
487
+ // class:AMBIGUOUS — the mechanical filter found NO string fact. Semantic placement
488
+ // is the LLM's call, NOT regex's. Run the LLM JUDGE (fable). internal→grep;
489
+ // external→research; UNCERTAIN→research (uncertain = verify, NEVER guess-internal —
490
+ // a silent miss is the one unacceptable outcome). The classifier never guessed a
491
+ // default — it deferred, and now the LLM decides (and on doubt we research).
492
+ log(`m89: ambiguous claim "${claimKey}" — routing to LLM judge (no string fact; doctrine: when unsure, research)`);
493
+ const judge = await agent(
494
+ [
495
+ `You are the M89 ambiguous-claim JUDGE (auto-research-contract §1.1). The mechanical`,
496
+ `string-fact classifier could not decide this GUESSED claim, so you decide it in natural`,
497
+ `language. Apply the known/internal/external test:`,
498
+ ``,
499
+ `Claim: "${claimText}"`,
500
+ ``,
501
+ `- "internal" = the claim is about THIS repo's OWN code / contracts / schema / file`,
502
+ ` ownership / tests — something grep/Read of this repo can confirm.`,
503
+ `- "external" = the claim asserts the behavior / shape / limit / value of a system`,
504
+ ` OUTSIDE this repo (a third-party API, library, browser, protocol, spec)`,
505
+ ` and is unverified — it needs web research to confirm.`,
506
+ `- "uncertain" = you cannot CONFIDENTLY place it as internal. Use this freely — it is NOT`,
507
+ ` a failure. Per the milestone's own doctrine, an unverified claim must be`,
508
+ ` RESEARCHED, never guessed.`,
509
+ ``,
510
+ `Return JSON: { "verdict": "internal" | "external" | "uncertain", "reason": "<one line>" }.`,
511
+ `Do NOT modify files. Do NOT run web searches in THIS step — only decide the verdict.`,
512
+ ].join("\n"),
513
+ { label: "classify-judge", phase: "Phase", schema: CLASSIFY_JUDGE_SCHEMA, model: "fable" }
514
+ ).catch((e) => ({ verdict: "uncertain", reason: `judge error: ${e && e.message} — failing toward research` }));
515
+
516
+ const verdict = (judge && judge.verdict) || "uncertain";
517
+ log(`m89: ambiguous claim "${claimKey}" → judge verdict: ${verdict} — ${(judge && judge.reason) || ""}`);
518
+ if (verdict === "internal") {
519
+ await doInternal();
520
+ } else {
521
+ // external OR uncertain → research (uncertain = verify, never guess-internal)
522
+ if (verdict === "uncertain") log(`m89: judge UNCERTAIN for "${claimKey}" → treating as external → research (no silent guess)`);
523
+ await doExternal();
524
+ }
525
+ }
526
+ }
527
+
528
+ return { ok: errors.length === 0, processed, errors };
529
+ }
530
+
142
531
  // Phases where competition pays off (wide solution space, pre-contract, high blast
143
532
  // radius). Competition is AUTOMATIC on these (M84) — the workflow probes the
144
533
  // solution space and self-decides; on any other phase it never runs.
@@ -312,14 +701,26 @@ if (_competitionEligible) {
312
701
 
313
702
  // M84 Red Team LOW: announce "Phase" only on the single-draft path (the
314
703
  // competition path announces Compete/Judge/Finalize instead) so no empty stage shows.
704
+ // M89: Stated-Claims instruction appended to each research-eligible phase's prompt.
705
+ // prd/design-decompose/doc-ripple are excluded (no load-bearing external claims expected).
315
706
  const promptByPhase = {
316
- partition: `Decompose the milestone into 2-5 independent domains. Write .gsd-t/domains/{domain}/{scope,constraints,tasks}.md. Cross-domain contracts in .gsd-t/contracts/.`,
707
+ partition: `Decompose the milestone into 2-5 independent domains. Write .gsd-t/domains/{domain}/{scope,constraints,tasks}.md. Cross-domain contracts in .gsd-t/contracts/.
708
+
709
+ ${STATED_CLAIMS_INSTRUCTION}`,
317
710
  plan: `For each domain, write atomic tasks.md entries with files, contract refs, dependencies, acceptance criteria. Update .gsd-t/contracts/integration-points.md with wave groupings.
318
711
 
319
- M83 PLAN HARDENING (mandatory — the plan is BLOCKED from execute otherwise): every task that declares acceptance criteria MUST also declare (1) **Files** = the concrete code path that implements it, and (2) a TEST that fails if that path is dead — name it in a **Test** field, a test-file path (\`*.test.*\` / \`*.spec.*\` / \`e2e/\`), or a runner (vitest/cargo test/playwright). The ONE task that delivers the milestone's HEADLINE capability MUST be tagged **Headline:** true and carry BOTH a real implementation path AND a test that exercises that capability end-to-end (e.g. for a "100MB+ file" milestone, a test that actually opens a >100MB fixture). NEVER defer a milestone's own headline capability or a core AC to a later milestone. This exists because NiceNote M5 shipped its headline (100MB+ chunked read) as DEAD CODE with no test and burned 4 verify cycles.`,
320
- discuss: `Multi-perspective exploration of design questions. Settle locked decisions into .gsd-t/CONTEXT.md. Do NOT implement.`,
321
- impact: `Analyze downstream effects of proposed changes. Identify breaking changes, affected consumers, migration paths.`,
322
- milestone: `Define a new milestone origin, goal, success criteria, falsifiable acceptance. Append to .gsd-t/progress.md. Defer partition/plan.`,
712
+ M83 PLAN HARDENING (mandatory — the plan is BLOCKED from execute otherwise): every task that declares acceptance criteria MUST also declare (1) **Files** = the concrete code path that implements it, and (2) a TEST that fails if that path is dead — name it in a **Test** field, a test-file path (\`*.test.*\` / \`*.spec.*\` / \`e2e/\`), or a runner (vitest/cargo test/playwright). The ONE task that delivers the milestone's HEADLINE capability MUST be tagged **Headline:** true and carry BOTH a real implementation path AND a test that exercises that capability end-to-end (e.g. for a "100MB+ file" milestone, a test that actually opens a >100MB fixture). NEVER defer a milestone's own headline capability or a core AC to a later milestone. This exists because NiceNote M5 shipped its headline (100MB+ chunked read) as DEAD CODE with no test and burned 4 verify cycles.
713
+
714
+ ${STATED_CLAIMS_INSTRUCTION}`,
715
+ discuss: `Multi-perspective exploration of design questions. Settle locked decisions into .gsd-t/CONTEXT.md. Do NOT implement.
716
+
717
+ ${STATED_CLAIMS_INSTRUCTION}`,
718
+ impact: `Analyze downstream effects of proposed changes. Identify breaking changes, affected consumers, migration paths.
719
+
720
+ ${STATED_CLAIMS_INSTRUCTION}`,
721
+ milestone: `Define a new milestone — origin, goal, success criteria, falsifiable acceptance. Append to .gsd-t/progress.md. Defer partition/plan.
722
+
723
+ ${STATED_CLAIMS_INSTRUCTION}`,
323
724
  prd: `Generate a product requirements doc at docs/prd.md. Functional + non-functional requirements traceable to acceptance criteria.`,
324
725
  "design-decompose": `Decompose a design reference (Figma URL / images) into hierarchical contracts: elements -> widgets -> pages, each at .gsd-t/contracts/design/.`,
325
726
  "doc-ripple": `Identify and update all docs affected by recent code changes per the Document Ripple Completion Gate. No code edits.`,
@@ -329,6 +730,9 @@ const baseObjective = promptByPhase[phaseName];
329
730
  const briefLine = `**Brief (REQUIRED):** ${brief.briefPath || "(no brief — re-walk repo)"}`;
330
731
 
331
732
  let result;
733
+ // M90 §2 D4-T4: holds the divergence-path result from the competition arm (R-ARCH-1).
734
+ // Set inside the competition arm (before Finalize), attached to result after.
735
+ let _pendingArchTriggerDivergence = null;
332
736
  if (!competitionOn) {
333
737
  // ── Single-producer path (default, unchanged behavior) ──
334
738
  phase("Phase");
@@ -518,6 +922,57 @@ if (!competitionOn) {
518
922
  }
519
923
  log(`competition: winner = ${winner.id} (of ${candidates.map((c) => c.id).join(", ")})`);
520
924
 
925
+ // M90 §2 D4-T4 — R-ARCH-1 divergence-sampling (competition arm only, EXPERIMENTAL+MEASURED).
926
+ // Feed the N producers' proposals to the divergence-sampling path. HONESTY CLAUSE (the doctrine
927
+ // on itself): Self-MoA (one model, temperature-varied) may not diverge like fresh-context saga
928
+ // cases the threshold was tuned on. The result is EXPERIMENTAL: the instrumentation sink records
929
+ // the fire-rate; we NEVER claim it works. If the divergence score is low (convergent), that is
930
+ // still a measurement — the path is not broken, the formula is just not exercised by Self-MoA.
931
+ {
932
+ const candidateTexts = candidates.map((c) => {
933
+ // Extract a representative text from each candidate for divergence analysis.
934
+ if (phaseName === "partition") {
935
+ return JSON.stringify(c.domains || []);
936
+ }
937
+ return String(c.proposal || c.rationale || "");
938
+ }).filter((t) => t.length > 0);
939
+
940
+ if (candidateTexts.length >= 2) {
941
+ const triggerInput = JSON.stringify({
942
+ type: "divergence-sampling",
943
+ answers: candidateTexts,
944
+ basis: `${phaseName} phase competition: ${candidates.length} Self-MoA producers — checking if proposal divergence signals an unproven architectural assumption`,
945
+ });
946
+ const divResult = await runCli(
947
+ projectDir,
948
+ "architectural-trigger",
949
+ ["trigger", triggerInput],
950
+ "gsd-t-architectural-trigger.cjs",
951
+ `arch-trigger-divergence:${phaseName}`,
952
+ true,
953
+ "Judge"
954
+ );
955
+ const divEnv = divResult.envelope || {};
956
+ // Log the result — ALWAYS note the experimental nature (no efficacy claim).
957
+ // The divergenceScore and fired flag are the measurement; the sink has the record.
958
+ log(
959
+ `M90 arch-trigger R-ARCH-1 (competition-arm, EXPERIMENTAL): ` +
960
+ `${phaseName} | fired=${divEnv.fired} | divergenceScore=${divEnv.divergenceScore != null ? divEnv.divergenceScore.toFixed(3) : "?"} | ` +
961
+ `reason=${divEnv.reason || "?"} | experimental=true (Self-MoA may not diverge like fresh-context saga cases)`
962
+ );
963
+ // Store the result for attachment to `result` after Finalize assigns it.
964
+ _pendingArchTriggerDivergence = {
965
+ fired: divEnv.fired || false,
966
+ divergenceScore: divEnv.divergenceScore != null ? divEnv.divergenceScore : null,
967
+ reason: divEnv.reason || null,
968
+ experimental: true,
969
+ n: candidateTexts.length,
970
+ };
971
+ } else {
972
+ log(`M90 arch-trigger R-ARCH-1: skipped — only ${candidateTexts.length} producer text(s) available (need ≥2)`);
973
+ }
974
+ }
975
+
521
976
  // FINALIZE: one agent commits the WINNING approach (pick-one at the thesis level),
522
977
  // then enriches it with non-overlapping good line-items from the losers (safe union
523
978
  // at the separable layer — "winner + salvage orphaned good ideas"; never grafts a
@@ -599,6 +1054,26 @@ if (!competitionOn) {
599
1054
 
600
1055
  // Thread the competition telemetry up so the caller can report measured SC#1.
601
1056
  result.competition = { n: candidates.length, winner: winner.id, ranked };
1057
+ // M90 §2 D4-T4: attach divergence-path result if computed (EXPERIMENTAL+MEASURED).
1058
+ if (_pendingArchTriggerDivergence && result) {
1059
+ result.archTriggerDivergence = _pendingArchTriggerDivergence;
1060
+ }
1061
+ }
1062
+
1063
+ // ── M89 Stated-Claims pipeline (research-eligible phases) ──────────────────
1064
+ // Runs AFTER the phase agent writes its artifacts and BEFORE the Plan Hardening
1065
+ // gate so that any external guessed claims get cited before the gate runs.
1066
+ // The pre-mortem (inside Plan Hardening below) also embeds the Stated-Claims
1067
+ // instruction — its claims are processed inside the pre-mortem agent call itself
1068
+ // (it cites in its prompt context); the gate here covers the main plan/partition/
1069
+ // discuss/milestone/impact output.
1070
+ if (result && result.status !== "failed" && RESEARCH_ELIGIBLE_PHASES.has(phaseName)) {
1071
+ const statedClaimsCtx = (result.summary || "") + "\n" + (result.decisions || []).join("\n");
1072
+ const scPipeline = await runStatedClaimsPipeline(projectDir, phaseName, result, statedClaimsCtx);
1073
+ result.statedClaimsPipeline = scPipeline;
1074
+ if (scPipeline.errors && scPipeline.errors.length > 0) {
1075
+ log(`m89: ${phaseName}: stated-claims pipeline completed with ${scPipeline.errors.length} error(s) — uncited markers may remain (verify gate will enforce)`);
1076
+ }
602
1077
  }
603
1078
 
604
1079
  // ── M83 Left-Shifted Plan Hardening (plan phase only) ──
@@ -659,6 +1134,8 @@ if (phaseName === "plan" && result && result.status !== "failed") {
659
1134
  `Predict, before any code is executed, how this milestone will FAIL: edge cases, dead deliverables, unguarded NFRs, shallow-test traps. Scrutinize the HEADLINE capability hardest — is it bound to a real path, reachable, and covered by a killing test?`,
660
1135
  `Every blocking finding MUST convert to a concrete requiredTest the plan must adopt. Advisory notes are forbidden.`,
661
1136
  `Verdict BLOCK if any concrete, falsifiable failure condition lacks a named required test; else CLEARED. Return JSON per the schema.`,
1137
+ ``,
1138
+ `M89 ${STATED_CLAIMS_INSTRUCTION}`,
662
1139
  ].join("\n"),
663
1140
  { label: "pre-mortem", phase: "Plan Hardening", schema: PRE_MORTEM_SCHEMA, model: overrides["pre-mortem"] ?? "fable" }
664
1141
  ).catch((e) => ({ verdict: "BLOCK", findings: [{ severity: "HIGH", condition: `pre-mortem agent error: ${e && e.message}`, requiredTest: "re-run pre-mortem" }], notes: "agent-error" }));