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/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
@@ -293,4 +289,7 @@ export { main as cliMain } from './cli.js';
293
289
  // source.mjs` and `scripts/run-u.mjs` are two separate call sites reaching
294
290
  // for the SAME functions — an adopter building a third has to reach the same
295
291
  // module, not a re-derivation of it.
296
- export { prepareSource, proveDestination, copyOut, readSourceManifest, frontDoorFromManifest } from './source.js';
292
+ export {
293
+ prepareSource, proveDestination, copyOut, readSourceManifest, frontDoorFromManifest,
294
+ datedDestination, pickDelivery,
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
package/src/kinds.js CHANGED
@@ -483,12 +483,14 @@ async function sh(cmd, args, { cwd, env: declared = {}, timeoutMs = DEFAULT_TIME
483
483
  }
484
484
 
485
485
  /**
486
- * git, read-only, in a repository. Faults come back as `ok:false` with the
487
- * stderr — a caller decides whether that is a stop.
486
+ * git, in a repository (reads by every caller in this file; `src/source.js`
487
+ * also uses it for the hidden-git seed's `init`/`add`/`commit`). Faults come
488
+ * back as `ok:false` with the stderr — a caller decides whether that is a
489
+ * stop.
488
490
  *
489
491
  * Exported (PRD item 33/M2, `src/source.js`) so the hidden-git seed a plain
490
492
  * folder/file/URL job runs against goes through the SAME spawn primitive as
491
- * every other git read in this file — a second `spawn('git', …)` for the
493
+ * every other git call in this file — a second `spawn('git', …)` for the
492
494
  * source front door would be a second instrument with its own timeout and
493
495
  * buffer ceiling to keep in sync with this one.
494
496
  * @param {string} cwd @param {string[]} args
@@ -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' }),
package/src/providers.js CHANGED
@@ -45,10 +45,17 @@ export const ANTHROPIC_TIER_MODELS = Object.freeze({
45
45
  * of a single-model provider, not a second model masquerading as `haiku`.
46
46
  * If DeepSeek (or a future openai-api entrant) ever ships a genuinely
47
47
  * cheaper tier model, this is where it would earn its own `haiku` id.
48
+ *
49
+ * MODEL SWAP (F171, item 34 L16, hamr 2026-09-13): `deepseek-chat` is no
50
+ * longer served — a live `/models` call returned only `deepseek-flash` and
51
+ * `deepseek-v4-pro`. Switched to `deepseek-flash` (DeepSeek V4.1). F149's
52
+ * `legacyMaxTokens` measurement (asked 64 output tokens, got 665/608) was
53
+ * taken on `deepseek-chat` and is UNVERIFIED on `deepseek-flash` — kept on
54
+ * below as the fail-safe (over-capping direction is safe; see F171).
48
55
  */
49
56
  export const OPENAI_TIER_MODELS = Object.freeze({
50
- sonnet: 'deepseek-chat',
51
- haiku: 'deepseek-chat',
57
+ sonnet: 'deepseek-flash',
58
+ haiku: 'deepseek-flash',
52
59
  });
53
60
 
54
61
  /**
@@ -97,9 +104,14 @@ export const PROBE_STATUS = Object.freeze({
97
104
  *
98
105
  * A model with no entry here gets `{}` — bare-agent's own default
99
106
  * (`max_completion_tokens`, GPT-5-safe) applies, unmodified.
107
+ *
108
+ * F171 (2026-09-13): `deepseek-chat` was retired and swapped for
109
+ * `deepseek-flash` (item 34 L16). This gating was measured on
110
+ * `deepseek-chat` only and carried forward onto `deepseek-flash`
111
+ * UNVERIFIED — re-verify before any paid DeepSeek run relies on it.
100
112
  */
101
113
  const OPENAI_MODEL_OPTIONS = Object.freeze({
102
- 'deepseek-chat': Object.freeze({ legacyMaxTokens: true }),
114
+ 'deepseek-flash': Object.freeze({ legacyMaxTokens: true }),
103
115
  });
104
116
 
105
117
  /**
package/src/source.js CHANGED
@@ -23,9 +23,9 @@
23
23
  // on rather than catch.
24
24
 
25
25
  import { createHash } from 'node:crypto';
26
- import { existsSync } from 'node:fs';
27
- import { mkdir, readdir, readFile, writeFile, lstat, stat, access, copyFile, cp, open, rm, realpath, readlink, symlink, constants as fsConstants } from 'node:fs/promises';
28
- import { dirname, join, resolve, sep, basename } from 'node:path';
26
+ import { existsSync, lstatSync } from 'node:fs';
27
+ import { mkdir, readdir, readFile, writeFile, lstat, stat, chmod, access, copyFile, cp, open, rm, realpath, readlink, symlink, constants as fsConstants } from 'node:fs/promises';
28
+ import { dirname, join, relative, resolve, sep, basename } from 'node:path';
29
29
  import { git } from './kinds.js';
30
30
  import { MAX_BUFFER } from './kinds.js';
31
31
  import { PROVIDER_TIMEOUT_MS } from './clock.js';
@@ -76,6 +76,69 @@ const MAX_SAME_DAY = 99;
76
76
  /** @param {string} code @param {string} stop @returns {SourceRefusal} */
77
77
  const refuse = (code, stop) => ({ stop, code });
78
78
 
79
+ /**
80
+ * Walk from `startDir` upward — inclusive — to the NEAREST ancestor whose own
81
+ * directory carries a `.git` entry, whether that entry is a real repo
82
+ * directory or a linked-worktree/submodule FILE (the caller decides what a
83
+ * file means for it). `null` when no ancestor up to the filesystem root has
84
+ * one. ONE helper for "find the repo boundary walking up", shared by
85
+ * {@link looksLikeRepoSource} and `prepareSource`'s own authoritative routing
86
+ * below (PRD item 33 M3 ruling 2 addendum, 2026-09-13: a Source that is a
87
+ * SUBFOLDER inside a git repo is a repo job, not a plain-folder one — the
88
+ * boundary is the nearest ancestor's `.git`, never only a `.git` sitting
89
+ * directly inside Source itself) and reused by `src/detectlang.js`'s own
90
+ * upward walk (`walkChain`), which only needs the boundary directory, not
91
+ * which kind of `.git` it is — never three copies of this rule.
92
+ * @param {string} startDir an absolute directory to start the walk FROM
93
+ * @returns {{dir: string, isFile: boolean}|null}
94
+ */
95
+ export function nearestGitAncestor(startDir) {
96
+ let dir = resolve(startDir);
97
+ for (;;) {
98
+ const dotGit = join(dir, '.git');
99
+ if (existsSync(dotGit)) {
100
+ let isFile = false;
101
+ try { isFile = !lstatSync(dotGit).isDirectory(); } catch { /* raced away between exists and lstat — treated as a directory below, the pre-existing walk's own behaviour */ }
102
+ return { dir, isFile };
103
+ }
104
+ const parent = dirname(dir);
105
+ if (parent === dir) return null; // reached the filesystem root
106
+ dir = parent;
107
+ }
108
+ }
109
+
110
+ /**
111
+ * A CHEAP peek (a stat, never a read) at whether `source` is going to turn
112
+ * out to be a REPO source — the ONE rule for that question, shared by
113
+ * `prepareSource` (which routes the destination check below before the real
114
+ * walk further down decides authoritatively) and `scripts/run-interview.mjs`
115
+ * (PRD item 33 M3 ruling 2), which has to know BEFORE Source is even frozen
116
+ * whether Destination fills the write fence or a delivery directory. Never a
117
+ * second, hand-typed copy of this test: a URL is never a repo; a local path
118
+ * is a repo source only when it is a DIRECTORY, and the NEAREST ancestor
119
+ * (Source itself, or an ancestor above it — ruling 2's 2026-09-13 addendum)
120
+ * carries a `.git` DIRECTORY. A nearest `.git` that is a FILE (a linked
121
+ * worktree or a submodule) is NOT repo-like here — the real walk below
122
+ * refuses `source-is-linked-worktree` for that case, and this peek must
123
+ * agree closely enough to route the destination question correctly rather
124
+ * than offer the write-fence wording for a source that is about to be
125
+ * refused. An unreadable path is reported properly by the real walk; this
126
+ * peek only has to agree with that walk closely enough to route correctly,
127
+ * never to replace it.
128
+ * @param {string} source
129
+ * @returns {boolean}
130
+ */
131
+ export function looksLikeRepoSource(source) {
132
+ if (/^https?:\/\//i.test(source)) return false;
133
+ try {
134
+ const abs = resolve(source);
135
+ const st = lstatSync(abs);
136
+ if (!st.isDirectory()) return false;
137
+ const found = nearestGitAncestor(abs);
138
+ return found !== null && !found.isFile;
139
+ } catch { return false; }
140
+ }
141
+
79
142
  /** @param {Buffer} buf @returns {boolean} */
80
143
  function hasNulByte(buf) {
81
144
  // "first 8 KB" (PRD/M2 text) — a NUL anywhere further in a huge log-shaped
@@ -351,22 +414,14 @@ export async function prepareSource({ source, into, destination, fetchTimeoutMs
351
414
  return refuse('into-exists', `${intoAbs} already exists — a source door writes a FRESH tree, never reuses one (the export worktree rule)`);
352
415
  }
353
416
  const isUrl = /^https?:\/\//i.test(source);
354
- // a CHEAP peek (a stat, never a read) at whether this source is going to
355
- // turn out to be a repo — needed here, before the source is actually
356
- // walked below, because a repo source's destination takes a DIFFERENT path
357
- // (the write-fence recording above) than every other kind's (the directory
358
- // proof). The real walk below makes the authoritative kind determination
359
- // (and refuses source-is-linked-worktree when `.git` is a file); this peek
417
+ // needed here, before the source is actually walked below, because a repo
418
+ // source's destination takes a DIFFERENT path (the write-fence recording
419
+ // above) than every other kind's (the directory proof). The real walk
420
+ // below makes the authoritative kind determination (and refuses
421
+ // source-is-linked-worktree when `.git` is a file); `looksLikeRepoSource`
360
422
  // only has to agree with it closely enough to route the destination check
361
423
  // correctly, and it does — both read the same `.git` existence test.
362
- let looksLikeRepoSource = false;
363
- if (!isUrl) {
364
- try {
365
- const peekAbs = resolve(source);
366
- const peekStat = await lstat(peekAbs);
367
- looksLikeRepoSource = peekStat.isDirectory() && existsSync(join(peekAbs, '.git'));
368
- } catch { /* an unreadable source is reported properly by the real walk below */ }
369
- }
424
+ const isRepoLike = looksLikeRepoSource(source);
370
425
  // hamr's ruling (PRD item 33): source AND destination are proven at job
371
426
  // start, $0 — and review finding #2 sharpens WHEN: before `into` is
372
427
  // created, never after, so a refused destination leaves nothing on disk
@@ -374,7 +429,7 @@ export async function prepareSource({ source, into, destination, fetchTimeoutMs
374
429
  // its result). Order matters here: this runs before any source is read,
375
430
  // fetched, or walked.
376
431
  if (destination !== undefined) {
377
- if (looksLikeRepoSource) {
432
+ if (isRepoLike) {
378
433
  if (typeof destination !== 'string' || destination.length === 0) {
379
434
  return refuse('destination-invalid', 'destination must be a non-empty value — a repo source records it as the declared write fence, wired into writeScope in a later milestone');
380
435
  }
@@ -384,7 +439,7 @@ export async function prepareSource({ source, into, destination, fetchTimeoutMs
384
439
  }
385
440
  }
386
441
 
387
- /** @type {{kind: 'url'|'file'|'folder'|'repo', files: {rel: string, abs?: string, buf?: Buffer, symlinkTarget?: string}[], root?: string, finalUrl?: string}} */
442
+ /** @type {{kind: 'url'|'file'|'folder'|'repo', files: {rel: string, abs?: string, buf?: Buffer, symlinkTarget?: string}[], root?: string, finalUrl?: string, sourceSubdir?: string}} */
388
443
  let frozen;
389
444
 
390
445
  if (isUrl) {
@@ -429,22 +484,54 @@ export async function prepareSource({ source, into, destination, fetchTimeoutMs
429
484
  // patients-are-copies rule), and gets the same `output/`, manifest,
430
485
  // destination and copy-out every other source gets. Its own guards
431
486
  // (the input stays untouched) arrive with M4.
432
- const dotGit = join(sourceAbs, '.git');
433
- let isRepo = false;
434
- if (existsSync(dotGit)) {
487
+ //
488
+ // RULING 2 ADDENDUM (hamr, 2026-09-13, option A): a Source that is a
489
+ // SUBFOLDER inside a git repo IS a repo job — the boundary is the
490
+ // NEAREST ancestor's `.git`, found by walking UP from Source, never
491
+ // only a `.git` sitting directly inside Source itself (a monorepo
492
+ // package like `myrepo/packages/api` used to fall to the plain-folder
493
+ // path below, which has no checks yet and would hit untracked
494
+ // `node_modules` symlinks as a refusal, F164's class). The WHOLE
495
+ // repo's tracked files are frozen — `listRepoFiles(repoRoot)`, not just
496
+ // the subfolder — exactly as for a repo-ROOT source today.
497
+ const found = nearestGitAncestor(sourceAbs);
498
+ if (found !== null && found.isFile) {
435
499
  // a `.git` FILE (a linked worktree or a submodule) points its gitdir
436
500
  // somewhere else entirely — copying it would aim every git command,
437
501
  // the seed commit included, at the ORIGINAL repo. Refused, never
438
- // followed, for the same reason a symlink is.
439
- if (!(await lstat(dotGit)).isDirectory()) {
440
- return refuse('source-is-linked-worktree', `${source}/.git is a file, not a directory — this is a linked worktree or a submodule, whose real git directory lives elsewhere; point the door at the repo itself (a copy would silently commit into the original)`);
441
- }
442
- isRepo = true;
502
+ // followed, for the same reason a symlink is. `found.dir` is the
503
+ // ancestor that actually carries the file — Source itself when
504
+ // Source IS that ancestor, an ancestor ABOVE Source otherwise.
505
+ return refuse('source-is-linked-worktree', `${found.dir}/.git is a file, not a directory — this is a linked worktree or a submodule, whose real git directory lives elsewhere; point the door at the repo itself (a copy would silently commit into the original)`);
443
506
  }
507
+ const isRepo = found !== null;
444
508
  if (isRepo) {
445
- const walked = await listRepoFiles(sourceAbs);
509
+ const repoRoot = /** @type {{dir: string}} */ (found).dir;
510
+ const walked = await listRepoFiles(repoRoot);
446
511
  if (walked.stop !== null) return walked;
447
- frozen = { kind: 'repo', root: sourceAbs, files: walked.files };
512
+ // Source's own path relative to the repo root — '' when Source IS
513
+ // the repo root, a real subpath (e.g. "packages/api") when it is a
514
+ // subfolder. Recorded in the manifest (below) for later use; nothing
515
+ // downstream reads it yet — the fence/scope wiring is a later piece.
516
+ const sourceSubdir = relative(repoRoot, sourceAbs).split(sep).join('/');
517
+ // hamr's ruling A (PRD item 34 loose end, 2026-09-13, live-proven at
518
+ // $0): the ruling-2 addendum above freezes the WHOLE repo whenever
519
+ // Source is a subfolder, but `listRepoFiles` only ever returns
520
+ // TRACKED paths — when the subfolder itself is gitignored or simply
521
+ // untracked, NONE of its own files are among them, and the freeze
522
+ // silently succeeds with a `kind: 'repo'` tree that holds nothing the
523
+ // person actually pointed Source at. Refused here, before `into` is
524
+ // created and before a single byte is scanned or written — a repo
525
+ // ROOT source (`sourceSubdir === ''`) is exempt (it always has the
526
+ // whole tracked tree by definition), and a subfolder with AT LEAST
527
+ // ONE tracked file underneath it is unaffected (that is the existing,
528
+ // unchanged "partially-ignored files inside a tracked folder" case).
529
+ if (sourceSubdir !== '' && !walked.files.some((f) => f.rel === sourceSubdir || f.rel.startsWith(`${sourceSubdir}/`))) {
530
+ return refuse('source-untracked-in-repo', `${sourceAbs} is inside the git repo at ${repoRoot}, but git does not track anything in it, so a repo job would not see its files — commit the files, or point Source at a folder outside the repo`);
531
+ }
532
+ frozen = {
533
+ kind: 'repo', root: repoRoot, files: walked.files, sourceSubdir,
534
+ };
448
535
  } else {
449
536
  const walked = await walkFolder(sourceAbs);
450
537
  if (walked.stop !== null) return walked;
@@ -529,12 +616,38 @@ export async function prepareSource({ source, into, destination, fetchTimeoutMs
529
616
  // git tracks no empty directories — a `.gitkeep` is what makes `output/`
530
617
  // actually exist in the seed commit the close measures changes against
531
618
  await writeFile(join(outputDir, '.gitkeep'), '');
532
- for (const f of frozen.files) {
619
+ // TOCTOU close (item 34 L1, fix-ledger `src/source.js:506,537`): a folder/
620
+ // repo file's bytes were hashed and secret-scanned ABOVE, from one read —
621
+ // then re-read a SECOND time here via `copyFile`, off disk, to freeze into
622
+ // the tree. A file swapped on disk between the two reads would have its
623
+ // secret scan run against bytes different from the ones actually frozen —
624
+ // exactly the gap the hard line (secrets never enter the tree) exists to
625
+ // close. Fixed by re-verifying, not by caching every buffer: caching would
626
+ // hold a whole repo's bytes in memory at once (the streaming design above
627
+ // this loop exists precisely to avoid that for a repo source "far larger
628
+ // than memory"). Instead, this second read is hashed again and compared
629
+ // against `fileMeta`'s scan-time `sha256` (same index, same file order as
630
+ // the scan loop above) — a match means the bytes frozen ARE the bytes
631
+ // scanned; a mismatch refuses rather than silently freezing content the
632
+ // scan never saw. `copyFile` also preserved the source file's mode bits
633
+ // (verified: 0o755 in, 0o755 out; a plain `writeFile` defaults to 0o644),
634
+ // so the replacement chmod's the destination from a fresh `stat` to keep
635
+ // that behaviour unchanged.
636
+ for (let i = 0; i < frozen.files.length; i += 1) {
637
+ const f = frozen.files[i];
533
638
  const dest = join(treeDir, treeRel(f.rel));
534
639
  await mkdir(dirname(dest), { recursive: true });
535
- if (f.buf) await writeFile(dest, f.buf);
536
- else if (f.symlinkTarget !== undefined) await symlink(f.symlinkTarget, dest);
537
- else await copyFile(/** @type {string} */ (f.abs), dest);
640
+ if (f.buf) { await writeFile(dest, f.buf); continue; }
641
+ if (f.symlinkTarget !== undefined) { await symlink(f.symlinkTarget, dest); continue; }
642
+ const [freshBuf, srcStat] = await Promise.all([
643
+ readFile(/** @type {string} */ (f.abs)),
644
+ stat(/** @type {string} */ (f.abs)),
645
+ ]);
646
+ if (sha256Hex(freshBuf) !== fileMeta[i].sha256) {
647
+ return refuse('source-changed-after-scan', `${f.rel} changed on disk after its secret scan — refused rather than freezing bytes the scan never saw (hard line #3)`);
648
+ }
649
+ await writeFile(dest, freshBuf);
650
+ await chmod(dest, srcStat.mode & 0o777);
538
651
  }
539
652
 
540
653
  if (frozen.kind !== 'repo') {
@@ -580,6 +693,13 @@ export async function prepareSource({ source, into, destination, fetchTimeoutMs
580
693
  const manifest = {
581
694
  kind: frozen.kind,
582
695
  source,
696
+ // Source's own path relative to the repo root (ruling 2 addendum,
697
+ // 2026-09-13) — '' when Source IS the repo root, a real subpath (e.g.
698
+ // "packages/api") when it is a subfolder frozen as part of the WHOLE
699
+ // repo. A NEW field beside the existing ones: `source` above keeps its
700
+ // existing meaning (the original Source path, untouched), and nothing
701
+ // downstream reads `sourceSubdir` yet — recorded here, wired later.
702
+ ...(frozen.sourceSubdir === undefined ? {} : { sourceSubdir: frozen.sourceSubdir }),
583
703
  ...(frozen.finalUrl === undefined ? {} : { finalUrl: frozen.finalUrl }),
584
704
  fetchedAt: new Date().toISOString(),
585
705
  files: fileMeta,