@juzser/blacksmith 0.2.0 → 0.4.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.
Files changed (71) hide show
  1. package/.claude/agents/auditor.md +3 -0
  2. package/.claude/agents/grader.md +12 -5
  3. package/.claude/agents/reviewer.md +3 -0
  4. package/.claude/agents/security-reviewer.md +3 -0
  5. package/.claude/agents/spec-reviewer.md +3 -0
  6. package/.claude/agents/verifier.md +3 -0
  7. package/.claude/skills/bs/audit.md +25 -9
  8. package/.claude/skills/bs/dispatch.md +60 -7
  9. package/.claude/skills/bs/plan.md +33 -1
  10. package/.claude/skills/bs/run.md +18 -13
  11. package/.claude/skills/bs/wave.md +68 -10
  12. package/README.md +4 -4
  13. package/factory/orchestrator/dist/agentsSync.js +178 -0
  14. package/factory/orchestrator/dist/audit.js +134 -17
  15. package/factory/orchestrator/dist/autonomy.js +25 -0
  16. package/factory/orchestrator/dist/budgets.js +91 -2
  17. package/factory/orchestrator/dist/claims.js +8 -4
  18. package/factory/orchestrator/dist/cli.js +394 -26
  19. package/factory/orchestrator/dist/crossFinding.js +20 -0
  20. package/factory/orchestrator/dist/daemon.js +230 -41
  21. package/factory/orchestrator/dist/db/projector.js +93 -36
  22. package/factory/orchestrator/dist/db/queries.js +58 -35
  23. package/factory/orchestrator/dist/db/schema.js +24 -0
  24. package/factory/orchestrator/dist/dispatchAudit.js +10 -2
  25. package/factory/orchestrator/dist/dispatchLint.js +132 -0
  26. package/factory/orchestrator/dist/epic.js +192 -39
  27. package/factory/orchestrator/dist/epicWidth.js +25 -20
  28. package/factory/orchestrator/dist/errorIssues.js +209 -24
  29. package/factory/orchestrator/dist/eventOrder.js +102 -0
  30. package/factory/orchestrator/dist/events.js +73 -91
  31. package/factory/orchestrator/dist/findingContext.js +1 -13
  32. package/factory/orchestrator/dist/findings.js +132 -11
  33. package/factory/orchestrator/dist/gh.js +29 -6
  34. package/factory/orchestrator/dist/git.js +20 -2
  35. package/factory/orchestrator/dist/harness.js +92 -10
  36. package/factory/orchestrator/dist/hookDecision.js +320 -37
  37. package/factory/orchestrator/dist/impact.js +24 -2
  38. package/factory/orchestrator/dist/integration.js +11 -1
  39. package/factory/orchestrator/dist/issueReporter.js +415 -0
  40. package/factory/orchestrator/dist/judgeStopHook.js +181 -0
  41. package/factory/orchestrator/dist/judges.js +1 -1
  42. package/factory/orchestrator/dist/lessons.js +46 -18
  43. package/factory/orchestrator/dist/logCache.js +291 -0
  44. package/factory/orchestrator/dist/paths.js +10 -0
  45. package/factory/orchestrator/dist/plan.js +138 -4
  46. package/factory/orchestrator/dist/planQuorum.js +1 -1
  47. package/factory/orchestrator/dist/policy.js +57 -11
  48. package/factory/orchestrator/dist/projects.js +12 -1
  49. package/factory/orchestrator/dist/queue.js +477 -29
  50. package/factory/orchestrator/dist/scheduler.js +51 -0
  51. package/factory/orchestrator/dist/severity.js +40 -8
  52. package/factory/orchestrator/dist/spec.js +70 -0
  53. package/factory/orchestrator/dist/taskEvents.js +13 -4
  54. package/factory/orchestrator/dist/taskStatus.js +74 -0
  55. package/factory/orchestrator/dist/taxonomy.js +11 -0
  56. package/factory/orchestrator/dist/testgate.js +32 -3
  57. package/factory/orchestrator/dist/usage.js +42 -9
  58. package/factory/orchestrator/dist/waivers.js +60 -9
  59. package/factory/orchestrator/dist/waveConcurrency.js +29 -0
  60. package/factory/orchestrator/dist/waveNext.js +12 -10
  61. package/factory/orchestrator/dist/waveSchedule.js +84 -0
  62. package/factory/orchestrator/dist/worktree.js +19 -6
  63. package/factory/orchestrator/drizzle/0012_issue_reports_error_issues.sql +20 -0
  64. package/factory/orchestrator/drizzle/meta/_journal.json +7 -0
  65. package/factory/policies/budgets.yml +9 -2
  66. package/factory/policies/crosscheck.yml +2 -2
  67. package/factory/policies/lessons.md +298 -0
  68. package/factory/policies/scheduler.yml +27 -2
  69. package/factory/policies/taxonomy.yml +10 -3
  70. package/factory/specs/roadmap.md +70 -23
  71. package/package.json +8 -8
