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/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,
|
|
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
|
|
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
|
|
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 {
|
|
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,
|
|
127
|
-
LIVE_KINDS, STOP_FAULTS, JUDGED_MARKER,
|
|
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,
|
|
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
|
-
|
|
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
|
|
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,
|
|
161
|
-
|
|
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,
|
|
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,
|
|
189
|
-
INJECTION_LOCATE_BATTERY,
|
|
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,
|
|
196
|
-
LOCKED_KINDS,
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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
|
|
216
|
+
export { scrubRaw, RAW_PERSIST_MAX } from './text.js';
|
|
220
217
|
export {
|
|
221
|
-
authorClose,
|
|
222
|
-
|
|
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,
|
|
230
|
-
|
|
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
|
-
|
|
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 {
|
|
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-
|
|
139
|
-
* models
|
|
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,
|
|
487
|
-
*
|
|
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
|
|
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
|
package/src/promptregisters.js
CHANGED
|
@@ -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-
|
|
51
|
-
haiku: 'deepseek-
|
|
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-
|
|
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
|
-
//
|
|
355
|
-
//
|
|
356
|
-
//
|
|
357
|
-
//
|
|
358
|
-
//
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
433
|
-
|
|
434
|
-
|
|
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
|
-
|
|
440
|
-
|
|
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
|
|
509
|
+
const repoRoot = /** @type {{dir: string}} */ (found).dir;
|
|
510
|
+
const walked = await listRepoFiles(repoRoot);
|
|
446
511
|
if (walked.stop !== null) return walked;
|
|
447
|
-
|
|
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
|
-
|
|
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
|
-
|
|
537
|
-
|
|
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,
|