@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.
- package/README.md +28 -0
- package/dist/cli/index.js +353 -9
- package/dist/constitution/experiment.js +60 -1
- package/dist/constitution/g3Conformance.js +26 -9
- package/dist/constitution/lifecycle.js +35 -0
- package/dist/constitution/repairPolicies.js +78 -0
- package/dist/constitution/schema.js +1 -1
- package/dist/constitution/service.js +72 -10
- package/dist/core/escalations.js +65 -0
- package/dist/core/memorylog.js +69 -0
- package/dist/core/repair.js +71 -0
- package/dist/core/reviewqueue.js +11 -0
- package/dist/extractors/git.js +39 -0
- package/dist/mcp/server.js +33 -1
- package/dist/synthesis/synthesize.js +8 -1
- package/dist/wiki/graph.js +301 -0
- package/dist/wiki/wiki.js +31 -3
- package/package.json +1 -1
|
@@ -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
|
|
21
|
-
* Unsupported
|
|
22
|
-
* silently treated as equivalent to
|
|
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
|
-
|
|
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:
|
|
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(
|
|
83
|
+
`--test-name-pattern=${exactNodeTestPattern(selectedTest.name)}`,
|
|
67
84
|
`--test-reporter=${pathToFileURL(reporter).href}`,
|
|
68
85
|
"--test-reporter-destination=stdout",
|
|
69
|
-
|
|
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 ===
|
|
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: { ...
|
|
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
|
-
|
|
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
|
|
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((
|
|
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
|
package/dist/core/reviewqueue.js
CHANGED
|
@@ -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. */
|
package/dist/extractors/git.js
CHANGED
|
@@ -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);
|