bareloop 0.25.1 → 0.27.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.
@@ -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
+ }
package/src/index.js CHANGED
@@ -52,7 +52,7 @@ export {
52
52
  BRIDGE_SCHEMA, validateBridge, deriveStatus, listingRow, loadGate,
53
53
  mintBridge, appendGreen, appendRed,
54
54
  loadBridge, loadRegistry, saveBridge, makeRegistry, registryExists,
55
- QUARANTINED_VERDICTS, QUARANTINED_CODE, quarantinesCredit, newestEligibleVersion, reuseEligibility, recordDoor,
55
+ QUARANTINED_VERDICTS, quarantinesCredit, newestEligibleVersion, reuseEligibility, recordDoor,
56
56
  } from './bridges.js';
57
57
  export { renderListing, selectionPrompt } from './selection.js';
58
58
  // Layer 3 modules 4+5 — the D7 envelope and the reuse runner. `runReuse` composes
@@ -81,7 +81,7 @@ export { renderListing, selectionPrompt } from './selection.js';
81
81
  // at a review door, and a door with no row to act on can only describe a held learning
82
82
  // credit it has no way to release. STORAGE ONLY — nothing here selects, promotes or reuses
83
83
  // a bridge; those stay parked on `layer-3-reuse`.
84
- export { validateEnvelope, resolveTrySpec, resolveReuse, reuseSpecHash, selectBridge, runReuse, REUSE_GRADED_RED, readResume, resumeTreeGate, CHECKPOINT_OUTCOMES, PAUSE_TTL_MS, checkpointAgeGate, applyDoorDecision, writeRunGreenRow, shapeForkName } from './reuse.js';
84
+ export { validateEnvelope, resolveTrySpec, resolveReuse, reuseSpecHash, selectBridge, runReuse, REUSE_GRADED_RED, readResume, resumeTreeGate, CHECKPOINT_OUTCOMES, PAUSE_TTL_MS, checkpointAgeGate, applyDoorDecision, writeRunGreenRow } from './reuse.js';
85
85
  // BA-21 pricing provenance rides out with the ledger because it is the same job: reading
86
86
  // a run's own spend record honestly. `rateProvenance`/`spendProvenance` answer "how much of
87
87
  // this was priced by a rate nobody vouched for" — REPORTING ONLY, no halt, no refusal.
