@davesheffer/hunch 1.32.1 → 1.32.3
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 +8 -3
- package/dist/cli/index.js +29 -25
- package/dist/constitution/behaviorEvaluator.js +9 -2
- package/dist/constitution/renderEvaluations.d.ts +8 -0
- package/dist/constitution/renderEvaluations.js +48 -0
- package/dist/core/drift.d.ts +2 -1
- package/dist/core/drift.js +1 -0
- package/dist/core/stateContract.d.ts +3 -0
- package/dist/core/stateContract.js +1 -0
- package/dist/core/taskReport.d.ts +6 -0
- package/dist/core/taskReport.js +15 -0
- package/dist/core/taskReportRender.d.ts +5 -0
- package/dist/core/taskReportRender.js +8 -0
- package/dist/extractors/git.js +25 -5
- package/dist/mcp/server.js +5 -2
- package/dist/store/stateBinding.js +16 -0
- package/package.json +1 -1
- package/server.json +2 -2
package/README.md
CHANGED
|
@@ -183,7 +183,7 @@ Hunch may hold durable state **about** external work with credential-free proven
|
|
|
183
183
|
|
|
184
184
|
The first real-world pilot is **Sofia**, a working operations agent over CRM, Gmail and WhatsApp. Sofia's approved actions, follow-ups, customer/source relationships and cited summaries map naturally to action receipts, commitments, entities/relationships and dependency-bound state.
|
|
185
185
|
|
|
186
|
-
The pilot measures whether Sofia and a second, different agent stop re-deriving contradictory state when the deterministic state is delivered before they answer or act.
|
|
186
|
+
The pilot measures whether Sofia and a second, different agent stop re-deriving contradictory state when the deterministic state is delivered before they answer or act. First live number (2026-09-12, one user): after one CRM read, the next status questions were answered from held state under a receipt, 0 of 3 without a source; the [roadmap](ROADMAP.md) carries the gate table.
|
|
187
187
|
|
|
188
188
|
### The state contract, shipped
|
|
189
189
|
|
|
@@ -223,7 +223,7 @@ The current release can keep a team's **repository-scoped** memory in a dedicate
|
|
|
223
223
|
Today Hunch does not host that shared Git repository; teammates/CI use normal Git access and one maintainer connects it:
|
|
224
224
|
|
|
225
225
|
```bash
|
|
226
|
-
npm i -g @davesheffer/hunch@1.
|
|
226
|
+
npm i -g @davesheffer/hunch@1.32.2
|
|
227
227
|
hunch shared --repo git@github.com:acme/project-hunch-memory.git
|
|
228
228
|
git add .gitignore .hunch/team.json
|
|
229
229
|
git commit -m "chore: connect shared Hunch memory"
|
|
@@ -233,7 +233,7 @@ git push
|
|
|
233
233
|
Teammates then install the same version and run:
|
|
234
234
|
|
|
235
235
|
```bash
|
|
236
|
-
npm i -g @davesheffer/hunch@1.
|
|
236
|
+
npm i -g @davesheffer/hunch@1.32.2
|
|
237
237
|
git pull
|
|
238
238
|
hunch init
|
|
239
239
|
hunch doctor
|
|
@@ -264,6 +264,8 @@ The v1.19 correction-search benchmark remains scoped evidence, but it no longer
|
|
|
264
264
|
- **v1.21 — Project DNA.** Hunch gained deterministic, revision-specific repository profiles, bounded DNA context delivery, explainable Project Match checks and auditable profile deltas.
|
|
265
265
|
- **v1.22 — authorized collaboration evidence.** Hosts can contribute bounded PR/review evidence to Project DNA through a typed, sealed contract without raw collaboration persistence or policy-authority change.
|
|
266
266
|
- **v1.23 — native change proof and proof-carrying evidence work.** Exact Git change identity, graph before/after, decisions/constraints, blast radius and Change Gate result can be bound into a sealed evidence artifact without granting authority.
|
|
267
|
+
- **v1.25 – v1.30 — the state layer.** `nuryel.state/1` as code, `hunch serve` partitions, union reads, subject identity by external reference, replay determinism, human corrections outranking agent writes.
|
|
268
|
+
- **v1.32 — see what Hunch contributed.** Task contribution reports: what was delivered, what the agent reports it applied, what a rule verified, what a command observed; concise card, local evidence view.
|
|
267
269
|
|
|
268
270
|
See the [changelog](CHANGELOG.md) for release detail and the [roadmap](ROADMAP.md) for active work.
|
|
269
271
|
|
|
@@ -277,6 +279,9 @@ See the [changelog](CHANGELOG.md) for release detail and the [roadmap](ROADMAP.m
|
|
|
277
279
|
- [Project DNA](docs/project-dna.md)
|
|
278
280
|
- [Native change proof](docs/change-proof.md)
|
|
279
281
|
- [The autonomy ladder](docs/autonomy-ladder.md)
|
|
282
|
+
- [Task contribution reports](docs/task-reports.md)
|
|
283
|
+
- [Autonomous development](docs/autonomous-development.md)
|
|
284
|
+
- [Local cookbook](docs/cookbook.md)
|
|
280
285
|
- [Engineering Landscape Graph](docs/engineering-landscape.md)
|
|
281
286
|
- [Hunch roadmap](ROADMAP.md)
|
|
282
287
|
- [VS Code extension](vscode-extension/README.md)
|
package/dist/cli/index.js
CHANGED
|
@@ -80,8 +80,9 @@ import { appendEvent, readEvents } from "../core/events.js";
|
|
|
80
80
|
import { computeStats, formatStats } from "../core/stats.js";
|
|
81
81
|
import { injectionMode, resetSessionInjections } from "../core/hookcache.js";
|
|
82
82
|
import { recordServed, servedSummary } from "../core/served.js";
|
|
83
|
-
import { recordTaskDelivery, reportActivity } from "../core/taskReport.js";
|
|
83
|
+
import { recordTaskDelivery, reportActivity, reportPresentationEnabled, unseenLessons } from "../core/taskReport.js";
|
|
84
84
|
import { snapshotDeliveredRecords } from "../core/taskReportEvidence.js";
|
|
85
|
+
import { renderRecalledLine } from "../core/taskReportRender.js";
|
|
85
86
|
import { hookReportTaskId, startHookReport, stopHookReport, observeHookDenial } from "../core/taskReportHook.js";
|
|
86
87
|
import { recordHookObservation } from "../core/hookObservations.js";
|
|
87
88
|
import { contextHookOutput, denyHookOutput, hookProvider, normalizeHookEvent, stopHookOutput } from "../core/agenthook.js";
|
|
@@ -90,7 +91,7 @@ import { draftDuplicateOf, isAcceptedDuplicateAnchor } from "../core/dupdetect.j
|
|
|
90
91
|
import { planAutoReview, planMutations } from "../core/autoreview.js";
|
|
91
92
|
import { loadGoldenSet, evaluateRetrieval, evaluateTraversalLift } from "../eval/harness.js";
|
|
92
93
|
import { loadGuardCases, evalGuards, generateGuardCases } from "../eval/guards.js";
|
|
93
|
-
import { computeDrift } from "../core/drift.js";
|
|
94
|
+
import { DRIFT_KINDS, computeDrift } from "../core/drift.js";
|
|
94
95
|
import { renderCompilerScorecard, scoreCompilerCaseBank } from "../constitution/scorecard.js";
|
|
95
96
|
import { generateWiki, wikiStatus, wikiPrompt, publicHome, privateHome, readWikiManifestAt, nowData } from "../wiki/wiki.js";
|
|
96
97
|
import { adoptProsePrompt } from "../wiki/adopt.js";
|
|
@@ -108,6 +109,7 @@ import { MAX_LANDSCAPE_REFRESH_REVISIONS, planLandscapeAdoption, } from "../core
|
|
|
108
109
|
import { discoverRepositoryLandscape } from "../extractors/landscapeDiscovery.js";
|
|
109
110
|
import { checkConformance } from "../core/conformance.js";
|
|
110
111
|
import { ConstitutionService, policyEvaluationEnvelope } from "../constitution/service.js";
|
|
112
|
+
import { renderPolicyEvaluations } from "../constitution/renderEvaluations.js";
|
|
111
113
|
import { sourceGraphSnapshot } from "../constitution/evaluator.js";
|
|
112
114
|
import { renderProofCard } from "../constitution/card.js";
|
|
113
115
|
import { movePolicyArtifactsToPrivate } from "../constitution/repository.js";
|
|
@@ -2295,20 +2297,6 @@ policyCmd
|
|
|
2295
2297
|
store.close();
|
|
2296
2298
|
}
|
|
2297
2299
|
});
|
|
2298
|
-
function renderPolicyEvaluations(results) {
|
|
2299
|
-
if (!results.length)
|
|
2300
|
-
return ["No Constitution policies matched."];
|
|
2301
|
-
const icon = { satisfied: "✅", violated: "⛔", not_applicable: "·", unknown: "?", error: "‼" };
|
|
2302
|
-
const out = [`Constitution policy evaluation: ${results.length} canonical receipt(s)`];
|
|
2303
|
-
for (const r of results) {
|
|
2304
|
-
out.push(` ${icon[r.evaluation.result] ?? "·"} ${r.policy.id} [${r.policy.state}] ${r.evaluation.result}${r.blocks ? " — BLOCK" : ""}`);
|
|
2305
|
-
out.push(` ${r.evaluation.explanation}`);
|
|
2306
|
-
if (r.gate_error)
|
|
2307
|
-
out.push(` gate error: ${r.gate_error}`);
|
|
2308
|
-
out.push(` receipt: ${r.evaluation.deterministic_hash}`);
|
|
2309
|
-
}
|
|
2310
|
-
return out;
|
|
2311
|
-
}
|
|
2312
2300
|
// ---- constitution (deterministic evidence -> candidate bootstrap) --------
|
|
2313
2301
|
const constitutionCmd = program
|
|
2314
2302
|
.command("constitution")
|
|
@@ -4201,8 +4189,9 @@ program
|
|
|
4201
4189
|
if (opts.task) {
|
|
4202
4190
|
try {
|
|
4203
4191
|
const records = asOf ? [] : snapshotDeliveredRecords(store, envelope);
|
|
4192
|
+
const recalled = renderRecalledLine(unseenLessons(root, opts.task, records));
|
|
4204
4193
|
const occurrence = recordTaskDelivery(root, opts.task, envelope, records);
|
|
4205
|
-
console.log(`\
|
|
4194
|
+
console.log(`\n${recalled ? `${recalled}\n` : ""}Task evidence: ${opts.task} · occurrence ${occurrence}`);
|
|
4206
4195
|
}
|
|
4207
4196
|
catch {
|
|
4208
4197
|
console.error(`Task evidence could not be recorded for ${opts.task}; context remains available but report attribution is unverified.`);
|
|
@@ -4894,16 +4883,22 @@ program
|
|
|
4894
4883
|
}
|
|
4895
4884
|
receipts("served");
|
|
4896
4885
|
let reportNotice = "";
|
|
4886
|
+
let recalled = null;
|
|
4897
4887
|
if (reportTaskId) {
|
|
4898
4888
|
try {
|
|
4899
|
-
const
|
|
4889
|
+
const snapshots = snapshotDeliveredRecords(store, envelope);
|
|
4890
|
+
// The first time a lesson reaches this prompt's task, tell the USER in one
|
|
4891
|
+
// line (systemMessage); repeats of the same revision stay silent.
|
|
4892
|
+
recalled = reportPresentationEnabled(root) ? renderRecalledLine(unseenLessons(root, reportTaskId, snapshots)) : null;
|
|
4893
|
+
const occurrence = recordTaskDelivery(root, reportTaskId, envelope, snapshots);
|
|
4900
4894
|
reportNotice = `\n\nHunch task ${reportTaskId} · delivery ${occurrence}. Inspect exact application references with hunch_report(task_id).`;
|
|
4901
4895
|
}
|
|
4902
4896
|
catch {
|
|
4903
4897
|
reportNotice = "\n\nTask report observation unavailable; this delivery's task contribution remains unverified.";
|
|
4898
|
+
recalled = null;
|
|
4904
4899
|
}
|
|
4905
4900
|
}
|
|
4906
|
-
emitContext(provider, "PreToolUse", text + reportNotice);
|
|
4901
|
+
emitContext(provider, "PreToolUse", text + reportNotice, recalled ?? undefined);
|
|
4907
4902
|
}
|
|
4908
4903
|
catch {
|
|
4909
4904
|
// swallow — never block an edit on a hook failure
|
|
@@ -6031,8 +6026,13 @@ program
|
|
|
6031
6026
|
// ---- drift (doc≠graph detector; advisory + CI-gateable) -------------------
|
|
6032
6027
|
program
|
|
6033
6028
|
.command("drift")
|
|
6034
|
-
.description("Detect memory drift: dead refs, dangling supersedes, stale 'proposed' docs, commit-unresolvable (a decision cites a commit that no longer resolves in this repository), doc≠graph anchor-stale (a file still anchored to a superseded decision), markdown sections whose <!-- hunch:topic … dec_id --> pin points at a superseded or missing decision (AGENTS.md/CLAUDE.md as a drift surface), and ledger≠records replay divergence when this partition has a change ledger. Exits non-zero on any anchor-stale drift, topic collision or replay divergence — the doc≠graph and ledger≠records gate.")
|
|
6035
|
-
.
|
|
6029
|
+
.description("Detect memory drift: dead refs, dangling supersedes, stale 'proposed' docs, commit-unresolvable (a decision cites a commit that no longer resolves in this repository), doc≠graph anchor-stale (a file still anchored to a superseded decision), markdown sections whose <!-- hunch:topic … dec_id --> pin points at a superseded or missing decision (AGENTS.md/CLAUDE.md as a drift surface), and ledger≠records replay divergence when this partition has a change ledger. Exits non-zero on any anchor-stale drift, topic collision or replay divergence — the doc≠graph and ledger≠records gate. --fail-on adds further kinds to the gate (the release gate passes finding-stale).")
|
|
6030
|
+
.option("--fail-on <kinds>", `comma-separated drift kinds that also fail the gate (${DRIFT_KINDS.join(", ")})`)
|
|
6031
|
+
.action((opts) => {
|
|
6032
|
+
const failOn = new Set((opts.failOn ?? "").split(",").map((k) => k.trim()).filter(Boolean));
|
|
6033
|
+
for (const kind of failOn)
|
|
6034
|
+
if (!DRIFT_KINDS.includes(kind))
|
|
6035
|
+
return fail(`--fail-on: unknown drift kind "${kind}" (known: ${DRIFT_KINDS.join(", ")})`);
|
|
6036
6036
|
const { store, root } = storeFor();
|
|
6037
6037
|
try {
|
|
6038
6038
|
const { findings } = computeDrift(store, root);
|
|
@@ -6056,8 +6056,9 @@ program
|
|
|
6056
6056
|
if (replayCount && !replayFailing.length)
|
|
6057
6057
|
console.log(`· [replay-fingerprint] ${scopePath(own)}: ledger fold ${replay.replay_hash} ≠ stored ${replay.stored_hash}`);
|
|
6058
6058
|
const anchor = findings.filter((f) => f.kind === "anchor-stale" || f.kind === "doc-anchor-stale").length;
|
|
6059
|
-
|
|
6060
|
-
|
|
6059
|
+
const failing = findings.filter((f) => failOn.has(f.kind)).length;
|
|
6060
|
+
console.log(`\n${findings.length + replayCount} finding(s)${anchor ? `, ${anchor} doc≠graph (anchor-stale)` : ""}${collisions.size ? `, ${collisions.size} topic-collision(s)` : ""}${replayCount ? `, ${replayCount} ledger≠records (replay: hunch serve replay --root .)` : ""}${failing ? `, ${failing} failing by --fail-on (${[...failOn].join(", ")})` : ""}.`);
|
|
6061
|
+
if (anchor || collisions.size || replayCount || failing)
|
|
6061
6062
|
process.exitCode = 1;
|
|
6062
6063
|
}
|
|
6063
6064
|
finally {
|
|
@@ -6813,7 +6814,10 @@ function realpathNorm(p) {
|
|
|
6813
6814
|
function toRepoRel(root, abs) {
|
|
6814
6815
|
return relative(realpathNorm(root), realpathNorm(abs)).split("\\").join("/");
|
|
6815
6816
|
}
|
|
6816
|
-
function emitContext(provider, event, text
|
|
6817
|
+
function emitContext(provider, event, text,
|
|
6818
|
+
/** One user-facing line where the host shows hook messages (Claude Code's
|
|
6819
|
+
* `systemMessage`); never a block, never a second model turn. */
|
|
6820
|
+
systemMessage) {
|
|
6817
6821
|
if (event === "SessionStart") {
|
|
6818
6822
|
const warning = integrationSessionWarning(findRoot(), provider);
|
|
6819
6823
|
if (warning)
|
|
@@ -6821,7 +6825,7 @@ function emitContext(provider, event, text) {
|
|
|
6821
6825
|
}
|
|
6822
6826
|
const output = contextHookOutput(provider, event, text);
|
|
6823
6827
|
if (output)
|
|
6824
|
-
process.stdout.write(JSON.stringify(output));
|
|
6828
|
+
process.stdout.write(JSON.stringify(provider === "claude" && systemMessage ? { ...output, systemMessage } : output));
|
|
6825
6829
|
}
|
|
6826
6830
|
function emitDeny(provider, reason) {
|
|
6827
6831
|
const result = denyHookOutput(provider, reason);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { execFileSync, spawnSync } from "node:child_process";
|
|
2
2
|
import { dirname, join } from "node:path";
|
|
3
3
|
import { pathToFileURL } from "node:url";
|
|
4
|
-
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
5
5
|
import { tmpdir } from "node:os";
|
|
6
6
|
import { headSha } from "../extractors/git.js";
|
|
7
7
|
import { canonicalHash } from "./canonical.js";
|
|
@@ -125,7 +125,14 @@ export function evaluateExecutableBehaviorPolicy(root, policy, opts = {}) {
|
|
|
125
125
|
}
|
|
126
126
|
const dependency = dependencySnapshotForCommit(root, commit, assertion.dependency_snapshot_ids);
|
|
127
127
|
if (!dependency) {
|
|
128
|
-
|
|
128
|
+
// Two different situations hid behind one message (fnd_b421b3f7ab): a machine
|
|
129
|
+
// that never built the snapshot cache, and a policy whose pinned snapshots no
|
|
130
|
+
// longer match the commit's dependency inputs. Name each with its recovery;
|
|
131
|
+
// both stay `error`, never a coerced pass.
|
|
132
|
+
if (!existsSync(join(root, ".hunch-cache", "behavior-deps"))) {
|
|
133
|
+
return evaluation(policy, commit, { ...baseExecution, commit, error_code: "dependency-snapshot-cache-absent" }, "error", "no dependency snapshot cache exists on this machine (.hunch-cache/behavior-deps); executable behavior is unevaluated here, not failed — provision the policy's snapshots (hunch constitution bootstrap --behavior-deps <candidate>) or evaluate where they were built");
|
|
134
|
+
}
|
|
135
|
+
return evaluation(policy, commit, { ...baseExecution, commit, error_code: "dependency-snapshot-unavailable" }, "error", `no unique exact dependency snapshot matches this commit's package.json/package-lock.json among the policy's pinned ids (${assertion.dependency_snapshot_ids.join(", ")}); dependency inputs changed since compilation — re-plan and re-prove the policy (rb_g2_stale_policy_01)`);
|
|
129
136
|
}
|
|
130
137
|
const session = mkdtempSync(join(tmpdir(), "hunch-behavior-policy-"));
|
|
131
138
|
const hooks = join(session, "hooks-disabled");
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** Terminal rendering of canonical policy receipts, shared by `hunch policy
|
|
2
|
+
* evaluate` and the pre-commit `hunch check`. Rendering never alters a receipt.
|
|
3
|
+
* Receipts that did not evaluate (error / unknown / not_applicable) and share
|
|
4
|
+
* one explanation are grouped, so ten policies failing for the same
|
|
5
|
+
* environmental reason read as one actionable block instead of ten
|
|
6
|
+
* (fnd_b421b3f7ab); satisfied and violated policies always stay one per line. */
|
|
7
|
+
import type { PolicyEvaluationSet } from "./service.js";
|
|
8
|
+
export declare function renderPolicyEvaluations(results: PolicyEvaluationSet[]): string[];
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
const ICON = { satisfied: "✅", violated: "⛔", not_applicable: "·", unknown: "?", error: "‼" };
|
|
2
|
+
const GROUP_AT = 3;
|
|
3
|
+
function groupKey(r) {
|
|
4
|
+
const result = r.evaluation.result;
|
|
5
|
+
const groupable = (result === "error" || result === "unknown" || result === "not_applicable") && !r.blocks && !r.gate_error;
|
|
6
|
+
if (!groupable)
|
|
7
|
+
return `one ${r.policy.id}`;
|
|
8
|
+
return `${r.policy.state} ${result} ${r.evaluation.explanation}`;
|
|
9
|
+
}
|
|
10
|
+
export function renderPolicyEvaluations(results) {
|
|
11
|
+
if (!results.length)
|
|
12
|
+
return ["No Constitution policies matched."];
|
|
13
|
+
const out = [`Constitution policy evaluation: ${results.length} canonical receipt(s)`];
|
|
14
|
+
const groups = new Map();
|
|
15
|
+
for (const r of results) {
|
|
16
|
+
const key = groupKey(r);
|
|
17
|
+
const members = groups.get(key) ?? [];
|
|
18
|
+
members.push(r);
|
|
19
|
+
groups.set(key, members);
|
|
20
|
+
}
|
|
21
|
+
const rendered = new Set();
|
|
22
|
+
for (const r of results) {
|
|
23
|
+
if (rendered.has(r))
|
|
24
|
+
continue;
|
|
25
|
+
const members = groups.get(groupKey(r)) ?? [r];
|
|
26
|
+
const icon = ICON[r.evaluation.result] ?? "·";
|
|
27
|
+
if (members.length >= GROUP_AT) {
|
|
28
|
+
for (const member of members)
|
|
29
|
+
rendered.add(member);
|
|
30
|
+
const ids = members.map((m) => m.policy.id);
|
|
31
|
+
const receipts = members.map((m) => `${m.policy.id}=${m.evaluation.deterministic_hash.slice(0, 17)}`);
|
|
32
|
+
out.push(` ${icon} ${members.length} policies [${r.policy.state}] ${r.evaluation.result} — same cause`);
|
|
33
|
+
out.push(` ${r.evaluation.explanation}`);
|
|
34
|
+
out.push(` policies: ${ids.join(", ")}`);
|
|
35
|
+
out.push(` receipts: ${receipts.join(" ")}`);
|
|
36
|
+
out.push(" full receipts: hunch policy evaluate --json");
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
rendered.add(r);
|
|
40
|
+
out.push(` ${icon} ${r.policy.id} [${r.policy.state}] ${r.evaluation.result}${r.blocks ? " — BLOCK" : ""}`);
|
|
41
|
+
out.push(` ${r.evaluation.explanation}`);
|
|
42
|
+
if (r.gate_error)
|
|
43
|
+
out.push(` gate error: ${r.gate_error}`);
|
|
44
|
+
out.push(` receipt: ${r.evaluation.deterministic_hash}`);
|
|
45
|
+
}
|
|
46
|
+
return out;
|
|
47
|
+
}
|
|
48
|
+
//# sourceMappingURL=renderEvaluations.js.map
|
package/dist/core/drift.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { HunchStore } from "../store/hunchStore.js";
|
|
2
|
-
export
|
|
2
|
+
export declare const DRIFT_KINDS: readonly ["dead-ref", "supersede", "doc-stale", "anchor-stale", "doc-anchor-stale", "doc-anchor-dangling", "wiki-stale", "finding-stale", "premise-stale", "commit-unresolvable", "madr-stale", "madr-edited", "madr-orphan"];
|
|
3
|
+
export type DriftKind = typeof DRIFT_KINDS[number];
|
|
3
4
|
export interface DriftFinding {
|
|
4
5
|
kind: DriftKind;
|
|
5
6
|
id: string;
|
package/dist/core/drift.js
CHANGED
|
@@ -27,6 +27,7 @@ import { markdownDocs, STALE_MARKER, SRC_REF } from "./docscan.js";
|
|
|
27
27
|
import { computeWikiDrift } from "../wiki/wiki.js";
|
|
28
28
|
import { computeMadrDrift } from "../integrations/madrManifest.js";
|
|
29
29
|
import { commitsExist, isGitRepo } from "../extractors/git.js";
|
|
30
|
+
export const DRIFT_KINDS = ["dead-ref", "supersede", "doc-stale", "anchor-stale", "doc-anchor-stale", "doc-anchor-dangling", "wiki-stale", "finding-stale", "premise-stale", "commit-unresolvable", "madr-stale", "madr-edited", "madr-orphan"];
|
|
30
31
|
export function computeDrift(store, root, deps = {}) {
|
|
31
32
|
const findings = [];
|
|
32
33
|
const decisions = store.recs("decisions");
|
|
@@ -961,6 +961,9 @@ export declare const STATE_INVARIANTS: readonly [{
|
|
|
961
961
|
}, {
|
|
962
962
|
readonly id: "derived-state-writer-owns-currentness";
|
|
963
963
|
readonly statement: "No source writes the drawer. The writer of a current derived statement owns keeping its dependencies true: re-validate them on a schedule or on a source event, and write the statement back stale with the moved pointer as cause when one no longer holds. Without this duty an agent may capture source-backed observations only as unknown; observations never assert currentness.";
|
|
964
|
+
}, {
|
|
965
|
+
readonly id: "one-current-derived-per-subject-transform";
|
|
966
|
+
readonly statement: "A subject holds at most one current derived statement per transform: a new one must name the incumbent in supersedes (refused 409 with the incumbent named otherwise); the same identity written again updates or replays that record.";
|
|
964
967
|
}];
|
|
965
968
|
/** The memory supply chain's top tier: a record whose provenance a human signed. Same tier rule
|
|
966
969
|
* as the strict gate's (strictgate.isHumanConfirmed), applied to a record instead of a source. */
|
|
@@ -306,6 +306,7 @@ export const STATE_INVARIANTS = [
|
|
|
306
306
|
{ id: "one-entity-per-external-ref", statement: "One external record is one entity in a partition: a second active entity carrying an external key an incumbent already carries is refused with the incumbent named, and a subject written as that record's external key is refused with the entity's id named. Identity is explicit refs, never similarity; merge is explicit — a retired entity names the survivor in `merged_into`, the ledger holds the `retired` event, nothing under the old id is rewritten and reads resolve to the survivor — and split is the explicit reverse; never a silent rewrite." },
|
|
307
307
|
{ id: "human-correction-outranks-agent-writes", statement: "A record a human confirmed is never overwritten or superseded by an agent or service principal: the agent may replay it, write derived state back stale with the external cause that moved, or close a commitment with a receipt on record. Changing what the human said takes a human." },
|
|
308
308
|
{ id: "derived-state-writer-owns-currentness", statement: "No source writes the drawer. The writer of a current derived statement owns keeping its dependencies true: re-validate them on a schedule or on a source event, and write the statement back stale with the moved pointer as cause when one no longer holds. Without this duty an agent may capture source-backed observations only as unknown; observations never assert currentness." },
|
|
309
|
+
{ id: "one-current-derived-per-subject-transform", statement: "A subject holds at most one current derived statement per transform: a new one must name the incumbent in supersedes (refused 409 with the incumbent named otherwise); the same identity written again updates or replays that record." },
|
|
309
310
|
];
|
|
310
311
|
const grantKey = (scope) => scopePath(scope);
|
|
311
312
|
/** The memory supply chain's top tier: a record whose provenance a human signed. Same tier rule
|
|
@@ -198,6 +198,12 @@ export declare function readLessonHistory(root: string, reference: LessonReferen
|
|
|
198
198
|
before?: number;
|
|
199
199
|
}): LessonHistory;
|
|
200
200
|
export declare function startReportTask(root: string, title: string, taskId?: string): ReportTask;
|
|
201
|
+
/** The record revisions among `records` that this task has not received before.
|
|
202
|
+
* Powers the one-line "Hunch recalled …" indication on a lesson's FIRST delivery
|
|
203
|
+
* in a task; repeats of the same revision stay silent (deduplicated per task and
|
|
204
|
+
* revision, never per session or file). Read-only; never throws for callers
|
|
205
|
+
* that must stay silent on failure — they catch. */
|
|
206
|
+
export declare function unseenLessons(root: string, taskId: string, records: readonly ReportRecord[]): ReportRecord[];
|
|
201
207
|
/** Strict operation for explicit callers. Passive integrations catch failure
|
|
202
208
|
* and disclose it without blocking context delivery. Empty envelopes count. */
|
|
203
209
|
export declare function recordTaskDelivery(root: string, taskId: string, envelope: DeliveryEnvelope, records: ReportRecord[], occurrenceId?: string): string;
|
package/dist/core/taskReport.js
CHANGED
|
@@ -281,6 +281,21 @@ function appendEvent(root, taskId, kind, body, eventId) {
|
|
|
281
281
|
return id;
|
|
282
282
|
}));
|
|
283
283
|
}
|
|
284
|
+
/** The record revisions among `records` that this task has not received before.
|
|
285
|
+
* Powers the one-line "Hunch recalled …" indication on a lesson's FIRST delivery
|
|
286
|
+
* in a task; repeats of the same revision stay silent (deduplicated per task and
|
|
287
|
+
* revision, never per session or file). Read-only; never throws for callers
|
|
288
|
+
* that must stay silent on failure — they catch. */
|
|
289
|
+
export function unseenLessons(root, taskId, records) {
|
|
290
|
+
if (!records.length)
|
|
291
|
+
return [];
|
|
292
|
+
return taskDb(root, db => {
|
|
293
|
+
readTask(db, root, taskId);
|
|
294
|
+
const seen = db.prepare(`SELECT 1 FROM report_record_links l JOIN report_events e ON e.event_id = l.event_id
|
|
295
|
+
WHERE e.task_id = ? AND e.kind = 'delivery' AND l.kind = ? AND l.record_id = ? AND l.content_hash = ? LIMIT 1`);
|
|
296
|
+
return records.filter(r => !seen.get(taskId, r.kind, r.record_id, r.content_hash));
|
|
297
|
+
});
|
|
298
|
+
}
|
|
284
299
|
/** Strict operation for explicit callers. Passive integrations catch failure
|
|
285
300
|
* and disclose it without blocking context delivery. Empty envelopes count. */
|
|
286
301
|
export function recordTaskDelivery(root, taskId, envelope, records, occurrenceId = `hocc_${randomBytes(12).toString("hex")}`) {
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import { type LessonHistory, type TaskReport } from "./taskReport.js";
|
|
2
2
|
export declare function writeTaskReportHtml(root: string, taskId: string, publicOnly?: boolean): string;
|
|
3
|
+
/** One short line for the first time a lesson reaches a task; null when every
|
|
4
|
+
* delivered revision was already seen in this task. Never a banner per delivery. */
|
|
5
|
+
export declare function renderRecalledLine(fresh: readonly {
|
|
6
|
+
title: string;
|
|
7
|
+
}[]): string | null;
|
|
3
8
|
export declare function renderTaskReport(report: TaskReport): string;
|
|
4
9
|
/** Standalone, local-only projection. No active content, external assets or
|
|
5
10
|
* untrusted outbound URLs; evidence references are internal anchors. */
|
|
@@ -36,6 +36,14 @@ function ruleStanding(report) {
|
|
|
36
36
|
function recordTitle(report, rule) {
|
|
37
37
|
return uniqueRecords(report).find(r => r.kind === rule.kind && r.record_id === rule.record_id && r.content_hash === rule.content_hash)?.title ?? rule.record_id;
|
|
38
38
|
}
|
|
39
|
+
/** One short line for the first time a lesson reaches a task; null when every
|
|
40
|
+
* delivered revision was already seen in this task. Never a banner per delivery. */
|
|
41
|
+
export function renderRecalledLine(fresh) {
|
|
42
|
+
if (!fresh.length)
|
|
43
|
+
return null;
|
|
44
|
+
const rest = fresh.length - 1;
|
|
45
|
+
return `Hunch recalled: ${clip(fresh[0].title, 90)}${rest ? ` (+${rest} more lesson${rest === 1 ? "" : "s"})` : ""}`;
|
|
46
|
+
}
|
|
39
47
|
export function renderTaskReport(report) {
|
|
40
48
|
const records = uniqueRecords(report);
|
|
41
49
|
const lines = [`Hunch · ${clip(report.task.title)}`, `Task ${report.task.task_id} · ${report.task.state}`];
|
package/dist/extractors/git.js
CHANGED
|
@@ -509,6 +509,14 @@ const READ_REMOTE_TIMEOUT_MS = 5_000;
|
|
|
509
509
|
// drains every already-durable JSON write itself. This removes the old "maybe a
|
|
510
510
|
// third capture sweeps it later" liveness hole.
|
|
511
511
|
const CAPTURE_LOCK_HANDOFF_MS = 120_000;
|
|
512
|
+
/** Longest one git call inside a memory flush may take before it is stopped and the flush
|
|
513
|
+
* reports durability "local" (HUNCH_COMMIT_GIT_TIMEOUT_MS overrides; tests use a short one). */
|
|
514
|
+
const COMMIT_GIT_TIMEOUT_MS = 60_000;
|
|
515
|
+
const SLOW_FLUSH_MS = 5_000;
|
|
516
|
+
function commitGitTimeoutMs() {
|
|
517
|
+
const raw = Number(process.env.HUNCH_COMMIT_GIT_TIMEOUT_MS);
|
|
518
|
+
return Number.isFinite(raw) && raw > 0 ? raw : COMMIT_GIT_TIMEOUT_MS;
|
|
519
|
+
}
|
|
512
520
|
function unsafeOverlayPublication(hunchDir, protectedRepoRoot) {
|
|
513
521
|
let currentOverlayRoot = dirname(resolve(hunchDir));
|
|
514
522
|
try {
|
|
@@ -566,10 +574,21 @@ export function commitAndPushHunch(hunchDir, message, opts) {
|
|
|
566
574
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
567
575
|
const startedAt = Date.now();
|
|
568
576
|
try {
|
|
569
|
-
|
|
577
|
+
// A served write blocks on this call: bound it, and never let a commit trigger git's
|
|
578
|
+
// automatic gc (minutes of repacking inside one write, fnd_4318727d35). A timed-out
|
|
579
|
+
// call returns false, the flush reports durability "local", and the next flush
|
|
580
|
+
// sweeps the same files up — nothing is lost, and the server is not frozen.
|
|
581
|
+
execFileSync("git", ["-C", hunchDir, "-c", "gc.auto=0", ...args], { stdio: "ignore", env, timeout: commitGitTimeoutMs() });
|
|
582
|
+
const took = Date.now() - startedAt;
|
|
583
|
+
if (took > SLOW_FLUSH_MS)
|
|
584
|
+
console.error(`hunch: git ${args.find((a) => !a.startsWith("-") && a !== "core.autocrlf=false") ?? args[0]} in "${hunchDir}" took ${took} ms`);
|
|
570
585
|
return true;
|
|
571
586
|
}
|
|
572
587
|
catch (error) {
|
|
588
|
+
if (error.signal === "SIGTERM" || error.code === "ETIMEDOUT") {
|
|
589
|
+
console.error(`hunch: git ${args.find((a) => !a.startsWith("-")) ?? args[0]} in "${hunchDir}" exceeded ${commitGitTimeoutMs()} ms and was stopped; the write stays local until the next flush`);
|
|
590
|
+
return false;
|
|
591
|
+
}
|
|
573
592
|
// best-effort: nothing staged / not a repo / offline — EXCEPT a
|
|
574
593
|
// stranded index.lock, which would otherwise fail every future
|
|
575
594
|
// flush silently (issue #53); heal it and retry once.
|
|
@@ -623,7 +642,7 @@ export function commitAndPushHunch(hunchDir, message, opts) {
|
|
|
623
642
|
const staged = stagedMemoryPaths(hunchDir, env, opts.push !== false);
|
|
624
643
|
if (staged === null) {
|
|
625
644
|
try {
|
|
626
|
-
execFileSync("git", ["-C", hunchDir, "reset", "-q", "--", "."], { stdio: "ignore", env });
|
|
645
|
+
execFileSync("git", ["-C", hunchDir, "reset", "-q", "--", "."], { stdio: "ignore", env, timeout: commitGitTimeoutMs() });
|
|
627
646
|
}
|
|
628
647
|
catch { /* best-effort unstage */ }
|
|
629
648
|
// Public-store commits (push:false) skip QUIETLY: a non-memory staged set there is
|
|
@@ -691,8 +710,9 @@ export function commitAndPushHunch(hunchDir, message, opts) {
|
|
|
691
710
|
...(opts.push === false ? [] : ["-c", `core.attributesFile=${gitNullDevice()}`]),
|
|
692
711
|
"-c", "core.autocrlf=false",
|
|
693
712
|
"-c", "commit.gpgsign=false",
|
|
713
|
+
"-c", "gc.auto=0",
|
|
694
714
|
"commit", "--no-gpg-sign", "--only", "-m", message, "--", ...commitPaths,
|
|
695
|
-
], { stdio: "ignore", env, timeout:
|
|
715
|
+
], { stdio: "ignore", env, timeout: commitGitTimeoutMs() });
|
|
696
716
|
committed = true;
|
|
697
717
|
}
|
|
698
718
|
catch (error) {
|
|
@@ -775,7 +795,7 @@ function stagedMemoryPaths(hunchDir, env, allowMemoryDeletions = false) {
|
|
|
775
795
|
let out = "";
|
|
776
796
|
let prefix = "";
|
|
777
797
|
try {
|
|
778
|
-
prefix = execFileSync("git", ["-C", hunchDir, "rev-parse", "--show-prefix"], { encoding: "utf8", env }).trim().replace(/\\/g, "/");
|
|
798
|
+
prefix = execFileSync("git", ["-C", hunchDir, "rev-parse", "--show-prefix"], { encoding: "utf8", env, timeout: commitGitTimeoutMs() }).trim().replace(/\\/g, "/");
|
|
779
799
|
}
|
|
780
800
|
catch {
|
|
781
801
|
return null;
|
|
@@ -786,7 +806,7 @@ function stagedMemoryPaths(hunchDir, env, allowMemoryDeletions = false) {
|
|
|
786
806
|
// heuristic rename presentation so the exact paths remain independently
|
|
787
807
|
// auditable against the contained-memory rules below.
|
|
788
808
|
try {
|
|
789
|
-
out = execFileSync("git", ["-C", hunchDir, "diff", "--cached", "--no-ext-diff", "--no-textconv", "--no-renames", "--name-status"], { encoding: "utf8", env });
|
|
809
|
+
out = execFileSync("git", ["-C", hunchDir, "diff", "--cached", "--no-ext-diff", "--no-textconv", "--no-renames", "--name-status"], { encoding: "utf8", env, timeout: commitGitTimeoutMs() });
|
|
790
810
|
}
|
|
791
811
|
catch {
|
|
792
812
|
return null;
|
package/dist/mcp/server.js
CHANGED
|
@@ -40,7 +40,8 @@ import { PROJECT_DNA_DELTA_SCHEMA_VERSION, diffProjectDna } from "../core/projec
|
|
|
40
40
|
import { projectDnaDeliverySupplement } from "../core/projectDnaDelivery.js";
|
|
41
41
|
import { armExecutionObligations, loadPipelineState, savePipelineState } from "../core/pipeline.js";
|
|
42
42
|
import { recordServed } from "../core/served.js";
|
|
43
|
-
import { TaskIdSchema, recordTaskDelivery } from "../core/taskReport.js";
|
|
43
|
+
import { TaskIdSchema, recordTaskDelivery, unseenLessons } from "../core/taskReport.js";
|
|
44
|
+
import { renderRecalledLine } from "../core/taskReportRender.js";
|
|
44
45
|
import { observeReportCapture } from "../core/taskReportCapture.js";
|
|
45
46
|
import { snapshotDeliveredRecords } from "../core/taskReportEvidence.js";
|
|
46
47
|
import { registerTaskReportTools } from "./taskReportTools.js";
|
|
@@ -1049,8 +1050,10 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
1049
1050
|
try {
|
|
1050
1051
|
// Historical contexts must not borrow today's record text/revision.
|
|
1051
1052
|
const records = as_of ? [] : snapshotDeliveredRecords(store, envelope);
|
|
1053
|
+
// First delivery of a revision in this task earns one line; repeats stay quiet.
|
|
1054
|
+
const recalled = renderRecalledLine(unseenLessons(root, task_id, records));
|
|
1052
1055
|
const occurrence = recordTaskDelivery(root, task_id, envelope, records);
|
|
1053
|
-
result.content.push({ type: "text", text: `Task evidence: ${task_id} · occurrence ${occurrence}.\n${records.slice(0, 20).map(r => `${r.record_id} @ ${r.content_hash}`).join("\n")}${records.length > 20 ? "\nMore record identities: hunch_report(task_id)." : ""}` });
|
|
1056
|
+
result.content.push({ type: "text", text: `${recalled ? `${recalled}\n` : ""}Task evidence: ${task_id} · occurrence ${occurrence}.\n${records.slice(0, 20).map(r => `${r.record_id} @ ${r.content_hash}`).join("\n")}${records.length > 20 ? "\nMore record identities: hunch_report(task_id)." : ""}` });
|
|
1054
1057
|
}
|
|
1055
1058
|
catch {
|
|
1056
1059
|
result.content.push({ type: "text", text: `Task evidence could not be recorded for ${task_id}. Context remains available; this delivery's report attribution is unverified. Check the task ID, working directory, and local ledger.` });
|
|
@@ -806,6 +806,22 @@ export function writeState(store, input, opts = {}) {
|
|
|
806
806
|
supersedes = null; // already closed by this record: nothing to close again, no second "superseded" event
|
|
807
807
|
}
|
|
808
808
|
}
|
|
809
|
+
// one-current-derived-per-subject-transform: a NEW current derived statement on a subject that
|
|
810
|
+
// already holds a current statement under the same transform must name it in `supersedes`.
|
|
811
|
+
// Otherwise a writer that never names its predecessor leaves a growing pile of "current"
|
|
812
|
+
// statements that every reader has to reconcile (season finding fnd_1939ced249: up to 58 on
|
|
813
|
+
// one subject over half a year). Writing the same identity again is an update or a replay of
|
|
814
|
+
// that record and is not affected; a different transform is a different statement.
|
|
815
|
+
if (facet === "derived" && record.state === "current") {
|
|
816
|
+
const d = record;
|
|
817
|
+
const incumbent = store.recsInHome("derived", home).find((r) => {
|
|
818
|
+
const x = r;
|
|
819
|
+
return x.id !== id && x.id !== supersedes && x.subject === d.subject && x.transform_version === d.transform_version && x.state === "current" && x.valid_to === null;
|
|
820
|
+
});
|
|
821
|
+
if (incumbent) {
|
|
822
|
+
throw new StateRefusal("conflict", `${d.subject} already has a current ${d.transform_version} statement ${incumbent.id}; pass supersedes: "${incumbent.id}" to replace it, or write that identity to update it`, { incumbent_id: incumbent.id, reason: "one-current-derived-per-subject-transform" });
|
|
823
|
+
}
|
|
824
|
+
}
|
|
809
825
|
// The chain (Gate 4): a receipt names what it rested on, a closure names the receipt.
|
|
810
826
|
// Both are checked against the drawer, grants first, before anything lands.
|
|
811
827
|
if (facet === "receipts")
|
package/package.json
CHANGED
package/server.json
CHANGED
|
@@ -7,13 +7,13 @@
|
|
|
7
7
|
"source": "github"
|
|
8
8
|
},
|
|
9
9
|
"websiteUrl": "https://www.hunchmemory.com",
|
|
10
|
-
"version": "1.32.
|
|
10
|
+
"version": "1.32.3",
|
|
11
11
|
"packages": [
|
|
12
12
|
{
|
|
13
13
|
"registryType": "npm",
|
|
14
14
|
"registryBaseUrl": "https://registry.npmjs.org",
|
|
15
15
|
"identifier": "@davesheffer/hunch",
|
|
16
|
-
"version": "1.32.
|
|
16
|
+
"version": "1.32.3",
|
|
17
17
|
"runtimeHint": "npx",
|
|
18
18
|
"packageArguments": [
|
|
19
19
|
{
|