instar 1.3.832 → 1.3.833

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,1472 @@
1
+ // safe-git-allow: duplicate-build-guard check library — READ-ONLY git only
2
+ // (rev-parse / log / grep / ls-tree / branch / fetch), every call an
3
+ // execFileSync ARGV ARRAY (never a shell string), untrusted values
4
+ // type-validated + passed after `--` per docs/specs/duplicate-build-guard.md
5
+ // §3.2a. Never a destructive git verb.
6
+
7
+ /**
8
+ * duplicate-build-check.mjs — the duplicate-build guard's deterministic,
9
+ * fail-open, security-hardened check library.
10
+ *
11
+ * Spec: docs/specs/duplicate-build-guard.md (converged + approved, ACT-592).
12
+ * Principle: docs/signal-vs-authority.md — this module only ever produces
13
+ * SIGNALS (a verdict + evidence). The proceed/abandon authority is the human
14
+ * author's recorded disposition; the only blocking surfaces are the
15
+ * build-start PreToolUse gate (which enforces that a disposition EXISTS) and
16
+ * the precommit presence-only backstop.
17
+ *
18
+ * The check asks three "is anyone else on this right now?" questions (§3.2):
19
+ * 1. Local sibling in-flight ledger (<agent-home>/state/dup-build-inflight.jsonl)
20
+ * 2. Open PRs (bounded two-stage `gh` scan; CI-skipped)
21
+ * 3. Recently-merged lookback (git log origin/main --since=…)
22
+ * plus a WEAK `main`-state corroborator (git grep — never a block source) and
23
+ * a WEAK changed-file overlap (reusing scripts/lib/pre-push-scope.mjs).
24
+ *
25
+ * TOTAL verdict ladder (§3.3, FD4):
26
+ * likely-duplicate ⇔ a STRONG-exact target matches a CONCURRENCY source.
27
+ * verify ⇔ fuzzy-only hit (cause `fuzzy`) OR strong target only on
28
+ * main-state (cause `main-only`) OR a degraded scan on a
29
+ * substrate-introducing spec (cause `degraded`).
30
+ * clear ⇔ no overlap + all concurrency sources scanned; a degraded
31
+ * NON-substrate scan is `clear` with a degraded audit flag.
32
+ * WEAK-only overlap → silent `clear` + a quiet note (never `verify`).
33
+ * Real-overlap causes (`fuzzy`, `main-only`) OUTRANK the environmental
34
+ * `degraded` tag; `causes[]` carries the full set for the audit trail.
35
+ *
36
+ * FAIL-OPEN TOTALITY (§3.3, FD5): any internal error → a non-blocking
37
+ * `check-errored` verdict; the CLI ALWAYS exits 0. Per-subprocess timeouts
38
+ * git ≤3s / gh ≤5s, total budget ≤8s.
39
+ *
40
+ * Off-switch: INSTAR_DUP_BUILD_CHECK=off (or instarDev.duplicateBuildGuard
41
+ * disabled in a reachable .instar/config.json) → total no-op (`skipped`).
42
+ *
43
+ * CLI:
44
+ * node scripts/lib/duplicate-build-check.mjs <specPath> [--json] [--root <p>]
45
+ * [--phase build-start|pre-push] [--agent-home <p>]
46
+ * node scripts/lib/duplicate-build-check.mjs --record-disposition \
47
+ * --decision proceed|abandon --reason "…" [--ack EV-1,EV-2] [--root <p>]
48
+ * node scripts/lib/duplicate-build-check.mjs --remove-marker [--root <p>] [--agent-home <p>]
49
+ *
50
+ * State files (worktree-local, gitignored):
51
+ * .instar/dup-build-check.json — the verdict stub + recorded disposition
52
+ * .instar/dup-build-cache.json — the (specSlug, mainSha, prListHash) cache
53
+ * logs/dup-build-check.jsonl — the append-only audit trail (metadata only)
54
+ * Agent-home state:
55
+ * <agent-home>/state/dup-build-inflight.jsonl — the sibling in-flight ledger (0600)
56
+ */
57
+
58
+ import fs from 'node:fs';
59
+ import os from 'node:os';
60
+ import path from 'node:path';
61
+ import crypto from 'node:crypto';
62
+ import { execFileSync, spawnSync } from 'node:child_process';
63
+ import { fileURLToPath } from 'node:url';
64
+ import { resolvePrePushBase, changedFilesSince } from './pre-push-scope.mjs';
65
+
66
+ const __filename = fileURLToPath(import.meta.url);
67
+ const DEFAULT_ROOT = path.resolve(path.dirname(__filename), '..', '..');
68
+
69
+ // ── Tunables (spec §3.1/§3.3 — bounds enforced BEFORE similarity math) ──────
70
+
71
+ /**
72
+ * FD6 — the token-set-Jaccard fuzzy floor. A frontloaded CONSERVATIVE constant
73
+ * tuned toward recall (a fuzzy-only hit only ever yields `verify`, the cheap
74
+ * verdict). CALIBRATED against the committed corpus at
75
+ * tests/fixtures/dup-build-calibration.json (tests/unit/duplicate-build-check.test.ts
76
+ * asserts recall on the known-duplicate pairs and a precision floor on the
77
+ * known-non-duplicate pairs at exactly this constant). An evidence-set
78
+ * constant, not a guess. Measured corpus scores at calibration time:
79
+ * duplicates 0.263–0.667 (min: the rename+rewrite pair), non-duplicates
80
+ * 0.000–0.111 (max: secret-sync vs secret-drop) — 0.22 sits below every
81
+ * known dup with a ~2x margin over the worst known non-dup.
82
+ */
83
+ export const JACCARD_THRESHOLD = 0.22;
84
+
85
+ export const MAX_TARGETS = 20;
86
+ export const PR_BODY_CAP_BYTES = 8 * 1024; // §3.1: each PR body ≤8KB BEFORE similarity
87
+ export const SPEC_SECTION_CAP_BYTES = 4 * 1024; // §3.1: each spec section ≤4KB
88
+ export const GIT_TIMEOUT_MS = 3_000; // §3.3 per-subprocess: git ≤3s
89
+ export const GH_TIMEOUT_MS = 5_000; // §3.3 per-subprocess: gh ≤5s
90
+ export const TOTAL_BUDGET_MS = 8_000; // §3.3 total budget
91
+ export const OPEN_PR_LIMIT = 100; // §3.2: gh pr list --limit
92
+ export const PR_DIFF_STAGE2_MAX = 5; // §3.2: gh pr diff on ≤5 matched PRs only
93
+ export const PR_DIFF_CAP_BYTES = 512 * 1024; // §3.2: per-PR diff-size cap
94
+ export const MERGE_LOOKBACK_MAX_COMMITS = 500; // §3.2: -n 500
95
+ export const MERGE_LOOKBACK_FLOOR_DAYS = 30; // §3.2: 30d floor
96
+ export const GREP_MAX_MATCHES = 50; // §3.2a: git grep max-match cap
97
+ export const LINE_CLAMP_CHARS = 300; // §3.2a: per-match / evidence line clamp
98
+ export const LEDGER_COMPACT_BYTES = 256 * 1024; // §3.2: read-time compaction threshold
99
+ export const CENSUS_FILE = 'src/data/provenanceCoverage.ts'; // pinned census artifact path (§4)
100
+
101
+ export const STUB_REL_PATH = path.join('.instar', 'dup-build-check.json');
102
+ export const MARKER_REL_PATH = path.join('.instar', 'dup-build-gate.marker.json');
103
+ export const CACHE_REL_PATH = path.join('.instar', 'dup-build-cache.json');
104
+ export const AUDIT_REL_PATH = path.join('logs', 'dup-build-check.jsonl');
105
+ export const LEDGER_REL_PATH = path.join('state', 'dup-build-inflight.jsonl');
106
+
107
+ // ── Rollout flag (§5) ────────────────────────────────────────────────────────
108
+
109
+ /** INSTAR_DUP_BUILD_CHECK=off|0|false → the whole guard no-ops. */
110
+ export function isGuardOff(env = process.env, root = DEFAULT_ROOT) {
111
+ const v = String(env.INSTAR_DUP_BUILD_CHECK ?? '').toLowerCase();
112
+ if (v === 'off' || v === '0' || v === 'false') return true;
113
+ // Best-effort config read (instarDev.duplicateBuildGuard) — the gates run
114
+ // pre-compile so this is a plain JSON probe, fail-open on any error.
115
+ try {
116
+ const cfgPath = path.join(root, '.instar', 'config.json');
117
+ if (fs.existsSync(cfgPath)) {
118
+ const cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf8'));
119
+ const g = cfg && cfg.instarDev && cfg.instarDev.duplicateBuildGuard;
120
+ if (g === false) return true;
121
+ if (g && typeof g === 'object' && g.enabled === false) return true;
122
+ }
123
+ } catch {
124
+ /* unreadable config → default-on per §5 */
125
+ }
126
+ return false;
127
+ }
128
+
129
+ /** The precommit backstop only REFUSES when the guard is explicitly live. */
130
+ export function isGuardExplicitlyOn(env = process.env) {
131
+ const v = String(env.INSTAR_DUP_BUILD_CHECK ?? '').toLowerCase();
132
+ return v === 'on' || v === '1' || v === 'true';
133
+ }
134
+
135
+ // ── §3.2a type validators (option-injection defense) ────────────────────────
136
+ // Validation is BY TYPE for the value's argument position — NOT a blanket
137
+ // leading-`-` reject (which would wrongly refuse valid slugs/paths). A value
138
+ // that fails its type check is DROPPED from the scan, never shelled. Every
139
+ // untrusted value is additionally passed after a `--` end-of-options separator
140
+ // where the git/gh grammar allows one.
141
+
142
+ const TOKEN_RE = /^[A-Za-z0-9_.:/-]{2,128}$/;
143
+
144
+ export function isValidToken(v) {
145
+ return typeof v === 'string' && TOKEN_RE.test(v) && !v.startsWith('-');
146
+ }
147
+
148
+ export function isValidRepoRelPath(p) {
149
+ if (typeof p !== 'string' || p.length < 2 || p.length > 256) return false;
150
+ if (path.isAbsolute(p)) return false;
151
+ if (p.includes('\\') || p.includes('\0')) return false;
152
+ const parts = p.split('/');
153
+ if (parts.some((seg) => seg === '' || seg === '.' || seg === '..')) return false;
154
+ return /^[A-Za-z0-9_.@/-]+$/.test(p);
155
+ }
156
+
157
+ export function isValidPrNumber(n) {
158
+ return Number.isInteger(n) && n > 0 && n < 10_000_000;
159
+ }
160
+
161
+ export function isValidRef(r) {
162
+ if (typeof r !== 'string' || r.length < 1 || r.length > 200) return false;
163
+ if (r.startsWith('-') || r.startsWith('.') || r.endsWith('.') || r.endsWith('/')) return false;
164
+ if (r.includes('..') || r.includes('@{') || r.includes('//')) return false;
165
+ return /^[A-Za-z0-9][A-Za-z0-9._/-]*$/.test(r);
166
+ }
167
+
168
+ /** Strip control/escape chars + clamp length — every untrusted string passes
169
+ * through here before entering evidence, a log line, or the agent surface. */
170
+ export function clampUntrusted(s, max = LINE_CLAMP_CHARS) {
171
+ return String(s ?? '')
172
+ // eslint-disable-next-line no-control-regex
173
+ .replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, '')
174
+ .replace(/\n/g, ' ')
175
+ .slice(0, max);
176
+ }
177
+
178
+ /** Byte-cap a string (enforced BEFORE any similarity math — §3.1/FD6). */
179
+ export function capBytes(s, capBytesN) {
180
+ const str = String(s ?? '');
181
+ const buf = Buffer.from(str, 'utf8');
182
+ if (buf.length <= capBytesN) return str;
183
+ return buf.subarray(0, capBytesN).toString('utf8');
184
+ }
185
+
186
+ // ── FD6 fuzzy floor: normalization + token-set Jaccard ──────────────────────
187
+
188
+ const STOPWORDS = new Set([
189
+ 'the', 'and', 'for', 'that', 'this', 'with', 'from', 'into', 'onto', 'over',
190
+ 'under', 'are', 'was', 'were', 'will', 'must', 'can', 'may', 'has', 'have',
191
+ 'had', 'its', 'not', 'but', 'when', 'then', 'than', 'per', 'via', 'each',
192
+ 'only', 'never', 'ever', 'also', 'out', 'all', 'any', 'one', 'two', 'you',
193
+ 'your', 'our', 'their', 'they', 'them', 'his', 'her', 'she', 'him', 'who',
194
+ 'what', 'which', 'where', 'how', 'why', 'does', 'did', 'been', 'being',
195
+ 'would', 'could', 'should', 'here', 'there', 'these', 'those', 'such',
196
+ 'more', 'most', 'less', 'least', 'very', 'just', 'both', 'same', 'other',
197
+ 'about', 'before', 'after', 'because', 'between', 'against', 'without',
198
+ 'within', 'itself', 'every', 'spec', 'specs',
199
+ ]);
200
+
201
+ function stem(t) {
202
+ if (t.length > 6 && t.endsWith('ing')) return t.slice(0, -3);
203
+ if (t.length > 5 && t.endsWith('ed')) return t.slice(0, -2);
204
+ if (t.length > 4 && t.endsWith('es')) return t.slice(0, -2);
205
+ if (t.length > 3 && t.endsWith('s') && !t.endsWith('ss')) return t.slice(0, -1);
206
+ return t;
207
+ }
208
+
209
+ /** Lowercase → split on non-alphanumerics → drop short/stopword tokens → light stem. */
210
+ export function normalizeTokens(text) {
211
+ const out = new Set();
212
+ for (const raw of String(text ?? '').toLowerCase().split(/[^a-z0-9]+/)) {
213
+ if (raw.length < 3) continue;
214
+ if (STOPWORDS.has(raw)) continue;
215
+ out.add(stem(raw));
216
+ }
217
+ return out;
218
+ }
219
+
220
+ /** Deterministic token-set Jaccard (FD6 — no embeddings, linear in capped tokens). */
221
+ export function tokenSetJaccard(a, b) {
222
+ if (!a || !b || a.size === 0 || b.size === 0) return 0;
223
+ let inter = 0;
224
+ const [small, large] = a.size <= b.size ? [a, b] : [b, a];
225
+ for (const t of small) if (large.has(t)) inter++;
226
+ const union = a.size + b.size - inter;
227
+ return union === 0 ? 0 : inter / union;
228
+ }
229
+
230
+ // ── §3.1 target extraction ───────────────────────────────────────────────────
231
+
232
+ function extractSection(content, headingRe) {
233
+ const lines = String(content ?? '').split('\n');
234
+ let start = -1;
235
+ for (let i = 0; i < lines.length; i++) {
236
+ if (headingRe.test(lines[i])) { start = i + 1; break; }
237
+ }
238
+ if (start < 0) return null;
239
+ const out = [];
240
+ for (let i = start; i < lines.length; i++) {
241
+ if (/^##\s/.test(lines[i])) break;
242
+ out.push(lines[i]);
243
+ }
244
+ return out.join('\n');
245
+ }
246
+
247
+ const SUBSTRATE_FILE_RE = /`((?:src|scripts|skills|dashboard|packages|tests)\/[A-Za-z0-9_./-]{2,200}\.(?:ts|tsx|js|mjs|cjs|json))`/g;
248
+ // Census/decision-point ids are COMPOUND (kebab/snake: `messaging-tone-gate`,
249
+ // `DP_EXTERNAL_HOG_KILL_LEAVE`) — requiring at least one separator kills the
250
+ // generic backticked prose words (`invariant`, `verify`) that would otherwise
251
+ // become false STRONG targets (precision over recall — §3.3: the author must
252
+ // never be trained to skim).
253
+ const CENSUS_ID_RE = /`([a-z0-9]+(?:[-_][a-z0-9]+){1,10}|DP_[A-Z0-9_]{2,64})`/g;
254
+ const SYMBOL_RE = /`([A-Z][A-Za-z0-9]{5,64})`/g;
255
+ // Generic platform nouns that appear in nearly every instar spec — never a
256
+ // duplicate signal, so never a target (as substring-matched STRONG targets
257
+ // they would drag unrelated commits/PR diffs into false likely-duplicates).
258
+ const SYMBOL_DENYLIST = new Set([
259
+ 'PreToolUse', 'PostToolUse', 'SessionStart', 'SessionEnd', 'UserPromptSubmit',
260
+ 'SubagentStart', 'SubagentStop', 'PreCompact', 'MultiEdit', 'NotebookEdit',
261
+ 'WorktreeCreate', 'WorktreeRemove', 'TaskCompleted', 'PermissionRequest',
262
+ 'AskUserQuestion', 'GitHub', 'JavaScript', 'TypeScript', 'MERGE_HEAD',
263
+ ]);
264
+
265
+ /**
266
+ * Derive the TARGET SET (§3.1) from a spec's content:
267
+ * - census/decision-point ids (STRONG-exact) — backticked kebab/snake ids (or
268
+ * `DP_*` constants) inside the `## Decision points touched` section;
269
+ * - substrate files (STRONG-exact) — backticked repo-relative code paths;
270
+ * - exported symbols (STRONG-exact) — backticked PascalCase identifiers;
271
+ * - a feature-description FINGERPRINT (FUZZY floor) from title + problem
272
+ * statement + scope sections, byte-capped BEFORE tokenization — survives a
273
+ * missing `## Decision points touched` section entirely.
274
+ * Values failing their §3.2a type validation are DROPPED (never shelled) and
275
+ * counted in `dropped`.
276
+ */
277
+ export function extractTargets(specContent) {
278
+ const content = String(specContent ?? '');
279
+ const targets = [];
280
+ const seen = new Set();
281
+ let dropped = 0;
282
+ const push = (kind, value, valid) => {
283
+ if (!valid) { dropped++; return; }
284
+ const key = `${kind}:${value}`;
285
+ if (seen.has(key)) return;
286
+ seen.add(key);
287
+ targets.push({ kind, value });
288
+ };
289
+
290
+ const dpSection = extractSection(content, /^##\s+Decision points touched\s*$/im);
291
+ if (dpSection) {
292
+ for (const m of dpSection.matchAll(CENSUS_ID_RE)) {
293
+ push('census-id', m[1], isValidToken(m[1]));
294
+ }
295
+ }
296
+ for (const m of content.matchAll(SUBSTRATE_FILE_RE)) {
297
+ push('file', m[1], isValidRepoRelPath(m[1]));
298
+ }
299
+ for (const m of content.matchAll(SYMBOL_RE)) {
300
+ if (SYMBOL_DENYLIST.has(m[1])) continue; // generic platform noun — not a drop, just not a target
301
+ push('symbol', m[1], isValidToken(m[1]));
302
+ }
303
+
304
+ // Priority order (census-id, file, symbol) then cap at MAX_TARGETS.
305
+ const order = { 'census-id': 0, file: 1, symbol: 2 };
306
+ targets.sort((a, b) => order[a.kind] - order[b.kind]);
307
+ const capped = targets.slice(0, MAX_TARGETS);
308
+
309
+ const titleMatch = content.match(/^#\s+(.+)$/m);
310
+ const title = titleMatch ? titleMatch[1] : '';
311
+ const problem =
312
+ extractSection(content, /^##\s+(?:\d+\.\s*)?Problem statement\b/im) ??
313
+ extractSection(content, /^##\s+(?:\d+\.\s*)?Problem\b/im) ?? '';
314
+ const scope = extractSection(content, /^##\s+(?:\d+\.\s*)?Scope\b/im) ?? '';
315
+ // §3.1/FD6: byte caps enforced BEFORE similarity math.
316
+ const fpText = [
317
+ capBytes(title, SPEC_SECTION_CAP_BYTES),
318
+ capBytes(problem, SPEC_SECTION_CAP_BYTES),
319
+ capBytes(scope, SPEC_SECTION_CAP_BYTES),
320
+ ].join(' ');
321
+
322
+ return {
323
+ targets: capped,
324
+ dropped,
325
+ fingerprint: normalizeTokens(fpText),
326
+ titleTokens: normalizeTokens(capBytes(title, SPEC_SECTION_CAP_BYTES)),
327
+ specTitle: clampUntrusted(title, 200),
328
+ // §3.3: "substrate-introducing" — the spec names concrete new substrate
329
+ // (files / census ids / symbols). Degradation on such a spec → `verify`.
330
+ substrateIntroducing: capped.length > 0,
331
+ };
332
+ }
333
+
334
+ export function specSlugFromPath(specPath) {
335
+ const base = path.basename(String(specPath ?? ''), '.md');
336
+ const slug = base.toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '');
337
+ return slug || 'unknown-spec';
338
+ }
339
+
340
+ // ── Safe subprocess wrappers (§3.2a) ─────────────────────────────────────────
341
+
342
+ function remainingMs(deadline, cap) {
343
+ const rem = deadline - Date.now();
344
+ return Math.max(1, Math.min(cap, rem));
345
+ }
346
+
347
+ /** READ-ONLY git via argv array. Throws on failure (callers degrade loudly). */
348
+ function gitRead(args, { cwd, deadline }) {
349
+ return execFileSync('git', args, {
350
+ cwd,
351
+ encoding: 'utf8',
352
+ timeout: remainingMs(deadline, GIT_TIMEOUT_MS),
353
+ maxBuffer: 16 * 1024 * 1024,
354
+ stdio: ['ignore', 'pipe', 'pipe'],
355
+ });
356
+ }
357
+
358
+ function tryGitRead(args, opts) {
359
+ try { return gitRead(args, opts); } catch { return null; }
360
+ }
361
+
362
+ function ghEnv(env) {
363
+ return {
364
+ ...env,
365
+ GH_PROMPT_DISABLED: '1',
366
+ GH_NO_UPDATE_NOTIFIER: '1',
367
+ GH_PAGER: 'cat',
368
+ NO_COLOR: '1',
369
+ CLICOLOR: '0',
370
+ };
371
+ }
372
+
373
+ // ── §3.2(1) the local sibling in-flight ledger ───────────────────────────────
374
+
375
+ export function ledgerPathFor(agentHome) {
376
+ // §3.2a path-jail: realpath-resolve the agent home (a symlinked home cannot
377
+ // redirect the ledger outside its trust domain); fall back to the literal
378
+ // path when the home doesn't exist yet (first write creates it).
379
+ let home = agentHome;
380
+ try {
381
+ home = fs.realpathSync(agentHome);
382
+ } catch { /* not yet created — literal path is fine */ }
383
+ return path.join(home, LEDGER_REL_PATH);
384
+ }
385
+
386
+ /**
387
+ * Resolve the agent home from the worktree convention
388
+ * (`<home>/.instar/agents/<agent>/.worktrees/<slug>` — the sandbox-safe root
389
+ * worktrees are already jailed to) or INSTAR_AGENT_HOME. null → ledger source
390
+ * degrades loudly.
391
+ */
392
+ export function resolveAgentHome(worktreeRoot, env = process.env) {
393
+ if (env.INSTAR_AGENT_HOME && typeof env.INSTAR_AGENT_HOME === 'string') {
394
+ try {
395
+ if (fs.existsSync(env.INSTAR_AGENT_HOME)) return path.resolve(env.INSTAR_AGENT_HOME);
396
+ } catch { /* fall through */ }
397
+ }
398
+ const m = String(worktreeRoot ?? '').match(/^(.*\/\.instar\/agents\/[^/]+)\/\.worktrees(\/|$)/);
399
+ if (m) return m[1];
400
+ return null;
401
+ }
402
+
403
+ /** Allowed roots a sibling `worktreePath` must realpath-resolve under (§3.2a). */
404
+ export function allowedWorktreeRoots(agentHome, root = DEFAULT_ROOT) {
405
+ const roots = [];
406
+ if (agentHome) roots.push(path.join(agentHome, '.worktrees'));
407
+ try {
408
+ const cfgPath = path.join(root, '.instar', 'config.json');
409
+ if (fs.existsSync(cfgPath)) {
410
+ const cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf8'));
411
+ const extra = cfg && cfg.worktree && cfg.worktree.allowedRoots;
412
+ if (Array.isArray(extra)) {
413
+ for (const r of extra) if (typeof r === 'string' && path.isAbsolute(r)) roots.push(r);
414
+ }
415
+ }
416
+ } catch { /* config unreadable → default roots only */ }
417
+ return roots;
418
+ }
419
+
420
+ export function pidAlive(pid) {
421
+ try {
422
+ process.kill(pid, 0);
423
+ return true;
424
+ } catch (err) {
425
+ return err && err.code === 'EPERM';
426
+ }
427
+ }
428
+
429
+ /** Process start-time stamp — the pid-reuse defense half of the liveness conjunction. */
430
+ export function getProcStartToken(pid) {
431
+ try {
432
+ const out = execFileSync('ps', ['-p', String(pid), '-o', 'lstart='], {
433
+ encoding: 'utf8',
434
+ timeout: GIT_TIMEOUT_MS,
435
+ stdio: ['ignore', 'pipe', 'ignore'],
436
+ });
437
+ const tok = out.trim();
438
+ return tok || null;
439
+ } catch {
440
+ return null;
441
+ }
442
+ }
443
+
444
+ /**
445
+ * §3.2(1) liveness is a CONJUNCTION: pid alive ∧ procStartToken matches ∧
446
+ * worktreePath still exists (and realpath-resolves under an allowed root, no
447
+ * symlink — the planted-marker defense). Only a PROVABLY dead marker is
448
+ * ignored; age alone never marks live work stale.
449
+ */
450
+ export function isEntryLive(entry, { allowedRoots = [], livenessProbe = null } = {}) {
451
+ if (livenessProbe) return !!livenessProbe(entry);
452
+ if (!entry || !Number.isInteger(entry.pid) || entry.pid <= 0) return false;
453
+ if (typeof entry.worktreePath !== 'string' || !path.isAbsolute(entry.worktreePath)) return false;
454
+ if (!pidAlive(entry.pid)) return false;
455
+ const tok = getProcStartToken(entry.pid);
456
+ if (!tok || typeof entry.procStartToken !== 'string' || tok !== entry.procStartToken) return false;
457
+ let lst;
458
+ try {
459
+ lst = fs.lstatSync(entry.worktreePath);
460
+ } catch {
461
+ return false; // ENOENT mid-scan → skipped, not fatal (§3.2)
462
+ }
463
+ if (lst.isSymbolicLink()) return false;
464
+ let real;
465
+ try {
466
+ real = fs.realpathSync(entry.worktreePath);
467
+ } catch {
468
+ return false;
469
+ }
470
+ const jailed = allowedRoots.some((r) => {
471
+ try {
472
+ const rr = fs.realpathSync(r);
473
+ return real === rr || real.startsWith(rr + path.sep);
474
+ } catch {
475
+ return false;
476
+ }
477
+ });
478
+ return jailed;
479
+ }
480
+
481
+ /**
482
+ * Line-by-line, torn-line-tolerant ledger parse (§3.2 concurrent-read
483
+ * integrity): an unparseable line is SKIPPED (never fails the scan to
484
+ * `clear`); a torn LAST line is reported so a substrate-introducing spec can
485
+ * raise `verify`.
486
+ */
487
+ export function parseLedgerLines(raw) {
488
+ const entries = [];
489
+ let skipped = 0;
490
+ let tornLast = false;
491
+ const lines = String(raw ?? '').split('\n');
492
+ let lastNonEmpty = -1;
493
+ for (let i = 0; i < lines.length; i++) if (lines[i].trim() !== '') lastNonEmpty = i;
494
+ for (let i = 0; i < lines.length; i++) {
495
+ const line = lines[i];
496
+ if (line.trim() === '') continue;
497
+ try {
498
+ const obj = JSON.parse(line);
499
+ if (obj && typeof obj === 'object' && !Array.isArray(obj)) entries.push(obj);
500
+ else skipped++;
501
+ } catch {
502
+ skipped++;
503
+ if (i === lastNonEmpty) tornLast = true;
504
+ }
505
+ }
506
+ return { entries, skipped, tornLast };
507
+ }
508
+
509
+ /** Write-FIRST-then-scan (§3.2 TOCTOU fix): append my marker before reading. */
510
+ export function appendLedgerMarker(agentHome, entry) {
511
+ const lp = ledgerPathFor(agentHome);
512
+ fs.mkdirSync(path.dirname(lp), { recursive: true });
513
+ const line = JSON.stringify(entry) + '\n';
514
+ fs.appendFileSync(lp, line, { mode: 0o600 });
515
+ return entry.id;
516
+ }
517
+
518
+ /** Terminal lifecycle (§3.2): remove the marker at commit-success / abandon. */
519
+ export function removeLedgerMarker(agentHome, markerId) {
520
+ const lp = ledgerPathFor(agentHome);
521
+ let raw;
522
+ try {
523
+ raw = fs.readFileSync(lp, 'utf8');
524
+ } catch {
525
+ return false;
526
+ }
527
+ const lines = String(raw).split('\n');
528
+ const kept = [];
529
+ let removed = false;
530
+ for (const line of lines) {
531
+ if (line.trim() === '') continue;
532
+ try {
533
+ const obj = JSON.parse(line);
534
+ if (obj && obj.id === markerId) { removed = true; continue; }
535
+ } catch { /* torn/foreign line — preserved verbatim */ }
536
+ kept.push(line);
537
+ }
538
+ fs.writeFileSync(lp, kept.length ? kept.join('\n') + '\n' : '', { mode: 0o600 });
539
+ return removed;
540
+ }
541
+
542
+ /**
543
+ * Read-time / boot compaction (§3.2 unbounded-growth fix): rewrite keeping
544
+ * only live entries. A torn (unparseable) LAST line is preserved verbatim —
545
+ * it may be a sibling's mid-flight append.
546
+ */
547
+ export function compactLedger(agentHome, opts = {}) {
548
+ const lp = ledgerPathFor(agentHome);
549
+ let raw;
550
+ try {
551
+ raw = fs.readFileSync(lp, 'utf8');
552
+ } catch {
553
+ return { compacted: false };
554
+ }
555
+ const lines = String(raw).split('\n').filter((l) => l.trim() !== '');
556
+ const kept = [];
557
+ for (let i = 0; i < lines.length; i++) {
558
+ let obj = null;
559
+ try { obj = JSON.parse(lines[i]); } catch { obj = null; }
560
+ if (obj === null) {
561
+ if (i === lines.length - 1) kept.push(lines[i]); // torn last line preserved
562
+ continue;
563
+ }
564
+ if (isEntryLive(obj, opts)) kept.push(lines[i]);
565
+ }
566
+ fs.writeFileSync(lp, kept.length ? kept.join('\n') + '\n' : '', { mode: 0o600 });
567
+ return { compacted: true, kept: kept.length, before: lines.length };
568
+ }
569
+
570
+ function targetValues(targets) {
571
+ return new Set((targets || []).map((t) => (typeof t === 'string' ? t : t && t.value)).filter(Boolean));
572
+ }
573
+
574
+ /**
575
+ * Scan the ledger for OTHER live overlapping entries (the caller has already
576
+ * appended `self` — write-first-then-scan). Race semantics (§3.2): an
577
+ * earlier-startedAt live overlap means I LOSE; equal startedAt ties break
578
+ * lexicographically by (pid, branch) so exactly one of two simultaneous
579
+ * builders yields.
580
+ */
581
+ export function scanLedgerForOverlap(agentHome, self, opts = {}) {
582
+ const lp = ledgerPathFor(agentHome);
583
+ let raw = '';
584
+ try {
585
+ raw = fs.readFileSync(lp, 'utf8');
586
+ } catch {
587
+ return { scanned: true, losses: [], liveOverlaps: [], tornLast: false, skipped: 0 };
588
+ }
589
+ if (Buffer.byteLength(raw, 'utf8') > (opts.compactBytes ?? LEDGER_COMPACT_BYTES)) {
590
+ try {
591
+ compactLedger(agentHome, opts);
592
+ raw = fs.readFileSync(lp, 'utf8');
593
+ } catch { /* compaction is best-effort */ }
594
+ }
595
+ const { entries, skipped, tornLast } = parseLedgerLines(raw);
596
+ const myTargets = targetValues(self.targets);
597
+ const liveOverlaps = [];
598
+ const losses = [];
599
+ for (const other of entries) {
600
+ if (!other || other.id === self.id) continue;
601
+ // Entries from THIS worktree are this build's own markers (an earlier
602
+ // hook/CLI run of the same build) — never a sibling.
603
+ if (typeof other.worktreePath === 'string' && other.worktreePath === self.worktreePath) continue;
604
+ const otherTargets = targetValues(other.targets);
605
+ const shared = [...myTargets].filter((v) => otherTargets.has(v));
606
+ const sameSlug = typeof other.specSlug === 'string' && other.specSlug === self.specSlug;
607
+ if (shared.length === 0 && !sameSlug) continue;
608
+ if (!isEntryLive(other, opts)) continue;
609
+ const overlap = { entry: other, shared, sameSlug };
610
+ liveOverlaps.push(overlap);
611
+ const otherStart = String(other.startedAt ?? '');
612
+ const myStart = String(self.startedAt ?? '');
613
+ if (otherStart < myStart) {
614
+ losses.push(overlap);
615
+ } else if (otherStart === myStart) {
616
+ const otherKey = `${String(other.pid).padStart(12, '0')}|${String(other.branch ?? '')}`;
617
+ const myKey = `${String(self.pid).padStart(12, '0')}|${String(self.branch ?? '')}`;
618
+ if (otherKey < myKey) losses.push(overlap); // lexicographic tiebreak — exactly one yields
619
+ }
620
+ }
621
+ return { scanned: true, losses, liveOverlaps, tornLast, skipped };
622
+ }
623
+
624
+ // ── §3.2(2) open-PR source (bounded, non-interactive, timed, two-stage) ──────
625
+
626
+ /**
627
+ * ONE `gh pr list` call. Returns sanitized PRs (every field clamped/validated —
628
+ * gh output is untrusted §3.2a) or {ok:false} with a GENERIC note (stderr is
629
+ * never echoed).
630
+ */
631
+ export function fetchOpenPrs({ cwd, env, deadline }) {
632
+ const res = spawnSync(
633
+ 'gh',
634
+ ['pr', 'list', '--state', 'open', '--limit', String(OPEN_PR_LIMIT), '--json', 'number,title,headRefName,files,body'],
635
+ {
636
+ cwd,
637
+ encoding: 'utf8',
638
+ timeout: remainingMs(deadline, GH_TIMEOUT_MS),
639
+ env: ghEnv(env),
640
+ stdio: ['ignore', 'pipe', 'pipe'],
641
+ maxBuffer: 16 * 1024 * 1024,
642
+ },
643
+ );
644
+ if (res.error || res.status !== 0 || typeof res.stdout !== 'string') return { ok: false };
645
+ let parsed;
646
+ try {
647
+ parsed = JSON.parse(res.stdout);
648
+ } catch {
649
+ return { ok: false };
650
+ }
651
+ if (!Array.isArray(parsed)) return { ok: false };
652
+ const prs = [];
653
+ for (const raw of parsed.slice(0, OPEN_PR_LIMIT)) {
654
+ if (!raw || !isValidPrNumber(raw.number)) continue; // fails its type check → dropped, never shelled
655
+ const files = [];
656
+ if (Array.isArray(raw.files)) {
657
+ for (const f of raw.files.slice(0, 400)) {
658
+ const p = f && typeof f.path === 'string' ? f.path : null;
659
+ if (p && isValidRepoRelPath(p)) files.push(p);
660
+ }
661
+ }
662
+ prs.push({
663
+ number: raw.number,
664
+ title: clampUntrusted(raw.title, 300),
665
+ headRefName: isValidRef(String(raw.headRefName ?? '')) ? String(raw.headRefName) : null,
666
+ // §3.1/FD6: body byte-capped BEFORE similarity; control chars stripped.
667
+ body: capBytes(clampUntrusted(raw.body, PR_BODY_CAP_BYTES), PR_BODY_CAP_BYTES),
668
+ files,
669
+ });
670
+ }
671
+ return { ok: true, prs, rawHash: crypto.createHash('sha256').update(res.stdout).digest('hex') };
672
+ }
673
+
674
+ /** Stage 2: `gh pr diff <n>` — added lines only, size-capped. */
675
+ export function fetchPrDiff({ number, cwd, env, deadline }) {
676
+ if (!isValidPrNumber(number)) return { ok: false };
677
+ const res = spawnSync('gh', ['pr', 'diff', String(number), '--patch'], {
678
+ cwd,
679
+ encoding: 'utf8',
680
+ timeout: remainingMs(deadline, GH_TIMEOUT_MS),
681
+ env: ghEnv(env),
682
+ stdio: ['ignore', 'pipe', 'pipe'],
683
+ maxBuffer: PR_DIFF_CAP_BYTES, // cap-exceed → res.error → degrade for THIS PR
684
+ });
685
+ if (res.error || res.status !== 0 || typeof res.stdout !== 'string') return { ok: false };
686
+ const added = res.stdout
687
+ .split('\n')
688
+ .filter((l) => l.startsWith('+') && !l.startsWith('+++'))
689
+ .map((l) => l.slice(1))
690
+ .join('\n');
691
+ return { ok: true, addedText: capBytes(added, PR_DIFF_CAP_BYTES) };
692
+ }
693
+
694
+ // ── §3.2(3) merged-commit lookback + main-state corroborator ─────────────────
695
+
696
+ export function resolveMainRef(cwd, deadline, preferred = null) {
697
+ const candidates = preferred
698
+ ? [preferred]
699
+ : ['JKHeadley/main', 'origin/main', 'upstream/main', 'main'];
700
+ for (const ref of candidates) {
701
+ if (!isValidRef(ref)) continue;
702
+ const out = tryGitRead(['rev-parse', '--verify', '--quiet', ref], { cwd, deadline });
703
+ if (out && out.trim()) return { ref, sha: out.trim() };
704
+ }
705
+ return null;
706
+ }
707
+
708
+ /**
709
+ * ONE bounded `git log <mainRef> --since=… -n 500 --name-only` call; strong
710
+ * targets are matched against the merged commits' changed FILE PATHS and
711
+ * SUBJECTS (deterministic, argv-safe, single call — the incident signal is a
712
+ * merged PR ADDING the substrate file).
713
+ */
714
+ export function scanMergedLookback({ cwd, mainRef, sinceIso, targets, titleTokens, deadline }) {
715
+ let out;
716
+ try {
717
+ out = gitRead(
718
+ ['log', mainRef, `--since=${sinceIso}`, '-n', String(MERGE_LOOKBACK_MAX_COMMITS), '--name-only', '--format=%x1e%H%x1f%s'],
719
+ { cwd, deadline },
720
+ );
721
+ } catch {
722
+ return { ok: false };
723
+ }
724
+ const strong = [];
725
+ const fuzzy = [];
726
+ const fileTargets = new Set(targets.filter((t) => t.kind === 'file').map((t) => t.value));
727
+ const idTargets = targets.filter((t) => t.kind !== 'file').map((t) => t.value);
728
+ for (const record of out.split('\u001e')) {
729
+ if (!record.trim()) continue;
730
+ const [head, ...rest] = record.split('\n');
731
+ const [sha, subjectRaw] = head.split('\u001f');
732
+ const subject = clampUntrusted(subjectRaw, LINE_CLAMP_CHARS);
733
+ const files = rest.map((l) => l.trim()).filter(Boolean);
734
+ const hitFiles = files.filter((f) => fileTargets.has(f));
735
+ const hitIds = idTargets.filter((t) => subject.toLowerCase().includes(t.toLowerCase()));
736
+ if (hitFiles.length || hitIds.length) {
737
+ strong.push({ sha: String(sha ?? '').slice(0, 12), subject, hitFiles: hitFiles.slice(0, 5), hitIds: hitIds.slice(0, 5) });
738
+ if (strong.length >= 10) break;
739
+ } else if (titleTokens && titleTokens.size > 0) {
740
+ const j = tokenSetJaccard(titleTokens, normalizeTokens(subject));
741
+ if (j >= JACCARD_THRESHOLD) {
742
+ fuzzy.push({ sha: String(sha ?? '').slice(0, 12), subject, jaccard: Number(j.toFixed(3)) });
743
+ if (fuzzy.length >= 10) break;
744
+ }
745
+ }
746
+ }
747
+ return { ok: true, strong, fuzzy };
748
+ }
749
+
750
+ /**
751
+ * The WEAK `main`-state corroborator (§3.2 tail): a single combined
752
+ * `git grep --fixed-strings -e … -e …` over ≤MAX_TARGETS capped targets,
753
+ * pathspec-scoped to the census file + src/** with a match cap and per-match
754
+ * line clamp. NEVER a block source — a hit yields cause `main-only` (verify).
755
+ */
756
+ export function scanMainState({ cwd, mainRef, targets, deadline }) {
757
+ const grepTargets = targets
758
+ .filter((t) => t.kind !== 'file')
759
+ .map((t) => t.value)
760
+ .filter(isValidToken)
761
+ .slice(0, MAX_TARGETS);
762
+ const fileTargets = targets
763
+ .filter((t) => t.kind === 'file')
764
+ .map((t) => t.value)
765
+ .filter(isValidRepoRelPath)
766
+ .slice(0, MAX_TARGETS);
767
+ const matches = [];
768
+ if (grepTargets.length > 0) {
769
+ const args = ['grep', '-I', '-n', '--fixed-strings'];
770
+ for (const t of grepTargets) args.push('-e', t);
771
+ args.push(mainRef, '--', CENSUS_FILE, 'src/');
772
+ let out = null;
773
+ try {
774
+ out = gitRead(args, { cwd, deadline });
775
+ } catch (err) {
776
+ // git grep exits 1 on "no match" — that's a clean scan, not a failure.
777
+ const status = err && typeof err.status === 'number' ? err.status : null;
778
+ if (status !== 1) return { ok: false };
779
+ out = '';
780
+ }
781
+ for (const line of String(out).split('\n')) {
782
+ if (!line.trim()) continue;
783
+ // format: <ref>:<path>:<lineno>:<content>
784
+ const parts = line.split(':');
785
+ if (parts.length < 4) continue;
786
+ const p = parts[1];
787
+ if (!isValidRepoRelPath(p)) continue;
788
+ matches.push({ path: p, line: clampUntrusted(parts.slice(3).join(':'), LINE_CLAMP_CHARS) });
789
+ if (matches.length >= GREP_MAX_MATCHES) break;
790
+ }
791
+ }
792
+ if (fileTargets.length > 0 && matches.length < GREP_MAX_MATCHES) {
793
+ const args = ['ls-tree', '--name-only', '-r', mainRef, '--', ...fileTargets];
794
+ const out = tryGitRead(args, { cwd, deadline });
795
+ if (out === null) return { ok: false, matches };
796
+ for (const line of out.split('\n')) {
797
+ const p = line.trim();
798
+ if (p && fileTargets.includes(p)) matches.push({ path: p, line: '(file exists on main)' });
799
+ }
800
+ }
801
+ return { ok: true, matches };
802
+ }
803
+
804
+ // ── Cache (§3.3) ─────────────────────────────────────────────────────────────
805
+
806
+ export function computeCacheKey({ specSlug, mainSha, prListHash }) {
807
+ return `${specSlug}:${mainSha || 'no-main'}:${prListHash || 'no-pr-list'}`;
808
+ }
809
+
810
+ function loadCache(root) {
811
+ try {
812
+ const c = JSON.parse(fs.readFileSync(path.join(root, CACHE_REL_PATH), 'utf8'));
813
+ return c && typeof c === 'object' ? c : null;
814
+ } catch {
815
+ return null;
816
+ }
817
+ }
818
+
819
+ function storeCache(root, key, record) {
820
+ try {
821
+ fs.mkdirSync(path.join(root, '.instar'), { recursive: true });
822
+ fs.writeFileSync(path.join(root, CACHE_REL_PATH), JSON.stringify({ key, record, at: new Date().toISOString() }, null, 2) + '\n');
823
+ } catch { /* cache is best-effort */ }
824
+ }
825
+
826
+ // ── Audit trail (§3.5 — metadata only, never untrusted PR body text) ────────
827
+
828
+ export function appendAudit(root, record) {
829
+ try {
830
+ const p = path.join(root, AUDIT_REL_PATH);
831
+ fs.mkdirSync(path.dirname(p), { recursive: true });
832
+ const entry = {
833
+ ts: new Date().toISOString(),
834
+ phase: record.phase ?? null,
835
+ specSlug: record.specSlug ?? null,
836
+ verdict: record.verdict,
837
+ cause: record.cause ?? null,
838
+ causes: record.causes ?? [],
839
+ degraded: !!record.degraded,
840
+ degradedSources: record.degradedSources ?? [],
841
+ cached: !!record.cached,
842
+ durationMs: record.durationMs ?? null,
843
+ evidence: (record.evidence ?? []).map((e) => ({
844
+ id: e.id,
845
+ source: e.source,
846
+ strength: e.strength,
847
+ ...(e.prNumber != null ? { prNumber: e.prNumber } : {}),
848
+ ...(e.path ? { path: e.path } : {}),
849
+ ...(e.sha ? { sha: e.sha } : {}),
850
+ })),
851
+ ...(record.disposition ? { disposition: {
852
+ decision: record.disposition.decision ?? null,
853
+ reason: clampUntrusted(record.disposition.reason, LINE_CLAMP_CHARS),
854
+ acknowledgedEvidenceIds: Array.isArray(record.disposition.acknowledgedEvidenceIds)
855
+ ? record.disposition.acknowledgedEvidenceIds.slice(0, 20)
856
+ : [],
857
+ } } : {}),
858
+ };
859
+ fs.appendFileSync(p, JSON.stringify(entry) + '\n');
860
+ } catch { /* audit is best-effort — never fails the check */ }
861
+ }
862
+
863
+ // ── Verdict stub (the build-start record the gate + write-trace consume) ─────
864
+
865
+ export function stubPathFor(root) {
866
+ return path.join(root, STUB_REL_PATH);
867
+ }
868
+
869
+ export function readStub(root) {
870
+ try {
871
+ const s = JSON.parse(fs.readFileSync(stubPathFor(root), 'utf8'));
872
+ return s && typeof s === 'object' ? s : null;
873
+ } catch {
874
+ return null;
875
+ }
876
+ }
877
+
878
+ export function writeStub(root, stub) {
879
+ fs.mkdirSync(path.join(root, '.instar'), { recursive: true });
880
+ fs.writeFileSync(stubPathFor(root), JSON.stringify(stub, null, 2) + '\n');
881
+ }
882
+
883
+ /** The §3.4 fail-open auto-stub — written on a hard check error, never blocks. */
884
+ export function checkErroredAutoStub(extra = {}) {
885
+ return {
886
+ verdict: 'check-errored',
887
+ cause: 'check-error',
888
+ causes: ['check-error'],
889
+ degraded: true,
890
+ evidence: [],
891
+ notes: ['duplicate-build check errored (fail-open)'],
892
+ checkedAt: new Date().toISOString(),
893
+ disposition: {
894
+ decision: 'proceed',
895
+ reason: 'auto: check errored (fail-open)',
896
+ acknowledgedEvidenceIds: [],
897
+ recordedAt: new Date().toISOString(),
898
+ auto: true,
899
+ },
900
+ ...extra,
901
+ };
902
+ }
903
+
904
+ /**
905
+ * Record the author's disposition into the stub (§3.4 schema):
906
+ * { decision: "proceed"|"abandon", reason, acknowledgedEvidenceIds[] }.
907
+ * A `likely-duplicate` proceed REQUIRES a non-empty reason AND ≥1
908
+ * acknowledgedEvidenceId naming a real evidence entry.
909
+ */
910
+ export function recordDisposition(root, { decision, reason, acknowledgedEvidenceIds = [] }) {
911
+ const stub = readStub(root);
912
+ if (!stub) return { ok: false, error: 'no check stub found — run the check first' };
913
+ if (decision !== 'proceed' && decision !== 'abandon') {
914
+ return { ok: false, error: 'decision must be "proceed" or "abandon"' };
915
+ }
916
+ if (typeof reason !== 'string' || reason.trim().length === 0) {
917
+ return { ok: false, error: 'a non-empty reason is required' };
918
+ }
919
+ const acks = (Array.isArray(acknowledgedEvidenceIds) ? acknowledgedEvidenceIds : [])
920
+ .map((s) => String(s).trim())
921
+ .filter(Boolean);
922
+ if (stub.verdict === 'likely-duplicate' && decision === 'proceed') {
923
+ const evidenceIds = new Set((stub.evidence ?? []).map((e) => e.id));
924
+ const named = acks.filter((a) => evidenceIds.size === 0 || evidenceIds.has(a));
925
+ if (named.length < 1) {
926
+ return {
927
+ ok: false,
928
+ error: 'a likely-duplicate proceed requires at least one acknowledgedEvidenceId naming a concrete evidence entry (e.g. EV-1)',
929
+ };
930
+ }
931
+ }
932
+ stub.disposition = {
933
+ decision,
934
+ reason: clampUntrusted(reason, 1000),
935
+ acknowledgedEvidenceIds: acks.slice(0, 20),
936
+ recordedAt: new Date().toISOString(),
937
+ };
938
+ writeStub(root, stub);
939
+ appendAudit(root, { ...stub, phase: 'disposition' });
940
+ // §3.2 terminal lifecycle: abandon IS a terminal transition — remove the
941
+ // in-flight ledger marker so the abandoned build stops reading as live.
942
+ // Fail-open: cleanup failure never fails the disposition.
943
+ if (decision === 'abandon' && stub.agentHome && stub.ledgerMarkerId) {
944
+ try { removeLedgerMarker(stub.agentHome, stub.ledgerMarkerId); } catch { /* fail-open */ }
945
+ }
946
+ return { ok: true, stub };
947
+ }
948
+
949
+ // ── The check itself ─────────────────────────────────────────────────────────
950
+
951
+ /**
952
+ * §3.3 fail-open TOTALITY: this wrapper guarantees a non-throwing, non-blocking
953
+ * result for EVERY input. Any internal error → `check-errored` (generic note —
954
+ * no stderr / stack echoed into evidence).
955
+ */
956
+ export function runDuplicateBuildCheck(opts = {}) {
957
+ try {
958
+ return runInner(opts);
959
+ } catch {
960
+ return {
961
+ verdict: 'check-errored',
962
+ cause: 'check-error',
963
+ causes: ['check-error'],
964
+ degraded: true,
965
+ degradedSources: ['internal-error'],
966
+ evidence: [],
967
+ notes: ['duplicate-build check errored (fail-open); details withheld (generic-error policy §3.2a)'],
968
+ specSlug: opts && opts.specPath ? specSlugFromPath(opts.specPath) : 'unknown-spec',
969
+ checkedAt: new Date().toISOString(),
970
+ };
971
+ }
972
+ }
973
+
974
+ function runInner(opts) {
975
+ const env = opts.env || process.env;
976
+ const root = path.resolve(opts.root || DEFAULT_ROOT);
977
+ const phase = opts.phase || 'build-start';
978
+ const startedMs = Date.now();
979
+ const deadline = startedMs + (opts.totalBudgetMs ?? TOTAL_BUDGET_MS);
980
+
981
+ if (isGuardOff(env, root)) {
982
+ return {
983
+ verdict: 'skipped', cause: 'disabled', causes: ['disabled'], degraded: false,
984
+ degradedSources: [], evidence: [], notes: ['INSTAR_DUP_BUILD_CHECK=off / config-disabled — guard no-op'],
985
+ specSlug: opts.specPath ? specSlugFromPath(opts.specPath) : 'unknown-spec',
986
+ checkedAt: new Date().toISOString(),
987
+ };
988
+ }
989
+
990
+ // ── Read + parse the spec (a bad spec is a HARD error → check-errored) ──
991
+ const specPath = opts.specPath;
992
+ const specContent = fs.readFileSync(specPath, 'utf8'); // throws → fail-open wrapper
993
+ if (!specContent || specContent.trim().length === 0) throw new Error('empty spec');
994
+ const specSlug = specSlugFromPath(specPath);
995
+ const extraction = extractTargets(specContent);
996
+ const { targets, fingerprint, titleTokens, substrateIntroducing } = extraction;
997
+ const strongValues = targets.map((t) => t.value);
998
+
999
+ const notes = [];
1000
+ const evidence = [];
1001
+ const degradedSources = [];
1002
+ let evSeq = 0;
1003
+ const addEvidence = (e) => {
1004
+ evSeq += 1;
1005
+ const id = `EV-${evSeq}`;
1006
+ evidence.push({ id, ...e });
1007
+ return id;
1008
+ };
1009
+ if (extraction.dropped > 0) {
1010
+ notes.push(`${extraction.dropped} extracted value(s) failed type validation and were dropped from the scan (§3.2a)`);
1011
+ }
1012
+
1013
+ // ── main ref (+ pre-push re-fetch so the lookback actually runs at push) ──
1014
+ if (phase === 'pre-push' && !opts.skipFetch) {
1015
+ const probe = resolveMainRef(root, deadline, opts.mainRef ?? null);
1016
+ if (probe && probe.ref.includes('/')) {
1017
+ const [remote] = probe.ref.split('/');
1018
+ if (isValidRef(remote)) {
1019
+ try {
1020
+ gitRead(['fetch', '--quiet', remote, 'main'], { cwd: root, deadline });
1021
+ } catch {
1022
+ notes.push('origin/main re-fetch failed — lookback runs against the last-known main (degraded freshness)');
1023
+ }
1024
+ }
1025
+ }
1026
+ }
1027
+ const main = resolveMainRef(root, deadline, opts.mainRef ?? null);
1028
+ const mainSha = main ? main.sha : null;
1029
+
1030
+ // ── 1. Local sibling in-flight ledger (write-FIRST-then-scan) ──────────────
1031
+ const agentHome = opts.agentHome !== undefined ? opts.agentHome : resolveAgentHome(root, env);
1032
+ const allowedRoots = opts.allowedRoots ?? allowedWorktreeRoots(agentHome ?? '', root);
1033
+ const ledgerOpts = { allowedRoots, livenessProbe: opts.livenessProbe ?? null };
1034
+ let ledgerResult = null;
1035
+ let markerId = null;
1036
+ if (agentHome) {
1037
+ try {
1038
+ const buildPid = Number.isInteger(opts.pid) ? opts.pid
1039
+ : (env.INSTAR_BUILD_PID && Number.isInteger(parseInt(env.INSTAR_BUILD_PID, 10)))
1040
+ ? parseInt(env.INSTAR_BUILD_PID, 10)
1041
+ : (process.ppid || process.pid);
1042
+ const self = {
1043
+ id: opts.markerId ?? crypto.randomBytes(6).toString('hex'),
1044
+ agent: path.basename(agentHome),
1045
+ host: os.hostname(),
1046
+ branch: clampUntrusted(tryGitRead(['branch', '--show-current'], { cwd: root, deadline }) ?? '', 100).trim(),
1047
+ specSlug,
1048
+ targets: strongValues.slice(0, MAX_TARGETS),
1049
+ startedAt: opts.startedAt ?? new Date().toISOString(),
1050
+ pid: buildPid,
1051
+ procStartToken: opts.procStartToken ?? getProcStartToken(buildPid),
1052
+ worktreePath: root,
1053
+ };
1054
+ if (phase !== 'pre-push') {
1055
+ // Build-start: write FIRST, then scan (TOCTOU fix). Pre-push scans only
1056
+ // (this build's marker was written at build-start; a second marker from
1057
+ // an ephemeral push process would just be swept as dead).
1058
+ markerId = appendLedgerMarker(agentHome, self);
1059
+ }
1060
+ ledgerResult = scanLedgerForOverlap(agentHome, self, ledgerOpts);
1061
+ if (ledgerResult.tornLast) {
1062
+ degradedSources.push('ledger-torn-line');
1063
+ notes.push('ledger has a torn/mid-write last line — a sibling may be appending right now');
1064
+ }
1065
+ for (const loss of ledgerResult.losses) {
1066
+ const e = loss.entry;
1067
+ addEvidence({
1068
+ source: 'local-sibling',
1069
+ strength: 'strong',
1070
+ detail: clampUntrusted(
1071
+ `live sibling build on this machine (branch ${e.branch || '?'}, started ${e.startedAt || '?'}) ` +
1072
+ (loss.sameSlug ? `is on the SAME spec (${self.specSlug})` : `shares target(s): ${loss.shared.slice(0, 5).join(', ')}`),
1073
+ ),
1074
+ });
1075
+ }
1076
+ } catch {
1077
+ degradedSources.push('ledger');
1078
+ notes.push('sibling ledger unavailable (scan degraded)');
1079
+ }
1080
+ } else {
1081
+ degradedSources.push('ledger-unresolvable');
1082
+ notes.push('agent home not resolvable — sibling ledger not scanned');
1083
+ }
1084
+
1085
+ // ── 2. Open PRs (bounded two-stage; CI-skipped by design §3.3/§5) ──────────
1086
+ let prListHash = null;
1087
+ const prStrong = [];
1088
+ const prFuzzy = [];
1089
+ const prWeak = [];
1090
+ if (env.CI) {
1091
+ degradedSources.push('open-prs-skipped-ci');
1092
+ notes.push('open-PR scan skipped under CI (by design §5)');
1093
+ } else if (Date.now() >= deadline) {
1094
+ degradedSources.push('open-prs-budget');
1095
+ notes.push('open-PR scan skipped — total budget exhausted');
1096
+ } else {
1097
+ const source = opts.openPrSource
1098
+ ? opts.openPrSource({ cwd: root, env, deadline })
1099
+ : fetchOpenPrs({ cwd: root, env, deadline });
1100
+ if (!source || !source.ok) {
1101
+ degradedSources.push('open-prs');
1102
+ notes.push('open-PR scan unavailable (gh missing/failed/timed out) — degraded to local-only');
1103
+ } else {
1104
+ prListHash = source.rawHash ?? null;
1105
+ const fileTargets = new Set(targets.filter((t) => t.kind === 'file').map((t) => t.value));
1106
+ const idTargets = targets.filter((t) => t.kind !== 'file').map((t) => t.value);
1107
+ const weakFiles = new Set(opts.weakChangedFiles ?? computeWeakChangedFiles(root, deadline, notes));
1108
+ const stage2Candidates = [];
1109
+ for (const pr of source.prs) {
1110
+ const strongFileHits = pr.files.filter((f) => fileTargets.has(f));
1111
+ if (strongFileHits.length > 0) {
1112
+ prStrong.push({ pr, hits: strongFileHits, via: 'file' });
1113
+ stage2Candidates.push(pr);
1114
+ continue;
1115
+ }
1116
+ const titleHit = idTargets.some((t) => pr.title.toLowerCase().includes(t.toLowerCase()));
1117
+ // §3.1/FD6: the body byte-cap is enforced HERE, at the similarity site
1118
+ // (defense in depth — fetchOpenPrs caps too, but an injected/fixture
1119
+ // source must be capped identically before ANY similarity math).
1120
+ const j = tokenSetJaccard(fingerprint, normalizeTokens(`${pr.title} ${capBytes(pr.body, PR_BODY_CAP_BYTES)}`));
1121
+ if (titleHit) stage2Candidates.push(pr);
1122
+ if (j >= JACCARD_THRESHOLD) {
1123
+ prFuzzy.push({ pr, jaccard: Number(j.toFixed(3)) });
1124
+ if (!titleHit) stage2Candidates.push(pr);
1125
+ }
1126
+ const weakHits = pr.files.filter((f) => weakFiles.has(f));
1127
+ if (weakHits.length > 0 && strongFileHits.length === 0) prWeak.push({ pr, hits: weakHits.slice(0, 5) });
1128
+ }
1129
+ // Stage 2: census/symbol identities a PR ADDS are only visible in its
1130
+ // diff — run `gh pr diff` on ≤5 already-matched PRs, diff-size-capped.
1131
+ const idTargetsForDiff = idTargets.filter(isValidToken);
1132
+ if (idTargetsForDiff.length > 0) {
1133
+ const seen = new Set();
1134
+ const candidates = stage2Candidates.filter((pr) => {
1135
+ if (seen.has(pr.number)) return false;
1136
+ seen.add(pr.number);
1137
+ return true;
1138
+ }).slice(0, PR_DIFF_STAGE2_MAX);
1139
+ for (const pr of candidates) {
1140
+ if (Date.now() >= deadline) {
1141
+ degradedSources.push(`pr-diff-${pr.number}-budget`);
1142
+ notes.push(`PR #${pr.number} diff skipped (budget) — matched at stage 1 only`);
1143
+ continue;
1144
+ }
1145
+ const diff = opts.prDiffSource
1146
+ ? opts.prDiffSource({ number: pr.number, cwd: root, env, deadline })
1147
+ : fetchPrDiff({ number: pr.number, cwd: root, env, deadline });
1148
+ if (!diff || !diff.ok) {
1149
+ // Cap-exceed / timeout → fall back to file-overlap-only for this PR;
1150
+ // a verify-worthy signal is never silently dropped (§3.2).
1151
+ degradedSources.push(`pr-diff-${pr.number}`);
1152
+ notes.push(`PR #${pr.number} diff unavailable/over-cap — falling back to file-overlap-only for it`);
1153
+ continue;
1154
+ }
1155
+ const addedHits = idTargetsForDiff.filter((t) => diff.addedText.includes(t));
1156
+ if (addedHits.length > 0) prStrong.push({ pr, hits: addedHits, via: 'diff' });
1157
+ }
1158
+ }
1159
+ for (const s of prStrong) {
1160
+ addEvidence({
1161
+ source: 'open-pr',
1162
+ strength: 'strong',
1163
+ prNumber: s.pr.number,
1164
+ detail: clampUntrusted(
1165
+ s.via === 'file'
1166
+ ? `open PR #${s.pr.number} touches substrate file(s) this spec introduces: ${s.hits.slice(0, 5).join(', ')}`
1167
+ : `open PR #${s.pr.number} is ADDING identity target(s) this spec names: ${s.hits.slice(0, 5).join(', ')}`,
1168
+ ),
1169
+ ...(s.via === 'file' ? { path: s.hits[0] } : {}),
1170
+ });
1171
+ }
1172
+ for (const f of prFuzzy) {
1173
+ addEvidence({
1174
+ source: 'open-pr',
1175
+ strength: 'fuzzy',
1176
+ prNumber: f.pr.number,
1177
+ detail: `open PR #${f.pr.number} title/body fingerprint similarity ${f.jaccard} ≥ ${JACCARD_THRESHOLD}`,
1178
+ });
1179
+ }
1180
+ if (prWeak.length > 0) {
1181
+ notes.push(
1182
+ `quiet note: ${prWeak.length} open PR(s) overlap only on WEAK touched files (` +
1183
+ prWeak.slice(0, 3).map((w) => `#${w.pr.number}`).join(', ') + ') — not escalated (§3.3)',
1184
+ );
1185
+ }
1186
+ }
1187
+ }
1188
+
1189
+ // ── 3. Recently-merged lookback ─────────────────────────────────────────────
1190
+ let mergedStrongCount = 0;
1191
+ let mergedFuzzyCount = 0;
1192
+ if (!main) {
1193
+ degradedSources.push('merged-lookback-no-main');
1194
+ notes.push('no main ref resolvable — merged-commit lookback not scanned');
1195
+ } else if (Date.now() >= deadline) {
1196
+ degradedSources.push('merged-lookback-budget');
1197
+ } else {
1198
+ const floorMs = MERGE_LOOKBACK_FLOOR_DAYS * 24 * 60 * 60 * 1000;
1199
+ const buildStartMs = opts.buildStartedAt ? Date.parse(opts.buildStartedAt) : NaN;
1200
+ const sinceMs = Math.min(
1201
+ Number.isFinite(buildStartMs) ? buildStartMs : Date.now(),
1202
+ Date.now() - floorMs,
1203
+ );
1204
+ const sinceIso = new Date(sinceMs).toISOString();
1205
+ const merged = scanMergedLookback({ cwd: root, mainRef: main.ref, sinceIso, targets, titleTokens, deadline });
1206
+ if (!merged.ok) {
1207
+ degradedSources.push('merged-lookback');
1208
+ notes.push('merged-commit lookback failed (git error/timeout) — degraded');
1209
+ } else {
1210
+ mergedStrongCount = merged.strong.length;
1211
+ mergedFuzzyCount = merged.fuzzy.length;
1212
+ for (const s of merged.strong) {
1213
+ addEvidence({
1214
+ source: 'merged-commit',
1215
+ strength: 'strong',
1216
+ sha: s.sha,
1217
+ detail: clampUntrusted(
1218
+ `merged commit ${s.sha} (“${s.subject}”) ` +
1219
+ (s.hitFiles.length ? `touches target file(s): ${s.hitFiles.join(', ')}` : `names target id(s): ${s.hitIds.join(', ')}`),
1220
+ ),
1221
+ ...(s.hitFiles.length ? { path: s.hitFiles[0] } : {}),
1222
+ });
1223
+ }
1224
+ for (const f of merged.fuzzy) {
1225
+ addEvidence({
1226
+ source: 'merged-commit',
1227
+ strength: 'fuzzy',
1228
+ sha: f.sha,
1229
+ detail: `merged commit ${f.sha} subject similarity ${f.jaccard} ≥ ${JACCARD_THRESHOLD}`,
1230
+ });
1231
+ }
1232
+ }
1233
+ }
1234
+
1235
+ // ── Cache (after the pr-list fetch so the key includes its hash) ───────────
1236
+ const cacheKey = computeCacheKey({ specSlug, mainSha, prListHash });
1237
+ if (!opts.noCache) {
1238
+ const cached = loadCache(root);
1239
+ if (cached && cached.key === cacheKey && cached.record && cached.record.verdict) {
1240
+ // Identical inputs → the previous computation stands (build-start +
1241
+ // pre-push share ONE computation; a HEAD move / new PR re-keys).
1242
+ const rec = { ...cached.record, cached: true, phase };
1243
+ appendAudit(root, rec);
1244
+ return rec;
1245
+ }
1246
+ }
1247
+
1248
+ // ── 4. main-state WEAK corroborator (never a block source) ─────────────────
1249
+ let mainOnlyMatches = [];
1250
+ if (main && Date.now() < deadline && targets.length > 0) {
1251
+ const ms = scanMainState({ cwd: root, mainRef: main.ref, targets, deadline });
1252
+ if (!ms.ok) {
1253
+ notes.push('main-state corroborator unavailable (weak signal only — not counted as degradation)');
1254
+ }
1255
+ mainOnlyMatches = ms.matches ?? [];
1256
+ for (const m of mainOnlyMatches.slice(0, 5)) {
1257
+ addEvidence({
1258
+ source: 'main-state',
1259
+ strength: 'weak',
1260
+ path: m.path,
1261
+ detail: clampUntrusted(`target already present on ${main.ref}: ${m.path} — ${m.line}`),
1262
+ });
1263
+ }
1264
+ }
1265
+
1266
+ // ── §3.3 TOTAL verdict ladder (FD4) ─────────────────────────────────────────
1267
+ const strongConcurrency = evidence.filter((e) => e.strength === 'strong' &&
1268
+ (e.source === 'local-sibling' || e.source === 'open-pr' || e.source === 'merged-commit'));
1269
+ const fuzzyHits = evidence.filter((e) => e.strength === 'fuzzy');
1270
+ const degraded = degradedSources.length > 0;
1271
+
1272
+ let verdict;
1273
+ const causes = [];
1274
+ if (strongConcurrency.length > 0) {
1275
+ verdict = 'likely-duplicate';
1276
+ causes.push('concurrency');
1277
+ if (fuzzyHits.length > 0) causes.push('fuzzy');
1278
+ if (degraded) causes.push('degraded');
1279
+ } else {
1280
+ if (fuzzyHits.length > 0) causes.push('fuzzy');
1281
+ if (mainOnlyMatches.length > 0) causes.push('main-only');
1282
+ if (degraded && substrateIntroducing) causes.push('degraded');
1283
+ if (causes.length > 0) {
1284
+ verdict = 'verify';
1285
+ } else {
1286
+ verdict = 'clear';
1287
+ if (degraded) notes.push('degraded NON-substrate scan → clear with a degraded audit flag (§3.3)');
1288
+ }
1289
+ }
1290
+ // Real-overlap causes OUTRANK the environmental `degraded` tag (§3.3).
1291
+ const causePriority = ['concurrency', 'fuzzy', 'main-only', 'degraded'];
1292
+ const cause = causePriority.find((c) => causes.includes(c)) ?? null;
1293
+
1294
+ const record = {
1295
+ verdict,
1296
+ cause,
1297
+ causes,
1298
+ degraded,
1299
+ degradedSources,
1300
+ substrateIntroducing,
1301
+ evidence,
1302
+ notes,
1303
+ specSlug,
1304
+ specPath: path.relative(root, path.resolve(specPath)) || String(specPath),
1305
+ specTitle: extraction.specTitle,
1306
+ targets: targets.slice(0, MAX_TARGETS),
1307
+ mainRef: main ? main.ref : null,
1308
+ mainSha,
1309
+ phase,
1310
+ cached: false,
1311
+ checkedAt: new Date().toISOString(),
1312
+ durationMs: Date.now() - startedMs,
1313
+ ...(markerId ? { ledgerMarkerId: markerId, agentHome } : {}),
1314
+ ...(mergedStrongCount + mergedFuzzyCount > 0 ? { mergedHits: { strong: mergedStrongCount, fuzzy: mergedFuzzyCount } } : {}),
1315
+ };
1316
+
1317
+ if (!opts.noCache) storeCache(root, cacheKey, record);
1318
+ appendAudit(root, record);
1319
+ return record;
1320
+ }
1321
+
1322
+ /** WEAK touched-file computation — REUSES pre-push-scope (§3.1), never a re-implemented diff. */
1323
+ function computeWeakChangedFiles(root, deadline, notes) {
1324
+ try {
1325
+ const base = resolvePrePushBase({ cwd: root });
1326
+ return changedFilesSince(base.ref, { cwd: root });
1327
+ } catch {
1328
+ notes.push('weak changed-file computation unavailable (quiet signal only)');
1329
+ return [];
1330
+ }
1331
+ }
1332
+
1333
+ /**
1334
+ * Resolve which spec drives an advisory/gate run when none was passed:
1335
+ * the stub's recorded specPath first, else the branch's own added/modified
1336
+ * spec under docs/specs/ (untracked or diverged from main), else null.
1337
+ */
1338
+ export function resolveSpecForAdvisory(root, { deadline = Date.now() + GIT_TIMEOUT_MS } = {}) {
1339
+ try {
1340
+ const stub = readStub(root);
1341
+ if (stub && typeof stub.specPath === 'string') {
1342
+ const p = path.resolve(root, stub.specPath);
1343
+ if (fs.existsSync(p)) return p;
1344
+ }
1345
+ const main = resolveMainRef(root, deadline);
1346
+ const candidates = new Set();
1347
+ if (main) {
1348
+ const diff = tryGitRead(['diff', '--name-only', `${main.ref}...HEAD`, '--', 'docs/specs'], { cwd: root, deadline });
1349
+ if (diff) for (const l of diff.split('\n')) { const f = l.trim(); if (f) candidates.add(f); }
1350
+ }
1351
+ // --untracked-files=all: porcelain otherwise collapses a fully-untracked
1352
+ // directory to `?? docs/specs/`, hiding the spec file inside it.
1353
+ const status = tryGitRead(['status', '--porcelain', '--untracked-files=all', '--', 'docs/specs'], { cwd: root, deadline });
1354
+ if (status) {
1355
+ for (const l of status.split('\n')) {
1356
+ const f = l.slice(3).trim();
1357
+ if (f) candidates.add(f);
1358
+ }
1359
+ }
1360
+ const specs = [...candidates].filter((f) =>
1361
+ /^docs\/specs\/[^/]+\.md$/.test(f) && !f.endsWith('.eli16.md') && !f.includes('/reports/'));
1362
+ let best = null;
1363
+ let bestM = -1;
1364
+ for (const f of specs) {
1365
+ const p = path.join(root, f);
1366
+ try {
1367
+ const m = fs.statSync(p).mtimeMs;
1368
+ if (m > bestM) { bestM = m; best = p; }
1369
+ } catch { /* deleted */ }
1370
+ }
1371
+ return best;
1372
+ } catch {
1373
+ return null;
1374
+ }
1375
+ }
1376
+
1377
+ // ── CLI ──────────────────────────────────────────────────────────────────────
1378
+ // FAIL-OPEN TOTALITY: the CLI ALWAYS exits 0 (FD5) — the verdict is the
1379
+ // signal; blocking authority lives with the gate + the author's disposition.
1380
+
1381
+ function cliMain(argv) {
1382
+ const args = argv.slice(2);
1383
+ const flag = (name) => {
1384
+ const i = args.indexOf(name);
1385
+ return i >= 0 ? (args[i + 1] ?? null) : null;
1386
+ };
1387
+ const has = (name) => args.includes(name);
1388
+ const root = path.resolve(flag('--root') ?? DEFAULT_ROOT);
1389
+ const asJson = has('--json');
1390
+ const print = (obj, human) => {
1391
+ if (asJson) console.log(JSON.stringify(obj, null, 2));
1392
+ else console.log(human);
1393
+ };
1394
+
1395
+ try {
1396
+ if (has('--record-disposition')) {
1397
+ const decision = flag('--decision');
1398
+ const reason = flag('--reason');
1399
+ const ack = (flag('--ack') ?? '').split(',').map((s) => s.trim()).filter(Boolean);
1400
+ const res = recordDisposition(root, { decision, reason, acknowledgedEvidenceIds: ack });
1401
+ if (!res.ok) {
1402
+ print({ ok: false, error: res.error }, `disposition NOT recorded: ${res.error}`);
1403
+ } else {
1404
+ print({ ok: true, disposition: res.stub.disposition },
1405
+ `disposition recorded: ${decision} (${res.stub.verdict}) — the build-start gate will now allow implementation writes`);
1406
+ }
1407
+ return 0;
1408
+ }
1409
+ if (has('--remove-marker')) {
1410
+ const stub = readStub(root);
1411
+ const agentHome = flag('--agent-home') ?? (stub && stub.agentHome) ?? resolveAgentHome(root, process.env);
1412
+ const id = flag('--marker-id') ?? (stub && stub.ledgerMarkerId);
1413
+ if (!agentHome || !id) {
1414
+ print({ ok: false, error: 'no marker recorded' }, 'no ledger marker to remove');
1415
+ return 0;
1416
+ }
1417
+ const removed = removeLedgerMarker(agentHome, id);
1418
+ print({ ok: true, removed }, removed ? 'ledger marker removed (terminal transition)' : 'marker already gone');
1419
+ return 0;
1420
+ }
1421
+
1422
+ const specPath = args.find((a) => !a.startsWith('--') && a !== flag('--root') && a !== flag('--phase') && a !== flag('--agent-home'));
1423
+ if (!specPath) {
1424
+ print({ ok: false, error: 'usage: duplicate-build-check.mjs <specPath> [--json] [--root <p>] [--phase build-start|pre-push]' },
1425
+ 'usage: node scripts/lib/duplicate-build-check.mjs <specPath> [--json]');
1426
+ return 0; // even usage errors are non-blocking (FD5)
1427
+ }
1428
+ const record = runDuplicateBuildCheck({
1429
+ specPath: path.resolve(root, specPath),
1430
+ root,
1431
+ phase: flag('--phase') ?? 'build-start',
1432
+ agentHome: flag('--agent-home') ?? undefined,
1433
+ env: process.env,
1434
+ });
1435
+ // Persist the stub (the build-start record the gate + write-trace consume).
1436
+ try {
1437
+ const existing = readStub(root);
1438
+ const stub = { ...record };
1439
+ if (existing && existing.disposition && existing.specSlug === record.specSlug) {
1440
+ stub.disposition = existing.disposition; // keep an already-recorded disposition
1441
+ } else if (record.verdict === 'clear' || record.verdict === 'skipped') {
1442
+ stub.disposition = {
1443
+ decision: 'proceed',
1444
+ reason: `auto: verdict ${record.verdict}`,
1445
+ acknowledgedEvidenceIds: [],
1446
+ recordedAt: new Date().toISOString(),
1447
+ auto: true,
1448
+ };
1449
+ } else if (record.verdict === 'check-errored') {
1450
+ stub.disposition = checkErroredAutoStub().disposition;
1451
+ }
1452
+ writeStub(root, stub);
1453
+ } catch { /* stub write is best-effort */ }
1454
+ print(record,
1455
+ `duplicate-build check: ${record.verdict}` +
1456
+ (record.cause ? ` (cause: ${record.cause})` : '') +
1457
+ (record.evidence.length ? '\n' + record.evidence.map((e) => ` ${e.id} [${e.source}] ${e.detail}`).join('\n') : '') +
1458
+ (record.notes.length ? '\n' + record.notes.map((n) => ` note: ${n}`).join('\n') : ''));
1459
+ return 0;
1460
+ } catch {
1461
+ // §3.3 fail-open totality — even a CLI-layer crash exits 0.
1462
+ try {
1463
+ print({ verdict: 'check-errored', cause: 'check-error' }, 'duplicate-build check errored (fail-open) — proceeding is allowed; the errored run is visible in the audit');
1464
+ } catch { /* ignore */ }
1465
+ return 0;
1466
+ }
1467
+ }
1468
+
1469
+ const invokedDirectly = process.argv[1] && path.resolve(process.argv[1]) === __filename;
1470
+ if (invokedDirectly) {
1471
+ process.exit(cliMain(process.argv));
1472
+ }