@davesheffer/hunch 1.39.1 → 1.39.3
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 +164 -55
- package/dist/cli/serve.d.ts +14 -0
- package/dist/cli/serve.js +18 -4
- package/dist/client/state.d.ts +2 -1
- package/dist/client/state.js +1 -0
- package/dist/core/agenthook.js +7 -3
- package/dist/core/capturetoken.d.ts +30 -3
- package/dist/core/capturetoken.js +29 -3
- package/dist/core/correction.d.ts +10 -4
- package/dist/core/correction.js +19 -11
- package/dist/core/countersign.d.ts +28 -0
- package/dist/core/countersign.js +50 -0
- package/dist/core/docanchors.js +16 -2
- package/dist/core/glob.d.ts +19 -0
- package/dist/core/glob.js +37 -0
- package/dist/core/paths.d.ts +27 -5
- package/dist/core/paths.js +51 -6
- package/dist/core/procstart.d.ts +94 -0
- package/dist/core/procstart.js +244 -0
- package/dist/core/reviewqueue.js +6 -1
- package/dist/core/served.d.ts +6 -3
- package/dist/core/served.js +10 -6
- package/dist/core/spawnCommand.js +41 -9
- package/dist/core/stateHttp.d.ts +1 -1
- package/dist/core/stateHttp.js +3 -1
- package/dist/core/taskReport.d.ts +2 -0
- package/dist/core/taskReport.js +46 -11
- package/dist/core/taskReportEvidence.js +42 -6
- package/dist/core/taskReportHook.js +14 -2
- package/dist/core/topics.js +1 -1
- package/dist/core/workspace.d.ts +9 -0
- package/dist/core/workspace.js +15 -0
- package/dist/extractors/git.d.ts +5 -0
- package/dist/extractors/git.js +87 -10
- package/dist/extractors/indexer.d.ts +10 -0
- package/dist/extractors/indexer.js +17 -1
- package/dist/extractors/k8sManifest.js +104 -16
- package/dist/integrations/hookmatch.d.ts +25 -0
- package/dist/integrations/hookmatch.js +58 -0
- package/dist/integrations/hooks.d.ts +38 -7
- package/dist/integrations/hooks.js +250 -24
- package/dist/integrations/providers.js +2 -17
- package/dist/integrations/scaffold.d.ts +1 -1
- package/dist/integrations/scaffold.js +25 -22
- package/dist/integrations/workspaceLedger.d.ts +10 -0
- package/dist/integrations/workspaceLedger.js +9 -1
- package/dist/mcp/server.d.ts +10 -0
- package/dist/mcp/server.js +131 -64
- package/dist/serve/app.d.ts +4 -0
- package/dist/serve/app.js +56 -36
- package/dist/serve/writelock.d.ts +21 -1
- package/dist/serve/writelock.js +157 -16
- package/dist/store/hunchStore.js +41 -11
- package/dist/store/jsonStore.d.ts +6 -4
- package/dist/store/jsonStore.js +225 -32
- package/dist/store/merge.d.ts +7 -4
- package/dist/store/merge.js +65 -5
- package/dist/store/privateMigrate.js +10 -0
- package/dist/store/stateBinding.js +28 -7
- package/dist/wiki/adopt.js +13 -2
- package/dist/wiki/wiki.d.ts +7 -0
- package/dist/wiki/wiki.js +19 -8
- package/package.json +1 -1
- package/server.json +2 -2
package/dist/cli/index.js
CHANGED
|
@@ -14,13 +14,13 @@
|
|
|
14
14
|
* doctor environment diagnostics
|
|
15
15
|
*/
|
|
16
16
|
import "./preflight.js"; // MUST stay the first import — Node-version gate before node:sqlite loads
|
|
17
|
-
import { chmodSync, existsSync, lstatSync, readFileSync, readdirSync, readlinkSync, writeFileSync, mkdirSync, mkdtempSync,
|
|
17
|
+
import { chmodSync, existsSync, lstatSync, readFileSync, readdirSync, readlinkSync, writeFileSync, mkdirSync, mkdtempSync, rmSync, rmdirSync, symlinkSync } from "node:fs";
|
|
18
18
|
import { execFileSync, spawnSync } from "node:child_process";
|
|
19
19
|
import { join, relative, dirname, basename, resolve, isAbsolute } from "node:path";
|
|
20
20
|
import { tmpdir } from "node:os";
|
|
21
21
|
import { fileURLToPath } from "node:url";
|
|
22
22
|
import { Command } from "commander";
|
|
23
|
-
import { hunchPaths, hunchPathsForDir, findRoot, toPosixTarget, isDir } from "../core/paths.js";
|
|
23
|
+
import { hunchPaths, hunchPathsForDir, findRoot, toPosixTarget, isDir, realpathNorm, repoRelativeTarget } from "../core/paths.js";
|
|
24
24
|
import { writeFileAtomic } from "../core/io.js";
|
|
25
25
|
import { looksLikeCorrection, CORRECTION_NUDGE } from "../core/correction.js";
|
|
26
26
|
import { HUNCH_VERSION } from "../core/version.js";
|
|
@@ -36,7 +36,7 @@ import { publishedStatus } from "../integrations/registry.js";
|
|
|
36
36
|
import { HunchStore } from "../store/hunchStore.js";
|
|
37
37
|
import { JsonStore } from "../store/jsonStore.js";
|
|
38
38
|
import { selectEmbedder } from "../store/embedder.js";
|
|
39
|
-
import { assertCompleteRepoScan, indexRepo, scanRepo } from "../extractors/indexer.js";
|
|
39
|
+
import { assertCompleteRepoScan, indexRepo, mergeScannedEdges, scanRepo } from "../extractors/indexer.js";
|
|
40
40
|
import { syncCommit, recordFailure, captureTestRun } from "../synthesis/synthesize.js";
|
|
41
41
|
import { parseTestReport } from "../extractors/testreport.js";
|
|
42
42
|
import { readSynthesisPreference, resolveSynthesisProvider, selectProvider, SYNTH_PREFERENCES, writeSynthesisPreference, normalizeProviderName, } from "../synthesis/provider.js";
|
|
@@ -52,7 +52,7 @@ import { deriveForbids, effectiveForbids } from "../core/constraintmatch.js";
|
|
|
52
52
|
import { extractInlineIntent } from "../extractors/comments.js";
|
|
53
53
|
import { renderText, renderMarkdown, renderSarif, renderImpact, reportFailsStrict } from "../core/checkreport.js";
|
|
54
54
|
import { partitionReview, isReviewDraft, READY_MIN_GROUNDED } from "../core/reviewqueue.js";
|
|
55
|
-
import { installPostCommitHook, installPreCommitHook, installPostMergeHook, installPostCheckoutHook, hookStatus, hookReport, formatHookInstall } from "../integrations/hooks.js";
|
|
55
|
+
import { installPostCommitHook, installPreCommitHook, installPostMergeHook, installPostCheckoutHook, hookStatus, hookReport, hookInvocationLines, formatHookInstall } from "../integrations/hooks.js";
|
|
56
56
|
import { ensureSharedOverlayPointer } from "../integrations/worktree.js";
|
|
57
57
|
import { flushCapture, flushMemoryHome, flushMemoryHomes, pinSharedRemote, sharedRemoteFor } from "../integrations/sync.js";
|
|
58
58
|
import { installMergeDriver } from "../integrations/mergeDriver.js";
|
|
@@ -80,7 +80,7 @@ import { diffProjectDna } from "../core/projectDnaDelta.js";
|
|
|
80
80
|
import { projectDnaDeliverySupplement } from "../core/projectDnaDelivery.js";
|
|
81
81
|
import { readConfig, writeConfig, FIRMNESS_LEVELS, isFirmness, workspacesConfig } from "../core/config.js";
|
|
82
82
|
import { loadOrCreateMachine, setMachineLabel, machineFile, labelLeaksIdentity } from "../core/machine.js";
|
|
83
|
-
import { worktreeRows, branchRows } from "../core/workspace.js";
|
|
83
|
+
import { worktreeRows, branchRows, publicWorkspaceRemovalRecipe } from "../core/workspace.js";
|
|
84
84
|
import { workspaceLedgerView, recordWorkspaceSnapshot, renderWorktreeTable, renderBranchTable, workspaceSummaryLine, prunePlanFor, renderPrunePlan, applyPrune, confirmPrune, pruneConfirmQuestion } from "../integrations/workspaceLedger.js";
|
|
85
85
|
import { blockingInScope, vetoInScope, proposedEditLines } from "../core/hookpolicy.js";
|
|
86
86
|
import { isHumanConfirmed } from "../core/strictgate.js";
|
|
@@ -88,7 +88,7 @@ import { appendEvent, readEvents } from "../core/events.js";
|
|
|
88
88
|
import { computeStats, formatStats } from "../core/stats.js";
|
|
89
89
|
import { injectionMode, resetSessionInjections } from "../core/hookcache.js";
|
|
90
90
|
import { recordServed, servedSummary } from "../core/served.js";
|
|
91
|
-
import { recordTaskDelivery, reportActivity, reportPresentationEnabled, unseenLessons } from "../core/taskReport.js";
|
|
91
|
+
import { recordTaskDelivery, reportActivity, reportHash, reportPresentationEnabled, unseenLessons } from "../core/taskReport.js";
|
|
92
92
|
import { snapshotDeliveredRecords } from "../core/taskReportEvidence.js";
|
|
93
93
|
import { renderRecalledLine } from "../core/taskReportRender.js";
|
|
94
94
|
import { closeHookTask, hookReportTaskId, nativeHookCwd, settleHookSession, startHookReport, stopHookReport, observeHookDenial } from "../core/taskReportHook.js";
|
|
@@ -102,10 +102,11 @@ import { loadGoldenSet, evaluateRetrieval, evaluateTraversalLift } from "../eval
|
|
|
102
102
|
import { loadGuardCases, evalGuards, generateGuardCases } from "../eval/guards.js";
|
|
103
103
|
import { DRIFT_KINDS, computeDrift } from "../core/drift.js";
|
|
104
104
|
import { renderCompilerScorecard, scoreCompilerCaseBank } from "../constitution/scorecard.js";
|
|
105
|
-
import { generateWiki, wikiStatus, wikiPrompt, publicHome, privateHome, readWikiManifestAt, nowData } from "../wiki/wiki.js";
|
|
105
|
+
import { generateWiki, wikiStatus, wikiPrompt, publicHome, privateHome, readWikiManifestAt, nowData, unconfirmedRoadmapMarker } from "../wiki/wiki.js";
|
|
106
106
|
import { adoptProsePrompt } from "../wiki/adopt.js";
|
|
107
107
|
import { topicCollisions, isInForce, liveForTopic } from "../core/topics.js";
|
|
108
108
|
import { ADR_DIR_CANDIDATES, ADR_FILE_RE, mapAdrCorpus } from "../extractors/adrImport.js";
|
|
109
|
+
import { countersignConstraint, countersignDecision } from "../core/countersign.js";
|
|
109
110
|
import { applyImportedAdrReview, carryImportedAdrReview, importedAdrReviewHash, importedAdrSourceHash, isImportedAdrDecision, pendingImportedAdrReviews } from "../core/importReview.js";
|
|
110
111
|
import { exportMadrCorpus, isRegenerableMadr } from "../integrations/madrExport.js";
|
|
111
112
|
import { buildMadrManifest, writeMadrManifest, refreshMadrCorpus } from "../integrations/madrManifest.js";
|
|
@@ -362,7 +363,7 @@ program
|
|
|
362
363
|
// any mutator. With no commit identity there is no safe public source,
|
|
363
364
|
// so leave no source-derived records behind for a future pump.
|
|
364
365
|
store.json.replaceAll("symbols", []);
|
|
365
|
-
store.json.replaceAll("edges", []);
|
|
366
|
+
store.json.replaceAll("edges", mergeScannedEdges(store.json.loadAll("edges"), []));
|
|
366
367
|
store.json.replaceAll("components", []);
|
|
367
368
|
store.reindex();
|
|
368
369
|
console.log(" ⚠ skipped code graph: no committed HEAD — commit code, then run `hunch index`");
|
|
@@ -496,8 +497,10 @@ program
|
|
|
496
497
|
// the same way; do the same for the hook so an upgrade doesn't require
|
|
497
498
|
// re-running init by hand. Gated on already having post-commit: `index`
|
|
498
499
|
// is not a setup command (it runs in CI, on any git repo), so it must
|
|
499
|
-
// never be what FIRST hooks a repo that never ran init at all.
|
|
500
|
-
|
|
500
|
+
// never be what FIRST hooks a repo that never ran init at all. A STALE
|
|
501
|
+
// post-commit block counts too (issue #315): it proves init ran, so the
|
|
502
|
+
// missing post-merge/post-checkout hooks are still this repo's to heal.
|
|
503
|
+
if (isGitRepo(root) && ["installed", "stale"].includes(hookReport(root).postCommit.state)) {
|
|
501
504
|
const inv = resolveInvocation().shell;
|
|
502
505
|
installPostMergeHook(root, inv);
|
|
503
506
|
installPostCheckoutHook(root, inv); // same upgrade path for the workspace-ledger hook
|
|
@@ -1516,8 +1519,15 @@ workspacesCmd
|
|
|
1516
1519
|
return console.log(" · or set .hunch/config.json {\"workspaces\":{\"publish_public\":true}} to commit it into this repo's .hunch/");
|
|
1517
1520
|
case "unchanged":
|
|
1518
1521
|
return console.log(`✓ unchanged since ${out.previous.observed_at} (${out.record.id}) — nothing written`);
|
|
1522
|
+
case "deferred":
|
|
1523
|
+
return console.log(out.reason === "detached-head"
|
|
1524
|
+
? "deferred: HEAD is detached — the next checkout or ledger read records this machine"
|
|
1525
|
+
: "deferred: a git operation is in progress — the next checkout or ledger read records this machine");
|
|
1519
1526
|
case "collision":
|
|
1520
|
-
|
|
1527
|
+
// The capture is routed to the overlay, so the only home it can collide with is
|
|
1528
|
+
// this repo's committed `.hunch/`. `forget` refuses that record (an additive pump
|
|
1529
|
+
// never stages a deletion), so the recipe here is the manual git removal.
|
|
1530
|
+
return fail(`not written: ${out.reason}\n the stale copy lives in this repo's .hunch/ — remove it by hand, then snapshot again:\n${publicWorkspaceRemovalRecipe(out.record.id, out.record.machine.label)}`);
|
|
1521
1531
|
case "written":
|
|
1522
1532
|
return console.log(`✓ recorded ${out.record.worktrees.length} worktree(s), ${out.record.branches.length} branch(es) as ${out.record.machine.label} (${out.record.id}, publish=${out.record.publish}) → ${out.home === "private" ? "overlay" : "public .hunch/"}${out.flushed ? `, ${out.flushed}` : ""}`);
|
|
1523
1533
|
}
|
|
@@ -1576,7 +1586,7 @@ workspacesCmd
|
|
|
1576
1586
|
console.log(` ${r.outcome === "deleted" ? "✓" : r.outcome === "skipped" ? "–" : "✗"} ${r.step.branch}: ${r.detail}`);
|
|
1577
1587
|
const failed = results.filter((r) => r.outcome === "failed").length;
|
|
1578
1588
|
const skipped = results.filter((r) => r.outcome === "skipped").length;
|
|
1579
|
-
const ledger = recorded === "written" ? " · ledger updated" : recorded === "unchanged" || recorded === "no-home" || recorded === "off" ? "" : ` · ledger not updated (${recorded})`;
|
|
1589
|
+
const ledger = recorded === "written" ? " · ledger updated" : recorded === "unchanged" || recorded === "no-home" || recorded === "off" ? "" : recorded === "deferred" ? " · ledger deferred (git operation in progress or detached HEAD)" : ` · ledger not updated (${recorded})`;
|
|
1580
1590
|
console.log(`\n${results.length - failed - skipped} deleted, ${skipped ? `${skipped} skipped (nothing changed), ` : ""}${failed} refused by git${ledger}`);
|
|
1581
1591
|
if (failed || skipped)
|
|
1582
1592
|
process.exitCode = 1;
|
|
@@ -1604,12 +1614,25 @@ workspacesCmd
|
|
|
1604
1614
|
const victims = store.recs("workspaces").filter((r) => r.machine.label === machine || r.machine.id === machine || r.id === machine);
|
|
1605
1615
|
if (!victims.length)
|
|
1606
1616
|
return fail(`no workspace record for "${machine}" — \`hunch workspaces\` lists the machines in memory`);
|
|
1607
|
-
// Decide each record's home BEFORE deleting
|
|
1608
|
-
|
|
1609
|
-
|
|
1617
|
+
// Decide each record's home BEFORE deleting anything, then flush exactly those homes.
|
|
1618
|
+
// A PUBLIC record (workspaces.publish_public) is refused rather than deleted: the
|
|
1619
|
+
// memory pump never stages a tracked deletion, so removing the file here would strand
|
|
1620
|
+
// `D .hunch/workspaces/<id>.json` and wedge every later auto-commit — see
|
|
1621
|
+
// publicWorkspaceRemovalRecipe.
|
|
1622
|
+
const overlay = victims.filter((v) => store.getPrivateRec("workspaces", v.id));
|
|
1623
|
+
const refused = victims.filter((v) => !store.getPrivateRec("workspaces", v.id));
|
|
1624
|
+
for (const v of overlay)
|
|
1610
1625
|
store.deleteWhereItLives("workspaces", v.id);
|
|
1611
|
-
|
|
1612
|
-
|
|
1626
|
+
if (overlay.length) {
|
|
1627
|
+
pumpMemoryHome(store, root, "private", `hunch: forget workspace ${machine}`);
|
|
1628
|
+
console.log(`✓ forgot ${overlay.length} record(s) for ${machine}`);
|
|
1629
|
+
}
|
|
1630
|
+
for (const v of refused) {
|
|
1631
|
+
console.log(`refused: ${v.id} (${v.machine.label}) lives in this repo's .hunch/ — Hunch publication is additive and never stages a deletion, so it is removed by hand:\n`
|
|
1632
|
+
+ publicWorkspaceRemovalRecipe(v.id, v.machine.label));
|
|
1633
|
+
}
|
|
1634
|
+
if (refused.length && !overlay.length)
|
|
1635
|
+
process.exitCode = 1;
|
|
1613
1636
|
}
|
|
1614
1637
|
finally {
|
|
1615
1638
|
store.close();
|
|
@@ -4501,18 +4524,26 @@ program
|
|
|
4501
4524
|
.requiredOption("--by <new>", "decision id that supersedes it")
|
|
4502
4525
|
.action((oldId, opts) => {
|
|
4503
4526
|
const { store, root } = storeFor();
|
|
4504
|
-
|
|
4527
|
+
// `old`'s home decides which store the close is written to; `--by` must resolve in
|
|
4528
|
+
// that SAME store, or a private `by` linked against a public `old` would write the
|
|
4529
|
+
// private id straight into the committed public store.
|
|
4530
|
+
const home = decisionMemoryHome(store, oldId);
|
|
4531
|
+
if (!store.decisionInStore(oldId, home === "private")) {
|
|
4532
|
+
store.close();
|
|
4533
|
+
return fail(`decision "${oldId}" not found`);
|
|
4534
|
+
}
|
|
4535
|
+
const by = store.decisionInStore(opts.by, home === "private");
|
|
4505
4536
|
if (!by) {
|
|
4506
4537
|
store.close();
|
|
4507
|
-
return fail(`--by decision "${opts.by}" not found`);
|
|
4538
|
+
return fail(`--by decision "${opts.by}" not found in the ${home} store that holds "${oldId}"`);
|
|
4508
4539
|
}
|
|
4509
|
-
const closed = store.supersede(oldId, by);
|
|
4540
|
+
const closed = home === "private" ? store.supersedePrivate(oldId, by) : store.supersede(oldId, by);
|
|
4510
4541
|
if (!closed) {
|
|
4511
4542
|
store.close();
|
|
4512
|
-
return fail(`decision "${oldId}"
|
|
4543
|
+
return fail(`decision "${oldId}" cannot supersede itself`);
|
|
4513
4544
|
}
|
|
4514
4545
|
store.reindex();
|
|
4515
|
-
pumpMemoryHome(store, root,
|
|
4546
|
+
pumpMemoryHome(store, root, home, `hunch: supersede ${oldId} by ${opts.by}`);
|
|
4516
4547
|
console.log(`✓ ${oldId} superseded by ${opts.by} — window closed at ${closed.valid_to?.slice(0, 10)}.`);
|
|
4517
4548
|
store.close();
|
|
4518
4549
|
});
|
|
@@ -4756,10 +4787,12 @@ program
|
|
|
4756
4787
|
if (/^(Edit|Write|MultiEdit)$/.test(evt.tool_name ?? "")) {
|
|
4757
4788
|
// A Codex apply_patch touches every file it lists (and each Move-to
|
|
4758
4789
|
// destination); the Stop gate must see all of them, not only the first.
|
|
4759
|
-
|
|
4760
|
-
|
|
4761
|
-
|
|
4762
|
-
|
|
4790
|
+
// Same in-repo filter as the pre-edit path: a scratch file outside the
|
|
4791
|
+
// repository is not a product edit, and recording it would block Stop on
|
|
4792
|
+
// a path no check can ever verify.
|
|
4793
|
+
const edited = editTargets(root, evt.tool_input);
|
|
4794
|
+
for (const e of edited)
|
|
4795
|
+
st = onEdit(st, e.target);
|
|
4763
4796
|
if (edited.length)
|
|
4764
4797
|
activity = { kind: "edit" };
|
|
4765
4798
|
}
|
|
@@ -5042,7 +5075,10 @@ program
|
|
|
5042
5075
|
L.push(` ${r.date} [${r.status}] ${r.title} (${r.id})`);
|
|
5043
5076
|
}
|
|
5044
5077
|
if (roadmap.length) {
|
|
5045
|
-
L.push(`Roadmap (${roadmap.length} live proposed): ${roadmap.slice(0, 3).map((r) => r.title).join(" · ")}${roadmap.length > 3 ? " · …" : ""}`);
|
|
5078
|
+
L.push(`Roadmap (${roadmap.length} live proposed): ${roadmap.slice(0, 3).map((r) => (r.unconfirmed ? `${r.title} [unconfirmed, ${r.id}]` : r.title)).join(" · ")}${roadmap.length > 3 ? " · …" : ""}`);
|
|
5079
|
+
const unconfirmed = roadmap.filter((r) => r.unconfirmed).length;
|
|
5080
|
+
if (unconfirmed)
|
|
5081
|
+
L.push(`${unconfirmed} roadmap item(s) are unconfirmed agent testimony — the human confirms each with \`hunch review --confirm <id>${s.unified ? " --private" : ""}\`.`);
|
|
5046
5082
|
}
|
|
5047
5083
|
if (pendingReview > 0)
|
|
5048
5084
|
L.push(`${pendingReview} legacy un-vouched draft(s) — adopt as advisory memory with \`hunch adopt-drafts\` (new captures auto-trust).`);
|
|
@@ -5214,7 +5250,11 @@ program
|
|
|
5214
5250
|
const reportTaskId = hookReportTaskId(root, provider, evt);
|
|
5215
5251
|
// A new authoritative prompt gets its own full delivery. An earlier
|
|
5216
5252
|
// prompt's session-level delta cannot establish this task's receipt.
|
|
5217
|
-
|
|
5253
|
+
// A subagent reports to the prompt's task but starts with FRESH context:
|
|
5254
|
+
// it never saw that grounding, so its dedup is scoped by its own agent
|
|
5255
|
+
// identity (hashed — the raw agent_id is never retained in the key).
|
|
5256
|
+
const agentKey = evt.agent_id ? `:${reportHash(evt.agent_id).slice(7, 19)}` : "";
|
|
5257
|
+
if (injectionMode(evt.session_id, `pre:${target}${reportTaskId ? `:${reportTaskId}` : ""}${agentKey}`, text) === "delta") {
|
|
5218
5258
|
receipts("refreshed");
|
|
5219
5259
|
emitContext(provider, "PreToolUse", `Hunch grounding for ${target}: unchanged this session (${envelope.delivered.filter((item) => item.kind === "decisions").length} decision(s), ${envelope.delivered.filter((item) => item.kind === "constraints").length} invariant(s) shown earlier — still current; hunch_why("${target}") to re-expand).`);
|
|
5220
5260
|
return;
|
|
@@ -5358,6 +5398,8 @@ program
|
|
|
5358
5398
|
.description("Answer hash-bound imported-ADR questions, or triage deliberate proposed drafts.")
|
|
5359
5399
|
.option("--accept <id>", "promote a decision to accepted/human-confirmed (confirms its tripwires)")
|
|
5360
5400
|
.option("--reject <id>", "reject a draft decision with a durable lifecycle tombstone")
|
|
5401
|
+
.option("--confirm <id>", "countersign an agent-recorded decision (dec_…) or correction (con_…) as human-confirmed WITHOUT changing its status or content — the human act a capture token alone cannot stand in for")
|
|
5402
|
+
.option("--severity <s>", "with --confirm on a correction: the severity you grant (advisory | warning | blocking); default keeps its current severity")
|
|
5361
5403
|
.option("--approve-import <id>", "approve one exact imported ADR as human-confirmed authority")
|
|
5362
5404
|
.option("--decline-import <id>", "record that one exact imported ADR was reviewed and must stay advisory")
|
|
5363
5405
|
.option("--expected-source-hash <hash>", "exact sha256 source hash printed with the imported-ADR question")
|
|
@@ -5374,11 +5416,15 @@ program
|
|
|
5374
5416
|
store.close();
|
|
5375
5417
|
return fail("--private needs HUNCH_PRIVATE_DIR set to a private store");
|
|
5376
5418
|
}
|
|
5377
|
-
const actionCount = [opts.accept, opts.reject, opts.approveImport, opts.declineImport, opts.acceptVerified, opts.rejectDuplicates].filter(Boolean).length;
|
|
5419
|
+
const actionCount = [opts.accept, opts.reject, opts.confirm, opts.approveImport, opts.declineImport, opts.acceptVerified, opts.rejectDuplicates].filter(Boolean).length;
|
|
5378
5420
|
if (actionCount > 1) {
|
|
5379
5421
|
store.close();
|
|
5380
5422
|
return fail("choose exactly one review action at a time");
|
|
5381
5423
|
}
|
|
5424
|
+
if (opts.severity && !opts.confirm) {
|
|
5425
|
+
store.close();
|
|
5426
|
+
return fail("--severity applies only with --confirm on a correction");
|
|
5427
|
+
}
|
|
5382
5428
|
const decisions = () => opts.private ? store.recs("decisions") : store.json.loadAll("decisions");
|
|
5383
5429
|
let publicGroundingChanged = false;
|
|
5384
5430
|
const touchedHomes = new Set();
|
|
@@ -5418,6 +5464,58 @@ program
|
|
|
5418
5464
|
return fail(error instanceof Error ? error.message : String(error));
|
|
5419
5465
|
}
|
|
5420
5466
|
}
|
|
5467
|
+
else if (opts.confirm) {
|
|
5468
|
+
// Human countersign of agent testimony. A capture token proves only that the
|
|
5469
|
+
// interview tool was called (inside the agent's channel), so agent-written records
|
|
5470
|
+
// land as agent_recorded; this is the out-of-channel human act that signs them.
|
|
5471
|
+
// Content and status are untouched: confirming a proposed decision is not shipping it.
|
|
5472
|
+
const id = opts.confirm;
|
|
5473
|
+
const SEV = ["advisory", "warning", "blocking"];
|
|
5474
|
+
if (opts.severity && !SEV.includes(opts.severity)) {
|
|
5475
|
+
store.close();
|
|
5476
|
+
return fail(`--severity must be one of: ${SEV.join(", ")}`);
|
|
5477
|
+
}
|
|
5478
|
+
const now = new Date().toISOString();
|
|
5479
|
+
const d = opts.private ? store.getRec("decisions", id) : store.json.get("decisions", id);
|
|
5480
|
+
const c = d ? undefined : opts.private ? store.getRec("constraints", id) : store.json.get("constraints", id);
|
|
5481
|
+
if (!d && !c) {
|
|
5482
|
+
store.close();
|
|
5483
|
+
return fail(`no decision or constraint ${id} found${opts.private ? "" : " in the public home (add --private for overlay records)"}`);
|
|
5484
|
+
}
|
|
5485
|
+
let home;
|
|
5486
|
+
if (d) {
|
|
5487
|
+
if (opts.severity) {
|
|
5488
|
+
store.close();
|
|
5489
|
+
return fail("--severity applies only when confirming a correction (con_…), not a decision");
|
|
5490
|
+
}
|
|
5491
|
+
if (isImportedAdrDecision(d)) {
|
|
5492
|
+
store.close();
|
|
5493
|
+
return fail(`imported ADR ${d.id} requires the hash-bound --approve-import flow shown by hunch review`);
|
|
5494
|
+
}
|
|
5495
|
+
home = opts.private ? decisionMemoryHome(store, d.id) : "public";
|
|
5496
|
+
putDecisionInHome(store, countersignDecision(d, now), home);
|
|
5497
|
+
console.log(`✓ confirmed decision ${id} as human_confirmed (status ${d.status} unchanged)`);
|
|
5498
|
+
}
|
|
5499
|
+
else {
|
|
5500
|
+
home = opts.private && store.getPrivateRec("constraints", id) ? "private" : "public";
|
|
5501
|
+
const next = countersignConstraint(c, now, opts.severity);
|
|
5502
|
+
if (home === "private")
|
|
5503
|
+
store.putPrivate("constraints", next);
|
|
5504
|
+
else
|
|
5505
|
+
store.json.put("constraints", next);
|
|
5506
|
+
const repoWide = next.scope.length === 1 && next.scope[0] === "**";
|
|
5507
|
+
console.log(`✓ confirmed ${next.severity} constraint ${id} as human_confirmed (scope: ${next.scope.join(", ")})`);
|
|
5508
|
+
if (next.severity === "blocking" && repoWide)
|
|
5509
|
+
console.log(" ⚠ repo-wide blocking rule: it can deny any edit at strict firmness and fail hunch check --strict.");
|
|
5510
|
+
}
|
|
5511
|
+
touchedHomes.add(home);
|
|
5512
|
+
store.reindex();
|
|
5513
|
+
if (home === "public") {
|
|
5514
|
+
publicGroundingChanged = true;
|
|
5515
|
+
if (!store.autoCommit)
|
|
5516
|
+
refreshExistingGrounding(root, store);
|
|
5517
|
+
}
|
|
5518
|
+
}
|
|
5421
5519
|
else if (opts.accept) {
|
|
5422
5520
|
const d = opts.private ? store.getRec("decisions", opts.accept) : store.json.get("decisions", opts.accept);
|
|
5423
5521
|
if (!d) {
|
|
@@ -5541,7 +5639,7 @@ program
|
|
|
5541
5639
|
if (dupCount)
|
|
5542
5640
|
console.log(`\n Batch-reject the ${dupCount} duplicate(s): hunch review --reject-duplicates`);
|
|
5543
5641
|
}
|
|
5544
|
-
console.log(`\nAccept: hunch review --accept <id> Reject: hunch review --reject <id>`);
|
|
5642
|
+
console.log(`\nAccept: hunch review --accept <id> Reject: hunch review --reject <id> Confirm (sign, keep status): hunch review --confirm <id>`);
|
|
5545
5643
|
}
|
|
5546
5644
|
}
|
|
5547
5645
|
if (touchedHomes.size)
|
|
@@ -6741,7 +6839,7 @@ program
|
|
|
6741
6839
|
if (!roadmap.length)
|
|
6742
6840
|
console.log(" (empty — record what's next as a PROPOSED decision via /capture and it appears here)");
|
|
6743
6841
|
for (const r of roadmap)
|
|
6744
|
-
console.log(` • ${r.title} (${r.id}${r.topic ? `, ${r.topic}` : ""}, since ${r.date})\n ${r.note}`);
|
|
6842
|
+
console.log(` • ${r.title} (${r.id}${r.topic ? `, ${r.topic}` : ""}, since ${r.date})${r.unconfirmed ? `\n ${unconfirmedRoadmapMarker(r, { private: !!opts.private })}` : ""}\n ${r.note}`);
|
|
6745
6843
|
if (pendingReview > 0)
|
|
6746
6844
|
console.log(`\n (${pendingReview} legacy un-vouched draft(s) — \`hunch adopt-drafts\` to auto-trust them as advisory)`);
|
|
6747
6845
|
// Task-record ranking: evaluated automatically on every task write; the kill rule applies itself.
|
|
@@ -7018,9 +7116,23 @@ program
|
|
|
7018
7116
|
// hook manager's regenerated file — issue #311) is NOT installed.
|
|
7019
7117
|
const unreachable = [["post-commit", report.postCommit], ["post-merge", report.postMerge], ["pre-commit", report.preCommit], ["post-checkout", report.postCheckout]]
|
|
7020
7118
|
.filter(([, e]) => e.state === "unreachable");
|
|
7119
|
+
// Present where git runs it, but its command is not a Hunch launcher that
|
|
7120
|
+
// exists (hand-edited, or an absolute path gone after a reinstall) — issue #315.
|
|
7121
|
+
const stale = [["post-commit", report.postCommit], ["post-merge", report.postMerge], ["pre-commit", report.preCommit], ["post-checkout", report.postCheckout]]
|
|
7122
|
+
.filter(([, e]) => e.state === "stale");
|
|
7123
|
+
// A bare `hunch init` regenerates post-commit without --private/--commit
|
|
7124
|
+
// and pre-commit without --strict, so pointing at it without naming the
|
|
7125
|
+
// options that keep them silently DOWNGRADES the install. --commit is left
|
|
7126
|
+
// out: init's auto-commit is ON unless --no-auto-commit is passed, so a
|
|
7127
|
+
// bare re-run keeps it and naming it here would claim a loss that never happens.
|
|
7128
|
+
const carried = [...new Set(stale.flatMap(([, e]) => e.flags ?? []))].filter((f) => f !== "--commit");
|
|
7129
|
+
const keep = carried.map((f) => (f === "--strict" ? "--enforce-strict" : f === "--private" ? "--private-sync (or --shared-sync)" : "")).filter(Boolean);
|
|
7130
|
+
const staleKeep = carried.length
|
|
7131
|
+
? ` — the stale block carried ${carried.join(", ")}${keep.length ? `: pass ${keep.join(", ")} or they are dropped` : ""}`
|
|
7132
|
+
: "";
|
|
7021
7133
|
// `hunch index` only ever installs post-merge onto an existing
|
|
7022
|
-
// post-commit install (it never hooks an un-hooked repo — see
|
|
7023
|
-
//
|
|
7134
|
+
// post-commit install (it never hooks an un-hooked repo — see the
|
|
7135
|
+
// hookReport(root).postCommit.state gate above) and never writes
|
|
7024
7136
|
// into a manager-owned hook — so the fix hint must not point there when
|
|
7025
7137
|
// post-commit itself is missing or a hook manager is in play.
|
|
7026
7138
|
const managedMissing = [report.postCommit, report.postMerge].find((e) => e.state === "missing" && e.manager !== "none");
|
|
@@ -7030,6 +7142,7 @@ program
|
|
|
7030
7142
|
const problems = [
|
|
7031
7143
|
missing.length ? `⚠ missing ${missing.join(", ")} — ${fix}` : "",
|
|
7032
7144
|
unreachable.length ? `⚠ installed but unreachable: ${unreachable.map(([n]) => n).join(", ")} — never runs; run \`hunch init\` to print the snippet for your hook manager` : "",
|
|
7145
|
+
stale.length ? `⚠ stale: ${stale.map(([n]) => n).join(", ")} — the hook's command is not a working Hunch launcher; run \`hunch init\` to regenerate it${staleKeep}` : "",
|
|
7033
7146
|
].filter(Boolean);
|
|
7034
7147
|
console.log(`hooks: ${problems.length
|
|
7035
7148
|
? problems.join("\n ")
|
|
@@ -7037,6 +7150,13 @@ program
|
|
|
7037
7150
|
for (const [name, e] of unreachable) {
|
|
7038
7151
|
console.log(dim(` ↳ ${name}: ${e.reason ?? "the block sits where git never reaches it"} (${rel(root, e.path)})`));
|
|
7039
7152
|
}
|
|
7153
|
+
for (const [name, e] of stale) {
|
|
7154
|
+
console.log(dim(` ↳ ${name}: ${e.reason ?? "the block's command is not a Hunch launcher"} (${rel(root, e.path)})`));
|
|
7155
|
+
}
|
|
7156
|
+
// Where the live hooks point — informational (a hook may legitimately run
|
|
7157
|
+
// another install), but the fastest way to spot a stale/foreign launcher.
|
|
7158
|
+
for (const line of hookInvocationLines(report, resolveInvocation().shell))
|
|
7159
|
+
console.log(dim(` ↳ ${line}`));
|
|
7040
7160
|
// Workspace ledger: what this machine is called, whether its record is in memory,
|
|
7041
7161
|
// and whether the checkout hook that keeps it fresh is installed.
|
|
7042
7162
|
try {
|
|
@@ -7045,7 +7165,7 @@ program
|
|
|
7045
7165
|
const others = store.recs("workspaces").filter((r) => r.machine.id !== machine.id).length;
|
|
7046
7166
|
const leak = labelLeaksIdentity(machine.label);
|
|
7047
7167
|
console.log(`workspaces: this machine is ${machine.label}${leak ? ` (⚠ label equals the ${leak})` : ""} · record in memory: ${stored ? `yes (${stored.observed_at})` : "no"} · ${others} other machine(s)` +
|
|
7048
|
-
`${hooks.postCheckout ? " · post-checkout hook installed" : report.postCheckout.state === "unreachable" ? " · ⚠ post-checkout hook unreachable" : hooks.postCommit ? " · post-checkout hook not installed (`hunch index` adds it)" : ""}`);
|
|
7168
|
+
`${hooks.postCheckout ? " · post-checkout hook installed" : report.postCheckout.state === "unreachable" ? " · ⚠ post-checkout hook unreachable" : report.postCheckout.state === "stale" ? " · ⚠ post-checkout hook stale" : hooks.postCommit ? " · post-checkout hook not installed (`hunch index` adds it)" : ""}`);
|
|
7049
7169
|
}
|
|
7050
7170
|
catch { /* no machine file writable: nothing to report */ }
|
|
7051
7171
|
}
|
|
@@ -7190,28 +7310,17 @@ function readStdin() {
|
|
|
7190
7310
|
});
|
|
7191
7311
|
}
|
|
7192
7312
|
/** Absolute edit path → repo-relative, forward-slash (constraint scopes are
|
|
7193
|
-
* forward-slash globs even on Windows)
|
|
7194
|
-
|
|
7195
|
-
*
|
|
7196
|
-
*
|
|
7197
|
-
|
|
7198
|
-
|
|
7199
|
-
|
|
7200
|
-
|
|
7201
|
-
catch {
|
|
7202
|
-
const parent = dirname(p);
|
|
7203
|
-
if (parent === p)
|
|
7204
|
-
return p; // hit the root; nothing more to resolve
|
|
7205
|
-
return join(realpathNorm(parent), basename(p));
|
|
7206
|
-
}
|
|
7207
|
-
}
|
|
7208
|
-
/** Repo-relative POSIX path. BOTH ends are realpath-normalized first: on macOS
|
|
7209
|
-
* `process.cwd()` (hence findRoot) resolves /var→/private/var, but a hook event's
|
|
7210
|
-
* file_path arrives UN-resolved — so a naive relative() yields a bogus "../" path
|
|
7211
|
-
* under any symlinked root (/var, /tmp, symlinked $HOME) and the caller treats the
|
|
7212
|
-
* file as outside the repo, silently dropping all context (dec_e0a36efbf5). */
|
|
7313
|
+
* forward-slash globs even on Windows), or "" when `abs` isn't (resolvably)
|
|
7314
|
+
* inside `root`. Thin adapter over the shared `repoRelativeTarget` (core/paths.ts,
|
|
7315
|
+
* which realpath-normalizes both ends first — see its docstring for why, incl.
|
|
7316
|
+
* dec_e0a36efbf5): that function passes an unresolvable absolute path through
|
|
7317
|
+
* UNCHANGED (still absolute) rather than signaling failure directly, since other
|
|
7318
|
+
* callers want the original target back to fail their own match safely. This
|
|
7319
|
+
* adapter converts that "still absolute" signal to "" — the sentinel `onEdit`
|
|
7320
|
+
* and `editTargets` below already treat as "skip, not in this repo". */
|
|
7213
7321
|
function toRepoRel(root, abs) {
|
|
7214
|
-
|
|
7322
|
+
const rel = repoRelativeTarget(abs, root);
|
|
7323
|
+
return isAbsolute(rel) || /^[a-zA-Z]:/.test(rel) ? "" : rel;
|
|
7215
7324
|
}
|
|
7216
7325
|
/** The in-repo files a pre-edit event would change, each with the lines the edit
|
|
7217
7326
|
* ADDS to that file. A Codex apply_patch yields one entry per touched path (a
|
package/dist/cli/serve.d.ts
CHANGED
|
@@ -1,2 +1,16 @@
|
|
|
1
1
|
import type { Command } from "commander";
|
|
2
|
+
/** Compact one served partition's ledger. Compaction is read → slice → write, so it takes the
|
|
3
|
+
* partition write lock like every other writer (#286): without it a live server's append between
|
|
4
|
+
* the read and the write is overwritten, regressing head_seq and reusing a seq. The ledger lives
|
|
5
|
+
* in the store's RESOLVED state home (the overlay in shared mode), never `<root>/.hunch` blindly. */
|
|
6
|
+
export declare function compactPartition(root: string, scope: {
|
|
7
|
+
kind: "organization" | "team" | "user" | "repository";
|
|
8
|
+
id: string;
|
|
9
|
+
}, keep: number, lockOptions?: {
|
|
10
|
+
timeoutMs?: number;
|
|
11
|
+
}): Promise<{
|
|
12
|
+
dropped: number;
|
|
13
|
+
floor_seq: number;
|
|
14
|
+
head_seq: number;
|
|
15
|
+
}>;
|
|
2
16
|
export declare function registerServeCommands(program: Command): void;
|
package/dist/cli/serve.js
CHANGED
|
@@ -6,9 +6,9 @@ import { initServeConfig, partitionFor, readServeConfig, writeServeConfig } from
|
|
|
6
6
|
import { compactLedger } from "../store/changeLedger.js";
|
|
7
7
|
import { HunchStore } from "../store/hunchStore.js";
|
|
8
8
|
import { hunchPaths } from "../core/paths.js";
|
|
9
|
-
import { partitionOf } from "../store/stateBinding.js";
|
|
9
|
+
import { partitionOf, stateHomeFor } from "../store/stateBinding.js";
|
|
10
10
|
import { formatReplayReport, verifyReplay } from "../store/replay.js";
|
|
11
|
-
import {
|
|
11
|
+
import { withWriteLock } from "../serve/writelock.js";
|
|
12
12
|
import { ScopeSchema, scopePath } from "../core/stateContract.js";
|
|
13
13
|
import { HUNCH_VERSION } from "../core/version.js";
|
|
14
14
|
function parseScopeArg(value) {
|
|
@@ -18,6 +18,20 @@ function parseScopeArg(value) {
|
|
|
18
18
|
throw new Error(`partition must be kind:id (organization|team|user|repository), got "${value}"`);
|
|
19
19
|
return parsed.data;
|
|
20
20
|
}
|
|
21
|
+
/** Compact one served partition's ledger. Compaction is read → slice → write, so it takes the
|
|
22
|
+
* partition write lock like every other writer (#286): without it a live server's append between
|
|
23
|
+
* the read and the write is overwritten, regressing head_seq and reusing a seq. The ledger lives
|
|
24
|
+
* in the store's RESOLVED state home (the overlay in shared mode), never `<root>/.hunch` blindly. */
|
|
25
|
+
export async function compactPartition(root, scope, keep, lockOptions = {}) {
|
|
26
|
+
const store = new HunchStore(hunchPaths(root));
|
|
27
|
+
try {
|
|
28
|
+
const { hunchDir } = stateHomeFor(store, scope);
|
|
29
|
+
return await withWriteLock(hunchDir, () => compactLedger(hunchDir, scope, { keep }), lockOptions);
|
|
30
|
+
}
|
|
31
|
+
finally {
|
|
32
|
+
store.close();
|
|
33
|
+
}
|
|
34
|
+
}
|
|
21
35
|
export function registerServeCommands(program) {
|
|
22
36
|
const DEFAULT_CONFIG = "hunch-serve.json";
|
|
23
37
|
const serve = program.command("serve")
|
|
@@ -57,7 +71,7 @@ export function registerServeCommands(program) {
|
|
|
57
71
|
.requiredOption("--partition <kind:id>", "the partition whose ledger to compact")
|
|
58
72
|
.option("--keep <n>", "events to keep", "1000")
|
|
59
73
|
.option("--json", "machine-readable output")
|
|
60
|
-
.action((opts) => {
|
|
74
|
+
.action(async (opts) => {
|
|
61
75
|
const parent = serve.opts();
|
|
62
76
|
const config = readServeConfig(resolve(parent.config ?? DEFAULT_CONFIG));
|
|
63
77
|
const scope = parseScopeArg(opts.partition);
|
|
@@ -67,7 +81,7 @@ export function registerServeCommands(program) {
|
|
|
67
81
|
const keep = Number(opts.keep);
|
|
68
82
|
if (!Number.isInteger(keep) || keep < 0)
|
|
69
83
|
throw new Error("--keep must be a non-negative integer");
|
|
70
|
-
const result =
|
|
84
|
+
const result = await compactPartition(partition.root, scope, keep);
|
|
71
85
|
if (opts.json) {
|
|
72
86
|
console.log(JSON.stringify({ partition: scopePath(scope), ...result }));
|
|
73
87
|
return;
|
package/dist/client/state.d.ts
CHANGED
|
@@ -306,11 +306,12 @@ export declare function createStateClient(opts: StateClientOptions): {
|
|
|
306
306
|
missing: string[];
|
|
307
307
|
denied: string[];
|
|
308
308
|
}>;
|
|
309
|
+
/** Liveness; `partitions` is present because this client sends its credential. */
|
|
309
310
|
health: () => Promise<{
|
|
310
311
|
ok: boolean;
|
|
311
312
|
version: string;
|
|
312
313
|
protocol: string;
|
|
313
|
-
partitions
|
|
314
|
+
partitions?: string[];
|
|
314
315
|
}>;
|
|
315
316
|
};
|
|
316
317
|
export type StateClient = ReturnType<typeof createStateClient>;
|
package/dist/client/state.js
CHANGED
|
@@ -59,6 +59,7 @@ export function createStateClient(opts) {
|
|
|
59
59
|
captureBatch: (request) => call("POST", "/nuryel/v1/capture-batch", request),
|
|
60
60
|
subscribe: (request) => call("POST", "/nuryel/v1/subscribe", request),
|
|
61
61
|
records: (request) => call("POST", "/nuryel/v1/records", request),
|
|
62
|
+
/** Liveness; `partitions` is present because this client sends its credential. */
|
|
62
63
|
health: () => call("GET", "/nuryel/v1/health"),
|
|
63
64
|
};
|
|
64
65
|
}
|
package/dist/core/agenthook.js
CHANGED
|
@@ -152,8 +152,12 @@ function toolOutput(value) {
|
|
|
152
152
|
}
|
|
153
153
|
function explicitToolOutcome(response) {
|
|
154
154
|
const raw = obj(response);
|
|
155
|
+
// A bare string carries no status. Codex sends a Bash call's raw output this
|
|
156
|
+
// way, without its exit code, and a failing test run prints output as readily
|
|
157
|
+
// as a passing one. Status-looking text inside it ("Exit code: 0") is output
|
|
158
|
+
// the command itself can print, so it is never parsed as a status either.
|
|
155
159
|
if (!raw)
|
|
156
|
-
return
|
|
160
|
+
return "unknown";
|
|
157
161
|
if (raw.success === false || raw.is_error === true || raw.isError === true || (raw.error !== undefined && raw.error !== null))
|
|
158
162
|
return "failure";
|
|
159
163
|
const status = raw.status;
|
|
@@ -176,8 +180,8 @@ function explicitToolOutcome(response) {
|
|
|
176
180
|
if (explicitSuccess)
|
|
177
181
|
return "success";
|
|
178
182
|
// Common successful tool-result shapes carry output fields even when the
|
|
179
|
-
// output is empty
|
|
180
|
-
//
|
|
183
|
+
// output is empty (Claude Code routes failed calls to PostToolUseFailure
|
|
184
|
+
// instead). An unstructured string remains unknown (see above).
|
|
181
185
|
if (["stdout", "stderr", "output", "content"].some(key => Object.prototype.hasOwnProperty.call(raw, key)))
|
|
182
186
|
return "success";
|
|
183
187
|
return "unknown";
|
|
@@ -3,10 +3,18 @@
|
|
|
3
3
|
*
|
|
4
4
|
* hunch_capture_decision issues a short-lived token; the commit path consumes it, so a
|
|
5
5
|
* decision written through the capture front door is provably the tail of an interview
|
|
6
|
-
* — the identity-principle guard against a silent, un-interviewed write.
|
|
7
|
-
* MCP server is long-lived); tokens are one-time-use and expire so an
|
|
8
|
-
* interview can't leak. Absence of a token never BLOCKS a write yet (staged
|
|
6
|
+
* PROTOCOL — the identity-principle guard against a silent, un-interviewed write.
|
|
7
|
+
* In-memory (the MCP server is long-lived); tokens are one-time-use and expire so an
|
|
8
|
+
* abandoned interview can't leak. Absence of a token never BLOCKS a write yet (staged
|
|
9
9
|
* deprecation §9.3) — the caller decides how to treat an un-gated write.
|
|
10
|
+
*
|
|
11
|
+
* WHAT A TOKEN DOES NOT PROVE: that a human answered. Any agent — or content steering
|
|
12
|
+
* one — can call hunch_capture_decision and consume the token it gets back, entirely
|
|
13
|
+
* inside the agent's own MCP channel. So a consumed token never confers
|
|
14
|
+
* `human_confirmed` on its own (the stamp the strict gate and the edit hook trust). It
|
|
15
|
+
* only licenses ASKING the human through a channel the agent does not control: an MCP
|
|
16
|
+
* elicitation answered in the client UI (`isHumanConfirmationAnswer`), or a human
|
|
17
|
+
* running `hunch review --confirm <id>`. Without one of those, the write is testimony.
|
|
10
18
|
*/
|
|
11
19
|
declare const CAPTURE_TOKEN_TTL_MS: number;
|
|
12
20
|
/** Issue a token stamped `now` (epoch ms). Prunes expired tokens first so the map can't
|
|
@@ -16,4 +24,23 @@ export declare function issueCaptureToken(mint: () => string, now: number): stri
|
|
|
16
24
|
/** Consume a token iff it is a live, unexpired capture session. One-time use: a second
|
|
17
25
|
* consume of the same token returns false. */
|
|
18
26
|
export declare function consumeCaptureToken(token: string | undefined, now: number): boolean;
|
|
27
|
+
/** The form a human answers in the client UI (MCP `elicitation/create`, form mode). One
|
|
28
|
+
* required boolean, so a bare "accept" (an empty submit) is never read as a yes. */
|
|
29
|
+
export declare const HUMAN_CONFIRMATION_SCHEMA: {
|
|
30
|
+
type: "object";
|
|
31
|
+
properties: {
|
|
32
|
+
confirm: {
|
|
33
|
+
type: "boolean";
|
|
34
|
+
title: string;
|
|
35
|
+
description: string;
|
|
36
|
+
};
|
|
37
|
+
};
|
|
38
|
+
required: string[];
|
|
39
|
+
};
|
|
40
|
+
/** Did the human affirmatively confirm? Only an explicit accept WITH confirm === true
|
|
41
|
+
* counts; decline, cancel, and a missing or false checkbox all mean "no signature". */
|
|
42
|
+
export declare function isHumanConfirmationAnswer(result: {
|
|
43
|
+
action?: string;
|
|
44
|
+
content?: Record<string, unknown>;
|
|
45
|
+
} | null | undefined): boolean;
|
|
19
46
|
export { CAPTURE_TOKEN_TTL_MS };
|
|
@@ -3,10 +3,18 @@
|
|
|
3
3
|
*
|
|
4
4
|
* hunch_capture_decision issues a short-lived token; the commit path consumes it, so a
|
|
5
5
|
* decision written through the capture front door is provably the tail of an interview
|
|
6
|
-
* — the identity-principle guard against a silent, un-interviewed write.
|
|
7
|
-
* MCP server is long-lived); tokens are one-time-use and expire so an
|
|
8
|
-
* interview can't leak. Absence of a token never BLOCKS a write yet (staged
|
|
6
|
+
* PROTOCOL — the identity-principle guard against a silent, un-interviewed write.
|
|
7
|
+
* In-memory (the MCP server is long-lived); tokens are one-time-use and expire so an
|
|
8
|
+
* abandoned interview can't leak. Absence of a token never BLOCKS a write yet (staged
|
|
9
9
|
* deprecation §9.3) — the caller decides how to treat an un-gated write.
|
|
10
|
+
*
|
|
11
|
+
* WHAT A TOKEN DOES NOT PROVE: that a human answered. Any agent — or content steering
|
|
12
|
+
* one — can call hunch_capture_decision and consume the token it gets back, entirely
|
|
13
|
+
* inside the agent's own MCP channel. So a consumed token never confers
|
|
14
|
+
* `human_confirmed` on its own (the stamp the strict gate and the edit hook trust). It
|
|
15
|
+
* only licenses ASKING the human through a channel the agent does not control: an MCP
|
|
16
|
+
* elicitation answered in the client UI (`isHumanConfirmationAnswer`), or a human
|
|
17
|
+
* running `hunch review --confirm <id>`. Without one of those, the write is testimony.
|
|
10
18
|
*/
|
|
11
19
|
const CAPTURE_TOKEN_TTL_MS = 30 * 60 * 1000; // 30 min
|
|
12
20
|
const sessions = new Map(); // token -> issuedAt (epoch ms)
|
|
@@ -32,5 +40,23 @@ export function consumeCaptureToken(token, now) {
|
|
|
32
40
|
sessions.delete(token);
|
|
33
41
|
return now - at <= CAPTURE_TOKEN_TTL_MS;
|
|
34
42
|
}
|
|
43
|
+
/** The form a human answers in the client UI (MCP `elicitation/create`, form mode). One
|
|
44
|
+
* required boolean, so a bare "accept" (an empty submit) is never read as a yes. */
|
|
45
|
+
export const HUMAN_CONFIRMATION_SCHEMA = {
|
|
46
|
+
type: "object",
|
|
47
|
+
properties: {
|
|
48
|
+
confirm: {
|
|
49
|
+
type: "boolean",
|
|
50
|
+
title: "I confirm this myself",
|
|
51
|
+
description: "Check only if YOU stated this. Unchecked, it is kept as agent testimony that cannot block.",
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
required: ["confirm"],
|
|
55
|
+
};
|
|
56
|
+
/** Did the human affirmatively confirm? Only an explicit accept WITH confirm === true
|
|
57
|
+
* counts; decline, cancel, and a missing or false checkbox all mean "no signature". */
|
|
58
|
+
export function isHumanConfirmationAnswer(result) {
|
|
59
|
+
return result?.action === "accept" && result.content?.confirm === true;
|
|
60
|
+
}
|
|
35
61
|
export { CAPTURE_TOKEN_TTL_MS };
|
|
36
62
|
//# sourceMappingURL=capturetoken.js.map
|