bareloop 0.25.0 → 0.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/authoring.js CHANGED
@@ -473,24 +473,42 @@ export function deniedCommandReason(cmd) {
473
473
  // ── 2. THE TYPES GENRE ───────────────────────────────────────────────────────
474
474
 
475
475
  /**
476
- * THE FROZEN TYPES GENRE TEMPLATE — prereg addendum 3's six policy points,
477
- * VERBATIM. The suite reads the prereg and compares BYTES, so a paraphrase here
478
- * fails the build rather than quietly changing what every future close is
479
- * authored against. It deliberately carries no repo-specific command, path, env
480
- * value or regex: round 2 measured that stating the LAW still leaves the model
481
- * to find the VALUE, and that is the axis the build must keep live.
476
+ * THE TYPES GENRE TEMPLATE — six policy points, VERBATIM against
477
+ * `docs/product/ITEM33-BUILD.md`'s own frozen block (PRD item 33 M3 piece 4,
478
+ * D6 = A ruling, 2026-09-14). This SUPERSEDES the original prereg-frozen
479
+ * version (`docs/logs/2026-08-08-close-authoring-gate2-poc-prereg.md`,
480
+ * addendum 3, the closed record — never edited): that version stated the
481
+ * WORK-stage skeleton (typecheck, typecheck-outside, tests-kept,
482
+ * suite-green) and the checker's STRICT form as UNCONDITIONAL law, which
483
+ * conflicted with ruling 6's "a genre never adds a check the goal did not
484
+ * ask for" — run mtv8jihy's unasked `tsc --strict` stage is the case this
485
+ * cures. Composing those stages/strictness only for a CONFIRMED check
486
+ * (never a code matcher over prose, same reasoning as the no-shell law) is
487
+ * enforced here, in the prompt; the GUARDS (changed-from-seed,
488
+ * no-suppressions) are enforced separately and structurally
489
+ * (`classGuards` + `validateDeclaration`'s `guards-absent` red), so making
490
+ * the WORK stages conditional here changes nothing about their own
491
+ * always-on status. The suite reads the SIGNED block and compares BYTES, so
492
+ * a paraphrase here fails the build rather than quietly changing what every
493
+ * future close is authored against. It deliberately carries no
494
+ * repo-specific command, path, env value or regex: round 2 measured that
495
+ * stating the LAW still leaves the model to find the VALUE, and that is the
496
+ * axis the build must keep live.
482
497
  */
483
- export const TYPES_GENRE_TEMPLATE = `1. The graded instrument is the STRICT form of the language's type checker, regardless of what
484
- the repo's own scripts run. If the repo's script omits strictness, the close adds it.
498
+ export const TYPES_GENRE_TEMPLATE = `1. The STRICT form of the language's type checker is used only when a confirmed check — or,
499
+ absent a confirm turn, the goal and interview answers — asks for strict typing; otherwise the
500
+ checker runs at whatever strictness the repo's own scripts already use.
485
501
  2. Tools are invoked so their binaries actually resolve — through the project's own package
486
502
  runner or language module runner, never a bare binary name.
487
- 3. The stage skeleton, in first-red-wins order: changed-from-seed (guard) → typecheck (error
488
- count IN the target scope, baseline 0, lower-is-better) → typecheck-outside (error count
489
- OUTSIDE the target scope, baseline measured at seed, a ceiling — required whenever the job
490
- scopes to a subset of the tree; omitted only for whole-tree jobs) → tests-kept (a floor on
491
- tests that actually EXECUTED, baseline at seed, higher-is-better; a skipped or deselected
492
- test did not run and must not count) → suite-green (the suite exits clean AND reports zero
493
- failing tests — two assertions) → no-suppressions (guard).
503
+ 3. The WORK stages — typecheck (error count IN the target scope, baseline 0, lower-is-better),
504
+ typecheck-outside (error count OUTSIDE the target scope, baseline measured at seed, a
505
+ ceiling — required whenever a typecheck stage is scoped to a subset of the tree), tests-kept
506
+ (a floor on tests that actually EXECUTED, baseline at seed, higher-is-better; a skipped or
507
+ deselected test did not run and must not count), and suite-green (the suite exits clean AND
508
+ reports zero failing tests — two assertions) — are composed ONLY for the checks a confirmed
509
+ plan asked for (or, absent a confirm turn, the goal and interview answers). The GUARDS —
510
+ changed-from-seed and no-suppressions — are composed always, first-red-wins:
511
+ changed-from-seed before every WORK stage, no-suppressions last.
494
512
  4. One population per stage — two structurally different counts never share a stage.
495
513
  5. The checker must judge the PATIENT's own tree: if imports could resolve to an installed or
496
514
  editable copy elsewhere, the environment is set so they resolve inside the patient.
package/src/authorjob.js CHANGED
@@ -109,7 +109,10 @@ import {
109
109
  GENRE_LANGUAGES, LOCKED_KINDS, TYPES_GENRE, VERDICT_CLASSES, LOCKED_CLASSES, LIVE_CLASSES,
110
110
  UNLISTED_CLASSES, MENU_CLASSES,
111
111
  } from './authoring.js';
112
- import { QUESTION_SETS, questionsFor, requiredAnswersFor, authorClose, makeCostBook } from './authorflow.js';
112
+ import {
113
+ QUESTION_SETS, questionsFor, requiredAnswersFor, authorClose, makeCostBook,
114
+ runConfirmTurn, WORSE_THAN_BEFORE_FIELD, LANGUAGE_PICK_FIELD,
115
+ } from './authorflow.js';
113
116
  import { runAuthorScout, buildSeedListing, SCOUT_ATTEMPTS } from './authorscout.js';
114
117
  import {
115
118
  DECLARED_GAP_PREFIX, DECLARED_GENRES, guardNames, isDeclaredClose, validateCloseDecl,
@@ -419,16 +422,24 @@ function composerRefusal(reds) {
419
422
  * onPhase?: (phase: string, data?: any) => void,
420
423
  * onCall?: (call: {label: string, costUsd: number|null, unpricedRounds: number}) => void,
421
424
  * seedFn?: Function, scoutFn?: Function, listingFn?: Function,
422
- * authorFn?: Function, authorOpts?: object,
423
- * signerFix?: Function|any, proposeFn?: Function, compileOpts?: object}} o
425
+ * authorFn?: Function, authorOpts?: object, writeScope?: string[]|null,
426
+ * signerFix?: Function|any, proposeFn?: Function, compileOpts?: object,
427
+ * ask?: ((step: {kind: string, [k: string]: any}) => Promise<string|null>)|null,
428
+ * confirmGenerate?: Function|null, isRepo?: boolean,
429
+ * langResult?: {kind: string, candidates?: string[], [k: string]: any}|null}} o
424
430
  * @returns {Promise<{ok: boolean, refusal: Refusal|null, verdictType: string|null,
425
431
  * closeDecl: any, seedRef: string|null, authoring: any, judged: any, reds: Red[],
426
- * stop: string|null, cost: any, interview: any}>}
432
+ * stop: string|null, cost: any, interview: any, confirmed: any}>}
427
433
  */
