@davesheffer/hunch 1.7.1 → 1.8.1
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/README.md +30 -0
- package/dist/cli/index.js +353 -9
- package/dist/constitution/experiment.js +60 -1
- package/dist/constitution/g3Conformance.js +26 -9
- package/dist/constitution/lifecycle.js +35 -0
- package/dist/constitution/repairPolicies.js +78 -0
- package/dist/constitution/schema.js +1 -1
- package/dist/constitution/service.js +72 -10
- package/dist/core/escalations.js +65 -0
- package/dist/core/memorylog.js +69 -0
- package/dist/core/repair.js +71 -0
- package/dist/core/reviewqueue.js +11 -0
- package/dist/extractors/diff.js +19 -3
- package/dist/extractors/git.js +39 -0
- package/dist/extractors/indexer.js +65 -6
- package/dist/extractors/languages.js +114 -0
- package/dist/extractors/nativeTreeSitter.js +9 -7
- package/dist/extractors/parse.js +25 -68
- package/dist/integrations/claudemd.js +2 -1
- package/dist/mcp/server.js +33 -1
- package/dist/synthesis/synthesize.js +23 -5
- package/dist/wiki/graph.js +301 -0
- package/dist/wiki/wiki.js +31 -3
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -49,10 +49,40 @@ Hunch creates a local graph of:
|
|
|
49
49
|
- **Constraints** — the invariants a change must not violate.
|
|
50
50
|
- **Bug lineage** — the root cause behind fixes, recurrences, and regression guards.
|
|
51
51
|
- **Architecture** — symbols, components, dependencies, blast radius, and fragility.
|
|
52
|
+
Deep code-structure parsing covers **TypeScript, JavaScript, and Python** (via a language
|
|
53
|
+
registry — each new language is one entry); the "why" layer works for any language.
|
|
52
54
|
|
|
53
55
|
It then puts that context where work happens: MCP tools, the CLI, a VS Code Change Gate, git hooks,
|
|
54
56
|
and an optional pull-request guard.
|
|
55
57
|
|
|
58
|
+
## Memory that runs itself (v1.8)
|
|
59
|
+
|
|
60
|
+
There is no review queue to manage. Hunch's memory loop is fully automated, and the rare decision
|
|
61
|
+
that genuinely needs a human is asked **inline, at the moment** — never parked in a backlog:
|
|
62
|
+
|
|
63
|
+
- **Auto-trust** — every captured decision enters the graph as live advisory memory the moment it
|
|
64
|
+
lands. It grounds and ranks immediately; it can never hard-block anything until a human
|
|
65
|
+
explicitly vouches for it. Migrate an old draft backlog once with `hunch adopt-drafts`.
|
|
66
|
+
- **A source-control panel for memory** — the VS Code **Hunch Memory** view shows every move
|
|
67
|
+
Hunch makes (capture / adopt / supersede / prune / repair) as a timeline: click for the diff,
|
|
68
|
+
right-click to revert locally. `hunch log` is the same spine in the terminal.
|
|
69
|
+
- **Inline escalations** — `hunch escalations` (and the `hunch_escalations` MCP tool) lists only
|
|
70
|
+
what the graph cannot resolve itself: a topic conflict, a candidate rule awaiting review, a
|
|
71
|
+
proposed rule ready to activate. Each entry is a question with its resolution verb. Normally
|
|
72
|
+
empty.
|
|
73
|
+
- **Self-repair** — rename a file and the next sync automatically heals every decision binding,
|
|
74
|
+
tripwire scope, constraint scope, and policy selector that matched it exactly (git's own rename
|
|
75
|
+
detection, zero guessing). Repairs land as revertable timeline moves; a repaired *policy* asks
|
|
76
|
+
once, inline, for a fresh proof.
|
|
77
|
+
- **Local-first by design** — memory auto-commits locally and rides your next push;
|
|
78
|
+
`hunch push` (or the panel's Approve-to-push) is the one deliberate outward step.
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
hunch log # the memory timeline (what Hunch did, when, revertable)
|
|
82
|
+
hunch escalations # the decisions only you can make — normally empty
|
|
83
|
+
hunch repair --apply # heal bindings after a rename (sync does this automatically)
|
|
84
|
+
```
|
|
85
|
+
|
|
56
86
|
## One graph. Every assistant. No lock-in.
|
|
57
87
|
|
|
58
88
|
Hunch is agent-agnostic by design. It scaffolds MCP and grounding for Claude Code, Cursor, VS Code / Copilot,
|
package/dist/cli/index.js
CHANGED
|
@@ -30,13 +30,16 @@ import { indexRepo } from "../extractors/indexer.js";
|
|
|
30
30
|
import { syncCommit, recordFailure, captureTestRun } from "../synthesis/synthesize.js";
|
|
31
31
|
import { parseTestReport } from "../extractors/testreport.js";
|
|
32
32
|
import { readSynthesisPreference, resolveSynthesisProvider, selectProvider, SYNTH_PREFERENCES, writeSynthesisPreference, } from "../synthesis/provider.js";
|
|
33
|
-
import { isGitRepo, headSha, logSince, lastChangeDate, stagedFiles, workingFiles, commitFiles, asOfDate, stagedDiff, workingDiff, commitDiff, rangeFiles, rangeDiff, rangeSubjects, revExists, revParse, commitAndPushHunch, pullHunch, gitUntrackCached, gitCommonDir, isLinkedWorktree, mainWorktreeRoot } from "../extractors/git.js";
|
|
33
|
+
import { isGitRepo, headSha, logSince, lastChangeDate, stagedFiles, workingFiles, commitFiles, asOfDate, stagedDiff, workingDiff, commitDiff, rangeFiles, rangeDiff, rangeSubjects, revExists, revParse, commitAndPushHunch, pullHunch, gitUntrackCached, gitCommonDir, isLinkedWorktree, mainWorktreeRoot, gitMemoryLog, memoryMoveDiff, revertMemoryMove, pushCurrentBranch, commitChanges } from "../extractors/git.js";
|
|
34
|
+
import { parseMemoryLog } from "../core/memorylog.js";
|
|
35
|
+
import { renamesOf, planRepair, repairDecision, repairConstraint } from "../core/repair.js";
|
|
36
|
+
import { planPolicyRepair, repairPolicySpec } from "../constitution/repairPolicies.js";
|
|
34
37
|
import { writeTeamConfig, ensureTeamOverlay, readTeamConfig } from "../integrations/team.js";
|
|
35
38
|
import { runbookId, decisionId } from "../core/ids.js";
|
|
36
39
|
import { deriveForbids, effectiveForbids } from "../core/constraintmatch.js";
|
|
37
40
|
import { extractInlineIntent } from "../extractors/comments.js";
|
|
38
41
|
import { renderText, renderMarkdown, renderImpact, reportFailsStrict } from "../core/checkreport.js";
|
|
39
|
-
import { partitionReview, READY_MIN_GROUNDED } from "../core/reviewqueue.js";
|
|
42
|
+
import { partitionReview, isReviewDraft, READY_MIN_GROUNDED } from "../core/reviewqueue.js";
|
|
40
43
|
import { installPostCommitHook, installPreCommitHook } from "../integrations/hooks.js";
|
|
41
44
|
import { ensureSharedOverlayPointer } from "../integrations/worktree.js";
|
|
42
45
|
import { flushCapture } from "../integrations/sync.js";
|
|
@@ -64,6 +67,7 @@ import { renderCompilerScorecard, scoreCompilerCaseBank } from "../constitution/
|
|
|
64
67
|
import { generateWiki, wikiStatus, wikiPrompt, publicHome, privateHome, readWikiManifestAt, nowData } from "../wiki/wiki.js";
|
|
65
68
|
import { adoptProsePrompt } from "../wiki/adopt.js";
|
|
66
69
|
import { topicCollisions, renderGrounding } from "../core/topics.js";
|
|
70
|
+
import { pendingEscalations, policyEscalations } from "../core/escalations.js";
|
|
67
71
|
import { parseDocAnchors, renderDocGrounding } from "../core/docanchors.js";
|
|
68
72
|
import { compareCandidates } from "../core/compare.js";
|
|
69
73
|
import { checkConformance } from "../core/conformance.js";
|
|
@@ -333,6 +337,15 @@ program
|
|
|
333
337
|
return opts.quiet ? undefined : fail("--private/--overlay needs HUNCH_PRIVATE_DIR set to an overlay store");
|
|
334
338
|
}
|
|
335
339
|
store.json.ensureDirs();
|
|
340
|
+
// Self-repair rides every sync (Phase 5, §59.5): a commit's renames heal the
|
|
341
|
+
// exact-path bindings they break, silently, as a revertable `repair` move.
|
|
342
|
+
// Fail open — a repair error must never take the capture path down.
|
|
343
|
+
try {
|
|
344
|
+
const repaired = runRepair(store, root, sha ?? headSha(root), true);
|
|
345
|
+
if (repaired?.applied && !opts.quiet)
|
|
346
|
+
console.log(` ↳ repaired ${repaired.plan.rewrites.length + repaired.policyRewrites.length} memory binding(s) after rename`);
|
|
347
|
+
}
|
|
348
|
+
catch { /* repair is best-effort; drift still surfaces anything left behind */ }
|
|
336
349
|
const r = await syncCommit(store, root, sha ?? headSha(root), {
|
|
337
350
|
force: opts.force,
|
|
338
351
|
private: toOverlay,
|
|
@@ -994,10 +1007,18 @@ policyCmd
|
|
|
994
1007
|
.description("List Policy IR records from the public store plus the local private overlay.")
|
|
995
1008
|
.option("--state <state>", "filter by lifecycle state")
|
|
996
1009
|
.option("--public-only", "exclude private-overlay policy records")
|
|
1010
|
+
.option("--json", "emit id/state/severity/statement/authority/proof/data_class as JSON (the VS Code panel's data source)")
|
|
997
1011
|
.action((opts) => {
|
|
998
1012
|
const { store, root } = storeFor();
|
|
999
1013
|
try {
|
|
1000
1014
|
const policies = new ConstitutionService(store, root).list({ state: opts.state, publicOnly: opts.publicOnly });
|
|
1015
|
+
if (opts.json) {
|
|
1016
|
+
console.log(JSON.stringify(policies.map((p) => ({
|
|
1017
|
+
id: p.id, state: p.state, severity: p.severity, statement: p.statement,
|
|
1018
|
+
authority: p.authority, proof: p.proof, data_class: p.data_class, topic: p.topic,
|
|
1019
|
+
}))));
|
|
1020
|
+
return;
|
|
1021
|
+
}
|
|
1001
1022
|
if (!policies.length) {
|
|
1002
1023
|
console.log("No Constitution policies found.");
|
|
1003
1024
|
return;
|
|
@@ -1277,6 +1298,44 @@ policyCmd
|
|
|
1277
1298
|
store.close();
|
|
1278
1299
|
}
|
|
1279
1300
|
});
|
|
1301
|
+
policyCmd
|
|
1302
|
+
.command("withdraw")
|
|
1303
|
+
.description("Targeted advisory withdrawal: pull the human authority back (active_advisory → proposed). The policy stops surfacing as an active rule and re-enters the inline escalation loop. History retained.")
|
|
1304
|
+
.argument("<id>", "policy id")
|
|
1305
|
+
.requiredOption("--actor <identity>", "explicit human identity: human:, github:, or git:")
|
|
1306
|
+
.requiredOption("--reason <reason>", "audited withdrawal reason")
|
|
1307
|
+
.action((id, opts) => {
|
|
1308
|
+
const { store, root } = storeFor();
|
|
1309
|
+
try {
|
|
1310
|
+
const policy = new ConstitutionService(store, root).withdraw(id, opts.actor, opts.reason);
|
|
1311
|
+
console.log(`✓ ${policy.id} withdrawn to ${policy.state}; authority returned to the human pool (revision ${policy.revision})`);
|
|
1312
|
+
}
|
|
1313
|
+
catch (e) {
|
|
1314
|
+
fail(e.message);
|
|
1315
|
+
}
|
|
1316
|
+
finally {
|
|
1317
|
+
store.close();
|
|
1318
|
+
}
|
|
1319
|
+
});
|
|
1320
|
+
policyCmd
|
|
1321
|
+
.command("retire")
|
|
1322
|
+
.description("Permanently retire a policy (active or proposed → retired): it stops surfacing anywhere, its valid-time window closes, and its full history stays (supersede, never erase).")
|
|
1323
|
+
.argument("<id>", "policy id")
|
|
1324
|
+
.requiredOption("--actor <identity>", "explicit human identity: human:, github:, or git:")
|
|
1325
|
+
.requiredOption("--reason <reason>", "audited retirement reason")
|
|
1326
|
+
.action((id, opts) => {
|
|
1327
|
+
const { store, root } = storeFor();
|
|
1328
|
+
try {
|
|
1329
|
+
const policy = new ConstitutionService(store, root).retire(id, opts.actor, opts.reason);
|
|
1330
|
+
console.log(`✓ ${policy.id} retired; window closed, history retained (revision ${policy.revision})`);
|
|
1331
|
+
}
|
|
1332
|
+
catch (e) {
|
|
1333
|
+
fail(e.message);
|
|
1334
|
+
}
|
|
1335
|
+
finally {
|
|
1336
|
+
store.close();
|
|
1337
|
+
}
|
|
1338
|
+
});
|
|
1280
1339
|
policyCmd
|
|
1281
1340
|
.command("demote")
|
|
1282
1341
|
.description("Immediately demote an active blocking policy to advisory without erasing history.")
|
|
@@ -1927,6 +1986,46 @@ experimentCmd
|
|
|
1927
1986
|
store.close();
|
|
1928
1987
|
}
|
|
1929
1988
|
});
|
|
1989
|
+
experimentCmd
|
|
1990
|
+
.command("respond")
|
|
1991
|
+
.description("Complete a revision-2 EXP-03 review with the standardized plain-language response: one choice, the rule you keep (accept/edit), one sentence of reasoning. Duration derives from the append-only start record; metrics are mapped deterministically — never hand-crafted.")
|
|
1992
|
+
.argument("<run-id>", "immutable experiment run id")
|
|
1993
|
+
.argument("<assignment-id>", "assignment returned by experiment next")
|
|
1994
|
+
.requiredOption("--reviewer <actor>", "explicit human reviewer (human:name)")
|
|
1995
|
+
.requiredOption("--choice <choice>", "accept | edit | reject | cannot_decide")
|
|
1996
|
+
.requiredOption("--reason <text>", "one plain-language sentence")
|
|
1997
|
+
.option("--rule <text>", "the rule exactly as it should be recorded (required for accept/edit)")
|
|
1998
|
+
.option("--rule-file <file>", "read the rule text from a file instead of --rule")
|
|
1999
|
+
.option("--inspected", "arm C only: you looked at the supporting checks before answering")
|
|
2000
|
+
.option("--confirmed-private-leak", "record an independently confirmed private leak incident")
|
|
2001
|
+
.option("--data-loss", "record a data loss/corruption incident")
|
|
2002
|
+
.option("--unsafe-evaluator", "record unsafe evaluator behavior")
|
|
2003
|
+
.action((runId, assignmentId, opts) => {
|
|
2004
|
+
const { store, root } = storeFor();
|
|
2005
|
+
try {
|
|
2006
|
+
if (opts.rule && opts.ruleFile)
|
|
2007
|
+
throw new Error("pass --rule or --rule-file, not both");
|
|
2008
|
+
const rule = opts.ruleFile ? readFileSync(resolve(opts.ruleFile), "utf8") : opts.rule ?? null;
|
|
2009
|
+
const service = new ConstitutionService(store, root);
|
|
2010
|
+
const appended = service.respondExperimentReview(runId, assignmentId, {
|
|
2011
|
+
reviewer: opts.reviewer,
|
|
2012
|
+
choice: opts.choice,
|
|
2013
|
+
rule_text: rule,
|
|
2014
|
+
reason: opts.reason,
|
|
2015
|
+
inspected_supporting_checks: !!opts.inspected,
|
|
2016
|
+
confirmed_private_leak: !!opts.confirmedPrivateLeak,
|
|
2017
|
+
data_loss_or_corruption: !!opts.dataLoss,
|
|
2018
|
+
unsafe_evaluator_behavior: !!opts.unsafeEvaluator,
|
|
2019
|
+
});
|
|
2020
|
+
console.log(JSON.stringify({ appended, report: service.experimentReport(runId) }, null, 2));
|
|
2021
|
+
}
|
|
2022
|
+
catch (e) {
|
|
2023
|
+
fail(e.message);
|
|
2024
|
+
}
|
|
2025
|
+
finally {
|
|
2026
|
+
store.close();
|
|
2027
|
+
}
|
|
2028
|
+
});
|
|
1930
2029
|
experimentCmd
|
|
1931
2030
|
.command("followup")
|
|
1932
2031
|
.description("Append the preregistered seven-day EXP-03 reversal measurement.")
|
|
@@ -2627,9 +2726,14 @@ program
|
|
|
2627
2726
|
.command("firmness")
|
|
2628
2727
|
.description("Get or set how firmly agent lifecycle hooks enforce Hunch before edits.")
|
|
2629
2728
|
.argument("[level]", "off | advisory | firm | strict (omit to print the current level)")
|
|
2630
|
-
.
|
|
2729
|
+
.option("--json", "print the current level + choices as JSON (for the VS Code switch)")
|
|
2730
|
+
.action((level, opts) => {
|
|
2631
2731
|
const paths = hunchPaths(findRoot());
|
|
2632
2732
|
if (!level) {
|
|
2733
|
+
if (opts.json) {
|
|
2734
|
+
console.log(JSON.stringify({ firmness: readConfig(paths).firmness, levels: FIRMNESS_LEVELS }));
|
|
2735
|
+
return;
|
|
2736
|
+
}
|
|
2633
2737
|
console.log(`firmness: ${readConfig(paths).firmness}`);
|
|
2634
2738
|
console.log(`levels: ${FIRMNESS_LEVELS.join(" | ")} (set with: hunch firmness <level>)`);
|
|
2635
2739
|
return;
|
|
@@ -2693,7 +2797,7 @@ program
|
|
|
2693
2797
|
const blocking = store.recs("constraints").filter((c) => c.status === "active" && c.severity === "blocking" && vouchedSrc(c.provenance?.source));
|
|
2694
2798
|
const precise = blocking.filter((c) => !!effectiveForbids(c));
|
|
2695
2799
|
const scopeOnly = blocking.filter((c) => !effectiveForbids(c));
|
|
2696
|
-
const drafts = store.json.loadAll("decisions").filter(
|
|
2800
|
+
const drafts = store.json.loadAll("decisions").filter(isReviewDraft);
|
|
2697
2801
|
const { ready, scrutiny } = partitionReview(drafts, READY_MIN_GROUNDED);
|
|
2698
2802
|
const stale = store.staleness((f) => lastChangeDate(f, root)).filter((s) => s.kind === "constraint");
|
|
2699
2803
|
const fnote = {
|
|
@@ -2866,7 +2970,19 @@ program
|
|
|
2866
2970
|
L.push(`Roadmap (${roadmap.length} live proposed): ${roadmap.slice(0, 3).map((r) => r.title).join(" · ")}${roadmap.length > 3 ? " · …" : ""}`);
|
|
2867
2971
|
}
|
|
2868
2972
|
if (pendingReview > 0)
|
|
2869
|
-
L.push(`${pendingReview}
|
|
2973
|
+
L.push(`${pendingReview} legacy un-vouched draft(s) — adopt as advisory memory with \`hunch adopt-drafts\` (new captures auto-trust).`);
|
|
2974
|
+
const escalations = pendingEscalations(decisions);
|
|
2975
|
+
try {
|
|
2976
|
+
// Constitution human moments ride the same line; a broken policy store
|
|
2977
|
+
// must never take session-start orientation down (fail open). Public
|
|
2978
|
+
// store only — session transcripts travel further than a terminal.
|
|
2979
|
+
const { ConstitutionService: CS } = await import("../constitution/service.js");
|
|
2980
|
+
escalations.push(...policyEscalations(new CS(s, paths.root).list({ publicOnly: true }).map((p) => ({ ...p, last_action: p.audit.at(-1)?.action ?? null }))));
|
|
2981
|
+
}
|
|
2982
|
+
catch { /* constitution unavailable */ }
|
|
2983
|
+
if (escalations.length) {
|
|
2984
|
+
L.push(`⚖ ${escalations.length} decision(s) need YOUR call — ASK the user inline (don't queue): ${escalations.map((e) => e.question).join(" · ")}`);
|
|
2985
|
+
}
|
|
2870
2986
|
L.push("Orient further: hunch_context(task) · hunch_structure() · `hunch now`.");
|
|
2871
2987
|
// The operating loop rides session start — guaranteed delivery, once
|
|
2872
2988
|
// (the zod bench showed ambient skills are read in ~0% of sessions).
|
|
@@ -3007,6 +3123,44 @@ function printReviewItem(it) {
|
|
|
3007
3123
|
const synthLine = synth.raw ? `\n ↳ ${synth.raw}` : "";
|
|
3008
3124
|
console.log(` ${d.id} [${d.status}, ${d.provenance.source} ${d.provenance.confidence}]${pruneNote}\n ${d.title}\n ${d.decision.slice(0, 120)}${synthLine}`);
|
|
3009
3125
|
}
|
|
3126
|
+
program
|
|
3127
|
+
.command("adopt-drafts")
|
|
3128
|
+
.description("Auto-trust migration: adopt every legacy un-vouched proposed draft as trusted ADVISORY memory (status → accepted, source unchanged so it STILL never blocks). Clears the old review backlog in one shot — nothing is deleted, enforcement stays human-gated, and blocking authority is still granted inline. Idempotent.")
|
|
3129
|
+
.option("--dry-run", "list what would be adopted; change nothing")
|
|
3130
|
+
.option("--private", "include local private/shared-overlay drafts")
|
|
3131
|
+
.action((opts) => {
|
|
3132
|
+
const { store, root } = storeFor();
|
|
3133
|
+
try {
|
|
3134
|
+
if (opts.private && !store.hasPrivate)
|
|
3135
|
+
return fail("--private needs HUNCH_PRIVATE_DIR set to a private store");
|
|
3136
|
+
const all = opts.private ? store.recs("decisions") : store.json.loadAll("decisions");
|
|
3137
|
+
const drafts = all.filter(isReviewDraft);
|
|
3138
|
+
if (!drafts.length) {
|
|
3139
|
+
console.log("✓ No un-vouched drafts — the graph is already fully auto-trusted.");
|
|
3140
|
+
return;
|
|
3141
|
+
}
|
|
3142
|
+
if (opts.dryRun) {
|
|
3143
|
+
console.log(`Would adopt ${drafts.length} draft(s) as trusted advisory memory (still never block):`);
|
|
3144
|
+
for (const d of drafts)
|
|
3145
|
+
console.log(` ${d.id} [${d.provenance.source} ${d.provenance.confidence}] ${d.title}`);
|
|
3146
|
+
console.log(`\n${dim("Dry run — nothing changed. Re-run without --dry-run to adopt. Roadmap intent? Re-declare it with hunch_record_decision(status:proposed).")}`);
|
|
3147
|
+
return;
|
|
3148
|
+
}
|
|
3149
|
+
// Flip status proposed → accepted (in-force advisory). Source/confidence are
|
|
3150
|
+
// UNCHANGED: it stays llm_draft-sourced, so the veto/strict gates (which key on
|
|
3151
|
+
// human_confirmed, not status) keep treating it as advisory — never blocking.
|
|
3152
|
+
let adopted = 0;
|
|
3153
|
+
for (const d of drafts) {
|
|
3154
|
+
store.putWhereItLives("decisions", { ...d, status: "accepted", provenance: { ...d.provenance, last_verified: new Date().toISOString() } });
|
|
3155
|
+
adopted++;
|
|
3156
|
+
}
|
|
3157
|
+
store.reindex();
|
|
3158
|
+
console.log(`✓ Adopted ${adopted} draft(s) as trusted advisory memory. The review backlog is clear; none of them can block an edit until a human grants blocking authority inline.`);
|
|
3159
|
+
}
|
|
3160
|
+
finally {
|
|
3161
|
+
store.close();
|
|
3162
|
+
}
|
|
3163
|
+
});
|
|
3010
3164
|
program
|
|
3011
3165
|
.command("review")
|
|
3012
3166
|
.description("Triage drafts: segmented list, accept/reject one, or batch-accept Critic-verified drafts.")
|
|
@@ -3100,10 +3254,10 @@ program
|
|
|
3100
3254
|
}
|
|
3101
3255
|
}
|
|
3102
3256
|
else {
|
|
3103
|
-
const drafts = decisions().filter(
|
|
3257
|
+
const drafts = decisions().filter(isReviewDraft);
|
|
3104
3258
|
const { ready, scrutiny } = partitionReview(drafts, minGrounded);
|
|
3105
3259
|
if (!ready.length && !scrutiny.length) {
|
|
3106
|
-
console.log("✓ No
|
|
3260
|
+
console.log("✓ No drafts awaiting review — captured memory auto-trusts (advisory) the moment it lands.");
|
|
3107
3261
|
}
|
|
3108
3262
|
else {
|
|
3109
3263
|
if (ready.length) {
|
|
@@ -3154,7 +3308,7 @@ program
|
|
|
3154
3308
|
const minRejectConfidence = Number.isFinite(Number(opts.minRejectConfidence)) ? Number(opts.minRejectConfidence) : 0.7;
|
|
3155
3309
|
const all = opts.private ? store.recs("decisions") : store.json.loadAll("decisions");
|
|
3156
3310
|
// Same draft set `hunch review` / `hunch status` triage.
|
|
3157
|
-
const drafts = all.filter(
|
|
3311
|
+
const drafts = all.filter(isReviewDraft);
|
|
3158
3312
|
if (!drafts.length) {
|
|
3159
3313
|
console.log("✓ No drafts to auto-review.");
|
|
3160
3314
|
return;
|
|
@@ -3323,6 +3477,196 @@ program
|
|
|
3323
3477
|
store.close();
|
|
3324
3478
|
}
|
|
3325
3479
|
});
|
|
3480
|
+
// ---- escalations (the inline "ask the human" surface) ---------------------
|
|
3481
|
+
program
|
|
3482
|
+
.command("escalations")
|
|
3483
|
+
.description("The decisions a human must make NOW — surfaced to be asked INLINE (in the prompt), never a background queue. Captured memory auto-trusts on landing; this lists only what the graph genuinely can't resolve itself: topic conflicts, Constitution candidates awaiting review, and proposed policies whose activation is a human call. Normally empty. Exits non-zero when any are open, so an assistant/CI knows to raise them.")
|
|
3484
|
+
.option("--json", "emit the escalation entries as JSON (the VS Code panel's data source)")
|
|
3485
|
+
.action(async (opts) => {
|
|
3486
|
+
const { store, root } = storeFor();
|
|
3487
|
+
try {
|
|
3488
|
+
const items = pendingEscalations(store.recs("decisions"));
|
|
3489
|
+
// Constitution moments ride the same inline surface (§59.5.3) — never a queue.
|
|
3490
|
+
// Fail open: a broken policy store must not take the memory escalations down.
|
|
3491
|
+
try {
|
|
3492
|
+
const { ConstitutionService: CS } = await import("../constitution/service.js");
|
|
3493
|
+
items.push(...policyEscalations(new CS(store, root).list().map((p) => ({ ...p, last_action: p.audit.at(-1)?.action ?? null }))));
|
|
3494
|
+
}
|
|
3495
|
+
catch { /* constitution unavailable — memory escalations still surface */ }
|
|
3496
|
+
if (opts.json) {
|
|
3497
|
+
console.log(JSON.stringify(items));
|
|
3498
|
+
if (items.length)
|
|
3499
|
+
process.exitCode = 1;
|
|
3500
|
+
return;
|
|
3501
|
+
}
|
|
3502
|
+
if (!items.length) {
|
|
3503
|
+
console.log("✓ Nothing needs your decision — memory is auto-trusted and self-consistent.");
|
|
3504
|
+
return;
|
|
3505
|
+
}
|
|
3506
|
+
console.log(`${items.length} decision(s) need your call (ask inline; nothing is queued):\n`);
|
|
3507
|
+
for (const e of items) {
|
|
3508
|
+
console.log(` ⚖ ${e.question}`);
|
|
3509
|
+
console.log(` ${dim(e.detail)}`);
|
|
3510
|
+
console.log(` ${dim("→ " + e.resolution)}\n`);
|
|
3511
|
+
}
|
|
3512
|
+
process.exitCode = 1;
|
|
3513
|
+
}
|
|
3514
|
+
finally {
|
|
3515
|
+
store.close();
|
|
3516
|
+
}
|
|
3517
|
+
});
|
|
3518
|
+
// ---- log / revert-move (the memory-move timeline — VS Code Source Control) -
|
|
3519
|
+
program
|
|
3520
|
+
.command("log")
|
|
3521
|
+
.description("The memory-move timeline: every commit that changed .hunch/ (capture/adopt/supersede/prune), newest first. --json powers the VS Code Hunch Source Control view.")
|
|
3522
|
+
.option("--json", "emit the moves as JSON for tooling")
|
|
3523
|
+
.option("-n, --limit <n>", "max moves to show", "100")
|
|
3524
|
+
.option("--diff <sha>", "print one move's .hunch/ diff (the click-through), instead of the list")
|
|
3525
|
+
.action((opts) => {
|
|
3526
|
+
const root = findRoot();
|
|
3527
|
+
if (!isGitRepo(root))
|
|
3528
|
+
return fail("not a git repo — the memory timeline needs git history.");
|
|
3529
|
+
if (opts.diff) {
|
|
3530
|
+
process.stdout.write(memoryMoveDiff(opts.diff, root));
|
|
3531
|
+
return;
|
|
3532
|
+
}
|
|
3533
|
+
const limit = Number.isFinite(Number(opts.limit)) ? Number(opts.limit) : 100;
|
|
3534
|
+
const moves = parseMemoryLog(gitMemoryLog(root, limit));
|
|
3535
|
+
if (opts.json) {
|
|
3536
|
+
console.log(JSON.stringify(moves));
|
|
3537
|
+
return;
|
|
3538
|
+
}
|
|
3539
|
+
if (!moves.length) {
|
|
3540
|
+
console.log("No memory moves yet — nothing has changed .hunch/.");
|
|
3541
|
+
return;
|
|
3542
|
+
}
|
|
3543
|
+
const icon = { capture: "✚", adopt: "✓", supersede: "↻", prune: "✗", repair: "🔧", edit: "•" };
|
|
3544
|
+
for (const m of moves) {
|
|
3545
|
+
const ids = [...m.decisionIds, ...m.otherIds].slice(0, 4).join(",");
|
|
3546
|
+
console.log(`${m.date.slice(0, 10)} ${icon[m.kind]} ${m.kind.padEnd(9)} ${m.shortSha} ${m.subject.slice(0, 60)}${ids ? " " + dim(ids) : ""}`);
|
|
3547
|
+
}
|
|
3548
|
+
});
|
|
3549
|
+
/** Self-repair (Phase 5): heal exact-path memory bindings after a commit's renames.
|
|
3550
|
+
* Returns the plan (null when the commit renamed nothing that memory binds).
|
|
3551
|
+
* Apply mode rewrites the records in their homes, reindexes, and auto-commits each
|
|
3552
|
+
* touched home as a `repair` move on the timeline — background, revertable. */
|
|
3553
|
+
function runRepair(store, root, sha, apply) {
|
|
3554
|
+
const renames = renamesOf(commitChanges(sha, root));
|
|
3555
|
+
if (!renames.length)
|
|
3556
|
+
return null;
|
|
3557
|
+
const plan = planRepair(renames, store.recs("decisions"), store.recs("constraints"));
|
|
3558
|
+
// Policy bindings heal under the same zero-guessing contract; a broken policy
|
|
3559
|
+
// store must never take graph-record repair down (fail open).
|
|
3560
|
+
let service = null;
|
|
3561
|
+
let policyRewrites = [];
|
|
3562
|
+
try {
|
|
3563
|
+
service = new ConstitutionService(store, root);
|
|
3564
|
+
policyRewrites = planPolicyRepair(renames, service.list());
|
|
3565
|
+
}
|
|
3566
|
+
catch {
|
|
3567
|
+
service = null;
|
|
3568
|
+
}
|
|
3569
|
+
if (!plan.rewrites.length && !policyRewrites.length)
|
|
3570
|
+
return null;
|
|
3571
|
+
if (!apply)
|
|
3572
|
+
return { plan, policyRewrites, applied: false };
|
|
3573
|
+
let privateTouched = false, publicTouched = false;
|
|
3574
|
+
for (const d of store.recs("decisions")) {
|
|
3575
|
+
const healed = repairDecision(d, plan);
|
|
3576
|
+
if (healed === d)
|
|
3577
|
+
continue;
|
|
3578
|
+
store.putWhereItLives("decisions", healed);
|
|
3579
|
+
if (store.getPrivateRec("decisions", d.id))
|
|
3580
|
+
privateTouched = true;
|
|
3581
|
+
else
|
|
3582
|
+
publicTouched = true;
|
|
3583
|
+
}
|
|
3584
|
+
for (const c of store.recs("constraints")) {
|
|
3585
|
+
const healed = repairConstraint(c, plan);
|
|
3586
|
+
if (healed === c)
|
|
3587
|
+
continue;
|
|
3588
|
+
store.putWhereItLives("constraints", healed);
|
|
3589
|
+
if (store.getPrivateRec("constraints", c.id))
|
|
3590
|
+
privateTouched = true;
|
|
3591
|
+
else
|
|
3592
|
+
publicTouched = true;
|
|
3593
|
+
}
|
|
3594
|
+
if (service && policyRewrites.length) {
|
|
3595
|
+
const at = new Date().toISOString();
|
|
3596
|
+
for (const p of service.list()) {
|
|
3597
|
+
const healed = repairPolicySpec(p, policyRewrites, at);
|
|
3598
|
+
if (healed === p)
|
|
3599
|
+
continue;
|
|
3600
|
+
service.repository.putPolicy(healed);
|
|
3601
|
+
if (healed.data_class === "public")
|
|
3602
|
+
publicTouched = true;
|
|
3603
|
+
else
|
|
3604
|
+
privateTouched = true;
|
|
3605
|
+
}
|
|
3606
|
+
}
|
|
3607
|
+
store.reindex();
|
|
3608
|
+
if (store.autoCommit) {
|
|
3609
|
+
const total = plan.rewrites.length + policyRewrites.length;
|
|
3610
|
+
const message = `hunch: repair ${total} binding(s) after rename (${sha.slice(0, 7)})`;
|
|
3611
|
+
if (publicTouched)
|
|
3612
|
+
commitAndPushHunch(hunchPaths(root).hunch, message, { push: false });
|
|
3613
|
+
if (privateTouched && store.privateDir)
|
|
3614
|
+
commitAndPushHunch(store.privateDir, message, { push: true });
|
|
3615
|
+
}
|
|
3616
|
+
return { plan, policyRewrites, applied: true };
|
|
3617
|
+
}
|
|
3618
|
+
program
|
|
3619
|
+
.command("repair")
|
|
3620
|
+
.description("Self-repair: heal memory bindings (decision files, tripwire/constraint scopes) after a commit's renames — git's own rename detection, exact-path matches only, zero guessing. Dry-run unless --apply; the sync hook applies this automatically in the background.")
|
|
3621
|
+
.argument("[sha]", "commit whose renames to heal (default: HEAD)")
|
|
3622
|
+
.option("--apply", "rewrite the bindings (auto-commits each touched store as a `repair` move)")
|
|
3623
|
+
.action((sha, opts) => {
|
|
3624
|
+
const { store, root } = storeFor();
|
|
3625
|
+
try {
|
|
3626
|
+
if (!isGitRepo(root))
|
|
3627
|
+
return fail("repair needs a git repo.");
|
|
3628
|
+
const res = runRepair(store, root, sha ?? headSha(root), !!opts.apply);
|
|
3629
|
+
if (!res) {
|
|
3630
|
+
console.log("✓ Nothing to repair — the commit renamed nothing that memory binds exactly.");
|
|
3631
|
+
return;
|
|
3632
|
+
}
|
|
3633
|
+
const total = res.plan.rewrites.length + res.policyRewrites.length;
|
|
3634
|
+
console.log(`${res.applied ? "✓ Repaired" : "Would repair"} ${total} binding(s):`);
|
|
3635
|
+
for (const r of res.plan.rewrites)
|
|
3636
|
+
console.log(` ${r.id} ${r.field}: ${r.from} → ${r.to}`);
|
|
3637
|
+
for (const r of res.policyRewrites)
|
|
3638
|
+
console.log(` ${r.id} ${r.field}: ${r.from} → ${r.to}`);
|
|
3639
|
+
if (res.policyRewrites.length && res.applied)
|
|
3640
|
+
console.log(dim("\nRepaired policies need a fresh proof — they ask via `hunch escalations`."));
|
|
3641
|
+
if (!res.applied)
|
|
3642
|
+
console.log(dim("\nDry run — nothing changed. Re-run with --apply."));
|
|
3643
|
+
}
|
|
3644
|
+
finally {
|
|
3645
|
+
store.close();
|
|
3646
|
+
}
|
|
3647
|
+
});
|
|
3648
|
+
program
|
|
3649
|
+
.command("revert-move <sha>")
|
|
3650
|
+
.description("Undo one memory move: git-revert the commit that made it (LOCAL only, never pushed). Powers the Hunch view's 'reject move'.")
|
|
3651
|
+
.action((sha) => {
|
|
3652
|
+
const root = findRoot();
|
|
3653
|
+
if (!isGitRepo(root))
|
|
3654
|
+
return fail("not a git repo.");
|
|
3655
|
+
if (!revertMemoryMove(sha, root))
|
|
3656
|
+
return fail(`could not revert ${sha} (conflict or unknown commit) — aborted; working tree unchanged.`);
|
|
3657
|
+
console.log(`✓ reverted memory move ${sha} (local; not pushed).`);
|
|
3658
|
+
});
|
|
3659
|
+
program
|
|
3660
|
+
.command("push")
|
|
3661
|
+
.description("The approve-to-push step: push the current branch to its remote. Auto-commit keeps memory LOCAL by design; this is the one explicit outward move (public .hunch/ rides the repo, so this is a plain branch push).")
|
|
3662
|
+
.action(() => {
|
|
3663
|
+
const root = findRoot();
|
|
3664
|
+
if (!isGitRepo(root))
|
|
3665
|
+
return fail("not a git repo.");
|
|
3666
|
+
if (!pushCurrentBranch(root))
|
|
3667
|
+
return fail("push failed — no upstream, offline, or nothing to push.");
|
|
3668
|
+
console.log("✓ pushed the current branch to its remote.");
|
|
3669
|
+
});
|
|
3326
3670
|
// ---- drift (doc≠graph detector; advisory + CI-gateable) -------------------
|
|
3327
3671
|
program
|
|
3328
3672
|
.command("drift")
|
|
@@ -3563,7 +3907,7 @@ program
|
|
|
3563
3907
|
for (const r of roadmap)
|
|
3564
3908
|
console.log(` • ${r.title} (${r.id}${r.topic ? `, ${r.topic}` : ""}, since ${r.date})\n ${r.note}`);
|
|
3565
3909
|
if (pendingReview > 0)
|
|
3566
|
-
console.log(`\n (${pendingReview}
|
|
3910
|
+
console.log(`\n (${pendingReview} legacy un-vouched draft(s) — \`hunch adopt-drafts\` to auto-trust them as advisory)`);
|
|
3567
3911
|
}
|
|
3568
3912
|
finally {
|
|
3569
3913
|
store.close();
|
|
@@ -349,7 +349,10 @@ const Exp01MetricsSchema = z.object({
|
|
|
349
349
|
ctx.addIssue({ code: "custom", path: ["policy_violation"], message: "policy_violation must be present exactly for valid completions" });
|
|
350
350
|
});
|
|
351
351
|
const Exp03MetricsSchema = z.object({
|
|
352
|
-
|
|
352
|
+
// "uncompilable" is the revision-1 vocabulary; "cannot_decide" is its revision-2
|
|
353
|
+
// successor (expreg_ba6aef4ecd counts cannot-decide as its own raw category, so
|
|
354
|
+
// the recorded token must be what the reviewer actually chose — never folded).
|
|
355
|
+
decision: z.enum(["accepted_precise", "accepted_edited", "rejected", "uncompilable", "cannot_decide", "abandoned", "timeout"]),
|
|
353
356
|
precise: z.boolean(),
|
|
354
357
|
proof_inspected: z.boolean(),
|
|
355
358
|
result_hash: z.string().regex(HASH).nullable(),
|
|
@@ -452,6 +455,58 @@ export function compileExperimentOutcome(input, run, opts = {}) {
|
|
|
452
455
|
}
|
|
453
456
|
return parsed;
|
|
454
457
|
}
|
|
458
|
+
// ---- EXP-03 revision-2 standardized response (dec_0be4fd3717) --------------
|
|
459
|
+
// Revision-2 reviews are SUBMITTED in the same plain-language vocabulary they are
|
|
460
|
+
// PRESENTED in: one choice out of four, the rule text when one is kept, and one
|
|
461
|
+
// plain sentence. The mapper below is the single deterministic translation from
|
|
462
|
+
// that template into the canonical Exp03 metrics vocabulary — the reviewer never
|
|
463
|
+
// hand-crafts metrics, so the presented contract and the recorded outcome cannot
|
|
464
|
+
// drift apart. Revision-1 cases are refused here (their original raw submission
|
|
465
|
+
// path stays byte-identical for the append-only pilot).
|
|
466
|
+
export const EXP03_REVIEW_CHOICES = ["accept", "edit", "reject", "cannot_decide"];
|
|
467
|
+
const CHOICE_TO_DECISION = {
|
|
468
|
+
accept: "accepted_precise",
|
|
469
|
+
edit: "accepted_edited",
|
|
470
|
+
reject: "rejected",
|
|
471
|
+
cannot_decide: "cannot_decide", // its own raw category per expreg_ba6aef4ecd — never folded into uncompilable
|
|
472
|
+
};
|
|
473
|
+
export function compileExp03ReviewResponse(item, arm, response) {
|
|
474
|
+
if (!item.required_relationship) {
|
|
475
|
+
throw new Error(`case ${item.id} is a revision-1 pilot case and keeps its original submission contract; use the raw review submission, not the standardized template`);
|
|
476
|
+
}
|
|
477
|
+
if (!EXP03_REVIEW_CHOICES.includes(response.choice)) {
|
|
478
|
+
throw new Error(`choice must be one of: ${EXP03_REVIEW_CHOICES.join(" | ")}`);
|
|
479
|
+
}
|
|
480
|
+
const reason = response.reason?.trim();
|
|
481
|
+
if (!reason)
|
|
482
|
+
throw new Error("the response requires one plain-language sentence of reasoning");
|
|
483
|
+
const accepted = response.choice === "accept" || response.choice === "edit";
|
|
484
|
+
const rule = response.rule_text?.trim() || null;
|
|
485
|
+
if (accepted && !rule)
|
|
486
|
+
throw new Error(`choice "${response.choice}" requires the rule text it keeps`);
|
|
487
|
+
if (!accepted && rule)
|
|
488
|
+
throw new Error(`choice "${response.choice}" must leave the rule text blank`);
|
|
489
|
+
// Arms B/C present a proposed rule: "use it as written" must be byte-faithful to
|
|
490
|
+
// what was shown, and "after I correct it" must actually change it — the choice
|
|
491
|
+
// and the submitted text can never contradict each other.
|
|
492
|
+
if (arm !== "A" && response.choice === "accept" && rule !== item.compiler_candidate) {
|
|
493
|
+
throw new Error('the submitted rule differs from the one presented; use choice "edit"');
|
|
494
|
+
}
|
|
495
|
+
if (arm !== "A" && response.choice === "edit" && rule === item.compiler_candidate) {
|
|
496
|
+
throw new Error('the submitted rule is unchanged from the one presented; use choice "accept"');
|
|
497
|
+
}
|
|
498
|
+
if (response.inspected_supporting_checks && arm !== "C") {
|
|
499
|
+
throw new Error("supporting checks are only shown in arm C; this review cannot claim to have inspected them");
|
|
500
|
+
}
|
|
501
|
+
return {
|
|
502
|
+
decision: CHOICE_TO_DECISION[response.choice],
|
|
503
|
+
precise: accepted, // schema invariant: accepted outcomes are precise; graded later against the target commitment
|
|
504
|
+
proof_inspected: arm === "C" && !!response.inspected_supporting_checks,
|
|
505
|
+
result: accepted ? rule : null,
|
|
506
|
+
silent_semantic_substitution: false, // graded post-hoc via the append-only correction workflow, never self-declared
|
|
507
|
+
rejection_reason: response.choice === "reject" ? reason : null,
|
|
508
|
+
};
|
|
509
|
+
}
|
|
455
510
|
export const ExperimentReviewStartSchema = z.object({
|
|
456
511
|
id: z.string().regex(/^expreview_[a-f0-9]{10}$/),
|
|
457
512
|
content_hash: z.string().regex(HASH),
|
|
@@ -564,6 +619,7 @@ export function compileExperimentStop(input, run, opts = {}) {
|
|
|
564
619
|
const contentHash = canonicalHash(body);
|
|
565
620
|
return ExperimentStopSchema.parse({ id: `expstop_${shortHash(contentHash)}`, content_hash: contentHash, ...body });
|
|
566
621
|
}
|
|
622
|
+
const EXP03_DECISION_TOKENS = ["accepted_precise", "accepted_edited", "rejected", "uncompilable", "cannot_decide", "abandoned", "timeout"];
|
|
567
623
|
function wilson(successes, total) {
|
|
568
624
|
if (!total)
|
|
569
625
|
return null;
|
|
@@ -668,6 +724,9 @@ export function buildExperimentReport(run, bank, outcomes, followups, stops = []
|
|
|
668
724
|
bootstrap_95: run.experiment === "EXP-03" ? bootstrapReviewerRate(reviews, `${run.seed}:${arm}:reviewer-rate`) : null,
|
|
669
725
|
reversals: run.experiment === "EXP-03" ? measuredFollowups.filter((item) => item.reversed === true).length : null,
|
|
670
726
|
followups_missing: run.experiment === "EXP-03" ? completed.length - measuredFollowups.length : null,
|
|
727
|
+
decisions: run.experiment === "EXP-03"
|
|
728
|
+
? Object.fromEntries(EXP03_DECISION_TOKENS.map((token) => [token, reviews.filter((item) => item.decision === token).length]))
|
|
729
|
+
: null,
|
|
671
730
|
};
|
|
672
731
|
});
|
|
673
732
|
const caseById = new Map(bank.cases.map((item) => [item.id, item]));
|