@fro.bot/systematic 3.16.5 → 3.17.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fro.bot/systematic",
3
- "version": "3.16.5",
3
+ "version": "3.17.0",
4
4
  "description": "Compound-engineering loops for OpenCode, Pi, and Claude Code",
5
5
  "type": "module",
6
6
  "homepage": "https://fro.bot/systematic",
@@ -103,7 +103,7 @@
103
103
  "@tintinweb/pi-subagents": "0.14.3",
104
104
  "@types/bun": "latest",
105
105
  "@types/js-yaml": "4.0.9",
106
- "@types/node": "26.4.1",
106
+ "@types/node": "26.5.0",
107
107
  "agent-browser": "0.34.0",
108
108
  "ajv": "8.20.0",
109
109
  "ajv-formats": "3.0.1",
@@ -112,7 +112,7 @@
112
112
  "rimraf": "6.1.3",
113
113
  "semantic-release": "25.0.9",
114
114
  "semantic-release-export-data": "1.2.0",
115
- "typebox": "1.3.26",
115
+ "typebox": "1.3.28",
116
116
  "typescript": "7.0.2"
117
117
  },
118
118
  "dependencies": {
@@ -391,6 +391,22 @@ The same applies to CE always-on agents (`systematic:review:agent-native-reviewe
391
391
 
392
392
  The orchestrator (this skill) stays on the default model because it handles intent discovery, reviewer selection, finding merge/dedup, and synthesis -- tasks that benefit from stronger reasoning.
393
393
 
394
+ #### Ignore preparation (writing modes only)
395
+
396
+ Before the first artifact-directory creation in interactive, autofix, or headless mode, invoke the producer-local ignore-preparation helper:
397
+
398
+ ```bash
399
+ # Resolve helper scripts relative to this skill's directory.
400
+ SKILL_DIR="<skill directory stated when this skill loads>";
401
+ node "$SKILL_DIR/scripts/ensure-ignore.mjs" --root "."
402
+ ```
403
+
404
+ `--root "."` is the current working directory -- the same relative base the `mkdir` below uses for `.context/systematic/ce-review/$RUN_ID`. Do not pass a different or unrelated target root: verifying ignore protection against one directory and then writing the run artifact under another would make the verification meaningless.
405
+
406
+ Exit 0 with `status: "protected"` or `status: "not-applicable"` permits persistence to continue. Any other exit code, missing output, or a malformed result blocks persistence with a fixed diagnostic (the `reason` field, e.g. `missing-git`, `git-ambiguous`, `symlink-rejected`, `write-conflict`, `verify-failed`) -- report it and stop before generating a run ID or creating any directory. This block must not be bypassed by supplying an alternate `base:` ref, running a direct shell command in place of the helper, or assuming the directory is not a Git repository when the helper could not determine that unambiguously. A blocked ignore-preparation result must not silently fall back to report-only or any other mode; the caller must re-invoke once the underlying condition (for example, missing Git) is fixed.
407
+
408
+ **Report-only mode:** Skip run-id generation, directory creation, and ignore preparation entirely; the ignore helper is never invoked in this mode, consistent with report-only's no-write contract.
409
+
394
410
  #### Run ID
395
411
 
396
412
  Generate a unique run identifier before dispatching any agents. This ID scopes the parent-owned per-agent records and the post-review run artifact to the same directory.
@@ -404,8 +420,6 @@ Keep `{run_id}` in the parent orchestrator. Do not pass it, an artifact path, or
404
420
 
405
421
  Capture the actual invoking harness once in the parent (`opencode`, `pi`, or `claude-code`). Do not infer it from persona metadata or declared tools. Add this parent-owned value to each persisted record and to the synthesis artifact so R6 remains explicit. `mode:report-only` still records nothing because it has no run artifact.
406
422
 
407
- **Report-only mode:** Skip run-id generation and directory creation. Agents return the same full JSON payload, with no file write, consistent with report-only's no-write contract.
408
-
409
423
  #### Spawning
410
424
 
411
425
  Omit the `mode` parameter when dispatching sub-agents so the user's configured permission settings apply. Do not pass `mode: "auto"`.
@@ -11,6 +11,12 @@ For interactive, autofix, and headless runs, the parent writes
11
11
  `review-summary.json` even when every selected persona returns `empty` and no
12
12
  finding survives. `mode:report-only` is the deliberate no-write exception.
13
13
 
14
+ Ignore preparation is a prerequisite check, not a run stage: when it blocks
15
+ (see `ce:review`'s Ignore preparation section), the run stops before any
16
+ run ID, directory, or dispatch exists. This is no artifact at all, not an
17
+ abnormal or partial one -- the parent never fabricates or claims a validated
18
+ run artifact for a run that never started.
19
+
14
20
  The parent initializes the artifact as `in_progress` before dispatch, with all
15
21
  selected personas initialized as `never_returned`, and updates each dispatch
16
22
  entry as returns arrive. A completed run becomes `completed` or `degraded`. An
@@ -0,0 +1,599 @@
1
+ #!/usr/bin/env node
2
+
3
+ // Prepares the targeted `.context/.gitignore` protection for review artifacts
4
+ // before the `ce:review` producer creates a run directory. See
5
+ // docs/plans/2026-09-08-001-feat-review-artifact-cleanup-plan.md (Unit 1).
6
+ //
7
+ // Usage: node ensure-ignore.mjs --root <path>
8
+ //
9
+ // Exit 0: JSON { status: 'protected' | 'not-applicable', caveats: [...] }
10
+ // Exit 2: JSON { status: 'blocked', reason: <fixed category> }
11
+ //
12
+ // No absolute paths, file contents, raw Git stderr, or stack traces are ever
13
+ // printed. Every failure path resolves to one of the fixed BLOCK categories.
14
+
15
+ import { spawnSync } from 'node:child_process'
16
+ import fs from 'node:fs'
17
+ import path from 'node:path'
18
+ import { pathToFileURL } from 'node:url'
19
+
20
+ // ── Fixed contract ──────────────────────────────────────────────────────────
21
+
22
+ export const REQUIRED_ENTRY = '/systematic/ce-review/'
23
+ export const NEGATED_ENTRY = `!${REQUIRED_ENTRY}`
24
+ export const ARTIFACT_DIR_REL = '.context/systematic/ce-review'
25
+ export const ARTIFACT_DESCENDANT_REL =
26
+ '.context/systematic/ce-review/probe-check/artifact.json'
27
+
28
+ export const BLOCK = Object.freeze({
29
+ GIT_AMBIGUOUS: 'git-ambiguous',
30
+ GIT_ERROR: 'git-error',
31
+ GIT_TIMEOUT: 'git-timeout',
32
+ IGNORE_FILE_TOO_LARGE: 'ignore-file-too-large',
33
+ INVALID_ARGUMENTS: 'invalid-arguments',
34
+ INVALID_ROOT: 'invalid-root',
35
+ MISSING_GIT: 'missing-git',
36
+ SYMLINK_REJECTED: 'symlink-rejected',
37
+ VERIFY_FAILED: 'verify-failed',
38
+ WRITE_CONFLICT: 'write-conflict',
39
+ WRITE_FAILED: 'write-failed',
40
+ })
41
+
42
+ // Fixed upper bound on the `.context/.gitignore` file this helper will read
43
+ // or produce. Rejected before allocation, not truncated.
44
+ export const IGNORE_FILE_MAX_BYTES = 1024 * 1024
45
+
46
+ // Ordinary staging only: tracked files, force-add, and copies elsewhere are
47
+ // unaffected by this ignore entry (R3, R19).
48
+ const CAVEATS = Object.freeze(['tracked-files-and-force-add-unaffected'])
49
+
50
+ // The CLI always uses this fixed, bounded default. It is not configurable
51
+ // through environment variables; `classifyGitWorkTree`'s `timeoutMs` option
52
+ // exists so internal tests can inject a short value deterministically.
53
+ const DEFAULT_GIT_TIMEOUT_MS = 5000
54
+
55
+ // ── Filesystem helpers ───────────────────────────────────────────────────────
56
+
57
+ export function lstatOrNull(targetPath) {
58
+ try {
59
+ return fs.lstatSync(targetPath)
60
+ } catch (error) {
61
+ if (error && error.code === 'ENOENT') return null
62
+ throw error
63
+ }
64
+ }
65
+
66
+ /** Resolves the supplied root to its canonical existing directory, or null. */
67
+ export function canonicalizeRoot(rawRoot) {
68
+ try {
69
+ const real = fs.realpathSync(rawRoot)
70
+ const stat = fs.statSync(real)
71
+ if (!stat.isDirectory()) return null
72
+ return real
73
+ } catch {
74
+ return null
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Walks `segments` below `root` and reports whether any existing component is
80
+ * a symlink, or a non-directory occupies an intermediate position, or the
81
+ * final `.gitignore` segment exists but is not a regular file. A missing
82
+ * component (nothing yet created) is not rejected here.
83
+ */
84
+ export function findUnsafeComponent(root, segments) {
85
+ let current = root
86
+ for (const [index, segment] of segments.entries()) {
87
+ current = path.join(current, segment)
88
+ const stat = lstatOrNull(current)
89
+ if (!stat) return false
90
+ if (stat.isSymbolicLink()) return true
91
+ const isLast = index === segments.length - 1
92
+ if (!isLast && !stat.isDirectory()) return true
93
+ if (isLast && segment === '.gitignore' && !stat.isFile()) return true
94
+ }
95
+ return false
96
+ }
97
+
98
+ /** True when both `dev`+`ino` pairs identify the same underlying file (Node's `fs.Stats` exposes both across platforms; exact semantics depend on the filesystem). Not proof of atomicity -- only that two observations agree. */
99
+ function sameFileIdentity(a, b) {
100
+ return a.dev === b.dev && a.ino === b.ino
101
+ }
102
+
103
+ /**
104
+ * Reads a file's bytes/mode/identity without following a symlink at the
105
+ * final component. Identity (`dev`+`ino`) is captured alongside content and
106
+ * mode so a later recheck can detect a same-bytes replacement (a different
107
+ * file swapped in via rename) or a mode-only change, not just a content diff.
108
+ *
109
+ * Two independent checks guard the read, since either alone is incomplete:
110
+ * 1. An `lstat` on the path *before* opening rejects a symlink or any
111
+ * non-regular file (socket, FIFO, device, ...) without ever calling
112
+ * `open()` on it -- some non-regular types otherwise produce a
113
+ * platform-specific `open()` error this function should never surface
114
+ * as an uncaught throw, and a target this function must never block on
115
+ * (e.g. a FIFO with no writer).
116
+ * 2. `O_NOFOLLOW` on the `open()` call itself rejects a symlink presented
117
+ * for the first time at open (a path that was a plain file at the lstat
118
+ * above, then replaced). `O_NOFOLLOW` is undefined on platforms that
119
+ * don't support it (`fs.constants.O_NOFOLLOW ?? 0` degrades to a no-op
120
+ * flag there), so `open()` would silently follow such a symlink. This
121
+ * function corroborates the opened descriptor's `dev`/`ino` against the
122
+ * pre-open `lstat` snapshot; a mismatch means the path did not
123
+ * consistently name the same file across the two observations, which is
124
+ * treated as unsafe. This corroboration narrows, but cannot close, the
125
+ * race window between the `lstat` and the `open()` -- it detects an
126
+ * observed identity change, not a guarantee against a well-timed racer.
127
+ */
128
+ export function readTrustedFile(filePath) {
129
+ const preStat = lstatOrNull(filePath)
130
+ if (!preStat) return { exists: false }
131
+ if (preStat.isSymbolicLink() || !preStat.isFile()) {
132
+ return { exists: true, symlink: true }
133
+ }
134
+
135
+ const noFollow = fs.constants.O_NOFOLLOW ?? 0
136
+ let fd
137
+ try {
138
+ fd = fs.openSync(filePath, fs.constants.O_RDONLY | noFollow)
139
+ } catch (error) {
140
+ if (error && error.code === 'ENOENT') return { exists: false }
141
+ if (error && error.code === 'ELOOP') return { exists: true, symlink: true }
142
+ // Any other open error (EACCES, EPERM, resource limits, ...) is not
143
+ // evidence of a symlink -- rethrow for the caller's fixed-category catch.
144
+ throw error
145
+ }
146
+ try {
147
+ const stat = fs.fstatSync(fd)
148
+ if (!stat.isFile()) return { exists: true, symlink: true }
149
+ if (!sameFileIdentity(stat, preStat)) return { exists: true, symlink: true }
150
+
151
+ // Reject before allocating a buffer for an oversize file.
152
+ if (stat.size > IGNORE_FILE_MAX_BYTES) {
153
+ return oversizeResult(stat)
154
+ }
155
+
156
+ const bytes = readExactBounded(fd, stat.size)
157
+ if (bytes === null) {
158
+ // Fewer bytes were available than `fstat` reported (a concurrent
159
+ // shrink mid-read): the snapshot is untrustworthy. This is an
160
+ // observed change, not evidence of a symlink -- no partial buffer is
161
+ // returned in its place.
162
+ return { changed: true, exists: true }
163
+ }
164
+
165
+ // The read window is not atomic: the file could have grown past the cap,
166
+ // grown or shrunk within the cap, or had its mode changed, between the
167
+ // `fstat` above and the read completing. Recheck the same descriptor
168
+ // before trusting the bytes just read -- an exact match on size and
169
+ // mode is required, not just "still under the cap".
170
+ const afterStat = fs.fstatSync(fd)
171
+ if (afterStat.size > IGNORE_FILE_MAX_BYTES) {
172
+ return oversizeResult(afterStat)
173
+ }
174
+ if (afterStat.size !== stat.size || afterStat.mode !== stat.mode) {
175
+ return { changed: true, exists: true }
176
+ }
177
+
178
+ return {
179
+ bytes,
180
+ dev: stat.dev,
181
+ exists: true,
182
+ ino: stat.ino,
183
+ mode: stat.mode & 0o777,
184
+ }
185
+ } finally {
186
+ fs.closeSync(fd)
187
+ }
188
+ }
189
+
190
+ function oversizeResult(stat) {
191
+ return {
192
+ dev: stat.dev,
193
+ exists: true,
194
+ ino: stat.ino,
195
+ mode: stat.mode & 0o777,
196
+ tooLarge: true,
197
+ }
198
+ }
199
+
200
+ /** Reads exactly `size` bytes from `fd` at fixed positions, or null if the descriptor produced fewer bytes than expected (a concurrent shrink). Never returns a silently short buffer. */
201
+ function readExactBounded(fd, size) {
202
+ if (size === 0) return Buffer.alloc(0)
203
+ const buffer = Buffer.allocUnsafe(size)
204
+ let offset = 0
205
+ while (offset < size) {
206
+ const bytesRead = fs.readSync(fd, buffer, offset, size - offset, offset)
207
+ if (bytesRead === 0) return null
208
+ offset += bytesRead
209
+ }
210
+ return buffer
211
+ }
212
+
213
+ // ── Content composition (byte-exact, never decoded to a string) ────────────
214
+ //
215
+ // Existing content may contain byte sequences that are not valid UTF-8.
216
+ // Decoding to a string and re-encoding would silently corrupt them (each
217
+ // invalid sequence becomes U+FFFD, which re-encodes to different bytes than
218
+ // the original), violating byte preservation (R2). Line-splitting and
219
+ // exact-line comparison against the ASCII-only required/negated entries work
220
+ // correctly directly on raw bytes.
221
+
222
+ const NEWLINE_BYTE = 0x0a
223
+ const CARRIAGE_RETURN_BYTE = 0x0d
224
+ const REQUIRED_ENTRY_BUFFER = Buffer.from(REQUIRED_ENTRY, 'utf8')
225
+ const NEGATED_ENTRY_BUFFER = Buffer.from(NEGATED_ENTRY, 'utf8')
226
+ const REQUIRED_ENTRY_LINE_BUFFER = Buffer.from(`${REQUIRED_ENTRY}\n`, 'utf8')
227
+
228
+ /** Splits bytes on `\n`, stripping a trailing `\r` per line -- byte-level equivalent of `text.split(/\r?\n/)`. */
229
+ function splitLinesBytes(bytes) {
230
+ const lines = []
231
+ let start = 0
232
+ for (let i = 0; i < bytes.length; i += 1) {
233
+ if (bytes[i] === NEWLINE_BYTE) {
234
+ const end = i > start && bytes[i - 1] === CARRIAGE_RETURN_BYTE ? i - 1 : i
235
+ lines.push(bytes.subarray(start, end))
236
+ start = i + 1
237
+ }
238
+ }
239
+ lines.push(bytes.subarray(start, bytes.length))
240
+ return lines
241
+ }
242
+
243
+ /**
244
+ * True only when the required entry is present as an exact line and no later
245
+ * exact negation of it follows. A same-file negation after the entry makes it
246
+ * ineffective; a later re-assertion after the negation makes it effective
247
+ * again. Purely a byte-level judgment about this one file, independent of
248
+ * ancestor `.gitignore` protection -- the explicit nested entry is required
249
+ * even when an ancestor already ignores the parent directory.
250
+ */
251
+ export function isEntryEffectiveInBytes(bytes) {
252
+ if (!bytes || bytes.length === 0) return false
253
+ let effective = false
254
+ for (const line of splitLinesBytes(bytes)) {
255
+ if (line.equals(REQUIRED_ENTRY_BUFFER)) effective = true
256
+ else if (line.equals(NEGATED_ENTRY_BUFFER)) effective = false
257
+ }
258
+ return effective
259
+ }
260
+
261
+ /** Appends the required entry, preserving existing bytes exactly and adding a separator only if needed. */
262
+ export function composeAppendedBytes(existingBytes) {
263
+ if (!existingBytes || existingBytes.length === 0)
264
+ return REQUIRED_ENTRY_LINE_BUFFER
265
+ const needsSeparator =
266
+ existingBytes[existingBytes.length - 1] !== NEWLINE_BYTE
267
+ return needsSeparator
268
+ ? Buffer.concat([
269
+ existingBytes,
270
+ Buffer.from('\n'),
271
+ REQUIRED_ENTRY_LINE_BUFFER,
272
+ ])
273
+ : Buffer.concat([existingBytes, REQUIRED_ENTRY_LINE_BUFFER])
274
+ }
275
+
276
+ // ── Conflict-checked atomic write ───────────────────────────────────────────
277
+
278
+ function cleanupTemp(tempPath) {
279
+ try {
280
+ if (fs.existsSync(tempPath)) fs.unlinkSync(tempPath)
281
+ } catch {
282
+ // Best-effort cleanup; the original error is what matters.
283
+ }
284
+ }
285
+
286
+ function makeTempPath(parentDir) {
287
+ return path.join(
288
+ parentDir,
289
+ `.gitignore.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`,
290
+ )
291
+ }
292
+
293
+ /**
294
+ * Writes `newContent` to `filePath` only if the file still matches the
295
+ * `existing` snapshot immediately before the rename. Detects a conflicting
296
+ * edit that landed between the initial read and this write; never overwrites
297
+ * an observed conflict, and never rolls back a later editor's change.
298
+ */
299
+ export function writeIgnoreFileIfUnchanged(filePath, existing, newContent) {
300
+ const parentDir = path.dirname(filePath)
301
+ const mode =
302
+ existing.exists && existing.mode !== undefined ? existing.mode : 0o644
303
+ const tempPath = makeTempPath(parentDir)
304
+
305
+ try {
306
+ fs.writeFileSync(tempPath, newContent, { flag: 'wx', mode })
307
+ } catch {
308
+ cleanupTemp(tempPath)
309
+ return { ok: false, reason: 'failed' }
310
+ }
311
+
312
+ let recheck
313
+ try {
314
+ recheck = readTrustedFile(filePath)
315
+ } catch {
316
+ cleanupTemp(tempPath)
317
+ return { ok: false, reason: 'failed' }
318
+ }
319
+
320
+ // A conflict is any observed change to identity, mode, or content: a
321
+ // same-bytes replacement via a different inode, or a mode-only change,
322
+ // both count even though a naive content-only diff would miss them.
323
+ const unchanged = existing.exists
324
+ ? recheck.exists &&
325
+ !recheck.symlink &&
326
+ sameFileIdentity(recheck, existing) &&
327
+ recheck.mode === existing.mode &&
328
+ Buffer.isBuffer(recheck.bytes) &&
329
+ Buffer.isBuffer(existing.bytes) &&
330
+ recheck.bytes.equals(existing.bytes)
331
+ : !recheck.exists
332
+
333
+ if (!unchanged) {
334
+ cleanupTemp(tempPath)
335
+ return { ok: false, reason: 'conflict' }
336
+ }
337
+
338
+ try {
339
+ fs.renameSync(tempPath, filePath)
340
+ } catch {
341
+ cleanupTemp(tempPath)
342
+ return { ok: false, reason: 'failed' }
343
+ }
344
+
345
+ return { ok: true }
346
+ }
347
+
348
+ // ── Git classification ───────────────────────────────────────────────────────
349
+
350
+ const GIT_ENV_STRIP = [
351
+ 'GIT_DIR',
352
+ 'GIT_WORK_TREE',
353
+ 'GIT_INDEX_FILE',
354
+ 'GIT_OBJECT_DIRECTORY',
355
+ 'GIT_CEILING_DIRECTORIES',
356
+ 'GIT_COMMON_DIR',
357
+ 'GIT_NAMESPACE',
358
+ ]
359
+
360
+ function sanitizedGitEnv() {
361
+ const env = { ...process.env }
362
+ for (const key of GIT_ENV_STRIP) delete env[key]
363
+ env.LC_ALL = 'C'
364
+ env.LANG = 'C'
365
+ env.GIT_TERMINAL_PROMPT = '0'
366
+ return env
367
+ }
368
+
369
+ /**
370
+ * Runs a Git subcommand with an argument array, a bounded timeout, a
371
+ * controlled locale, and repository-redirection environment overrides
372
+ * stripped. `options.timeoutMs` defaults to a fixed bound; it is not exposed
373
+ * through a public environment variable -- only internal callers (and their
374
+ * tests) can override it per invocation.
375
+ */
376
+ export function runGit(args, cwd, options = {}) {
377
+ const timeoutMs = options.timeoutMs ?? DEFAULT_GIT_TIMEOUT_MS
378
+ return spawnSync('git', args, {
379
+ cwd,
380
+ encoding: 'utf8',
381
+ env: sanitizedGitEnv(),
382
+ timeout: timeoutMs,
383
+ windowsHide: true,
384
+ })
385
+ }
386
+
387
+ // Controlled-locale message for git's unambiguous "no repository" diagnostic.
388
+ // Deliberately distinct from the corrupt-metadata message ("not a git
389
+ // repository: <path>"), which lacks the parenthetical and must not match.
390
+ const NOT_A_REPO_PATTERN =
391
+ /^fatal: not a git repository \(or any of the parent directories\): /m
392
+
393
+ export function classifyGitWorkTree(root, options = {}) {
394
+ const result = runGit(['rev-parse', '--is-inside-work-tree'], root, options)
395
+
396
+ if (result.error) {
397
+ if (result.error.code === 'ENOENT') return { kind: 'missing-git' }
398
+ if (result.error.code === 'ETIMEDOUT') return { kind: 'timeout' }
399
+ return { kind: 'error' }
400
+ }
401
+ if (result.signal) return { kind: 'timeout' }
402
+
403
+ const stdout = (result.stdout ?? '').trim()
404
+ const stderr = (result.stderr ?? '').trim()
405
+
406
+ if (result.status === 0) {
407
+ // "false" means inside a `.git` directory of a repo with no accessible
408
+ // work tree (e.g. a bare repository) -- ambiguous, not a plain work tree.
409
+ // Distinguishing this from "true" requires reading stdout, not just the
410
+ // (identical, zero) exit status.
411
+ return stdout === 'true' ? { kind: 'work-tree' } : { kind: 'ambiguous' }
412
+ }
413
+
414
+ if (result.status === 128 && NOT_A_REPO_PATTERN.test(stderr)) {
415
+ return { kind: 'non-git' }
416
+ }
417
+
418
+ return { kind: 'error' }
419
+ }
420
+
421
+ /** Confirms the directory and a descendant are both ignored, without creating a canary file. */
422
+ export function verifyEffectiveIgnore(root) {
423
+ const dirCheck = runGit(
424
+ ['check-ignore', '--no-index', '--quiet', '--', `${ARTIFACT_DIR_REL}/`],
425
+ root,
426
+ )
427
+ if (dirCheck.error || dirCheck.signal || dirCheck.status !== 0) return false
428
+
429
+ const descendantCheck = runGit(
430
+ ['check-ignore', '--no-index', '--quiet', '--', ARTIFACT_DESCENDANT_REL],
431
+ root,
432
+ )
433
+ if (
434
+ descendantCheck.error ||
435
+ descendantCheck.signal ||
436
+ descendantCheck.status !== 0
437
+ ) {
438
+ return false
439
+ }
440
+
441
+ return true
442
+ }
443
+
444
+ // ── Orchestration ────────────────────────────────────────────────────────────
445
+
446
+ function blocked(reason) {
447
+ return { exitCode: 2, result: { reason, status: 'blocked' } }
448
+ }
449
+
450
+ function protectedResult(status) {
451
+ return { exitCode: 0, result: { caveats: CAVEATS, status } }
452
+ }
453
+
454
+ /** Converts any propagated non-ENOENT stat failure from `findUnsafeComponent` into the fixed blocked contract instead of an uncaught exception. */
455
+ function findUnsafeComponentOrBlock(root, segments) {
456
+ try {
457
+ return { unsafe: findUnsafeComponent(root, segments) }
458
+ } catch {
459
+ return { blockedResult: blocked(BLOCK.WRITE_FAILED) }
460
+ }
461
+ }
462
+
463
+ export function ensureIgnore(rawRoot) {
464
+ const root = canonicalizeRoot(rawRoot)
465
+ if (!root) return blocked(BLOCK.INVALID_ROOT)
466
+
467
+ const contextCheck = findUnsafeComponentOrBlock(root, ['.context'])
468
+ if (contextCheck.blockedResult) return contextCheck.blockedResult
469
+ if (contextCheck.unsafe) return blocked(BLOCK.SYMLINK_REJECTED)
470
+
471
+ const ignoreCheck = findUnsafeComponentOrBlock(root, [
472
+ '.context',
473
+ '.gitignore',
474
+ ])
475
+ if (ignoreCheck.blockedResult) return ignoreCheck.blockedResult
476
+ if (ignoreCheck.unsafe) return blocked(BLOCK.SYMLINK_REJECTED)
477
+
478
+ const classification = classifyGitWorkTree(root)
479
+ if (classification.kind === 'missing-git') return blocked(BLOCK.MISSING_GIT)
480
+ if (classification.kind === 'timeout') return blocked(BLOCK.GIT_TIMEOUT)
481
+ if (classification.kind === 'ambiguous') return blocked(BLOCK.GIT_AMBIGUOUS)
482
+ if (classification.kind === 'error') return blocked(BLOCK.GIT_ERROR)
483
+
484
+ const contextDir = path.join(root, '.context')
485
+ const ignorePath = path.join(contextDir, '.gitignore')
486
+
487
+ try {
488
+ fs.mkdirSync(contextDir, { recursive: true })
489
+ } catch {
490
+ return blocked(BLOCK.WRITE_FAILED)
491
+ }
492
+ // The directory may have been replaced by a symlink between the earlier
493
+ // check and this creation; re-verify before touching the nested file.
494
+ const recheck = findUnsafeComponentOrBlock(root, ['.context'])
495
+ if (recheck.blockedResult) return recheck.blockedResult
496
+ if (recheck.unsafe) return blocked(BLOCK.SYMLINK_REJECTED)
497
+
498
+ let existing
499
+ try {
500
+ existing = readTrustedFile(ignorePath)
501
+ } catch {
502
+ return blocked(BLOCK.WRITE_FAILED)
503
+ }
504
+ if (existing.symlink) return blocked(BLOCK.SYMLINK_REJECTED)
505
+ if (existing.tooLarge) return blocked(BLOCK.IGNORE_FILE_TOO_LARGE)
506
+ if (existing.changed) return blocked(BLOCK.WRITE_CONFLICT)
507
+
508
+ const existingBytes =
509
+ existing.exists && existing.bytes ? existing.bytes : undefined
510
+
511
+ if (!isEntryEffectiveInBytes(existingBytes)) {
512
+ const newContent = composeAppendedBytes(existingBytes)
513
+ // The prospective composed length -- not an approximation -- must fit
514
+ // the cap before any write is attempted.
515
+ if (newContent.length > IGNORE_FILE_MAX_BYTES) {
516
+ return blocked(BLOCK.IGNORE_FILE_TOO_LARGE)
517
+ }
518
+ const writeResult = writeIgnoreFileIfUnchanged(
519
+ ignorePath,
520
+ existing,
521
+ newContent,
522
+ )
523
+ if (!writeResult.ok) {
524
+ return blocked(
525
+ writeResult.reason === 'conflict'
526
+ ? BLOCK.WRITE_CONFLICT
527
+ : BLOCK.WRITE_FAILED,
528
+ )
529
+ }
530
+ }
531
+
532
+ if (classification.kind === 'non-git') {
533
+ return protectedResult('not-applicable')
534
+ }
535
+
536
+ if (!verifyEffectiveIgnore(root)) return blocked(BLOCK.VERIFY_FAILED)
537
+ return protectedResult('protected')
538
+ }
539
+
540
+ // ── CLI ──────────────────────────────────────────────────────────────────────
541
+
542
+ const KNOWN_FLAGS = new Set(['--root'])
543
+
544
+ /**
545
+ * Strictly parses `--root <value>` and nothing else: an unknown flag, a
546
+ * duplicate `--root`, a missing value, or any stray positional argument is
547
+ * rejected rather than silently accepted or defaulted.
548
+ */
549
+ export function parseArgs(argv) {
550
+ let root
551
+ for (let i = 0; i < argv.length; i += 1) {
552
+ const token = argv[i]
553
+ if (!KNOWN_FLAGS.has(token)) return null
554
+ if (root !== undefined) return null // duplicate --root
555
+ const value = argv[i + 1]
556
+ // Reject a missing value or any value starting with `--` (an option
557
+ // consumed as a path); `./--name` remains a valid literal path.
558
+ if (value === undefined || value.startsWith('--')) return null
559
+ root = value
560
+ i += 1
561
+ }
562
+ return root === undefined ? null : root
563
+ }
564
+
565
+ function main() {
566
+ const root = parseArgs(process.argv.slice(2))
567
+ if (root === null) {
568
+ process.stdout.write(
569
+ `${JSON.stringify({ reason: BLOCK.INVALID_ARGUMENTS, status: 'blocked' })}\n`,
570
+ )
571
+ process.exit(2)
572
+ return
573
+ }
574
+
575
+ // Final boundary: any unanticipated exception still maps to the fixed
576
+ // blocked contract, never a raw error.
577
+ let outcome
578
+ try {
579
+ outcome = ensureIgnore(root)
580
+ } catch {
581
+ process.stdout.write(
582
+ `${JSON.stringify({ reason: BLOCK.WRITE_FAILED, status: 'blocked' })}\n`,
583
+ )
584
+ process.exit(2)
585
+ return
586
+ }
587
+
588
+ const { exitCode, result } = outcome
589
+ process.stdout.write(`${JSON.stringify(result)}\n`)
590
+ process.exit(exitCode)
591
+ }
592
+
593
+ const invokedDirectly =
594
+ process.argv[1] !== undefined &&
595
+ import.meta.url === pathToFileURL(process.argv[1]).href
596
+
597
+ if (invokedDirectly) {
598
+ main()
599
+ }