@davesheffer/hunch 1.10.5 → 1.10.7
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/dist/cli/index.js +49 -12
- package/dist/constitution/g2BehaviorCandidates.js +34 -43
- package/dist/constitution/service.js +15 -5
- package/dist/core/checkreport.js +75 -0
- package/dist/core/drift.js +14 -0
- package/dist/core/premises.js +72 -0
- package/dist/core/topics.js +35 -3
- package/dist/core/types.js +22 -0
- package/dist/mcp/server.js +72 -16
- package/dist/store/hunchStore.js +36 -5
- package/package.json +3 -1
- package/server.json +30 -0
- package/tooling/competitive-watch.mjs +54 -21
package/dist/cli/index.js
CHANGED
|
@@ -39,7 +39,7 @@ import { writeTeamConfig, ensureTeamOverlay, readTeamConfig, safeGitUrl, safeTea
|
|
|
39
39
|
import { runbookId, decisionId } from "../core/ids.js";
|
|
40
40
|
import { deriveForbids, effectiveForbids } from "../core/constraintmatch.js";
|
|
41
41
|
import { extractInlineIntent } from "../extractors/comments.js";
|
|
42
|
-
import { renderText, renderMarkdown, renderImpact, reportFailsStrict } from "../core/checkreport.js";
|
|
42
|
+
import { renderText, renderMarkdown, renderSarif, renderImpact, reportFailsStrict } from "../core/checkreport.js";
|
|
43
43
|
import { partitionReview, isReviewDraft, READY_MIN_GROUNDED } from "../core/reviewqueue.js";
|
|
44
44
|
import { installPostCommitHook, installPreCommitHook } from "../integrations/hooks.js";
|
|
45
45
|
import { ensureSharedOverlayPointer } from "../integrations/worktree.js";
|
|
@@ -70,6 +70,7 @@ import { generateWiki, wikiStatus, wikiPrompt, publicHome, privateHome, readWiki
|
|
|
70
70
|
import { adoptProsePrompt } from "../wiki/adopt.js";
|
|
71
71
|
import { topicCollisions, renderGrounding, isInForce } from "../core/topics.js";
|
|
72
72
|
import { pendingEscalations, policyEscalations } from "../core/escalations.js";
|
|
73
|
+
import { premiseEscalations } from "../core/premises.js";
|
|
73
74
|
import { parseDocAnchors, renderDocGrounding } from "../core/docanchors.js";
|
|
74
75
|
import { compareCandidates } from "../core/compare.js";
|
|
75
76
|
import { checkConformance } from "../core/conformance.js";
|
|
@@ -3193,7 +3194,7 @@ program
|
|
|
3193
3194
|
.option("--commit <sha>", "check a specific commit's files")
|
|
3194
3195
|
.option("--base <ref>", "check a PR/branch: files changed vs <ref> (e.g. origin/main) — for CI")
|
|
3195
3196
|
.option("--strict", "exit non-zero ONLY on a direct, high-confidence, non-stale blocking invariant (near/stale/low-confidence stay advisory)")
|
|
3196
|
-
.option("--format <fmt>", "output: text (default) | markdown (a PR comment)", "text")
|
|
3197
|
+
.option("--format <fmt>", "output: text (default) | markdown (a PR comment) | sarif (SARIF 2.1.0 for code scanning)", "text")
|
|
3197
3198
|
.option("--blast", "also print the dependency blast radius of the changed files")
|
|
3198
3199
|
.option("--public-only", "exclude the private overlay (HUNCH_PRIVATE_DIR) from the report — use for any output that may be posted publicly (the CI PR comment passes this)")
|
|
3199
3200
|
.action((opts) => {
|
|
@@ -3201,6 +3202,9 @@ program
|
|
|
3201
3202
|
if (sources.length > 1)
|
|
3202
3203
|
return fail(`pick one of --staged / --working / --commit / --base (got ${sources.join(", ")})`);
|
|
3203
3204
|
const markdown = opts.format === "markdown";
|
|
3205
|
+
// SARIF collects every gate family into ONE JSON document at the end, so all
|
|
3206
|
+
// interleaved section prints are suppressed for this format.
|
|
3207
|
+
const sarif = opts.format === "sarif";
|
|
3204
3208
|
const emptyReport = { fileCount: 0, strict: !!opts.strict, direct: [], near: [], regressions: [], vetoes: [], redundant: [], strictBlockers: 0, regBlocking: 0, vetoBlocking: 0 };
|
|
3205
3209
|
const { store, root, teamPullStatus } = storeFor({ requireFreshTeamMemory: !!opts.strict && !opts.publicOnly });
|
|
3206
3210
|
const teamFreshnessFailure = teamPullStatus !== null
|
|
@@ -3246,7 +3250,7 @@ program
|
|
|
3246
3250
|
? sourceGraphSnapshot(root, graphScan.source, graphScan.symbols, graphScan.edges, graphScan.components)
|
|
3247
3251
|
: undefined;
|
|
3248
3252
|
const semanticIssues = graphScan?.issues ?? [];
|
|
3249
|
-
if (semanticIssues.length) {
|
|
3253
|
+
if (semanticIssues.length && !sarif) {
|
|
3250
3254
|
if (markdown) {
|
|
3251
3255
|
console.log(`\n### ‼ Incomplete semantic source scan — ${semanticIssues.length} file(s) rejected\n`);
|
|
3252
3256
|
for (const issue of semanticIssues)
|
|
@@ -3281,7 +3285,7 @@ program
|
|
|
3281
3285
|
publicOnly: !!opts.publicOnly,
|
|
3282
3286
|
})
|
|
3283
3287
|
: emptyReport;
|
|
3284
|
-
if (opts.blast && !markdown && files.length) {
|
|
3288
|
+
if (opts.blast && !markdown && !sarif && files.length) {
|
|
3285
3289
|
console.log(`Blast radius of ${files.length} changed file(s):`);
|
|
3286
3290
|
for (const f of files) {
|
|
3287
3291
|
const b = store.blastRadiusFiles(f);
|
|
@@ -3290,9 +3294,11 @@ program
|
|
|
3290
3294
|
}
|
|
3291
3295
|
console.log("");
|
|
3292
3296
|
}
|
|
3293
|
-
|
|
3294
|
-
|
|
3295
|
-
|
|
3297
|
+
if (!sarif) {
|
|
3298
|
+
console.log(files.length
|
|
3299
|
+
? (markdown ? renderMarkdown(report) : renderText(report))
|
|
3300
|
+
: (markdown ? renderMarkdown(emptyReport) : "No changed files to check."));
|
|
3301
|
+
}
|
|
3296
3302
|
// ARCHITECTURAL CONFORMANCE: does the RESULTING code still satisfy every recorded
|
|
3297
3303
|
// architectural invariant? This is graph-reachability, not a diff — so it catches semantic
|
|
3298
3304
|
// violations a pattern-matcher / SAST can't express (a controller that now reaches the DB
|
|
@@ -3305,7 +3311,7 @@ program
|
|
|
3305
3311
|
graph: { symbols: graphScan.symbols, edges: graphScan.edges },
|
|
3306
3312
|
}).filter((c) => !c.satisfied)
|
|
3307
3313
|
: [];
|
|
3308
|
-
if (confViolations.length) {
|
|
3314
|
+
if (confViolations.length && !sarif) {
|
|
3309
3315
|
if (markdown) {
|
|
3310
3316
|
console.log(`\n### ⛔ Architectural conformance — ${confViolations.length} invariant(s) violated\n`);
|
|
3311
3317
|
for (const c of confViolations) {
|
|
@@ -3339,7 +3345,7 @@ program
|
|
|
3339
3345
|
const policyResults = hasActivePolicies
|
|
3340
3346
|
? constitution.evaluate({ activeOnly: true, publicOnly: !!opts.publicOnly, behavior, snapshot: staticSnapshot })
|
|
3341
3347
|
: [];
|
|
3342
|
-
if (policyResults.length) {
|
|
3348
|
+
if (policyResults.length && !sarif) {
|
|
3343
3349
|
if (markdown) {
|
|
3344
3350
|
console.log(`\n### 📜 Hunch Constitution — ${policyResults.length} policy receipt(s)\n`);
|
|
3345
3351
|
for (const r of policyResults) {
|
|
@@ -3354,6 +3360,24 @@ program
|
|
|
3354
3360
|
renderPolicyEvaluations(policyResults).forEach((line) => console.log(line));
|
|
3355
3361
|
}
|
|
3356
3362
|
}
|
|
3363
|
+
if (sarif) {
|
|
3364
|
+
const extras = {
|
|
3365
|
+
conformance: confViolations.map((c) => {
|
|
3366
|
+
const dec = store.json.get("decisions", c.decision);
|
|
3367
|
+
return { decision: c.decision, title: c.title, detail: c.detail, ...(dec?.context ? { why: dec.context } : {}), ...(dec?.caused_by_bug ? { bug: dec.caused_by_bug } : {}) };
|
|
3368
|
+
}),
|
|
3369
|
+
policies: policyResults.map((r) => ({
|
|
3370
|
+
id: r.policy.id,
|
|
3371
|
+
result: r.evaluation.result,
|
|
3372
|
+
explanation: r.evaluation.explanation,
|
|
3373
|
+
blocks: r.blocks,
|
|
3374
|
+
receipt: r.evaluation.deterministic_hash,
|
|
3375
|
+
...(r.gate_error ? { gateError: r.gate_error } : {}),
|
|
3376
|
+
})),
|
|
3377
|
+
scanIssues: semanticIssues.map((s) => ({ path: s.path, detail: s.detail, code: s.code })),
|
|
3378
|
+
};
|
|
3379
|
+
console.log(renderSarif(report, HUNCH_VERSION, extras));
|
|
3380
|
+
}
|
|
3357
3381
|
const constitutionFails = policyResults.some((r) => r.blocks || r.strict_error);
|
|
3358
3382
|
if (teamFreshnessFailure)
|
|
3359
3383
|
console.error(`error: ${teamFreshnessFailure}`);
|
|
@@ -3781,7 +3805,12 @@ program
|
|
|
3781
3805
|
// travel further than a terminal. Union view: `hunch now --private`.
|
|
3782
3806
|
const s = new HunchStore(paths);
|
|
3783
3807
|
try {
|
|
3784
|
-
|
|
3808
|
+
// Mode-aware: in unified ("shared") mode the public `.hunch/` is only a routing
|
|
3809
|
+
// shell, so loading it alone makes session-start orientation — recent work,
|
|
3810
|
+
// roadmap, escalations — report an empty graph for a repo whose memory is all
|
|
3811
|
+
// in the overlay. Private mode stays public-only: session transcripts travel
|
|
3812
|
+
// further than a terminal.
|
|
3813
|
+
const decisions = s.advisoryRecs("decisions");
|
|
3785
3814
|
const { recent, roadmap, pendingReview } = nowData(decisions, 3);
|
|
3786
3815
|
if (!decisions.length) {
|
|
3787
3816
|
// Fresh graph: nothing to orient on, but the operating loop still ships.
|
|
@@ -3802,6 +3831,7 @@ program
|
|
|
3802
3831
|
if (pendingReview > 0)
|
|
3803
3832
|
L.push(`${pendingReview} legacy un-vouched draft(s) — adopt as advisory memory with \`hunch adopt-drafts\` (new captures auto-trust).`);
|
|
3804
3833
|
const escalations = pendingEscalations(decisions);
|
|
3834
|
+
escalations.push(...premiseEscalations(decisions, { now: new Date().toISOString(), exists: (p) => existsSync(join(paths.root, p)) }));
|
|
3805
3835
|
try {
|
|
3806
3836
|
// Constitution human moments ride the same line; a broken policy store
|
|
3807
3837
|
// must never take session-start orientation down (fail open). Public
|
|
@@ -3912,7 +3942,10 @@ program
|
|
|
3912
3942
|
}
|
|
3913
3943
|
// Decision-grounding (§3): for topic-anchored decisions governing this file, state
|
|
3914
3944
|
// the current decision assertively (graph over any stale doc) + what it rejected.
|
|
3915
|
-
|
|
3945
|
+
// The FULL decision set is passed alongside the file slice so a topic contested
|
|
3946
|
+
// somewhere else in the graph is reported as unresolved instead of being asserted
|
|
3947
|
+
// as settled — the collision's two sides often live in different files.
|
|
3948
|
+
const grounding = renderGrounding(ctx.decisions, store.recs("decisions"));
|
|
3916
3949
|
if (grounding)
|
|
3917
3950
|
text += `\n\n${grounding}`;
|
|
3918
3951
|
if (docGround)
|
|
@@ -4370,7 +4403,11 @@ program
|
|
|
4370
4403
|
.action(async (opts) => {
|
|
4371
4404
|
const { store, root } = storeFor();
|
|
4372
4405
|
try {
|
|
4373
|
-
const
|
|
4406
|
+
const decisionsForEsc = store.recs("decisions");
|
|
4407
|
+
const items = pendingEscalations(decisionsForEsc);
|
|
4408
|
+
// Premise decay rides the same inline surface: a decision whose recorded
|
|
4409
|
+
// reason died is a QUESTION for the human — authority never changes here.
|
|
4410
|
+
items.push(...premiseEscalations(decisionsForEsc, { now: new Date().toISOString(), exists: (p) => existsSync(join(root, p)) }));
|
|
4374
4411
|
// Constitution moments ride the same inline surface (§59.5.3) — never a queue.
|
|
4375
4412
|
// Fail open: a broken policy store must not take the memory escalations down.
|
|
4376
4413
|
try {
|
|
@@ -567,22 +567,27 @@ function runLeg(root, session, hooks, env, candidate, commit, expected, source,
|
|
|
567
567
|
const testFile = join(checkout, candidate.test.file);
|
|
568
568
|
mkdirSync(dirname(testFile), { recursive: true });
|
|
569
569
|
writeFileSync(testFile, source);
|
|
570
|
-
|
|
570
|
+
// ONE scoring mode, always reporter-based. The exit code of a `node --test` run is a
|
|
571
|
+
// property of the whole FILE, not of the selected test: a failure in an unrelated
|
|
572
|
+
// sibling flips it, so a proxy-grounded candidate could record behavior_confirmed (or
|
|
573
|
+
// its negation) from evidence that has nothing to do with the candidate. The reporter
|
|
574
|
+
// path already existed and is the unconditional standard in every other evidence
|
|
575
|
+
// surface here (behaviorEvaluator, g2Drills, g3Conformance) — this was the one holdout.
|
|
576
|
+
//
|
|
577
|
+
// The pattern is also derived from the candidate rather than trusting runner.argv:
|
|
578
|
+
// argv could carry a pattern selecting a DIFFERENT test than the one being attested,
|
|
579
|
+
// and an un-escaped raw name is a regex that can match siblings.
|
|
571
580
|
const reporter = join(run, "reporter.mjs");
|
|
572
|
-
const patternArg =
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
"--test-reporter-destination=stdout",
|
|
583
|
-
candidate.test.file,
|
|
584
|
-
]
|
|
585
|
-
: ["--test", patternArg, candidate.test.file];
|
|
581
|
+
const patternArg = `--test-name-pattern=${exactNodeTestPattern(candidate.test.name)}`;
|
|
582
|
+
writeFileSync(reporter, NODE_TEST_REPORTER_SOURCE);
|
|
583
|
+
const testArgs = [
|
|
584
|
+
"--test",
|
|
585
|
+
nodeTestIsolationFlag(),
|
|
586
|
+
patternArg,
|
|
587
|
+
`--test-reporter=${pathToFileURL(reporter).href}`,
|
|
588
|
+
"--test-reporter-destination=stdout",
|
|
589
|
+
candidate.test.file,
|
|
590
|
+
];
|
|
586
591
|
let args;
|
|
587
592
|
if (candidate.runner.kind === "node-test") {
|
|
588
593
|
args = testArgs;
|
|
@@ -614,36 +619,22 @@ function runLeg(root, session, hooks, env, candidate, commit, expected, source,
|
|
|
614
619
|
else {
|
|
615
620
|
const exitCode = result.status ?? null;
|
|
616
621
|
const output = `${result.stdout ?? ""}\n${result.stderr ?? ""}`;
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
leg = { commit, expected, result: "failed", exit_code: exitCode, ...(dependencySnapshotId ? { dependency_snapshot_id: dependencySnapshotId } : {}) };
|
|
631
|
-
}
|
|
632
|
-
else {
|
|
633
|
-
leg = errorLeg(commit, expected, "runner-outcome-inconsistent", dependencySnapshotId);
|
|
634
|
-
}
|
|
622
|
+
const matches = nodeTestReporterEvents(result.stdout ?? "")
|
|
623
|
+
.filter((event) => event.name === candidate.test.name && !event.skip && !event.todo);
|
|
624
|
+
if (matches.length === 0) {
|
|
625
|
+
leg = errorLeg(commit, expected, nodeTestInfrastructureError(output) ?? "selected-test-not-executed", dependencySnapshotId);
|
|
626
|
+
}
|
|
627
|
+
else if (matches.length > 1) {
|
|
628
|
+
leg = errorLeg(commit, expected, "selected-test-ambiguous", dependencySnapshotId);
|
|
629
|
+
}
|
|
630
|
+
else if (matches[0].type === "test:pass") {
|
|
631
|
+
leg = { commit, expected, result: "passed", exit_code: exitCode, ...(dependencySnapshotId ? { dependency_snapshot_id: dependencySnapshotId } : {}) };
|
|
632
|
+
}
|
|
633
|
+
else if (matches[0].type === "test:fail") {
|
|
634
|
+
leg = { commit, expected, result: "failed", exit_code: exitCode, ...(dependencySnapshotId ? { dependency_snapshot_id: dependencySnapshotId } : {}) };
|
|
635
635
|
}
|
|
636
636
|
else {
|
|
637
|
-
|
|
638
|
-
leg = infrastructureError
|
|
639
|
-
? errorLeg(commit, expected, infrastructureError, dependencySnapshotId)
|
|
640
|
-
: {
|
|
641
|
-
commit,
|
|
642
|
-
expected,
|
|
643
|
-
result: exitCode === 0 ? "passed" : "failed",
|
|
644
|
-
exit_code: exitCode,
|
|
645
|
-
...(dependencySnapshotId ? { dependency_snapshot_id: dependencySnapshotId } : {}),
|
|
646
|
-
};
|
|
637
|
+
leg = errorLeg(commit, expected, "runner-outcome-inconsistent", dependencySnapshotId);
|
|
647
638
|
}
|
|
648
639
|
}
|
|
649
640
|
}
|
|
@@ -97,6 +97,19 @@ export function shadowCommitEligible(root, policy, commit) {
|
|
|
97
97
|
return false;
|
|
98
98
|
throw new Error(`cannot compare shadow commit ${commit} with policy introduction ${sourceCommit}: ${(check.stderr ?? "").trim() || `git exited ${check.status}`}`);
|
|
99
99
|
}
|
|
100
|
+
/** The commit a shadow receipt's ancestry check must be anchored to.
|
|
101
|
+
*
|
|
102
|
+
* A WORKSPACE receipt retains a content-addressed PSEUDO-head for dedupe — it is not a
|
|
103
|
+
* real git rev. Handing it to `git merge-base --is-ancestor` makes git exit with
|
|
104
|
+
* neither 0 nor 1, which shadowCommitEligible turns into a THROW, so the surface
|
|
105
|
+
* hard-fails instead of reporting. Ancestry is always the real base commit.
|
|
106
|
+
*
|
|
107
|
+
* Shared because that rule was previously written out at one call site (with this
|
|
108
|
+
* explanation attached) and silently omitted at another — the same one-side-only drift
|
|
109
|
+
* as OVERLAY_IGNORE. One definition, both callers. */
|
|
110
|
+
export function shadowAncestryCommit(record) {
|
|
111
|
+
return record.evaluation.repository.base ?? record.evaluation.repository.head;
|
|
112
|
+
}
|
|
100
113
|
export class ConstitutionService {
|
|
101
114
|
store;
|
|
102
115
|
root;
|
|
@@ -859,7 +872,7 @@ export class ConstitutionService {
|
|
|
859
872
|
const proof = proofs.find((candidate) => candidate.id === record.proof_id);
|
|
860
873
|
if (!proof || proof.policy_hash !== record.policy_hash)
|
|
861
874
|
return false;
|
|
862
|
-
if (!shadowCommitEligible(this.root, policy, record
|
|
875
|
+
if (!shadowCommitEligible(this.root, policy, shadowAncestryCommit(record)))
|
|
863
876
|
return false;
|
|
864
877
|
const composition = compositionDescendants(policy, policies);
|
|
865
878
|
return proof.policy_hash === policyProofHash(policy, composition);
|
|
@@ -1099,10 +1112,7 @@ export class ConstitutionService {
|
|
|
1099
1112
|
const audit = this.repository.listShadowDispositions(opts).filter((record) => record.policy_id === id);
|
|
1100
1113
|
const current = currentShadowDispositions(audit);
|
|
1101
1114
|
const history = this.repository.listDispositions(opts).filter((record) => record.policy_id === id && record.proof_id === proof.id);
|
|
1102
|
-
const scoringRecords = records.filter((record) => shadowCommitEligible(this.root, policy,
|
|
1103
|
-
// Workspace receipts retain their content-addressed pseudo-head for
|
|
1104
|
-
// dedupe, but ancestry eligibility is anchored to the real base commit.
|
|
1105
|
-
record.evaluation.repository.base ?? record.evaluation.repository.head));
|
|
1115
|
+
const scoringRecords = records.filter((record) => shadowCommitEligible(this.root, policy, shadowAncestryCommit(record)));
|
|
1106
1116
|
const report = scoreShadowPrecision(policy, proof, scoringRecords, audit, history, thresholds);
|
|
1107
1117
|
return {
|
|
1108
1118
|
...report,
|
package/dist/core/checkreport.js
CHANGED
|
@@ -211,4 +211,79 @@ export function renderMarkdown(r) {
|
|
|
211
211
|
out.push(`\n<sub>🧠 Hunch · engineering memory · run \`hunch why <file>\` for the full reasoning.</sub>`);
|
|
212
212
|
return out.join("\n");
|
|
213
213
|
}
|
|
214
|
+
const sarifLoc = (file) => file ? [{ physicalLocation: { artifactLocation: { uri: file.replace(/\\/g, "/") } } }] : undefined;
|
|
215
|
+
/** Render the full check verdict as a SARIF 2.1.0 document. Level mapping is
|
|
216
|
+
* severity-truth, independent of the exit code: `error` = would fail --strict
|
|
217
|
+
* (strict-blocking invariants, blocking-linked regressions/vetoes, conformance
|
|
218
|
+
* violations, authorized policy blocks, incomplete scans), `warning` = blocking
|
|
219
|
+
* records the strict gate downgraded plus warning-severity hits, `note` = advisory
|
|
220
|
+
* (near, redundant, non-blocking receipts). */
|
|
221
|
+
export function renderSarif(r, version, extras = {}) {
|
|
222
|
+
const rules = new Map();
|
|
223
|
+
const results = [];
|
|
224
|
+
const add = (ruleId, level, text, ruleText, file) => {
|
|
225
|
+
if (!rules.has(ruleId))
|
|
226
|
+
rules.set(ruleId, { id: ruleId, shortDescription: { text: clip(ruleText, 300) }, defaultConfiguration: { level } });
|
|
227
|
+
results.push({ ruleId, level, message: { text }, ...(sarifLoc(file) ? { locations: sarifLoc(file) } : {}) });
|
|
228
|
+
};
|
|
229
|
+
for (const c of r.direct) {
|
|
230
|
+
const level = c.strictBlocks ? "error" : c.severity === "advisory" ? "note" : "warning";
|
|
231
|
+
let text = `[${c.severity}] ${c.statement}`;
|
|
232
|
+
if (c.rationale)
|
|
233
|
+
text += ` — ${c.rationale}`;
|
|
234
|
+
if (c.downgrade)
|
|
235
|
+
text += ` (advisory under strict: ${c.downgrade})`;
|
|
236
|
+
if (c.why?.decision)
|
|
237
|
+
text += `\nwhy: “${c.why.decision.title}” (${c.why.decision.id})`;
|
|
238
|
+
if (c.why?.bug)
|
|
239
|
+
text += `\nguards against: ${c.why.bug.title} (${c.why.bug.id})`;
|
|
240
|
+
add(c.id, level, text, c.statement, c.files[0]);
|
|
241
|
+
}
|
|
242
|
+
for (const n of r.near) {
|
|
243
|
+
add(n.id, "note", `[${n.severity}] near via blast radius: ${n.statement}${n.via[0] ? `\nvia ${n.via[0]}` : ""}`, n.statement);
|
|
244
|
+
}
|
|
245
|
+
for (const h of r.regressions) {
|
|
246
|
+
add(h.decision, h.blocking ? "error" : "warning", `re-adds ${h.kind} \`${h.name}\` — ${h.decision} deliberately removed it: “${h.title}”. ${h.reason}`, h.title);
|
|
247
|
+
}
|
|
248
|
+
for (const v of r.vetoes) {
|
|
249
|
+
add(v.decision, v.blocking ? "error" : "warning", `reverses rejected approach: you rejected “${clip(v.alternative)}”; you chose “${clip(v.chosen)}” (${v.decision})`, v.title);
|
|
250
|
+
}
|
|
251
|
+
for (const x of r.redundant) {
|
|
252
|
+
add("hunch/redundant-symbol", "note", `adds ${x.kind} \`${x.name}\` — already defined in ${x.existingFile}`, "Possibly re-implements an existing symbol", x.existingFile);
|
|
253
|
+
}
|
|
254
|
+
for (const c of extras.conformance ?? []) {
|
|
255
|
+
let text = `architectural conformance violated: ${c.detail} (“${c.title}”)`;
|
|
256
|
+
if (c.why)
|
|
257
|
+
text += `\nwhy: ${c.why}`;
|
|
258
|
+
if (c.bug)
|
|
259
|
+
text += `\nprevents recurrence of: ${c.bug}`;
|
|
260
|
+
add(c.decision, "error", text, c.title);
|
|
261
|
+
}
|
|
262
|
+
for (const p of extras.policies ?? []) {
|
|
263
|
+
const level = p.blocks || p.gateError ? "error" : p.result === "satisfied" ? "note" : "warning";
|
|
264
|
+
let text = `policy ${p.result}${p.blocks ? " (authorized block)" : ""}: ${p.explanation}\nreceipt: ${p.receipt}`;
|
|
265
|
+
if (p.gateError)
|
|
266
|
+
text += `\ngate error: ${p.gateError}`;
|
|
267
|
+
add(p.id, level, text, `Constitution policy ${p.id}`);
|
|
268
|
+
}
|
|
269
|
+
for (const s of extras.scanIssues ?? []) {
|
|
270
|
+
add("hunch/incomplete-scan", "error", `semantic source scan rejected ${s.path}: ${s.detail} [${s.code}] — an omitted file could hide a violation, so strict fails closed`, "Incomplete semantic source scan", s.path);
|
|
271
|
+
}
|
|
272
|
+
const doc = {
|
|
273
|
+
$schema: "https://json.schemastore.org/sarif-2.1.0.json",
|
|
274
|
+
version: "2.1.0",
|
|
275
|
+
runs: [{
|
|
276
|
+
tool: {
|
|
277
|
+
driver: {
|
|
278
|
+
name: "hunch",
|
|
279
|
+
informationUri: "https://github.com/davesheffer/hunch",
|
|
280
|
+
version,
|
|
281
|
+
rules: [...rules.values()],
|
|
282
|
+
},
|
|
283
|
+
},
|
|
284
|
+
results,
|
|
285
|
+
}],
|
|
286
|
+
};
|
|
287
|
+
return JSON.stringify(doc, null, 2);
|
|
288
|
+
}
|
|
214
289
|
//# sourceMappingURL=checkreport.js.map
|
package/dist/core/drift.js
CHANGED
|
@@ -16,6 +16,7 @@ import { existsSync, readFileSync } from "node:fs";
|
|
|
16
16
|
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
17
17
|
import { toPosixTarget } from "./paths.js";
|
|
18
18
|
import { currentForTopic, isLive } from "./topics.js";
|
|
19
|
+
import { evaluatePremises } from "./premises.js";
|
|
19
20
|
import { parseDocAnchors } from "./docanchors.js";
|
|
20
21
|
import { markdownDocs, STALE_MARKER, SRC_REF } from "./docscan.js";
|
|
21
22
|
import { computeWikiDrift } from "../wiki/wiki.js";
|
|
@@ -28,6 +29,7 @@ export function computeDrift(store, root) {
|
|
|
28
29
|
// anchor-stale. Keeps the doc≠graph gate's false-positive rate ~zero: a routine
|
|
29
30
|
// narrowing supersession (successor lists fewer files) never flags files still governed.
|
|
30
31
|
const liveFiles = new Set(decisions.filter(isLive).flatMap((d) => (d.related_files ?? []).map(toPosixTarget)));
|
|
32
|
+
const premiseEnv = { now: new Date().toISOString(), exists: (p) => existsSync(join(root, p)) };
|
|
31
33
|
for (const d of decisions) {
|
|
32
34
|
// 1. DEAD-REFERENCE — only for in-force decisions; a superseded one referencing
|
|
33
35
|
// a since-deleted file is legitimate history, not drift.
|
|
@@ -76,6 +78,18 @@ export function computeDrift(store, root) {
|
|
|
76
78
|
}
|
|
77
79
|
}
|
|
78
80
|
}
|
|
81
|
+
// 7. PREMISE-STALE (world≠graph) — the decision's recorded REASON no longer
|
|
82
|
+
// holds while code and docs may be perfectly in sync. Advisory like every
|
|
83
|
+
// kind here, and authority is NEVER changed by a dead premise — the
|
|
84
|
+
// escalation surface asks the human (a self-relaxing gate could be
|
|
85
|
+
// disarmed by the very actor it guards against).
|
|
86
|
+
if (isLive(d) && d.premises?.length) {
|
|
87
|
+
for (const v of evaluatePremises(d, premiseEnv)) {
|
|
88
|
+
if (!v.holds) {
|
|
89
|
+
findings.push({ kind: "premise-stale", id: d.id, detail: `premise "${v.claim}" no longer holds — ${v.reason}. Re-attest, supersede, or retire; authority unchanged until a human decides.` });
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
79
93
|
}
|
|
80
94
|
// One markdown pass feeds both prose checks (3 + 5): read each doc once.
|
|
81
95
|
for (const doc of markdownDocs(root)) {
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { isLive } from "./topics.js";
|
|
2
|
+
const clip = (s, n = 90) => (s.length > n ? s.slice(0, n - 1).trimEnd() + "…" : s);
|
|
3
|
+
/** Repo-relative only — a premise check is not an escape hatch into arbitrary
|
|
4
|
+
* local files (same containment rule as private doc references). */
|
|
5
|
+
function validRel(p) {
|
|
6
|
+
return !!p && !p.startsWith("/") && !/^[A-Za-z]:[\\/]/.test(p) && !p.split(/[\\/]/).includes("..");
|
|
7
|
+
}
|
|
8
|
+
function checkPath(claim, rel, wantExists, env) {
|
|
9
|
+
if (!validRel(rel)) {
|
|
10
|
+
return { claim, holds: false, reason: `unevaluable: path must be repo-relative ("${rel}") — fix the premise record` };
|
|
11
|
+
}
|
|
12
|
+
const exists = env.exists(rel);
|
|
13
|
+
if (wantExists) {
|
|
14
|
+
return exists
|
|
15
|
+
? { claim, holds: true, reason: `"${rel}" still exists` }
|
|
16
|
+
: { claim, holds: false, reason: `"${rel}" no longer exists` };
|
|
17
|
+
}
|
|
18
|
+
return exists
|
|
19
|
+
? { claim, holds: false, reason: `"${rel}" now exists` }
|
|
20
|
+
: { claim, holds: true, reason: `"${rel}" still absent` };
|
|
21
|
+
}
|
|
22
|
+
function checkOne(p, env) {
|
|
23
|
+
if (p.path_absent !== undefined)
|
|
24
|
+
return checkPath(p.claim, p.path_absent, false, env);
|
|
25
|
+
if (p.path_exists !== undefined)
|
|
26
|
+
return checkPath(p.claim, p.path_exists, true, env);
|
|
27
|
+
if (p.review_by !== undefined) {
|
|
28
|
+
const due = Date.parse(p.review_by);
|
|
29
|
+
if (!Number.isFinite(due)) {
|
|
30
|
+
return { claim: p.claim, holds: false, reason: `unevaluable: review_by is not a date ("${p.review_by}") — fix the premise record` };
|
|
31
|
+
}
|
|
32
|
+
if (Date.parse(env.now) > due) {
|
|
33
|
+
const attested = p.attested ? ` (last attested ${p.attested.slice(0, 10)})` : "";
|
|
34
|
+
return { claim: p.claim, holds: false, reason: `attestation expired ${p.review_by.slice(0, 10)}${attested} — needs a human re-attest` };
|
|
35
|
+
}
|
|
36
|
+
return { claim: p.claim, holds: true, reason: `attested until ${p.review_by.slice(0, 10)}` };
|
|
37
|
+
}
|
|
38
|
+
// Claim-only: documents the reason, can never fire. Deliberately allowed —
|
|
39
|
+
// an honest unwatchable premise beats a fake check.
|
|
40
|
+
return { claim: p.claim, holds: true, reason: "documented only (no check attached)" };
|
|
41
|
+
}
|
|
42
|
+
/** Evaluate every recorded premise of one decision. Empty when none recorded. */
|
|
43
|
+
export function evaluatePremises(d, env) {
|
|
44
|
+
return (d.premises ?? []).map((p) => checkOne(p, env));
|
|
45
|
+
}
|
|
46
|
+
/** One inline escalation per LIVE decision with ≥1 dead premise — the question
|
|
47
|
+
* a human must answer, framed with its resolution verbs. Rejected alternatives
|
|
48
|
+
* are MENTIONED (count only), never re-litigated into agent context: reopening
|
|
49
|
+
* a settled rejection is a human call, and consistency alone is a valid reason
|
|
50
|
+
* to keep a decision whose original premise died. */
|
|
51
|
+
export function premiseEscalations(decisions, env) {
|
|
52
|
+
const out = [];
|
|
53
|
+
for (const d of decisions) {
|
|
54
|
+
if (!isLive(d) || !d.premises?.length)
|
|
55
|
+
continue;
|
|
56
|
+
const dead = evaluatePremises(d, env).filter((v) => !v.holds);
|
|
57
|
+
if (!dead.length)
|
|
58
|
+
continue;
|
|
59
|
+
const rejected = d.alternatives_rejected.length;
|
|
60
|
+
out.push({
|
|
61
|
+
kind: "premise-stale",
|
|
62
|
+
topic: d.topic ?? d.id,
|
|
63
|
+
decisionIds: [d.id],
|
|
64
|
+
question: `Decision "${clip(d.title)}" (${d.id}) rests on ${dead.length} premise(s) that no longer hold — is it still right?`,
|
|
65
|
+
detail: dead.map((v) => `"${clip(v.claim, 80)}" — ${v.reason}`).join(" · ")
|
|
66
|
+
+ (rejected ? ` · ${rejected} rejected alternative(s) on record — reopening them is your call, and keeping the decision for consistency is a valid answer` : ""),
|
|
67
|
+
resolution: "re-attest (update the premise's review_by/attested), supersede via /capture, or retire the decision — its authority is UNCHANGED until you decide.",
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
//# sourceMappingURL=premises.js.map
|
package/dist/core/topics.js
CHANGED
|
@@ -59,14 +59,46 @@ export function captureConflicts(decisions, topic, selfId, willCloseId) {
|
|
|
59
59
|
* assembleContext, so no freshness re-check is needed here — a superseded-only-anchored
|
|
60
60
|
* file is caught by the anchor-stale drift check, and the commit-time staleness gate
|
|
61
61
|
* applies the age-downgrade. Returns "" when no anchored decision governs the file. */
|
|
62
|
-
export function renderGrounding(fileDecisions) {
|
|
62
|
+
export function renderGrounding(fileDecisions, allDecisions = fileDecisions) {
|
|
63
|
+
// A CONTESTED topic must never be stated as authority. This is the same fail-safe
|
|
64
|
+
// currentForTopic applies (`live.length === 1 ? live[0] : null`) and renderDocGrounding
|
|
65
|
+
// already honours — this reader was the one that bypassed it, filtering per-decision
|
|
66
|
+
// instead of per-topic. Two live decisions on one topic each got their own assertive
|
|
67
|
+
// bullet, and because each bullet lists what it REJECTED, the agent was told, in the
|
|
68
|
+
// last context before it writes, that both answers are correct and each is forbidden.
|
|
69
|
+
//
|
|
70
|
+
// `allDecisions` is the FULL set, not the file slice, on purpose: the colliding pair
|
|
71
|
+
// can name different files, so a file-scoped check would see one decision, call it
|
|
72
|
+
// uncontested, and assert it as THE answer while the topic is globally disputed.
|
|
73
|
+
const contested = topicCollisions(allDecisions);
|
|
63
74
|
const anchored = fileDecisions.filter((d) => d.topic && isLive(d));
|
|
64
75
|
if (!anchored.length)
|
|
65
76
|
return "";
|
|
66
|
-
const
|
|
77
|
+
const settled = anchored.filter((d) => !contested.has(d.topic));
|
|
78
|
+
const disputed = [...new Set(anchored.filter((d) => contested.has(d.topic)).map((d) => d.topic))].sort();
|
|
79
|
+
const lines = settled.map((d) => {
|
|
67
80
|
const rej = d.alternatives_rejected.length ? ` (rejected: ${d.alternatives_rejected.join("; ")})` : "";
|
|
68
|
-
|
|
81
|
+
// Memory supply chain: an agent-recorded decision (no capture interview, no
|
|
82
|
+
// human countersign) is TESTIMONY. It still grounds — but never with the same
|
|
83
|
+
// voice as a human-confirmed record, because this block is delivered with
|
|
84
|
+
// doc-precedence framing ("follow the graph") and would otherwise launder an
|
|
85
|
+
// unvouched write into the most trusted context the next agent sees.
|
|
86
|
+
// Token-aware match (mirrors strictgate.isHumanConfirmed; not imported — that
|
|
87
|
+
// module imports this one).
|
|
88
|
+
const testimony = d.provenance.source.split("+").includes("agent_recorded")
|
|
89
|
+
? " — ⚠ agent-recorded testimony, no human countersign yet (/capture confirms it)"
|
|
90
|
+
: "";
|
|
91
|
+
return `• "${d.topic}": ${d.decision || d.title} [${d.id}]${rej}${testimony}`;
|
|
69
92
|
});
|
|
93
|
+
// Name the conflict instead of silently dropping it: an unexplained absence would read
|
|
94
|
+
// as "nothing is recorded here", which is how a contested topic gets re-decided by
|
|
95
|
+
// accident. This is a question for the human, never an answer for the agent.
|
|
96
|
+
for (const topic of disputed) {
|
|
97
|
+
const ids = contested.get(topic).map((d) => d.id).join(", ");
|
|
98
|
+
lines.push(`• "${topic}": ⚠ UNRESOLVED — ${contested.get(topic).length} live decisions (${ids}). No current answer; ask the human before choosing. Resolve with \`hunch reconcile-topics\` (supersede one, or split the topic).`);
|
|
99
|
+
}
|
|
100
|
+
if (!lines.length)
|
|
101
|
+
return "";
|
|
70
102
|
return `🧭 Hunch grounding — this file is anchored to recorded decisions; follow the graph, not a stale doc:\n${lines.join("\n")}`;
|
|
71
103
|
}
|
|
72
104
|
/** Every topic with MORE THAN ONE live decision — the invariant violations a post-merge
|
package/dist/core/types.js
CHANGED
|
@@ -106,6 +106,22 @@ export const ConformancePredicateSchema = z.object({
|
|
|
106
106
|
object: z.string().optional().describe("required (calls/imports) or forbidden (not-*) target"),
|
|
107
107
|
transitive: z.boolean().default(false).describe("allow an indirect path over the dependency graph"),
|
|
108
108
|
});
|
|
109
|
+
// A premise: the WHY under the decision, as a checkable record. Decisions decay
|
|
110
|
+
// when their REASONS die, not (only) when code changes — a premise makes one
|
|
111
|
+
// recorded reason watchable. Exactly one check per premise (or none: claim-only
|
|
112
|
+
// premises document the reason but can never fire). Checks are deterministic and
|
|
113
|
+
// explicit — path presence or a dated human attestation — never semantic guesses.
|
|
114
|
+
// A failing premise NEVER changes authority; it only raises an inline escalation
|
|
115
|
+
// (the human renews, supersedes, or retires — same ethos as topic anchors).
|
|
116
|
+
export const PremiseSchema = z.object({
|
|
117
|
+
claim: z.string().min(1).describe("the human-readable reason this decision rests on"),
|
|
118
|
+
path_absent: z.string().optional().describe("premise holds while this repo-relative path does NOT exist"),
|
|
119
|
+
path_exists: z.string().optional().describe("premise holds while this repo-relative path exists"),
|
|
120
|
+
review_by: z.string().optional().describe("dated attestation: premise holds until this ISO date, then needs re-attesting"),
|
|
121
|
+
attested: z.string().optional().describe("ISO date a human last attested the claim (informational)"),
|
|
122
|
+
}).refine((p) => [p.path_absent, p.path_exists, p.review_by].filter((x) => x !== undefined).length <= 1, {
|
|
123
|
+
message: "a premise carries at most one check (path_absent | path_exists | review_by)",
|
|
124
|
+
});
|
|
109
125
|
export const DecisionSchema = z.object({
|
|
110
126
|
id: z.string().describe("dec_*"),
|
|
111
127
|
title: z.string(),
|
|
@@ -137,6 +153,12 @@ export const DecisionSchema = z.object({
|
|
|
137
153
|
valid_to: z.string().nullable().default(null).describe("ISO instant it was superseded (null = in force)"),
|
|
138
154
|
retired: RetiredSignalSchema.default({ symbols: [], deps: [] }),
|
|
139
155
|
conformance: z.array(ConformancePredicateSchema).optional().describe("deterministic intent-conformance checks over the graph"),
|
|
156
|
+
// Optional like `topic`/`conformance`: absent = today's behavior exactly (no
|
|
157
|
+
// migration, no new burden). Intended to be RARE — blocking constraints and
|
|
158
|
+
// contested decisions, not every record (attestation fatigue kills reminder
|
|
159
|
+
// systems). A decision with premises is "conditioned on [these]", never
|
|
160
|
+
// "verified valid" — the system watches recorded reasons only.
|
|
161
|
+
premises: z.array(PremiseSchema).optional().describe("the checkable reasons this decision rests on; a dead premise escalates, never auto-relaxes"),
|
|
140
162
|
provenance: ProvenanceSchema,
|
|
141
163
|
date: z.string(),
|
|
142
164
|
});
|
package/dist/mcp/server.js
CHANGED
|
@@ -33,6 +33,7 @@ import { HUNCH_VERSION } from "../core/version.js";
|
|
|
33
33
|
import { assertCompleteRepoScan, indexRepo, scanRepo } from "../extractors/indexer.js";
|
|
34
34
|
import { liveForTopic, historyForTopic, rejectedForTopic, captureConflicts } from "../core/topics.js";
|
|
35
35
|
import { pendingEscalations, policyEscalations } from "../core/escalations.js";
|
|
36
|
+
import { premiseEscalations } from "../core/premises.js";
|
|
36
37
|
import { issueCaptureToken as issueToken, consumeCaptureToken as consumeToken } from "../core/capturetoken.js";
|
|
37
38
|
import { randomUUID } from "node:crypto";
|
|
38
39
|
import { existsSync } from "node:fs";
|
|
@@ -606,7 +607,8 @@ export function buildServerWithRootControl(initialRoot) {
|
|
|
606
607
|
L.push(` • ${r.title} (${r.id}${r.topic ? `, ${r.topic}` : ""}, since ${r.date})\n ${r.note}`);
|
|
607
608
|
if (pendingReview > 0)
|
|
608
609
|
L.push("", `${pendingReview} legacy un-vouched draft(s) — \`hunch adopt-drafts\` auto-trusts them as advisory (new captures land trusted automatically).`);
|
|
609
|
-
const escalations = pendingEscalations(store.
|
|
610
|
+
const escalations = pendingEscalations(store.advisoryRecs("decisions"));
|
|
611
|
+
escalations.push(...premiseEscalations(store.advisoryRecs("decisions"), { now: new Date().toISOString(), exists: (p) => existsSync(join(root, p)) }));
|
|
610
612
|
if (escalations.length) {
|
|
611
613
|
L.push("", `⚖ ${escalations.length} decision(s) need the human's call — ASK inline (never queue): ${escalations.map((e) => e.question).join(" · ")}`);
|
|
612
614
|
}
|
|
@@ -621,10 +623,13 @@ export function buildServerWithRootControl(initialRoot) {
|
|
|
621
623
|
// (con_e04226bd05): no Claude-specific behavior.
|
|
622
624
|
server.registerTool("hunch_escalations", {
|
|
623
625
|
title: "Decisions the human must make now (ask inline, not a queue)",
|
|
624
|
-
description: "The rare decisions the graph cannot resolve on its own — surfaced so you ASK THE USER in the prompt at the moment, then act. Auto-captured memory is trusted automatically and never appears here; this returns topic conflicts (>1 live decision for one topic) and Constitution human moments (candidate policies awaiting review, proposed policies awaiting an activation decision). Normally empty. Raise each question with the user; do NOT decide it for them — an entry is a question, never an approval.
|
|
626
|
+
description: "The rare decisions the graph cannot resolve on its own — surfaced so you ASK THE USER in the prompt at the moment, then act. Auto-captured memory is trusted automatically and never appears here; this returns topic conflicts (>1 live decision for one topic), premise-stale decisions (a live decision whose recorded REASON no longer holds — its authority is unchanged until the human re-attests, supersedes, or retires), and Constitution human moments (candidate policies awaiting review, proposed policies awaiting an activation decision). Normally empty. Raise each question with the user; do NOT decide it for them — an entry is a question, never an approval. Reads the public store, or the unified overlay when the repo is in shared mode (where the overlay IS the store) — never private-mode overlay records.",
|
|
625
627
|
inputSchema: {},
|
|
626
628
|
}, async () => {
|
|
627
|
-
const items = pendingEscalations(store.
|
|
629
|
+
const items = pendingEscalations(store.advisoryRecs("decisions"));
|
|
630
|
+
// Premise decay: a live decision whose recorded reason died. Question-framed
|
|
631
|
+
// like every entry here — authority never changes until the human answers.
|
|
632
|
+
items.push(...premiseEscalations(store.advisoryRecs("decisions"), { now: new Date().toISOString(), exists: (p) => existsSync(join(root, p)) }));
|
|
628
633
|
try {
|
|
629
634
|
items.push(...policyEscalations(new ConstitutionService(store, root).list({ publicOnly: true }).map((p) => ({ ...p, last_action: p.audit.at(-1)?.action ?? null }))));
|
|
630
635
|
}
|
|
@@ -726,6 +731,14 @@ export function buildServerWithRootControl(initialRoot) {
|
|
|
726
731
|
related_files: z.array(z.string()).optional(),
|
|
727
732
|
related_components: z.array(z.string()).optional(),
|
|
728
733
|
topic: z.string().optional().describe("decision-grounding anchor — one topic per decision; enables doc≠graph drift detection for it. Omit to leave un-anchored."),
|
|
734
|
+
premises: z.array(z.object({
|
|
735
|
+
claim: z.string().describe("the checkable reason this decision rests on, in plain words"),
|
|
736
|
+
check: z.object({
|
|
737
|
+
kind: z.enum(["path_absent", "path_exists", "review_by"]),
|
|
738
|
+
path: z.string().optional().describe("repo-relative path for path_absent / path_exists"),
|
|
739
|
+
review_by: z.string().optional().describe("ISO date this attestation expires (review_by)"),
|
|
740
|
+
}).optional().describe("at most one deterministic check; omit for an unchecked note"),
|
|
741
|
+
})).optional().describe("the checkable reasons this decision rests on. A dead premise NEVER changes authority — it raises an escalation for the human. Omit on re-record to keep the incumbent's premises."),
|
|
729
742
|
status: z.enum(["proposed", "accepted", "rejected", "superseded"]).optional(),
|
|
730
743
|
commit: z.string().optional(),
|
|
731
744
|
supersedes: z.string().optional().describe("id of a decision this one replaces — closes its valid-time window (invalidate, don't delete)"),
|
|
@@ -758,17 +771,51 @@ export function buildServerWithRootControl(initialRoot) {
|
|
|
758
771
|
const sameHumanIdentity = existing?.topic && decision.topic
|
|
759
772
|
? decision.topic === existing.topic
|
|
760
773
|
: existing?.title === decision.title;
|
|
761
|
-
|
|
762
|
-
|
|
774
|
+
// CURATED slots are overwrite-protected, not just human-confirmed ones:
|
|
775
|
+
// agent_recorded testimony carries session content a silent replace would
|
|
776
|
+
// destroy (issue #23's harm, one tier down). Only regenerable machine
|
|
777
|
+
// drafts (llm_draft/inferred synthesis, deterministically re-derivable
|
|
778
|
+
// from the commit) stay upgradeable by a different identity. Same-identity
|
|
779
|
+
// re-record remains the countersign/refine path for every tier.
|
|
780
|
+
const curated = ["human_confirmed", "agent_recorded"].some((t) => existing?.provenance.source.split("+").includes(t));
|
|
781
|
+
// AUTHORSHIP STAMP (memory supply chain): only a consumed capture token — proof a
|
|
782
|
+
// grilling interview preceded this write — mints human_confirmed. Any agent can
|
|
783
|
+
// CALL this tool mid-session, possibly steered by untrusted content it read;
|
|
784
|
+
// "the human probably asked me to" is testimony, not a signature.
|
|
785
|
+
//
|
|
786
|
+
// Resolved HERE, before the overwrite guard, because the guard's answer depends on
|
|
787
|
+
// it: testimony must yield to a signature. (Consuming before a possible refusal
|
|
788
|
+
// burns the token, which is the safe direction — a re-run of /capture mints another.)
|
|
789
|
+
const gated = consumeCaptureToken(capture_token);
|
|
790
|
+
const existingTiers = existing?.provenance.source.split("+") ?? [];
|
|
791
|
+
const existingIsHuman = existingTiers.includes("human_confirmed");
|
|
792
|
+
// A slot held only by AGENT TESTIMONY must not block a later human capture — the
|
|
793
|
+
// stamp's own contract says so ("never lock the id slot against a later human
|
|
794
|
+
// capture"), but including agent_recorded in `curated` did exactly that. A
|
|
795
|
+
// human_confirmed slot stays protected as before (issue #23): a signature is never
|
|
796
|
+
// displaced by a differently-identified record, vouched or not.
|
|
797
|
+
const conflictsWithHuman = curated && !sameHumanIdentity && !(gated && !existingIsHuman);
|
|
763
798
|
if (conflictsWithHuman) {
|
|
764
|
-
return err(`Decision id ${id} already identifies a different
|
|
799
|
+
return err(`Decision id ${id} already identifies a different curated decision: ` +
|
|
765
800
|
`"${existing.title}"${existing.topic ? ` (topic "${existing.topic}")` : ""}. ` +
|
|
766
801
|
`Refusing to overwrite it with "${decision.title}"${decision.topic ? ` (topic "${decision.topic}")` : ""}. ` +
|
|
767
802
|
"Record the additional decision without commit, or reuse the incumbent topic/title when refining the same decision.");
|
|
768
803
|
}
|
|
804
|
+
// Un-token'd writes land as agent_recorded: fully functional advisory memory that
|
|
805
|
+
// never carries human authority (strict/veto gates key on human_confirmed) and
|
|
806
|
+
// surfaces with a testimony marker. Re-record through /capture to countersign.
|
|
807
|
+
//
|
|
808
|
+
// A signature already on this slot is INHERITED, never erased. The un-token'd path
|
|
809
|
+
// is exactly what the nudge below tells an agent to do ("re-record… supersedes"),
|
|
810
|
+
// and the tier expression preserved `llm_draft` while dropping `human_confirmed` —
|
|
811
|
+
// so an un-vouched agent write silently stripped human authority from a decision a
|
|
812
|
+
// human had vouched for. That inverts the whole point of the stamp: it exists to
|
|
813
|
+
// stop an agent CLAIMING human authority, not to let one DESTROY it. Downgrading a
|
|
814
|
+
// signature is a human act (`hunch review --reject`, or supersede via /capture).
|
|
815
|
+
const tier = gated || existingIsHuman ? "human_confirmed" : "agent_recorded";
|
|
769
816
|
const source = existing && existing.provenance.source.includes("llm_draft")
|
|
770
|
-
?
|
|
771
|
-
:
|
|
817
|
+
? `llm_draft+${tier}`
|
|
818
|
+
: tier;
|
|
772
819
|
const now = new Date().toISOString();
|
|
773
820
|
const rec = {
|
|
774
821
|
id,
|
|
@@ -780,6 +827,14 @@ export function buildServerWithRootControl(initialRoot) {
|
|
|
780
827
|
consequences: decision.consequences ?? [],
|
|
781
828
|
alternatives_rejected: decision.alternatives_rejected ?? [],
|
|
782
829
|
rejected_tripwires: existing?.rejected_tripwires ?? [], // preserve confirmed tripwires across re-record
|
|
830
|
+
// Premises survive a re-record for the same reason tripwires do. Rebuilding the
|
|
831
|
+
// record field-by-field WITHOUT them silently deleted the decision's recorded
|
|
832
|
+
// reasons — so the escalation for a dead premise stopped firing while the
|
|
833
|
+
// decision kept full authority, and nothing reported the loss. That is the exact
|
|
834
|
+
// fail-open premise decay exists to prevent, relocated from the evaluator to the
|
|
835
|
+
// writer — and the escalation's own advice ("re-attest… or re-record") walked
|
|
836
|
+
// straight into it. Caller-supplied premises win; otherwise the incumbent's carry.
|
|
837
|
+
premises: decision.premises ?? existing?.premises ?? [],
|
|
783
838
|
related_components: decision.related_components ?? existing?.related_components ?? [],
|
|
784
839
|
related_files: (decision.related_files ?? existing?.related_files ?? []).map(toPosixTarget),
|
|
785
840
|
supersedes: decision.supersedes ?? existing?.supersedes ?? null,
|
|
@@ -789,7 +844,7 @@ export function buildServerWithRootControl(initialRoot) {
|
|
|
789
844
|
valid_from: existing?.valid_from ?? now,
|
|
790
845
|
valid_to: existing?.valid_to ?? null,
|
|
791
846
|
retired: existing?.retired ?? { symbols: [], deps: [] },
|
|
792
|
-
provenance: { source, confidence: 0.95, evidence: (decision.related_files ?? existing?.provenance.evidence ?? []).map(toPosixTarget) },
|
|
847
|
+
provenance: { source, confidence: gated ? 0.95 : 0.75, evidence: (decision.related_files ?? existing?.provenance.evidence ?? []).map(toPosixTarget) },
|
|
793
848
|
date: now,
|
|
794
849
|
};
|
|
795
850
|
// Where this write will actually land (see captureHome). Resolved BEFORE the
|
|
@@ -843,16 +898,17 @@ export function buildServerWithRootControl(initialRoot) {
|
|
|
843
898
|
// (commit only — it rides the user's next push, never auto-pushing their code branch).
|
|
844
899
|
const flush = flushCapture(store, hunchPaths(root).hunch, !!decision.private, `hunch: capture ${id}`, startupTeamRoute ?? undefined);
|
|
845
900
|
const flushed = flushNote(flush, home, store.mode);
|
|
846
|
-
// Capture-session gate (staged deprecation, §9.3):
|
|
847
|
-
//
|
|
848
|
-
//
|
|
849
|
-
// presented but unknown to THIS process (server restart/expiry) is
|
|
850
|
-
|
|
901
|
+
// Capture-session gate (staged deprecation, §9.3): the token was consumed
|
|
902
|
+
// above (it also decides the provenance tier). No token still writes
|
|
903
|
+
// (non-breaking) but lands as agent_recorded with a nudge toward /capture.
|
|
904
|
+
// A token presented but unknown to THIS process (server restart/expiry) is
|
|
905
|
+
// not shamed — but it also cannot be VERIFIED, so the record still lands
|
|
906
|
+
// agent_recorded with a note saying how to countersign.
|
|
851
907
|
const captureNote = gated
|
|
852
908
|
? " [via capture front door]"
|
|
853
909
|
: capture_token
|
|
854
|
-
?
|
|
855
|
-
: `\n\n⚠ Recorded WITHOUT a capture interview — the record stands
|
|
910
|
+
? `\n\nℹ The capture token could not be verified (server restart or expiry), so this record is stamped agent_recorded. Re-record through hunch_capture_decision → hunch_record_decision to countersign it as human_confirmed.`
|
|
911
|
+
: `\n\n⚠ Recorded WITHOUT a capture interview — the record stands as agent_recorded TESTIMONY (advisory: it never carries human authority; a /capture interview on the same topic/title countersigns it). Harden it NOW in one exchange instead of switching flows: answer the first grilling question directly — "What alternative did you seriously consider and reject for '${rec.title.slice(0, 60)}', and what breaks if a future session re-introduces it?" — then fold the answer into alternatives_rejected via a /capture interview (hunch_capture_decision → hunch_record_decision(supersedes: ${id})), which countersigns the record as human_confirmed. (A future major version will require a capture token here.)`;
|
|
856
912
|
// Quality nudge only when the untokened deprecation nudge isn't already
|
|
857
913
|
// grilling — one advisory voice per response, never two.
|
|
858
914
|
const quality = gated || capture_token ? qualityNudge(rec) : "";
|
package/dist/store/hunchStore.js
CHANGED
|
@@ -226,6 +226,21 @@ export class HunchStore {
|
|
|
226
226
|
byId.set(r.id, r);
|
|
227
227
|
return [...byId.values()];
|
|
228
228
|
}
|
|
229
|
+
/** Records for an AGENT-FACING advisory surface (escalations, orientation).
|
|
230
|
+
*
|
|
231
|
+
* In unified ("shared") mode the overlay IS the one store — the public `.hunch/` is
|
|
232
|
+
* only a routing shell — so reading the public home alone returns NOTHING and the
|
|
233
|
+
* surface reports "all clear" for a store whose every record is elsewhere. That is
|
|
234
|
+
* the worst possible answer for escalations, whose entire job is to raise the
|
|
235
|
+
* questions only a human can settle: a real topic collision came back as an empty
|
|
236
|
+
* list and the agent was affirmatively told there was nothing to escalate.
|
|
237
|
+
*
|
|
238
|
+
* In "private" mode the split is a real privacy boundary, so this stays public-only:
|
|
239
|
+
* private records must not surface on a public advisory surface. Mode-aware, not a
|
|
240
|
+
* blanket union — the distinction is the point. */
|
|
241
|
+
advisoryRecs(kind) {
|
|
242
|
+
return this.unified ? this.recs(kind) : this.json.loadAll(kind);
|
|
243
|
+
}
|
|
229
244
|
/** Records from exactly one storage home (no public/private union). Capture
|
|
230
245
|
* paths use this for identity/lineage checks so a private record can never
|
|
231
246
|
* inherit or disclose relationships from an identically-shaped public record. */
|
|
@@ -389,21 +404,37 @@ export class HunchStore {
|
|
|
389
404
|
}
|
|
390
405
|
/** Portable bounded fallback over titles/bodies. Each natural-language token
|
|
391
406
|
* is an OR candidate, mirroring the high-recall FTS query closely enough for
|
|
392
|
-
* runtimes whose SQLite build omits the optional FTS5 module.
|
|
407
|
+
* runtimes whose SQLite build omits the optional FTS5 module.
|
|
408
|
+
*
|
|
409
|
+
* `_` is BOTH a LIKE single-character wildcard and the dominant character in this
|
|
410
|
+
* codebase's identifiers (dec_/con_/bug_ ids, snake_case symbols). The old code
|
|
411
|
+
* STRIPPED it, so a search for `hunch_record_decision` looked for the literal
|
|
412
|
+
* `hunchrecorddecision` and matched nothing — on precisely the runtimes with no FTS5,
|
|
413
|
+
* where this fallback is the only search there is. Escaping keeps the term literal;
|
|
414
|
+
* leaving `_` unescaped would silently over-match instead. */
|
|
393
415
|
likeSearch(query, limit, kind) {
|
|
394
416
|
const terms = (query.toLowerCase().match(/[\p{L}\p{N}_]+/gu)
|
|
395
|
-
?? [query.toLowerCase().
|
|
417
|
+
?? [query.toLowerCase().trim()].filter(Boolean)).slice(0, 32);
|
|
396
418
|
if (!terms.length)
|
|
397
419
|
return [];
|
|
398
|
-
const predicates = terms.map(() => `(lower(title) LIKE ? OR lower(body) LIKE ?)`).join(" OR ");
|
|
420
|
+
const predicates = terms.map(() => `(lower(title) LIKE ? ESCAPE '\\' OR lower(body) LIKE ? ESCAPE '\\')`).join(" OR ");
|
|
399
421
|
const likes = terms.flatMap((term) => {
|
|
400
|
-
const like = `%${term.replace(/[
|
|
422
|
+
const like = `%${term.replace(/[\\%_]/g, "\\$&")}%`;
|
|
401
423
|
return [like, like];
|
|
402
424
|
});
|
|
403
425
|
const where = kind ? `kind = ? AND (${predicates})` : `(${predicates})`;
|
|
404
426
|
const params = kind ? [kind, ...likes, limit] : [...likes, limit];
|
|
427
|
+
// Ordered so a TRUNCATING limit drops the least relevant row rather than an
|
|
428
|
+
// arbitrary one: a title hit outranks a body-only hit, then shortest title
|
|
429
|
+
// (a constraint's one-line statement beats a long decision body that merely
|
|
430
|
+
// mentions the term), then id for determinism. Without this, `LIMIT` returned
|
|
431
|
+
// rowid order and could drop the constraint a caller was checking for.
|
|
432
|
+
const titleLikes = terms.map(() => `lower(title) LIKE ? ESCAPE '\\'`).join(" OR ");
|
|
433
|
+
const titleParams = terms.map((term) => `%${term.replace(/[\\%_]/g, "\\$&")}%`);
|
|
405
434
|
const rows = this.db.prepare(`SELECT ref, kind, title, substr(body,1,120) AS snip FROM search
|
|
406
|
-
WHERE ${where}
|
|
435
|
+
WHERE ${where}
|
|
436
|
+
ORDER BY CASE WHEN ${titleLikes} THEN 0 ELSE 1 END, length(title), ref
|
|
437
|
+
LIMIT ?`).all(...(kind ? [kind, ...likes, ...titleParams, limit] : [...likes, ...titleParams, limit]));
|
|
407
438
|
return rows.map((r) => ({ ref: r.ref, kind: r.kind, title: r.title, snippet: r.snip, score: 0 }));
|
|
408
439
|
}
|
|
409
440
|
// ---- semantic search (opt-in embeddings) --------------------------------
|
package/package.json
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@davesheffer/hunch",
|
|
3
|
-
"version": "1.10.
|
|
3
|
+
"version": "1.10.7",
|
|
4
|
+
"mcpName": "io.github.davesheffer/hunch",
|
|
4
5
|
"license": "Apache-2.0",
|
|
5
6
|
"author": "Dave Sheffer <dave.sheffer1@gmail.com>",
|
|
6
7
|
"description": "Engineering memory and a deterministic Change Gate for AI-assisted codebases: decisions, rejected approaches, constraints, and bug lineage become portable context and opt-in enforcement for every MCP assistant.",
|
|
@@ -18,6 +19,7 @@
|
|
|
18
19
|
},
|
|
19
20
|
"files": [
|
|
20
21
|
"dist/**/*.js",
|
|
22
|
+
"server.json",
|
|
21
23
|
"bench/constitution-exp03-v1.json",
|
|
22
24
|
"tooling/competitive-watch.mjs",
|
|
23
25
|
"tooling/md1-benchmark.mjs",
|
package/server.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-09-29/server.schema.json",
|
|
3
|
+
"name": "io.github.davesheffer/hunch",
|
|
4
|
+
"description": "Engineering memory for AI-assisted codebases: decisions, rejected alternatives, bug lineage, and deterministic architectural gates, served git-natively over MCP.",
|
|
5
|
+
"repository": {
|
|
6
|
+
"url": "https://github.com/davesheffer/hunch",
|
|
7
|
+
"source": "github"
|
|
8
|
+
},
|
|
9
|
+
"websiteUrl": "https://hunch-pi.vercel.app",
|
|
10
|
+
"version": "1.10.7",
|
|
11
|
+
"packages": [
|
|
12
|
+
{
|
|
13
|
+
"registryType": "npm",
|
|
14
|
+
"registryBaseUrl": "https://registry.npmjs.org",
|
|
15
|
+
"identifier": "@davesheffer/hunch",
|
|
16
|
+
"version": "1.10.7",
|
|
17
|
+
"runtimeHint": "npx",
|
|
18
|
+
"packageArguments": [
|
|
19
|
+
{
|
|
20
|
+
"type": "positional",
|
|
21
|
+
"value": "mcp",
|
|
22
|
+
"description": "Start the Hunch MCP server (stdio) for the current repository."
|
|
23
|
+
}
|
|
24
|
+
],
|
|
25
|
+
"transport": {
|
|
26
|
+
"type": "stdio"
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
]
|
|
30
|
+
}
|
|
@@ -18,16 +18,29 @@ const distinctivePhrases = [
|
|
|
18
18
|
"deterministic Change Gate for AI-assisted codebases",
|
|
19
19
|
];
|
|
20
20
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
21
|
+
let token = process.env.GITHUB_TOKEN?.trim();
|
|
22
|
+
// A rejected token must DEGRADE the run, never kill it: ambient CI/proxy tokens
|
|
23
|
+
// are often invalid for api.github.com, and the metadata half of this watch
|
|
24
|
+
// works unauthenticated. Flipped on the first 401 so every later call skips
|
|
25
|
+
// the bad credential instead of failing eight times.
|
|
26
|
+
let tokenRejected = false;
|
|
27
|
+
|
|
28
|
+
function headers() {
|
|
29
|
+
return {
|
|
30
|
+
Accept: "application/vnd.github+json",
|
|
31
|
+
"User-Agent": "hunch-competitive-watch",
|
|
32
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
33
|
+
...(token && !tokenRejected ? { Authorization: `Bearer ${token}` } : {}),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
28
36
|
|
|
29
37
|
async function github(path) {
|
|
30
|
-
|
|
38
|
+
let response = await fetch(`https://api.github.com${path}`, { headers: headers() });
|
|
39
|
+
if (response.status === 401 && token && !tokenRejected) {
|
|
40
|
+
tokenRejected = true;
|
|
41
|
+
process.stderr.write("competitive-watch: GITHUB_TOKEN rejected (401) — continuing unauthenticated; phrase search will be skipped.\n");
|
|
42
|
+
response = await fetch(`https://api.github.com${path}`, { headers: headers() });
|
|
43
|
+
}
|
|
31
44
|
if (!response.ok) {
|
|
32
45
|
const detail = (await response.text()).slice(0, 300).replaceAll("\n", " ");
|
|
33
46
|
throw new Error(`GitHub ${response.status} for ${path}: ${detail}`);
|
|
@@ -36,20 +49,26 @@ async function github(path) {
|
|
|
36
49
|
}
|
|
37
50
|
|
|
38
51
|
async function repoSnapshot(repo) {
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
52
|
+
// One vanished/renamed repo is itself a signal worth a row — never a reason
|
|
53
|
+
// to lose the other seven.
|
|
54
|
+
try {
|
|
55
|
+
const data = await github(`/repos/${repo}`);
|
|
56
|
+
return {
|
|
57
|
+
repo,
|
|
58
|
+
created: data.created_at,
|
|
59
|
+
pushed: data.pushed_at,
|
|
60
|
+
stars: data.stargazers_count,
|
|
61
|
+
forks: data.forks_count,
|
|
62
|
+
issues: data.open_issues_count,
|
|
63
|
+
url: data.html_url,
|
|
64
|
+
};
|
|
65
|
+
} catch (error) {
|
|
66
|
+
return { repo, error: error instanceof Error ? error.message : String(error) };
|
|
67
|
+
}
|
|
49
68
|
}
|
|
50
69
|
|
|
51
70
|
async function phraseSnapshot(phrase) {
|
|
52
|
-
const query = encodeURIComponent(
|
|
71
|
+
const query = encodeURIComponent(`"${phrase}"`);
|
|
53
72
|
const data = await github(`/search/code?q=${query}&per_page=100`);
|
|
54
73
|
const external = data.items
|
|
55
74
|
.filter((item) => !item.repository.full_name.startsWith("davesheffer/"))
|
|
@@ -72,14 +91,25 @@ function render(snapshot, phrases) {
|
|
|
72
91
|
];
|
|
73
92
|
|
|
74
93
|
for (const item of snapshot) {
|
|
94
|
+
if (item.error) {
|
|
95
|
+
lines.push(`| ${item.repo} | — | — | — | — | — |`);
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
75
98
|
lines.push(
|
|
76
99
|
`| [${item.repo}](${item.url}) | ${item.created.slice(0, 10)} | ${item.pushed.slice(0, 10)} | ${item.stars} | ${item.forks} | ${item.issues} |`,
|
|
77
100
|
);
|
|
78
101
|
}
|
|
102
|
+
const failed = snapshot.filter((item) => item.error);
|
|
103
|
+
if (failed.length) {
|
|
104
|
+
lines.push("");
|
|
105
|
+
for (const item of failed) lines.push(`- ⚠ ${item.repo}: ${item.error}`);
|
|
106
|
+
}
|
|
79
107
|
|
|
80
108
|
lines.push("", "## Distinctive phrase search", "");
|
|
81
109
|
if (!token) {
|
|
82
110
|
lines.push("Skipped: set `GITHUB_TOKEN` to enable authenticated GitHub code search.");
|
|
111
|
+
} else if (tokenRejected) {
|
|
112
|
+
lines.push("Skipped: `GITHUB_TOKEN` was rejected by api.github.com (401) — repo metadata above was collected unauthenticated. Provide a valid token (e.g. `GITHUB_TOKEN=\"$(gh auth token)\"`) to run the phrase-copy check.");
|
|
83
113
|
} else {
|
|
84
114
|
for (const result of phrases) {
|
|
85
115
|
lines.push(`- **${result.phrase}** — ${result.external.length} external indexed match(es)`);
|
|
@@ -97,8 +127,11 @@ function render(snapshot, phrases) {
|
|
|
97
127
|
}
|
|
98
128
|
|
|
99
129
|
try {
|
|
100
|
-
const snapshot =
|
|
101
|
-
|
|
130
|
+
const snapshot = [];
|
|
131
|
+
// Sequential on purpose: the first call settles token validity before the
|
|
132
|
+
// rest, and unauthenticated rate limits are too small to burst against.
|
|
133
|
+
for (const repo of repos) snapshot.push(await repoSnapshot(repo));
|
|
134
|
+
const phrases = token && !tokenRejected
|
|
102
135
|
? await Promise.all(distinctivePhrases.map(phraseSnapshot))
|
|
103
136
|
: [];
|
|
104
137
|
process.stdout.write(render(snapshot, phrases));
|