@@ -95,7 +95,7 @@ export { classifyIncidents, foldLedger, ledgerDeltas, updateLedger, LEDGER_CLASS
95
95
  // runner that resumes reads the branch off the dead spine and may want to verify it is
96
96
  // still there before spending anything. The runner applies the rule itself either way —
97
97
  // neither export is a way to opt out of it.
98
- export { workBranchName, prepareWorkBranch, WORK_BRANCH_PREFIX, WORK_BRANCH_RE } from './workbranch.js';
98
+ export { workBranchName } from './workbranch.js';
99
99
  // THE READ SHIM (arm-gated, OFF by default via `runJob`'s `readShim`). The cap is exported
100
100
  // because bareloop.context.md names it, and documented-but-unexported is a false contract;
101
101
  // the wrapper goes with it so a caller building its own worker seam wraps the SAME ledger
@@ -105,7 +105,7 @@ export { workBranchName, prepareWorkBranch, WORK_BRANCH_PREFIX, WORK_BRANCH_RE }
105
105
  // `readShimArm` is exported for the same reason the cap is: a battery driver has to be able
106
106
  // to name and check the arm it is about to run BEFORE it launches, and the legal set living
107
107
  // only inside a runner would leave the driver guessing at spellings the guard will reject.
108
- export { createReadShim, wrapReadTool, readShimArm, readShimStrategy, READ_SHIM_CAP, READ_SHIM_STRATEGY, READ_SHIM_DIFF_STRATEGY, READ_SHIM_ARMS } from './readshim.js';
108
+ export { readShimArm, READ_SHIM_CAP, READ_SHIM_STRATEGY, READ_SHIM_DIFF_STRATEGY } from './readshim.js';
109
109
  // ── CLOSE AUTHORING v1 (gate 4) — the user declares what done means ──────────
110
110
  // The public surface is settled ONCE, here, at M4 (M2's header flagged the
111
111
  // naming collision and deferred it rather than exporting piecemeal).
@@ -123,12 +123,12 @@ export { createReadShim, wrapReadTool, readShimArm, readShimStrategy, READ_SHIM_
123
123
  // gate (that is what `prepareSigning` does with them), and `seedAtHead` because
124
124
  // D8's seed is READ at run start rather than typed into a spec.
125
125
  export {
126
- runDeclaredClose, runStage, seedRead, seedAtHead, seedListing, changedSet, makeSeedTrees,
127
- LIVE_KINDS, STOP_FAULTS, JUDGED_MARKER, EXIT_GREEN, EXIT_RED, EXIT_STOP,
126
+ runDeclaredClose, runStage, seedRead, seedAtHead,
127
+ LIVE_KINDS, STOP_FAULTS, JUDGED_MARKER,
128
128
  // N4 slice 1 — the hitl surface an adopting runner needs: the three doors, the
129
129
  // gate that reads a signer's answer (and refuses an empty one), and the seed
130
130
  // exemption ruling 8 states.
131
- HUMAN_DECISIONS, SEED_EXEMPT_KINDS, JUDGED_FLOOR_KIND, normalizeHumanRuling, resolveHumanRuling,
131
+ HUMAN_DECISIONS, SEED_EXEMPT_KINDS, normalizeHumanRuling, resolveHumanRuling,
132
132
  // SOFTGREEN module 2 — the judged stage's own arbiter-owned bounds. An adopter
133
133
  // wiring `judgeProvider` needs both numbers to reason about what a judged close
134
134
  // can cost: one paid call per artifact, at most one retry each.
@@ -136,7 +136,7 @@ export {
136
136
  // SOFTGREEN module 8 — the REVIEW DOOR's vocabulary: the record type a run
137
137
  // writes when it ends at the door, which terminals open one, which classes open
138
138
  // one unasked, and the mechanical-only rule an `accept` re-proves against.
139
- REVIEW_DOOR, DOOR_OPEN_OUTCOMES, REVIEW_DOOR_CLASSES, doorOpens, mechanicalStages,
139
+ DOOR_OPEN_OUTCOMES, REVIEW_DOOR_CLASSES, doorOpens, mechanicalStages,
140
140
  } from './kinds.js';
141
141
  // SOFTGREEN module 8 — the door's ANSWERING half. A run OPENS the door on its own
142
142
  // spine and ends; a person answers minutes or days later, from another process, so
@@ -144,7 +144,7 @@ export {
144
144
  // it re-proves the tree for an `accept`, releases a held judged green through
145
145
  // module 6's registry half, and refuses an expired door under the same 60-day TTL a
146
146
  // hitl checkpoint keeps. It never returns, writes or implies a verdict.
147
- export { answerReviewDoor, doorRecordOf, doorAgeGate } from './reviewdoor.js';
147
+ export { answerReviewDoor } from './reviewdoor.js';
148
148
  // SOFTGREEN module 1 — the judged floor's core. The pieces an adopter (and an
149
149
  // integrating UI) genuinely needs: `resolveJudge` (PRD item 32.1 — WHICH model
150
150
  // grades a job: the spec's signed `judge` override, else the job's own worker
@@ -157,13 +157,13 @@ export { answerReviewDoor, doorRecordOf, doorAgeGate } from './reviewdoor.js';
157
157
  // runner reaches it through `runPlan`'s `judgeProvider`, and it is exported so a
158
158
  // caller building its own harness does not spell a second one.
159
159
  export {
160
- JUDGE_MODEL, resolveJudge, JUDGE_MAX_TOKENS, JUDGE_RULES, JUDGE_RULE_IDS, LOCATE_AXES, LOCATE_LABEL,
161
- validateCard, validateFacts, locatePrompt, runLocate, decide, defaultJudgeLoop,
160
+ JUDGE_MODEL, resolveJudge, JUDGE_RULES, JUDGE_RULE_IDS,
161
+ runLocate, decide,
162
162
  // SOFTGREEN module 4 — what a legal SIGNED calibration set is. The SIZE is
163
163
  // hamr's own ruling and a size change is a spec-level threshold change;
164
164
  // `expectedOf` is the ONE reduction of a `decide()` result to the shape a case
165
165
  // stores, so a calibration harness compares one shape rather than two.
166
- CALIBRATION_SIZE, CASE_VERDICTS, validateCalibrationSet, validateJudgedArtifacts, expectedOf,
166
+ CALIBRATION_SIZE, validateCalibrationSet, expectedOf,
167
167
  } from './judged.js';
168
168
  // SOFTGREEN module 4 — the COMPILE. Q6 becomes the rubric card and Q7 becomes the
169
169
  // frozen calibration set, both on the D5 shown-and-fixed path: the LLM proposes
@@ -173,8 +173,6 @@ export {
173
173
  // line or a case is a re-sign like every other spec edit.
174
174
  export {
175
175
  proposeJudgedArtifacts, signJudgedArtifacts, foldJudgedArtifacts,
176
- proposalSchema, proposalTool, cardCasesPrompt,
177
- PROPOSAL_TOOL_NAME, PROPOSAL_LABEL, MAX_PROPOSAL_RETRIES, COMPILE_SYSTEM,
178
176
  } from './cardauthor.js';
179
177
  // SOFTGREEN module 5 — THE CALIBRATION GATE. `runCalibration` grades the whole
180
178
  // pipe (locate + decide) over the signed ten and the five arbiter-owned injection
@@ -185,22 +183,21 @@ export {
185
183
  // on trust. `INJECTION_LOCATE_BATTERY` and `INJECTION_CARD` are ARBITER-OWNED
186
184
  // constants: no signer authors them and nothing stores them in a spec.
187
185
  export {
188
- runCalibration, compareExpectation, factsResist, artifactHash,
189
- INJECTION_LOCATE_BATTERY, INJECTION_CARD, CALIBRATION_LABEL, CASUALTY_AXES,
186
+ runCalibration,
187
+ INJECTION_LOCATE_BATTERY, CASUALTY_AXES,
190
188
  } from './calibrate.js';
191
189
  // M2 — what a declaration may SAY, and whether one said it legally. The
192
190
  // catalogue and the genre are DATA an integrating UI renders (the kind menu, the
193
191
  // guard batteries it must show the user under D5 and cannot let them remove).
194
192
  export {
195
- validateDeclaration, normalizeDeclaration, KIND_CATALOGUE, CATALOGUE_KINDS, CATALOGUE_LIVE_KINDS,
196
- LOCKED_KINDS, TYPES_GENRE, TYPES_GENRE_TEMPLATE, GENRE_LANGUAGES, genreEnv, genreOwnedEnvNames,
193
+ validateDeclaration, KIND_CATALOGUE,
194
+ LOCKED_KINDS, genreEnv,
197
195
  // how the genre's own tools PRINT — genre property for the same reason the
198
196
  // suppression battery is (2026-08-09, run msmbpjk6)
199
197
  genreInstruments,
200
- DIRECTIONS, BASELINES, MAX_STAGES,
201
198
  // the verdict-class surface (PRD v1.57 §1–§2): the radio's own menu, the guard
202
199
  // battery keyed off it, and the ceiling that makes the pick a promise
203
- VERDICT_CLASSES, LOCKED_CLASSES, LIVE_CLASSES, CLASS_BATTERIES, classGuards, classMenu, closeCeiling,
200
+ LOCKED_CLASSES, CLASS_BATTERIES, classGuards, closeCeiling,
204
201
  // ruling 5 as data: the kinds no agent may ever be offered as an in-run ruler
205
202
  NEVER_OFFERED_KINDS,
206
203
  } from './authoring.js';
@@ -208,27 +205,26 @@ export {
208
205
  // loop; `runAuthorScout`/`buildSeedListing` are exported because a caller that
209
206
  // already paid for a survey passes it back in rather than buying a second one.
210
207
  export {
211
- runAuthorScout, buildSeedListing, seedFileList, classifySurvey, AUTHOR_SCOUT_VERBS,
208
+ runAuthorScout, buildSeedListing, AUTHOR_SCOUT_VERBS,
212
209
  // the malformed-class retry ladder: the hardcoded ceiling, the typed causes it
213
210
  // reads, and the closed set of causes that may fire it (2026-08-09, PRD v1.58)
214
- SCOUT_ATTEMPTS, SURVEY_CAUSES, SCOUT_RETRY_CAUSES,
211
+ SCOUT_ATTEMPTS, SCOUT_RETRY_CAUSES,
215
212
  } from './authorscout.js';
216
213
  /** the ONE scrubbed-persist boundary for a model's raw output, and its announced
217
214
  * bound — exported because every future writer of a raw must reach for this one
218
215
  * rather than spell a second scrub (PRD v1.58) */
219
- export { scrubRaw, RAW_PERSIST_MAX, RAW_TRIM_MARKER } from './text.js';
216
+ export { scrubRaw, RAW_PERSIST_MAX } from './text.js';
220
217
  export {
221
- authorClose, authorPrompt, declarationSchema, makeLoopGenerate, MAX_REVISIONS,
222
- QUESTION_SETS, GREEN_QUESTIONS, SOFTGREEN_QUESTIONS, CLASS_STATEMENTS, questionsFor, requiredAnswersFor,
218
+ authorClose, MAX_REVISIONS,
219
+ questionsFor, requiredAnswersFor,
223
220
  } from './authorflow.js';
224
221
  // M4a — the runtime bridge. `validateCloseDecl` is the spec-level gate (the job
225
222
  // validator's own branch calls it); `runDeclaredStages` is the executor seam a
226
223
  // runner other than `runPlan` would need; `closeStagesOf` is the ONE staging
227
224
  // every close consumer reads, widened to both fields.
228
225
  export {
229
- validateCloseDecl, runDeclaredStages, declaredStages, isDeclaredClose, guardNames, closeGrade,
230
- DECLARED_GENRES, DECLARED_GAP_PREFIX, DECLARED_GAP_KEEP, DECLARED_CLOSE_CLASSES, CLOSE_DECL_FIELDS,
231
- HUMAN_PAUSE, HITL_PAUSE, HITL_DECISION_RED, HUMAN_CHECKPOINTS,
226
+ validateCloseDecl, runDeclaredStages, declaredStages,
227
+ HUMAN_PAUSE,
232
228
  } from './declaredclose.js';
233
229
  export { closeStagesOf } from './plan.js';
234
230
  // Build-list item 2 (2026-08-23) — the run behaviour summary: a report-only
@@ -247,7 +243,7 @@ export { replayRun, formatReplay } from './replay.js';
247
243
  // never signs — the approvals array and the human's word are unchanged.
248
244
  export {
249
245
  runInterview, authorCloseForJob, assembleSpec, prepareSigning, refusalEvents, refuseLockedKind,
250
- GENRE, REFUSAL_LIB, REFUSAL_CATEGORY, AUTHORED_SPEC_FIELDS,
246
+ AUTHORED_SPEC_FIELDS,
251
247
  } from './authorjob.js';
252
248
  // PRD build-list item 5 (TODO #8) — the prompt-register inventory: every file
253
249
  // known to carry model-facing prompt/instruction text, and the predicate a
@@ -295,5 +291,5 @@ export { main as cliMain } from './cli.js';
295
291
  // module, not a re-derivation of it.
296
292
  export {
297
293
  prepareSource, proveDestination, copyOut, readSourceManifest, frontDoorFromManifest,
298
- datedDestination, pickDelivery,
294
+ datedDestination, pickDelivery, missingDependencies,
299
295
  } from './source.js';
package/src/job.js CHANGED
@@ -135,8 +135,9 @@ export const CADENCE_UNITS = Object.freeze(['hour', 'day', 'week']);
135
135
  * rows on the cost axis, and budgets sized for one do not transfer. */
136
136
  /** PRD item 28 (2026-09-06/09-09): `openai-api` — an OpenAI-shaped provider,
137
137
  * constructed via `src/providers.js`'s factory. hamr's ruling (PRD 30.7):
138
- * `deepseek-chat` is THE secondary provider (one, not a menu of half-tested
139
- * models); the factory's tier table is where that lives, not this menu.
138
+ * `deepseek-flash` is THE secondary provider (one, not a menu of half-tested
139
+ * models; F171, item 34 L16: swapped from the retired `deepseek-chat`); the
140
+ * factory's tier table is where that lives, not this menu.
140
141
  *
141
142
  * PRD item 31.3 (hamr, 2026-09-09: *"anthropic, openai, gemini drop ollama for
142
143
  * now"*): `gemini-api` joins. That is bareloop matching bare-agent's own
@@ -191,6 +192,42 @@ const SLUG_RE = /^[a-z0-9][a-z0-9-]*$/;
191
192
  * bareloop's OWN menu. Inferring it downstream is how a bareloop refusal gets
192
193
  * filed as an upstream bug (the BA-2 misattribution class). */
193
194
 
195
+ /**
196
+ * The `baseUrl` SHAPE rule, standalone (PRD item 28 ruling (d); PRD item 33
197
+ * close-out, "authoring provider selectable" — a bad `--base-url` used to
198
+ * refuse only at the end of the interview, through `validateJob`, after every
199
+ * question had already been asked). ONE spelling of the rule: `validateJob`
200
+ * below calls this, and so does `scripts/run-interview.mjs`, at flag-parse
201
+ * time, before it asks anything.
202
+ *
203
+ * No network access, so this checks SHAPE only: `https://` for a real
204
+ * endpoint, or `http://` restricted to a loopback host (a local
205
+ * OpenAI-compatible server, the only legitimate reason to skip TLS) — and
206
+ * never a credential embedded IN the URL itself. A URL travels into logs and
207
+ * spine records far more casually than a header ever would, and secrets never
208
+ * enter the tree/spine (hard line #3) — a baseUrl carrying `user:pass@host`
209
+ * is exactly that leak, just spelled differently. The returned message never
210
+ * echoes the value it judged — it is printed as-is by both callers, and a
211
+ * value that failed for carrying credentials must not repeat them back.
212
+ * @param {unknown} value
213
+ * @returns {string|null} a red `detail`, or `null` when the value is admitted
214
+ */
215
+ export function validateBaseUrl(value) {
216
+ if (!isNonEmptyString(value)) {
217
+ return 'non-empty string — an https:// URL (http:// admitted only to 127.0.0.1/localhost/::1)';
218
+ }
219
+ /** @type {URL|null} */
220
+ let u = null;
221
+ try { u = new URL(value); } catch { /* u stays null — caught below */ }
222
+ if (!u) return 'must be a parseable URL';
223
+ if (u.username !== '' || u.password !== '') return 'no embedded credentials (user:pass@host) — secrets never enter a signed spec';
224
+ if (u.protocol === 'https:') return null; // admitted
225
+ if (u.protocol === 'http:' && (u.hostname === '127.0.0.1' || u.hostname === 'localhost' || u.hostname === '::1')) {
226
+ return null; // admitted — a local OpenAI-compatible endpoint, the one case that legitimately skips TLS
227
+ }
228
+ return 'https:// required (http:// admitted only to 127.0.0.1/localhost/::1)';
229
+ }
230
+
194
231
  /**
195
232
  * Validate an operator-owned job spec (`schema: "job-v1"`). Never throws on
196
233
  * JSON text or plain parsed data — the ingest contract; a live object with a
@@ -328,33 +365,13 @@ export function validateJob(input, { shellCapUsd = 2 } = {}) {
328
365
 
329
366
  // BASE URL — PRD item 28, ruling (d), 2026-09-09: admitted in v1 of
330
367
  // openai-api (an OpenAI-compatible gateway/endpoint), OPTIONAL and part of
331
- // the signed hash like `model` above. This validator has no network
332
- // access, so it checks SHAPE only: `https://` for a real endpoint, or
333
- // `http://` restricted to a loopback host (a local OpenAI-compatible
334
- // server, the only legitimate reason to skip TLS) — and never a
335
- // credential embedded IN the URL itself. A URL travels into logs and
336
- // spine records far more casually than a header ever would, and secrets
337
- // never enter the tree/spine (hard line #3) — a bareUrl carrying
338
- // `user:pass@host` is exactly that leak, just spelled differently.
368
+ // the signed hash like `model` above. `validateBaseUrl` (below) is the ONE
369
+ // spelling of the shape rule — `scripts/run-interview.mjs` calls it too, at
370
+ // flag-parse time, before the interview asks a single question, rather than
371
+ // this file's copy being the only one that ever ran it.
339
372
  if (spec.baseUrl !== undefined) {
340
- if (!isNonEmptyString(spec.baseUrl)) {
341
- red('invalid-value', 'baseUrl', 'non-empty string — an https:// URL (http:// admitted only to 127.0.0.1/localhost/::1)');
342
- } else {
343
- /** @type {URL|null} */
344
- let u = null;
345
- try { u = new URL(spec.baseUrl); } catch { /* u stays null — caught below */ }
346
- if (!u) {
347
- red('invalid-value', 'baseUrl', 'must be a parseable URL');
348
- } else if (u.username !== '' || u.password !== '') {
349
- red('invalid-value', 'baseUrl', 'no embedded credentials (user:pass@host) — secrets never enter a signed spec');
350
- } else if (u.protocol === 'https:') {
351
- // admitted
352
- } else if (u.protocol === 'http:' && (u.hostname === '127.0.0.1' || u.hostname === 'localhost' || u.hostname === '::1')) {
353
- // admitted — a local OpenAI-compatible endpoint, the one case that legitimately skips TLS
354
- } else {
355
- red('invalid-value', 'baseUrl', 'https:// required (http:// admitted only to 127.0.0.1/localhost/::1)');
356
- }
357
- }
373
+ const err = validateBaseUrl(spec.baseUrl);
374
+ if (err) red('invalid-value', 'baseUrl', err);
358
375
  }
359
376
 
360
377
  // 3. the outer write fence — operator law (interview decision #4), same
package/src/planrun.js CHANGED
@@ -11,7 +11,14 @@
11
11
  // absolute repo root, its target, prior steps' artifacts labeled by id, its
12
12
  // gap, and a cut-off notice. It NEVER sees the budget, the close command, a
13
13
  // check's command, the validator, other steps' grants, or the arbiter's books
14
- // (fs.deny on the gate audit / .smoke / .litectx, unchanged).
14
+ // (fs.deny on the gate audit / .smoke / .litectx, unchanged). `.git` and
15
+ // `node_modules` are a DIFFERENT law (F178/F177): WRITES there are blocked
16
+ // (tools.denyArgPatterns on write/edit, FORBIDDEN_WRITE_SEGMENT_PATTERN,
17
+ // below), but READS are not — fs.deny blocks ALL fs actions including reads
18
+ // (bareguard node_modules/bareguard/src/primitives/fs.js:43,62-66), and
19
+ // hamr's ruling was to block WRITING, never reading; the worker's persona
20
+ // also never names `.git`/`node_modules` as denied books (tests/tools.test.js),
21
+ // which putting either in fs.deny would silently contradict.
15
22
 
16
23
  import { createRequire } from 'node:module';
17
24
  import { readFileSync, readdirSync } from 'node:fs';
@@ -465,7 +472,6 @@ const REPLAN_GAP_KEEP = '\\S';
465
472
  * No gap → the empty string, so the brief renders byte-identically to the pre-F86
466
473
  * one. A labelled empty section would be an invitation to explain an absence the
467
474
  * run never observed (a stall never judged its exits at all).
468
- *
469
475
  * @param {string | null | undefined} gap the step's last exit gap text
470
476
  * @returns {string} the labelled block, or '' when there is nothing to show
471
477
  */
@@ -480,6 +486,48 @@ export function closeGapBlock(gap) {
480
486
  + (scrubbed.length > CHECK_GAP_MAX ? boundGap(scrubbed, REPLAN_GAP_KEEP) : scrubbed);
481
487
  }
482
488
 
489
+ /**
490
+ * The runtime belt for F178 (`.git`) and F177's follow-up (`node_modules`) —
491
+ * hamr's option B ruling (2026-09-14): block WRITING to either, never reading.
492
+ * bareguard's `fs.deny` cannot be used for this: it blocks ALL fs actions,
493
+ * reads included (node_modules/bareguard/src/primitives/fs.js:43 "paths/
494
+ * prefixes denied for all fs actions", checked at :62-66 before the read/write
495
+ * split) — putting `.git` there (the first version of this fix) silently also
496
+ * blocked READING it, which was never ruled, and the worker's persona never
497
+ * names `.git`/`node_modules` as a denied book the way it does the gate audit
498
+ * / `.smoke` / `.litectx` (`tests/tools.test.js`'s persona/fence-drift guard
499
+ * caught exactly this in the full gate). `fs.deny` is exact prefix-containment
500
+ * only anyway — no glob, no path-segment match — confirmed by reading
501
+ * `within()` (node_modules/bareguard/src/primitives/fs.js:26-31): `p === b ||
502
+ * p.startsWith(b + '/')` against each configured entry, nothing else; it
503
+ * could not express `node_modules`-at-any-depth even if reads were not a
504
+ * problem (`node_modules/bareguard/src/glob.js`'s `matchAny`/`globToRegex`
505
+ * exist in the same package but `fs.js` never imports them).
506
+ *
507
+ * `tools.denyArgPatterns` (`node_modules/bareguard/src/primitives/tools.js`,
508
+ * step 3 of `Gate#_stepEval` — the SAME step `fsCheck` runs at, checked right
509
+ * after it in the `??` chain, `node_modules/bareguard/src/gate.js`) is the
510
+ * supported hook used instead: a RegExp tested against
511
+ * `JSON.stringify(action)`, keyed by `action.type` so it is scoped to
512
+ * `write`/`edit` ONLY — a `read` action never reaches this pattern at all.
513
+ * Because `fsCheck` runs first in the same `??` chain, this pattern is
514
+ * reached only when the path already passed `writeScope`/`deny` — exactly the
515
+ * residual case (an in-fence write/edit whose path still carries a `.git` or
516
+ * `node_modules` segment; `.git` itself is never IN a signed fence in the
517
+ * first place, `scopeContained` in src/validate.js refuses that at
518
+ * declaration time — this is the belt for a validator regression). Matched
519
+ * whole-segment (a look-alike like `.github`/`.gitignore`/`my.git`/
520
+ * `node_modules_util` stays admitted). Safe to match the WHOLE serialized
521
+ * action (not just the `path` field in isolation) because bareloop's own
522
+ * action shape (`toolAction`, src/tools.js) never carries a write's byte
523
+ * CONTENT in the action object — `args.bytes` is a length, not the text — so
524
+ * there is no written-file-content collision risk the way there would be for
525
+ * bareguard's own `content.denyPatterns` primitive (which explicitly strips
526
+ * payload fields for exactly that reason).
527
+ * @type {RegExp}
528
+ */
529
+ export const FORBIDDEN_WRITE_SEGMENT_PATTERN = /"path":"(?:(?:[^"\\]|\\.)*\/)?(?:\.git|node_modules)(?:\/|")/;
530
+
483
531
  /**
484
532
  * The recorded bound-reason's ceiling in the WORKER-facing note. The reason is a
485
533
  * provider string (`denied:<tool>` today, ~20 bytes), not a gap, so this is a
@@ -2250,6 +2298,13 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
2250
2298
  readScope: [workdir],
2251
2299
  deny: [auditPath, ...ARBITER_BOOK_STORES.map((s) => join(workdir, s))],
2252
2300
  },
2301
+ // F178/F177's runtime belt: `.git` and `node_modules` WRITES only — never
2302
+ // fs.deny, which blocks reads too (see FORBIDDEN_WRITE_SEGMENT_PATTERN's
2303
+ // own doc). Reached only when a write/edit path already cleared
2304
+ // fs.writeScope/deny above (step 3's fsCheck runs first in the SAME `??`
2305
+ // chain, node_modules/bareguard/src/gate.js) — i.e. only the residual
2306
+ // case of an in-fence path that still carries one of these segments.
2307
+ tools: { denyArgPatterns: { write: [FORBIDDEN_WRITE_SEGMENT_PATTERN], edit: [FORBIDDEN_WRITE_SEGMENT_PATTERN] } },
2253
2308
  budget: { maxCostUsd: Math.max(remainingUsd(), 0.0001) },
2254
2309
  limits: { maxTurns },
2255
2310
  audit: { path: auditPath },
@@ -2756,6 +2811,30 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
2756
2811
  err.lib = 'bare-agent';
2757
2812
  throw err;
2758
2813
  }
2814
+ // F184 — bare-agent 0.43.0 (BA-27) returns a malformed final-turn tool
2815
+ // call PRICED rather than throwing: `toolCalls: []`, `text: ''`,
2816
+ // `error: null`, and its own `malformedToolCall: {name, error}`
2817
+ // marker. Nothing below this point can tell that apart from the model
2818
+ // genuinely producing no useful work — `middle()` reads `r.text` into
2819
+ // `lastText`, which comes back empty either way, and the ordinary
2820
+ // `needs_revision`/`exit-eval` gap is what reaches the spine. That is
2821
+ // NOT changed here — no retry, no new cause on `attemptBounded`, no
2822
+ // effect on strike/ladder/attempt counting or verdict routing (all
2823
+ // arbiter-adjacent, none of this finding's scope). What was missing is
2824
+ // VISIBILITY: this is the one place bareloop code ever sees the
2825
+ // marker before `ask()` returns it unexamined, so a distinct spine
2826
+ // record names the transport class here — the same honesty bar
2827
+ // F179/F180 set for the authoring path, applied to the worker path.
2828
+ if (r.malformedToolCall) {
2829
+ const rawErr = String(r.malformedToolCall.error ?? '');
2830
+ const scrubbedErr = scrub(rawErr);
2831
+ const shownErr = scrubbedErr.length > BOUND_REASON_MAX
2832
+ ? `${scrubbedErr.slice(0, BOUND_REASON_MAX)} [${GAP_TRIM_MARKER} ${scrubbedErr.length - BOUND_REASON_MAX} of ${scrubbedErr.length} characters withheld — the cap is ${BOUND_REASON_MAX}]`
2833
+ : scrubbedErr;
2834
+ emit('worker-malformed-tool-call', {
2835
+ phase, iteration: roundIteration, name: scrub(String(r.malformedToolCall.name ?? '')), error: shownErr,
2836
+ });
2837
+ }
2759
2838
  return r;
2760
2839
  };
2761
2840
  return { ask, askFrom, workerWrites, writeCount, setIteration, wasBounded: () => attemptBounded };
@@ -45,6 +45,12 @@ export const PROMPT_REGISTERS = Object.freeze([
45
45
  Object.freeze({ file: 'src/authorflow.js', name: 'STRUCTURE_INSTRUCTION_TOOL' }),
46
46
  Object.freeze({ file: 'src/authorflow.js', name: 'STRUCTURE_INSTRUCTION_TEXT' }),
47
47
 
48
+ // src/authorflow.js — the confirm turn (PRD item 33 M3 piece 4)
49
+ Object.freeze({ file: 'src/authorflow.js', name: 'CONFIRM_SYSTEM' }),
50
+ Object.freeze({ file: 'src/authorflow.js', name: 'confirmPrompt() (inline template, not a const)' }),
51
+ Object.freeze({ file: 'src/authorflow.js', name: 'CONFIRM_STRUCTURE_INSTRUCTION' }),
52
+ Object.freeze({ file: 'src/authorflow.js', name: 'confirmedBlock() (inline template, not a const — feeds authorPrompt)' }),
53
+
48
54
  // src/judged.js — the softgreen judge's locate prompt (spine + per-rule asks)
49
55
  Object.freeze({ file: 'src/judged.js', name: 'PROMPT_HEAD' }),
50
56
  Object.freeze({ file: 'src/judged.js', name: 'PROMPT_TAIL' }),
@@ -70,6 +76,14 @@ export const PROMPT_REGISTERS = Object.freeze([
70
76
 
71
77
  // src/planrun.js — the native-surface read-truncation strategy line
72
78
  Object.freeze({ file: 'src/planrun.js', name: 'NATIVE_READ_STRATEGY' }),
79
+
80
+ // src/authoring.js — the types-genre close template, interpolated verbatim
81
+ // into the authoring prompt (src/authorflow.js's assembled system message,
82
+ // "THE GENRE TEMPLATE" block) — model-facing law for a types-genre
83
+ // declaration, not merely internal genre-table data. Found missing from
84
+ // this inventory 2026-09-15 (item 6 close-out); no other exported const or
85
+ // inline template literal in this file carries prompt/instruction text.
86
+ Object.freeze({ file: 'src/authoring.js', name: 'TYPES_GENRE_TEMPLATE' }),
73
87
  ]);
74
88
 
75
89
  /** The distinct file paths carrying prompt registers, repo-root-relative with