@@ -11,6 +11,8 @@
11
11
  // `policyHook.ts` be an entry point whose imports are only what deciding
12
12
  // actually needs, while `cli.ts` keeps `smith policy hook` by calling the
13
13
  // same function rather than a second copy of it.
14
+ import { accessSync, constants, realpathSync, statSync } from 'node:fs';
15
+ import path from 'node:path';
14
16
  import { detectCurrentBranch, detectRepoRoot, evaluateCommand, loadGuardrailPolicy, } from './policy.js';
15
17
  import { activeSandboxFor } from './sandbox.js';
16
18
  /**
@@ -32,7 +34,9 @@ import { activeSandboxFor } from './sandbox.js';
32
34
  * again: the first is failure, the second is a real allow, and only guard.sh's
33
35
  * fail-closed handling of a non-zero exit may turn "failure" into a denial.
34
36
  */
35
- export function decideHookPayload(raw, fallbackCwd) {
37
+ export function decideHookPayload(raw, fallbackCwd,
38
+ /** Where judge leases live; the live `state/sandboxes` unless a test points elsewhere. */
39
+ leaseDir) {
36
40
  const payload = JSON.parse(raw);
37
41
  const toolName = typeof payload.tool_name === 'string' ? payload.tool_name : '';
38
42
  const command = typeof payload.tool_input?.command === 'string' ? payload.tool_input.command : '';
@@ -44,34 +48,72 @@ export function decideHookPayload(raw, fallbackCwd) {
44
48
  // handed. Same shape as `command`: a missing or non-string field reads as
45
49
  // absent, never as a guess.
46
50
  const filePath = typeof payload.tool_input?.file_path === 'string' ? payload.tool_input.file_path : null;
47
- // Branch and repo root come from where the command would actually run, which
48
- // the PreToolUse payload carries as `cwd`. Resolving them from this binary's
49
- // own checkout instead would be wrong in the case this factory spends most of
50
- // its time in: guard.sh always execs the main clone's dist, while the session
51
- // issuing the command is usually inside a per-task git worktree on a
52
- // different branch. That gap is not academic — `git rebase` on
53
- // smith/<epic>/integration is a rule-5 denial, and the merge queue performs
54
- // rebases from a worktree standing on exactly that branch while the main
55
- // clone stands elsewhere. The old bash hook got this right for free by
56
- // running `git rev-parse` in its own inherited cwd; the port has to ask for
57
- // it. `fallbackCwd` covers a payload without the field (a hand-driven
58
- // invocation, or an older client), and a cwd outside any repo resolves to
59
- // ''/null, which denies an out-of-bounds removal rather than allowing it.
51
+ // Branch and repo root come from where the command would actually run.
52
+ // Resolving them from this binary's own checkout would be wrong in the case
53
+ // this factory spends most of its time in: guard.sh always execs the main
54
+ // clone's dist, while the session issuing the command is usually inside a
55
+ // per-task git worktree on a different branch. That gap is not academic —
56
+ // `git rebase` on smith/<epic>/integration is a rule-5 denial, and the merge
57
+ // queue performs rebases from a worktree standing on exactly that branch
58
+ // while the main clone stands elsewhere. `fallbackCwd` covers a payload
59
+ // without the field (a hand-driven invocation, or an older client), and a
60
+ // cwd outside any repo resolves to ''/null, which denies an out-of-bounds
61
+ // removal rather than allowing it.
62
+ //
63
+ // The payload's `cwd` is where the SESSION stands, though, and a command can
64
+ // move before it acts. `cd <worktree> && git merge main` from a session in
65
+ // the main clone runs on the worktree's side branch, so judging it on `main`
66
+ // is a false deny; `cd <main clone> && git merge x` from a worktree session
67
+ // is the matching false allow. So one narrow shape — `shortcutDirectories`
68
+ // — is judged in its target alone, dropping `cwd`; that is the ONLY way this
69
+ // function can ever be more lenient than judging `cwd`, so the shape is an
70
+ // allowlist and anything it cannot read with certainty (including a target
71
+ // that is not on a named branch of a repo) falls back. The fallback judges
72
+ // `cwd` plus every directory the command names, and the command is denied
73
+ // if ANY of them denies it, so it is never more lenient than `cwd` alone.
74
+ // A command naming no directory change is judged on `cwd` alone, as before.
60
75
  const cwd = typeof payload.cwd === 'string' && payload.cwd !== '' ? payload.cwd : fallbackCwd;
61
- const context = {
62
- toolName,
63
- command,
64
- branch: detectCurrentBranch(cwd),
65
- repoRoot: detectRepoRoot(cwd),
66
- // Resolved from the same `cwd`, and for the same reason: the lease is keyed
67
- // by the worktree a judge was handed, and `cwd` is the only thing in a
68
- // PreToolUse payload that says where the command will run. No lease is the
69
- // ordinary case and costs one directory read.
70
- sandbox: activeSandboxFor(cwd),
71
- filePath,
72
- };
73
- const decision = evaluateCommand(context, loadGuardrailPolicy());
74
- if (decision.allowed) {
76
+ const policy = loadGuardrailPolicy();
77
+ // The lease binds the session a judge was handed, so it is read from `cwd`
78
+ // whatever the command does next: reading it only from the cd target would
79
+ // let `cd <elsewhere> && curl …` leave the sandbox in one hop. A lease over
80
+ // a target is checked too, so moving INTO a leased worktree is bound by it.
81
+ // No lease is the ordinary case and costs one directory read.
82
+ const sessionLease = activeSandboxFor(cwd, leaseDir);
83
+ const locate = (dir) => ({
84
+ dir,
85
+ branch: detectCurrentBranch(dir),
86
+ repoRoot: detectRepoRoot(dir),
87
+ });
88
+ const certain = MOVES_RE.test(command) ? shortcutDirectories(command, cwd)?.map(locate) : null;
89
+ // `HEAD` is what a detached worktree's branch reads as — not a name a
90
+ // protected-branch rule can ever match, and guardrails.md requires the
91
+ // shortcut's target to sit on a named branch.
92
+ const places = certain?.every((p) => p.branch !== '' && p.branch !== 'HEAD' && p.repoRoot !== null) === true
93
+ ? certain
94
+ : fallbackDirectories(command, cwd).map(locate);
95
+ let reason = null;
96
+ for (const { dir, branch, repoRoot } of places) {
97
+ const targetLease = dir === cwd ? null : activeSandboxFor(dir, leaseDir);
98
+ for (const sandbox of distinctLeases(sessionLease, targetLease)) {
99
+ const context = { toolName, command, branch, repoRoot, sandbox, filePath };
100
+ const decision = evaluateCommand(context, policy);
101
+ // guard.sh only ever surfaced one reason: each rule block()ed and exited
102
+ // immediately on its own match, sequentially, so the first rule in
103
+ // guard.sh's 1-6 order to fire is the only one an agent ever saw.
104
+ // evaluateCommand's checks array preserves that order, so violations[0]
105
+ // of the first denying evaluation reproduces guard.sh's visible
106
+ // behaviour even though evaluateCommand (policy check's diagnostic
107
+ // output) reports every rule that tripped.
108
+ if (!decision.allowed) {
109
+ reason = decision.violations[0]?.reason ?? 'guardrails.yml denied this command.';
110
+ break;
111
+ }
112
+ }
113
+ if (reason !== null)
114
+ break;
115
+ }
116
+ if (reason === null) {
75
117
  // Allow prints NOTHING, on purpose, and this is not a cosmetic choice.
76
118
  // Claude Code reads an explicit `permissionDecision: "allow"` from a hook
77
119
  // as "skip the permission system for this call" — it outranks the
@@ -87,15 +129,6 @@ export function decideHookPayload(raw, fallbackCwd) {
87
129
  // load-bearing the way the old hook's was.
88
130
  return null;
89
131
  }
90
- // guard.sh only ever surfaced one reason: each rule block()ed and exited
91
- // immediately on its own match, sequentially, so the first rule in guard.sh's
92
- // 1-6 order to fire is the only one an agent ever saw. evaluateCommand's
93
- // checks array preserves that same order, so violations[0] reproduces
94
- // guard.sh's exact visible behaviour even though evaluateCommand (policy
95
- // check's diagnostic output) reports every rule that tripped, not just the
96
- // first.
97
- const first = decision.violations[0];
98
- const reason = first ? first.reason : 'guardrails.yml denied this command.';
99
132
  return {
100
133
  hookSpecificOutput: {
101
134
  hookEventName: 'PreToolUse',
@@ -104,3 +137,253 @@ export function decideHookPayload(raw, fallbackCwd) {
104
137
  },
105
138
  };
106
139
  }
140
+ /**
141
+ * The one shape judged in its target alone, or `null` to fall back. An
142
+ * allowlist, because this is the only path that drops `cwd` and so the only
143
+ * one that can allow what `cwd` alone would deny:
144
+ *
145
+ * - `cd <target> && <cmd> && <cmd> …`: a cd, then plain words joined by
146
+ * nothing but `&&` — so everything after runs in the target or not at
147
+ * all. A relative target must start with `./` or `../` (or be `.`/`..`),
148
+ * since a bare name is looked up on CDPATH first.
149
+ * - `git -C <target> <plain words>`, alone.
150
+ *
151
+ * A plain word is unquoted and uses only `PLAIN_WORD_RE`'s characters: no
152
+ * `;`, `|`, `&`, newline, backslash, `$`, backtick, quote, parens, glob or
153
+ * redirection. The target may also be one whole single- or double-quoted
154
+ * span. Every `&&`-joined command after the target must be a `git`
155
+ * invocation whose subcommand sits in `GIT_SUBCOMMAND_ALLOWLIST` —
156
+ * positively, not a denylist of mover words: any plain word could be a shell
157
+ * alias or function (zsh's autopushd defines `-` and `1`..`9` as `cd`
158
+ * shortcuts), so nothing short of "must be a git builtin that cannot run a
159
+ * command in another repo" is sound. `checkout` and `switch` are excluded
160
+ * even though they only ever touch the repo they run in: either can move the
161
+ * target onto a different branch mid-command, and the branch judged is read
162
+ * once, before the command runs — `cd <W> && git checkout main && git merge
163
+ * x` would be judged on W's branch before the checkout, not the `main` it
164
+ * actually merges onto. Git builtins cannot be shadowed by a git alias; a
165
+ * shell alias named `git` itself is out of scope. A mover flag
166
+ * (`MOVER_FLAG_RE`), a short option bundling `x` or `s`
167
+ * (`hasDangerousShortOption` — rebase `-x`/`-kx`, an arbitrary command; merge
168
+ * or checkout `-s`, a strategy or search run off PATH as `git-<name>`), or a
169
+ * long option starting `--e`/`--s` (`hasDangerousLongOption` — `--exec`,
170
+ * `--strategy`, and their `=value` forms) still forfeits, and so does a git
171
+ * location override anywhere. Returns the target lexically and physically
172
+ * when the two differ (a cd is logical, git's chdir physical), and the
173
+ * caller falls back too if any of them is not on a named branch of a repo.
174
+ */
175
+ function shortcutDirectories(command, cwd) {
176
+ // No `.trim()`/split reliance below this line for a security decision: both
177
+ // read NBSP, BOM and form-feed as whitespace and drop them, while a shell's
178
+ // IFS does not — so a target's trailing NBSP would parse away to a clean,
179
+ // different directory than the one the shell actually enters.
180
+ if (NON_ASCII_RE.test(command))
181
+ return null;
182
+ if (GIT_LOCATION_RE.test(command) || /[\r\n]/.test(command.trim()))
183
+ return null;
184
+ const segments = command
185
+ .trim()
186
+ .split('&&')
187
+ .map((segment) => segment.trim().split(/[ \t]+/));
188
+ const [first = [], ...rest] = segments;
189
+ let target;
190
+ let after;
191
+ if (first.length === 2 && first[0] === 'cd' && rest.length > 0) {
192
+ target = first[1] ?? '';
193
+ after = rest.flat();
194
+ if (!/^\.{1,2}(?:\/|$)|^\//.test(unquote(target) ?? ''))
195
+ return null;
196
+ if (!rest.every((segment) => segment[0] === 'git' && GIT_SUBCOMMAND_ALLOWLIST.has(segment[1] ?? '')))
197
+ return null;
198
+ }
199
+ else if (segments.length === 1 && first[0] === 'git' && first[1] === '-C' && first.length > 3) {
200
+ target = first[2] ?? '';
201
+ after = first.slice(3);
202
+ if (!GIT_SUBCOMMAND_ALLOWLIST.has(after[0] ?? ''))
203
+ return null;
204
+ }
205
+ else {
206
+ return null;
207
+ }
208
+ const plain = (word) => PLAIN_WORD_RE.test(word) && !MOVER_WORDS.has(word) && !MOVER_FLAG_RE.test(word);
209
+ if (!after.every(plain))
210
+ return null;
211
+ if (after.some((word) => hasDangerousShortOption(word) || hasDangerousLongOption(word)))
212
+ return null;
213
+ if (!PLAIN_WORD_RE.test(target) && !/^'[^']*'$|^"[^"]*"$/.test(target))
214
+ return null;
215
+ return literalDirectories(target, cwd);
216
+ }
217
+ /** Every directory a command names, plus `cwd` — the fail-closed fallback. */
218
+ function fallbackDirectories(command, cwd) {
219
+ const dirs = [cwd];
220
+ for (const [, word = ''] of command.matchAll(TARGET_RE)) {
221
+ // A relative hop is resolved against every directory found so far, so
222
+ // `cd a && cd b` still reaches `a/b`: an extra candidate costs nothing
223
+ // but a possible denial, and a missed one is a hole.
224
+ for (const base of [...dirs]) {
225
+ for (const target of literalDirectories(word, base) ?? []) {
226
+ if (!dirs.includes(target))
227
+ dirs.push(target);
228
+ }
229
+ }
230
+ }
231
+ return dirs;
232
+ }
233
+ /**
234
+ * Anything that could put a command somewhere other than where it started.
235
+ * Deliberately loose — `echo cd` or `git commit -C HEAD` match too — because
236
+ * a match only forfeits the no-move path, never a check.
237
+ */
238
+ const MOVES_RE = /(?:^|[\s;&|(])(?:cd|pushd|popd)(?=$|[\s;&|)])|\s-C(?:\s|$)|[()`]/;
239
+ /** A word the shortcut reads as itself: nothing the shell would expand, split or redirect. */
240
+ const PLAIN_WORD_RE = /^[A-Za-z0-9_./:@%+,=-]+$/;
241
+ /**
242
+ * Anything outside printable ASCII (tab allowed as a separator), forfeiting
243
+ * the shortcut before any `.trim()`/split touches the command — see
244
+ * `shortcutDirectories`'s note on why those cannot be trusted past this
245
+ * point.
246
+ */
247
+ const NON_ASCII_RE = /[^\t\x20-\x7E]/;
248
+ /**
249
+ * Git builtins the shortcut may follow a `cd`/`-C` target with: read-only or
250
+ * ordinary write commands that act on the repo they are invoked in, never on
251
+ * another one, and that cannot themselves move the target onto a different
252
+ * branch than the one just read. Deliberately excludes indirect executors
253
+ * that run a git command somewhere else (`for-each-repo`, `submodule`),
254
+ * anything that rewrites history destructively outside a plain invocation
255
+ * (`filter-branch`, `filter-repo`, `worktree`, `bisect`), and `checkout`/
256
+ * `switch` — either can move the target's branch mid-command, after
257
+ * `shortcutDirectories`' caller has already read it once, so a later command
258
+ * in the same chain would be judged on the branch the target left rather
259
+ * than the one it moved to. A git builtin cannot be shadowed by a git alias,
260
+ * which is what makes this allowlist sound.
261
+ */
262
+ const GIT_SUBCOMMAND_ALLOWLIST = new Set([
263
+ 'status',
264
+ 'log',
265
+ 'diff',
266
+ 'show',
267
+ 'add',
268
+ 'commit',
269
+ 'merge',
270
+ 'rebase',
271
+ 'push',
272
+ 'fetch',
273
+ 'pull',
274
+ 'branch',
275
+ 'reset',
276
+ 'rev-parse',
277
+ 'tag',
278
+ 'stash',
279
+ ]);
280
+ /**
281
+ * A short option (`-x`, `-kx`, `-x./s`) that bundles `x` or `s` among its
282
+ * option letters, read up to the first non-letter (a stuck argument, `=`, or
283
+ * end of word) — `git rebase -x <cmd>`/`-kx<cmd>` runs an arbitrary command,
284
+ * and `git merge -s <strategy>`/`git checkout -s` (`-s` is a bundlable short
285
+ * flag on several subcommands) can run `git-<strategy>` off PATH. A plain
286
+ * number (`-5`) is exempt, since `git log -5` is common and harmless.
287
+ */
288
+ function hasDangerousShortOption(word) {
289
+ if (!word.startsWith('-') || word.startsWith('--'))
290
+ return false;
291
+ if (/^-\d+$/.test(word))
292
+ return false;
293
+ const letters = /^-([A-Za-z]+)/.exec(word)?.[1] ?? '';
294
+ return letters.includes('x') || letters.includes('s');
295
+ }
296
+ /**
297
+ * A long option that could be `--exec`/`--exec=…` (rebase, arbitrary command)
298
+ * or `--strategy`/`--strategy-option=…` (merge/rebase, runs `git-<name>` off
299
+ * PATH) by prefix rather than exact match, since either can carry `=value`.
300
+ * Over-forfeits `--edit`, `--squash`, and the like on purpose: forfeiting
301
+ * only falls back to the full check, never widens what the shortcut allows.
302
+ */
303
+ function hasDangerousLongOption(word) {
304
+ return word.startsWith('--e') || word.startsWith('--s');
305
+ }
306
+ /** Commands that can move the shell, or run a command string somewhere else. */
307
+ const MOVER_WORDS = new Set([
308
+ 'cd',
309
+ 'pushd',
310
+ 'popd',
311
+ 'eval',
312
+ 'source',
313
+ '.',
314
+ 'exec',
315
+ 'command',
316
+ 'builtin',
317
+ 'env',
318
+ 'sudo',
319
+ 'xargs',
320
+ 'find',
321
+ 'sh',
322
+ 'bash',
323
+ 'zsh',
324
+ 'dash',
325
+ 'ksh',
326
+ ]);
327
+ /** Flags that move a command (`-C`, `--chdir`, `--directory`) or configure git (`-c`). */
328
+ const MOVER_FLAG_RE = /^(?:-C|-c$|--chdir|--directory)/;
329
+ /** Git location overrides: they point git at another repo whatever the cwd. */
330
+ const GIT_LOCATION_RE = /GIT_DIR|GIT_WORK_TREE|GIT_COMMON_DIR|--git-dir|--work-tree/;
331
+ /** One word: double-quoted, single-quoted, or bare up to a separator. */
332
+ const WORD = `("[^"]*"|'[^']*'|[^\\s;&|<>()]+)`;
333
+ /** Every `cd`/`pushd <word>` and every `-C <word>`, wherever it sits. */
334
+ const TARGET_RE = new RegExp(`(?:(?:^|[\\s;&|(])(?:cd|pushd)|\\s-C)\\s+${WORD}`, 'g');
335
+ /**
336
+ * `word` with its quotes removed, or `null` when it is quoted any other way
337
+ * than one whole span: `"a"/"b"` is two spans the shell splices, and reading
338
+ * it as one quoted `a"/"b` names a different directory.
339
+ */
340
+ function unquote(word) {
341
+ if (/^'[^']*'$/.test(word) || /^"[^"]*"$/.test(word))
342
+ return word.slice(1, -1);
343
+ return /["'\\]/.test(word) ? null : word;
344
+ }
345
+ /**
346
+ * `word` as an existing, enterable directory resolved against `base` —
347
+ * lexically (where a logical `cd` lands) and physically (where git's chdir
348
+ * lands, `..` after a symlink included), both when they differ — or `null`
349
+ * when it is not a literal path: an expansion (`$`, backtick, `~`), a glob, a
350
+ * flag or `-`, an escape, a quote this does not read, or a path that does not
351
+ * resolve. A cd there fails and runs nothing after its `&&`, and this is not
352
+ * the place to guess what it meant.
353
+ */
354
+ function literalDirectories(word, base) {
355
+ const bare = unquote(word);
356
+ if (bare === null || bare === '' || bare.startsWith('-') || /[$`~*?[\]\\]/.test(bare)) {
357
+ return null;
358
+ }
359
+ const lexical = path.resolve(base, bare);
360
+ try {
361
+ // `native` is realpath(3); the JS realpathSync resolves `..` lexically first.
362
+ const physical = realpathSync.native(path.isAbsolute(bare) ? bare : `${base}/${bare}`);
363
+ if (!statSync(physical).isDirectory())
364
+ return null;
365
+ accessSync(physical, constants.X_OK);
366
+ if (lexical === physical || !isDirectory(lexical))
367
+ return [physical];
368
+ return [lexical, physical];
369
+ }
370
+ catch {
371
+ return null;
372
+ }
373
+ }
374
+ function isDirectory(dir) {
375
+ try {
376
+ return statSync(dir).isDirectory();
377
+ }
378
+ catch {
379
+ return false;
380
+ }
381
+ }
382
+ /** The leases to judge under — `[null]` for none, so one unleased evaluation still runs. */
383
+ function distinctLeases(session, target) {
384
+ if (session === null)
385
+ return [target];
386
+ if (target === null || target.worktreeDir === session.worktreeDir)
387
+ return [session];
388
+ return [session, target];
389
+ }
@@ -216,6 +216,11 @@ function consumes(names, symbol) {
216
216
  /**
217
217
  * A promise against the diff of the file it names. A file the diff does not
218
218
  * hold was not touched, so its exports are as they were: kept.
219
+ *
220
+ * That inference is only sound because `collectExportDiffs` never drops a
221
+ * promised file: whatever it cannot read it hands over as `unverifiable`
222
+ * rather than leaving out. Absent therefore means untouched here, and can go
223
+ * on meaning it only while that stays true on the other side.
219
224
  */
220
225
  function verdictOn(file, diffs) {
221
226
  const diff = diffs.find((entry) => entry.file === file);
@@ -322,13 +327,30 @@ export function exportImpact(graph, diffs, claims, keepsExports = []) {
322
327
  * `ls-tree` before `show` on purpose — a file added on this branch is absent
323
328
  * from the integration branch, and asking `show` for it would raise an error
324
329
  * that means "new file", which is not an error at all.
330
+ *
331
+ * `keepsExports` is here because silence means two different things downstream.
332
+ * A changed file this scanner does not speak for — a `.yml`, a lockfile, a
333
+ * `.vue` — is left out, and for a file nobody named that is right: reporting
334
+ * every one of them as a hole is the noise the header refuses to make. But
335
+ * `verdictOn` reads a promised file's absence as "not touched, so kept", and
336
+ * for a file the diff dropped that inference is false — the task changed it,
337
+ * nobody read it, and the strongest verdict in this module is minted over a
338
+ * file that was never opened. A promise is a question that was explicitly
339
+ * asked, so it gets an explicit answer: unreadable here is `unverifiable`,
340
+ * which `verdictOn` turns into `unverified` and the header's "reported, never
341
+ * fatal" covers. Skipped stays skipped for everything else.
325
342
  */
326
- export function collectExportDiffs(worktreeDir, changedFiles) {
343
+ export function collectExportDiffs(worktreeDir, changedFiles, keepsExports = []) {
327
344
  const integrationBranch = integrationBranchFor(worktreeDir);
345
+ const promised = new Set(keepsExports);
328
346
  const diffs = [];
329
347
  for (const file of changedFiles) {
330
- if (!DEFAULT_SOURCE_EXTENSIONS.includes(extname(file)))
348
+ if (!DEFAULT_SOURCE_EXTENSIONS.includes(extname(file))) {
349
+ if (promised.has(file)) {
350
+ diffs.push({ file, removed: [], added: [], signatureChanged: [], unverifiable: true });
351
+ }
331
352
  continue;
353
+ }
332
354
  const listed = runGit(worktreeDir, [
333
355
  'ls-tree',
334
356
  '-r',
@@ -1,5 +1,5 @@
1
1
  import { SmithError } from './errors.js';
2
- import { appendEvent } from './events.js';
2
+ import { appendEvent, validateEventEnvelope } from './events.js';
3
3
  import { runGit as git } from './git.js';
4
4
  import { run } from './testgate.js';
5
5
  import { integrationBranchName, RESERVED_TASK_ID } from './worktree.js';
@@ -75,6 +75,16 @@ export function latestIntegrationCheck(events, epicId) {
75
75
  * one thing this must never do is write down a pass that nothing earned.
76
76
  */
77
77
  export async function runIntegrationCheck(input, ctx, opts = {}) {
78
+ // Refused before the check suite below spends a full run on it: a bad
79
+ // --causal-parent used to surface only at the append this function makes
80
+ // once the suite has already finished (D-42's shape, one level up — the
81
+ // waste here is CPU time, not a git mutation, but the same envelope
82
+ // `appendEvent` would refuse anyway, so refuse it first).
83
+ await validateEventEnvelope({
84
+ session_id: ctx.sessionId,
85
+ event_type: INTEGRATION_CHECK_EVENT,
86
+ causal_parent: ctx.causalParent,
87
+ }, opts);
78
88
  const { epicId, projectDir } = input;
79
89
  const branch = integrationBranchName(epicId);
80
90
  // testgate.run([]) returns pass: true for an empty list — correct there