@davesheffer/hunch 1.8.2 → 1.9.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.
Files changed (59) hide show
  1. package/README.md +96 -1
  2. package/dist/cli/index.js +1238 -396
  3. package/dist/constitution/adapters.js +31 -14
  4. package/dist/constitution/behaviorEvaluator.js +20 -7
  5. package/dist/constitution/behaviorProof.js +3 -2
  6. package/dist/constitution/canonical.js +7 -1
  7. package/dist/constitution/card.js +7 -2
  8. package/dist/constitution/compiler.js +71 -1
  9. package/dist/constitution/correctionPolicyMaterializer.js +496 -0
  10. package/dist/constitution/delta.js +3 -2
  11. package/dist/constitution/evaluator.js +29 -3
  12. package/dist/constitution/experiment.js +96 -5
  13. package/dist/constitution/experimentRunner.js +43 -14
  14. package/dist/constitution/g2BehaviorCandidates.js +49 -26
  15. package/dist/constitution/g2BehaviorDependencies.js +203 -14
  16. package/dist/constitution/g2Candidates.js +1 -1
  17. package/dist/constitution/lifecycle.js +17 -0
  18. package/dist/constitution/plan.js +26 -9
  19. package/dist/constitution/replacementFreeGit.js +67 -0
  20. package/dist/constitution/replay.js +6 -0
  21. package/dist/constitution/replayCache.js +1 -1
  22. package/dist/constitution/replayWorker.js +1 -1
  23. package/dist/constitution/repository.js +141 -5
  24. package/dist/constitution/safeCheckout.js +75 -0
  25. package/dist/constitution/schema.js +30 -5
  26. package/dist/constitution/service.js +74 -14
  27. package/dist/constitution/sourceMutation.js +65 -12
  28. package/dist/constitution/staticGraphBaseline.js +44 -0
  29. package/dist/constitution/structural.js +60 -4
  30. package/dist/core/autoreview.js +1 -1
  31. package/dist/core/canonicalOrder.js +6 -0
  32. package/dist/core/conformance.js +68 -27
  33. package/dist/core/docscan.js +2 -1
  34. package/dist/core/escalations.js +11 -0
  35. package/dist/core/io.js +44 -9
  36. package/dist/core/overlaySafety.js +178 -0
  37. package/dist/core/paths.js +13 -2
  38. package/dist/core/safeRepoFile.js +74 -0
  39. package/dist/extractors/comments.js +6 -8
  40. package/dist/extractors/git.js +1631 -82
  41. package/dist/extractors/indexer.js +86 -47
  42. package/dist/extractors/repoSource.js +390 -0
  43. package/dist/integrations/ciAction.js +10 -2
  44. package/dist/integrations/gitignore.js +44 -5
  45. package/dist/integrations/mergeDriver.js +23 -5
  46. package/dist/integrations/sync.js +61 -5
  47. package/dist/integrations/team.js +666 -23
  48. package/dist/mcp/server.js +261 -34
  49. package/dist/store/db.js +57 -7
  50. package/dist/store/hunchStore.js +92 -11
  51. package/dist/store/jsonStore.js +350 -63
  52. package/dist/store/schema.js +27 -11
  53. package/dist/synthesis/provider.js +13 -4
  54. package/dist/synthesis/synthesize.js +56 -19
  55. package/dist/wiki/graph.js +5 -4
  56. package/dist/wiki/wiki.js +16 -10
  57. package/package.json +15 -3
  58. package/tooling/competitive-watch.mjs +108 -0
  59. package/tooling/md1-benchmark.mjs +628 -0
package/dist/cli/index.js CHANGED
@@ -14,9 +14,10 @@
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 { existsSync, readFileSync, writeFileSync, mkdirSync, realpathSync } from "node:fs";
17
+ import { chmodSync, existsSync, lstatSync, readFileSync, readlinkSync, writeFileSync, mkdirSync, mkdtempSync, realpathSync, 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
+ import { tmpdir } from "node:os";
20
21
  import { fileURLToPath } from "node:url";
21
22
  import { Command } from "commander";
22
23
  import { hunchPaths, hunchPathsForDir, findRoot, toPosixTarget } from "../core/paths.js";
@@ -26,15 +27,15 @@ import { HUNCH_VERSION } from "../core/version.js";
26
27
  import { HunchStore } from "../store/hunchStore.js";
27
28
  import { JsonStore } from "../store/jsonStore.js";
28
29
  import { selectEmbedder } from "../store/embedder.js";
29
- import { indexRepo } from "../extractors/indexer.js";
30
+ import { assertCompleteRepoScan, indexRepo, scanRepo } from "../extractors/indexer.js";
30
31
  import { syncCommit, recordFailure, captureTestRun } from "../synthesis/synthesize.js";
31
32
  import { parseTestReport } from "../extractors/testreport.js";
32
33
  import { readSynthesisPreference, resolveSynthesisProvider, selectProvider, SYNTH_PREFERENCES, writeSynthesisPreference, normalizeProviderName, } 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, gitMemoryLog, memoryMoveDiff, revertMemoryMove, pushCurrentBranch, commitChanges } from "../extractors/git.js";
34
+ import { isGitRepo, isGitRepoRoot, sameGitPublication, sameRemoteUrl, canonicalRemoteUrl, repositoryUsesRemote, headSha, isolatedHeadSha, logSince, lastChangeDate, stagedFiles, workingFiles, commitFiles, asOfDate, stagedDiff, workingDiff, commitDiff, rangeFiles, rangeDiff, rangeSubjects, revExists, revParse, commitAndPushHunch, pullHunchStatus, syncExistingHunch, gitUntrackCached, gitCommonDir, hooksDir, isLinkedWorktree, mainWorktreeRoot, gitMemoryLog, memoryMoveDiff, revertMemoryMove, pushCurrentBranch, commitChanges } from "../extractors/git.js";
34
35
  import { parseMemoryLog } from "../core/memorylog.js";
35
36
  import { renamesOf, planRepair, repairDecision, repairConstraint } from "../core/repair.js";
36
37
  import { planPolicyRepair, repairPolicySpec } from "../constitution/repairPolicies.js";
37
- import { writeTeamConfig, ensureTeamOverlay, readTeamConfig } from "../integrations/team.js";
38
+ import { writeTeamConfig, ensureTeamOverlay, readTeamConfig, safeGitUrl, safeTeamRef, overlayMatchesTeamRemote, advertisedTeamRemoteContract, boundedTeamGitEnv, cloneValidatedTeamOverlay, explicitTeamRemoteContract, teamRemoteContract } from "../integrations/team.js";
38
39
  import { runbookId, decisionId } from "../core/ids.js";
39
40
  import { deriveForbids, effectiveForbids } from "../core/constraintmatch.js";
40
41
  import { extractInlineIntent } from "../extractors/comments.js";
@@ -42,7 +43,7 @@ import { renderText, renderMarkdown, renderImpact, reportFailsStrict } from "../
42
43
  import { partitionReview, isReviewDraft, READY_MIN_GROUNDED } from "../core/reviewqueue.js";
43
44
  import { installPostCommitHook, installPreCommitHook } from "../integrations/hooks.js";
44
45
  import { ensureSharedOverlayPointer } from "../integrations/worktree.js";
45
- import { flushCapture } from "../integrations/sync.js";
46
+ import { flushCapture, flushMemoryHome, flushMemoryHomes, pinSharedRemote, sharedRemoteFor } from "../integrations/sync.js";
46
47
  import { installMergeDriver } from "../integrations/mergeDriver.js";
47
48
  import { ensureGitignore, ignoreHunchMemory, HUNCH_MEMORY_DIRS } from "../integrations/gitignore.js";
48
49
  import { writeCiWorkflow } from "../integrations/ciAction.js";
@@ -53,6 +54,7 @@ import { healClaudeConfigCaseSplit } from "../integrations/claudeConfig.js";
53
54
  import { formatContext, formatStructure } from "../core/format.js";
54
55
  import { readConfig, writeConfig, FIRMNESS_LEVELS, isFirmness } from "../core/config.js";
55
56
  import { blockingInScope, vetoInScope, proposedEditLines } from "../core/hookpolicy.js";
57
+ import { isHumanConfirmed } from "../core/strictgate.js";
56
58
  import { appendEvent, readEvents } from "../core/events.js";
57
59
  import { computeStats, formatStats } from "../core/stats.js";
58
60
  import { injectionMode } from "../core/hookcache.js";
@@ -71,7 +73,8 @@ import { pendingEscalations, policyEscalations } from "../core/escalations.js";
71
73
  import { parseDocAnchors, renderDocGrounding } from "../core/docanchors.js";
72
74
  import { compareCandidates } from "../core/compare.js";
73
75
  import { checkConformance } from "../core/conformance.js";
74
- import { ConstitutionService } from "../constitution/service.js";
76
+ import { ConstitutionService, policyEvaluationEnvelope } from "../constitution/service.js";
77
+ import { sourceGraphSnapshot } from "../constitution/evaluator.js";
75
78
  import { renderProofCard } from "../constitution/card.js";
76
79
  import { movePolicyArtifactsToPrivate } from "../constitution/repository.js";
77
80
  import { HistoryDispositionClassificationSchema } from "../constitution/schema.js";
@@ -89,11 +92,74 @@ import { resolveInvocation, dim, synthesisStatusLines, maybeWarnOllamaContext }
89
92
  const program = new Command();
90
93
  program.name("hunch").description("Hunch — an Engineering Memory OS: a git-native reasoning graph for your codebase.").version(HUNCH_VERSION);
91
94
  let openStore = null;
92
- function storeFor() {
93
- const root = findRoot();
95
+ function openTeamStore(root, opts = {}) {
96
+ // A committed team.json is an explicit declaration that this checkout belongs
97
+ // to a shared memory graph. Once that declaration exists, silently falling back
98
+ // to public `.hunch/` on an invalid config, offline first clone, or dead pointer
99
+ // would turn a missing team rule into a false pass (and could publish a first
100
+ // write in the code repository). An explicit HUNCH_PRIVATE_DIR remains the
101
+ // documented per-process override and therefore owns its own availability.
102
+ const explicitOverlay = !!process.env.HUNCH_PRIVATE_DIR?.trim();
103
+ const teamFile = join(hunchPaths(root).hunch, "team.json");
104
+ const teamAdvertised = !explicitOverlay && existsSync(teamFile);
105
+ if (teamAdvertised && !readTeamConfig(root)) {
106
+ throw new Error(".hunch/team.json is invalid or unsafe; refusing to fall back to public memory");
107
+ }
108
+ const teamWired = ensureTeamOverlay(root);
94
109
  const store = new HunchStore(hunchPaths(root));
95
110
  openStore = store;
96
- return { store, root };
111
+ if (teamAdvertised && (store.mode !== "shared"
112
+ || !store.privateDir
113
+ || !existsSync(store.privateDir)
114
+ || !overlayMatchesTeamRemote(root, dirname(store.privateDir)))) {
115
+ store.close();
116
+ openStore = null;
117
+ throw new Error("the advertised team memory store is unavailable or tracks a different remote; refusing to read or write another graph");
118
+ }
119
+ // Short-lived CLI processes need the same live edge as the long-lived MCP
120
+ // server. Refresh once at command start. Ordinary reads/writes can continue
121
+ // from their durable local overlay when the network is temporarily down, but a
122
+ // strict guard must prove it saw the advertised remote before it may pass.
123
+ let pullStatus = null;
124
+ if (teamAdvertised && store.mode === "shared" && store.privateDir) {
125
+ const remote = advertisedTeamRemoteContract(root, dirname(store.privateDir));
126
+ if (!remote || !remote.verify()) {
127
+ store.close();
128
+ openStore = null;
129
+ throw new Error("the advertised team memory route could not be pinned for this command");
130
+ }
131
+ pinSharedRemote(store, remote);
132
+ pullStatus = pullHunchStatus(store.privateDir, {
133
+ timeoutMs: 5_000,
134
+ remote,
135
+ });
136
+ // The route may be coherently rewritten while the bounded pull is blocked.
137
+ // No handler — including an ordinary non-strict read — may run after the
138
+ // graph epoch that admitted this command stops verifying.
139
+ if (!remote.verify()) {
140
+ store.close();
141
+ openStore = null;
142
+ throw new Error("the advertised team memory route changed while this command was refreshing; refusing to serve or mutate stale memory");
143
+ }
144
+ }
145
+ return { store, teamWired, teamPullStatus: opts.requireFreshTeamMemory ? pullStatus : null };
146
+ }
147
+ function storeFor(opts = {}) {
148
+ const root = findRoot();
149
+ const { store, teamPullStatus } = openTeamStore(root, opts);
150
+ return { store, root, teamPullStatus };
151
+ }
152
+ function pumpMemoryHome(store, root, home, message) {
153
+ flushMemoryHome(store, hunchPaths(root).hunch, home, message);
154
+ }
155
+ function pumpPolicyHome(store, root, service, policyId, message) {
156
+ const home = service.repository.homeOfPolicy(policyId);
157
+ if (!home)
158
+ throw new Error(`policy ${policyId} has no exact storage home`);
159
+ pumpMemoryHome(store, root, home, message);
160
+ }
161
+ function pumpMemoryHomes(store, root, homes, message) {
162
+ flushMemoryHomes(store, hunchPaths(root).hunch, homes, message);
97
163
  }
98
164
  // ---- init -----------------------------------------------------------------
99
165
  program
@@ -119,9 +185,7 @@ program
119
185
  // Team auto-discovery FIRST: a committed .hunch/team.json advertises the shared
120
186
  // store — a fresh clone wires itself to it before anything reads memory, so every
121
187
  // teammate/agent resolves the same single source of truth with zero manual setup.
122
- const teamWired = ensureTeamOverlay(root);
123
- const store = new HunchStore(paths);
124
- openStore = store; // so the top-level error handler closes it on failure
188
+ const { store, teamWired } = openTeamStore(root);
125
189
  const inv = resolveInvocation();
126
190
  console.log(`🧠 Initializing Hunch at ${root}`);
127
191
  store.json.ensureDirs(); // stamps the manifest at the current version when fresh
@@ -135,11 +199,23 @@ program
135
199
  if (gi.action !== "unchanged")
136
200
  console.log(` ✓ .gitignore ${gi.action} (Hunch runtime index excluded)`);
137
201
  if (opts.index !== false) {
138
- const res = indexRepo(store, root);
139
- store.reindex();
140
- console.log(` ✓ indexed ${res.files} files → ${res.symbols} symbols, ${res.edges} edges, ${res.components} components`);
141
- if (res.skipped)
142
- console.log(` ⚠ ${res.skipped} file(s) could not be parsed (skipped)`);
202
+ if (isGitRepo(root) && revExists("HEAD", root)) {
203
+ const res = indexRepo(store, root, { source: { kind: "commit", ref: "HEAD" } });
204
+ store.reindex();
205
+ console.log(` ✓ indexed committed HEAD (${res.files} files) → ${res.symbols} symbols, ${res.edges} edges, ${res.components} components`);
206
+ if (res.skipped)
207
+ console.log(` ⚠ ${res.skipped} file(s) could not be parsed (skipped)`);
208
+ }
209
+ else {
210
+ // A checkout-derived graph can be swept into a later memory commit by
211
+ // any mutator. With no commit identity there is no safe public source,
212
+ // so leave no source-derived records behind for a future pump.
213
+ store.json.replaceAll("symbols", []);
214
+ store.json.replaceAll("edges", []);
215
+ store.json.replaceAll("components", []);
216
+ store.reindex();
217
+ console.log(" ⚠ skipped code graph: no committed HEAD — commit code, then run `hunch index`");
218
+ }
143
219
  }
144
220
  // Auto-commit is ON by default in every mode; --no-auto-commit persists the opt-out in
145
221
  // the gitignored local.json (merge — never clobber an existing overlay pointer).
@@ -228,21 +304,32 @@ program
228
304
  program
229
305
  .command("index")
230
306
  .description("Parse the repo into a symbol/dependency graph + components (deterministic, no LLM).")
231
- .action(() => {
307
+ .option("--no-auto-commit", "refresh the graph without committing it (for validation and release gates)")
308
+ .action((opts) => {
232
309
  const { store, root } = storeFor();
233
310
  store.json.ensureDirs();
234
311
  ensureGitignore(root); // keep the derived SQLite index out of git (idempotent)
235
- const res = indexRepo(store, root);
312
+ const res = indexRepo(store, root, { requireClean: true });
236
313
  const { counts } = store.reindex();
237
- // Self-heal grounding: pick up generator fixes (param names) + fresh counts in every
238
- // assistant doc the project already has — no manual re-init. Refresh-only (no scaffold).
239
- const healed = refreshExistingGrounding(root, store);
314
+ const correctionSweep = new ConstitutionService(store, root).upgradeCorrections();
315
+ const shouldAutoCommit = opts.autoCommit !== false && store.autoCommit;
316
+ // With auto-commit on, the central public flush must perform the refresh
317
+ // while the docs are still Git-clean so it can stage them atomically with
318
+ // the graph. Pre-refreshing would make the safety filter treat generated
319
+ // docs as user-dirty and leave them stranded outside the memory commit.
320
+ const healed = shouldAutoCommit ? [] : refreshExistingGrounding(root, store);
240
321
  console.log(`Indexed ${res.files} files:`);
241
322
  console.log(` ${counts.symbols} symbols, ${counts.edges} edges, ${counts.components} components`);
323
+ if (correctionSweep.scanned) {
324
+ console.log(` correction reviews: ${correctionSweep.proved} proved · ${correctionSweep.already_proved} current · ${correctionSweep.pending} pending · ${correctionSweep.legacy_only} legacy-only · ${correctionSweep.conflicted} conflicted · ${correctionSweep.failed.length} failed; authority none`);
325
+ }
242
326
  if (healed.length)
243
327
  console.log(` grounding refreshed: ${healed.join(", ")}`);
244
328
  if (res.skipped)
245
329
  console.log(` ⚠ ${res.skipped} file(s) could not be parsed (skipped)`);
330
+ if (shouldAutoCommit) {
331
+ pumpMemoryHomes(store, root, store.privateDir ? ["public", "private"] : ["public"], "hunch: refresh index and correction reviews");
332
+ }
246
333
  store.close();
247
334
  });
248
335
  // ---- backfill -------------------------------------------------------------
@@ -293,6 +380,7 @@ program
293
380
  console.log(ctxWarning);
294
381
  }
295
382
  let written = 0, skipped = 0, llm = 0, heuristic = 0;
383
+ const home = store.captureHome(false);
296
384
  // The per-commit cost is the Claude synthesis spawn; run several at once. Safe:
297
385
  // each commit drafts independently and writes its OWN decision file atomically,
298
386
  // and the store's JS-side reads/writes run synchronously between awaits (single
@@ -313,7 +401,10 @@ program
313
401
  skipped++;
314
402
  });
315
403
  store.reindex();
316
- updateClaudeMd(root, store);
404
+ if (written && home === "public" && !store.autoCommit)
405
+ updateClaudeMd(root, store);
406
+ if (written)
407
+ pumpMemoryHome(store, root, home, `hunch: backfill ${written} decision(s)`);
317
408
  // Honest tally of where the tokens went: trivial commits are seeded by the
318
409
  // free deterministic heuristic, only substantive ones spend the LLM.
319
410
  console.log(`Done: ${written} decision(s) seeded (${llm} via LLM, ${heuristic} heuristic), ${skipped} skipped (trivial/non-code/already-captured).`);
@@ -347,12 +438,21 @@ program
347
438
  }
348
439
  store.json.ensureDirs();
349
440
  // Self-repair rides every sync (Phase 5, §59.5): a commit's renames heal the
350
- // exact-path bindings they break, silently, as a revertable `repair` move.
441
+ // exact-path bindings they break. Sync defers the repair commit so every
442
+ // touched home is flushed at most once after all capture/review writes.
351
443
  // Fail open — a repair error must never take the capture path down.
