@davesheffer/hunch 1.7.1 → 1.8.0

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.
@@ -11,22 +11,38 @@ export const G3_CONFORMANCE_TEST = {
11
11
  file: "test/behavior-workspace.test.ts",
12
12
  name: "CLI, MCP, and check share one non-blocking working-snapshot receipt without public leakage",
13
13
  };
14
+ /** The certifiable client profiles. Each maps a HUMAN-SELECTED client set to the
15
+ * ONE executable fixture that exercises every named surface end-to-end and
16
+ * asserts receipt equality + zero private leaks. The vscode profile executes the
17
+ * extension's real spawn seam (vscode-extension/src/spawnCore.ts) against an
18
+ * npm-style shim — naming a client never certifies it; only its fixture does. */
19
+ export const G3_CONFORMANCE_PROFILES = [
20
+ { clients: G3_CONFORMANCE_CLIENTS, test: G3_CONFORMANCE_TEST },
21
+ {
22
+ clients: ["ci", "cli", "mcp", "vscode"],
23
+ test: {
24
+ file: "test/behavior-workspace.test.ts",
25
+ name: "CLI, MCP, check, and the VS Code seam share one non-blocking working-snapshot receipt without public leakage",
26
+ },
27
+ },
28
+ ];
14
29
  export function g3ConformanceSourceHash(root) {
15
30
  return canonicalHash(readFileSync(join(root, G3_CONFORMANCE_TEST.file), "utf8"));
16
31
  }
17
32
  function sameClients(left, right) {
18
33
  return canonicalHash([...left].sort()) === canonicalHash([...right].sort());
19
34
  }
