@davesheffer/hunch 1.28.0 → 1.30.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +29 -17
- package/dist/cli/index.js +122 -9
- package/dist/cli/reviewMemory.js +32 -0
- package/dist/cli/serve.js +44 -0
- package/dist/core/groundingLag.js +82 -0
- package/dist/core/reviewMemory.js +100 -0
- package/dist/core/stateContract.js +14 -0
- package/dist/core/stateRecords.js +42 -1
- package/dist/extractors/diff.js +26 -21
- package/dist/extractors/git.js +4 -0
- package/dist/extractors/languages.js +8 -0
- package/dist/integrations/hooks.js +41 -0
- package/dist/integrations/providers.js +8 -0
- package/dist/mcp/server.js +79 -49
- package/dist/store/changeLedger.js +5 -0
- package/dist/store/replay.js +153 -0
- package/dist/store/stateBinding.js +260 -18
- package/dist/synthesis/provider.js +6 -2
- package/dist/synthesis/synthesize.js +36 -15
- package/package.json +2 -2
- package/server.json +2 -2
- package/tooling/competitive-watch.mjs +1 -0
|
@@ -44,9 +44,29 @@ export const ExternalRefSchema = z.object({
|
|
|
44
44
|
observed_at: z.string().regex(ISO),
|
|
45
45
|
locator: credentialFree("external locator").optional(),
|
|
46
46
|
}).strict();
|
|
47
|
+
/** The external system's own key, canonicalized so two writers that copied it from the same
|
|
48
|
+
* system agree byte for byte: Unicode NFC, trimmed, internal whitespace collapsed. Case is
|
|
49
|
+
* preserved — the key belongs to the external system, and folding it could merge two of its
|
|
50
|
+
* records. No other guessing: identity here is explicit refs, never similarity. */
|
|
51
|
+
export function canonicalObjectKey(key) {
|
|
52
|
+
return key.normalize("NFC").trim().replace(/\s+/g, " ");
|
|
53
|
+
}
|
|
54
|
+
/** The identity of an external record across writers: system, type and canonical key. Two
|
|
55
|
+
* entities in one partition that carry the same external key are the same thing. */
|
|
56
|
+
export function externalKey(ref) {
|
|
57
|
+
return `${ref.system}/${ref.object_type}/${canonicalObjectKey(ref.object_key)}`;
|
|
58
|
+
}
|
|
59
|
+
/** The subject an external record is known by, the convention the read verb already uses for
|
|
60
|
+
* receipts (`event:26904`): the object type and the canonical key. When an entity in the
|
|
61
|
+
* partition carries the ref, that entity's id is the subject and this form resolves to it. */
|
|
62
|
+
export function subjectOfRef(ref) {
|
|
63
|
+
return `${ref.object_type}:${canonicalObjectKey(ref.object_key)}`;
|
|
64
|
+
}
|
|
47
65
|
/** What a derived statement rests on. Exactly what a currentness check re-validates. */
|
|
48
66
|
export const DependencyRefSchema = z.discriminatedUnion("kind", [
|
|
49
|
-
|
|
67
|
+
/** `scope` (additive) points into ANOTHER partition — the repository decision an
|
|
68
|
+
* organization-drawer receipt rests on. Absent, the ref is in the record's own partition. */
|
|
69
|
+
z.object({ kind: z.literal("record"), id: z.string().regex(TOKEN), record_hash: z.string().regex(SHA256), scope: ScopeSchema.optional() }).strict(),
|
|
50
70
|
z.object({ kind: z.literal("external"), ref: ExternalRefSchema }).strict(),
|
|
51
71
|
z.object({ kind: z.literal("schema"), name: z.string().max(256), fingerprint: z.string().regex(SHA256) }).strict(),
|
|
52
72
|
]);
|
|
@@ -66,6 +86,12 @@ export const ActionReceiptSchema = z.object({
|
|
|
66
86
|
verified_at: z.string().regex(ISO).optional(),
|
|
67
87
|
result_fingerprint: z.string().regex(SHA256).optional(),
|
|
68
88
|
invalidates: z.array(z.string().max(512)).max(64).default([]),
|
|
89
|
+
/** What the action rested on (additive): the decision it implements, the change proof for
|
|
90
|
+
* the shipped revision, the commitment or incident it answers. Same shape as a derived
|
|
91
|
+
* statement's dependencies, so "what does this closure rest on" is one read. A record ref
|
|
92
|
+
* in the receipt's own partition is verified by hash on write; a ref into another partition
|
|
93
|
+
* is a pointer the reader resolves with `records`, grants first. */
|
|
94
|
+
rests_on: z.array(DependencyRefSchema).max(64).optional(),
|
|
69
95
|
provenance: ProvenanceSchema,
|
|
70
96
|
}).strict();
|
|
71
97
|
/** committed — an obligation with a due date and an in-force window. */
|
|
@@ -80,6 +106,9 @@ export const CommitmentSchema = z.object({
|
|
|
80
106
|
status: z.enum(["open", "waiting", "done", "cancelled"]),
|
|
81
107
|
source: ExternalRefSchema.optional(),
|
|
82
108
|
evidence_excerpt: z.string().max(900).optional(),
|
|
109
|
+
/** The receipt that fulfilled this commitment (additive). A closure names what happened:
|
|
110
|
+
* the binding refuses a `closed_by` that is not a succeeded/verified receipt on record. */
|
|
111
|
+
closed_by: z.string().regex(/^nrc_[a-f0-9]{24}$/).optional(),
|
|
83
112
|
valid_from: z.string().regex(ISO),
|
|
84
113
|
valid_to: z.string().regex(ISO).nullable().default(null),
|
|
85
114
|
provenance: ProvenanceSchema,
|
|
@@ -113,10 +142,22 @@ export const ExternalEntitySchema = z.object({
|
|
|
113
142
|
refs: z.array(ExternalRefSchema).min(1).max(64),
|
|
114
143
|
attributes: z.record(z.string().max(128), AttributeValue).default({}),
|
|
115
144
|
lifecycle: z.enum(["active", "deprecated", "retired"]).default("active"),
|
|
145
|
+
/** Audited merge (additive): this entity was folded into another. Set only on a retired
|
|
146
|
+
* entity; the survivor must be an active entity on record. Nothing under the retired id is
|
|
147
|
+
* rewritten — reads resolve the old id and its keys to the survivor, and the ledger holds
|
|
148
|
+
* the `retired` event with its provenance. A split is the explicit reverse: retire or
|
|
149
|
+
* re-key the survivor, then write the entity active again without `merged_into`. */
|
|
150
|
+
merged_into: z.string().min(3).max(2048).optional(),
|
|
116
151
|
provenance: ProvenanceSchema,
|
|
117
152
|
created_at: z.string().regex(ISO),
|
|
118
153
|
updated_at: z.string().regex(ISO),
|
|
119
154
|
}).strict().superRefine((entity, ctx) => {
|
|
155
|
+
if (entity.merged_into !== undefined) {
|
|
156
|
+
if (entity.lifecycle !== "retired")
|
|
157
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["merged_into"], message: "a merged entity is retired; the survivor stays active" });
|
|
158
|
+
if (entity.merged_into === entity.id)
|
|
159
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["merged_into"], message: "an entity cannot merge into itself" });
|
|
160
|
+
}
|
|
120
161
|
const prefix = `${entity.kind}:`;
|
|
121
162
|
const key = entity.id.startsWith(prefix) ? entity.id.slice(prefix.length) : "";
|
|
122
163
|
if (!key.trim() || entity.id !== resourceId(entity.kind, key)) {
|
package/dist/extractors/diff.js
CHANGED
|
@@ -26,7 +26,7 @@ const DECL_PATTERNS = [
|
|
|
26
26
|
// `class Foo(Bar):` header too, and — since declOf() returns on the first match —
|
|
27
27
|
// always wins for Python class lines before any Python-specific pattern would run.
|
|
28
28
|
];
|
|
29
|
-
import { languageFor } from "./languages.js";
|
|
29
|
+
import { languageFor, isSubstantive } from "./languages.js";
|
|
30
30
|
const IMPORT_RE = /^\s*import\s+(?:[^'"]*from\s+)?['"]([^'"]+)['"]/;
|
|
31
31
|
const CONT_IMPORT_RE = /^\s*\}?\s*from\s+['"]([^'"]+)['"]/; // multi-line: "} from 'x'"
|
|
32
32
|
const REQUIRE_RE = /\brequire\(\s*['"]([^'"]+)['"]\s*\)/;
|
|
@@ -114,7 +114,7 @@ export function analyzeDiff(diff) {
|
|
|
114
114
|
else if (raw.startsWith("rename to ") || raw.startsWith("copy to ")) {
|
|
115
115
|
const to = raw.slice(raw.indexOf(" to ") + 4).trim();
|
|
116
116
|
curFile = to;
|
|
117
|
-
if (
|
|
117
|
+
if (isSubstantive(to))
|
|
118
118
|
filesRenamed.push({ from: renameFrom, to });
|
|
119
119
|
}
|
|
120
120
|
else if (raw.startsWith("--- ")) {
|
|
@@ -126,7 +126,7 @@ export function analyzeDiff(diff) {
|
|
|
126
126
|
const p = raw.slice(4).trim();
|
|
127
127
|
if (p !== "/dev/null")
|
|
128
128
|
curFile = stripAB(p); // new path preferred
|
|
129
|
-
if (
|
|
129
|
+
if (isSubstantive(curFile)) {
|
|
130
130
|
if (curAdded)
|
|
131
131
|
filesAdded.add(curFile);
|
|
132
132
|
else if (curDeleted)
|
|
@@ -138,45 +138,50 @@ export function analyzeDiff(diff) {
|
|
|
138
138
|
// ---- inside a hunk: content lines ----
|
|
139
139
|
if (raw.startsWith("+")) {
|
|
140
140
|
const body = raw.slice(1);
|
|
141
|
-
// Raw added lines are captured for EVERY file, before the
|
|
141
|
+
// Raw added lines are captured for EVERY file, before the substantive gate:
|
|
142
142
|
// content-matched constraints and Veto tripwires are not code-only rules
|
|
143
143
|
// (a blocking invariant legitimately scopes .github/workflows/**, *.sql,
|
|
144
144
|
// Dockerfile). Skipping them here left `scopedAdded` empty, which
|
|
145
145
|
// buildCheckReport reads as "cannot prove a violation ⇒ complies" — so the
|
|
146
146
|
// pre-edit hook denied the edit while `hunch check --strict` passed the
|
|
147
|
-
// very commit that landed it.
|
|
148
|
-
//
|
|
147
|
+
// very commit that landed it. The churn counters below now also include
|
|
148
|
+
// prose (isSubstantive, issue #12); symbol/import extraction stays
|
|
149
|
+
// code-only (isCode/languageFor) since declarations are a code concept.
|
|
149
150
|
let lines = addedLinesBy.get(curFile);
|
|
150
151
|
if (!lines) {
|
|
151
152
|
lines = [];
|
|
152
153
|
addedLinesBy.set(curFile, lines);
|
|
153
154
|
}
|
|
154
155
|
lines.push(body);
|
|
155
|
-
if (!
|
|
156
|
+
if (!isSubstantive(curFile))
|
|
156
157
|
continue;
|
|
157
158
|
addedLines++;
|
|
158
159
|
if (!curAdded && !curDeleted)
|
|
159
160
|
filesModified.add(curFile);
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
161
|
+
if (isCode(curFile)) {
|
|
162
|
+
const d = declOf(body);
|
|
163
|
+
if (d)
|
|
164
|
+
declsFor(curFile)?.added.set(d.name, d);
|
|
165
|
+
const imp = importOf(body);
|
|
166
|
+
if (imp && !imp.startsWith("."))
|
|
167
|
+
addedImports.add(imp);
|
|
168
|
+
}
|
|
166
169
|
}
|
|
167
170
|
else if (raw.startsWith("-")) {
|
|
168
|
-
if (!
|
|
171
|
+
if (!isSubstantive(curFile))
|
|
169
172
|
continue;
|
|
170
173
|
removedLines++;
|
|
171
174
|
if (!curAdded && !curDeleted)
|
|
172
175
|
filesModified.add(curFile);
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
176
|
+
if (isCode(curFile)) {
|
|
177
|
+
const body = raw.slice(1);
|
|
178
|
+
const d = declOf(body);
|
|
179
|
+
if (d)
|
|
180
|
+
declsFor(curFile)?.removed.set(d.name, d);
|
|
181
|
+
const imp = importOf(body);
|
|
182
|
+
if (imp && !imp.startsWith("."))
|
|
183
|
+
removedImports.add(imp);
|
|
184
|
+
}
|
|
180
185
|
}
|
|
181
186
|
}
|
|
182
187
|
// per-file symbol classification (added/removed/changed within the same file)
|
package/dist/extractors/git.js
CHANGED
|
@@ -748,6 +748,10 @@ export function headFileContent(root, rel) {
|
|
|
748
748
|
encoding: "utf8",
|
|
749
749
|
env: foreignRepoEnv(process.env),
|
|
750
750
|
maxBuffer: 16 * 1024 * 1024,
|
|
751
|
+
// An untracked/absent-at-HEAD path is an expected, silently-handled case
|
|
752
|
+
// (falls through to the catch below) — don't let git's "fatal: path ...
|
|
753
|
+
// does not exist in 'HEAD'" leak onto the caller's stderr for it.
|
|
754
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
751
755
|
});
|
|
752
756
|
}
|
|
753
757
|
catch {
|
|
@@ -281,4 +281,12 @@ export function languageFor(file) {
|
|
|
281
281
|
}
|
|
282
282
|
return null;
|
|
283
283
|
}
|
|
284
|
+
/** Prose formats worth drafting a decision from even though no LanguageSpec parses
|
|
285
|
+
* them — no grammar, no symbols/edges, just eligible input to synthesis (issue #12). */
|
|
286
|
+
export const PROSE_EXTENSIONS = [".md"];
|
|
287
|
+
/** Broader than languageFor: "is this worth reasoning about" (synthesis input)
|
|
288
|
+
* rather than "can tree-sitter parse this" (symbol/dependency extraction). */
|
|
289
|
+
export function isSubstantive(file) {
|
|
290
|
+
return languageFor(file) !== null || PROSE_EXTENSIONS.some((ext) => file.endsWith(ext));
|
|
291
|
+
}
|
|
284
292
|
//# sourceMappingURL=languages.js.map
|
|
@@ -97,4 +97,45 @@ export function installPreCommitHook(root, invocation, strict = false) {
|
|
|
97
97
|
chmodSync(hookPath, 0o755);
|
|
98
98
|
return { path: hookPath, action: "appended" };
|
|
99
99
|
}
|
|
100
|
+
const MERGE_MARK = "# >>> hunch post-merge >>>";
|
|
101
|
+
const MERGE_END = "# <<< hunch post-merge <<<";
|
|
102
|
+
/** Install a post-merge hook that re-syncs the committed grounding docs when a merge
|
|
103
|
+
* brought memory in behind them (fnd_c402046ac7). Two branches that each captured a
|
|
104
|
+
* record regenerate the same "N+1" counts line; git merges identical lines silently
|
|
105
|
+
* and the doc ends up one behind the store. The hook regenerates the existing docs
|
|
106
|
+
* from the PUBLIC store right after a local merge/pull that touched .hunch/, so the
|
|
107
|
+
* next commit carries them. Foreground (it rewrites five files), loop-guarded via
|
|
108
|
+
* HUNCH_SYNC, and it can never fail the merge. Preserves any existing hook. */
|
|
109
|
+
export function installPostMergeHook(root, invocation) {
|
|
110
|
+
const dir = hooksDir(root);
|
|
111
|
+
const abs = isAbsolute(dir) ? dir : join(root, dir);
|
|
112
|
+
mkdirSync(abs, { recursive: true });
|
|
113
|
+
const hookPath = join(abs, "post-merge");
|
|
114
|
+
const blk = [
|
|
115
|
+
MERGE_MARK,
|
|
116
|
+
'if [ -z "$HUNCH_SYNC" ]; then',
|
|
117
|
+
" if ! git diff --quiet ORIG_HEAD HEAD -- .hunch 2>/dev/null; then",
|
|
118
|
+
` ( HUNCH_SYNC=1 ${invocation} grounding --refresh 2>/dev/null || true )`,
|
|
119
|
+
" fi",
|
|
120
|
+
"fi",
|
|
121
|
+
MERGE_END,
|
|
122
|
+
].join("\n");
|
|
123
|
+
if (!existsSync(hookPath)) {
|
|
124
|
+
writeFileSync(hookPath, `#!/bin/sh\n${blk}\n`);
|
|
125
|
+
chmodSync(hookPath, 0o755);
|
|
126
|
+
return { path: hookPath, action: "created" };
|
|
127
|
+
}
|
|
128
|
+
const cur = readFileSync(hookPath, "utf8");
|
|
129
|
+
if (cur.includes(MERGE_MARK)) {
|
|
130
|
+
const updated = cur.replace(new RegExp(`${escapeRe(MERGE_MARK)}[\\s\\S]*?${escapeRe(MERGE_END)}`), blk);
|
|
131
|
+
if (updated === cur)
|
|
132
|
+
return { path: hookPath, action: "unchanged" };
|
|
133
|
+
writeFileSync(hookPath, updated);
|
|
134
|
+
chmodSync(hookPath, 0o755);
|
|
135
|
+
return { path: hookPath, action: "updated" };
|
|
136
|
+
}
|
|
137
|
+
writeFileSync(hookPath, cur.endsWith("\n") ? `${cur}${blk}\n` : `${cur}\n${blk}\n`);
|
|
138
|
+
chmodSync(hookPath, 0o755);
|
|
139
|
+
return { path: hookPath, action: "appended" };
|
|
140
|
+
}
|
|
100
141
|
//# sourceMappingURL=hooks.js.map
|
|
@@ -360,6 +360,14 @@ export function regenerateGrounding(root, store) {
|
|
|
360
360
|
writeWindsurfRule(root, store),
|
|
361
361
|
];
|
|
362
362
|
}
|
|
363
|
+
/** The five grounding docs, repo-relative (POSIX separators, as git prints them). */
|
|
364
|
+
export const GROUNDING_DOC_PATHS = Object.freeze([
|
|
365
|
+
"CLAUDE.md",
|
|
366
|
+
"AGENTS.md",
|
|
367
|
+
".github/copilot-instructions.md",
|
|
368
|
+
".cursor/rules/hunch.mdc",
|
|
369
|
+
".windsurf/rules/hunch.md",
|
|
370
|
+
]);
|
|
363
371
|
function groundingTargets(root, store) {
|
|
364
372
|
return [
|
|
365
373
|
["CLAUDE.md", () => updateClaudeMd(root, store)],
|