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/CHANGELOG.md +317 -0
- package/bareloop.context.md +268 -83
- package/package.json +1 -1
- package/src/authorflow.js +713 -92
- package/src/authoring.js +33 -15
- package/src/authorjob.js +158 -5
- package/src/cardauthor.js +28 -18
- package/src/detectlang.js +200 -0
- package/src/index.js +29 -30
- package/src/job.js +3 -2
- package/src/kinds.js +5 -3
- package/src/promptregisters.js +6 -0
- package/src/providers.js +15 -3
- package/src/source.js +153 -33
- package/types/authorflow.d.ts +433 -104
- package/types/authoring.d.ts +23 -8
- package/types/authorjob.d.ts +22 -4
- package/types/cardauthor.d.ts +7 -3
- package/types/detectlang.d.ts +38 -0
- package/types/index.d.ts +16 -16
- package/types/job.d.ts +3 -2
- package/types/kinds.d.ts +5 -3
- package/types/providers.d.ts +9 -2
- package/types/source.d.ts +42 -0
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
|
|
477
|
-
*
|
|
478
|
-
*
|
|
479
|
-
*
|
|
480
|
-
*
|
|
481
|
-
*
|
|
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
|
|
484
|
-
|
|
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
|
|
488
|
-
count
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
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 {
|
|
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
|
|
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,
|
|
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
|
|
7
|
-
//
|
|
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
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
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.
|
|
208
|
-
* input, and paraphrasing a lay answer is how a rubric stops being the
|
|
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
|
|
217
|
-
const
|
|
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
|
|
226
|
-
|
|
227
|
-
Q6. ${q6}
|
|
228
|
-
A6. ${answers?.[6] ?? '(no answer given)'}
|
|
238
|
+
THEIR ANSWER — verbatim, and the only input you have
|
|
229
239
|
|
|
230
|
-
|
|
231
|
-
|
|
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
|
|
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
|
+
}
|