444
+ let publicRepairTouched = false;
445
+ let privateRepairTouched = false;
446
+ let repairedBindings = 0;
352
447
  try {
353
- const repaired = runRepair(store, root, sha ?? headSha(root), true);
354
- if (repaired?.applied && !opts.quiet)
355
- console.log(` ↳ repaired ${repaired.plan.rewrites.length + repaired.policyRewrites.length} memory binding(s) after rename`);
448
+ const repaired = runRepair(store, root, sha ?? headSha(root), true, { commitMode: "deferred" });
449
+ if (repaired?.applied) {
450
+ publicRepairTouched = repaired.publicTouched;
451
+ privateRepairTouched = repaired.privateTouched;
452
+ repairedBindings = repaired.plan.rewrites.length + repaired.policyRewrites.length;
453
+ if (!opts.quiet)
454
+ console.log(` ↳ repaired ${repairedBindings} memory binding(s) after rename`);
455
+ }
356
456
  }
357
457
  catch { /* repair is best-effort; drift still surfaces anything left behind */ }
358
458
  const r = await syncCommit(store, root, sha ?? headSha(root), {
@@ -368,232 +468,659 @@ program
368
468
  const doCommit = opts.commit ?? store.autoCommit;
369
469
  if (r.status === "written") {
370
470
  store.reindex();
371
- // Refresh grounding so committed counts track the store. Git-CLEAN docs are
372
- // refreshed and folded into the capture commit below (kills the refresh-counts
373
- // treadmill: every capture bumped the count and re-staled the docs for the
374
- // release gate's clean-tree check). A user-dirty doc is never touched from the
375
- // hook; manual `hunch sync` still self-heals ALL existing grounding docs.
376
- const groundingToStage = toOverlay ? [] : refreshCommittableGrounding(root, store);
377
- if (!opts.fromHook) {
378
- const healed = refreshExistingGrounding(root, store);
379
- const refreshed = [...new Set([...groundingToStage.map((file) => relative(root, file)), ...healed])];
380
- if (refreshed.length && !opts.quiet)
381
- console.log(` ↳ grounding refreshed: ${refreshed.join(", ")}`);
382
- }
383
- // Persist the captured decision in the repo it landed in (private store under
384
- // --private, else this repo). ON by default (follows auto-commit; --no-commit or
385
- // `--no-auto-commit` at setup opts out). Best-effort — a non-repo dir / offline push
386
- // just no-ops. Stage ONLY the hunch dir (never sweep unrelated working-tree
387
- // changes), and set HUNCH_SYNC=1 so the commit we create can't re-trigger this
388
- // hook (no recursion). The overlay is pushed; the public .hunch/ is committed
389
- // WITHOUT pushing — auto-pushing the user's code branch would publish their
390
- // unpushed commits (bug_overlay_clobber lineage).
391
- const commitTarget = doCommit ? (toOverlay ? store.privateDir : hunchPaths(root).hunch) : undefined;
392
- if (commitTarget) {
393
- commitAndPushHunch(commitTarget, `hunch: capture ${r.decision?.id ?? "decision"}`, { push: toOverlay, alsoStage: groundingToStage });
394
- if (!opts.quiet)
395
- console.log(` ↳ committed ${toOverlay ? "+ pushed " : ""}${r.decision?.id} (${commitTarget}${toOverlay ? "" : " — rides your next push"})`);
396
- }
397
471
  if (!opts.quiet)
398
472
  console.log(`✓ captured decision ${r.decision?.id} via ${r.provider}: "${r.decision?.title}"`);
399
473
  }
400
474
  else if (!opts.quiet) {
401
475
  console.log(`· skipped: ${r.reason}`);
402
476
  }
477
+ let graphRefreshed = false;
478
+ let publicCorrectionQueued = false;
479
+ let privateCorrectionQueued = false;
480
+ let g2Recorded = 0;
481
+ try {
482
+ publicCorrectionQueued = store.recsInHome("constraints", "public").some((constraint) => constraint.status === "active" && !constraint.valid_to && isHumanConfirmed(constraint.provenance.source));
483
+ privateCorrectionQueued = store.recsInHome("constraints", "private").some((constraint) => constraint.status === "active" && !constraint.valid_to && isHumanConfirmed(constraint.provenance.source));
484
+ if (publicCorrectionQueued || privateCorrectionQueued) {
485
+ // A captured Constraint is the durable correction-review queue. Refresh
486
+ // and retry even when synthesis skipped, so fixing the code is enough.
487
+ indexRepo(store, root, { churn: false, requireClean: true });
488
+ store.reindex();
489
+ graphRefreshed = true;
490
+ const correctionSweep = new ConstitutionService(store, root).upgradeCorrections();
491
+ if (correctionSweep.scanned && !opts.quiet) {
492
+ console.log(` ↳ correction reviews: ${correctionSweep.proved} proved · ${correctionSweep.already_proved} current · ${correctionSweep.pending} pending · ${correctionSweep.legacy_only} legacy-only · ${correctionSweep.conflicted} conflicted · ${correctionSweep.failed.length} failed; authority none`);
493
+ }
494
+ }
495
+ }
496
+ catch (e) {
497
+ // Post-commit learning is background/best-effort and never changes source
498
+ // commit success, constraint enforcement, or policy authority.
499
+ if (!opts.quiet)
500
+ console.log(` ↳ correction reviews skipped safely: ${e.message}`);
501
+ }
403
502
  try {
404
503
  const constitution = new ConstitutionService(store, root);
405
504
  if (constitution.g2Repository.currentPlan()) {
406
- indexRepo(store, root, { churn: false });
407
- store.reindex();
408
- const sweep = constitution.g2ShadowSweep();
409
- if (sweep.recorded.length && doCommit && store.privateDir) {
410
- commitAndPushHunch(store.privateDir, `hunch: record ${sweep.recorded.length} G2 shadow observation(s)`);
505
+ if (!graphRefreshed) {
506
+ indexRepo(store, root, { churn: false, requireClean: true });
507
+ store.reindex();
508
+ graphRefreshed = true;
411
509
  }
510
+ const sweep = constitution.g2ShadowSweep();
511
+ g2Recorded = sweep.recorded.length;
412
512
  if (!opts.quiet) {
413
513
  console.log(` ↳ G2 shadow: ${sweep.recorded.length} recorded · ${sweep.existing.length} existing · ${sweep.failures.length} failed; authority none`);
414
514
  }
415
515
  }
416
516
  }
417
517
  catch (e) {
418
- // Post-commit learning is background/best-effort. Shadow operation must
419
- // never make a source commit, decision capture, warning, or block fail.
420
518
  if (!opts.quiet)
421
519
  console.log(` ↳ G2 shadow skipped safely: ${e.message}`);
422
520
  }
521
+ // Refresh grounding once, after every capture/review/shadow write, then fold
522
+ // each home's artifacts into at most one memory commit.
523
+ const publicCapture = r.status === "written" && !toOverlay;
524
+ const privateCapture = r.status === "written" && toOverlay;
525
+ const groundingToStage = publicCapture || graphRefreshed || publicRepairTouched ? refreshCommittableGrounding(root, store) : [];
526
+ if (!opts.fromHook && (r.status === "written" || graphRefreshed || publicRepairTouched)) {
527
+ const healed = refreshExistingGrounding(root, store);
528
+ const refreshed = [...new Set([...groundingToStage.map((file) => relative(root, file)), ...healed])];
529
+ if (refreshed.length && !opts.quiet)
530
+ console.log(` ↳ grounding refreshed: ${refreshed.join(", ")}`);
531
+ }
532
+ if (doCommit) {
533
+ const publicNeedsFlush = publicCapture || graphRefreshed || publicRepairTouched;
534
+ const privateNeedsFlush = privateCapture || (privateCorrectionQueued && graphRefreshed) || g2Recorded > 0 || privateRepairTouched;
535
+ const publicResult = publicNeedsFlush
536
+ ? commitAndPushHunch(hunchPaths(root).hunch, publicCapture
537
+ ? `hunch: capture ${r.decision?.id ?? "decision"}`
538
+ : graphRefreshed
539
+ ? "hunch: refresh derived graph and correction reviews"
540
+ : `hunch: repair ${repairedBindings} binding(s) after rename`, { push: false, alsoStage: groundingToStage })
541
+ : null;
542
+ const privateResult = privateNeedsFlush && store.privateDir
543
+ ? commitAndPushHunch(store.privateDir, privateCapture
544
+ ? `hunch: capture ${r.decision?.id ?? "decision"}`
545
+ : g2Recorded
546
+ ? `hunch: record ${g2Recorded} G2 shadow observation(s) and correction reviews`
547
+ : privateCorrectionQueued && graphRefreshed
548
+ ? "hunch: refresh correction review proposals"
549
+ : `hunch: repair ${repairedBindings} binding(s) after rename`, {
550
+ push: true,
551
+ protectedRepoRoot: root,
552
+ remote: sharedRemoteFor(store),
553
+ })
554
+ : null;
555
+ if (r.status === "written" && !opts.quiet) {
556
+ const captureResult = toOverlay ? privateResult : publicResult;
557
+ const commitTarget = toOverlay ? store.privateDir : hunchPaths(root).hunch;
558
+ if (captureResult && commitTarget) {
559
+ console.log(` ↳ ${captureResult === "pushed" ? "committed + pushed" : "committed"} ${r.decision?.id} (${commitTarget}${toOverlay ? "" : " — rides your next push"})`);
560
+ }
561
+ }
562
+ }
423
563
  store.close();
424
564
  });
425
- function configureOverlay(dir, opts, mode) {
426
- const root = findRoot();
427
- const paths = hunchPaths(root);
428
- const commandName = mode === "private" ? "private" : "shared";
429
- if (opts.sync) {
430
- const s = new HunchStore(hunchPaths(root));
431
- const target = s.privateDir;
432
- s.close();
433
- if (!target)
434
- return fail(`no overlay configured — run \`hunch ${commandName}\` first`);
435
- commitAndPushHunch(target, "hunch: sync overlay memory");
436
- console.log(`✓ flushed overlay store → ${target}`);
565
+ function canonicalSharedRef(repoRoot) {
566
+ const env = boundedTeamGitEnv();
567
+ const refs = spawnSync("git", ["-C", repoRoot, "for-each-ref", "--format=%(refname)", "refs/remotes/origin"], {
568
+ encoding: "utf8",
569
+ stdio: ["ignore", "pipe", "ignore"],
570
+ env,
571
+ });
572
+ const branches = refs.status === 0
573
+ ? refs.stdout.split(/\r?\n/).map((line) => line.trim()).filter((line) => line && line !== "refs/remotes/origin/HEAD")
574
+ : [];
575
+ if (branches.length === 1) {
576
+ return safeTeamRef(branches[0].replace(/^refs\/remotes\/origin\//, "refs/heads/"));
577
+ }
578
+ if (branches.length > 1)
579
+ return null;
580
+ const current = spawnSync("git", ["-C", repoRoot, "symbolic-ref", "--quiet", "--short", "HEAD"], {
581
+ encoding: "utf8",
582
+ stdio: ["ignore", "pipe", "ignore"],
583
+ env,
584
+ });
585
+ return current.status === 0 ? safeTeamRef(`refs/heads/${current.stdout.trim()}`) : null;
586
+ }
587
+ /** Discover the one graph branch without consulting the overlay repository's
588
+ * ambient remote/refspec/transport settings. An empty remote has no selected
589
+ * ref yet; the initialized overlay's current branch supplies it afterward. */
590
+ function discoverSharedRemote(remoteUrl) {
591
+ if (!safeGitUrl(remoteUrl))
592
+ return null;
593
+ const probe = mkdtempSync(join(tmpdir(), "hunch-shared-probe-"));
594
+ const hooksDir = join(probe, "hooks");
595
+ mkdirSync(hooksDir);
596
+ try {
597
+ const configured = spawnSync("git", ["-C", probe, "config", "--includes", "--show-scope", "--null", "--list"], {
598
+ encoding: "utf8",
599
+ stdio: ["ignore", "pipe", "ignore"],
600
+ env: boundedTeamGitEnv(),
601
+ });
602
+ if (configured.status !== 0)
603
+ return null;
604
+ const fields = configured.stdout.split("\0").filter(Boolean);
605
+ if (fields.length % 2 !== 0)
606
+ return null;
607
+ for (let i = 1; i < fields.length; i += 2) {
608
+ const entry = fields[i];
609
+ const newline = entry.indexOf("\n");
610
+ if (newline < 1)
611
+ return null;
612
+ const key = entry.slice(0, newline).toLowerCase();
613
+ const prefix = entry.slice(newline + 1);
614
+ if (prefix && (/^url\..*\.insteadof$/.test(key) || /^url\..*\.pushinsteadof$/.test(key))
615
+ && remoteUrl.startsWith(prefix))
616
+ return null;
617
+ }
618
+ const listed = spawnSync("git", [
619
+ "-C", probe,
620
+ "-c", `core.hooksPath=${hooksDir}`,
621
+ "ls-remote", "--refs", "--heads", "--upload-pack=git-upload-pack", remoteUrl,
622
+ ], {
623
+ encoding: "utf8",
624
+ stdio: ["ignore", "pipe", "ignore"],
625
+ env: boundedTeamGitEnv(),
626
+ timeout: 15_000,
627
+ });
628
+ if (listed.status !== 0)
629
+ return null;
630
+ const refs = [];
631
+ for (const line of listed.stdout.split(/\r?\n/).filter(Boolean)) {
632
+ const match = line.match(/^[0-9a-f]{40,64}\t(refs\/heads\/.+)$/i);
633
+ if (!match)
634
+ return null;
635
+ const ref = safeTeamRef(match[1]);
636
+ if (!ref)
637
+ return null;
638
+ refs.push(ref);
639
+ }
640
+ if (refs.length > 1)
641
+ return null;
642
+ return refs.length === 1 ? { ref: refs[0], empty: false } : { ref: null, empty: true };
643
+ }
644
+ finally {
645
+ rmSync(probe, { recursive: true, force: true });
646
+ }
647
+ }
648
+ function configureExistingValidatedOverlay(destRoot, codeRoot, requestedRemote, hasOrigin) {
649
+ const env = boundedTeamGitEnv();
650
+ let addedOrigin = false;
651
+ const rollback = () => {
652
+ if (addedOrigin)
653
+ spawnSync("git", ["-C", destRoot, "remote", "remove", "origin"], { stdio: "ignore", env });
654
+ };
655
+ if (!hasOrigin) {
656
+ const added = spawnSync("git", ["-C", destRoot, "remote", "add", "origin", requestedRemote], { stdio: "ignore", env });
657
+ if (added.status !== 0)
658
+ return null;
659
+ addedOrigin = true;
660
+ }
661
+ if (sameGitPublication(destRoot, codeRoot)) {
662
+ rollback();
663
+ return null;
664
+ }
665
+ // Prove the pre-existing local config before even branch discovery performs
666
+ // network I/O. This catches upload/receive-pack, refspec, pushurl, mirror, URL
667
+ // rewrite, and wrong-upstream traps before any of them can run.
668
+ const provisionalRef = canonicalSharedRef(destRoot);
669
+ if (!provisionalRef
670
+ || !explicitTeamRemoteContract(destRoot, requestedRemote, destRoot, provisionalRef)) {
671
+ rollback();
672
+ return null;
673
+ }
674
+ const discovered = discoverSharedRemote(requestedRemote);
675
+ const ref = discovered?.ref ?? (discovered?.empty ? provisionalRef : null);
676
+ if (!discovered || !ref) {
677
+ rollback();
678
+ return null;
679
+ }
680
+ const contract = explicitTeamRemoteContract(destRoot, requestedRemote, destRoot, ref);
681
+ if (!contract) {
682
+ rollback();
683
+ return null;
684
+ }
685
+ const hunchDir = join(destRoot, ".hunch");
686
+ // Git needs a contained cwd, but writing the default manifest before pull
687
+ // would make an existing committed overlay look dirty and block convergence.
688
+ // The normal setup phase creates the actual Hunch layout after sync.
689
+ mkdirSync(hunchDir, { recursive: true });
690
+ if (discovered.empty) {
691
+ if (isolatedHeadSha(destRoot)) {
692
+ const synced = syncExistingHunch(hunchDir, codeRoot, 15_000, contract);
693
+ if (synced !== "pushed" && synced !== "current") {
694
+ rollback();
695
+ return null;
696
+ }
697
+ }
698
+ }
699
+ else {
700
+ const pulled = pullHunchStatus(hunchDir, {
701
+ timeoutMs: 5_000,
702
+ remote: contract,
703
+ allowUnrelatedHistories: true,
704
+ });
705
+ if (pulled !== "updated" && pulled !== "current") {
706
+ rollback();
707
+ return null;
708
+ }
709
+ const synced = syncExistingHunch(hunchDir, codeRoot, 15_000, contract);
710
+ if (synced !== "pushed" && synced !== "current") {
711
+ rollback();
712
+ return null;
713
+ }
714
+ }
715
+ if (!contract.verify()) {
716
+ rollback();
717
+ return null;
718
+ }
719
+ return { ref, empty: discovered.empty };
720
+ }
721
+ function setupPathSnapshot(path) {
722
+ try {
723
+ const stat = lstatSync(path);
724
+ if (stat.isSymbolicLink())
725
+ return { kind: "symlink", target: readlinkSync(path) };
726
+ if (stat.isFile())
727
+ return { kind: "file", contents: readFileSync(path, "utf8"), mode: stat.mode & 0o777 };
728
+ return { kind: "other" };
729
+ }
730
+ catch (error) {
731
+ if (error.code === "ENOENT")
732
+ return { kind: "missing" };
733
+ throw error;
734
+ }
735
+ }
736
+ function sameSetupPathSnapshot(a, b) {
737
+ if (a.kind !== b.kind)
738
+ return false;
739
+ if (a.kind === "file" && b.kind === "file")
740
+ return a.contents === b.contents && a.mode === b.mode;
741
+ if (a.kind === "symlink" && b.kind === "symlink")
742
+ return a.target === b.target;
743
+ return true;
744
+ }
745
+ function restoreSetupPath(path, before) {
746
+ const current = setupPathSnapshot(path);
747
+ if (sameSetupPathSnapshot(current, before))
748
+ return;
749
+ // Setup only creates/replaces ordinary files. Never recursively remove a
750
+ // directory/device that appeared at a route path outside this invocation.
751
+ if (current.kind === "other")
437
752
  return;
753
+ if (current.kind !== "missing")
754
+ rmSync(path, { force: true });
755
+ if (before.kind === "file") {
756
+ writeFileAtomic(path, before.contents);
757
+ chmodSync(path, before.mode);
438
758
  }
439
- // 1) resolve the overlay store's hunch dir (holds decisions/, bugs/, …)
440
- let hunchDir;
441
- // Anchor the default store at the MAIN worktree root: a linked worktree can be
442
- // `git worktree remove`d, which would take the store (and every other worktree's
443
- // absolute pointer to it) down with it. An explicit [dir] still resolves from here.
444
- const anchor = mainWorktreeRoot(root);
445
- if (opts.repo) {
446
- const dest = join(anchor, ".hunch-private");
447
- if (!existsSync(dest)) {
448
- const r = spawnSync("git", ["clone", opts.repo, dest], { stdio: "inherit" });
449
- if (r.status !== 0)
450
- return fail(`git clone failed for ${opts.repo}`);
759
+ else if (before.kind === "symlink") {
760
+ symlinkSync(before.target, path);
761
+ }
762
+ }
763
+ function readOverlaySetupLocal(file) {
764
+ if (!existsSync(file))
765
+ return {};
766
+ const parsed = JSON.parse(readFileSync(file, "utf8"));
767
+ if (!parsed || Array.isArray(parsed) || typeof parsed !== "object")
768
+ throw new Error("not an object");
769
+ return parsed;
770
+ }
771
+ /** Snapshot only state a fresh overlay setup can mutate outside its new clone.
772
+ * Rollback is ownership-scoped: an overlay is removed only after this invocation
773
+ * successfully installed it, and pre-existing route/config bytes are restored. */
774
+ function beginFreshOverlaySetup(root, dest, existingLocal, includeHook) {
775
+ const localFile = join(hunchPaths(root).hunch, "local.json");
776
+ const teamFile = join(hunchPaths(root).hunch, "team.json");
777
+ const codeGitignore = join(root, ".gitignore");
778
+ const commonDir = gitCommonDir(root);
779
+ const sharedPointer = commonDir ? join(commonDir, "hunch", "local.json") : "";
780
+ const configuredHooks = includeHook ? hooksDir(root) : "";
781
+ const hookDir = configuredHooks ? (isAbsolute(configuredHooks) ? configuredHooks : join(root, configuredHooks)) : "";
782
+ const hookFile = hookDir ? join(hookDir, "post-commit") : "";
783
+ const paths = [localFile, codeGitignore, teamFile, ...(sharedPointer ? [sharedPointer] : []), ...(hookFile ? [hookFile] : [])];
784
+ const snapshots = new Map(paths.map((path) => [path, setupPathSnapshot(path)]));
785
+ const parentExisted = new Map([
786
+ [dirname(localFile), existsSync(dirname(localFile))],
787
+ ...(sharedPointer ? [[dirname(sharedPointer), existsSync(dirname(sharedPointer))]] : []),
788
+ ...(hookDir ? [[hookDir, existsSync(hookDir)]] : []),
789
+ ]);
790
+ const touched = [];
791
+ let ownsOverlay = false;
792
+ const mark = (path) => {
793
+ if (path && !touched.includes(path))
794
+ touched.push(path);
795
+ };
796
+ return {
797
+ existingLocal,
798
+ markOverlayCreated: () => { ownsOverlay = true; },
799
+ markLocalWrite: () => mark(localFile),
800
+ markGitignoreWrite: () => mark(codeGitignore),
801
+ markTeamWrite: () => mark(teamFile),
802
+ markSharedPointerWrite: () => mark(sharedPointer),
803
+ markHookWrite: () => mark(hookFile),
804
+ // Migration is a one-way ownership handoff. Once public records have been
805
+ // durably copied into this clone, a later setup failure may restore routing
806
+ // files but must not delete the clone that now holds their surviving copy.
807
+ preserveOverlayOnRollback: () => { ownsOverlay = false; },
808
+ rollback: () => {
809
+ for (const path of [...touched].reverse()) {
810
+ const before = snapshots.get(path);
811
+ if (!before)
812
+ continue;
813
+ try {
814
+ restoreSetupPath(path, before);
815
+ }
816
+ catch { /* preserve the original setup failure */ }
817
+ }
818
+ if (ownsOverlay)
819
+ rmSync(dest, { recursive: true, force: true });
820
+ for (const [dir, existed] of [...parentExisted].reverse()) {
821
+ if (!existed) {
822
+ try {
823
+ rmdirSync(dir);
824
+ }
825
+ catch { /* keep non-empty or concurrently-created directories */ }
826
+ }
827
+ }
828
+ },
829
+ };
830
+ }
831
+ function configureOverlay(dir, opts, mode) {
832
+ let freshSetup = null;
833
+ let setupComplete = false;
834
+ try {
835
+ const root = findRoot();
836
+ const paths = hunchPaths(root);
837
+ const commandName = mode === "private" ? "private" : "shared";
838
+ // A repository URL reaches Git before the overlay is trusted in BOTH modes.
839
+ // Keep private split stores private by omitting team.json, not by weakening the
840
+ // clone transport gate: credentials stay in normal Git helpers and every setup
841
+ // path receives the same no-checkout validation boundary.
842
+ if (opts.repo && !safeGitUrl(opts.repo)) {
843
+ return fail(`refusing to attach the overlay: the ${mode} remote must be a safe Git URL or absolute local path without embedded credentials`);
844
+ }
845
+ if (opts.sync) {
846
+ const s = openTeamStore(root).store;
847
+ const target = s.privateDir;
848
+ const remote = target ? sharedRemoteFor(s) : undefined;
849
+ s.close();
850
+ openStore = null;
851
+ if (!target)
852
+ return fail(`no overlay configured — run \`hunch ${commandName}\` first`);
853
+ const commitResult = commitAndPushHunch(target, "hunch: sync overlay memory", {
854
+ push: true,
855
+ protectedRepoRoot: root,
856
+ remote,
857
+ });
858
+ if (commitResult === "pushed")
859
+ console.log(`✓ committed + pushed overlay store → ${target}`);
860
+ else {
861
+ // A prior capture may already be committed but stranded locally after an
862
+ // offline or raced push. Explicit --sync must retry that history even when
863
+ // there is no fresh JSON to commit in this invocation.
864
+ const syncResult = syncExistingHunch(target, root, undefined, remote);
865
+ if (syncResult === "pushed")
866
+ console.log(`✓ pushed existing overlay memory → ${target}`);
867
+ else if (syncResult === "current")
868
+ console.log(`· overlay memory is already current → ${target}`);
869
+ else if (commitResult === "committed")
870
+ console.log(`✓ committed overlay store locally; push did not complete → ${target}`);
871
+ else
872
+ console.log(`· overlay sync did not complete → ${target}`);
873
+ }
874
+ return;
451
875
  }
452
- else {
453
- // NEVER silently ignore --repo when the store dir already exists: same remote →
454
- // freshen; no remote → attach + converge; different remote → refuse loudly.
455
- const cur = spawnSync("git", ["-C", dest, "remote", "get-url", "origin"], { encoding: "utf8" });
456
- const existingUrl = cur.status === 0 ? cur.stdout.trim() : "";
457
- if (existingUrl === opts.repo) {
458
- pullHunch(join(dest, ".hunch"));
459
- console.log(` · ${dest} already tracks ${opts.repo} — pulled the latest memory`);
876
+ // 1) resolve the overlay store's hunch dir (holds decisions/, bugs/, …)
877
+ let hunchDir;
878
+ let selectedSharedRef = null;
879
+ // Anchor the default store at the MAIN worktree root: a linked worktree can be
880
+ // `git worktree remove`d, which would take the store (and every other worktree's
881
+ // absolute pointer to it) down with it. An explicit [dir] still resolves from here.
882
+ const anchor = mainWorktreeRoot(root);
883
+ if (opts.repo) {
884
+ const dest = join(anchor, ".hunch-private");
885
+ // Canonicalize once in the invocation context so clone, attach, fetch, and
886
+ // push cannot reinterpret a relative local URL. Also preflight the old
887
+ // destination-relative interpretation: an existing overlay may already
888
+ // have been vulnerable to that spelling, and no external operation is safe
889
+ // until BOTH interpretations are known to be outside the code publication.
890
+ const requestedRemote = canonicalRemoteUrl(opts.repo, process.cwd());
891
+ if (!requestedRemote
892
+ || repositoryUsesRemote(root, requestedRemote)
893
+ || repositoryUsesRemote(root, opts.repo, dest)) {
894
+ return fail("the private/shared overlay remote must be different from every remote configured for the code repository and from the code repository itself");
460
895
  }
461
- else if (!existingUrl) {
462
- if (!isGitRepo(dest))
463
- spawnSync("git", ["init", "-q", dest], { stdio: "ignore" });
464
- spawnSync("git", ["-C", dest, "remote", "add", "origin", opts.repo], { stdio: "ignore" });
465
- spawnSync("git", ["-C", dest, "fetch", "-q", "origin"], { stdio: "ignore" });
466
- spawnSync("git", ["-C", dest, "merge", "-q", "--no-edit", "--allow-unrelated-histories", "FETCH_HEAD"], { stdio: "ignore" });
467
- spawnSync("git", ["-C", dest, "push", "-q", "-u", "origin", "HEAD"], { stdio: "ignore" });
468
- console.log(` · attached the existing local store ${dest} to ${opts.repo} (merged + pushed, best-effort)`);
896
+ if (!existsSync(dest)) {
897
+ const localFile = join(paths.hunch, "local.json");
898
+ let existingLocal;
899
+ try {
900
+ existingLocal = readOverlaySetupLocal(localFile);
901
+ }
902
+ catch {
903
+ return fail(`refusing to overwrite malformed local configuration: ${localFile}`);
904
+ }
905
+ freshSetup = beginFreshOverlaySetup(root, dest, existingLocal, opts.hook);
906
+ const cloned = cloneValidatedTeamOverlay(requestedRemote, process.cwd(), dest, { timeoutMs: 15_000 });
907
+ if (!cloned) {
908
+ return fail(`could not clone and validate exactly one safe ${mode} overlay branch (or an empty repository) at ${opts.repo}`);
909
+ }
910
+ freshSetup.markOverlayCreated();
911
+ selectedSharedRef = cloned.sharedRef;
469
912
  }
470
913
  else {
471
- return fail(`${dest} already tracks a DIFFERENT remote:\n current: ${existingUrl}\n requested: ${opts.repo}\n` +
472
- `Refusing to silently re-point your memory. Move that directory aside, or pass an explicit dir: \`hunch ${commandName} <dir> --repo <url>\`.`);
914
+ // Establish an exact repository boundary BEFORE any remote inspection or
915
+ // mutation. `git -C dest` otherwise walks up to the code repo; an empty
916
+ // code-repo origin once caused this branch to attach the memory remote and
917
+ // push source code into it.
918
+ const destRoot = realpathNorm(dest);
919
+ const overlayEnv = boundedTeamGitEnv();
920
+ if (!isGitRepoRoot(destRoot))
921
+ spawnSync("git", ["init", "-q", destRoot], { stdio: "ignore", env: overlayEnv });
922
+ if (!isGitRepoRoot(destRoot) || sameGitPublication(destRoot, root)) {
923
+ return fail(`refusing to attach the overlay remote: ${dest} is not a standalone Git repository distinct from the code repository`);
924
+ }
925
+ // NEVER silently ignore --repo when the store dir already exists: same remote →
926
+ // freshen; no remote → attach + converge; different remote → refuse loudly.
927
+ const cur = spawnSync("git", ["-C", dest, "remote", "get-url", "origin"], { encoding: "utf8", env: overlayEnv });
928
+ const existingUrl = cur.status === 0 ? cur.stdout.trim() : "";
929
+ if (existingUrl && sameRemoteUrl(existingUrl, destRoot, requestedRemote, destRoot)) {
930
+ const configured = configureExistingValidatedOverlay(destRoot, root, requestedRemote, true);
931
+ if (!configured) {
932
+ return fail(`could not prove and converge ${dest} with the exact ${mode} URL and canonical branch at ${opts.repo}`);
933
+ }
934
+ selectedSharedRef = configured.ref;
935
+ console.log(` · ${dest} already tracks ${opts.repo} — safely refreshed the latest memory`);
936
+ }
937
+ else if (!existingUrl) {
938
+ const configured = configureExistingValidatedOverlay(destRoot, root, requestedRemote, false);
939
+ if (!configured) {
940
+ return fail(`could not attach ${dest}: the exact ${mode} URL, transport, and canonical branch could not be proved`);
941
+ }
942
+ selectedSharedRef = configured.ref;
943
+ console.log(configured.empty
944
+ ? ` · attached the existing local store ${dest} to the empty remote ${opts.repo}`
945
+ : ` · attached the existing local store ${dest} to ${opts.repo} (merged + pushed)`);
946
+ }
947
+ else {
948
+ return fail(`${dest} already tracks a DIFFERENT remote:\n current: ${existingUrl}\n requested: ${opts.repo}\n` +
949
+ `Refusing to silently re-point your memory. Move that directory aside, or pass an explicit dir: \`hunch ${commandName} <dir> --repo <url>\`.`);
950
+ }
951
+ }
952
+ const destRoot = realpathNorm(dest);
953
+ if (!isGitRepoRoot(destRoot) || sameGitPublication(destRoot, root)) {
954
+ return fail(`refusing to use the overlay clone: ${dest} is not a standalone Git repository distinct from the code repository`);
473
955
  }
956
+ hunchDir = join(dest, ".hunch");
957
+ }
958
+ else {
959
+ hunchDir = dir ? resolve(root, dir) : join(anchor, ".hunch-private", ".hunch");
960
+ }
961
+ // 2) create the layout (decisions/, manifest, …) so it's queryable immediately.
962
+ // Resolve final-component symlinks before choosing the overlay repository root:
963
+ // setup and later commits must operate on the same physical repository.
964
+ if (realpathNorm(hunchDir) === realpathNorm(paths.hunch)) {
965
+ return fail("the private/shared overlay must not resolve to the public .hunch directory");
966
+ }
967
+ new JsonStore(hunchPathsForDir(hunchDir)).ensureDirs();
968
+ const inv = resolveInvocation();
969
+ // Install the structured merge driver IN the overlay repo so concurrent captures from
970
+ // multiple machines/worktrees merge by RECORD ID (no manual conflict resolution) when the
971
+ // two-way auto-sync pulls before pushing. The overlay repo root is the parent of its .hunch.
972
+ const overlayRoot = dirname(realpathNorm(hunchDir));
973
+ // CRITICAL (bug_overlay_clobber): the overlay is always its own repository,
974
+ // even with auto-commit disabled. This makes the privacy boundary structural
975
+ // rather than dependent on a later staged-file heuristic.
976
+ if (!isGitRepoRoot(overlayRoot))
977
+ spawnSync("git", ["init", "-q", overlayRoot], {
978
+ stdio: "ignore",
979
+ env: boundedTeamGitEnv(),
980
+ });
981
+ if (!isGitRepoRoot(overlayRoot) || sameGitPublication(overlayRoot, root)) {
982
+ return fail("the private/shared overlay must be a standalone Git repository distinct from the code repository");
474
983
  }
475
- hunchDir = join(dest, ".hunch");
476
- }
477
- else {
478
- hunchDir = dir ? resolve(root, dir) : join(anchor, ".hunch-private", ".hunch");
479
- }
480
- // 2) create the layout (decisions/, manifest, …) so it's queryable immediately
481
- new JsonStore(hunchPathsForDir(hunchDir)).ensureDirs();
482
- const inv = resolveInvocation();
483
- // Install the structured merge driver IN the overlay repo so concurrent captures from
484
- // multiple machines/worktrees merge by RECORD ID (no manual conflict resolution) when the
485
- // two-way auto-sync pulls before pushing. The overlay repo root is the parent of its .hunch.
486
- const overlayRoot = hunchPathsForDir(hunchDir).root;
487
- // CRITICAL (bug_overlay_clobber): with --auto-commit, the overlay MUST be its own git repo.
488
- // Otherwise the post-commit auto-commit (commitAndPushHunch) runs `git -C overlayDir …` which
489
- // walks UP to the PROJECT repo and can commit memory over your code. Initialize a standalone
490
- // repo when one isn't there (a local repo with no remote just accumulates commits — safe).
491
- if (opts.autoCommit && !isGitRepo(overlayRoot)) {
492
- spawnSync("git", ["init", "-q", overlayRoot], { stdio: "ignore" });
493
- }
494
- if (isGitRepo(overlayRoot))
495
984
  installMergeDriver(overlayRoot, inv.shell);
496
- // 3) record the path in a GITIGNORED local config auto-detected, no env var, and
497
- // the MCP server picks it up too. Atomic write (con_902759b3dc) since it's under .hunch/.
498
- mkdirSync(paths.hunch, { recursive: true }); // tolerate a repo where `hunch init` hasn't run yet
499
- // Store a repo-relative POSIX path when the store lives INSIDE the repo (portable +
500
- // OS-clean — survives a repo move, resolves the same on any OS); an absolute path for
501
- // a store elsewhere on disk. Resolution (env || local.json) re-resolves against root.
502
- const rel = relative(root, hunchDir);
503
- const stored = rel && !rel.startsWith("..") && !isAbsolute(rel) ? toPosixTarget(rel) : hunchDir;
504
- const localFile = join(paths.hunch, "local.json");
505
- let existingLocal = {};
506
- if (existsSync(localFile)) {
985
+ // The overlay can itself host a Hunch CLI process (most notably Git invoking
986
+ // `hunch merge-driver` with the overlay as cwd). Keep its derived SQLite files
987
+ // out of the memory repo, or the next JSON-only auto-flush correctly refuses
988
+ // the non-memory stage and strands an otherwise valid team capture.
989
+ ensureGitignore(overlayRoot);
990
+ // 3) record the path in a GITIGNORED local config — auto-detected, no env var, and
991
+ // the MCP server picks it up too. Atomic write (con_902759b3dc) since it's under .hunch/.
992
+ mkdirSync(paths.hunch, { recursive: true }); // tolerate a repo where `hunch init` hasn't run yet
993
+ // Store a repo-relative POSIX path when the store lives INSIDE the repo (portable +
994
+ // OS-clean survives a repo move, resolves the same on any OS); an absolute path for
995
+ // a store elsewhere on disk. Resolution (env || local.json) re-resolves against root.
996
+ const rel = relative(root, hunchDir);
997
+ const stored = rel && !rel.startsWith("..") && !isAbsolute(rel) ? toPosixTarget(rel) : hunchDir;
998
+ const localFile = join(paths.hunch, "local.json");
999
+ let existingLocal;
507
1000
  try {
508
- const parsed = JSON.parse(readFileSync(localFile, "utf8"));
509
- if (!parsed || Array.isArray(parsed) || typeof parsed !== "object")
510
- throw new Error("not an object");
511
- existingLocal = parsed;
1001
+ existingLocal = freshSetup?.existingLocal ?? readOverlaySetupLocal(localFile);
512
1002
  }
513
1003
  catch {
514
1004
  return fail(`refusing to overwrite malformed local configuration: ${localFile}`);
515
1005
  }
1006
+ freshSetup?.markLocalWrite();
1007
+ writeFileAtomic(localFile, JSON.stringify({ ...existingLocal, privateDir: stored, autoCommit: !!opts.autoCommit, mode }, null, 2) + "\n");
1008
+ freshSetup?.markGitignoreWrite();
1009
+ ensureGitignore(root); // keeps .hunch/local.json + .hunch-private/ out of git
1010
+ // SHARED mode with a remote: publish the store's URL in a COMMITTED team.json, so a
1011
+ // fresh clone / new teammate / headless agent auto-connects on `hunch init` (or MCP
1012
+ // server start) — everyone resolves the same single source of truth. Private mode
1013
+ // never publishes its URL.
1014
+ let teamNote = "";
1015
+ let setupSharedRemote = null;
1016
+ if (mode === "shared" && opts.repo) {
1017
+ const sharedRef = selectedSharedRef ?? canonicalSharedRef(overlayRoot);
1018
+ if (!sharedRef)
1019
+ return fail("could not select one canonical branch for the shared memory repository");
1020
+ freshSetup?.markTeamWrite();
1021
+ writeTeamConfig(root, { shared_repo: opts.repo, shared_ref: sharedRef });
1022
+ // Bind the graph epoch immediately, in clone-local Git metadata. Waiting
1023
+ // until the next command would let a coherent team.json+origin repoint
1024
+ // relabel this clone after setup but before its first normal open.
1025
+ setupSharedRemote = teamRemoteContract(root, overlayRoot);
1026
+ if (!setupSharedRemote) {
1027
+ return fail("the shared overlay route could not be bound to this clone after setup");
1028
+ }
1029
+ teamNote = " ✓ published .hunch/team.json (commit it) — teammates, worktrees, and agents auto-connect\n";
1030
+ }
1031
+ // Also register the overlay at the SHARED git common dir, so EVERY worktree of this repo
1032
+ // (current + future, any branch) auto-discovers the same memory with zero per-worktree
1033
+ // setup. Stored ABSOLUTE — a linked worktree resolves relative paths from its OWN root, so
1034
+ // only an absolute path survives the move. Lives under .git/ (never tracked; nothing to ignore).
1035
+ let worktreeNote = "";
1036
+ freshSetup?.markSharedPointerWrite();
1037
+ if (ensureSharedOverlayPointer(root, hunchDir, !!opts.autoCommit, mode)) {
1038
+ worktreeNote = " ✓ registered in the git common dir — shared by every worktree of this repo, on any branch\n";
1039
+ }
1040
+ // 4) route post-commit synthesis to the overlay (local hook, never committed)
1041
+ let hookNote = "";
1042
+ if (opts.hook && isGitRepo(root)) {
1043
+ freshSetup?.markHookWrite();
1044
+ const h = installPostCommitHook(root, inv.shell, { private: true, commit: opts.autoCommit, localOnly: mode === "private" });
1045
+ hookNote = ` ✓ post-commit hook ${h.action} — captured decisions route here${opts.autoCommit ? " (auto-commit+push on)" : ""}\n`;
1046
+ }
1047
+ // 5) one-time migration: MOVE existing public memory INTO the overlay, then make
1048
+ // THIS repo code-only. Records are absorbed (union by id) BEFORE the public
1049
+ // store is emptied, so an interrupted run never loses memory.
1050
+ let migrateNote = "";
1051
+ if (opts.migrate) {
1052
+ const pub = new JsonStore(paths);
1053
+ const priv = new JsonStore(hunchPathsForDir(hunchDir));
1054
+ const res = movePublicMemoryToPrivate(pub, priv);
1055
+ // The Constitution migration below deletes each public artifact category
1056
+ // after copying it. Transfer ownership of a fresh clone before that first
1057
+ // destructive seam: failures before here leave public memory intact and may
1058
+ // remove the clone; failures from here onward retain the migrated clone.
1059
+ freshSetup?.preserveOverlayOnRollback();
1060
+ const constitutionMoved = movePolicyArtifactsToPrivate(paths.hunch, hunchDir);
1061
+ for (const kind of ENTITY_KINDS)
1062
+ pub.dropAll(kind); // public store now empty on disk
1063
+ if (process.env.HUNCH_TEST_FAIL_OVERLAY_MIGRATION_AFTER_PUBLIC_DROP === "1") {
1064
+ throw new Error("injected late overlay migration failure after public memory drop");
1065
+ }
1066
+ if (isGitRepo(root))
1067
+ gitUntrackCached(root, HUNCH_MEMORY_DIRS); // stop publishing it
1068
+ ignoreHunchMemory(root);
1069
+ const gstore = new HunchStore(paths); // public store is empty → grounding shows no memory
1070
+ const grounding = regenerateGrounding(root, gstore);
1071
+ gstore.close();
1072
+ const migrationCommit = commitAndPushHunch(hunchDir, "hunch: absorb public memory into private overlay", {
1073
+ push: true,
1074
+ protectedRepoRoot: root,
1075
+ remote: setupSharedRemote ?? undefined,
1076
+ }); // durable
1077
+ const breakdownParts = Object.entries(res.moved).map(([k, n]) => `${n} ${k}`);
1078
+ if (constitutionMoved.policies)
1079
+ breakdownParts.push(`${constitutionMoved.policies} policies`);
1080
+ if (constitutionMoved.proofs)
1081
+ breakdownParts.push(`${constitutionMoved.proofs} proofs`);
1082
+ if (constitutionMoved.plans)
1083
+ breakdownParts.push(`${constitutionMoved.plans} proof plans`);
1084
+ if (constitutionMoved.evidence)
1085
+ breakdownParts.push(`${constitutionMoved.evidence} evidence events`);
1086
+ if (constitutionMoved.corpora)
1087
+ breakdownParts.push(`${constitutionMoved.corpora} proof corpora`);
1088
+ if (constitutionMoved.dispositions)
1089
+ breakdownParts.push(`${constitutionMoved.dispositions} history dispositions`);
1090
+ if (constitutionMoved.shadow)
1091
+ breakdownParts.push(`${constitutionMoved.shadow} shadow records`);
1092
+ const breakdown = breakdownParts.join(", ") || "0 records";
1093
+ migrateNote =
1094
+ ` ✓ migrated public memory → overlay (${breakdown}); public store emptied\n` +
1095
+ ` ✓ untracked + gitignored the .hunch memory tree — this repo is now CODE-ONLY\n` +
1096
+ ` ✓ regenerated ${grounding.length} grounding file(s) (CLAUDE.md, AGENTS.md, …) — no public memory shown\n` +
1097
+ (migrationCommit === "pushed"
1098
+ ? " ✓ committed + pushed the private overlay\n"
1099
+ : migrationCommit === "committed"
1100
+ ? " ✓ committed the private overlay locally; push did not complete\n"
1101
+ : " ⚠ private overlay files remain local; no memory commit was created\n") +
1102
+ ` next: review, then commit the PUBLIC repo:\n` +
1103
+ ` git add -A && git commit -m "chore: move engineering memory to a private overlay" && git push\n`;
1104
+ }
1105
+ const lead = mode === "private"
1106
+ ? `✓ private overlay enabled → ${hunchDir}\n`
1107
+ : `✓ shared overlay enabled → ${hunchDir}\n`;
1108
+ const tail = mode === "private"
1109
+ ? " record sensitive items with private:true (hunch_record_decision / hunch_record_correction)\n override per-shell with HUNCH_PRIVATE_DIR; CI / public PR comments stay public-only."
1110
+ : " UNIFIED: every capture (decisions, bugs, constraints, runbooks) routes HERE — one source of truth\n across branches, worktrees, teammates, and agents. Override per-shell with HUNCH_PRIVATE_DIR if needed.";
1111
+ console.log(lead +
1112
+ " ✓ recorded in .hunch/local.json (gitignored) — auto-detected, no env var or shell-profile edit\n" +
1113
+ worktreeNote +
1114
+ teamNote +
1115
+ hookNote +
1116
+ migrateNote +
1117
+ tail);
1118
+ setupComplete = true;
1119
+ }
1120
+ finally {
1121
+ if (freshSetup && !setupComplete)
1122
+ freshSetup.rollback();
516
1123
  }
517
- writeFileAtomic(localFile, JSON.stringify({ ...existingLocal, privateDir: stored, autoCommit: !!opts.autoCommit, mode }, null, 2) + "\n");
518
- ensureGitignore(root); // keeps .hunch/local.json + .hunch-private/ out of git
519
- // SHARED mode with a remote: publish the store's URL in a COMMITTED team.json, so a
520
- // fresh clone / new teammate / headless agent auto-connects on `hunch init` (or MCP
521
- // server start) — everyone resolves the same single source of truth. Private mode
522
- // never publishes its URL.
523
- let teamNote = "";
524
- if (mode === "shared" && opts.repo) {
525
- writeTeamConfig(root, { shared_repo: opts.repo });
526
- teamNote = " ✓ published .hunch/team.json (commit it) — teammates, worktrees, and agents auto-connect\n";
527
- }
528
- // Also register the overlay at the SHARED git common dir, so EVERY worktree of this repo
529
- // (current + future, any branch) auto-discovers the same memory with zero per-worktree
530
- // setup. Stored ABSOLUTE — a linked worktree resolves relative paths from its OWN root, so
531
- // only an absolute path survives the move. Lives under .git/ (never tracked; nothing to ignore).
532
- let worktreeNote = "";
533
- if (ensureSharedOverlayPointer(root, hunchDir, !!opts.autoCommit, mode)) {
534
- worktreeNote = " ✓ registered in the git common dir — shared by every worktree of this repo, on any branch\n";
535
- }
536
- // 4) route post-commit synthesis to the overlay (local hook, never committed)
537
- let hookNote = "";
538
- if (opts.hook && isGitRepo(root)) {
539
- const h = installPostCommitHook(root, inv.shell, { private: true, commit: opts.autoCommit, localOnly: mode === "private" });
540
- hookNote = ` ✓ post-commit hook ${h.action} — captured decisions route here${opts.autoCommit ? " (auto-commit+push on)" : ""}\n`;
541
- }
542
- // 5) one-time migration: MOVE existing public memory INTO the overlay, then make
543
- // THIS repo code-only. Records are absorbed (union by id) BEFORE the public
544
- // store is emptied, so an interrupted run never loses memory.
545
- let migrateNote = "";
546
- if (opts.migrate) {
547
- const pub = new JsonStore(paths);
548
- const priv = new JsonStore(hunchPathsForDir(hunchDir));
549
- const res = movePublicMemoryToPrivate(pub, priv);
550
- const constitutionMoved = movePolicyArtifactsToPrivate(paths.hunch, hunchDir);
551
- for (const kind of ENTITY_KINDS)
552
- pub.dropAll(kind); // public store now empty on disk
553
- if (isGitRepo(root))
554
- gitUntrackCached(root, HUNCH_MEMORY_DIRS); // stop publishing it
555
- ignoreHunchMemory(root);
556
- const gstore = new HunchStore(paths); // public store is empty → grounding shows no memory
557
- const grounding = regenerateGrounding(root, gstore);
558
- gstore.close();
559
- commitAndPushHunch(hunchDir, "hunch: absorb public memory into private overlay"); // durable
560
- const breakdownParts = Object.entries(res.moved).map(([k, n]) => `${n} ${k}`);
561
- if (constitutionMoved.policies)
562
- breakdownParts.push(`${constitutionMoved.policies} policies`);
563
- if (constitutionMoved.proofs)
564
- breakdownParts.push(`${constitutionMoved.proofs} proofs`);
565
- if (constitutionMoved.plans)
566
- breakdownParts.push(`${constitutionMoved.plans} proof plans`);
567
- if (constitutionMoved.evidence)
568
- breakdownParts.push(`${constitutionMoved.evidence} evidence events`);
569
- if (constitutionMoved.corpora)
570
- breakdownParts.push(`${constitutionMoved.corpora} proof corpora`);
571
- if (constitutionMoved.dispositions)
572
- breakdownParts.push(`${constitutionMoved.dispositions} history dispositions`);
573
- if (constitutionMoved.shadow)
574
- breakdownParts.push(`${constitutionMoved.shadow} shadow records`);
575
- const breakdown = breakdownParts.join(", ") || "0 records";
576
- migrateNote =
577
- ` ✓ migrated public memory → overlay (${breakdown}); public store emptied\n` +
578
- ` ✓ untracked + gitignored the .hunch memory tree — this repo is now CODE-ONLY\n` +
579
- ` ✓ regenerated ${grounding.length} grounding file(s) (CLAUDE.md, AGENTS.md, …) — no public memory shown\n` +
580
- ` ✓ committed + pushed the private overlay (best-effort)\n` +
581
- ` next: review, then commit the PUBLIC repo:\n` +
582
- ` git add -A && git commit -m "chore: move engineering memory to a private overlay" && git push\n`;
583
- }
584
- const lead = mode === "private"
585
- ? `✓ private overlay enabled → ${hunchDir}\n`
586
- : `✓ shared overlay enabled → ${hunchDir}\n`;
587
- const tail = mode === "private"
588
- ? " record sensitive items with private:true (hunch_record_decision / hunch_record_correction)\n override per-shell with HUNCH_PRIVATE_DIR; CI / public PR comments stay public-only."
589
- : " UNIFIED: every capture (decisions, bugs, constraints, runbooks) routes HERE — one source of truth\n across branches, worktrees, teammates, and agents. Override per-shell with HUNCH_PRIVATE_DIR if needed.";
590
- console.log(lead +
591
- " ✓ recorded in .hunch/local.json (gitignored) — auto-detected, no env var or shell-profile edit\n" +
592
- worktreeNote +
593
- teamNote +
594
- hookNote +
595
- migrateNote +
596
- tail);
597
1124
  }
598
1125
  program
599
1126
  .command("private [dir]")
@@ -611,6 +1138,7 @@ program
611
1138
  .option("--no-hook", "don't switch the post-commit hook to overlay sync")
612
1139
  .option("--no-auto-commit", "DON'T auto commit+push the overlay after each capture (default: ON — fully automated two-way sync)")
613
1140
  .option("--sync", "flush the configured overlay store now (git add+commit+push)")
1141
+ .option("--migrate", "ONE-TIME: move this repo's EXISTING public .hunch memory into the shared overlay, then make the public repo code-only")
614
1142
  .action((dir, opts) => configureOverlay(dir, opts, "shared"));
615
1143
  // ---- worktree (one-command worktree wired into Hunch) ----------------------
616
1144
  program
@@ -626,21 +1154,26 @@ program
626
1154
  const dest = resolve(root, path);
627
1155
  if (existsSync(dest))
628
1156
  return fail(`path already exists: ${dest}`);
1157
+ // Validate the advertised team route before `git worktree add` creates any
1158
+ // external state. A dead/mismatched overlay must not turn this command into
1159
+ // an implicit public-memory initializer in the new checkout.
1160
+ const rootStore = openTeamStore(root).store;
1161
+ const overlay = rootStore.privateDir;
1162
+ const autoCommit = rootStore.privateAutoCommit;
1163
+ const overlayMode = rootStore.mode === "shared" ? "shared" : "private";
1164
+ rootStore.close();
1165
+ openStore = null;
629
1166
  // 1) create the worktree (on a new branch if asked, else a checkout of HEAD)
630
1167
  const r = spawnSync("git", ["-C", root, "worktree", "add", ...(opts.branch ? ["-b", opts.branch] : []), dest], { stdio: "inherit" });
631
1168
  if (r.status !== 0)
632
1169
  return fail("git worktree add failed");
633
1170
  // 2) register the overlay at the SHARED git common dir so the new worktree (and every
634
1171
  // other) auto-discovers the same memory — also backfills pre-0.32 single-worktree setups.
635
- const store = new HunchStore(hunchPaths(root));
636
- const overlay = store.privateDir;
637
- const autoCommit = store.privateAutoCommit;
638
- store.close();
639
1172
  let shareNote;
640
1173
  if (!opts.share) {
641
1174
  shareNote = ` · --no-share — the worktree will NOT see private memory`;
642
1175
  }
643
- else if (overlay && ensureSharedOverlayPointer(root, overlay, autoCommit)) {
1176
+ else if (overlay && ensureSharedOverlayPointer(root, overlay, autoCommit, overlayMode)) {
644
1177
  shareNote = ` ✓ memory shared via the git common dir — this worktree sees the same decisions / bugs / constraints`;
645
1178
  }
646
1179
  else if (overlay) {
@@ -656,18 +1189,23 @@ program
656
1189
  // derived (gitignored) index, never the working tree.
657
1190
  let indexNote = "";
658
1191
  if (opts.index !== false) {
659
- const wstore = new HunchStore(hunchPaths(dest));
660
- wstore.json.ensureDirs();
661
- if (wstore.json.loadAll("symbols").length === 0) {
662
- const res = indexRepo(wstore, dest);
663
- wstore.reindex();
664
- indexNote = `\n ✓ indexed ${res.files} file(s) → ${res.symbols} symbols — blast-radius ready (code graph isn't committed here)`;
1192
+ const wstore = openTeamStore(dest).store;
1193
+ try {
1194
+ wstore.json.ensureDirs();
1195
+ if (wstore.json.loadAll("symbols").length === 0) {
1196
+ const res = indexRepo(wstore, dest, { source: { kind: "commit", ref: "HEAD" } });
1197
+ wstore.reindex();
1198
+ indexNote = `\n ✓ indexed ${res.files} file(s) → ${res.symbols} symbols — blast-radius ready (code graph isn't committed here)`;
1199
+ }
1200
+ else {
1201
+ wstore.reindex(); // committed graph already in the checkout → just build the derived SQLite
1202
+ indexNote = `\n ✓ code graph present in the checkout — blast-radius ready`;
1203
+ }
665
1204
  }
666
- else {
667
- wstore.reindex(); // committed graph already in the checkout → just build the derived SQLite
668
- indexNote = `\n ✓ code graph present in the checkout — blast-radius ready`;
1205
+ finally {
1206
+ wstore.close();
1207
+ openStore = null;
669
1208
  }
670
- wstore.close();
671
1209
  }
672
1210
  console.log(`✓ worktree created → ${dest}${opts.branch ? ` (new branch ${opts.branch})` : ""}\n` +
673
1211
  `${shareNote}${indexNote}\n` +
@@ -867,8 +1405,10 @@ program
867
1405
  provenance: { source: "extracted", confidence: 0.5, evidence: [range] },
868
1406
  date: now,
869
1407
  };
1408
+ const home = store.captureHome(!!opts.private);
870
1409
  store.putCapture("runbooks", rec, opts.private);
871
1410
  store.reindex();
1411
+ pumpMemoryHome(store, root, home, `hunch: capture runbook ${rec.id}`);
872
1412
  console.log(`✓ runbook ${rec.id} — "${rec.task}" (${rec.steps.length} steps, ${rec.files.length} files)${opts.private ? " [private overlay]" : ""}`);
873
1413
  console.log(dim(" advisory, deterministic draft — refine the steps/gotchas; surfaced via `hunch query` and MCP."));
874
1414
  store.close();
@@ -886,12 +1426,16 @@ program
886
1426
  }
887
1427
  const intents = extractInlineIntent(root);
888
1428
  const now = new Date().toISOString();
1429
+ const home = store.captureHome(!!opts.private);
889
1430
  let dec = 0, con = 0;
890
1431
  for (const it of intents) {
891
1432
  const ev = [`${it.file}:${it.line}`];
892
1433
  if (it.kind === "why") {
893
1434
  const id = decisionId(`inline:${it.file}:${it.text}`);
894
- const prev = store.recs("decisions").find((d) => d.id === id); // preserve window for idempotent re-capture
1435
+ // Preserve idempotence only from the selected physical home. A merged
1436
+ // overlay-first lookup could copy a private topic/window into a public
1437
+ // record with the same deterministic id.
1438
+ const prev = store.recsInHome("decisions", home).find((d) => d.id === id);
895
1439
  const rec = {
896
1440
  id, title: it.text, topic: prev?.topic ?? null, status: "accepted",
897
1441
  context: `Captured from an inline hunch-why comment (${it.file}:${it.line}).`,
@@ -906,7 +1450,7 @@ program
906
1450
  }
907
1451
  else {
908
1452
  const id = constraintId(`inline:${it.file}:${it.text}`);
909
- const prev = store.recs("constraints").find((c) => c.id === id);
1453
+ const prev = store.recsInHome("constraints", home).find((c) => c.id === id);
910
1454
  const rec = {
911
1455
  id, type: "correctness", statement: it.text, scope: [it.file],
912
1456
  // Advisory by default — an inline rule never auto-blocks a build; raise severity
@@ -955,6 +1499,7 @@ program
955
1499
  store.json.ensureDirs();
956
1500
  const now = new Date().toISOString();
957
1501
  const arrow = opts.assert.startsWith("not-") ? "↛" : "→";
1502
+ const home = store.captureHome(false);
958
1503
  const d = store.putCapture("decisions", {
959
1504
  id: decisionId(`conform:${opts.add}:${opts.subject}:${opts.object ?? ""}`),
960
1505
  title: opts.add,
@@ -968,44 +1513,53 @@ program
968
1513
  valid_from: now,
969
1514
  });
970
1515
  store.reindex();
971
- refreshExistingGrounding(root, store); // the invariant reaches every assistant's grounding
1516
+ if (home === "public" && !store.autoCommit)
1517
+ refreshExistingGrounding(root, store);
1518
+ pumpMemoryHome(store, root, home, `hunch: record architectural invariant ${d.id}`);
972
1519
  console.log(`✓ recorded architectural invariant ${d.id}: "${opts.add}"`);
973
1520
  console.log(` ${opts.subject} ${arrow} ${opts.object ?? ""}${opts.transitive ? " (transitive)" : ""} [${opts.assert}]`);
974
1521
  console.log(` enforce on every change: hunch conform --strict (wire into CI alongside hunch ci)`);
975
1522
  store.close();
976
1523
  return;
977
1524
  }
978
- store.reindex();
979
- const results = checkConformance(store);
980
- if (!results.length) {
981
- console.log("No architectural invariants recorded yet.");
982
- console.log(dim(' Record one: hunch conform --add "controllers never touch the DB directly" --assert not-calls --subject OrdersController --object dbQuery'));
983
- store.close();
984
- return;
1525
+ try {
1526
+ const scan = scanRepo(store, root, { churn: false, source: { kind: "working" } });
1527
+ assertCompleteRepoScan(scan);
1528
+ const results = checkConformance(store, { graph: scan });
1529
+ if (!results.length) {
1530
+ console.log("No architectural invariants recorded yet.");
1531
+ console.log(dim(' Record one: hunch conform --add "controllers never touch the DB directly" --assert not-calls --subject OrdersController --object dbQuery'));
1532
+ return;
1533
+ }
1534
+ const violations = results.filter((r) => !r.satisfied);
1535
+ console.log(`Architectural conformance: ${results.length - violations.length}/${results.length} invariants satisfied\n`);
1536
+ for (const r of results) {
1537
+ console.log(` ${r.satisfied ? "✅" : "⛔"} ${r.decision} — "${r.title}"`);
1538
+ console.log(` ${r.assert} ${r.subject}${r.object ? ` → ${r.object}` : ""}: ${r.detail}`);
1539
+ if (!r.satisfied) {
1540
+ // The receipt — WHY this invariant exists, which pattern-SAST can't tell you.
1541
+ const dec = store.json.get("decisions", r.decision);
1542
+ if (dec?.context)
1543
+ console.log(` ↳ why: ${dec.context}`);
1544
+ if (dec?.caused_by_bug)
1545
+ console.log(` ↳ prevents recurrence of: ${dec.caused_by_bug}`);
1546
+ }
1547
+ }
1548
+ if (violations.length) {
1549
+ console.log(`\n⛔ ${violations.length} architectural invariant(s) the code no longer satisfies — an AI change drifted from the recorded architecture.`);
1550
+ if (opts.strict)
1551
+ process.exitCode = 1;
1552
+ }
1553
+ else {
1554
+ console.log(`\n✅ the code satisfies every recorded architectural invariant.`);
1555
+ }
985
1556
  }
986
- const violations = results.filter((r) => !r.satisfied);
987
- console.log(`Architectural conformance: ${results.length - violations.length}/${results.length} invariants satisfied\n`);
988
- for (const r of results) {
989
- console.log(` ${r.satisfied ? "✅" : "⛔"} ${r.decision} — "${r.title}"`);
990
- console.log(` ${r.assert} ${r.subject}${r.object ? ` → ${r.object}` : ""}: ${r.detail}`);
991
- if (!r.satisfied) {
992
- // The receipt — WHY this invariant exists, which pattern-SAST can't tell you.
993
- const dec = store.json.get("decisions", r.decision);
994
- if (dec?.context)
995
- console.log(` ↳ why: ${dec.context}`);
996
- if (dec?.caused_by_bug)
997
- console.log(` ↳ prevents recurrence of: ${dec.caused_by_bug}`);
998
- }
999
- }
1000
- if (violations.length) {
1001
- console.log(`\n⛔ ${violations.length} architectural invariant(s) the code no longer satisfies — an AI change drifted from the recorded architecture.`);
1002
- if (opts.strict)
1003
- process.exitCode = 1;
1557
+ catch (error) {
1558
+ fail(error.message);
1004
1559
  }
1005
- else {
1006
- console.log(`\n✅ the code satisfies every recorded architectural invariant.`);
1560
+ finally {
1561
+ store.close();
1007
1562
  }
1008
- store.close();
1009
1563
  });
1010
1564
  // ---- policy (Hunch Constitution — versioned policy/proof lifecycle) -------
1011
1565
  const policyCmd = program
@@ -1081,6 +1635,7 @@ policyCmd
1081
1635
  throw new Error("--public-only cannot be combined with --import");
1082
1636
  const file = resolve(root, opts.import);
1083
1637
  const corpus = service.importCorpus(id, JSON.parse(readFileSync(file, "utf8")));
1638
+ pumpPolicyHome(store, root, service, id, `hunch: import policy corpus ${id}`);
1084
1639
  const attestedGood = corpus.known_good.filter((fixture) => !!fixture.attestation).length;
1085
1640
  console.log(`✓ imported ${corpus.id} for ${corpus.policy_id}: ${corpus.known_bad.length} known bad, ${corpus.known_good.length} known good (${attestedGood} human-attested)`);
1086
1641
  console.log(` hash: ${corpus.content_hash} · home follows policy data class (${corpus.data_class})`);
@@ -1124,7 +1679,9 @@ policyCmd
1124
1679
  .action((decisionId, opts) => {
1125
1680
  const { store, root } = storeFor();
1126
1681
  try {
1127
- const policy = new ConstitutionService(store, root).compile(decisionId, opts);
1682
+ const service = new ConstitutionService(store, root);
1683
+ const policy = service.compile(decisionId, opts);
1684
+ pumpPolicyHome(store, root, service, policy.id, `hunch: compile policy ${policy.id}`);
1128
1685
  console.log(`✓ compiled ${policy.id} [${policy.state}] — ${policy.statement}`);
1129
1686
  console.log(` ${policy.assertion.kind}: ${JSON.stringify(policy.assertion)}`);
1130
1687
  console.log(" authority: none; this candidate cannot block until proved and explicitly accepted by a human.");
@@ -1136,6 +1693,68 @@ policyCmd
1136
1693
  store.close();
1137
1694
  }
1138
1695
  });
1696
+ policyCmd
1697
+ .command("upgrade-correction")
1698
+ .description("Turn one exact supported correction into a proved review proposal without activating it.")
1699
+ .argument("<constraint-id>", "captured correction constraint id")
1700
+ .option("--public-only", "read and write only the public correction home")
1701
+ .option("--private", "keep correction-derived artifacts private; refresh the public source-code graph before proof")
1702
+ .option("--json", "emit the complete deterministic upgrade result")
1703
+ .action((constraintId, opts) => {
1704
+ const { store, root } = storeFor();
1705
+ try {
1706
+ if (opts.publicOnly && opts.private)
1707
+ throw new Error("choose only one of --public-only or --private");
1708
+ // Correction upgrades must classify a supported uncommitted fix as
1709
+ // pending evidence. Refresh the durable graph from immutable HEAD so no
1710
+ // checkout bytes can leak, then let the correction materializer inspect
1711
+ // only its exact scoped dirty path.
1712
+ indexRepo(store, root, { churn: false, source: { kind: "commit", ref: "HEAD" } });
1713
+ store.reindex();
1714
+ // A private/shared correction id must never enter the public code history
1715
+ // through an otherwise-derived graph commit message.
1716
+ pumpMemoryHome(store, root, "public", "hunch: refresh derived graph and correction reviews");
1717
+ const service = new ConstitutionService(store, root);
1718
+ const upgrade = service.upgradeCorrection(constraintId, {
1719
+ publicOnly: opts.publicOnly,
1720
+ privateOnly: opts.private,
1721
+ });
1722
+ // The proof packet is memory too. Persist it through the same one-home funnel as
1723
+ // captures so a shared/private proposal cannot remain stranded in one teammate's
1724
+ // overlay after the command reports success. Proposed stays non-authoritative.
1725
+ const artifactHome = upgrade.policy
1726
+ ? service.repository.homeOfPolicy(upgrade.policy.id)
1727
+ : opts.publicOnly ? "public"
1728
+ : opts.private || store.unified || upgrade.evidence.data_class === "private" ? "private" : "public";
1729
+ pumpMemoryHome(store, root, artifactHome ?? "private", `hunch: prove correction ${constraintId}`);
1730
+ if (opts.json) {
1731
+ console.log(JSON.stringify(upgrade, null, 2));
1732
+ }
1733
+ else if (upgrade.review && upgrade.policy) {
1734
+ console.log(`READY FOR REVIEW ${upgrade.policy.id}`);
1735
+ console.log(` rule: ${upgrade.review.rule}`);
1736
+ console.log(` meaning: ${upgrade.review.meaning}`);
1737
+ console.log(` why: ${upgrade.review.why}`);
1738
+ console.log(` catches: ${upgrade.review.catches}`);
1739
+ console.log(` does not catch: ${upgrade.review.does_not_catch}`);
1740
+ console.log(` authority: ${upgrade.review.authority} — this operation did not activate anything`);
1741
+ console.log(` next: ${upgrade.review.next_action}`);
1742
+ console.log(` details: hunch policy card ${upgrade.policy.id}`);
1743
+ }
1744
+ else {
1745
+ console.log(`CORRECTION ${upgrade.status.toUpperCase().replaceAll("_", " ")} ${upgrade.correction_id}`);
1746
+ console.log(` reason: ${upgrade.reason}`);
1747
+ console.log(" immediate legacy guard: retained");
1748
+ console.log(" authority: none — no policy was activated");
1749
+ }
1750
+ }
1751
+ catch (e) {
1752
+ fail(e.message);
1753
+ }
1754
+ finally {
1755
+ store.close();
1756
+ }
1757
+ });
1139
1758
  policyCmd
1140
1759
  .command("plan")
1141
1760
  .description("Generate or inspect the canonical, non-executing ProofPlan for a Policy IR candidate.")
@@ -1151,12 +1770,17 @@ policyCmd
1151
1770
  if (values.slice(0, 2).some((n) => !Number.isFinite(n) || n < 0) || !Number.isFinite(values[2]) || values[2] <= 0) {
1152
1771
  throw new Error("history/mutation budgets must be non-negative and minutes must be positive");
1153
1772
  }
1154
- const plan = new ConstitutionService(store, root).plan(id, {
1773
+ const service = new ConstitutionService(store, root);
1774
+ const plan = service.plan(id, {
1155
1775
  maxCommits: values[0],
1156
1776
  maxMutations: values[1],
1157
1777
  maxMinutes: values[2],
1158
1778
  publicOnly: opts.publicOnly,
1159
1779
  });
1780
+ if (opts.publicOnly)
1781
+ pumpMemoryHome(store, root, "public", `hunch: plan policy ${id}`);
1782
+ else
1783
+ pumpPolicyHome(store, root, service, id, `hunch: plan policy ${id}`);
1160
1784
  console.log(JSON.stringify(plan, null, 2));
1161
1785
  }
1162
1786
  catch (e) {
@@ -1173,9 +1797,17 @@ policyCmd
1173
1797
  .action((id) => {
1174
1798
  const { store, root } = storeFor();
1175
1799
  try {
1176
- indexRepo(store, root, { churn: false });
1800
+ indexRepo(store, root, { churn: false, requireClean: true, requireComplete: true });
1177
1801
  store.reindex();
1178
- const { policy, proof } = new ConstitutionService(store, root).prove(id);
1802
+ // The selected policy may be private. Keep its identifier in the exact
1803
+ // artifact-home commit below, never in the public derived-graph history.
1804
+ pumpMemoryHome(store, root, "public", "hunch: refresh derived graph for policy proof");
1805
+ const service = new ConstitutionService(store, root);
1806
+ const { policy, proof } = service.prove(id);
1807
+ const policyHome = service.repository.homeOfPolicy(id);
1808
+ if (!policyHome)
1809
+ throw new Error(`policy ${id} has no exact storage home`);
1810
+ pumpMemoryHome(store, root, policyHome, `hunch: prove policy ${id}`);
1179
1811
  console.log(`POLICY PROOF ${policy.id}`);
1180
1812
  console.log(` state: ${policy.state} · class: ${proof.proof_class} · proof: ${proof.id}`);
1181
1813
  console.log(` current: ${proof.current.satisfied} satisfied · ${proof.current.violated} violated · ${proof.current.unknown} unknown · ${proof.current.error} error`);
@@ -1184,7 +1816,9 @@ policyCmd
1184
1816
  console.log(` mutations: ${proof.mutations.violated}/${proof.mutations.total} caught · ${Object.keys(proof.mutations.operator_coverage).join(", ") || "none"}`);
1185
1817
  for (const limitation of proof.limitations)
1186
1818
  console.log(` limitation: ${limitation}`);
1187
- console.log(" next: hunch policy accept " + policy.id + " --advisory|--blocking --actor human:<identity>");
1819
+ console.log(policy.activation_gate?.status === "blocked"
1820
+ ? ` next: inspect hunch policy card ${policy.id}; activation is blocked until source-currentness is implemented and cleared`
1821
+ : " next: hunch policy accept " + policy.id + " --advisory|--blocking --actor human:<identity>");
1188
1822
  }
1189
1823
  catch (e) {
1190
1824
  fail(e.message);
@@ -1216,6 +1850,7 @@ policyCmd
1216
1850
  }
1217
1851
  const classification = HistoryDispositionClassificationSchema.parse(opts.classify);
1218
1852
  const disposition = service.classifyHistory(id, opts.commit, classification, opts.actor, opts.reason, { supersedes: opts.supersedes });
1853
+ pumpPolicyHome(store, root, service, id, `hunch: classify policy history ${id}`);
1219
1854
  console.log(`✓ recorded ${disposition.id}: ${disposition.classification} for ${disposition.commit}`);
1220
1855
  console.log(` proof: ${disposition.proof_id} · actor: ${disposition.actor} · home follows policy data class (${disposition.data_class})`);
1221
1856
  console.log(" classification grants no activation authority; blocking still requires an explicit human policy acceptance.");
@@ -1255,9 +1890,14 @@ policyCmd
1255
1890
  if ((opts.record || classifying) && opts.publicOnly)
1256
1891
  throw new Error("--public-only cannot be combined with shadow writes");
1257
1892
  if (opts.record) {
1258
- indexRepo(store, root, { churn: false });
1259
- store.reindex();
1260
- const record = service.recordShadow(id);
1893
+ const graphScan = scanRepo(store, root, { churn: false, source: { kind: "working" } });
1894
+ assertCompleteRepoScan(graphScan);
1895
+ const snapshot = sourceGraphSnapshot(root, graphScan.source, graphScan.symbols, graphScan.edges, graphScan.components);
1896
+ const record = service.recordShadow(id, { snapshot, behavior: { workspace: "working" } });
1897
+ const policyHome = service.repository.homeOfPolicy(id);
1898
+ if (!policyHome)
1899
+ throw new Error(`policy ${id} has no exact storage home`);
1900
+ pumpMemoryHome(store, root, policyHome, `hunch: record policy shadow ${id}`);
1261
1901
  console.log(`✓ recorded ${record.id}: ${record.evaluation.result} on ${record.evaluation.repository.graph_hash}`);
1262
1902
  console.log(" shadow recording never warns, blocks, changes lifecycle, or grants authority.");
1263
1903
  return;
@@ -1268,6 +1908,7 @@ policyCmd
1268
1908
  }
1269
1909
  const classification = HistoryDispositionClassificationSchema.parse(opts.classify);
1270
1910
  const disposition = service.classifyShadow(id, opts.event, classification, opts.actor, opts.reason, { supersedes: opts.supersedes });
1911
+ pumpPolicyHome(store, root, service, id, `hunch: classify policy shadow ${id}`);
1271
1912
  console.log(`✓ recorded ${disposition.id}: ${disposition.classification} for ${disposition.shadow_id}`);
1272
1913
  console.log(" disposition changes measurement only; it cannot activate or block.");
1273
1914
  return;
@@ -1297,7 +1938,9 @@ policyCmd
1297
1938
  if (!!opts.advisory === !!opts.blocking)
1298
1939
  throw new Error("choose exactly one of --advisory or --blocking");
1299
1940
  const mode = opts.blocking ? "blocking" : "advisory";
1300
- const policy = new ConstitutionService(store, root).approve(id, mode, opts.actor);
1941
+ const service = new ConstitutionService(store, root);
1942
+ const policy = service.approve(id, mode, opts.actor);
1943
+ pumpPolicyHome(store, root, service, id, `hunch: accept policy ${id}`);
1301
1944
  console.log(`✓ ${policy.id} is ${policy.state} by ${policy.authority?.actor} (revision ${policy.revision})`);
1302
1945
  }
1303
1946
  catch (e) {
@@ -1316,7 +1959,9 @@ policyCmd
1316
1959
  .action((id, opts) => {
1317
1960
  const { store, root } = storeFor();
1318
1961
  try {
1319
- const policy = new ConstitutionService(store, root).withdraw(id, opts.actor, opts.reason);
1962
+ const service = new ConstitutionService(store, root);
1963
+ const policy = service.withdraw(id, opts.actor, opts.reason);
1964
+ pumpPolicyHome(store, root, service, id, `hunch: withdraw policy ${id}`);
1320
1965
  console.log(`✓ ${policy.id} withdrawn to ${policy.state}; authority returned to the human pool (revision ${policy.revision})`);
1321
1966
  }
1322
1967
  catch (e) {
@@ -1335,7 +1980,9 @@ policyCmd
1335
1980
  .action((id, opts) => {
1336
1981
  const { store, root } = storeFor();
1337
1982
  try {
1338
- const policy = new ConstitutionService(store, root).retire(id, opts.actor, opts.reason);
1983
+ const service = new ConstitutionService(store, root);
1984
+ const policy = service.retire(id, opts.actor, opts.reason);
1985
+ pumpPolicyHome(store, root, service, id, `hunch: retire policy ${id}`);
1339
1986
  console.log(`✓ ${policy.id} retired; window closed, history retained (revision ${policy.revision})`);
1340
1987
  }
1341
1988
  catch (e) {
@@ -1354,7 +2001,9 @@ policyCmd
1354
2001
  .action((id, opts) => {
1355
2002
  const { store, root } = storeFor();
1356
2003
  try {
1357
- const policy = new ConstitutionService(store, root).demote(id, opts.actor, opts.reason);
2004
+ const service = new ConstitutionService(store, root);
2005
+ const policy = service.demote(id, opts.actor, opts.reason);
2006
+ pumpPolicyHome(store, root, service, id, `hunch: demote policy ${id}`);
1358
2007
  console.log(`✓ ${policy.id} demoted to ${policy.state}; history retained (revision ${policy.revision})`);
1359
2008
  }
1360
2009
  catch (e) {
@@ -1374,7 +2023,9 @@ policyCmd
1374
2023
  .action((id, opts) => {
1375
2024
  const { store, root } = storeFor();
1376
2025
  try {
1377
- const policy = new ConstitutionService(store, root).linkException(id, opts.parent, opts.actor, opts.reason);
2026
+ const service = new ConstitutionService(store, root);
2027
+ const policy = service.linkException(id, opts.parent, opts.actor, opts.reason);
2028
+ pumpPolicyHome(store, root, service, id, `hunch: link policy exception ${id}`);
1378
2029
  console.log(`✓ ${policy.id} linked as a non-blocking exception of ${policy.exception_of} (revision ${policy.revision})`);
1379
2030
  console.log(" proof and authority cleared; composition remains advisory until separately proved.");
1380
2031
  }
@@ -1427,9 +2078,9 @@ policyCmd
1427
2078
  .argument("[id]", "optional policy id")
1428
2079
  .option("--active", "evaluate active policies only")
1429
2080
  .option("--public-only", "exclude private-overlay policies and graph records")
1430
- .option("--staged", "evaluate executable-behavior policies against the staged index snapshot")
1431
- .option("--working", "evaluate executable-behavior policies against all staged, unstaged, and untracked changes")
1432
- .option("--commit <sha>", "evaluate executable-behavior policies at an exact commit")
2081
+ .option("--staged", "evaluate policies against the staged index snapshot")
2082
+ .option("--working", "evaluate policies against all staged, unstaged, and safe untracked source (the default)")
2083
+ .option("--commit <sha>", "evaluate policies at an exact commit")
1433
2084
  .option("--strict", "exit non-zero on an authorized blocking violation or evaluator error")
1434
2085
  .option("--json", "emit canonical receipt objects as JSON")
1435
2086
  .action((id, opts) => {
@@ -1440,15 +2091,25 @@ policyCmd
1440
2091
  throw new Error(`pick one executable-behavior snapshot source (got ${sources.join(", ")})`);
1441
2092
  if (opts.commit && !revExists(opts.commit, root))
1442
2093
  throw new Error(`--commit ref "${opts.commit}" does not resolve`);
2094
+ const exactCommit = opts.commit ? revParse(`${opts.commit}^{commit}`, root) : undefined;
1443
2095
  const behavior = opts.staged ? { workspace: "staged" }
1444
2096
  : opts.working ? { workspace: "working" }
1445
- : opts.commit ? { commit: revParse(opts.commit, root) }
1446
- : undefined;
1447
- indexRepo(store, root, { churn: false });
1448
- store.reindex();
1449
- const results = new ConstitutionService(store, root).evaluate({ id, activeOnly: opts.active, publicOnly: opts.publicOnly, behavior });
2097
+ : exactCommit ? { commit: exactCommit }
2098
+ : { workspace: "working" };
2099
+ const service = new ConstitutionService(store, root);
2100
+ // Evaluation is a read. Static and executable policy legs select the
2101
+ // same source surface, and the receipt binds exact raw bytes plus graph
2102
+ // topology. Default to the complete working view so new safe untracked
2103
+ // code participates without ever publishing derived JSON.
2104
+ const semanticSource = exactCommit ? { kind: "commit", ref: exactCommit }
2105
+ : opts.staged ? { kind: "staged" }
2106
+ : { kind: "working" };
2107
+ const graphScan = scanRepo(store, root, { churn: false, source: semanticSource });
2108
+ assertCompleteRepoScan(graphScan);
2109
+ const snapshot = sourceGraphSnapshot(root, graphScan.source, graphScan.symbols, graphScan.edges, graphScan.components);
2110
+ const results = service.evaluate({ id, activeOnly: opts.active, publicOnly: opts.publicOnly, behavior, snapshot });
1450
2111
  if (opts.json)
1451
- console.log(JSON.stringify(results.map((r) => r.evaluation), null, 2));
2112
+ console.log(JSON.stringify(results.map(policyEvaluationEnvelope), null, 2));
1452
2113
  else
1453
2114
  renderPolicyEvaluations(results).forEach((line) => console.log(line));
1454
2115
  if (opts.strict && results.some((r) => r.blocks || r.strict_error))
@@ -1524,6 +2185,8 @@ constitutionCmd
1524
2185
  console.log(` ${event.id} [${event.kind}/${event.compiler?.status ?? "normalized"}] ${event.text_ref ?? ""}`);
1525
2186
  }
1526
2187
  console.log(" authority: none; ingestion never proves, proposes, activates, or blocks.");
2188
+ const homes = opts.publicOnly ? ["public"] : opts.private ? ["private"] : store.unified ? ["private"] : store.privateDir ? ["public", "private"] : ["public"];
2189
+ pumpMemoryHomes(store, root, homes, "hunch: ingest Constitution evidence");
1527
2190
  }
1528
2191
  catch (e) {
1529
2192
  fail(e.message);
@@ -1569,8 +2232,9 @@ constitutionCmd
1569
2232
  if (!Number.isFinite(requested) || requested <= 0)
1570
2233
  throw new Error("--max-candidates must be a positive number");
1571
2234
  if (opts.history) {
1572
- indexRepo(store, root, { churn: false });
2235
+ indexRepo(store, root, { churn: false, requireClean: true });
1573
2236
  store.reindex();
2237
+ pumpMemoryHome(store, root, "public", "hunch: refresh Constitution history graph");
1574
2238
  }
1575
2239
  const report = new ConstitutionService(store, root).bootstrap({
1576
2240
  since: opts.since,
@@ -1587,6 +2251,8 @@ constitutionCmd
1587
2251
  console.log(` ${report.compiled.length} compiled · ${report.covered} already covered · ${report.conflicted} conflicted · ${report.deferred} deferred by max-three cap · ${report.uncompilable} uncompilable`);
1588
2252
  if (!report.compiled.length)
1589
2253
  console.log(" No new candidates; existing policy lifecycle states were left untouched.");
2254
+ const homes = opts.publicOnly ? ["public"] : opts.private ? ["private"] : store.unified ? ["private"] : store.privateDir ? ["public", "private"] : ["public"];
2255
+ pumpMemoryHomes(store, root, homes, "hunch: bootstrap Constitution candidates");
1590
2256
  }
1591
2257
  catch (e) {
1592
2258
  fail(e.message);
@@ -1645,6 +2311,8 @@ constitutionCmd
1645
2311
  || behaviorAttestRequested || behaviorMaterializeRequested || behaviorPolicyMaterializeRequested;
1646
2312
  const attestRequested = opts.attest !== undefined;
1647
2313
  const actions = [!!opts.plan, !!opts.rehearse, attestRequested, !!opts.observe, backfillRequested, drillRequested, queueRequested, candidatesRequested, behaviorCandidatesRequested, behaviorReplayRequested, behaviorDepsRequested, behaviorAttestRequested, behaviorMaterializeRequested, behaviorPolicyMaterializeRequested].filter(Boolean).length;
2314
+ const writesPrivateMemory = !!opts.plan || !!opts.rehearse || attestRequested || !!opts.observe
2315
+ || backfillRequested || behaviorAttestRequested || behaviorPolicyMaterializeRequested;
1648
2316
  if (actions > 1)
1649
2317
  throw new Error("choose only one G2 plan, rehearsal, structural attestation, observation, historical backfill, operational drill, queue, structural candidate, behavior candidate, behavior replay, dependency snapshot, behavior attestation, behavior assessment, or behavior policy materialization action");
1650
2318
  if (opts.allowInstallScript && !behaviorDepsRequested && !behaviorPolicyMaterializeRequested)
@@ -1694,8 +2362,9 @@ constitutionCmd
1694
2362
  output = { appended, readiness: service.g2Readiness() };
1695
2363
  }
1696
2364
  else if (opts.observe) {
1697
- indexRepo(store, root, { churn: false });
2365
+ indexRepo(store, root, { churn: false, requireClean: true });
1698
2366
  store.reindex();
2367
+ pumpMemoryHome(store, root, "public", "hunch: refresh Constitution G2 observation graph");
1699
2368
  output = { sweep: service.g2ShadowSweep(), readiness: service.g2Readiness() };
1700
2369
  }
1701
2370
  else if (backfillRequested) {
@@ -1798,6 +2467,8 @@ constitutionCmd
1798
2467
  output = service.g2Readiness();
1799
2468
  }
1800
2469
  const readiness = service.g2Readiness();
2470
+ if (writesPrivateMemory)
2471
+ pumpMemoryHome(store, root, "private", "hunch: update Constitution G2 evidence");
1801
2472
  console.log(JSON.stringify(output, null, 2));
1802
2473
  if (opts.strict && readiness.recommendation !== "eligible_for_human_g2_signoff")
1803
2474
  process.exitCode = 1;
@@ -1847,6 +2518,8 @@ constitutionCmd
1847
2518
  output = service.g3Readiness();
1848
2519
  }
1849
2520
  const readiness = service.g3Readiness();
2521
+ if (actions)
2522
+ pumpMemoryHome(store, root, "private", "hunch: update Constitution G3 evidence");
1850
2523
  console.log(JSON.stringify(output, null, 2));
1851
2524
  if (opts.strict && readiness.recommendation !== "eligible_for_human_g3_signoff")
1852
2525
  process.exitCode = 1;
@@ -1887,7 +2560,10 @@ experimentCmd
1887
2560
  const { store, root } = storeFor();
1888
2561
  try {
1889
2562
  const input = JSON.parse(readFileSync(resolve(file), "utf8"));
1890
- console.log(JSON.stringify(new ConstitutionService(store, root).lockExperimentCaseBank(input), null, 2));
2563
+ const service = new ConstitutionService(store, root);
2564
+ const bank = service.lockExperimentCaseBank(input);
2565
+ pumpMemoryHome(store, root, "private", "hunch: prepare Constitution experiment");
2566
+ console.log(JSON.stringify(bank, null, 2));
1891
2567
  }
1892
2568
  catch (e) {
1893
2569
  fail(e.message);
@@ -1927,6 +2603,7 @@ experimentCmd
1927
2603
  actor: opts.actor,
1928
2604
  reason: opts.reason,
1929
2605
  });
2606
+ pumpMemoryHome(store, root, "private", `hunch: create Constitution experiment ${appended.id}`);
1930
2607
  console.log(JSON.stringify({ appended, report: service.experimentReport(appended.id) }, null, 2));
1931
2608
  }
1932
2609
  catch (e) {
@@ -1945,10 +2622,33 @@ experimentCmd
1945
2622
  .action((runId, opts) => {
1946
2623
  const { store, root } = storeFor();
1947
2624
  try {
1948
- console.log(JSON.stringify(new ConstitutionService(store, root).executeExperimentRun(runId, {
2625
+ const service = new ConstitutionService(store, root);
2626
+ const executed = service.executeExperimentRun(runId, {
1949
2627
  limit: Number(opts.limit),
1950
2628
  timeoutMs: Number(opts.timeoutMs),
1951
- }), null, 2));
2629
+ });
2630
+ pumpMemoryHome(store, root, "private", `hunch: execute Constitution experiment ${runId}`);
2631
+ console.log(JSON.stringify(executed, null, 2));
2632
+ }
2633
+ catch (e) {
2634
+ fail(e.message);
2635
+ }
2636
+ finally {
2637
+ store.close();
2638
+ }
2639
+ });
2640
+ experimentCmd
2641
+ .command("qualify")
2642
+ .description("Record a passing excluded comprehension check before an EXP-03 revision-2 timed review.")
2643
+ .argument("<file>", "reviewer qualification JSON")
2644
+ .action((file) => {
2645
+ const { store, root } = storeFor();
2646
+ try {
2647
+ const input = JSON.parse(readFileSync(resolve(file), "utf8"));
2648
+ const service = new ConstitutionService(store, root);
2649
+ const qualification = service.qualifyExperimentReviewer(input);
2650
+ pumpMemoryHome(store, root, "private", "hunch: qualify Constitution experiment reviewer");
2651
+ console.log(JSON.stringify(qualification, null, 2));
1952
2652
  }
1953
2653
  catch (e) {
1954
2654
  fail(e.message);
@@ -1965,7 +2665,10 @@ experimentCmd
1965
2665
  .action((runId, opts) => {
1966
2666
  const { store, root } = storeFor();
1967
2667
  try {
1968
- console.log(JSON.stringify(new ConstitutionService(store, root).nextExperimentReview(runId, opts.reviewer), null, 2));
2668
+ const service = new ConstitutionService(store, root);
2669
+ const review = service.nextExperimentReview(runId, opts.reviewer);
2670
+ pumpMemoryHome(store, root, "private", `hunch: start Constitution experiment review ${runId}`);
2671
+ console.log(JSON.stringify(review, null, 2));
1969
2672
  }
1970
2673
  catch (e) {
1971
2674
  fail(e.message);
@@ -1986,6 +2689,7 @@ experimentCmd
1986
2689
  const input = JSON.parse(readFileSync(resolve(file), "utf8"));
1987
2690
  const service = new ConstitutionService(store, root);
1988
2691
  const appended = service.submitExperimentReview(runId, assignmentId, input);
2692
+ pumpMemoryHome(store, root, "private", `hunch: submit Constitution experiment review ${runId}`);
1989
2693
  console.log(JSON.stringify({ appended, report: service.experimentReport(runId) }, null, 2));
1990
2694
  }
1991
2695
  catch (e) {
@@ -2026,6 +2730,7 @@ experimentCmd
2026
2730
  data_loss_or_corruption: !!opts.dataLoss,
2027
2731
  unsafe_evaluator_behavior: !!opts.unsafeEvaluator,
2028
2732
  });
2733
+ pumpMemoryHome(store, root, "private", `hunch: respond to Constitution experiment ${runId}`);
2029
2734
  console.log(JSON.stringify({ appended, report: service.experimentReport(runId) }, null, 2));
2030
2735
  }
2031
2736
  catch (e) {
@@ -2047,6 +2752,7 @@ experimentCmd
2047
2752
  const input = JSON.parse(readFileSync(resolve(file), "utf8"));
2048
2753
  const service = new ConstitutionService(store, root);
2049
2754
  const appended = service.recordExperimentFollowup(runId, assignmentId, input);
2755
+ pumpMemoryHome(store, root, "private", `hunch: follow up Constitution experiment ${runId}`);
2050
2756
  console.log(JSON.stringify({ appended, report: service.experimentReport(runId) }, null, 2));
2051
2757
  }
2052
2758
  catch (e) {
@@ -2067,6 +2773,7 @@ experimentCmd
2067
2773
  const input = JSON.parse(readFileSync(resolve(file), "utf8"));
2068
2774
  const service = new ConstitutionService(store, root);
2069
2775
  const appended = service.stopExperiment(runId, input);
2776
+ pumpMemoryHome(store, root, "private", `hunch: stop Constitution experiment ${runId}`);
2070
2777
  console.log(JSON.stringify({ appended, report: service.experimentReport(runId) }, null, 2));
2071
2778
  }
2072
2779
  catch (e) {
@@ -2241,14 +2948,12 @@ program
2241
2948
  store.json.ensureDirs();
2242
2949
  const r = await recordFailure(store, root, { test: opts.test, message: opts.message }, { private: opts.private });
2243
2950
  store.reindex();
2244
- const flush = flushCapture(store, hunchPaths(root).hunch, !!opts.private, `hunch: capture ${r.bug.id}`);
2951
+ pumpMemoryHomes(store, root, r.touchedHomes, `hunch: capture ${r.bug.id}`);
2245
2952
  console.log(`✓ recorded bug ${r.bug.id} via ${r.provider}: "${r.bug.title}"${opts.private ? " [private overlay; local-only synthesis]" : ""}`);
2246
2953
  if (r.bug.lineage.recurrence_of)
2247
2954
  console.log(` ↳ recurrence of ${r.bug.lineage.recurrence_of}`);
2248
2955
  if (r.constraint)
2249
2956
  console.log(` ↳ promoted constraint ${r.constraint.id} [${r.constraint.severity}]: ${r.constraint.statement}`);
2250
- if (flush === "pushed")
2251
- console.log(" ↳ private memory committed + pushed");
2252
2957
  store.close();
2253
2958
  });
2254
2959
  // ---- record-constraint (human-authored invariant) -------------------------
@@ -2275,6 +2980,25 @@ program
2275
2980
  store.close();
2276
2981
  return fail("--private needs HUNCH_PRIVATE_DIR set to a private store");
2277
2982
  }
2983
+ const home = store.captureHome(!!opts.private);
2984
+ if (opts.sourceDecision) {
2985
+ const source = home === "private"
2986
+ // Private reads intentionally see public + private memory, so a private
2987
+ // elaboration may safely cite an already-public decision. The privacy
2988
+ // boundary is one-way: a public artifact may never cite private-only.
2989
+ ? store.getRec("decisions", opts.sourceDecision)
2990
+ : store.json.get("decisions", opts.sourceDecision);
2991
+ if (!source) {
2992
+ const existsInOtherHome = home === "public" && !!store.getPrivateRec("decisions", opts.sourceDecision);
2993
+ const location = existsInOtherHome
2994
+ ? "exists only in the private overlay"
2995
+ : home === "private"
2996
+ ? "does not exist in visible public or private memory"
2997
+ : "does not exist in the public home";
2998
+ store.close();
2999
+ return fail(`refusing to record ${home} constraint: source decision ${opts.sourceDecision} ${location}`);
3000
+ }
3001
+ }
2278
3002
  store.json.ensureDirs();
2279
3003
  const scope = opts.scope.split(",").map((s) => toPosixTarget(s.trim())).filter(Boolean);
2280
3004
  const csv = (s) => (s ? s.split(",").map((x) => x.trim()).filter(Boolean) : []);
@@ -2308,7 +3032,7 @@ program
2308
3032
  store.reindex();
2309
3033
  // Public grounding is a publishable artifact. Private rules stay local and
2310
3034
  // are surfaced by local checks/MCP, never copied into committed agent docs.
2311
- if (store.captureHome(!!opts.private) === "public")
3035
+ if (home === "public" && !store.autoCommit)
2312
3036
  refreshExistingGrounding(root, store);
2313
3037
  const flush = flushCapture(store, hunchPaths(root).hunch, !!opts.private, `hunch: capture ${c.id}`);
2314
3038
  console.log(`✓ recorded ${c.severity} constraint ${c.id}: "${c.statement}" (scope: ${scope.join(", ") || "repo"})${opts.private ? " [private overlay]" : ""}`);
@@ -2373,8 +3097,8 @@ program
2373
3097
  for (const b of cap.fixed)
2374
3098
  console.log(` ✓ ${b.id} "${b.title}" → fixed (test passing)`);
2375
3099
  store.reindex();
2376
- if (cap.results.length || cap.fixed.length)
2377
- flushCapture(store, hunchPaths(root).hunch, !!opts.private, `hunch: capture test results`);
3100
+ if (cap.touchedHomes.length)
3101
+ pumpMemoryHomes(store, root, cap.touchedHomes, `hunch: capture test results`);
2378
3102
  store.close();
2379
3103
  const recurrences = cap.results.filter((r) => r.bug.lineage.recurrence_of).length;
2380
3104
  const promoted = cap.results.filter((r) => r.constraint).length;
@@ -2408,20 +3132,23 @@ program
2408
3132
  // --resync: regenerate each stale DECISION from its commit. Constraints have no
2409
3133
  // commit to replay, so they're reported as needing manual review instead.
2410
3134
  let resynced = 0, skipped = 0;
3135
+ const touchedHomes = new Set();
2411
3136
  for (const s of stale) {
2412
3137
  if (!s.kind.startsWith("decision")) {
2413
3138
  skipped++;
2414
3139
  console.log(` · ${s.kind} ${s.id} — manual (no commit to replay)`);
2415
3140
  continue;
2416
3141
  }
2417
- const d = store.json.get("decisions", s.id);
3142
+ const d = store.getRec("decisions", s.id);
2418
3143
  if (!d?.commit) {
2419
3144
  skipped++;
2420
3145
  console.log(` · ${s.id} — skipped (no source commit)`);
2421
3146
  continue;
2422
3147
  }
2423
- const r = await syncCommit(store, root, d.commit, { force: true });
3148
+ const home = decisionMemoryHome(store, d.id);
3149
+ const r = await syncCommit(store, root, d.commit, { force: true, home });
2424
3150
  if (r.status === "written") {
3151
+ touchedHomes.add(home);
2425
3152
  resynced++;
2426
3153
  console.log(` ↻ ${s.id} ← ${d.commit.slice(0, 8)} (${r.provider})`);
2427
3154
  }
@@ -2431,6 +3158,8 @@ program
2431
3158
  }
2432
3159
  }
2433
3160
  store.reindex();
3161
+ if (touchedHomes.size)
3162
+ pumpMemoryHomes(store, root, touchedHomes, `hunch: resync ${resynced} stale decision(s)`);
2434
3163
  store.close();
2435
3164
  console.log(`\n✓ re-synthesized ${resynced} stale decision(s), ${skipped} left for manual review.`);
2436
3165
  });
@@ -2452,7 +3181,12 @@ program
2452
3181
  return fail(`pick one of --staged / --working / --commit / --base (got ${sources.join(", ")})`);
2453
3182
  const markdown = opts.format === "markdown";
2454
3183
  const emptyReport = { fileCount: 0, strict: !!opts.strict, direct: [], near: [], regressions: [], vetoes: [], redundant: [], strictBlockers: 0, regBlocking: 0, vetoBlocking: 0 };
2455
- const { store, root } = storeFor();
3184
+ const { store, root, teamPullStatus } = storeFor({ requireFreshTeamMemory: !!opts.strict && !opts.publicOnly });
3185
+ const teamFreshnessFailure = teamPullStatus !== null
3186
+ && teamPullStatus !== "updated"
3187
+ && teamPullStatus !== "current"
3188
+ ? `strict check could not refresh the advertised team memory (${teamPullStatus}); refusing a stale pass`
3189
+ : null;
2456
3190
  // Fail loudly on an unresolvable --base (e.g. CI forgot to fetch the base
2457
3191
  // branch) — otherwise the diff is empty and the guard passes vacuously.
2458
3192
  if (opts.base && !revExists(opts.base, root)) {
@@ -2463,13 +3197,53 @@ program
2463
3197
  store.close();
2464
3198
  return fail(`--commit ref "${opts.commit}" does not resolve.`);
2465
3199
  }
3200
+ const exactCommit = opts.commit ? revParse(`${opts.commit}^{commit}`, root) : undefined;
2466
3201
  store.reindex(); // blast radius walks the edge graph — make the index current
2467
- const files = opts.commit ? commitFiles(opts.commit, root)
3202
+ const files = exactCommit ? commitFiles(exactCommit, root)
2468
3203
  : opts.base ? rangeFiles(opts.base, root)
2469
3204
  : opts.working ? workingFiles(root)
2470
3205
  : stagedFiles(root);
3206
+ // Select the full semantic source independently from the changed-file diff:
3207
+ // staged = stage-0 index blobs; commit = that exact tree; base = current HEAD
3208
+ // tree; working = safe filesystem bytes including non-ignored untracked code.
3209
+ // This scan is pure and content-addressed — it never rewrites durable graph JSON.
3210
+ const conformanceDecisions = opts.publicOnly ? store.json.loadAll("decisions") : store.recs("decisions");
3211
+ const hasConformance = conformanceDecisions.some((d) => (d.conformance?.length ?? 0) > 0);
3212
+ const constitution = new ConstitutionService(store, root);
3213
+ const activePolicies = constitution.list({ publicOnly: !!opts.publicOnly })
3214
+ .filter((p) => p.state === "active_advisory" || p.state === "active_blocking");
3215
+ const hasActivePolicies = activePolicies.length > 0;
3216
+ const hasActiveStaticPolicies = activePolicies.some((p) => p.assertion.kind !== "executable-behavior");
3217
+ const semanticSource = exactCommit ? { kind: "commit", ref: exactCommit }
3218
+ : opts.base ? { kind: "base" }
3219
+ : opts.working ? { kind: "working" }
3220
+ : { kind: "staged" };
3221
+ const graphScan = hasConformance || hasActiveStaticPolicies
3222
+ ? scanRepo(store, root, { churn: false, source: semanticSource })
3223
+ : null;
3224
+ const staticSnapshot = graphScan
3225
+ ? sourceGraphSnapshot(root, graphScan.source, graphScan.symbols, graphScan.edges, graphScan.components)
3226
+ : undefined;
3227
+ const semanticIssues = graphScan?.issues ?? [];
3228
+ if (semanticIssues.length) {
3229
+ if (markdown) {
3230
+ console.log(`\n### ‼ Incomplete semantic source scan — ${semanticIssues.length} file(s) rejected\n`);
3231
+ for (const issue of semanticIssues)
3232
+ console.log(`- **${issue.path}** — ${issue.detail} (${issue.code})`);
3233
+ }
3234
+ else {
3235
+ console.log(`\n‼ Incomplete semantic source scan — ${semanticIssues.length} file(s) rejected:`);
3236
+ for (const issue of semanticIssues)
3237
+ console.log(` ${issue.path}: ${issue.detail} [${issue.code}]`);
3238
+ console.log(" Strict mode fails closed because an omitted source file could hide a semantic violation.");
3239
+ }
3240
+ }
2471
3241
  if (!files.length) {
2472
3242
  console.log(markdown ? renderMarkdown(emptyReport) : "No changed files to check.");
3243
+ if (teamFreshnessFailure)
3244
+ fail(teamFreshnessFailure);
3245
+ if (opts.strict && semanticIssues.length)
3246
+ process.exitCode = 1;
2473
3247
  store.close();
2474
3248
  return;
2475
3249
  }
@@ -2477,7 +3251,7 @@ program
2477
3251
  // code) + REDUNDANT (adds a symbol already defined elsewhere — advisory) + the
2478
3252
  // hardened strict gate + causal `why` citations — all assembled by the shared
2479
3253
  // store.buildCheckReport (also used by the hunch_merge_verdict tool).
2480
- const diff = opts.commit ? commitDiff(opts.commit, root) : opts.base ? rangeDiff(opts.base, root) : opts.working ? workingDiff(root) : stagedDiff(root);
3254
+ const diff = exactCommit ? commitDiff(exactCommit, root) : opts.base ? rangeDiff(opts.base, root) : opts.working ? workingDiff(root) : stagedDiff(root);
2481
3255
  const report = store.buildCheckReport(files, diff, {
2482
3256
  strict: !!opts.strict,
2483
3257
  lastChange: (f) => lastChangeDate(f, root),
@@ -2496,18 +3270,15 @@ program
2496
3270
  // ARCHITECTURAL CONFORMANCE: does the RESULTING code still satisfy every recorded
2497
3271
  // architectural invariant? This is graph-reachability, not a diff — so it catches semantic
2498
3272
  // violations a pattern-matcher / SAST can't express (a controller that now reaches the DB
2499
- // directly). It must run over the CHANGED code, so re-parse the working tree first — but
2500
- // ONLY when conformance predicates exist (zero cost on repos that don't use them). The
2501
- // gate cases (--staged / --base / --commit HEAD) all have the working tree AT the change.
2502
- // Surfaced always; gates the commit/PR under --strict, with the receipt of the why.
2503
- const hasConformance = store.recs("decisions").some((d) => (d.conformance?.length ?? 0) > 0);
2504
- const constitution = new ConstitutionService(store, root);
2505
- const hasActivePolicies = constitution.list({ publicOnly: !!opts.publicOnly }).some((p) => p.state === "active_advisory" || p.state === "active_blocking");
2506
- if (hasConformance || hasActivePolicies) {
2507
- indexRepo(store, root, { churn: false }); // refresh the symbol/dep graph from the working tree
2508
- store.reindex();
2509
- }
2510
- const confViolations = hasConformance ? checkConformance(store).filter((c) => !c.satisfied) : [];
3273
+ // directly). Derive an EPHEMERAL graph so a read-only check can never rewrite/publish the
3274
+ // durable JSON graph. Every source mode above is isolated and the receipt
3275
+ // binds both its raw-byte fingerprint and resulting graph topology.
3276
+ const confViolations = hasConformance
3277
+ ? checkConformance(store, {
3278
+ publicOnly: !!opts.publicOnly,
3279
+ graph: { symbols: graphScan.symbols, edges: graphScan.edges },
3280
+ }).filter((c) => !c.satisfied)
3281
+ : [];
2511
3282
  if (confViolations.length) {
2512
3283
  if (markdown) {
2513
3284
  console.log(`\n### ⛔ Architectural conformance — ${confViolations.length} invariant(s) violated\n`);
@@ -2536,11 +3307,11 @@ program
2536
3307
  // authority can block. An evaluator error also fails strict CI; unknown remains
2537
3308
  // visible/advisory and can never masquerade as satisfied.
2538
3309
  const behavior = opts.working ? { workspace: "working" }
2539
- : opts.commit ? { commit: revParse(opts.commit, root) }
3310
+ : exactCommit ? { commit: exactCommit }
2540
3311
  : opts.base ? undefined
2541
3312
  : { workspace: "staged" };
2542
3313
  const policyResults = hasActivePolicies
2543
- ? constitution.evaluate({ activeOnly: true, publicOnly: !!opts.publicOnly, behavior })
3314
+ ? constitution.evaluate({ activeOnly: true, publicOnly: !!opts.publicOnly, behavior, snapshot: staticSnapshot })
2544
3315
  : [];
2545
3316
  if (policyResults.length) {
2546
3317
  if (markdown) {
@@ -2558,7 +3329,9 @@ program
2558
3329
  }
2559
3330
  }
2560
3331
  const constitutionFails = policyResults.some((r) => r.blocks || r.strict_error);
2561
- if (reportFailsStrict(report) || (!!opts.strict && (confViolations.length > 0 || constitutionFails)))
3332
+ if (teamFreshnessFailure)
3333
+ console.error(`error: ${teamFreshnessFailure}`);
3334
+ if (teamFreshnessFailure || reportFailsStrict(report) || (!!opts.strict && (semanticIssues.length > 0 || confViolations.length > 0 || constitutionFails)))
2562
3335
  process.exitCode = 1;
2563
3336
  store.close();
2564
3337
  });
@@ -2621,11 +3394,15 @@ vetoCmd
2621
3394
  if ((d.rejected_tripwires?.length ?? 0) > 0)
2622
3395
  continue; // never clobber existing tripwires
2623
3396
  const tws = draftTripwires(d.alternatives_rejected, d.related_files, knownDeps);
2624
- store.putWhereItLives("decisions", { ...d, rejected_tripwires: tws });
3397
+ // Selection is explicitly public. Never let an identically-named private
3398
+ // record redirect this public backfill through overlay-first update routing.
3399
+ store.json.put("decisions", { ...d, rejected_tripwires: tws });
2625
3400
  drafted += tws.length;
2626
3401
  touched++;
2627
3402
  }
2628
3403
  store.reindex();
3404
+ if (touched)
3405
+ pumpMemoryHome(store, root, "public", "hunch: backfill decision tripwires");
2629
3406
  if (!touched) {
2630
3407
  console.log("✓ Nothing to backfill — every in-force decision with rejected alternatives already has tripwires.");
2631
3408
  }
@@ -2715,7 +3492,7 @@ program
2715
3492
  .argument("<old>", "decision id being replaced")
2716
3493
  .requiredOption("--by <new>", "decision id that supersedes it")
2717
3494
  .action((oldId, opts) => {
2718
- const { store } = storeFor();
3495
+ const { store, root } = storeFor();
2719
3496
  const by = store.json.get("decisions", opts.by);
2720
3497
  if (!by) {
2721
3498
  store.close();
@@ -2727,6 +3504,7 @@ program
2727
3504
  return fail(`decision "${oldId}" not found (or same as --by)`);
2728
3505
  }
2729
3506
  store.reindex();
3507
+ pumpMemoryHome(store, root, "public", `hunch: supersede ${oldId} by ${opts.by}`);
2730
3508
  console.log(`✓ ${oldId} superseded by ${opts.by} — window closed at ${closed.valid_to?.slice(0, 10)}.`);
2731
3509
  store.close();
2732
3510
  });
@@ -3020,7 +3798,19 @@ program
3020
3798
  // → nothing for Hunch to say.
3021
3799
  if (!target || target.startsWith("..") || /^[a-zA-Z]:/.test(target))
3022
3800
  return;
3023
- store = new HunchStore(paths);
3801
+ // Pre-edit grounding must resolve the same advertised graph as every CLI
3802
+ // and MCP consumer. Any unavailable/mismatched team route falls through to
3803
+ // the outer fail-open catch and emits nothing, preserving the hook's
3804
+ // non-blocking invariant without false-passing against public/stale memory.
3805
+ const opened = openTeamStore(root, { requireFreshTeamMemory: firmness === "strict" });
3806
+ store = opened.store;
3807
+ if (firmness === "strict" && opened.teamPullStatus
3808
+ && opened.teamPullStatus !== "updated" && opened.teamPullStatus !== "current") {
3809
+ // A strict deny is only trustworthy when it includes the latest team
3810
+ // rules. Offline/busy/unconfigured team memory is unavailable, so the
3811
+ // non-blocking hook emits nothing instead of denying from stale state.
3812
+ return;
3813
+ }
3024
3814
  // strict: refuse an edit that hits a BLOCKING invariant (direct OR via blast
3025
3815
  // radius), feeding the invariant statement back as the refusal reason. Reindex
3026
3816
  // first so the blast radius reflects uncommitted edges — strict opts into the
@@ -3105,7 +3895,16 @@ program
3105
3895
  * this is how the Veto Guard goes advisory → blocking ("confirm rides hunch review";
3106
3896
  * dec_a466655539). Returns the new source tag and how many tripwires can now actually
3107
3897
  * block (non-empty forbids) so bulk enforcement is never silent. */
3108
- function acceptDecision(store, d) {
3898
+ function decisionMemoryHome(store, id) {
3899
+ return store.getPrivateRec("decisions", id) ? "private" : "public";
3900
+ }
3901
+ function putDecisionInHome(store, d, home) {
3902
+ if (home === "private")
3903
+ store.putPrivate("decisions", d);
3904
+ else
3905
+ store.json.put("decisions", d);
3906
+ }
3907
+ function acceptDecision(store, d, home = decisionMemoryHome(store, d.id)) {
3109
3908
  const source = d.provenance.source.includes("llm_draft") ? "llm_draft+human_confirmed" : "human_confirmed";
3110
3909
  const now = new Date().toISOString();
3111
3910
  const confirmedTws = (d.rejected_tripwires ?? []).map((tw) => ({
@@ -3120,9 +3919,24 @@ function acceptDecision(store, d) {
3120
3919
  last_verified: now,
3121
3920
  },
3122
3921
  }));
3123
- store.putWhereItLives("decisions", { ...d, status: "accepted", rejected_tripwires: confirmedTws, provenance: { ...d.provenance, source, confidence: 0.95, last_verified: now } });
3922
+ putDecisionInHome(store, { ...d, status: "accepted", rejected_tripwires: confirmedTws, provenance: { ...d.provenance, source, confidence: 0.95, last_verified: now } }, home);
3124
3923
  const armed = confirmedTws.filter((tw) => tw.forbids.deps.length || tw.forbids.symbols.length || tw.forbids.patterns.length).length;
3125
- return { source, armed };
3924
+ return { source, armed, home };
3925
+ }
3926
+ /** Close a proposed decision without deleting its Git-native history. Shared-memory
3927
+ * publishing is intentionally additive, so a lifecycle tombstone is both safer and
3928
+ * publishable while a raw file deletion would remain dirty behind the additive guard. */
3929
+ function rejectDecision(store, d, home = decisionMemoryHome(store, d.id)) {
3930
+ if (d.status !== "proposed")
3931
+ throw new Error(`refusing to reject ${d.status} decision ${d.id}; review --reject only rejects proposed drafts`);
3932
+ const now = new Date().toISOString();
3933
+ putDecisionInHome(store, {
3934
+ ...d,
3935
+ status: "rejected",
3936
+ valid_to: d.valid_to ?? now,
3937
+ provenance: { ...d.provenance, last_verified: now },
3938
+ }, home);
3939
+ return home;
3126
3940
  }
3127
3941
  /** Print one draft for the review listing: id/status/source/confidence, the Critic's
3128
3942
  * prune count (its visible value), the title, a decision snippet, and the raw synth line. */
@@ -3159,11 +3973,15 @@ program
3159
3973
  // UNCHANGED: it stays llm_draft-sourced, so the veto/strict gates (which key on
3160
3974
  // human_confirmed, not status) keep treating it as advisory — never blocking.
3161
3975
  let adopted = 0;
3976
+ const touchedHomes = new Set();
3162
3977
  for (const d of drafts) {
3163
- store.putWhereItLives("decisions", { ...d, status: "accepted", provenance: { ...d.provenance, last_verified: new Date().toISOString() } });
3978
+ const home = opts.private ? decisionMemoryHome(store, d.id) : "public";
3979
+ touchedHomes.add(home);
3980
+ putDecisionInHome(store, { ...d, status: "accepted", provenance: { ...d.provenance, last_verified: new Date().toISOString() } }, home);
3164
3981
  adopted++;
3165
3982
  }
3166
3983
  store.reindex();
3984
+ pumpMemoryHomes(store, root, touchedHomes, "hunch: adopt advisory decisions");
3167
3985
  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.`);
3168
3986
  }
3169
3987
  finally {
@@ -3174,7 +3992,7 @@ program
3174
3992
  .command("review")
3175
3993
  .description("Triage drafts: segmented list, accept/reject one, or batch-accept Critic-verified drafts.")
3176
3994
  .option("--accept <id>", "promote a decision to accepted/human-confirmed (confirms its tripwires)")
3177
- .option("--reject <id>", "delete a draft decision")
3995
+ .option("--reject <id>", "reject a draft decision with a durable lifecycle tombstone")
3178
3996
  .option("--accept-verified", "batch-accept every Critic-verified, well-grounded draft (>= --min-grounded)")
3179
3997
  .option("--reject-duplicates", "batch-reject drafts that near-duplicate an accepted record (deterministic term+file similarity — hygiene, not judgment)")
3180
3998
  .option("--min-grounded <n>", "grounded-ness threshold for the ready group / --accept-verified", String(READY_MIN_GROUNDED))
@@ -3188,18 +4006,20 @@ program
3188
4006
  }
3189
4007
  const decisions = () => opts.private ? store.recs("decisions") : store.json.loadAll("decisions");
3190
4008
  let publicGroundingChanged = false;
4009
+ const touchedHomes = new Set();
3191
4010
  if (opts.accept) {
3192
4011
  const d = opts.private ? store.getRec("decisions", opts.accept) : store.json.get("decisions", opts.accept);
3193
4012
  if (!d) {
3194
4013
  store.close();
3195
4014
  return fail(`decision ${opts.accept} not found`);
3196
4015
  }
3197
- const inPrivate = !!store.getPrivateRec("decisions", d.id);
3198
- const { source, armed } = acceptDecision(store, d);
4016
+ const { source, armed, home } = acceptDecision(store, d, opts.private ? decisionMemoryHome(store, d.id) : "public");
4017
+ touchedHomes.add(home);
3199
4018
  store.reindex();
3200
- if (!inPrivate) {
3201
- refreshExistingGrounding(root, store);
4019
+ if (home === "public") {
3202
4020
  publicGroundingChanged = true;
4021
+ if (!store.autoCommit)
4022
+ refreshExistingGrounding(root, store);
3203
4023
  }
3204
4024
  console.log(`✓ accepted ${opts.accept} (now ${source}, confidence 0.95${armed ? `, ${armed} tripwire(s) now blocking` : ""})`);
3205
4025
  }
@@ -3211,11 +4031,11 @@ program
3211
4031
  }
3212
4032
  if (d.status !== "proposed") {
3213
4033
  store.close();
3214
- return fail(`refusing to reject ${d.status} decision ${d.id}; review --reject only removes proposed drafts`);
4034
+ return fail(`refusing to reject ${d.status} decision ${d.id}; review --reject only rejects proposed drafts`);
3215
4035
  }
3216
- const ok2 = opts.private ? store.deleteWhereItLives("decisions", d.id) : store.json.delete("decisions", d.id);
4036
+ touchedHomes.add(rejectDecision(store, d, opts.private ? decisionMemoryHome(store, d.id) : "public"));
3217
4037
  store.reindex();
3218
- console.log(ok2 ? `✓ rejected and removed ${d.id}` : `decision ${d.id} not found`);
4038
+ console.log(`✓ rejected ${d.id} (lifecycle retained for history and team sync)`);
3219
4039
  }
3220
4040
  else if (opts.rejectDuplicates) {
3221
4041
  // Deterministic hygiene, not a trust decision (dec_a466655539 stays intact):
@@ -3229,14 +4049,14 @@ program
3229
4049
  console.log("✓ No near-duplicate drafts.");
3230
4050
  }
3231
4051
  else {
3232
- let removed = 0;
4052
+ let rejected = 0;
3233
4053
  for (const { d, m } of dupes) {
3234
- if ((opts.private ? store.deleteWhereItLives("decisions", d.id) : store.json.delete("decisions", d.id)))
3235
- removed++;
4054
+ touchedHomes.add(rejectDecision(store, d, opts.private ? decisionMemoryHome(store, d.id) : "public"));
4055
+ rejected++;
3236
4056
  console.log(` ✗ ${d.id} — "${d.title}"\n duplicate of ${m.of.id} — "${m.of.title}" (${Math.round(m.score * 100)}%)`);
3237
4057
  }
3238
4058
  store.reindex();
3239
- console.log(`\n✓ Rejected ${removed} duplicate draft(s). Accepted records untouched.`);
4059
+ console.log(`\n✓ Rejected ${rejected} duplicate draft(s). Accepted records untouched.`);
3240
4060
  }
3241
4061
  }
3242
4062
  else if (opts.acceptVerified) {
@@ -3250,13 +4070,15 @@ program
3250
4070
  else {
3251
4071
  let armedTotal = 0;
3252
4072
  for (const it of ready) {
3253
- if (!store.getPrivateRec("decisions", it.d.id))
4073
+ const accepted = acceptDecision(store, it.d, opts.private ? decisionMemoryHome(store, it.d.id) : "public");
4074
+ touchedHomes.add(accepted.home);
4075
+ if (accepted.home === "public")
3254
4076
  publicGroundingChanged = true;
3255
- armedTotal += acceptDecision(store, it.d).armed;
4077
+ armedTotal += accepted.armed;
3256
4078
  }
3257
4079
  store.reindex();
3258
- if (publicGroundingChanged)
3259
- refreshExistingGrounding(root, store); // committed grounding stays public-only
4080
+ if (publicGroundingChanged && !store.autoCommit)
4081
+ refreshExistingGrounding(root, store);
3260
4082
  console.log(`✓ accepted ${ready.length} verified draft(s); ${armedTotal} tripwire(s) now blocking.`);
3261
4083
  for (const it of ready)
3262
4084
  console.log(` ${it.d.id} grounded=${it.synth.grounded ?? "?"} ${it.d.title}`);
@@ -3293,6 +4115,8 @@ program
3293
4115
  console.log(`\nAccept: hunch review --accept <id> Reject: hunch review --reject <id>`);
3294
4116
  }
3295
4117
  }
4118
+ if (touchedHomes.size)
4119
+ pumpMemoryHomes(store, root, touchedHomes, "hunch: review decision lifecycle");
3296
4120
  store.close();
3297
4121
  });
3298
4122
  // ---- auto-review (harness-driven triage) ----------------------------------
@@ -3302,8 +4126,8 @@ function printAutoEntry(e) {
3302
4126
  }
3303
4127
  program
3304
4128
  .command("auto-review")
3305
- .description("Harness-driven draft triage: delegate relevance to the coding-assistant CLI, then dedup, auto-confirm the verified+relevant, and delete duplicates/irrelevant. Dry-run unless --apply; apply refuses an incomplete requested harness batch.")
3306
- .option("--apply", "execute the plan (accept/delete) only after a complete requested harness batch. Without it, print the plan and change nothing.")
4129
+ .description("Harness-driven draft triage: delegate relevance to the coding-assistant CLI, then dedup, auto-confirm the verified+relevant, and reject duplicates/irrelevant with lifecycle tombstones. Dry-run unless --apply; apply refuses an incomplete requested harness batch.")
4130
+ .option("--apply", "execute the plan (accept/reject) only after a complete requested harness batch. Without it, print the plan and change nothing.")
3307
4131
  .option("--min-grounded <n>", "grounded-ness threshold for the auto-accept gate", String(READY_MIN_GROUNDED))
3308
4132
  .option("--min-reject-confidence <n>", "minimum harness confidence to DELETE an irrelevant draft (else kept for a human)", "0.7")
3309
4133
  .option("--no-llm", "skip the harness judgment (dedup + grounding only — no relevance deletion)")
@@ -3382,24 +4206,28 @@ program
3382
4206
  fail(`incomplete harness batch (${verdicts.size}/${drafts.length} judged); no changes applied. Re-run when the provider is healthy, or explicitly choose deterministic-only triage with --no-llm --apply.`);
3383
4207
  return;
3384
4208
  }
3385
- // Apply: accept the verified+relevant, delete duplicates + irrelevant.
3386
- let accepted = 0, deleted = 0, armedTotal = 0, publicAccepted = false;
4209
+ // Apply: accept the verified+relevant, lifecycle-reject duplicates + irrelevant.
4210
+ let accepted = 0, rejected = 0, armedTotal = 0, publicAccepted = false;
4211
+ const touchedHomes = new Set();
3387
4212
  for (const e of plan.accept) {
3388
- if (!store.getPrivateRec("decisions", e.d.id))
4213
+ const result = acceptDecision(store, e.d, opts.private ? decisionMemoryHome(store, e.d.id) : "public");
4214
+ touchedHomes.add(result.home);
4215
+ if (result.home === "public")
3389
4216
  publicAccepted = true;
3390
- armedTotal += acceptDecision(store, e.d).armed;
4217
+ armedTotal += result.armed;
3391
4218
  accepted++;
3392
4219
  }
3393
4220
  for (const e of [...plan.rejectDuplicate, ...plan.rejectIrrelevant]) {
3394
- if ((opts.private ? store.deleteWhereItLives("decisions", e.d.id) : store.json.delete("decisions", e.d.id)))
3395
- deleted++;
4221
+ touchedHomes.add(rejectDecision(store, e.d, opts.private ? decisionMemoryHome(store, e.d.id) : "public"));
4222
+ rejected++;
3396
4223
  }
3397
- if (accepted || deleted) {
4224
+ if (accepted || rejected) {
3398
4225
  store.reindex();
3399
- if (publicAccepted)
3400
- refreshExistingGrounding(root, store); // committed grounding stays public-only
4226
+ if (publicAccepted && !store.autoCommit)
4227
+ refreshExistingGrounding(root, store);
4228
+ pumpMemoryHomes(store, root, touchedHomes, "hunch: auto-review decision lifecycle");
3401
4229
  }
3402
- console.log(`\n✓ auto-review applied: ${accepted} accepted${armedTotal ? ` (${armedTotal} tripwire(s) now blocking)` : ""}, ${deleted} deleted, ${plan.keep.length} kept for review.`);
4230
+ console.log(`\n✓ auto-review applied: ${accepted} accepted${armedTotal ? ` (${armedTotal} tripwire(s) now blocking)` : ""}, ${rejected} rejected, ${plan.keep.length} kept for review.`);
3403
4231
  }
3404
4232
  finally {
3405
4233
  store.close();
@@ -3412,11 +4240,11 @@ function printAutoReviewPlan(plan) {
3412
4240
  plan.accept.forEach(printAutoEntry);
3413
4241
  }
3414
4242
  if (plan.rejectDuplicate.length) {
3415
- console.log(`\n✗ DELETE (duplicate) — restates an accepted record (${plan.rejectDuplicate.length}):`);
4243
+ console.log(`\n✗ REJECT (duplicate) — restates an accepted record (${plan.rejectDuplicate.length}):`);
3416
4244
  plan.rejectDuplicate.forEach(printAutoEntry);
3417
4245
  }
3418
4246
  if (plan.rejectIrrelevant.length) {
3419
- console.log(`\n✗ DELETE (irrelevant) — harness judged not worth keeping (${plan.rejectIrrelevant.length}):`);
4247
+ console.log(`\n✗ REJECT (irrelevant) — harness judged not worth keeping (${plan.rejectIrrelevant.length}):`);
3420
4248
  plan.rejectIrrelevant.forEach(printAutoEntry);
3421
4249
  }
3422
4250
  if (plan.keep.length) {
@@ -3437,10 +4265,13 @@ program
3437
4265
  .command("migrate")
3438
4266
  .description("Upgrade .hunch/ records to the current schema version and stamp the manifest.")
3439
4267
  .action(() => {
3440
- const root = findRoot();
3441
- const paths = hunchPaths(root);
3442
- const store = new HunchStore(paths);
3443
- openStore = store;
4268
+ const { root, store } = storeFor();
4269
+ // Unified shared mode has one source of truth: migrate and stamp the overlay
4270
+ // itself, never the empty/public routing shell in the code repository.
4271
+ const paths = store.unified && store.privateDir
4272
+ ? hunchPathsForDir(store.privateDir)
4273
+ : hunchPaths(root);
4274
+ const target = store.unified ? new JsonStore(paths) : store.json;
3444
4275
  const from = readManifest(paths).schema_version;
3445
4276
  if (from > SCHEMA_VERSION) {
3446
4277
  store.close();
@@ -3448,13 +4279,15 @@ program
3448
4279
  }
3449
4280
  if (from === SCHEMA_VERSION) {
3450
4281
  writeManifest(paths, SCHEMA_VERSION); // record the version even if the manifest was absent
4282
+ pumpMemoryHome(store, root, store.unified ? "private" : "public", `hunch: stamp schema v${SCHEMA_VERSION}`);
3451
4283
  console.log(`✓ Already at schema v${SCHEMA_VERSION} — nothing to migrate.`);
3452
4284
  store.close();
3453
4285
  return;
3454
4286
  }
3455
- const res = store.json.persistMigration();
4287
+ const res = target.persistMigration();
3456
4288
  writeManifest(paths, SCHEMA_VERSION);
3457
4289
  store.reindex();
4290
+ pumpMemoryHome(store, root, store.unified ? "private" : "public", `hunch: migrate schema v${from} to v${SCHEMA_VERSION}`);
3458
4291
  console.log(`✓ Migrated v${from} → v${SCHEMA_VERSION}: ${res.migrated} record(s) upgraded.`);
3459
4292
  if (res.skipped) {
3460
4293
  console.warn(`⚠ ${res.skipped} record(s) could NOT be migrated and will no longer load. They are preserved on disk in their old shape under .hunch/ for manual recovery.`);
@@ -3555,11 +4388,7 @@ program
3555
4388
  console.log(`${m.date.slice(0, 10)} ${icon[m.kind]} ${m.kind.padEnd(9)} ${m.shortSha} ${m.subject.slice(0, 60)}${ids ? " " + dim(ids) : ""}`);
3556
4389
  }
3557
4390
  });
3558
- /** Self-repair (Phase 5): heal exact-path memory bindings after a commit's renames.
3559
- * Returns the plan (null when the commit renamed nothing that memory binds).
3560
- * Apply mode rewrites the records in their homes, reindexes, and auto-commits each
3561
- * touched home as a `repair` move on the timeline — background, revertable. */
3562
- function runRepair(store, root, sha, apply) {
4391
+ function runRepair(store, root, sha, apply, options = {}) {
3563
4392
  const renames = renamesOf(commitChanges(sha, root));
3564
4393
  if (!renames.length)
3565
4394
  return null;
@@ -3578,14 +4407,17 @@ function runRepair(store, root, sha, apply) {
3578
4407
  if (!plan.rewrites.length && !policyRewrites.length)
3579
4408
  return null;
3580
4409
  if (!apply)
3581
- return { plan, policyRewrites, applied: false };
4410
+ return { plan, policyRewrites, applied: false, publicTouched: false, privateTouched: false };
3582
4411
  let privateTouched = false, publicTouched = false;
4412
+ const touchedHomes = new Set();
3583
4413
  for (const d of store.recs("decisions")) {
3584
4414
  const healed = repairDecision(d, plan);
3585
4415
  if (healed === d)
3586
4416
  continue;
4417
+ const home = decisionMemoryHome(store, d.id);
3587
4418
  store.putWhereItLives("decisions", healed);
3588
- if (store.getPrivateRec("decisions", d.id))
4419
+ touchedHomes.add(home);
4420
+ if (home === "private")
3589
4421
  privateTouched = true;
3590
4422
  else
3591
4423
  publicTouched = true;
@@ -3594,8 +4426,10 @@ function runRepair(store, root, sha, apply) {
3594
4426
  const healed = repairConstraint(c, plan);
3595
4427
  if (healed === c)
3596
4428
  continue;
4429
+ const home = store.getPrivateRec("constraints", c.id) ? "private" : "public";
3597
4430
  store.putWhereItLives("constraints", healed);
3598
- if (store.getPrivateRec("constraints", c.id))
4431
+ touchedHomes.add(home);
4432
+ if (home === "private")
3599
4433
  privateTouched = true;
3600
4434
  else
3601
4435
  publicTouched = true;
@@ -3606,23 +4440,24 @@ function runRepair(store, root, sha, apply) {
3606
4440
  const healed = repairPolicySpec(p, policyRewrites, at);
3607
4441
  if (healed === p)
3608
4442
  continue;
4443
+ const home = service.repository.homeOfPolicy(p.id);
4444
+ if (!home)
4445
+ throw new Error(`policy ${p.id} has no exact storage home during repair`);
3609
4446
  service.repository.putPolicy(healed);
3610
- if (healed.data_class === "public")
4447
+ touchedHomes.add(home);
4448
+ if (home === "public")
3611
4449
  publicTouched = true;
3612
4450
  else
3613
4451
  privateTouched = true;
3614
4452
  }
3615
4453
  }
3616
4454
  store.reindex();
3617
- if (store.autoCommit) {
4455
+ if (store.autoCommit && options.commitMode !== "deferred") {
3618
4456
  const total = plan.rewrites.length + policyRewrites.length;
3619
4457
  const message = `hunch: repair ${total} binding(s) after rename (${sha.slice(0, 7)})`;
3620
- if (publicTouched)
3621
- commitAndPushHunch(hunchPaths(root).hunch, message, { push: false });
3622
- if (privateTouched && store.privateDir)
3623
- commitAndPushHunch(store.privateDir, message, { push: true });
4458
+ pumpMemoryHomes(store, root, touchedHomes, message);
3624
4459
  }
3625
- return { plan, policyRewrites, applied: true };
4460
+ return { plan, policyRewrites, applied: true, publicTouched, privateTouched };
3626
4461
  }
3627
4462
  program
3628
4463
  .command("repair")
@@ -3656,13 +4491,14 @@ program
3656
4491
  });
3657
4492
  program
3658
4493
  .command("revert-move <sha>")
3659
- .description("Undo one memory move: git-revert the commit that made it (LOCAL only, never pushed). Powers the Hunch view's 'reject move'.")
4494
+ .description("Undo one validated memory-only move from a clean checkout (LOCAL only, never pushed). Powers the Hunch view's 'reject move'.")
3660
4495
  .action((sha) => {
3661
4496
  const root = findRoot();
3662
4497
  if (!isGitRepo(root))
3663
4498
  return fail("not a git repo.");
3664
- if (!revertMemoryMove(sha, root))
3665
- return fail(`could not revert ${sha} (conflict or unknown commit) aborted; working tree unchanged.`);
4499
+ if (!revertMemoryMove(sha, root)) {
4500
+ return fail(`refused to revert ${sha}: require a reachable, non-merge, append-only Hunch JSON/grounding commit and a clean checkout/index; conflicts are aborted. Nothing changed.`);
4501
+ }
3666
4502
  console.log(`✓ reverted memory move ${sha} (local; not pushed).`);
3667
4503
  });
3668
4504
  program
@@ -3710,7 +4546,7 @@ program
3710
4546
  .argument("<to>", "symbol id/name or file path")
3711
4547
  .option("--max-depth <n>", "maximum hops to search", "8")
3712
4548
  .action((from, to, opts) => {
3713
- const { store } = storeFor();
4549
+ const { store, root } = storeFor();
3714
4550
  try {
3715
4551
  store.reindex(); // reflect out-of-band JSON edits before walking the graph
3716
4552
  const A = store.resolveNodeIds(from);
@@ -3995,7 +4831,7 @@ program
3995
4831
  .requiredOption("--to <path>", "exact current path (use private:<path> for a private-overlay file)")
3996
4832
  .option("--private", "require the decision to be in the configured private overlay")
3997
4833
  .action((id, opts) => {
3998
- const { store } = storeFor();
4834
+ const { store, root } = storeFor();
3999
4835
  try {
4000
4836
  if (opts.from === opts.to)
4001
4837
  return fail("--from and --to must be different paths");
@@ -4009,8 +4845,10 @@ program
4009
4845
  const repaired = repairDecisionReference(d, opts.from, opts.to);
4010
4846
  if (!repaired)
4011
4847
  return fail(`decision "${id}" does not contain the exact reference "${opts.from}"`);
4848
+ const home = decisionMemoryHome(store, d.id);
4012
4849
  store.putWhereItLives("decisions", repaired.decision);
4013
4850
  store.reindex();
4851
+ pumpMemoryHome(store, root, home, `hunch: repair decision reference ${id}`);
4014
4852
  console.log(`✓ repaired ${id}: ${repaired.relatedFiles} related file reference(s) + ${repaired.evidence} provenance evidence reference(s).`);
4015
4853
  console.log(` ${opts.from} → ${opts.to}`);
4016
4854
  }
@@ -4021,11 +4859,20 @@ program
4021
4859
  // ---- compact (bound Hunch growth) -----------------------------------------
4022
4860
  program
4023
4861
  .command("compact")
4024
- .description("Prune low-value auto-captured records (rejected/superseded/stale drafts, resolved low-confidence bugs).")
4025
- .option("--apply", "actually delete (default: dry-run preview)")
4862
+ .description("Preview low-value auto-captured records eligible for future tombstone-based compaction.")
4863
+ .option("--apply", "reserved for a future tombstone-based GC transaction; currently refused")
4026
4864
  .option("--max-age <days>", "minimum age in days for stale-draft pruning", "180")
4027
4865
  .option("--min-confidence <n>", "confidence below which a draft is prunable", "0.35")
4028
4866
  .action((opts) => {
4867
+ // Hunch publication is deliberately additive: an ordinary memory pump
4868
+ // refuses tracked deletions so a stale clone cannot erase team history.
4869
+ // Physically deleting here therefore strands `D .hunch/**` and wedges every
4870
+ // later auto-pump. Keep the useful deterministic preview, but fail before
4871
+ // opening or mutating the store until compaction has an explicit replicated
4872
+ // tombstone/GC protocol with its own authorization and recovery tests.
4873
+ if (opts.apply) {
4874
+ return fail("compact --apply is disabled: physical deletion is incompatible with additive team-memory publication. Use the dry-run preview; tombstone-based GC is not implemented yet.");
4875
+ }
4029
4876
  const { store, root } = storeFor();
4030
4877
  const plan = planCompaction({ decisions: store.json.loadAll("decisions"), bugs: store.json.loadAll("bugs"), constraints: store.json.loadAll("constraints") }, { now: Date.now(), maxAgeDays: Number(opts.maxAge), minConfidence: Number(opts.minConfidence) });
4031
4878
  if (!plan.remove.length) {
@@ -4033,21 +4880,10 @@ program
4033
4880
  store.close();
4034
4881
  return;
4035
4882
  }
4036
- console.log(`${plan.remove.length} of ${plan.considered} record(s) ${opts.apply ? "removed" : "would be removed"}:\n`);
4883
+ console.log(`${plan.remove.length} of ${plan.considered} record(s) would be removed:\n`);
4037
4884
  for (const c of plan.remove)
4038
- console.log(` ${opts.apply ? "✗" : "·"} [${c.kind}] ${c.id} ${c.title}\n ${c.reason}`);
4039
- if (opts.apply) {
4040
- let removed = 0;
4041
- for (const c of plan.remove)
4042
- if (store.json.delete(c.kind, c.id))
4043
- removed++;
4044
- store.reindex();
4045
- refreshExistingGrounding(root, store); // removing records must reach EVERY assistant's grounding
4046
- console.log(`\n✓ Removed ${removed} record(s).`);
4047
- }
4048
- else {
4049
- console.log(`\nDry run — re-run with --apply to delete. Accepted/human-confirmed, open bugs, constraints, and referenced records are never removed.`);
4050
- }
4885
+ console.log(` · [${c.kind}] ${c.id} ${c.title}\n ${c.reason}`);
4886
+ console.log("\nDry run only — physical deletion is disabled until Hunch has replicated tombstones. Accepted/human-confirmed, open bugs, constraints, and referenced records are never selected.");
4051
4887
  store.close();
4052
4888
  });
4053
4889
  // ---- merge-driver (internal; git invokes this) ----------------------------
@@ -4090,7 +4926,13 @@ program
4090
4926
  const { store, root } = storeFor();
4091
4927
  console.log(`Hunch root: ${root}`);
4092
4928
  console.log(`git repo: ${isGitRepo(root) ? "yes" : "no"} ${isGitRepo(root) ? `(HEAD ${headSha(root).slice(0, 8)})` : ""}`);
4093
- const onDisk = readManifest(hunchPaths(root)).schema_version;
4929
+ // In unified mode the public .hunch directory is only a routing shell.
4930
+ // Report the same effective manifest that `hunch migrate` reads and stamps,
4931
+ // or every healthy code-only team clone looks permanently out of date.
4932
+ const manifestPaths = store.unified && store.privateDir
4933
+ ? hunchPathsForDir(store.privateDir)
4934
+ : hunchPaths(root);
4935
+ const onDisk = readManifest(manifestPaths).schema_version;
4094
4936
  const schemaNote = onDisk === SCHEMA_VERSION ? "" : onDisk > SCHEMA_VERSION ? ` ⚠ newer than this Hunch (v${SCHEMA_VERSION}) — upgrade hunch` : ` ⚠ run \`hunch migrate\``;
4095
4937
  console.log(`schema: v${onDisk} (hunch v${SCHEMA_VERSION})${schemaNote}`);
4096
4938
  const resolution = await resolveSynthesisProvider({ root });