@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 +41 -16
- package/dist/cli/serve.d.ts +2 -0
- package/dist/cli/serve.js +2 -1
- package/dist/core/agenthook.d.ts +1 -0
- package/dist/core/agenthook.js +56 -20
- package/dist/core/delivery.d.ts +5 -0
- package/dist/core/delivery.js +46 -4
- package/dist/core/pipeline.d.ts +9 -2
- package/dist/core/pipeline.js +13 -3
- package/dist/core/procstart.d.ts +17 -3
- package/dist/core/procstart.js +26 -5
- package/dist/core/taskSelection.d.ts +1 -0
- package/dist/core/taskSelection.js +90 -11
- package/dist/core/whyRecord.d.ts +47 -0
- package/dist/core/whyRecord.js +105 -0
- package/dist/extractors/git.d.ts +32 -0
- package/dist/extractors/git.js +177 -66
- package/dist/integrations/team.js +2 -2
- package/dist/integrations/uninstall.d.ts +7 -0
- package/dist/integrations/uninstall.js +19 -1
- package/dist/mcp/server.js +136 -26
- package/dist/serve/writelock.d.ts +3 -4
- package/dist/serve/writelock.js +9 -18
- package/dist/store/hunchStore.d.ts +10 -0
- package/dist/store/hunchStore.js +80 -1
- package/dist/store/jsonStore.d.ts +11 -1
- package/dist/store/jsonStore.js +38 -13
- package/package.json +1 -1
- package/server.json +2 -2
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
|
-
|
|
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 =
|
|
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
|
|
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.");
|
package/dist/cli/serve.d.ts
CHANGED
|
@@ -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}`)
|
package/dist/core/agenthook.d.ts
CHANGED
|
@@ -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 {
|
package/dist/core/agenthook.js
CHANGED
|
@@ -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
|
-
|
|
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;
|
package/dist/core/delivery.d.ts
CHANGED
|
@@ -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
|
package/dist/core/delivery.js
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/dist/core/pipeline.d.ts
CHANGED
|
@@ -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
|
-
|
|
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[];
|
package/dist/core/pipeline.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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);
|
package/dist/core/procstart.d.ts
CHANGED
|
@@ -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
|
|
73
|
-
*
|
|
74
|
-
*
|
|
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 {};
|
package/dist/core/procstart.js
CHANGED
|
@@ -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.
|
|
30
|
-
* forever-deadlock on
|
|
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
|
-
*
|
|
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.
|
|
249
|
-
//
|
|
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
|