20
- /** Execute the real CLI/MCP/CI fixture selected by the first G3 conformance profile.
21
- * Unsupported human-selected client sets return an error receipt rather than being
22
- * silently treated as equivalent to the built-in three-surface profile. */
35
+ /** Execute the real end-to-end fixture matching the plan's human-selected client
36
+ * profile. Unsupported client sets return an error receipt rather than being
37
+ * silently treated as equivalent to a certified profile. */
23
38
  export function executeG3AdapterConformance(root, plan, opts = {}) {
24
39
  const timeoutMs = opts.timeoutMs ?? 180_000;
25
40
  if (!Number.isInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 300_000)
26
41
  throw new Error("G3 conformance timeout must be a positive integer no greater than 300000");
27
42
  const sourceHash = g3ConformanceSourceHash(root);
28
43
  const isolationFlag = nodeTestIsolationFlag();
29
- if (!sameClients(plan.clients, G3_CONFORMANCE_CLIENTS)) {
44
+ const profile = G3_CONFORMANCE_PROFILES.find((candidate) => sameClients(plan.clients, candidate.clients));
45
+ if (!profile) {
30
46
  return compileAdapterConformanceReceipt({
31
47
  plan_id: plan.id,
32
48
  clients: plan.clients,
@@ -38,10 +54,11 @@ export function executeG3AdapterConformance(root, plan, opts = {}) {
38
54
  verdict_agreement: null,
39
55
  confirmed_private_leaks: null,
40
56
  error_code: "unsupported-client-profile",
41
- log_hash: canonicalHash({ supported: G3_CONFORMANCE_CLIENTS, selected: plan.clients }),
57
+ log_hash: canonicalHash({ supported: G3_CONFORMANCE_PROFILES.map((candidate) => candidate.clients), selected: plan.clients }),
42
58
  supersedes: opts.supersedes ?? null,
43
59
  }, { now: opts.now });
44
60
  }
61
+ const selectedTest = profile.test;
45
62
  const session = mkdtempSync(join(tmpdir(), "hunch-g3-conformance-"));
46
63
  const reporter = join(session, "reporter.mjs");
47
64
  writeFileSync(reporter, NODE_TEST_REPORTER_SOURCE);
@@ -63,10 +80,10 @@ export function executeG3AdapterConformance(root, plan, opts = {}) {
63
80
  tsx,
64
81
  "--test",
65
82
  isolationFlag,
66
- `--test-name-pattern=${exactNodeTestPattern(G3_CONFORMANCE_TEST.name)}`,
83
+ `--test-name-pattern=${exactNodeTestPattern(selectedTest.name)}`,
67
84
  `--test-reporter=${pathToFileURL(reporter).href}`,
68
85
  "--test-reporter-destination=stdout",
69
- G3_CONFORMANCE_TEST.file,
86
+ selectedTest.file,
70
87
  ], {
71
88
  cwd: root,
72
89
  env,
@@ -79,7 +96,7 @@ export function executeG3AdapterConformance(root, plan, opts = {}) {
79
96
  rmSync(session, { recursive: true, force: true });
80
97
  const stdout = run.stdout ?? "";
81
98
  const stderr = run.stderr ?? "";
82
- const events = nodeTestReporterEvents(stdout).filter((event) => event.name === G3_CONFORMANCE_TEST.name && !event.skip && !event.todo);
99
+ const events = nodeTestReporterEvents(stdout).filter((event) => event.name === selectedTest.name && !event.skip && !event.todo);
83
100
  const selectedEvent = events.length === 1 ? (events[0].type === "test:pass" ? "passed" : "failed") : null;
84
101
  let result = "error";
85
102
  let errorCode;
@@ -100,7 +117,7 @@ export function executeG3AdapterConformance(root, plan, opts = {}) {
100
117
  return compileAdapterConformanceReceipt({
101
118
  plan_id: plan.id,
102
119
  clients: plan.clients,
103
- test: { ...G3_CONFORMANCE_TEST, source_hash: sourceHash },
120
+ test: { ...selectedTest, source_hash: sourceHash },
104
121
  runner: { name: "node-test-tsx", isolation_flag: isolationFlag },
105
122
  result,
106
123
  exit_code: run.status ?? null,
@@ -186,4 +186,39 @@ export function demotePolicy(policy, actor, reason, at) {
186
186
  audit: [...policy.audit, { action: "demoted", actor_kind: "human", actor, at, reason, proof: policy.proof }],
187
187
  };
188
188
  }
189
+ /** Targeted advisory withdrawal (the §57 gap): pull the human authority back and
190
+ * return the policy to `proposed`. It stops surfacing as an active rule and
191
+ * RE-ENTERS the inline escalation loop ("activate or reject?") — the reversible
192
+ * half of retirement. History is append-only; nothing is erased. */
193
+ export function withdrawPolicy(policy, actor, reason, at) {
194
+ requireHuman(actor);
195
+ if (policy.state !== "active_advisory")
196
+ throw new Error(`policy ${policy.id} is ${policy.state}; only active advisory policy can be withdrawn to proposed`);
197
+ return {
198
+ ...policy,
199
+ revision: policy.revision + 1,
200
+ state: "proposed",
201
+ authority: null,
202
+ updated_at: at,
203
+ audit: [...policy.audit, { action: "withdrawn", actor_kind: "human", actor, at, reason, proof: policy.proof }],
204
+ };
205
+ }
206
+ /** Permanent retirement: the rule is deliberately DONE — it stops surfacing
207
+ * anywhere (active rules, escalations) but its full history and audit trail stay
208
+ * (supersede-never-erase, HC-08). Closes the valid-time window. */
209
+ export function retirePolicy(policy, actor, reason, at) {
210
+ requireHuman(actor);
211
+ if (policy.state !== "active_advisory" && policy.state !== "active_blocking" && policy.state !== "proposed") {
212
+ throw new Error(`policy ${policy.id} is ${policy.state}; only an active or proposed policy can be retired`);
213
+ }
214
+ return {
215
+ ...policy,
216
+ revision: policy.revision + 1,
217
+ state: "retired",
218
+ authority: null,
219
+ valid_to: at,
220
+ updated_at: at,
221
+ audit: [...policy.audit, { action: "retired", actor_kind: "human", actor, at, reason, proof: policy.proof }],
222
+ };
223
+ }
189
224
  //# sourceMappingURL=lifecycle.js.map
@@ -0,0 +1,78 @@
1
+ /** Rewrite the file part of a `symbol:<file>:<name>` selector when it EXACTLY
2
+ * matches a renamed path; every other selector form passes through unchanged. */
3
+ function repairSelector(raw, map) {
4
+ if (!raw.startsWith("symbol:"))
5
+ return raw;
6
+ const target = raw.slice("symbol:".length);
7
+ const split = target.lastIndexOf(":");
8
+ if (split <= 0)
9
+ return raw; // bare symbol name — no file identity to heal
10
+ const file = target.slice(0, split);
11
+ const to = map.get(file);
12
+ return to ? `symbol:${to}:${target.slice(split + 1)}` : raw;
13
+ }
14
+ function isExactPath(entry) {
15
+ return !/[*?[\]{}]/.test(entry);
16
+ }
17
+ /** Live policies only — a superseded/retired/rejected policy's history stays as
18
+ * written. Returns the rewrites; empty when the renames touch nothing exactly. */
19
+ export function planPolicyRepair(renames, policies) {
20
+ const map = new Map(renames.map((r) => [r.before, r.after]));
21
+ const rewrites = [];
22
+ if (!map.size)
23
+ return rewrites;
24
+ for (const p of policies) {
25
+ if (p.state === "superseded" || p.state === "retired" || p.state === "rejected")
26
+ continue;
27
+ for (const path of p.scope.paths) {
28
+ const to = isExactPath(path) ? map.get(path) : undefined;
29
+ if (to)
30
+ rewrites.push({ id: p.id, field: "scope.paths", from: path, to });
31
+ }
32
+ if (p.assertion.kind !== "executable-behavior") {
33
+ const selectors = p.assertion.kind === "exists"
34
+ ? [p.assertion.subject.selector]
35
+ : [p.assertion.subject.selector, p.assertion.object.selector];
36
+ for (const raw of selectors) {
37
+ const healed = repairSelector(raw, map);
38
+ if (healed !== raw)
39
+ rewrites.push({ id: p.id, field: "assertion.selector", from: raw, to: healed });
40
+ }
41
+ }
42
+ }
43
+ return rewrites;
44
+ }
45
+ /** Apply a policy's rewrites (pure): revision+1, system-actor audit, updated_at.
46
+ * Returns the original reference when nothing in the plan touches this policy. */
47
+ export function repairPolicySpec(policy, rewrites, at) {
48
+ const mine = rewrites.filter((r) => r.id === policy.id);
49
+ if (!mine.length)
50
+ return policy;
51
+ const subPath = (value) => mine.find((r) => r.field === "scope.paths" && r.from === value)?.to ?? value;
52
+ const subSelector = (value) => mine.find((r) => r.field === "assertion.selector" && r.from === value)?.to ?? value;
53
+ const assertion = policy.assertion.kind === "executable-behavior"
54
+ ? policy.assertion
55
+ : policy.assertion.kind === "exists"
56
+ ? { ...policy.assertion, subject: { selector: subSelector(policy.assertion.subject.selector) } }
57
+ : {
58
+ ...policy.assertion,
59
+ subject: { selector: subSelector(policy.assertion.subject.selector) },
60
+ object: { selector: subSelector(policy.assertion.object.selector) },
61
+ };
62
+ return {
63
+ ...policy,
64
+ revision: policy.revision + 1,
65
+ scope: { ...policy.scope, paths: policy.scope.paths.map(subPath) },
66
+ assertion,
67
+ updated_at: at,
68
+ audit: [...policy.audit, {
69
+ action: "repaired",
70
+ actor_kind: "system",
71
+ actor: "system:repair",
72
+ at,
73
+ reason: mine.map((r) => `${r.field}: ${r.from} -> ${r.to}`).join("; "),
74
+ proof: policy.proof,
75
+ }],
76
+ };
77
+ }
78
+ //# sourceMappingURL=repairPolicies.js.map
@@ -182,7 +182,7 @@ export const PolicyAssertionSchema = z.discriminatedUnion("kind", [
182
182
  ExecutableBehaviorAssertionSchema,
183
183
  ]);
184
184
  export const PolicyAuditEventSchema = z.object({
185
- action: z.enum(["compiled", "enriched", "linked_exception", "proved", "approved_advisory", "approved_blocking", "demoted", "retired", "rejected"]),
185
+ action: z.enum(["compiled", "enriched", "linked_exception", "proved", "approved_advisory", "approved_blocking", "demoted", "withdrawn", "retired", "rejected", "repaired"]),
186
186
  actor_kind: z.enum(["system", "human"]),
187
187
  actor: z.string().min(1),
188
188
  at: z.string().datetime({ offset: true }),
@@ -4,7 +4,7 @@ import { canonicalHash, canonicalJson } from "./canonical.js";
4
4
  import { shortHash } from "../core/ids.js";
5
5
  import { compileDecisionPolicy } from "./compiler.js";
6
6
  import { evaluatePolicy, policyBlocks, policyIsActive } from "./evaluator.js";
7
- import { approvePolicy, blockingProofError, demotePolicy, linkPolicyException, proposeProvedPolicy } from "./lifecycle.js";
7
+ import { approvePolicy, blockingProofError, demotePolicy, linkPolicyException, proposeProvedPolicy, retirePolicy, withdrawPolicy } from "./lifecycle.js";
8
8
  import { provePolicy } from "./proof.js";
9
9
  import { PolicyRepository } from "./repository.js";
10
10
  import { bootstrapPolicies } from "./bootstrap.js";
@@ -31,7 +31,7 @@ import { evaluateExecutableBehaviorPolicy } from "./behaviorEvaluator.js";
31
31
  import { executeG2OperationalDrill } from "./g2Drills.js";
32
32
  import { G3_REQUIRED_EXPERIMENTS, G3EvidenceRepository, compileExperimentPreregistration, compileG3Plan, compileProofReviewMeasurement, scoreG3Readiness, } from "./g3.js";
33
33
  import { executeG3AdapterConformance, g3ConformanceSourceHash } from "./g3Conformance.js";
34
- import { ExperimentRepository, assignmentTreatment, buildExperimentReport, compileExperimentCaseBank, compileExperimentFollowup, compileExperimentOutcome, compileExperimentReviewStart, compileExperimentRun, compileExperimentStop, currentExperimentOutcomes, normalizedEditDistance, } from "./experiment.js";
34
+ import { ExperimentRepository, assignmentTreatment, buildExperimentReport, compileExperimentCaseBank, compileExperimentFollowup, compileExperimentOutcome, compileExperimentReviewStart, compileExperimentRun, compileExperimentStop, compileExp03ReviewResponse, currentExperimentOutcomes, normalizedEditDistance, } from "./experiment.js";
35
35
  import { executeExp01Assignment } from "./experimentRunner.js";
36
36
  function relationSummary(policy) {
37
37
  return {
@@ -492,6 +492,17 @@ export class ConstitutionService {
492
492
  const bank = this.experimentRepository.listCaseBanks().find((item) => item.id === run.case_bank_id);
493
493
  if (!bank)
494
494
  throw new Error(`run ${run.id} is missing exact case bank ${run.case_bank_id}`);
495
+ // Single-operator mitigation (expreg_9c9617cd13, revision >= 3): at least 48 hours
496
+ // must separate the case-bank lock from the FIRST review start — enforced, not
497
+ // merely auditable, so a violation is impossible rather than post-hoc visible.
498
+ const prereg = this.g3Repository.listExperiments().find((item) => item.id === run.preregistration_id);
499
+ if (prereg && prereg.revision >= 3) {
500
+ const elapsed = Date.parse(opts.now ?? new Date().toISOString()) - Date.parse(bank.locked_at);
501
+ const hasStart = this.experimentRepository.listReviewStarts().some((item) => item.run_id === run.id);
502
+ if (!hasStart && elapsed < 48 * 3_600_000) {
503
+ throw new Error(`the preregistered single-operator protocol requires at least 48 hours between the case-bank lock (${bank.locked_at}) and the first review start; ${Math.ceil((48 * 3_600_000 - elapsed) / 3_600_000)}h remain`);
504
+ }
505
+ }
495
506
  const current = new Set(currentExperimentOutcomes(this.experimentRepository.listOutcomes()).filter((item) => item.run_id === run.id).map((item) => item.assignment_id));
496
507
  const starts = this.experimentRepository.listReviewStarts().filter((item) => item.run_id === run.id);
497
508
  const existing = starts.find((item) => item.reviewer === reviewer && !current.has(item.assignment_id));
@@ -509,25 +520,63 @@ export class ConstitutionService {
509
520
  const start = existing ?? this.experimentRepository.putReviewStart(compileExperimentReviewStart(run, assignment, reviewer, opts));
510
521
  return { start, assignment, treatment: assignmentTreatment(bank, run, assignment) };
511
522
  }
512
- submitExperimentReview(runId, assignmentId, input, opts = {}) {
523
+ /** Resolve an EXP-03 run/assignment/case triple (the shared lookup for both
524
+ * review-submission dialects). */
525
+ resolveExp03Assignment(runId, assignmentId) {
513
526
  const run = this.experimentRun(runId);
514
527
  if (run.experiment !== "EXP-03")
515
528
  throw new Error("human review submission is available only for EXP-03");
516
- const start = this.experimentRepository.listReviewStarts().find((item) => item.run_id === run.id && item.assignment_id === assignmentId);
529
+ const assignment = run.assignments.find((entry) => entry.id === assignmentId);
530
+ const bank = this.experimentRepository.listCaseBanks().find((entry) => entry.id === run.case_bank_id);
531
+ const item = bank?.cases.find((candidate) => candidate.id === assignment?.case_id);
532
+ if (!assignment || !bank || !item || !("compiler_candidate" in item))
533
+ throw new Error("review submission cannot resolve the exact assigned case and treatment");
534
+ return { run, assignment, item };
535
+ }
536
+ /** Raw metrics-vocabulary submission — the ORIGINAL revision-1 contract, kept
537
+ * byte-identical for the append-only pilot. Revision-2 cases are refused here:
538
+ * they must go through the standardized plain-language template (dec_0be4fd3717)
539
+ * so the presented question and the recorded outcome cannot drift apart. */
540
+ submitExperimentReview(runId, assignmentId, input, opts = {}) {
541
+ const { run, assignment, item } = this.resolveExp03Assignment(runId, assignmentId);
542
+ // Revision-2 reviewer ANSWERS travel only through the template; the raw path
543
+ // stays open solely for the non-answer terminal states the preregistration
544
+ // requires retained (abandoned/timeout bookkeeping — expreg_ba6aef4ecd).
545
+ if (item.required_relationship && input.decision !== "abandoned" && input.decision !== "timeout") {
546
+ throw new Error(`assignment ${assignmentId} is a revision-2 plain-language case; submit it with the standardized response template (hunch experiment respond)`);
547
+ }
548
+ return this.completeExp03Review(run, assignment, item, input, opts);
549
+ }
550
+ /** Revision-2 standardized submission: the reviewer answers in the SAME
551
+ * plain-language vocabulary the treatment presented (choice + rule + one
552
+ * sentence); the deterministic mapper translates it into canonical metrics.
553
+ * Revision-1 cases are refused (the mapper throws) — they keep the raw path. */
554
+ respondExperimentReview(runId, assignmentId, input, opts = {}) {
555
+ const { run, assignment, item } = this.resolveExp03Assignment(runId, assignmentId);
556
+ const mapped = compileExp03ReviewResponse(item, assignment.arm, input);
557
+ return this.completeExp03Review(run, assignment, item, {
558
+ reviewer: input.reviewer,
559
+ ...mapped,
560
+ confirmed_private_leak: !!input.confirmed_private_leak,
561
+ data_loss_or_corruption: !!input.data_loss_or_corruption,
562
+ unsafe_evaluator_behavior: !!input.unsafe_evaluator_behavior,
563
+ reason: input.reason,
564
+ }, opts);
565
+ }
566
+ /** The shared review-completion core: machine-owned timing, append-only dedup,
567
+ * derived edit distance, and the outcome write. Both dialects land here. */
568
+ completeExp03Review(run, assignment, item, input, opts = {}) {
569
+ const assignmentId = assignment.id;
570
+ const start = this.experimentRepository.listReviewStarts().find((entry) => entry.run_id === run.id && entry.assignment_id === assignmentId);
517
571
  if (!start || start.reviewer !== input.reviewer)
518
572
  throw new Error("review submission must bind the machine-recorded start and exact reviewer");
519
573
  const recordedAt = opts.now ?? new Date().toISOString();
520
574
  const duration = Date.parse(recordedAt) - Date.parse(start.started_at);
521
575
  if (!Number.isFinite(duration) || duration < 1)
522
576
  throw new Error("review completion must occur after the machine-recorded start");
523
- const current = currentExperimentOutcomes(this.experimentRepository.listOutcomes()).find((item) => item.run_id === run.id && item.assignment_id === assignmentId);
577
+ const current = currentExperimentOutcomes(this.experimentRepository.listOutcomes()).find((entry) => entry.run_id === run.id && entry.assignment_id === assignmentId);
524
578
  if (current)
525
579
  throw new Error(`assignment ${assignmentId} already has current outcome ${current.id}; use an explicit append-only correction workflow`);
526
- const assignment = run.assignments.find((item) => item.id === assignmentId);
527
- const bank = this.experimentRepository.listCaseBanks().find((item) => item.id === run.case_bank_id);
528
- const item = bank?.cases.find((candidate) => candidate.id === assignment?.case_id);
529
- if (!assignment || !bank || !item || !("compiler_candidate" in item))
530
- throw new Error("review submission cannot resolve the exact assigned case and treatment");
531
580
  const accepted = input.decision.startsWith("accepted");
532
581
  const result = input.result?.trim() || null;
533
582
  if (accepted !== (result !== null))
@@ -1024,6 +1073,19 @@ export class ConstitutionService {
1024
1073
  const demoted = demotePolicy(policy, actor, reason, opts.now ?? new Date().toISOString());
1025
1074
  return this.repository.putPolicy(demoted);
1026
1075
  }
1076
+ /** Targeted advisory withdrawal (§57): active_advisory → proposed; authority
1077
+ * returns to the human pool and the policy re-enters the escalation loop. */
1078
+ withdraw(id, actor, reason, opts = {}) {
1079
+ const policy = this.get(id);
1080
+ const withdrawn = withdrawPolicy(policy, actor, reason, opts.now ?? new Date().toISOString());
1081
+ return this.repository.putPolicy(withdrawn);
1082
+ }
1083
+ /** Permanent retirement: active/proposed → retired; window closed, history kept. */
1084
+ retire(id, actor, reason, opts = {}) {
1085
+ const policy = this.get(id);
1086
+ const retired = retirePolicy(policy, actor, reason, opts.now ?? new Date().toISOString());
1087
+ return this.repository.putPolicy(retired);
1088
+ }
1027
1089
  linkException(id, parentId, actor, reason, opts = {}) {
1028
1090
  const child = this.get(id);
1029
1091
  const parent = this.get(parentId);
@@ -0,0 +1,65 @@
1
+ import { topicCollisions } from "./topics.js";
2
+ /** The decisions a human must make NOW, to be asked INLINE. Empty in a healthy graph. */
3
+ export function pendingEscalations(decisions) {
4
+ const out = [];
5
+ for (const [topic, decs] of topicCollisions(decisions)) {
6
+ out.push({
7
+ kind: "topic-conflict",
8
+ topic,
9
+ decisionIds: decs.map((d) => d.id),
10
+ question: `Topic "${topic}" has ${decs.length} live decisions — which one is current?`,
11
+ detail: decs.map((d) => `${d.id} — "${d.title}"`).join(" · "),
12
+ resolution: `supersede the others: re-record the chosen one with supersedes:<other-id>, or split the topic.`,
13
+ });
14
+ }
15
+ return out;
16
+ }
17
+ /** The Constitution's genuine human moments (§59.5.3), framed as inline questions:
18
+ * a candidate awaiting review, and a proposed policy whose next step (prove, or
19
+ * accept/reject) is a human call. Machine conclusions never appear here as
20
+ * approvals — every entry is a QUESTION with its explicit resolution verb. */
21
+ export function policyEscalations(policies) {
22
+ const out = [];
23
+ const clip = (s) => (s.length > 90 ? s.slice(0, 89).trimEnd() + "…" : s);
24
+ for (const p of policies) {
25
+ // An auto-repaired policy asks FIRST (and only once): its bindings moved, so
26
+ // its proof is stale by construction — the human moment is "re-prove it".
27
+ if (p.last_action === "repaired" && (p.state === "proposed" || p.state === "active_advisory" || p.state === "active_blocking")) {
28
+ out.push({
29
+ kind: "policy-repaired",
30
+ topic: p.id,
31
+ decisionIds: [p.id],
32
+ question: `Rule "${clip(p.statement)}" (${p.id}) was auto-repaired after a rename — its proof is stale; re-prove it?`,
33
+ detail: `state ${p.state} · last action repaired · ${p.proof ? `proof ${p.proof} (stale)` : "no proof"}`,
34
+ resolution: `hunch policy prove ${p.id} — blocking stays fail-safe until the fresh proof lands`,
35
+ });
36
+ continue;
37
+ }
38
+ if (p.state === "compiled" || p.state === "validating") {
39
+ out.push({
40
+ kind: "policy-candidate",
41
+ topic: p.id,
42
+ decisionIds: [p.id],
43
+ question: `Candidate rule "${clip(p.statement)}" (${p.id}) awaits your review — keep it moving or reject it?`,
44
+ detail: `state ${p.state} · authority none · not yet proved`,
45
+ resolution: `hunch policy prove ${p.id} — then accept/reject; or hunch policy reject ${p.id} --reason "..."`,
46
+ });
47
+ }
48
+ else if (p.state === "proposed") {
49
+ out.push({
50
+ kind: "policy-proposal",
51
+ topic: p.id,
52
+ decisionIds: [p.id],
53
+ question: p.proof
54
+ ? `Proposed rule "${clip(p.statement)}" (${p.id}) carries its proof — activate it (advisory/blocking) or reject it?`
55
+ : `Proposed rule "${clip(p.statement)}" (${p.id}) has no current proof — prove it, then decide.`,
56
+ detail: `state proposed · ${p.proof ? `proof ${p.proof}` : "no proof"} · authority none`,
57
+ resolution: p.proof
58
+ ? `inspect: hunch policy card ${p.id} — then hunch policy accept ${p.id} --advisory|--blocking --actor human:<you>, or reject`
59
+ : `hunch policy prove ${p.id}`,
60
+ });
61
+ }
62
+ }
63
+ return out;
64
+ }
65
+ //# sourceMappingURL=escalations.js.map
@@ -0,0 +1,69 @@
1
+ /**
2
+ * The memory-move timeline — the data behind `hunch log` and the VS Code "Hunch
3
+ * Source Control" view. Every commit that touched `.hunch/` is one MOVE (a capture,
4
+ * adoption, supersession, or prune), classified deterministically from git's own
5
+ * name-status. Pure parsing — no LLM, no judgment — so the panel shows exactly what
6
+ * git recorded and each move maps back to a real, revertable commit.
7
+ *
8
+ * The parser is split from the git shell-out (extractors/git.gitMemoryLog) so it is
9
+ * unit-testable with canned `git log` output.
10
+ */
11
+ /** The record-header separator we ask `git log --format` to emit, so header lines
12
+ * are unambiguous against the name-status lines that follow each commit. */
13
+ export const MEMLOG_HEADER = "@@@";
14
+ /** The `--format` string that pairs with {@link parseMemoryLog}. */
15
+ export const MEMLOG_FORMAT = `${MEMLOG_HEADER}%H\t%h\t%cI\t%s`;
16
+ const ID_RE = /\/((?:dec|bug|con|cmp|pol)_[0-9a-f]+)\.json$/;
17
+ /** Parse `git log <MEMLOG_FORMAT> --name-status -- .hunch/` output into classified
18
+ * moves, newest first (git's order). */
19
+ export function parseMemoryLog(raw) {
20
+ const moves = [];
21
+ let cur = null;
22
+ for (const line of raw.split("\n")) {
23
+ if (line.startsWith(MEMLOG_HEADER)) {
24
+ if (cur)
25
+ moves.push(classify(cur));
26
+ const f = line.slice(MEMLOG_HEADER.length).split("\t");
27
+ cur = {
28
+ sha: f[0] ?? "", shortSha: f[1] ?? "", date: f[2] ?? "", subject: f.slice(3).join("\t"),
29
+ kind: "edit", decisionIds: [], otherIds: [], added: 0, modified: 0, deleted: 0, files: [],
30
+ };
31
+ continue;
32
+ }
33
+ if (!cur || !line.trim())
34
+ continue;
35
+ // name-status: "A\tpath", "M\tpath", "D\tpath", or rename "R100\told\tnew".
36
+ const parts = line.split("\t");
37
+ const status = parts[0]?.[0];
38
+ const path = parts[parts.length - 1];
39
+ if (!path || !path.startsWith(".hunch/"))
40
+ continue;
41
+ cur.files.push(path);
42
+ if (status === "A")
43
+ cur.added++;
44
+ else if (status === "D")
45
+ cur.deleted++;
46
+ else
47
+ cur.modified++;
48
+ const id = ID_RE.exec(path)?.[1];
49
+ if (id)
50
+ (id.startsWith("dec_") ? cur.decisionIds : cur.otherIds).push(id);
51
+ }
52
+ if (cur)
53
+ moves.push(classify(cur));
54
+ return moves;
55
+ }
56
+ /** Deterministic move kind from the subject + the add/modify/delete shape. */
57
+ function classify(m) {
58
+ const s = m.subject.toLowerCase();
59
+ m.kind =
60
+ /\brepair\b/.test(s) ? "repair"
61
+ : /\badopt/.test(s) ? "adopt"
62
+ : /supersed/.test(s) ? "supersede"
63
+ : m.deleted > 0 && m.added === 0 && m.modified === 0 ? "prune"
64
+ : m.added > 0 && m.modified === 0 && m.deleted === 0 ? "capture"
65
+ : /\bcapture\b/.test(s) ? "capture"
66
+ : "edit";
67
+ return m;
68
+ }
69
+ //# sourceMappingURL=memorylog.js.map
@@ -0,0 +1,71 @@
1
+ /** A scope entry is exact (rewritable) only when it contains no glob syntax. */
2
+ function isExactPath(entry) {
3
+ return !/[*?[\]{}]/.test(entry);
4
+ }
5
+ /** Extract the rename pairs from a commit's change records (already 1:1 by git -M). */
6
+ export function renamesOf(changes) {
7
+ return changes
8
+ .filter((c) => c.status === "renamed" && c.before && c.after && c.before !== c.after)
9
+ .map((c) => ({ before: c.before, after: c.after }));
10
+ }
11
+ /** Plan every safe rewrite. Only exact matches against a git-confirmed rename move;
12
+ * live records only (a superseded/retired record's history stays as written). */
13
+ export function planRepair(renames, decisions, constraints) {
14
+ const map = new Map(renames.map((r) => [r.before, r.after]));
15
+ const rewrites = [];
16
+ if (!map.size)
17
+ return { rewrites, records: [] };
18
+ for (const d of decisions) {
19
+ if (d.status === "superseded" || d.status === "rejected")
20
+ continue;
21
+ for (const f of d.related_files ?? []) {
22
+ const to = map.get(f);
23
+ if (to)
24
+ rewrites.push({ kind: "decisions", id: d.id, field: "related_files", from: f, to });
25
+ }
26
+ for (const tw of d.rejected_tripwires ?? []) {
27
+ for (const s of tw.scope ?? []) {
28
+ const to = isExactPath(s) ? map.get(s) : undefined;
29
+ if (to)
30
+ rewrites.push({ kind: "decisions", id: d.id, field: "tripwire.scope", from: s, to });
31
+ }
32
+ }
33
+ }
34
+ for (const c of constraints) {
35
+ if (c.status && c.status !== "active")
36
+ continue;
37
+ for (const s of c.scope ?? []) {
38
+ const to = isExactPath(s) ? map.get(s) : undefined;
39
+ if (to)
40
+ rewrites.push({ kind: "constraints", id: c.id, field: "scope", from: s, to });
41
+ }
42
+ }
43
+ return { rewrites, records: [...new Set(rewrites.map((r) => `${r.kind}:${r.id}`))] };
44
+ }
45
+ /** Apply a plan's rewrites to one decision (pure — returns the healed copy, or the
46
+ * original reference when nothing in the plan touches it). */
47
+ export function repairDecision(d, plan) {
48
+ const mine = plan.rewrites.filter((r) => r.kind === "decisions" && r.id === d.id);
49
+ if (!mine.length)
50
+ return d;
51
+ const sub = (field, value) => mine.find((r) => r.field === field && r.from === value)?.to ?? value;
52
+ return {
53
+ ...d,
54
+ related_files: (d.related_files ?? []).map((f) => sub("related_files", f)),
55
+ rejected_tripwires: (d.rejected_tripwires ?? []).map((tw) => ({
56
+ ...tw,
57
+ scope: (tw.scope ?? []).map((s) => sub("tripwire.scope", s)),
58
+ })),
59
+ };
60
+ }
61
+ /** Apply a plan's rewrites to one constraint (pure). */
62
+ export function repairConstraint(c, plan) {
63
+ const mine = plan.rewrites.filter((r) => r.kind === "constraints" && r.id === c.id);
64
+ if (!mine.length)
65
+ return c;
66
+ return {
67
+ ...c,
68
+ scope: (c.scope ?? []).map((s) => mine.find((r) => r.field === "scope" && r.from === s)?.to ?? s),
69
+ };
70
+ }
71
+ //# sourceMappingURL=repair.js.map
@@ -24,6 +24,17 @@ export function parseSynth(evidence) {
24
24
  }
25
25
  /** Grounded-ness at/above which a Critic-verified draft is a "quick yes". */
26
26
  export const READY_MIN_GROUNDED = 0.7;
27
+ /** Whether a decision is an un-vouched draft still awaiting a human — the ONLY thing
28
+ * the review path surfaces under the auto-trust model.
29
+ *
30
+ * Low confidence NO LONGER makes a draft: captured memory is trusted-advisory the
31
+ * moment it lands (status `accepted`, source `llm_draft`), so it grounds and ranks
32
+ * but never nags. Only a DELIBERATE, not-yet-human-vouched `proposed` record — an
33
+ * explicit roadmap/intent entry a human hasn't confirmed — counts as a review draft.
34
+ * (Enforcement authority is granted INLINE, not by draining a background queue.) */
35
+ export function isReviewDraft(d) {
36
+ return d.status === "proposed" && !d.provenance.source.includes("human_confirmed");
37
+ }
27
38
  /** A draft is "ready to confirm" only when the Critic actually audited it (source
28
39
  * includes "verified") AND judged it well-grounded. A high confidence number alone
29
40
  * is NOT enough — an un-audited draft always needs human eyes. */
@@ -3,6 +3,7 @@
3
3
  import { execFileSync } from "node:child_process";
4
4
  import { isAbsolute, resolve, join, basename, dirname } from "node:path";
5
5
  import { mkdirSync, rmSync, statSync, realpathSync, readFileSync } from "node:fs";
6
+ import { MEMLOG_FORMAT } from "../core/memorylog.js";
6
7
  function git(args, cwd, maxBuffer = 64 * 1024 * 1024) {
7
8
  // stdio: capture stdout, silence stderr (so "no commits yet" etc. don't leak).
8
9
  return execFileSync("git", args, {
@@ -295,6 +296,44 @@ export function commitFiles(sha, cwd) {
295
296
  const out = gitSafe(["diff-tree", "--no-commit-id", "--name-only", "-r", "--root", sha], cwd);
296
297
  return out ? out.split("\n").filter(Boolean) : [];
297
298
  }
299
+ /** Raw `git log` over `.hunch/`, paired with parseMemoryLog — the memory-move
300
+ * timeline (each commit that changed the graph). Newest first; empty on any error
301
+ * (no repo / no history), so the caller degrades to an empty timeline. */
302
+ export function gitMemoryLog(root, limit = 200) {
303
+ return gitSafe(["log", `--max-count=${limit}`, "--no-color", `--format=${MEMLOG_FORMAT}`, "--name-status", "--", ".hunch/"], root);
304
+ }
305
+ /** The diff of a single commit restricted to `.hunch/` — what one memory move
306
+ * actually changed, for the click-through popup. Empty on error. */
307
+ export function memoryMoveDiff(sha, root) {
308
+ return gitSafe(["show", "--no-color", "--format=%H%n%an%n%cI%n%s%n", sha, "--", ".hunch/"], root);
309
+ }
310
+ /** Push the current branch to its remote (the "approve-to-push" step — public
311
+ * memory rides the repo, so this is a plain branch push). Returns true on success;
312
+ * false when there is no upstream / offline / not a repo. */
313
+ export function pushCurrentBranch(root) {
314
+ try {
315
+ execFileSync("git", ["-C", root, "push"], { stdio: "ignore" });
316
+ return true;
317
+ }
318
+ catch {
319
+ return false;
320
+ }
321
+ }
322
+ /** Revert a single memory move locally (no push). Returns true on success. A
323
+ * conflicting revert is aborted so the working tree is never left half-reverted. */
324
+ export function revertMemoryMove(sha, root) {
325
+ try {
326
+ execFileSync("git", ["-C", root, "revert", "--no-edit", sha], { stdio: "ignore" });
327
+ return true;
328
+ }
329
+ catch {
330
+ try {
331
+ execFileSync("git", ["-C", root, "revert", "--abort"], { stdio: "ignore" });
332
+ }
333
+ catch { /* nothing to abort */ }
334
+ return false;
335
+ }
336
+ }
298
337
  /** Full metadata + changed files for a commit. */
299
338
  export function commitMeta(sha, cwd) {
300
339
  const raw = gitSafe(["show", "-s", "--format=%H%x1f%h%x1f%s%x1f%b%x1f%an%x1f%aI", sha], cwd);