@davesheffer/hunch 1.8.3 → 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 +92 -1
- package/dist/cli/index.js +1222 -397
- 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 +6 -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 +53 -10
- 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 +7 -2
- 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))
|
|
437
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")
|
|
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);
|
|
758
|
+
}
|
|
759
|
+
else if (before.kind === "symlink") {
|
|
760
|
+
symlinkSync(before.target, path);
|
|
438
761
|
}
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
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,13 @@ 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));
|
|
1952
2632
|
}
|
|
1953
2633
|
catch (e) {
|
|
1954
2634
|
fail(e.message);
|
|
@@ -1965,7 +2645,10 @@ experimentCmd
|
|
|
1965
2645
|
const { store, root } = storeFor();
|
|
1966
2646
|
try {
|
|
1967
2647
|
const input = JSON.parse(readFileSync(resolve(file), "utf8"));
|
|
1968
|
-
|
|
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));
|
|
1969
2652
|
}
|
|
1970
2653
|
catch (e) {
|
|
1971
2654
|
fail(e.message);
|
|
@@ -1982,7 +2665,10 @@ experimentCmd
|
|
|
1982
2665
|
.action((runId, opts) => {
|
|
1983
2666
|
const { store, root } = storeFor();
|
|
1984
2667
|
try {
|
|
1985
|
-
|
|
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));
|
|
1986
2672
|
}
|
|
1987
2673
|
catch (e) {
|
|
1988
2674
|
fail(e.message);
|
|
@@ -2003,6 +2689,7 @@ experimentCmd
|
|
|
2003
2689
|
const input = JSON.parse(readFileSync(resolve(file), "utf8"));
|
|
2004
2690
|
const service = new ConstitutionService(store, root);
|
|
2005
2691
|
const appended = service.submitExperimentReview(runId, assignmentId, input);
|
|
2692
|
+
pumpMemoryHome(store, root, "private", `hunch: submit Constitution experiment review ${runId}`);
|
|
2006
2693
|
console.log(JSON.stringify({ appended, report: service.experimentReport(runId) }, null, 2));
|
|
2007
2694
|
}
|
|
2008
2695
|
catch (e) {
|
|
@@ -2043,6 +2730,7 @@ experimentCmd
|
|
|
2043
2730
|
data_loss_or_corruption: !!opts.dataLoss,
|
|
2044
2731
|
unsafe_evaluator_behavior: !!opts.unsafeEvaluator,
|
|
2045
2732
|
});
|
|
2733
|
+
pumpMemoryHome(store, root, "private", `hunch: respond to Constitution experiment ${runId}`);
|
|
2046
2734
|
console.log(JSON.stringify({ appended, report: service.experimentReport(runId) }, null, 2));
|
|
2047
2735
|
}
|
|
2048
2736
|
catch (e) {
|
|
@@ -2064,6 +2752,7 @@ experimentCmd
|
|
|
2064
2752
|
const input = JSON.parse(readFileSync(resolve(file), "utf8"));
|
|
2065
2753
|
const service = new ConstitutionService(store, root);
|
|
2066
2754
|
const appended = service.recordExperimentFollowup(runId, assignmentId, input);
|
|
2755
|
+
pumpMemoryHome(store, root, "private", `hunch: follow up Constitution experiment ${runId}`);
|
|
2067
2756
|
console.log(JSON.stringify({ appended, report: service.experimentReport(runId) }, null, 2));
|
|
2068
2757
|
}
|
|
2069
2758
|
catch (e) {
|
|
@@ -2084,6 +2773,7 @@ experimentCmd
|
|
|
2084
2773
|
const input = JSON.parse(readFileSync(resolve(file), "utf8"));
|
|
2085
2774
|
const service = new ConstitutionService(store, root);
|
|
2086
2775
|
const appended = service.stopExperiment(runId, input);
|
|
2776
|
+
pumpMemoryHome(store, root, "private", `hunch: stop Constitution experiment ${runId}`);
|
|
2087
2777
|
console.log(JSON.stringify({ appended, report: service.experimentReport(runId) }, null, 2));
|
|
2088
2778
|
}
|
|
2089
2779
|
catch (e) {
|
|
@@ -2258,14 +2948,12 @@ program
|
|
|
2258
2948
|
store.json.ensureDirs();
|
|
2259
2949
|
const r = await recordFailure(store, root, { test: opts.test, message: opts.message }, { private: opts.private });
|
|
2260
2950
|
store.reindex();
|
|
2261
|
-
|
|
2951
|
+
pumpMemoryHomes(store, root, r.touchedHomes, `hunch: capture ${r.bug.id}`);
|
|
2262
2952
|
console.log(`✓ recorded bug ${r.bug.id} via ${r.provider}: "${r.bug.title}"${opts.private ? " [private overlay; local-only synthesis]" : ""}`);
|
|
2263
2953
|
if (r.bug.lineage.recurrence_of)
|
|
2264
2954
|
console.log(` ↳ recurrence of ${r.bug.lineage.recurrence_of}`);
|
|
2265
2955
|
if (r.constraint)
|
|
2266
2956
|
console.log(` ↳ promoted constraint ${r.constraint.id} [${r.constraint.severity}]: ${r.constraint.statement}`);
|
|
2267
|
-
if (flush === "pushed")
|
|
2268
|
-
console.log(" ↳ private memory committed + pushed");
|
|
2269
2957
|
store.close();
|
|
2270
2958
|
});
|
|
2271
2959
|
// ---- record-constraint (human-authored invariant) -------------------------
|
|
@@ -2292,6 +2980,25 @@ program
|
|
|
2292
2980
|
store.close();
|
|
2293
2981
|
return fail("--private needs HUNCH_PRIVATE_DIR set to a private store");
|
|
2294
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
|
+
}
|
|
2295
3002
|
store.json.ensureDirs();
|
|
2296
3003
|
const scope = opts.scope.split(",").map((s) => toPosixTarget(s.trim())).filter(Boolean);
|
|
2297
3004
|
const csv = (s) => (s ? s.split(",").map((x) => x.trim()).filter(Boolean) : []);
|
|
@@ -2325,7 +3032,7 @@ program
|
|
|
2325
3032
|
store.reindex();
|
|
2326
3033
|
// Public grounding is a publishable artifact. Private rules stay local and
|
|
2327
3034
|
// are surfaced by local checks/MCP, never copied into committed agent docs.
|
|
2328
|
-
if (
|
|
3035
|
+
if (home === "public" && !store.autoCommit)
|
|
2329
3036
|
refreshExistingGrounding(root, store);
|
|
2330
3037
|
const flush = flushCapture(store, hunchPaths(root).hunch, !!opts.private, `hunch: capture ${c.id}`);
|
|
2331
3038
|
console.log(`✓ recorded ${c.severity} constraint ${c.id}: "${c.statement}" (scope: ${scope.join(", ") || "repo"})${opts.private ? " [private overlay]" : ""}`);
|
|
@@ -2390,8 +3097,8 @@ program
|
|
|
2390
3097
|
for (const b of cap.fixed)
|
|
2391
3098
|
console.log(` ✓ ${b.id} "${b.title}" → fixed (test passing)`);
|
|
2392
3099
|
store.reindex();
|
|
2393
|
-
if (cap.
|
|
2394
|
-
|
|
3100
|
+
if (cap.touchedHomes.length)
|
|
3101
|
+
pumpMemoryHomes(store, root, cap.touchedHomes, `hunch: capture test results`);
|
|
2395
3102
|
store.close();
|
|
2396
3103
|
const recurrences = cap.results.filter((r) => r.bug.lineage.recurrence_of).length;
|
|
2397
3104
|
const promoted = cap.results.filter((r) => r.constraint).length;
|
|
@@ -2425,20 +3132,23 @@ program
|
|
|
2425
3132
|
// --resync: regenerate each stale DECISION from its commit. Constraints have no
|
|
2426
3133
|
// commit to replay, so they're reported as needing manual review instead.
|
|
2427
3134
|
let resynced = 0, skipped = 0;
|
|
3135
|
+
const touchedHomes = new Set();
|
|
2428
3136
|
for (const s of stale) {
|
|
2429
3137
|
if (!s.kind.startsWith("decision")) {
|
|
2430
3138
|
skipped++;
|
|
2431
3139
|
console.log(` · ${s.kind} ${s.id} — manual (no commit to replay)`);
|
|
2432
3140
|
continue;
|
|
2433
3141
|
}
|
|
2434
|
-
const d = store.
|
|
3142
|
+
const d = store.getRec("decisions", s.id);
|
|
2435
3143
|
if (!d?.commit) {
|
|
2436
3144
|
skipped++;
|
|
2437
3145
|
console.log(` · ${s.id} — skipped (no source commit)`);
|
|
2438
3146
|
continue;
|
|
2439
3147
|
}
|
|
2440
|
-
const
|
|
3148
|
+
const home = decisionMemoryHome(store, d.id);
|
|
3149
|
+
const r = await syncCommit(store, root, d.commit, { force: true, home });
|
|
2441
3150
|
if (r.status === "written") {
|
|
3151
|
+
touchedHomes.add(home);
|
|
2442
3152
|
resynced++;
|
|
2443
3153
|
console.log(` ↻ ${s.id} ← ${d.commit.slice(0, 8)} (${r.provider})`);
|
|
2444
3154
|
}
|
|
@@ -2448,6 +3158,8 @@ program
|
|
|
2448
3158
|
}
|
|
2449
3159
|
}
|
|
2450
3160
|
store.reindex();
|
|
3161
|
+
if (touchedHomes.size)
|
|
3162
|
+
pumpMemoryHomes(store, root, touchedHomes, `hunch: resync ${resynced} stale decision(s)`);
|
|
2451
3163
|
store.close();
|
|
2452
3164
|
console.log(`\n✓ re-synthesized ${resynced} stale decision(s), ${skipped} left for manual review.`);
|
|
2453
3165
|
});
|
|
@@ -2469,7 +3181,12 @@ program
|
|
|
2469
3181
|
return fail(`pick one of --staged / --working / --commit / --base (got ${sources.join(", ")})`);
|
|
2470
3182
|
const markdown = opts.format === "markdown";
|
|
2471
3183
|
const emptyReport = { fileCount: 0, strict: !!opts.strict, direct: [], near: [], regressions: [], vetoes: [], redundant: [], strictBlockers: 0, regBlocking: 0, vetoBlocking: 0 };
|
|
2472
|
-
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;
|
|
2473
3190
|
// Fail loudly on an unresolvable --base (e.g. CI forgot to fetch the base
|
|
2474
3191
|
// branch) — otherwise the diff is empty and the guard passes vacuously.
|
|
2475
3192
|
if (opts.base && !revExists(opts.base, root)) {
|
|
@@ -2480,13 +3197,53 @@ program
|
|
|
2480
3197
|
store.close();
|
|
2481
3198
|
return fail(`--commit ref "${opts.commit}" does not resolve.`);
|
|
2482
3199
|
}
|
|
3200
|
+
const exactCommit = opts.commit ? revParse(`${opts.commit}^{commit}`, root) : undefined;
|
|
2483
3201
|
store.reindex(); // blast radius walks the edge graph — make the index current
|
|
2484
|
-
const files =
|
|
3202
|
+
const files = exactCommit ? commitFiles(exactCommit, root)
|
|
2485
3203
|
: opts.base ? rangeFiles(opts.base, root)
|
|
2486
3204
|
: opts.working ? workingFiles(root)
|
|
2487
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
|
+
}
|
|
2488
3241
|
if (!files.length) {
|
|
2489
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;
|
|
2490
3247
|
store.close();
|
|
2491
3248
|
return;
|
|
2492
3249
|
}
|
|
@@ -2494,7 +3251,7 @@ program
|
|
|
2494
3251
|
// code) + REDUNDANT (adds a symbol already defined elsewhere — advisory) + the
|
|
2495
3252
|
// hardened strict gate + causal `why` citations — all assembled by the shared
|
|
2496
3253
|
// store.buildCheckReport (also used by the hunch_merge_verdict tool).
|
|
2497
|
-
const diff =
|
|
3254
|
+
const diff = exactCommit ? commitDiff(exactCommit, root) : opts.base ? rangeDiff(opts.base, root) : opts.working ? workingDiff(root) : stagedDiff(root);
|
|
2498
3255
|
const report = store.buildCheckReport(files, diff, {
|
|
2499
3256
|
strict: !!opts.strict,
|
|
2500
3257
|
lastChange: (f) => lastChangeDate(f, root),
|
|
@@ -2513,18 +3270,15 @@ program
|
|
|
2513
3270
|
// ARCHITECTURAL CONFORMANCE: does the RESULTING code still satisfy every recorded
|
|
2514
3271
|
// architectural invariant? This is graph-reachability, not a diff — so it catches semantic
|
|
2515
3272
|
// violations a pattern-matcher / SAST can't express (a controller that now reaches the DB
|
|
2516
|
-
// directly).
|
|
2517
|
-
//
|
|
2518
|
-
//
|
|
2519
|
-
|
|
2520
|
-
|
|
2521
|
-
|
|
2522
|
-
|
|
2523
|
-
|
|
2524
|
-
|
|
2525
|
-
store.reindex();
|
|
2526
|
-
}
|
|
2527
|
-
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
|
+
: [];
|
|
2528
3282
|
if (confViolations.length) {
|
|
2529
3283
|
if (markdown) {
|
|
2530
3284
|
console.log(`\n### ⛔ Architectural conformance — ${confViolations.length} invariant(s) violated\n`);
|
|
@@ -2553,11 +3307,11 @@ program
|
|
|
2553
3307
|
// authority can block. An evaluator error also fails strict CI; unknown remains
|
|
2554
3308
|
// visible/advisory and can never masquerade as satisfied.
|
|
2555
3309
|
const behavior = opts.working ? { workspace: "working" }
|
|
2556
|
-
:
|
|
3310
|
+
: exactCommit ? { commit: exactCommit }
|
|
2557
3311
|
: opts.base ? undefined
|
|
2558
3312
|
: { workspace: "staged" };
|
|
2559
3313
|
const policyResults = hasActivePolicies
|
|
2560
|
-
? constitution.evaluate({ activeOnly: true, publicOnly: !!opts.publicOnly, behavior })
|
|
3314
|
+
? constitution.evaluate({ activeOnly: true, publicOnly: !!opts.publicOnly, behavior, snapshot: staticSnapshot })
|
|
2561
3315
|
: [];
|
|
2562
3316
|
if (policyResults.length) {
|
|
2563
3317
|
if (markdown) {
|
|
@@ -2575,7 +3329,9 @@ program
|
|
|
2575
3329
|
}
|
|
2576
3330
|
}
|
|
2577
3331
|
const constitutionFails = policyResults.some((r) => r.blocks || r.strict_error);
|
|
2578
|
-
if (
|
|
3332
|
+
if (teamFreshnessFailure)
|
|
3333
|
+
console.error(`error: ${teamFreshnessFailure}`);
|
|
3334
|
+
if (teamFreshnessFailure || reportFailsStrict(report) || (!!opts.strict && (semanticIssues.length > 0 || confViolations.length > 0 || constitutionFails)))
|
|
2579
3335
|
process.exitCode = 1;
|
|
2580
3336
|
store.close();
|
|
2581
3337
|
});
|
|
@@ -2638,11 +3394,15 @@ vetoCmd
|
|
|
2638
3394
|
if ((d.rejected_tripwires?.length ?? 0) > 0)
|
|
2639
3395
|
continue; // never clobber existing tripwires
|
|
2640
3396
|
const tws = draftTripwires(d.alternatives_rejected, d.related_files, knownDeps);
|
|
2641
|
-
|
|
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 });
|
|
2642
3400
|
drafted += tws.length;
|
|
2643
3401
|
touched++;
|
|
2644
3402
|
}
|
|
2645
3403
|
store.reindex();
|
|
3404
|
+
if (touched)
|
|
3405
|
+
pumpMemoryHome(store, root, "public", "hunch: backfill decision tripwires");
|
|
2646
3406
|
if (!touched) {
|
|
2647
3407
|
console.log("✓ Nothing to backfill — every in-force decision with rejected alternatives already has tripwires.");
|
|
2648
3408
|
}
|
|
@@ -2732,7 +3492,7 @@ program
|
|
|
2732
3492
|
.argument("<old>", "decision id being replaced")
|
|
2733
3493
|
.requiredOption("--by <new>", "decision id that supersedes it")
|
|
2734
3494
|
.action((oldId, opts) => {
|
|
2735
|
-
const { store } = storeFor();
|
|
3495
|
+
const { store, root } = storeFor();
|
|
2736
3496
|
const by = store.json.get("decisions", opts.by);
|
|
2737
3497
|
if (!by) {
|
|
2738
3498
|
store.close();
|
|
@@ -2744,6 +3504,7 @@ program
|
|
|
2744
3504
|
return fail(`decision "${oldId}" not found (or same as --by)`);
|
|
2745
3505
|
}
|
|
2746
3506
|
store.reindex();
|
|
3507
|
+
pumpMemoryHome(store, root, "public", `hunch: supersede ${oldId} by ${opts.by}`);
|
|
2747
3508
|
console.log(`✓ ${oldId} superseded by ${opts.by} — window closed at ${closed.valid_to?.slice(0, 10)}.`);
|
|
2748
3509
|
store.close();
|
|
2749
3510
|
});
|
|
@@ -3037,7 +3798,19 @@ program
|
|
|
3037
3798
|
// → nothing for Hunch to say.
|
|
3038
3799
|
if (!target || target.startsWith("..") || /^[a-zA-Z]:/.test(target))
|
|
3039
3800
|
return;
|
|
3040
|
-
|
|
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
|
+
}
|
|
3041
3814
|
// strict: refuse an edit that hits a BLOCKING invariant (direct OR via blast
|
|
3042
3815
|
// radius), feeding the invariant statement back as the refusal reason. Reindex
|
|
3043
3816
|
// first so the blast radius reflects uncommitted edges — strict opts into the
|
|
@@ -3122,7 +3895,16 @@ program
|
|
|
3122
3895
|
* this is how the Veto Guard goes advisory → blocking ("confirm rides hunch review";
|
|
3123
3896
|
* dec_a466655539). Returns the new source tag and how many tripwires can now actually
|
|
3124
3897
|
* block (non-empty forbids) so bulk enforcement is never silent. */
|
|
3125
|
-
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)) {
|
|
3126
3908
|
const source = d.provenance.source.includes("llm_draft") ? "llm_draft+human_confirmed" : "human_confirmed";
|
|
3127
3909
|
const now = new Date().toISOString();
|
|
3128
3910
|
const confirmedTws = (d.rejected_tripwires ?? []).map((tw) => ({
|
|
@@ -3137,9 +3919,24 @@ function acceptDecision(store, d) {
|
|
|
3137
3919
|
last_verified: now,
|
|
3138
3920
|
},
|
|
3139
3921
|
}));
|
|
3140
|
-
store
|
|
3922
|
+
putDecisionInHome(store, { ...d, status: "accepted", rejected_tripwires: confirmedTws, provenance: { ...d.provenance, source, confidence: 0.95, last_verified: now } }, home);
|
|
3141
3923
|
const armed = confirmedTws.filter((tw) => tw.forbids.deps.length || tw.forbids.symbols.length || tw.forbids.patterns.length).length;
|
|
3142
|
-
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;
|
|
3143
3940
|
}
|
|
3144
3941
|
/** Print one draft for the review listing: id/status/source/confidence, the Critic's
|
|
3145
3942
|
* prune count (its visible value), the title, a decision snippet, and the raw synth line. */
|
|
@@ -3176,11 +3973,15 @@ program
|
|
|
3176
3973
|
// UNCHANGED: it stays llm_draft-sourced, so the veto/strict gates (which key on
|
|
3177
3974
|
// human_confirmed, not status) keep treating it as advisory — never blocking.
|
|
3178
3975
|
let adopted = 0;
|
|
3976
|
+
const touchedHomes = new Set();
|
|
3179
3977
|
for (const d of drafts) {
|
|
3180
|
-
|
|
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);
|
|
3181
3981
|
adopted++;
|
|
3182
3982
|
}
|
|
3183
3983
|
store.reindex();
|
|
3984
|
+
pumpMemoryHomes(store, root, touchedHomes, "hunch: adopt advisory decisions");
|
|
3184
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.`);
|
|
3185
3986
|
}
|
|
3186
3987
|
finally {
|
|
@@ -3191,7 +3992,7 @@ program
|
|
|
3191
3992
|
.command("review")
|
|
3192
3993
|
.description("Triage drafts: segmented list, accept/reject one, or batch-accept Critic-verified drafts.")
|
|
3193
3994
|
.option("--accept <id>", "promote a decision to accepted/human-confirmed (confirms its tripwires)")
|
|
3194
|
-
.option("--reject <id>", "
|
|
3995
|
+
.option("--reject <id>", "reject a draft decision with a durable lifecycle tombstone")
|
|
3195
3996
|
.option("--accept-verified", "batch-accept every Critic-verified, well-grounded draft (>= --min-grounded)")
|
|
3196
3997
|
.option("--reject-duplicates", "batch-reject drafts that near-duplicate an accepted record (deterministic term+file similarity — hygiene, not judgment)")
|
|
3197
3998
|
.option("--min-grounded <n>", "grounded-ness threshold for the ready group / --accept-verified", String(READY_MIN_GROUNDED))
|
|
@@ -3205,18 +4006,20 @@ program
|
|
|
3205
4006
|
}
|
|
3206
4007
|
const decisions = () => opts.private ? store.recs("decisions") : store.json.loadAll("decisions");
|
|
3207
4008
|
let publicGroundingChanged = false;
|
|
4009
|
+
const touchedHomes = new Set();
|
|
3208
4010
|
if (opts.accept) {
|
|
3209
4011
|
const d = opts.private ? store.getRec("decisions", opts.accept) : store.json.get("decisions", opts.accept);
|
|
3210
4012
|
if (!d) {
|
|
3211
4013
|
store.close();
|
|
3212
4014
|
return fail(`decision ${opts.accept} not found`);
|
|
3213
4015
|
}
|
|
3214
|
-
const
|
|
3215
|
-
|
|
4016
|
+
const { source, armed, home } = acceptDecision(store, d, opts.private ? decisionMemoryHome(store, d.id) : "public");
|
|
4017
|
+
touchedHomes.add(home);
|
|
3216
4018
|
store.reindex();
|
|
3217
|
-
if (
|
|
3218
|
-
refreshExistingGrounding(root, store);
|
|
4019
|
+
if (home === "public") {
|
|
3219
4020
|
publicGroundingChanged = true;
|
|
4021
|
+
if (!store.autoCommit)
|
|
4022
|
+
refreshExistingGrounding(root, store);
|
|
3220
4023
|
}
|
|
3221
4024
|
console.log(`✓ accepted ${opts.accept} (now ${source}, confidence 0.95${armed ? `, ${armed} tripwire(s) now blocking` : ""})`);
|
|
3222
4025
|
}
|
|
@@ -3228,11 +4031,11 @@ program
|
|
|
3228
4031
|
}
|
|
3229
4032
|
if (d.status !== "proposed") {
|
|
3230
4033
|
store.close();
|
|
3231
|
-
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`);
|
|
3232
4035
|
}
|
|
3233
|
-
|
|
4036
|
+
touchedHomes.add(rejectDecision(store, d, opts.private ? decisionMemoryHome(store, d.id) : "public"));
|
|
3234
4037
|
store.reindex();
|
|
3235
|
-
console.log(
|
|
4038
|
+
console.log(`✓ rejected ${d.id} (lifecycle retained for history and team sync)`);
|
|
3236
4039
|
}
|
|
3237
4040
|
else if (opts.rejectDuplicates) {
|
|
3238
4041
|
// Deterministic hygiene, not a trust decision (dec_a466655539 stays intact):
|
|
@@ -3246,14 +4049,14 @@ program
|
|
|
3246
4049
|
console.log("✓ No near-duplicate drafts.");
|
|
3247
4050
|
}
|
|
3248
4051
|
else {
|
|
3249
|
-
let
|
|
4052
|
+
let rejected = 0;
|
|
3250
4053
|
for (const { d, m } of dupes) {
|
|
3251
|
-
|
|
3252
|
-
|
|
4054
|
+
touchedHomes.add(rejectDecision(store, d, opts.private ? decisionMemoryHome(store, d.id) : "public"));
|
|
4055
|
+
rejected++;
|
|
3253
4056
|
console.log(` ✗ ${d.id} — "${d.title}"\n duplicate of ${m.of.id} — "${m.of.title}" (${Math.round(m.score * 100)}%)`);
|
|
3254
4057
|
}
|
|
3255
4058
|
store.reindex();
|
|
3256
|
-
console.log(`\n✓ Rejected ${
|
|
4059
|
+
console.log(`\n✓ Rejected ${rejected} duplicate draft(s). Accepted records untouched.`);
|
|
3257
4060
|
}
|
|
3258
4061
|
}
|
|
3259
4062
|
else if (opts.acceptVerified) {
|
|
@@ -3267,13 +4070,15 @@ program
|
|
|
3267
4070
|
else {
|
|
3268
4071
|
let armedTotal = 0;
|
|
3269
4072
|
for (const it of ready) {
|
|
3270
|
-
|
|
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")
|
|
3271
4076
|
publicGroundingChanged = true;
|
|
3272
|
-
armedTotal +=
|
|
4077
|
+
armedTotal += accepted.armed;
|
|
3273
4078
|
}
|
|
3274
4079
|
store.reindex();
|
|
3275
|
-
if (publicGroundingChanged)
|
|
3276
|
-
refreshExistingGrounding(root, store);
|
|
4080
|
+
if (publicGroundingChanged && !store.autoCommit)
|
|
4081
|
+
refreshExistingGrounding(root, store);
|
|
3277
4082
|
console.log(`✓ accepted ${ready.length} verified draft(s); ${armedTotal} tripwire(s) now blocking.`);
|
|
3278
4083
|
for (const it of ready)
|
|
3279
4084
|
console.log(` ${it.d.id} grounded=${it.synth.grounded ?? "?"} ${it.d.title}`);
|
|
@@ -3310,6 +4115,8 @@ program
|
|
|
3310
4115
|
console.log(`\nAccept: hunch review --accept <id> Reject: hunch review --reject <id>`);
|
|
3311
4116
|
}
|
|
3312
4117
|
}
|
|
4118
|
+
if (touchedHomes.size)
|
|
4119
|
+
pumpMemoryHomes(store, root, touchedHomes, "hunch: review decision lifecycle");
|
|
3313
4120
|
store.close();
|
|
3314
4121
|
});
|
|
3315
4122
|
// ---- auto-review (harness-driven triage) ----------------------------------
|
|
@@ -3319,8 +4126,8 @@ function printAutoEntry(e) {
|
|
|
3319
4126
|
}
|
|
3320
4127
|
program
|
|
3321
4128
|
.command("auto-review")
|
|
3322
|
-
.description("Harness-driven draft triage: delegate relevance to the coding-assistant CLI, then dedup, auto-confirm the verified+relevant, and
|
|
3323
|
-
.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.")
|
|
3324
4131
|
.option("--min-grounded <n>", "grounded-ness threshold for the auto-accept gate", String(READY_MIN_GROUNDED))
|
|
3325
4132
|
.option("--min-reject-confidence <n>", "minimum harness confidence to DELETE an irrelevant draft (else kept for a human)", "0.7")
|
|
3326
4133
|
.option("--no-llm", "skip the harness judgment (dedup + grounding only — no relevance deletion)")
|
|
@@ -3399,24 +4206,28 @@ program
|
|
|
3399
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.`);
|
|
3400
4207
|
return;
|
|
3401
4208
|
}
|
|
3402
|
-
// Apply: accept the verified+relevant,
|
|
3403
|
-
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();
|
|
3404
4212
|
for (const e of plan.accept) {
|
|
3405
|
-
|
|
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")
|
|
3406
4216
|
publicAccepted = true;
|
|
3407
|
-
armedTotal +=
|
|
4217
|
+
armedTotal += result.armed;
|
|
3408
4218
|
accepted++;
|
|
3409
4219
|
}
|
|
3410
4220
|
for (const e of [...plan.rejectDuplicate, ...plan.rejectIrrelevant]) {
|
|
3411
|
-
|
|
3412
|
-
|
|
4221
|
+
touchedHomes.add(rejectDecision(store, e.d, opts.private ? decisionMemoryHome(store, e.d.id) : "public"));
|
|
4222
|
+
rejected++;
|
|
3413
4223
|
}
|
|
3414
|
-
if (accepted ||
|
|
4224
|
+
if (accepted || rejected) {
|
|
3415
4225
|
store.reindex();
|
|
3416
|
-
if (publicAccepted)
|
|
3417
|
-
refreshExistingGrounding(root, store);
|
|
4226
|
+
if (publicAccepted && !store.autoCommit)
|
|
4227
|
+
refreshExistingGrounding(root, store);
|
|
4228
|
+
pumpMemoryHomes(store, root, touchedHomes, "hunch: auto-review decision lifecycle");
|
|
3418
4229
|
}
|
|
3419
|
-
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.`);
|
|
3420
4231
|
}
|
|
3421
4232
|
finally {
|
|
3422
4233
|
store.close();
|
|
@@ -3429,11 +4240,11 @@ function printAutoReviewPlan(plan) {
|
|
|
3429
4240
|
plan.accept.forEach(printAutoEntry);
|
|
3430
4241
|
}
|
|
3431
4242
|
if (plan.rejectDuplicate.length) {
|
|
3432
|
-
console.log(`\n✗
|
|
4243
|
+
console.log(`\n✗ REJECT (duplicate) — restates an accepted record (${plan.rejectDuplicate.length}):`);
|
|
3433
4244
|
plan.rejectDuplicate.forEach(printAutoEntry);
|
|
3434
4245
|
}
|
|
3435
4246
|
if (plan.rejectIrrelevant.length) {
|
|
3436
|
-
console.log(`\n✗
|
|
4247
|
+
console.log(`\n✗ REJECT (irrelevant) — harness judged not worth keeping (${plan.rejectIrrelevant.length}):`);
|
|
3437
4248
|
plan.rejectIrrelevant.forEach(printAutoEntry);
|
|
3438
4249
|
}
|
|
3439
4250
|
if (plan.keep.length) {
|
|
@@ -3454,10 +4265,13 @@ program
|
|
|
3454
4265
|
.command("migrate")
|
|
3455
4266
|
.description("Upgrade .hunch/ records to the current schema version and stamp the manifest.")
|
|
3456
4267
|
.action(() => {
|
|
3457
|
-
const root =
|
|
3458
|
-
|
|
3459
|
-
|
|
3460
|
-
|
|
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;
|
|
3461
4275
|
const from = readManifest(paths).schema_version;
|
|
3462
4276
|
if (from > SCHEMA_VERSION) {
|
|
3463
4277
|
store.close();
|
|
@@ -3465,13 +4279,15 @@ program
|
|
|
3465
4279
|
}
|
|
3466
4280
|
if (from === SCHEMA_VERSION) {
|
|
3467
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}`);
|
|
3468
4283
|
console.log(`✓ Already at schema v${SCHEMA_VERSION} — nothing to migrate.`);
|
|
3469
4284
|
store.close();
|
|
3470
4285
|
return;
|
|
3471
4286
|
}
|
|
3472
|
-
const res =
|
|
4287
|
+
const res = target.persistMigration();
|
|
3473
4288
|
writeManifest(paths, SCHEMA_VERSION);
|
|
3474
4289
|
store.reindex();
|
|
4290
|
+
pumpMemoryHome(store, root, store.unified ? "private" : "public", `hunch: migrate schema v${from} to v${SCHEMA_VERSION}`);
|
|
3475
4291
|
console.log(`✓ Migrated v${from} → v${SCHEMA_VERSION}: ${res.migrated} record(s) upgraded.`);
|
|
3476
4292
|
if (res.skipped) {
|
|
3477
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.`);
|
|
@@ -3572,11 +4388,7 @@ program
|
|
|
3572
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) : ""}`);
|
|
3573
4389
|
}
|
|
3574
4390
|
});
|
|
3575
|
-
|
|
3576
|
-
* Returns the plan (null when the commit renamed nothing that memory binds).
|
|
3577
|
-
* Apply mode rewrites the records in their homes, reindexes, and auto-commits each
|
|
3578
|
-
* touched home as a `repair` move on the timeline — background, revertable. */
|
|
3579
|
-
function runRepair(store, root, sha, apply) {
|
|
4391
|
+
function runRepair(store, root, sha, apply, options = {}) {
|
|
3580
4392
|
const renames = renamesOf(commitChanges(sha, root));
|
|
3581
4393
|
if (!renames.length)
|
|
3582
4394
|
return null;
|
|
@@ -3595,14 +4407,17 @@ function runRepair(store, root, sha, apply) {
|
|
|
3595
4407
|
if (!plan.rewrites.length && !policyRewrites.length)
|
|
3596
4408
|
return null;
|
|
3597
4409
|
if (!apply)
|
|
3598
|
-
return { plan, policyRewrites, applied: false };
|
|
4410
|
+
return { plan, policyRewrites, applied: false, publicTouched: false, privateTouched: false };
|
|
3599
4411
|
let privateTouched = false, publicTouched = false;
|
|
4412
|
+
const touchedHomes = new Set();
|
|
3600
4413
|
for (const d of store.recs("decisions")) {
|
|
3601
4414
|
const healed = repairDecision(d, plan);
|
|
3602
4415
|
if (healed === d)
|
|
3603
4416
|
continue;
|
|
4417
|
+
const home = decisionMemoryHome(store, d.id);
|
|
3604
4418
|
store.putWhereItLives("decisions", healed);
|
|
3605
|
-
|
|
4419
|
+
touchedHomes.add(home);
|
|
4420
|
+
if (home === "private")
|
|
3606
4421
|
privateTouched = true;
|
|
3607
4422
|
else
|
|
3608
4423
|
publicTouched = true;
|
|
@@ -3611,8 +4426,10 @@ function runRepair(store, root, sha, apply) {
|
|
|
3611
4426
|
const healed = repairConstraint(c, plan);
|
|
3612
4427
|
if (healed === c)
|
|
3613
4428
|
continue;
|
|
4429
|
+
const home = store.getPrivateRec("constraints", c.id) ? "private" : "public";
|
|
3614
4430
|
store.putWhereItLives("constraints", healed);
|
|
3615
|
-
|
|
4431
|
+
touchedHomes.add(home);
|
|
4432
|
+
if (home === "private")
|
|
3616
4433
|
privateTouched = true;
|
|
3617
4434
|
else
|
|
3618
4435
|
publicTouched = true;
|
|
@@ -3623,23 +4440,24 @@ function runRepair(store, root, sha, apply) {
|
|
|
3623
4440
|
const healed = repairPolicySpec(p, policyRewrites, at);
|
|
3624
4441
|
if (healed === p)
|
|
3625
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`);
|
|
3626
4446
|
service.repository.putPolicy(healed);
|
|
3627
|
-
|
|
4447
|
+
touchedHomes.add(home);
|
|
4448
|
+
if (home === "public")
|
|
3628
4449
|
publicTouched = true;
|
|
3629
4450
|
else
|
|
3630
4451
|
privateTouched = true;
|
|
3631
4452
|
}
|
|
3632
4453
|
}
|
|
3633
4454
|
store.reindex();
|
|
3634
|
-
if (store.autoCommit) {
|
|
4455
|
+
if (store.autoCommit && options.commitMode !== "deferred") {
|
|
3635
4456
|
const total = plan.rewrites.length + policyRewrites.length;
|
|
3636
4457
|
const message = `hunch: repair ${total} binding(s) after rename (${sha.slice(0, 7)})`;
|
|
3637
|
-
|
|
3638
|
-
commitAndPushHunch(hunchPaths(root).hunch, message, { push: false });
|
|
3639
|
-
if (privateTouched && store.privateDir)
|
|
3640
|
-
commitAndPushHunch(store.privateDir, message, { push: true });
|
|
4458
|
+
pumpMemoryHomes(store, root, touchedHomes, message);
|
|
3641
4459
|
}
|
|
3642
|
-
return { plan, policyRewrites, applied: true };
|
|
4460
|
+
return { plan, policyRewrites, applied: true, publicTouched, privateTouched };
|
|
3643
4461
|
}
|
|
3644
4462
|
program
|
|
3645
4463
|
.command("repair")
|
|
@@ -3673,13 +4491,14 @@ program
|
|
|
3673
4491
|
});
|
|
3674
4492
|
program
|
|
3675
4493
|
.command("revert-move <sha>")
|
|
3676
|
-
.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'.")
|
|
3677
4495
|
.action((sha) => {
|
|
3678
4496
|
const root = findRoot();
|
|
3679
4497
|
if (!isGitRepo(root))
|
|
3680
4498
|
return fail("not a git repo.");
|
|
3681
|
-
if (!revertMemoryMove(sha, root))
|
|
3682
|
-
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
|
+
}
|
|
3683
4502
|
console.log(`✓ reverted memory move ${sha} (local; not pushed).`);
|
|
3684
4503
|
});
|
|
3685
4504
|
program
|
|
@@ -3727,7 +4546,7 @@ program
|
|
|
3727
4546
|
.argument("<to>", "symbol id/name or file path")
|
|
3728
4547
|
.option("--max-depth <n>", "maximum hops to search", "8")
|
|
3729
4548
|
.action((from, to, opts) => {
|
|
3730
|
-
const { store } = storeFor();
|
|
4549
|
+
const { store, root } = storeFor();
|
|
3731
4550
|
try {
|
|
3732
4551
|
store.reindex(); // reflect out-of-band JSON edits before walking the graph
|
|
3733
4552
|
const A = store.resolveNodeIds(from);
|
|
@@ -4012,7 +4831,7 @@ program
|
|
|
4012
4831
|
.requiredOption("--to <path>", "exact current path (use private:<path> for a private-overlay file)")
|
|
4013
4832
|
.option("--private", "require the decision to be in the configured private overlay")
|
|
4014
4833
|
.action((id, opts) => {
|
|
4015
|
-
const { store } = storeFor();
|
|
4834
|
+
const { store, root } = storeFor();
|
|
4016
4835
|
try {
|
|
4017
4836
|
if (opts.from === opts.to)
|
|
4018
4837
|
return fail("--from and --to must be different paths");
|
|
@@ -4026,8 +4845,10 @@ program
|
|
|
4026
4845
|
const repaired = repairDecisionReference(d, opts.from, opts.to);
|
|
4027
4846
|
if (!repaired)
|
|
4028
4847
|
return fail(`decision "${id}" does not contain the exact reference "${opts.from}"`);
|
|
4848
|
+
const home = decisionMemoryHome(store, d.id);
|
|
4029
4849
|
store.putWhereItLives("decisions", repaired.decision);
|
|
4030
4850
|
store.reindex();
|
|
4851
|
+
pumpMemoryHome(store, root, home, `hunch: repair decision reference ${id}`);
|
|
4031
4852
|
console.log(`✓ repaired ${id}: ${repaired.relatedFiles} related file reference(s) + ${repaired.evidence} provenance evidence reference(s).`);
|
|
4032
4853
|
console.log(` ${opts.from} → ${opts.to}`);
|
|
4033
4854
|
}
|
|
@@ -4038,11 +4859,20 @@ program
|
|
|
4038
4859
|
// ---- compact (bound Hunch growth) -----------------------------------------
|
|
4039
4860
|
program
|
|
4040
4861
|
.command("compact")
|
|
4041
|
-
.description("
|
|
4042
|
-
.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")
|
|
4043
4864
|
.option("--max-age <days>", "minimum age in days for stale-draft pruning", "180")
|
|
4044
4865
|
.option("--min-confidence <n>", "confidence below which a draft is prunable", "0.35")
|
|
4045
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
|
+
}
|
|
4046
4876
|
const { store, root } = storeFor();
|
|
4047
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) });
|
|
4048
4878
|
if (!plan.remove.length) {
|
|
@@ -4050,21 +4880,10 @@ program
|
|
|
4050
4880
|
store.close();
|
|
4051
4881
|
return;
|
|
4052
4882
|
}
|
|
4053
|
-
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`);
|
|
4054
4884
|
for (const c of plan.remove)
|
|
4055
|
-
console.log(`
|
|
4056
|
-
|
|
4057
|
-
let removed = 0;
|
|
4058
|
-
for (const c of plan.remove)
|
|
4059
|
-
if (store.json.delete(c.kind, c.id))
|
|
4060
|
-
removed++;
|
|
4061
|
-
store.reindex();
|
|
4062
|
-
refreshExistingGrounding(root, store); // removing records must reach EVERY assistant's grounding
|
|
4063
|
-
console.log(`\n✓ Removed ${removed} record(s).`);
|
|
4064
|
-
}
|
|
4065
|
-
else {
|
|
4066
|
-
console.log(`\nDry run — re-run with --apply to delete. Accepted/human-confirmed, open bugs, constraints, and referenced records are never removed.`);
|
|
4067
|
-
}
|
|
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.");
|
|
4068
4887
|
store.close();
|
|
4069
4888
|
});
|
|
4070
4889
|
// ---- merge-driver (internal; git invokes this) ----------------------------
|
|
@@ -4107,7 +4926,13 @@ program
|
|
|
4107
4926
|
const { store, root } = storeFor();
|
|
4108
4927
|
console.log(`Hunch root: ${root}`);
|
|
4109
4928
|
console.log(`git repo: ${isGitRepo(root) ? "yes" : "no"} ${isGitRepo(root) ? `(HEAD ${headSha(root).slice(0, 8)})` : ""}`);
|
|
4110
|
-
|
|
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;
|
|
4111
4936
|
const schemaNote = onDisk === SCHEMA_VERSION ? "" : onDisk > SCHEMA_VERSION ? ` ⚠ newer than this Hunch (v${SCHEMA_VERSION}) — upgrade hunch` : ` ⚠ run \`hunch migrate\``;
|
|
4112
4937
|
console.log(`schema: v${onDisk} (hunch v${SCHEMA_VERSION})${schemaNote}`);
|
|
4113
4938
|
const resolution = await resolveSynthesisProvider({ root });
|