@davesheffer/hunch 1.45.1 → 1.45.2

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/dist/cli/index.js CHANGED
@@ -27,7 +27,7 @@ import { HUNCH_VERSION } from "../core/version.js";
27
27
  import { registerIntegrationCommands } from "./integrations.js";
28
28
  import { registerTaskReportCommands } from "./taskReport.js";
29
29
  import { registerStateCommands } from "./state.js";
30
- import { registerServeCommands } from "./serve.js";
30
+ import { DEFAULT_CONFIG as SERVE_DEFAULT_CONFIG_FILE, registerServeCommands } from "./serve.js";
31
31
  import { registerUpdateCommand } from "./update.js";
32
32
  import { registerReviewMemoryCommands } from "./reviewMemory.js";
33
33
  import { detectInitiator, normalizeInitiator } from "../synthesis/initiator.js";
@@ -43,7 +43,7 @@ import { isParserLoadError, loadNativeTreeSitter } from "../extractors/nativeTre
43
43
  import { syncCommit, recordFailure, captureTestRun } from "../synthesis/synthesize.js";
44
44
  import { parseTestReport } from "../extractors/testreport.js";
45
45
  import { readSynthesisPreference, resolveSynthesisProvider, selectProvider, SYNTH_PREFERENCES, writeSynthesisPreference, normalizeProviderName, } from "../synthesis/provider.js";
46
- import { isGitRepo, isGitRepoRoot, sameGitPublication, sameRemoteUrl, canonicalRemoteUrl, repositoryUsesRemote, headSha, isolatedHeadSha, logSince, lastChangeDate, checkFreshnessRevision, teamPullIsFresh, firstCommitForFile, stagedFiles, workingFiles, commitFiles, asOfDate, stagedGateDiff, workingGateDiff, commitGateDiff, rangeFiles, rangeGateDiff, rangeCommits, revExists, revParse, commitAndPushHunch, pullHunchStatus, syncExistingHunch, gitUntrackCached, gitCommonDir, checkoutCommonDir, separateGitDirCandidate, separateGitDirLink, hooksDir, isLinkedWorktree, mainWorktreeRoot, gitMemoryLog, memoryMoveDiff, revertMemoryMove, pushCurrentBranch, commitChanges, commitRepairStatusAgainst, commitPatchIds, mergeRangeChanges, commitsExist } from "../extractors/git.js";
46
+ import { isGitRepo, isGitRepoRoot, sameGitPublication, sameRemoteUrl, canonicalRemoteUrl, repositoryUsesRemote, headSha, isolatedHeadSha, logSince, lastChangeDate, checkFreshnessRevision, teamPullIsFresh, firstCommitForFile, stagedFiles, workingFiles, commitFiles, asOfDate, stagedGateDiff, workingGateDiff, commitGateDiff, rangeFiles, rangeGateDiff, rangeCommits, revExists, revParse, commitAndPushHunch, pullHunchStatus, pullHunchStatusDetailed, describePullFailure, syncExistingHunch, gitUntrackCached, gitCommonDir, checkoutCommonDir, separateGitDirCandidate, separateGitDirLink, hooksDir, isLinkedWorktree, mainWorktreeRoot, gitMemoryLog, memoryMoveDiff, revertMemoryMove, pushCurrentBranch, commitChanges, commitRepairStatusAgainst, commitPatchIds, mergeRangeChanges, commitsExist } from "../extractors/git.js";
47
47
  import { runbookDraft } from "../core/runbookDraft.js";
48
48
  import { parseMemoryLog } from "../core/memorylog.js";
49
49
  import { renamesOf, planRepair, repairDecision, repairConstraint } from "../core/repair.js";
