@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.
- package/README.md +96 -1
- package/dist/cli/index.js +1238 -396
- package/dist/constitution/adapters.js +31 -14
- package/dist/constitution/behaviorEvaluator.js +20 -7
- package/dist/constitution/behaviorProof.js +3 -2
- package/dist/constitution/canonical.js +7 -1
- package/dist/constitution/card.js +7 -2
- package/dist/constitution/compiler.js +71 -1
- package/dist/constitution/correctionPolicyMaterializer.js +496 -0
- package/dist/constitution/delta.js +3 -2
- package/dist/constitution/evaluator.js +29 -3
- package/dist/constitution/experiment.js +96 -5
- package/dist/constitution/experimentRunner.js +43 -14
- package/dist/constitution/g2BehaviorCandidates.js +49 -26
- package/dist/constitution/g2BehaviorDependencies.js +203 -14
- package/dist/constitution/g2Candidates.js +1 -1
- package/dist/constitution/lifecycle.js +17 -0
- package/dist/constitution/plan.js +26 -9
- package/dist/constitution/replacementFreeGit.js +67 -0
- package/dist/constitution/replay.js +6 -0
- package/dist/constitution/replayCache.js +1 -1
- package/dist/constitution/replayWorker.js +1 -1
- package/dist/constitution/repository.js +141 -5
- package/dist/constitution/safeCheckout.js +75 -0
- package/dist/constitution/schema.js +30 -5
- package/dist/constitution/service.js +74 -14
- package/dist/constitution/sourceMutation.js +65 -12
- package/dist/constitution/staticGraphBaseline.js +44 -0
- package/dist/constitution/structural.js +60 -4
- package/dist/core/autoreview.js +1 -1
- package/dist/core/canonicalOrder.js +6 -0
- package/dist/core/conformance.js +68 -27
- package/dist/core/docscan.js +2 -1
- package/dist/core/escalations.js +11 -0
- package/dist/core/io.js +44 -9
- package/dist/core/overlaySafety.js +178 -0
- package/dist/core/paths.js +13 -2
- package/dist/core/safeRepoFile.js +74 -0
- package/dist/extractors/comments.js +6 -8
- package/dist/extractors/git.js +1631 -82
- package/dist/extractors/indexer.js +86 -47
- package/dist/extractors/repoSource.js +390 -0
- package/dist/integrations/ciAction.js +10 -2
- package/dist/integrations/gitignore.js +44 -5
- package/dist/integrations/mergeDriver.js +23 -5
- package/dist/integrations/sync.js +61 -5
- package/dist/integrations/team.js +666 -23
- package/dist/mcp/server.js +261 -34
- package/dist/store/db.js +57 -7
- package/dist/store/hunchStore.js +92 -11
- package/dist/store/jsonStore.js +350 -63
- package/dist/store/schema.js +27 -11
- package/dist/synthesis/provider.js +13 -4
- package/dist/synthesis/synthesize.js +56 -19
- package/dist/wiki/graph.js +5 -4
- package/dist/wiki/wiki.js +16 -10
- package/package.json +15 -3
- package/tooling/competitive-watch.mjs +108 -0
- 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,
|
|
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
|
|
93
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
.
|
|
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
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
355
|
-
|
|
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
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
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
|
|
426
|
-
const
|
|
427
|
-
const
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
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
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
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
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
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
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
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
|
-
|
|
472
|
-
|
|
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
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
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
|
-
|
|
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 =
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
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
|
-
|
|
667
|
-
wstore.
|
|
668
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
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
|
-
|
|
987
|
-
|
|
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
|
-
|
|
1006
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
1259
|
-
|
|
1260
|
-
const
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
1431
|
-
.option("--working", "evaluate
|
|
1432
|
-
.option("--commit <sha>", "evaluate
|
|
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
|
-
:
|
|
1446
|
-
:
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
})
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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.
|
|
2377
|
-
|
|
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.
|
|
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
|
|
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 =
|
|
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 =
|
|
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).
|
|
2500
|
-
//
|
|
2501
|
-
//
|
|
2502
|
-
|
|
2503
|
-
|
|
2504
|
-
|
|
2505
|
-
|
|
2506
|
-
|
|
2507
|
-
|
|
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
|
-
:
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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>", "
|
|
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
|
|
3198
|
-
|
|
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 (
|
|
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
|
|
4034
|
+
return fail(`refusing to reject ${d.status} decision ${d.id}; review --reject only rejects proposed drafts`);
|
|
3215
4035
|
}
|
|
3216
|
-
|
|
4036
|
+
touchedHomes.add(rejectDecision(store, d, opts.private ? decisionMemoryHome(store, d.id) : "public"));
|
|
3217
4037
|
store.reindex();
|
|
3218
|
-
console.log(
|
|
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
|
|
4052
|
+
let rejected = 0;
|
|
3233
4053
|
for (const { d, m } of dupes) {
|
|
3234
|
-
|
|
3235
|
-
|
|
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 ${
|
|
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
|
-
|
|
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 +=
|
|
4077
|
+
armedTotal += accepted.armed;
|
|
3256
4078
|
}
|
|
3257
4079
|
store.reindex();
|
|
3258
|
-
if (publicGroundingChanged)
|
|
3259
|
-
refreshExistingGrounding(root, store);
|
|
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
|
|
3306
|
-
.option("--apply", "execute the plan (accept/
|
|
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,
|
|
3386
|
-
let accepted = 0,
|
|
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
|
-
|
|
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 +=
|
|
4217
|
+
armedTotal += result.armed;
|
|
3391
4218
|
accepted++;
|
|
3392
4219
|
}
|
|
3393
4220
|
for (const e of [...plan.rejectDuplicate, ...plan.rejectIrrelevant]) {
|
|
3394
|
-
|
|
3395
|
-
|
|
4221
|
+
touchedHomes.add(rejectDecision(store, e.d, opts.private ? decisionMemoryHome(store, e.d.id) : "public"));
|
|
4222
|
+
rejected++;
|
|
3396
4223
|
}
|
|
3397
|
-
if (accepted ||
|
|
4224
|
+
if (accepted || rejected) {
|
|
3398
4225
|
store.reindex();
|
|
3399
|
-
if (publicAccepted)
|
|
3400
|
-
refreshExistingGrounding(root, store);
|
|
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)` : ""}, ${
|
|
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✗
|
|
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✗
|
|
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 =
|
|
3441
|
-
|
|
3442
|
-
|
|
3443
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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(`
|
|
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("
|
|
4025
|
-
.option("--apply", "
|
|
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)
|
|
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(`
|
|
4039
|
-
|
|
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
|
-
|
|
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 });
|