428
434
  export async function authorCloseForJob({
429
435
  answers, repoPath = null, lang, verdictType = null, questions = null,
430
436
  generate, provider = null, seedRef = null, scout = null, listing = null,
431
437
  ceilingUsd = null,
438
+ // Q2 IS GONE from the numbered interview (PRD item 33 M3 piece 3) — the
439
+ // fence it used to describe in prose is now Destination's own proven
440
+ // `writeScope`, and this is what carries it into the composer prompt
441
+ // (`authorPrompt`'s `writeScopeBlock`) instead of letting it vanish.
442
+ writeScope = null,
432
443
  // THE JUDGE IDENTITY (PRD item 32.1), required only on the path that actually
433
444
  // composes a judged stage — the stored calibration set carries the judge that
434
445
  // certified it, and there is no library pin to fall back to. Absent on a purely
@@ -448,11 +459,30 @@ export async function authorCloseForJob({
448
459
  // signed as proposed, and `signJudgedArtifacts` records which of the two
449
460
  // happened rather than leaving a reader to guess.
450
461
  signerFix = null, proposeFn = proposeJudgedArtifacts, compileOpts = {},
462
+ // PRD item 33 M3 piece 4 — the confirm turn. ALL FOUR default to nothing so
463
+ // every existing caller runs byte-identical: `ask` absent means no confirm
464
+ // turn runs at all, exactly as before this piece existed.
465
+ // ask the ONE interactive seam (see `runConfirmTurn`'s own doc)
466
+ // — its ABSENCE is the switch that turns the whole confirm
467
+ // turn off, never a boolean flag next to it.
468
+ // confirmGenerate a SEPARATE model boundary from `generate`: the authoring
469
+ // call is bound to AUTHOR_SYSTEM and the confirm call is
470
+ // bound to CONFIRM_SYSTEM — reusing `generate` would run the
471
+ // wrong system prompt silently.
472
+ // isRepo whether Source resolved to a repo (`looksLikeRepoSource`)
473
+ // — gates the repo-only "worse than before" $0 question and
474
+ // whether a confirm turn runs at all (M3 ruling 7: a
475
+ // plain-folder job's confirm turn is a LATER piece).
476
+ // langResult `src/detectlang.js`'s own result — only its `ambiguous`
477
+ // shape matters here (a $0 language pick before the scout,
478
+ // D7); every other kind leaves `lang` exactly as given.
479
+ ask = null, confirmGenerate = null, isRepo = false, langResult = null,
451
480
  }) {
452
481
  /** @type {any} */
453
482
  const base = {
454
483
  ok: false, refusal: null, verdictType: null, closeDecl: null, seedRef: null,
455
484
  authoring: null, judged: null, reds: [], stop: null, cost: null, interview: null,
485
+ confirmed: null,
456
486
  };
457
487
 
458
488
  const interview = runInterview({ answers, verdictType, repoPath, questions });
@@ -463,6 +493,27 @@ export async function authorCloseForJob({
463
493
  const workdir = /** @type {string} */ (interview.repoPath);
464
494
  const picked = /** @type {string} */ (interview.verdictType);
465
495
 
496
+ // ── THE CONFIRM TURN'S $0 HALF (PRD item 33 M3 piece 4, D7) ────────────────
497
+ // Entirely BEFORE the scout: a missing person here stops at $0, never after a
498
+ // paid survey. `ask` ABSENT is the whole confirm turn's off switch — every
499
+ // caller that predates this piece (or a caller that never wires an
500
+ // interactive seam) reaches the genre check below exactly as it always did.
501
+ let worseThanBefore = '';
502
+ if (ask) {
503
+ if (isRepo) {
504
+ onPhase('confirm-worse-than-before', {});
505
+ const wtb = await ask({ kind: 'worseThanBefore', field: WORSE_THAN_BEFORE_FIELD });
506
+ if (wtb === null) return { ...base, interview, reds: [], stop: 'confirm-abandoned' };
507
+ worseThanBefore = redactSecrets(String(wtb).trim());
508
+ }
509
+ if (langResult?.kind === 'ambiguous') {
510
+ onPhase('confirm-language-pick', { candidates: langResult.candidates });
511
+ const pick = await ask({ kind: 'language', field: LANGUAGE_PICK_FIELD, candidates: langResult.candidates });
512
+ if (pick === null) return { ...base, interview, reds: [], stop: 'confirm-abandoned' };
513
+ lang = String(pick);
514
+ }
515
+ }
516
+
466
517
  if (!GENRE_LANGUAGES.includes(lang)) {
467
518
  // THE GENRE REFUSAL, at the composer. This used to be a plain wiring red;
468
519
  // under v1.57 §2 the interview no longer asks the user to confirm a genre, so
@@ -537,6 +588,72 @@ export async function authorCloseForJob({
537
588
  // back with anything to author from.
538
589
  onPhase('scout-done', { state: survey?.state ?? null, facts: Object.keys(survey?.facts ?? {}).length });
539
590
  }
591
+
592
+ // ── THE CONFIRM TURN'S PAID HALF (PRD item 33 M3 piece 4) ──────────────────
593
+ // Slotted between the scout and the listing: it reuses the scout's own
594
+ // survey (ruling 4 — never a second read of the source) and needs nothing
595
+ // the listing would add. Runs ONLY when `ask` is wired AND this is a repo
596
+ // job AND the survey actually came back PRESENT — an ABSENT survey means
597
+ // `authorFn` below refuses at its own $0 preflight (`scout-absent` or its
598
+ // budget-stop variants) exactly as it always has; drafting a plan from
599
+ // facts that never arrived would be worse than that honest refusal, so the
600
+ // confirm turn is skipped rather than run against nothing. A plain-folder
601
+ // job's confirm turn (no survey at all, D5) is a LATER piece.
602
+ /** @type {any} */
603
+ let confirmed = null;
604
+ /** @type {any[]} */
605
+ let confirmPriorCalls = [];
606
+ /** @type {any[]} */
607
+ let confirmPriorRaws = [];
608
+ if (ask && isRepo && survey?.state === 'PRESENT') {
609
+ if (typeof confirmGenerate !== 'function') {
610
+ throw new Error('[authorjob] authorCloseForJob was given `ask` for a repo job but no `confirmGenerate` — the '
611
+ + 'confirm turn needs its OWN model boundary (bound to CONFIRM_SYSTEM), never the authoring `generate` '
612
+ + '(bound to AUTHOR_SYSTEM)');
613
+ }
614
+ const confirmBook = makeCostBook({ ceilingUsd, onCall });
615
+ // THE CEILING FOLDS IN PRIOR SPEND (the standing hard line's money form,
616
+ // F1). The scout is the FIRST paid stage — its calls are absorbed here,
617
+ // BEFORE `runConfirmTurn` ever asks `book.capStop()`, so a scout that
618
+ // already spent most (or all) of the ceiling is visible to the confirm
619
+ // turn's own pre-call check, and a scout call that came back unpriced
620
+ // (`costUsd: null`) taints `spendComplete` here too — "unpriced is never
621
+ // free" applies to the confirm turn's OWN calls exactly as it already
622
+ // does to the author call's. `absorb` never fires `onCall` (see
623
+ // `makeCostBook`'s own doc): these calls were already reported once, when
624
+ // the scout made them.
625
+ const scoutCallCount = (survey?.calls ?? []).length;
626
+ const scoutRawCount = (survey?.raws ?? []).length;
627
+ confirmBook.absorb(survey?.calls ?? [], survey?.raws ?? []);
628
+ onPhase('confirm', {});
629
+ const confirm = await runConfirmTurn({
630
+ verdictType: picked, answers: interview.answers, questions: questions ?? questionsFor(picked),
631
+ facts: survey.facts, listing: null, writeScope, isRepo, lang, worseThanBefore,
632
+ generate: confirmGenerate, book: confirmBook, ask, onPhase,
633
+ });
634
+ // DISTINCT from `runConfirmTurn`'s own per-round 'confirm-done' (fired once
635
+ // per round when a plan arrives) — this is the WRAPPER's own line, once,
636
+ // saying how the whole turn ended.
637
+ onPhase('confirm-turn-done', { ok: confirm.ok, stop: confirm.stop, rounds: confirm.rounds });
638
+ if (!confirm.ok) {
639
+ return {
640
+ ...base, interview, verdictType: picked, seedRef: seed,
641
+ reds: confirm.reds, stop: confirm.stop, cost: confirm.cost,
642
+ };
643
+ }
644
+ confirmed = confirm.accepted;
645
+ lang = confirmed.lang;
646
+ // ONLY the confirm turn's OWN new calls travel onward as `priorCalls` —
647
+ // never the scout entries this same book absorbed two lines above.
648
+ // `authorClose` (below, via `authorFn`) absorbs `scout.calls`/`scout.raws`
649
+ // itself; handing it the scout's calls a SECOND time (folded inside
650
+ // `confirmBook`'s own report) would double-count the scout's spend in the
651
+ // author call's own ceiling check — the exact bug this fix must not
652
+ // reintroduce while fixing the other one.
653
+ confirmPriorCalls = confirmBook.report().calls.slice(scoutCallCount);
654
+ confirmPriorRaws = confirmBook.raws().slice(scoutRawCount);
655
+ }
656
+
540
657
  /** @type {any} */
541
658
  let seeds = listing;
542
659
  if (seeds == null) {
@@ -558,7 +675,9 @@ export async function authorCloseForJob({
558
675
  const authored = await authorFn({
559
676
  workdir, seedRef: seed, lang, verdictType: picked,
560
677
  answers: interview.answers, questions: questions ?? questionsFor(picked),
561
- scout: survey, listing: seeds, generate, ceilingUsd, onPhase, onCall, ...authorOpts,
678
+ scout: survey, listing: seeds, generate, ceilingUsd, onPhase, onCall, writeScope,
679
+ priorCalls: confirmPriorCalls, priorRaws: confirmPriorRaws, confirmed,
680
+ ...authorOpts,
562
681
  });
563
682
 
564
683
  if (!authored.ok) {
@@ -690,6 +809,7 @@ export async function authorCloseForJob({
690
809
  stop: authored.stop,
691
810
  cost: book.report(),
692
811
  interview,
812
+ confirmed,
693
813
  };
694
814
  }
695
815
 
@@ -705,6 +825,7 @@ export async function authorCloseForJob({
705
825
  stop: authored.stop,
706
826
  cost: authored.cost,
707
827
  interview,
828
+ confirmed,
708
829
  };
709
830
  }
710
831
 
@@ -727,6 +848,38 @@ export async function authorCloseForJob({
727
848
  // the same way one carrying a raw `close` array is.
728
849
  export const AUTHORED_SPEC_FIELDS = Object.freeze(['close', 'closeDecl', 'verdictType', 'sha256', 'closeTimeoutMs']);
729
850
 
851
+ // `goal` IS DIFFERENT from the fields above, and deliberately kept in its own
852
+ // list (PRD item 33 M3, ruling 5's 2026-09-13 addendum, D2 = option B). It is
853
+ // NOT authored by anything in this file — `assembleSpec` above passes it
854
+ // through from the draft untouched, exactly as it always has — so it is not
855
+ // one of `AUTHORED_SPEC_FIELDS` and `assembleSpec` does not refuse a draft
856
+ // that carries it. What changed is WHO writes it: `scripts/run-interview.mjs`
857
+ // no longer asks a separate goal question (that question is GONE, step S5),
858
+ // and `scripts/run-author.mjs` instead sets `draft.goal` from the confirm
859
+ // turn's own accepted plan (`authored.confirmed.goal`, step S4) before
860
+ // calling `assembleSpec`. A draft written by `run-interview.mjs` therefore
861
+ // has NO `goal` field at all, and `validateJob`'s $0 pass over that draft
862
+ // (the same pass that already filters `AUTHORED_SPEC_FIELDS`'s reds, since
863
+ // that half does not exist yet either) would otherwise red `missing-required`
864
+ // at `goal` for a field the interview never had a chance to fill. This is
865
+ // the ONE library constant that filter reads for that field, so no caller
866
+ // hand-types the field name `'goal'` a second time.
867
+ export const CONFIRM_AUTHORED_FIELDS = Object.freeze(['goal']);
868
+
869
+ // PRD item 33 M3 piece 4, step S6 (D5 = A: a plain-folder source runs no
870
+ // scout, and its confirm turn ends in the honest "no checks yet" stop, M4).
871
+ // `writeScope` is the signed fence for a REPO job (Destination's own proven
872
+ // answer IS it); for a plain folder, Destination is an OUTPUT directory, not
873
+ // a fence, and this build authors no close for that kind of job at all — so
874
+ // `scripts/run-interview.mjs` writes no `writeScope` field for one. Named
875
+ // here, separately from `CONFIRM_AUTHORED_FIELDS`, because nothing in THIS
876
+ // build ever fills it in for a plain-folder draft (goal, above, IS always
877
+ // filled in later, by the confirm turn) — it stays absent until M4 gives a
878
+ // plain-folder job a fence of its own kind. Filtering it unconditionally in
879
+ // the $0 validator pass is harmless for a repo draft, which always carries
880
+ // one: there is never a `writeScope` red left to filter there.
881
+ export const PLAIN_FOLDER_DEFERRED_FIELDS = Object.freeze(['writeScope']);
882
+
730
883
  /**
731
884
  * Fold an authored close into the OPERATOR's own half of the spec.
732
885
  *
package/src/cardauthor.js CHANGED
@@ -3,13 +3,22 @@
3
3
  // plus the 2026-08-18 threshold addendum).
4
4
  //
5
5
  // Module 1 built the judged floor's core, module 2 wired the stage into a close,
6
- // module 3 opened the class and asked the two extra questions. Nothing compiled
7
- // their answers. This module does exactly that and nothing more:
6
+ // module 3 opened the class and asked the extra question. Nothing compiled its
7
+ // answer. This module does exactly that and nothing more:
8
8
  //
9
- // Q6 → THE RUBRIC CARD (§4.3) — the person's own pass/fail lines, as
10
- // ENUMERATED items over the arbiter's own rulebook.
11
- // Q7 → THE CALIBRATION SET (§4.4) — TEN cases, frozen, that the whole pipe
12
- // must grade before the close is signable.
9
+ // Judge Examples → THE RUBRIC CARD (§4.3) — the person's own pass/fail
10
+ // lines, as ENUMERATED items over the arbiter's own
11
+ // rulebook, compiled from the "why" half of their answer.
12
+ // → THE CALIBRATION SET (§4.4) — TEN cases, frozen, that the
13
+ // whole pipe must grade before the close is signable.
14
+ //
15
+ // PRD item 33 M3 piece 3 (2026-09-13): the old Q6 ("what separates a pass from
16
+ // a fail?") and Q7 ("give one you'd pass and one you'd fail, and say why")
17
+ // merged into ONE form field, Judge Examples — Q7's wording, verbatim
18
+ // (`SOFTGREEN_JUDGE_EXAMPLES_KEY`/`JUDGE_EXAMPLES_QUESTION`, `authorflow.js`).
19
+ // Q6 is not lost: the "why" lines of a real pass/fail pair are what this
20
+ // module already compiled a card FROM, and asking the differentiator twice —
21
+ // once in the abstract, once against a real example — was the double-ask.
13
22
  //
14
23
  // ── THE D5 PATH, AND WHY IT IS THIS ONE ─────────────────────────────────────
15
24
  //
@@ -62,7 +71,7 @@
62
71
  import {
63
72
  JUDGE_RULE_IDS, JUDGE_RULES, CALIBRATION_SIZE, CASE_VERDICTS, validateJudgedArtifacts,
64
73
  } from './judged.js';
65
- import { askStructured, makeCostBook } from './authorflow.js';
74
+ import { askStructured, makeCostBook, SOFTGREEN_JUDGE_EXAMPLES_KEY, JUDGE_EXAMPLES_QUESTION } from './authorflow.js';
66
75
  import { judgedStages } from './kinds.js';
67
76
  import { isObj, redactSecrets } from './validate.js';
68
77
 
@@ -204,8 +213,12 @@ export const COMPILE_SYSTEM = 'You compile a person\'s own words into a rubric a
204
213
  + 'is fixed and you select from it.';
205
214
 
206
215
  /**
207
- * The compile prompt. Q6's and Q7's answers travel VERBATIM — they are the whole
208
- * input, and paraphrasing a lay answer is how a rubric stops being the person's.
216
+ * The compile prompt. The Judge Examples answer travels VERBATIM — it is the
217
+ * whole input, and paraphrasing a lay answer is how a rubric stops being the
218
+ * person's. PRD item 33 M3 piece 3: this used to be two answers (Q6+Q7);
219
+ * `SOFTGREEN_JUDGE_EXAMPLES_KEY` is the ONE key both the interview and this
220
+ * compile now read, so a future renumbering of the form moves both readers
221
+ * together rather than drifting.
209
222
  *
210
223
  * The rulebook is rendered from `JUDGE_RULES` itself rather than restated, for
211
224
  * the reason the catalogue block is rendered from the catalogue: the prompt and
@@ -213,8 +226,8 @@ export const COMPILE_SYSTEM = 'You compile a person\'s own words into a rubric a
213
226
  * @param {{answers: Record<string|number, any>, questions?: Record<string|number, string>}} o
214
227
  */
215
228
  export function cardCasesPrompt({ answers, questions = {} }) {
216
- const q6 = questions[6] ?? 'When you judge the result yourself, what separates a pass from a fail?';
217
- const q7 = questions[7] ?? 'Give one example you\'d pass and one you\'d fail, and say why.';
229
+ const qKey = SOFTGREEN_JUDGE_EXAMPLES_KEY;
230
+ const q = questions[qKey] ?? JUDGE_EXAMPLES_QUESTION;
218
231
  const rules = JUDGE_RULE_IDS.map((id) => `- ${id}\n the facts it reads: ${JUDGE_RULES[id].ask.trim().split('\n')[0].trim()}`);
219
232
 
220
233
  return `You are compiling ONE person's answers into the two artifacts a judged close is signed with.
@@ -222,13 +235,10 @@ export function cardCasesPrompt({ answers, questions = {} }) {
222
235
  They are not a software engineer. They described what they look for in their own words, and your whole job is to
223
236
  express that over the rulebook below — never to add a standard they did not state, and never to drop one they did.
224
237
 
225
- THEIR ANSWERS — verbatim, and the only input you have
226
-
227
- Q6. ${q6}
228
- A6. ${answers?.[6] ?? '(no answer given)'}
238
+ THEIR ANSWER — verbatim, and the only input you have
229
239
 
230
- Q7. ${q7}
231
- A7. ${answers?.[7] ?? '(no answer given)'}
240
+ Q${qKey}. ${q}
241
+ A${qKey}. ${answers?.[qKey] ?? '(no answer given)'}
232
242
 
233
243
  ---
234
244
 
@@ -407,7 +417,7 @@ export function foldJudgedArtifacts(closeDecl, { card, cases, judgeModel }) {
407
417
  // ── 6. THE PAID SEAM ────────────────────────────────────────────────────────
408
418
 
409
419
  /**
410
- * ONE compile call: the person's Q6 and Q7 in, a proposal out.
420
+ * ONE compile call: the person's Judge Examples answer in, a proposal out.
411
421
  *
412
422
  * It buys ONE artifact through ONE channel, and the malformed-emission ladder is
413
423
  * the SHARED one (`askStructured`) rather than a second copy — a second ladder is
@@ -0,0 +1,200 @@
1
+ // LANGUAGE DETECTION (PRD item 33/M3, ruling 3 — `docs/product/ITEM33-BUILD.md`,
2
+ // "Five detection points"): a code job's checks always run the repo's OWN
3
+ // tools in the repo's own language, so language is a FACT of the repository,
4
+ // never a question asked of the person. This module is that fact-finder —
5
+ // $0, no provider, no model — and it replaces the `--lang` flag (default
6
+ // `js`) `scripts/run-interview.mjs` and `scripts/run-author.mjs` used to
7
+ // carry.
8
+ //
9
+ // ONE DATA TABLE (`MANIFEST_RULES`), not a code path per language: M3b's job
10
+ // is to flip a language from "known but no genre data" to "known and
11
+ // supported" by adding it to `GENRE_LANGUAGES` (`src/authoring.js`) — this
12
+ // module never hardcodes the supported set a second time, it reads
13
+ // `GENRE_LANGUAGES` live, so a language landing in M3b needs no edit here.
14
+ //
15
+ // FOUR OUTCOMES, discriminated by `kind`, never a throw for the ones a caller
16
+ // must act on in the ordinary course of a run:
17
+ // - `resolved` — a known, SUPPORTED manifest: 'js' or 'python'.
18
+ // - `language-unsupported` — a known manifest this catalogue has no genre
19
+ // data for yet (go, rust, java, csharp, php...).
20
+ // Never a silent fallback to js. Carries a
21
+ // `refusal` shaped exactly like the `Refusal`
22
+ // `src/authorjob.js` already returns for its own
23
+ // language refusal (same `REFUSAL_LIB`,
24
+ // `REFUSAL_CATEGORY`, `request-red` shape) —
25
+ // reusing that pattern rather than inventing a
26
+ // second one, so a caller with a spine can emit
27
+ // it through the same `refusalEvents()` either
28
+ // already imports.
29
+ // - `no-code-job` — no manifest anywhere in the walk. NOT an
30
+ // error: PRD item 33 M3 ruling 7 says a
31
+ // plain-folder job gets the form and an honest
32
+ // "no checks yet" stop later (M4), so this is a
33
+ // normal reading, not a refusal.
34
+ // - `ambiguous` — two DIFFERENT supported languages' manifests
35
+ // at the same (nearest) directory level. The
36
+ // confirm-turn UI that would ask a person to
37
+ // pick is a later M3 piece; for now this is
38
+ // reported so a caller can stop rather than
39
+ // guess.
40
+
41
+ import { readdirSync, statSync } from 'node:fs';
42
+ import { dirname, resolve } from 'node:path';
43
+ import { GENRE_LANGUAGES } from './authoring.js';
44
+ import { GENRE, REFUSAL_CATEGORY, REFUSAL_LIB } from './authorjob.js';
45
+ import { nearestGitAncestor } from './source.js';
46
+
47
+ /** @typedef {{code: string, path: string, detail: string, [k: string]: any}} Red */
48
+ /** @typedef {{kind: 'request-red', verb: string|null, path: string, detail: string,
49
+ * options: string[], red: Red}} LangRefusal */
50
+
51
+ /**
52
+ * THE MANIFEST TABLE, as data — the whole language vocabulary this module
53
+ * knows, one entry per manifest shape. `supported` is DERIVED from
54
+ * `GENRE_LANGUAGES` at lookup time (never stored), so a language M3b adds
55
+ * there flips from "unsupported stop" to "resolved" by that one edit, never
56
+ * by adding a new branch here.
57
+ * @type {ReadonlyArray<{kind: 'exact'|'ext', name?: string, ext?: string, language: string}>}
58
+ */
59
+ const MANIFEST_RULES = Object.freeze([
60
+ Object.freeze({ kind: 'exact', name: 'package.json', language: 'js' }),
61
+ Object.freeze({ kind: 'exact', name: 'pyproject.toml', language: 'python' }),
62
+ Object.freeze({ kind: 'exact', name: 'setup.py', language: 'python' }),
63
+ Object.freeze({ kind: 'exact', name: 'go.mod', language: 'go' }),
64
+ Object.freeze({ kind: 'exact', name: 'Cargo.toml', language: 'rust' }),
65
+ Object.freeze({ kind: 'exact', name: 'pom.xml', language: 'java' }),
66
+ Object.freeze({ kind: 'exact', name: 'build.gradle', language: 'java' }),
67
+ Object.freeze({ kind: 'exact', name: 'build.gradle.kts', language: 'java' }),
68
+ Object.freeze({ kind: 'exact', name: 'composer.json', language: 'php' }),
69
+ Object.freeze({ kind: 'ext', ext: '.csproj', language: 'csharp' }),
70
+ Object.freeze({ kind: 'ext', ext: '.sln', language: 'csharp' }),
71
+ ]);
72
+
73
+ /**
74
+ * A human label for a matched rule, for messages — `package.json` for an
75
+ * exact match, `*.csproj` for an extension match.
76
+ * @param {{kind: 'exact'|'ext', name?: string, ext?: string}} rule
77
+ * @returns {string}
78
+ */
79
+ function ruleLabel(rule) {
80
+ return rule.kind === 'exact' ? /** @type {string} */ (rule.name) : `*${rule.ext}`;
81
+ }
82
+
83
+ /**
84
+ * The directory chain to walk, NEAREST first, stopping at the repo root (the
85
+ * nearest ancestor carrying a `.git` entry, directory OR file — a submodule's
86
+ * `.git` is a file). When no `.git` exists anywhere above `start`, the walk
87
+ * is exactly `[start]` — this module never reads above the folder it was
88
+ * asked about when there is no repo boundary to stop it honestly.
89
+ *
90
+ * The boundary itself is `nearestGitAncestor` (`src/source.js`) — the ONE
91
+ * walk-up-for-`.git` rule, shared with `looksLikeRepoSource`/`prepareSource`'s
92
+ * own repo-root routing (PRD item 33 M3 ruling 2 addendum) — this function
93
+ * only turns that single boundary into the full chain of directories a
94
+ * manifest search walks.
95
+ * @param {string} start
96
+ * @returns {string[]}
97
+ */
98
+ function walkChain(start) {
99
+ /** @type {string[]} */
100
+ const chain = [];
101
+ let d = resolve(start);
102
+ for (;;) {
103
+ chain.push(d);
104
+ const parent = dirname(d);
105
+ if (parent === d) break; // filesystem root
106
+ d = parent;
107
+ }
108
+ const boundary = nearestGitAncestor(chain[0]);
109
+ const repoRootIdx = boundary === null ? -1 : chain.indexOf(boundary.dir);
110
+ return repoRootIdx === -1 ? [chain[0]] : chain.slice(0, repoRootIdx + 1);
111
+ }
112
+
113
+ /**
114
+ * Every manifest rule matched inside `dir`, reading its entries once.
115
+ * @param {string} dir
116
+ * @returns {{kind: 'exact'|'ext', name?: string, ext?: string, language: string}[]}
117
+ */
118
+ function matchesIn(dir) {
119
+ /** @type {string[]} */
120
+ let entries;
121
+ try {
122
+ entries = readdirSync(dir);
123
+ } catch {
124
+ return []; // an unreadable directory has no manifest, not an error this module raises
125
+ }
126
+ return MANIFEST_RULES.filter((r) => (
127
+ r.kind === 'exact' ? entries.includes(/** @type {string} */ (r.name)) : entries.some((e) => e.endsWith(/** @type {string} */ (r.ext)))
128
+ ));
129
+ }
130
+
131
+ /**
132
+ * The `language-unsupported` refusal, shaped exactly like `src/authorjob.js`'s
133
+ * own `Refusal` — same `REFUSAL_LIB`, same `request-red` code, so a caller
134
+ * holding a spine can hand it straight to `refusalEvents()` without this
135
+ * module inventing a second recording shape.
136
+ * @param {string} language @param {string} manifest
137
+ * @returns {LangRefusal}
138
+ */
139
+ function unsupportedRefusal(language, manifest) {
140
+ const detail = `We can't run this kind of job yet: the nearest manifest is ${manifest}, which names "${language}" `
141
+ + `as this repository's language. The catalogue only has ${GENRE} genre data for ${GENRE_LANGUAGES.join(' and ')} `
142
+ + `today — a language it has no data for must refuse rather than author a close without the guards it cannot `
143
+ + 'supply.';
144
+ return {
145
+ kind: 'request-red',
146
+ verb: 'language-unsupported',
147
+ path: 'lang',
148
+ detail,
149
+ options: [
150
+ `wait for a later build to add ${language} (PRD item 33 M3b)`,
151
+ `restate the job for ${GENRE_LANGUAGES.join(' or ')}, the languages this catalogue currently measures`,
152
+ ],
153
+ red: {
154
+ code: 'request-red', path: 'lang', detail, verb: 'language-unsupported', lib: REFUSAL_LIB, category: REFUSAL_CATEGORY,
155
+ },
156
+ };
157
+ }
158
+
159
+ /**
160
+ * Detect a code job's language from its repository, $0, no provider.
161
+ *
162
+ * @param {string} sourcePath the Source path (a folder, or a file inside one)
163
+ * @returns {{kind: 'resolved', lang: string, dir: string, manifest: string} | {kind: 'language-unsupported', language: string, dir: string, manifest: string, refusal: LangRefusal} | {kind: 'no-code-job'} | {kind: 'ambiguous', candidates: string[], dir: string}}
164
+ */
165
+ export function detectLanguage(sourcePath) {
166
+ const st = statSync(sourcePath);
167
+ const start = st.isDirectory() ? sourcePath : dirname(sourcePath);
168
+ const chain = walkChain(start);
169
+
170
+ for (const dir of chain) {
171
+ const matched = matchesIn(dir);
172
+ if (!matched.length) continue;
173
+
174
+ const languages = [...new Set(matched.map((m) => m.language))];
175
+ if (languages.length > 1) {
176
+ // Ruling 3, point (3): two manifests at the same level. Named literally
177
+ // rather than the spec's minimal "two supported languages" example —
178
+ // ANY two different languages at the nearest level is an unresolved
179
+ // pick, listed rather than silently narrowed to the supported ones.
180
+ return { kind: 'ambiguous', candidates: languages.sort(), dir };
181
+ }
182
+
183
+ const language = languages[0];
184
+ const matchedRule = matched.find((m) => m.language === language);
185
+ // `matched` was just built from `languages`, so this find always succeeds —
186
+ // narrowed with a real runtime check rather than an `any`/`!` cast, so a
187
+ // future refactor that breaks the invariant reds loudly instead of typing
188
+ // past it.
189
+ if (!matchedRule) throw new Error(`unreachable: "${language}" matched from its own manifest list`);
190
+ const manifest = ruleLabel(matchedRule);
191
+ if (GENRE_LANGUAGES.includes(language)) {
192
+ return { kind: 'resolved', lang: language, dir, manifest };
193
+ }
194
+ return {
195
+ kind: 'language-unsupported', language, dir, manifest, refusal: unsupportedRefusal(language, manifest),
196
+ };
197
+ }
198
+
199
+ return { kind: 'no-code-job' };
200
+ }