@@ -62,7 +62,7 @@ import { flushCapture, flushMemoryHome, flushMemoryHomes, pinSharedRemote, share
62
62
  import { installMergeDriver } from "../integrations/mergeDriver.js";
63
63
  import { ensureGitignore, ignoreHunchMemory, HUNCH_MEMORY_DIRS, upgradeManagedGitignore, describeGitignoreUpgrade, memoryIgnoreRule, describeMemoryIgnoreRule } from "../integrations/gitignore.js";
64
64
  import { writeCiWorkflow } from "../integrations/ciAction.js";
65
- import { applyUninstall, formatUninstallPlan, isOptedOut, OPT_OUT_FILE, optOutMessage, planUninstall } from "../integrations/uninstall.js";
65
+ import { applyUninstall, formatUninstallPlan, isOptedOut, OPT_OUT_FILE, optedOutAt, optOutMessage, planUninstall } from "../integrations/uninstall.js";
66
66
  import { updateClaudeMd, renderHunchSection } from "../integrations/claudemd.js";
67
67
  import { HOOK_REMINDER } from "../core/hookText.js";
68
68
  import { measureFootprint } from "../core/footprint.js";
@@ -102,7 +102,7 @@ import { renderRecalledLine } from "../core/taskReportRender.js";
102
102
  import { closeHookTask, hookReportTaskId, isNotificationPrompt, nativeHookCwd, settleHookSession, startHookReport, stopHookReport, observeHookDenial } from "../core/taskReportHook.js";
103
103
  import { persistTaskRecord } from "../core/taskRecord.js";
104
104
  import { recordHookObservation } from "../core/hookObservations.js";
105
- import { contextHookOutput, deliversPromptEvent, denyHookOutput, hookProvider, normalizeHookEvent, stopHookOutput } from "../core/agenthook.js";
105
+ import { contextHookOutput, deliversCommandOutcome, deliversPromptEvent, denyHookOutput, hookProvider, normalizeHookEvent, stopHookOutput } from "../core/agenthook.js";
106
106
  import { PIPELINE_LOOP, armExecutionObligations, beforeEditProbeVerdict, compileExecutableProbes, environmentExecutableProbes, environmentExecutionObligations, executionObligationBrief, isProductPath, loadPipelineState, onCommand, onEdit, onPrompt, onSkill, pipelineEnabled, proofCheckpoint, savePipelineState, stopVerdict, onLessonsDelivered, lessonReminder, unverifiedNag, } from "../core/pipeline.js";
107
107
  import { draftDuplicateOf, isAcceptedDuplicateAnchor } from "../core/dupdetect.js";
108
108
  import { planAutoReview, planMutations } from "../core/autoreview.js";
@@ -147,6 +147,7 @@ import { partitionOf, stateHomeFor } from "../store/stateBinding.js";
147
147
  import { scopePath } from "../core/stateContract.js";
148
148
  import { movePublicMemoryToPrivate } from "../store/privateMigrate.js";
149
149
  import { ENTITY_KINDS } from "../core/types.js";
150
+ import { formatWhyRecord } from "../core/whyRecord.js";
150
151
  import { planCompaction } from "../store/compact.js";
151
152
  import { repairDecisionReference } from "../core/refrepair.js";
152
153
  import { resolveInvocation, dim, synthesisStatusLines, maybeWarnOllamaContext } from "./invocation.js";
@@ -234,7 +235,20 @@ program.hook("preAction", (_thisCommand, actionCommand) => {
234
235
  const name = top.name();
235
236
  if (OPT_OUT_ALLOWED.has(name))
236
237
  return;
237
- if (!isOptedOut(findRoot()))
238
+ // Gate the repository the command acts on (#458): a `--root` names it (`hunch mcp
239
+ // --root`, `hunch serve ... --root`), so its real path is gated with every ancestor
240
+ // up to its store or repository and the cwd's repository is irrelevant; without
241
+ // one, the command acts on the cwd's repository. `serve init` also writes its
242
+ // config file (resolved against the cwd), so the directory it lands in gates it too.
243
+ const rootOpt = actionCommand.opts().root;
244
+ const rootGiven = typeof rootOpt === "string" && rootOpt.trim() !== "";
245
+ let gated = rootGiven ? optedOutAt(realpathNorm(resolve(rootOpt))) : isOptedOut(findRoot());
246
+ if (!gated && name === "serve" && actionCommand.name() === "init") {
247
+ const configOpt = top.opts().config ?? actionCommand.opts().config;
248
+ const configFile = resolve(typeof configOpt === "string" ? configOpt : SERVE_DEFAULT_CONFIG_FILE);
249
+ gated = optedOutAt(realpathNorm(dirname(configFile)));
250
+ }
251
+ if (!gated)
238
252
  return;
239
253
  if (name !== "hook")
240
254
  console.error(optOutMessage());
@@ -301,6 +315,7 @@ function openTeamStore(root, opts = {}) {
301
315
  // from their durable local overlay when the network is temporarily down, but a
302
316
  // strict guard must prove it saw the advertised remote before it may pass.
303
317
  let pullStatus = null;
318
+ let pullDetail;
304
319
  if (teamAdvertised && store.mode === "shared" && store.privateDir) {
305
320
  const remote = advertisedTeamRemoteContract(root, dirname(store.privateDir));
306
321
  if (!remote || !remote.verify()) {
@@ -309,10 +324,9 @@ function openTeamStore(root, opts = {}) {
309
324
  throw new Error("the advertised team memory route could not be pinned for this command");
310
325
  }
311
326
  pinSharedRemote(store, remote);
312
- pullStatus = pullHunchStatus(store.privateDir, {
313
- timeoutMs: 5_000,
327
+ ({ status: pullStatus, detail: pullDetail } = pullHunchStatusDetailed(store.privateDir, {
314
328
  remote,
315
- });
329
+ }));
316
330
  // The route may be coherently rewritten while the bounded pull is blocked.
317
331
  // No handler — including an ordinary non-strict read — may run after the
318
332
  // graph epoch that admitted this command stops verifying.
@@ -322,12 +336,12 @@ function openTeamStore(root, opts = {}) {
322
336
  throw new Error("the advertised team memory route changed while this command was refreshing; refusing to serve or mutate stale memory");
323
337
  }
324
338
  }
325
- return { store, teamWired, teamPullStatus: opts.requireFreshTeamMemory ? pullStatus : null };
339
+ return { store, teamWired, teamPullStatus: opts.requireFreshTeamMemory ? pullStatus : null, teamPullDetail: opts.requireFreshTeamMemory ? pullDetail : undefined };
326
340
  }
327
341
  function storeFor(opts = {}) {
328
342
  const root = findRoot();
329
- const { store, teamPullStatus } = openTeamStore(root, opts);
330
- return { store, root, teamPullStatus };
343
+ const { store, teamPullStatus, teamPullDetail } = openTeamStore(root, opts);
344
+ return { store, root, teamPullStatus, teamPullDetail };
331
345
  }
332
346
  function pumpMemoryHome(store, root, home, message) {
333
347
  flushMemoryHome(store, hunchPaths(root).hunch, home, message);
@@ -1054,7 +1068,6 @@ function configureExistingValidatedOverlay(destRoot, codeRoot, requestedRemote,
1054
1068
  }
1055
1069
  else {
1056
1070
  const pulled = pullHunchStatus(hunchDir, {
1057
- timeoutMs: 5_000,
1058
1071
  remote: contract,
1059
1072
  allowUnrelatedHistories: true,
1060
1073
  });
@@ -3783,7 +3796,7 @@ program
3783
3796
  program
3784
3797
  .command("why")
3785
3798
  .description("Explain why a file/symbol is the way it is (decisions, bugs, constraints).")
3786
- .argument("<target>", "file path or symbol name")
3799
+ .argument("<target>", "file path, symbol name, or record id (dec_/con_/bug_/fnd_/cmp_)")
3787
3800
  .option("--as-of <ref>", "time-travel: what was believed as of a commit/tag/branch (e.g. v0.7.0, HEAD~5)")
3788
3801
  .action((target, opts) => {
3789
3802
  const { store, root } = storeFor();
@@ -3791,6 +3804,13 @@ program
3791
3804
  if (opts.asOf && !asOf)
3792
3805
  return fail(`could not resolve --as-of "${opts.asOf}" to a commit (need a git repo and a valid ref)`);
3793
3806
  const w = store.why(target, { asOf });
3807
+ if (w.byId?.record) {
3808
+ if (asOf)
3809
+ console.log(`Why "${target}" (as of ${opts.asOf} — ${asOf.slice(0, 10)}):\n`);
3810
+ console.log(formatWhyRecord(w.byId).join("\n"));
3811
+ store.close();
3812
+ return;
3813
+ }
3794
3814
  const staleIds = new Set(store.staleness((f) => lastChangeDate(f, root)).map((s) => s.id));
3795
3815
  const drift = (id) => (staleIds.has(id) ? " ⚠STALE" : "");
3796
3816
  console.log(asOf ? `Why "${target}" (as of ${opts.asOf} — ${asOf.slice(0, 10)}):\n` : `Why "${target}":\n`);
@@ -3810,7 +3830,7 @@ program
3810
3830
  console.log(` • ${b.id} [${b.status}] ${b.title} — ${b.root_cause}`);
3811
3831
  }
3812
3832
  if (!w.decisions.length && !w.constraints.length && !w.bugs.length) {
3813
- console.log("(No recorded decisions/bugs/constraints yet. Try `hunch backfill` or make a commit.)");
3833
+ console.log(w.byId ? formatWhyRecord(w.byId).join("\n") : "(No recorded decisions/bugs/constraints yet. Try `hunch backfill` or make a commit.)");
3814
3834
  }
3815
3835
  if (w.symbols.length)
3816
3836
  console.log(`\nSYMBOLS: ${w.symbols.map((s) => `${s.name} [fan-in ${s.metrics.fan_in}, churn ${s.metrics.churn_90d}]`).join(", ")}`);
@@ -4233,9 +4253,10 @@ program
4233
4253
  // interleaved section prints are suppressed for this format.
4234
4254
  const sarif = opts.format === "sarif";
4235
4255
  const emptyReport = { fileCount: 0, strict: !!opts.strict, direct: [], near: [], regressions: [], vetoes: [], redundant: [], strictBlockers: 0, regBlocking: 0, vetoBlocking: 0 };
4236
- const { store, root, teamPullStatus } = storeFor({ requireFreshTeamMemory: !!opts.strict && !opts.publicOnly });
4256
+ const { store, root, teamPullStatus, teamPullDetail } = storeFor({ requireFreshTeamMemory: !!opts.strict && !opts.publicOnly });
4237
4257
  const teamFreshnessFailure = teamPullStatus !== null && !teamPullIsFresh(teamPullStatus)
4238
4258
  ? `strict check could not refresh the advertised team memory (${teamPullStatus}); refusing a stale pass` +
4259
+ (teamPullDetail ? ` (${describePullFailure(teamPullDetail)})` : "") +
4239
4260
  (teamPullStatus === "unconfigured" ? " — the shared memory remote has no published branch while this clone has memory; publish it with `hunch shared --sync`" : "")
4240
4261
  : null;
4241
4262
  // An empty team remote is in sync with a never-committed clone, but it is also what
@@ -5466,7 +5487,11 @@ program
5466
5487
  // before implementation. Firm/strict may deny two edits per prompt, then
5467
5488
  // fail open so a malformed or unavailable probe can never deadlock work.
5468
5489
  if ((firmness === "firm" || firmness === "strict") && evt.session_id && pipelineEnabled() && targets.some((t) => isProductPath(t.target))) {
5469
- const baseline = beforeEditProbeVerdict(loadPipelineState(evt.session_id));
5490
+ const baseline = beforeEditProbeVerdict(loadPipelineState(evt.session_id), { commandOutcomes: deliversCommandOutcome(provider) });
5491
+ // Unverifiable on this host: the edit proceeds and the hook says why on
5492
+ // stderr (stdout stays free for the grounding/deny response below).
5493
+ if (baseline.unverifiable && baseline.reason)
5494
+ process.stderr.write(`${baseline.reason}\n`);
5470
5495
  if (baseline.block) {
5471
5496
  savePipelineState(evt.session_id, baseline.state);
5472
5497
  emitDeny(provider, baseline.reason ?? "Hunch evidence gate — establish the pre-edit probe baseline first.");
@@ -13,4 +13,6 @@ export declare function compactPartition(root: string, scope: {
13
13
  floor_seq: number;
14
14
  head_seq: number;
15
15
  }>;
16
+ /** The serve config file used when no --config is given, resolved against the cwd. */
17
+ export declare const DEFAULT_CONFIG = "hunch-serve.json";
16
18
  export declare function registerServeCommands(program: Command): void;
package/dist/cli/serve.js CHANGED
@@ -32,8 +32,9 @@ export async function compactPartition(root, scope, keep, lockOptions = {}) {
32
32
  store.close();
33
33
  }
34
34
  }
35
+ /** The serve config file used when no --config is given, resolved against the cwd. */
36
+ export const DEFAULT_CONFIG = "hunch-serve.json";
35
37
  export function registerServeCommands(program) {
36
- const DEFAULT_CONFIG = "hunch-serve.json";
37
38
  const serve = program.command("serve")
38
39
  .description("Serve nuryel.state/1 over HTTP for organization / team / user / repository partitions (binds 127.0.0.1; put it behind SSH or a reverse proxy)")
39
40
  .option("--config <file>", `serve config (nuryel.serve-config/1); default ${DEFAULT_CONFIG}`)
@@ -8,6 +8,7 @@
8
8
  export declare const HOOK_PROVIDERS: readonly ["claude", "codex", "vscode", "windsurf", "antigravity", "cursor"];
9
9
  export type HookProvider = (typeof HOOK_PROVIDERS)[number];
10
10
  export declare function deliversPromptEvent(provider: HookProvider): boolean;
11
+ export declare function deliversCommandOutcome(provider: HookProvider): boolean;
11
12
  export type HunchHookEvent = "PreToolUse" | "PostToolUse" | "PostToolUseFailure" | "UserPromptSubmit" | "SessionStart" | "SubagentStart" | "PreCompact" | "Stop";
12
13
  /** One file section of a Codex `apply_patch` payload. */
13
14
  export interface HunchPatchFile {
@@ -17,6 +17,18 @@ const PROMPT_EVENT = {
17
17
  export function deliversPromptEvent(provider) {
18
18
  return PROMPT_EVENT[provider];
19
19
  }
20
+ /** Whether a provider's post-command hook carries the command's result (exit
21
+ * status or an explicit success/failure). Windsurf's post_run_command sends only
22
+ * the command line and cwd; Antigravity's installed hooks (PreInvocation,
23
+ * PreToolUse on edit tools, Stop) include no post-tool event at all. Evidence
24
+ * gates keyed on a command's result can never be satisfied there and must stay
25
+ * advisory. A Record, so a new provider must declare its answer. */
26
+ const COMMAND_OUTCOME = {
27
+ claude: true, codex: true, vscode: true, windsurf: false, cursor: true, antigravity: false,
28
+ };
29
+ export function deliversCommandOutcome(provider) {
30
+ return COMMAND_OUTCOME[provider];
31
+ }
20
32
  function obj(value) {
21
33
  return value && typeof value === "object" && !Array.isArray(value) ? value : null;
22
34
  }
@@ -235,12 +247,11 @@ function eventName(value, provider) {
235
247
  return "PostToolUse";
236
248
  }
237
249
  if (provider === "windsurf") {
238
- if (name === "pre_write_code" || name === "pre_run_command")
239
- return "PreToolUse";
240
- if (name === "post_write_code" || name === "post_run_command")
241
- return "PostToolUse";
242
250
  if (name === "pre_user_prompt")
243
251
  return "UserPromptSubmit";
252
+ const action = /^(pre|post)_(write_code|run_command|read_code|mcp_tool_use)$/.exec(name);
253
+ if (action)
254
+ return action[1] === "pre" ? "PreToolUse" : "PostToolUse";
244
255
  }
245
256
  // Antigravity's PreInvocation is the lifecycle point which can inject a
246
257
  // transient message before the model sees the turn. Internally it provides
@@ -256,6 +267,45 @@ export function hookProvider(value) {
256
267
  ? value.toLowerCase()
257
268
  : null;
258
269
  }
270
+ /** Windsurf Cascade (docs.windsurf.com/windsurf/cascade/hooks) names the event in
271
+ * top-level `agent_action_name` (`pre_write_code`, `post_run_command`, …) and
272
+ * carries per-event data in `tool_info`. The tool is implied by the event. Payloads
273
+ * written before #484 (`event` + `agent_action_name: "write_code"`) still parse. */
274
+ function normalizeWindsurfEvent(input) {
275
+ const action = stringAt(input, "agent_action_name")?.toLowerCase();
276
+ const legacy = typeof input.event === "string" ? input.event : typeof input.hook_event_name === "string" ? input.hook_event_name : undefined;
277
+ const name = eventName(action, "windsurf") ? action : legacy?.toLowerCase();
278
+ const event = eventName(name, "windsurf");
279
+ if (!name || !event)
280
+ return null;
281
+ const info = obj(input.tool_info) ?? obj(input.toolInput) ?? obj(input.tool_input) ?? {};
282
+ const kind = name.replace(/^(?:pre|post)_/, "");
283
+ const base = {
284
+ hook_event_name: event,
285
+ session_id: stringAt(input, "trajectory_id", "session_id", "sessionId"),
286
+ };
287
+ if (event === "UserPromptSubmit") {
288
+ return { ...base, prompt: stringAt(info, "user_prompt", "prompt") ?? stringAt(input, "prompt", "user_prompt", "userPrompt") };
289
+ }
290
+ const toolOutcome = normalizeToolOutcome(input, event);
291
+ const outcome = toolOutcome ? { tool_outcome: toolOutcome } : {};
292
+ if (kind === "run_command") {
293
+ const command = stringAt(info, "command_line", "command", "commandLine");
294
+ const cwd = stringAt(info, "cwd");
295
+ return { ...base, ...(cwd ? { cwd } : {}), tool_name: "Bash", tool_input: command ? { command } : undefined, ...outcome };
296
+ }
297
+ // Reads and MCP calls are observed only: their arguments never reach the
298
+ // pre-edit gate, which treats any file_path as an edit target.
299
+ if (kind === "read_code")
300
+ return { ...base, tool_name: "Read", ...outcome };
301
+ if (kind === "mcp_tool_use") {
302
+ const server = stringAt(info, "mcp_server_name", "server_name", "server");
303
+ const tool = stringAt(info, "mcp_tool_name", "tool_name", "tool");
304
+ return { ...base, tool_name: server && tool ? `mcp__${server}__${tool}` : tool ?? "mcp", ...outcome };
305
+ }
306
+ const toolInput = normalizeToolInput(info);
307
+ return { ...base, tool_name: kind === "write_code" ? "Edit" : hunchToolName(stringAt(input, "tool_name", "toolName"), toolInput ?? {}), tool_input: toolInput, ...outcome };
308
+ }
259
309
  /** Normalize a hook stdin payload. Unknown/malformed events return null and the
260
310
  * CLI exits successfully without output — the Never Block on Hook Failure rule. */
261
311
  export function normalizeHookEvent(raw, provider) {
@@ -276,22 +326,8 @@ export function normalizeHookEvent(raw, provider) {
276
326
  tool_input: toolInput,
277
327
  };
278
328
  }
279
- if (provider === "windsurf") {
280
- const event = eventName(input.event ?? input.hook_event_name, provider);
281
- if (!event)
282
- return null;
283
- const info = obj(input.tool_info) ?? obj(input.toolInput) ?? obj(input.tool_input);
284
- const toolInput = normalizeToolInput(info);
285
- const toolOutcome = normalizeToolOutcome(input, event);
286
- return {
287
- hook_event_name: event,
288
- session_id: stringAt(input, "trajectory_id", "session_id", "sessionId"),
289
- tool_name: hunchToolName(stringAt(input, "agent_action_name", "tool_name", "toolName"), toolInput ?? {}),
290
- tool_input: toolInput,
291
- ...(toolOutcome ? { tool_outcome: toolOutcome } : {}),
292
- prompt: stringAt(input, "prompt", "user_prompt", "userPrompt"),
293
- };
294
- }
329
+ if (provider === "windsurf")
330
+ return normalizeWindsurfEvent(input);
295
331
  const event = eventName(input.hook_event_name ?? input.hookEventName ?? input.event, provider);
296
332
  if (!event)
297
333
  return null;
@@ -124,6 +124,11 @@ export declare const SEVERITY: {
124
124
  export declare const PROFILE_BASE_SCORE: Record<DeliveryProfile, Record<DeliveryKind, number>>;
125
125
  export declare const TASK_STOP_WORDS: Set<string>;
126
126
  export declare function lexicalTokens(value: string): Set<string>;
127
+ /** Test hook: forget memoized commit verdicts. */
128
+ export declare function resetCommitReachabilityMemo(): void;
129
+ /** Test hook: number of git spawns issued by delivery provenance probes so far. */
130
+ export declare function commitReachabilityProbeCount(): number;
131
+ export declare function localCommitReachability(root: string): (commit: string) => CommitReachability;
127
132
  /** Validate the public receipt without trusting a caller-supplied identity. */
128
133
  export declare function assertDeliveryEnvelope(envelope: DeliveryEnvelope): void;
129
134
  /** The string a hash-based injection dedup (`injectionMode`'s `hashInput`) must
@@ -311,7 +311,9 @@ function componentResolves(id, target, options) {
311
311
  const component = (options.components ?? []).find((candidate) => candidate.id === id && candidate.status === "active");
312
312
  return !!component && component.paths.some((path) => anchorResolves(path, target, options));
313
313
  }
314
+ let gitProbeCount = 0;
314
315
  function gitProbe(root, args) {
316
+ gitProbeCount++;
315
317
  const result = spawnSync("git", ["-C", root, ...args], {
316
318
  encoding: "utf8",
317
319
  env: { ...process.env, GIT_NO_REPLACE_OBJECTS: "1" },
@@ -336,30 +338,70 @@ function defaultBranchRef(root) {
336
338
  }
337
339
  return null;
338
340
  }
339
- function localCommitReachability(root) {
341
+ // Process-wide memo (#473): every delivery build used to re-probe git for the
342
+ // same capture commits. Verdicts are keyed by the default branch's TIP sha, so a
343
+ // long-lived process stays correct after new commits land. Only ancestry
344
+ // answers (merge-base exit 0/1) are cached; a missing object or a transient
345
+ // spawn failure is re-probed on the next call.
346
+ const REACHABILITY_MEMO_CAP = 10_000;
347
+ const reachabilityMemo = new Map();
348
+ /** Test hook: forget memoized commit verdicts. */
349
+ export function resetCommitReachabilityMemo() {
350
+ reachabilityMemo.clear();
351
+ }
352
+ /** Test hook: number of git spawns issued by delivery provenance probes so far. */
353
+ export function commitReachabilityProbeCount() {
354
+ return gitProbeCount;
355
+ }
356
+ function memoizeReachability(key, verdict) {
357
+ if (reachabilityMemo.size >= REACHABILITY_MEMO_CAP)
358
+ reachabilityMemo.clear();
359
+ reachabilityMemo.set(key, verdict);
360
+ return verdict;
361
+ }
362
+ export function localCommitReachability(root) {
340
363
  let initialized = false;
341
364
  let branch = null;
365
+ let tip = null;
342
366
  return (commit) => {
343
367
  if (!initialized) {
368
+ // The default ref is looked up every build (origin/HEAD can be retargeted
369
+ // while the old ref still exists); only verdicts are shared across builds.
344
370
  branch = defaultBranchRef(root);
371
+ if (branch) {
372
+ // One spawn: the tip sha plus whether the repo is shallow. A shallow
373
+ // boundary can make a present commit look unreachable until a deepen,
374
+ // so a shallow build never memoizes. Unexpected output disables the memo.
375
+ const resolved = gitProbe(root, ["rev-parse", "--is-shallow-repository", "--verify", "--quiet", `${branch}^{commit}`]);
376
+ const [shallow, sha, ...rest] = resolved.stdout.split(/\r?\n/);
377
+ if (resolved.available && resolved.status === 0 && !rest.length && shallow === "false" && sha && /^[0-9a-f]{40,64}$/i.test(sha)) {
378
+ tip = sha;
379
+ }
380
+ }
345
381
  initialized = true;
346
382
  }
347
383
  if (!branch)
348
384
  return "unknown";
349
385
  if (!/^[0-9a-f]{7,64}$/i.test(commit))
350
386
  return "unreachable";
387
+ const key = tip ? `${root}\0${tip}\0${commit}` : null;
388
+ const memo = key ? reachabilityMemo.get(key) : undefined;
389
+ if (memo)
390
+ return memo;
391
+ const remember = (verdict) => key ? memoizeReachability(key, verdict) : verdict;
351
392
  const exact = gitProbe(root, ["rev-parse", "--verify", "--quiet", `${commit}^{commit}`]);
352
393
  if (!exact.available)
353
394
  return "unknown";
395
+ // A missing object is not memoized: a later fetch/deepen can bring it in under the same tip.
354
396
  if (exact.status !== 0)
355
397
  return "unreachable";
356
- const ancestry = gitProbe(root, ["merge-base", "--is-ancestor", exact.stdout, branch]);
398
+ const ancestry = gitProbe(root, ["merge-base", "--is-ancestor", exact.stdout, tip ?? branch]);
357
399
  if (!ancestry.available)
358
400
  return "unknown";
359
401
  if (ancestry.status === 0)
360
- return "reachable";
402
+ return remember("reachable");
361
403
  if (ancestry.status === 1)
362
- return "unreachable";
404
+ return remember("unreachable");
363
405
  return "unknown";
364
406
  };
365
407
  }
@@ -231,11 +231,18 @@ export interface BeforeEditProbeVerdict {
231
231
  block: boolean;
232
232
  state: PipelineState;
233
233
  reason?: string;
234
+ /** The host cannot report command results, so the evidence is unverifiable:
235
+ * the gate is advisory and `reason` says why instead of denying. */
236
+ unverifiable?: boolean;
234
237
  }
235
238
  /** Before-edit evidence is useful only before implementation. At firm/strict,
236
239
  * deny at most two product edits per prompt until all such receipts exist;
237
- * then fail open so a broken probe or discriminator cannot deadlock the agent. */
238
- export declare function beforeEditProbeVerdict(state: PipelineState): BeforeEditProbeVerdict;
240
+ * then fail open so a broken probe or discriminator cannot deadlock the agent.
241
+ * A host whose hooks carry no command outcome (`commandOutcomes: false`) can
242
+ * never produce a receipt, so its gate is advisory: it never denies. */
243
+ export declare function beforeEditProbeVerdict(state: PipelineState, opts?: {
244
+ commandOutcomes?: boolean;
245
+ }): BeforeEditProbeVerdict;
239
246
  export declare function executionObligationBrief(state: PipelineState): string;
240
247
  /** Harness/orchestrator injection. Malformed input returns no obligations. */
241
248
  export declare function environmentExecutionObligations(value?: string | undefined): ExecutionObligation[];
@@ -704,10 +704,20 @@ export function pendingExecutionObligations(state) {
704
704
  }
705
705
  /** Before-edit evidence is useful only before implementation. At firm/strict,
706
706
  * deny at most two product edits per prompt until all such receipts exist;
707
- * then fail open so a broken probe or discriminator cannot deadlock the agent. */
708
- export function beforeEditProbeVerdict(state) {
707
+ * then fail open so a broken probe or discriminator cannot deadlock the agent.
708
+ * A host whose hooks carry no command outcome (`commandOutcomes: false`) can
709
+ * never produce a receipt, so its gate is advisory: it never denies. */
710
+ export function beforeEditProbeVerdict(state, opts = {}) {
709
711
  const pending = state.obligations.filter((item) => item.phase === "before-edit" && item.status !== "satisfied");
710
- if (!pending.length || state.probeBlocks >= 2)
712
+ if (!pending.length)
713
+ return { block: false, state };
714
+ if (opts.commandOutcomes === false) {
715
+ return {
716
+ block: false, state, unverifiable: true,
717
+ reason: `Hunch evidence gate is advisory here: this host's hooks do not report command results, so ${pending.length} pre-edit evidence obligation(s) cannot be verified and do not block edits. Run them anyway and check their output yourself.`,
718
+ };
719
+ }
720
+ if (state.probeBlocks >= 2)
711
721
  return { block: false, state };
712
722
  const instructions = pending.slice(0, 3).map((item) => {
713
723
  const command = item.probe?.command ?? firstCommand(item);
@@ -69,9 +69,9 @@ export declare const heldLockNonces: Set<string>;
69
69
  * - `stale` — provably not the owner's process any more: take it over.
70
70
  * - `live` — authoritative, however old the file looks: never steal it.
71
71
  * - `unproven` — a live pid whose process instance no token can compare (macOS,
72
- * Windows, a legacy record). Treated as live; a caller with a
73
- * better probe may still disprove it (src/serve/writelock.ts
74
- * asks the OS when that process was created, issue #287).
72
+ * Windows, a legacy record). Treated as live unless
73
+ * `createdAfterLock` proves the pid reused (both locks ask the
74
+ * OS when that process was created, issues #287, #457).
75
75
  * - `age` — nothing is proven; fall back to the caller's own age rule. */
76
76
  export type SameHostVerdict = "stale" | "live" | "unproven" | "age";
77
77
  /** The parts of a lock record this decision reads. Both lock formats supply it. */
@@ -102,4 +102,18 @@ export declare function judgeSameHostOwner(owner: SameHostOwner, env: SameHostEn
102
102
  * other platform: linux derives `lstart` from boot time, which a forward clock
103
103
  * step shifts for live processes too (see the header). */
104
104
  export declare function processStartedAt(pid: number): number | null;
105
+ /** How much later than the lock's recorded `at` the pid's current process must
106
+ * have been created before it is proven not to be the owner. A genuine owner
107
+ * started BEFORE it took the lock; the slack only absorbs clock granularity. */
108
+ export declare const CREATED_AFTER_SLACK_MS = 5000;
109
+ /** Is the process now holding the owner's pid provably newer than the lock?
110
+ * The one rule both `src/serve/writelock.ts` and jsonStore's `.rmw-lock` apply
111
+ * to an "unproven" verdict (issues #287, #457). Unreadable either way — no `at`
112
+ * recorded (a legacy record), or no creation time from the OS — means not
113
+ * proven, and a live pid keeps its lock. SPAWNS through `processStartedAt`, so
114
+ * callers consult it only for a lock that already looks stale by age. */
115
+ export declare function createdAfterLock(owner: {
116
+ pid: number;
117
+ at?: string;
118
+ }, startedAt?: (pid: number) => number | null): boolean;
105
119
  export {};
@@ -26,8 +26,9 @@
26
26
  * OFF LINUX (issue #287). Only linux exposes a spawn-free, stable process-instance
27
27
  * id (`/proc/<pid>/stat` field 22 plus the boot id and the pid/time namespaces), so
28
28
  * on macOS and Windows a DIFFERENT live process that recycled the owner's pid is
29
- * "unproven" here, which this table treats as live. The write lock closes that
30
- * forever-deadlock on its own rare path, once a lock already looks stale: it asks
29
+ * "unproven" here, which this table treats as live. Both locks close that
30
+ * forever-deadlock on their own rare path, once a lock already looks stale
31
+ * (`createdAfterLock`; the `.rmw-lock` since issue #457): they ask
31
32
  * the OS when the process now holding the pid was CREATED (`processStartedAt`, a
32
33
  * spawn) and compares that with the `at` the owner recorded when it took the lock.
33
34
  * A process created after the lock was taken cannot have taken it. Both instants
@@ -56,7 +57,7 @@
56
57
  * holder may judge itself against a token that no longer describes it. Tokenless
57
58
  * records (a legacy pre-token hunch, any non-linux writer, an unreadable /proc)
58
59
  * get main's unchanged pid rule — a live same-host pid keeps its lock (unless the
59
- * write lock's creation-time check above proves the pid reused), a dead one is
60
+ * creation-time check above proves the pid reused), a dead one is
60
61
  * stealable — with exactly one exception, the own-pid + foreign-nonce row
61
62
  * described in `judgeSameHostOwner`, which gets the AGE rule instead of main's
62
63
  * "live forever".
@@ -245,8 +246,9 @@ export function judgeSameHostOwner(owner, env) {
245
246
  return "age";
246
247
  // 7. A live pid no token can compare — another process's, or a nonce-less
247
248
  // record of our own pid. Never stolen on a guess: "unproven" is "live" to a
248
- // caller that has nothing better. The write lock asks the OS for that
249
- // process's creation time, which can prove the pid was reused (issue #287).
249
+ // caller that has nothing better. Both locks ask the OS for that process's
250
+ // creation time (`createdAfterLock`), which can prove the pid was reused
251
+ // (issues #287, #457).
250
252
  return "unproven";
251
253
  }
252
254
  /** Wall-clock ms at which the process now holding `pid` was created, from the
@@ -279,4 +281,23 @@ export function processStartedAt(pid) {
279
281
  return null;
280
282
  }
281
283
  }
284
+ /** How much later than the lock's recorded `at` the pid's current process must
285
+ * have been created before it is proven not to be the owner. A genuine owner
286
+ * started BEFORE it took the lock; the slack only absorbs clock granularity. */
287
+ export const CREATED_AFTER_SLACK_MS = 5_000;
288
+ /** Is the process now holding the owner's pid provably newer than the lock?
289
+ * The one rule both `src/serve/writelock.ts` and jsonStore's `.rmw-lock` apply
290
+ * to an "unproven" verdict (issues #287, #457). Unreadable either way — no `at`
291
+ * recorded (a legacy record), or no creation time from the OS — means not
292
+ * proven, and a live pid keeps its lock. SPAWNS through `processStartedAt`, so
293
+ * callers consult it only for a lock that already looks stale by age. */
294
+ export function createdAfterLock(owner, startedAt = processStartedAt) {
295
+ if (owner.at === undefined)
296
+ return false;
297
+ const at = Date.parse(owner.at);
298
+ if (!Number.isFinite(at))
299
+ return false;
300
+ const started = startedAt(owner.pid);
301
+ return started !== null && started > at + CREATED_AFTER_SLACK_MS;
302
+ }
282
303
  //# sourceMappingURL=procstart.js.map
@@ -59,6 +59,7 @@ export declare function liveSelectionRecords(all: TaskSelectionRecords): TaskSel
59
59
  * or `:start-end` and a possessive `'s`, forward-slashed, repo-relative. A drive-letter root compares
60
60
  * case-insensitively (Windows paths are case-insensitive on every host). */
61
61
  export declare function promptPaths(prompt: string, root?: string): string[];
62
+ export declare function identifierTokens(text: string): Set<string>;
62
63
  /** Score live records against one prompt. Text fields per kind (title + rationale):
63
64
  * decision title/context/decision; bug title/symptom/root_cause; constraint
64
65
  * statement/rationale; finding title/observation. Path anchors: decision