@davesheffer/hunch 1.39.0 → 1.39.2
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 +193 -65
- package/dist/cli/integrations.js +10 -0
- package/dist/client/state.d.ts +2 -1
- package/dist/client/state.js +1 -0
- package/dist/core/agenthook.d.ts +14 -0
- package/dist/core/agenthook.js +55 -8
- package/dist/core/capturetoken.d.ts +30 -3
- package/dist/core/capturetoken.js +29 -3
- package/dist/core/changeProof.js +5 -1
- package/dist/core/checkreport.d.ts +7 -0
- package/dist/core/checkreport.js +20 -3
- package/dist/core/compare.js +3 -2
- package/dist/core/correction.d.ts +10 -4
- package/dist/core/correction.js +7 -4
- package/dist/core/countersign.d.ts +28 -0
- package/dist/core/countersign.js +50 -0
- package/dist/core/reviewqueue.js +6 -1
- package/dist/core/spawnCommand.js +41 -9
- package/dist/core/stateHttp.d.ts +1 -1
- package/dist/core/stateHttp.js +3 -1
- package/dist/core/taskReportEvidence.js +31 -11
- package/dist/core/topics.js +1 -1
- package/dist/core/types.d.ts +1 -0
- package/dist/core/workspace.d.ts +23 -1
- package/dist/core/workspace.js +31 -7
- package/dist/extractors/diff.d.ts +34 -0
- package/dist/extractors/diff.js +147 -5
- package/dist/extractors/git.d.ts +40 -11
- package/dist/extractors/git.js +147 -43
- package/dist/extractors/workspaces.d.ts +10 -0
- package/dist/extractors/workspaces.js +92 -15
- package/dist/integrations/gitignore.d.ts +27 -2
- package/dist/integrations/gitignore.js +103 -17
- package/dist/integrations/hooks.d.ts +61 -7
- package/dist/integrations/hooks.js +330 -43
- package/dist/integrations/scaffold.js +1 -1
- package/dist/integrations/workspaceLedger.d.ts +23 -3
- package/dist/integrations/workspaceLedger.js +114 -8
- package/dist/mcp/server.js +115 -56
- package/dist/serve/app.d.ts +4 -0
- package/dist/serve/app.js +56 -36
- package/dist/store/hunchStore.d.ts +4 -2
- package/dist/store/hunchStore.js +23 -6
- package/dist/store/stateBinding.js +111 -42
- package/dist/wiki/wiki.d.ts +7 -0
- package/dist/wiki/wiki.js +19 -8
- package/package.json +1 -1
- package/server.json +2 -2
|
@@ -7,11 +7,15 @@
|
|
|
7
7
|
* index is ignored.
|
|
8
8
|
*
|
|
9
9
|
* Idempotent + merge-safe (con_8460b6770f): appends a single marked block and
|
|
10
|
-
* never rewrites the user's existing entries
|
|
10
|
+
* never rewrites the user's existing entries. Once the block exists, re-running
|
|
11
|
+
* brings ONLY the content between its markers up to the current entry list (so a
|
|
12
|
+
* block written by an older release gains entries added later) and is otherwise
|
|
13
|
+
* byte-identical; everything outside the markers is left untouched.
|
|
11
14
|
*/
|
|
12
15
|
import { readFileSync, existsSync, lstatSync, realpathSync } from "node:fs";
|
|
13
16
|
import { isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
14
17
|
import { writeFileAtomic } from "../core/io.js";
|
|
18
|
+
import { gitTrackedPaths, gitUntrackCached, isGitRepoRoot } from "../extractors/git.js";
|
|
15
19
|
const MARK = "# >>> hunch (derived runtime index — regenerable from .hunch/*.json) >>>";
|
|
16
20
|
const END = "# <<< hunch <<<";
|
|
17
21
|
const ENTRIES = [
|
|
@@ -45,11 +49,16 @@ const ENTRIES = [
|
|
|
45
49
|
// re-touch the block above, and so the two concerns read clearly in the file.
|
|
46
50
|
const MEM_MARK = "# >>> hunch private-only (engineering memory kept in a private overlay; not published here) >>>";
|
|
47
51
|
const MEM_END = "# <<< hunch private-only <<<";
|
|
52
|
+
// Every ENTITY_KINDS directory (src/core/types.ts) must be listed here — guarded by
|
|
53
|
+
// test/gitignore.test.ts — plus the non-entity Constitution/ledger directories.
|
|
48
54
|
const MEM_ENTRIES = [
|
|
49
55
|
".hunch/decisions/",
|
|
50
56
|
".hunch/bugs/",
|
|
51
57
|
".hunch/constraints/",
|
|
52
58
|
".hunch/components/",
|
|
59
|
+
".hunch/resources/",
|
|
60
|
+
".hunch/conventions/",
|
|
61
|
+
".hunch/workspaces/",
|
|
53
62
|
".hunch/evidence/",
|
|
54
63
|
".hunch/corpora/",
|
|
55
64
|
".hunch/policies/",
|
|
@@ -107,33 +116,71 @@ export function assertSafeTopLevelConfigFile(root, name) {
|
|
|
107
116
|
}
|
|
108
117
|
return path;
|
|
109
118
|
}
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
*
|
|
113
|
-
|
|
119
|
+
const cleanLine = (line) => line.replace(/\r$/, "").trim();
|
|
120
|
+
/** Locate a managed block as whole lines: the marker line and the FIRST end-marker
|
|
121
|
+
* line after it. `null` when either is missing — a hand-damaged block is never
|
|
122
|
+
* guessed at (con_8460b6770f). Indices are into `text.split("\n")`, whose items
|
|
123
|
+
* keep a trailing `\r` in a CRLF file. */
|
|
124
|
+
function findBlock(lines, mark, end) {
|
|
125
|
+
const start = lines.findIndex((l) => cleanLine(l) === mark);
|
|
126
|
+
if (start < 0)
|
|
127
|
+
return null;
|
|
128
|
+
const stop = lines.findIndex((l, i) => i > start && cleanLine(l) === end);
|
|
129
|
+
if (stop < 0)
|
|
130
|
+
return null;
|
|
131
|
+
return { start, end: stop, inner: lines.slice(start + 1, stop).map(cleanLine).filter(Boolean) };
|
|
132
|
+
}
|
|
133
|
+
/** Rewrite ONLY the lines between an existing block's markers to the current entry
|
|
134
|
+
* list, keeping the file's line-ending style and every byte outside the markers.
|
|
135
|
+
* No write (byte-identical) when the block is already current. */
|
|
136
|
+
function upgradeBlock(root, path, cur, mark, entries, end) {
|
|
137
|
+
const lines = cur.split("\n");
|
|
138
|
+
const span = findBlock(lines, mark, end);
|
|
139
|
+
if (!span)
|
|
140
|
+
return { path, action: "unchanged", added: [] };
|
|
141
|
+
const cr = lines[span.start].endsWith("\r") ? "\r" : "";
|
|
142
|
+
const endCr = lines[span.end].endsWith("\r") ? "\r" : "";
|
|
143
|
+
const replacement = [mark + cr, ...entries.map((e) => e + cr), end + endCr];
|
|
144
|
+
const next = [...lines.slice(0, span.start), ...replacement, ...lines.slice(span.end + 1)].join("\n");
|
|
145
|
+
if (next === cur)
|
|
146
|
+
return { path, action: "unchanged", added: [] };
|
|
147
|
+
const had = new Set(span.inner);
|
|
148
|
+
assertSafeTopLevelConfigFile(root, ".gitignore");
|
|
149
|
+
writeFileAtomic(path, next);
|
|
150
|
+
return { path, action: "updated", added: entries.filter((e) => !had.has(e)) };
|
|
151
|
+
}
|
|
152
|
+
/** Idempotent + merge-safe write of one marked block (con_8460b6770f): never
|
|
153
|
+
* rewrites the user's entries outside the markers. A present block is upgraded in
|
|
154
|
+
* place to the current entry list; an absent one is appended unless the user's own
|
|
155
|
+
* lines already cover every entry. Re-running on a current file is a no-op. */
|
|
156
|
+
function appendBlock(root, mark, entries, end, upgrade = true) {
|
|
114
157
|
const path = assertSafeTopLevelConfigFile(root, ".gitignore");
|
|
115
|
-
const block = [mark, ...entries, end].join("\n");
|
|
116
158
|
if (!existsSync(path)) {
|
|
117
159
|
assertSafeTopLevelConfigFile(root, ".gitignore");
|
|
118
|
-
writeFileAtomic(path,
|
|
119
|
-
return { path, action: "created" };
|
|
160
|
+
writeFileAtomic(path, [mark, ...entries, end].join("\n") + "\n");
|
|
161
|
+
return { path, action: "created", added: [...entries] };
|
|
120
162
|
}
|
|
121
163
|
const cur = readFileSync(path, "utf8");
|
|
122
|
-
if (cur.includes(mark))
|
|
123
|
-
return { path, action: "unchanged" };
|
|
164
|
+
if (cur.includes(mark)) { // already managed
|
|
165
|
+
return upgrade ? upgradeBlock(root, path, cur, mark, entries, end) : { path, action: "unchanged", added: [] };
|
|
166
|
+
}
|
|
124
167
|
// Already covered by the user's OWN entries (e.g. a hand-written, commented
|
|
125
168
|
// section listing the same patterns)? Don't append a redundant managed block —
|
|
126
169
|
// that would leave two copies of every ignore. Keep the .gitignore clean.
|
|
127
|
-
const lines = new Set(cur.split("\n").map(
|
|
170
|
+
const lines = new Set(cur.split("\n").map(cleanLine));
|
|
128
171
|
if (entries.every((e) => lines.has(e)))
|
|
129
|
-
return { path, action: "unchanged" };
|
|
130
|
-
const
|
|
172
|
+
return { path, action: "unchanged", added: [] };
|
|
173
|
+
const eol = cur.includes("\r\n") ? "\r\n" : "\n";
|
|
174
|
+
const gap = cur.endsWith("\n") || cur.length === 0 ? "" : eol;
|
|
131
175
|
assertSafeTopLevelConfigFile(root, ".gitignore");
|
|
132
|
-
writeFileAtomic(path, `${cur}${
|
|
133
|
-
return { path, action: "appended" };
|
|
176
|
+
writeFileAtomic(path, `${cur}${gap}${[mark, ...entries, end].join(eol)}${eol}`);
|
|
177
|
+
return { path, action: "appended", added: [...entries] };
|
|
134
178
|
}
|
|
135
|
-
|
|
136
|
-
|
|
179
|
+
/** `upgradeExisting: false` only adds a missing block and never rewrites a present
|
|
180
|
+
* one — for `hunch index`, which also runs in CI and release gates where rewriting
|
|
181
|
+
* a tracked .gitignore would dirty the checkout. Setup and repair commands upgrade. */
|
|
182
|
+
export function ensureGitignore(root, opts = {}) {
|
|
183
|
+
return appendBlock(root, MARK, ENTRIES, END, opts.upgradeExisting ?? true);
|
|
137
184
|
}
|
|
138
185
|
/** Ignore the engineering-memory tree so a private-migrated repo stays code-only.
|
|
139
186
|
* The kind subdirs the user's records live in (decisions/, bugs/, …) move to the
|
|
@@ -144,4 +191,43 @@ export function ignoreHunchMemory(root) {
|
|
|
144
191
|
}
|
|
145
192
|
/** The .hunch memory subdirs un-published by a private migration (git pathspecs). */
|
|
146
193
|
export const HUNCH_MEMORY_DIRS = MEM_ENTRIES.map((e) => e.replace(/\/$/, ""));
|
|
194
|
+
/** Bring every EXISTING managed block up to the current entry lists without adding
|
|
195
|
+
* a block the repository never had (a repair path, not setup). A private-only
|
|
196
|
+
* block means the repository already declared its memory tree unpublished, so
|
|
197
|
+
* memory directories that a later release added to that block are also removed
|
|
198
|
+
* from the git index — never from disk — as `private --migrate` does for the whole
|
|
199
|
+
* list. */
|
|
200
|
+
export function upgradeManagedGitignore(root) {
|
|
201
|
+
const result = { base: null, memory: null, untracked: [] };
|
|
202
|
+
const path = assertSafeTopLevelConfigFile(root, ".gitignore");
|
|
203
|
+
if (!existsSync(path))
|
|
204
|
+
return result;
|
|
205
|
+
if (readFileSync(path, "utf8").includes(MARK))
|
|
206
|
+
result.base = ensureGitignore(root);
|
|
207
|
+
if (readFileSync(path, "utf8").includes(MEM_MARK)) {
|
|
208
|
+
result.memory = ignoreHunchMemory(root);
|
|
209
|
+
const dirs = result.memory.added.map((e) => e.replace(/\/$/, ""));
|
|
210
|
+
if (dirs.length && isGitRepoRoot(root)) {
|
|
211
|
+
result.untracked = gitTrackedPaths(root, dirs);
|
|
212
|
+
if (result.untracked.length)
|
|
213
|
+
gitUntrackCached(root, dirs);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
return result;
|
|
217
|
+
}
|
|
218
|
+
/** Output lines describing what an upgrade changed; empty when nothing did. */
|
|
219
|
+
export function describeGitignoreUpgrade(upgrade) {
|
|
220
|
+
const out = [];
|
|
221
|
+
if (upgrade.base?.action === "updated") {
|
|
222
|
+
out.push(`.gitignore: Hunch runtime block updated (added ${upgrade.base.added.join(", ") || "nothing; block normalized"})`);
|
|
223
|
+
}
|
|
224
|
+
if (upgrade.memory?.action === "updated") {
|
|
225
|
+
out.push(`.gitignore: private-only memory block updated (added ${upgrade.memory.added.join(", ") || "nothing; block normalized"})`);
|
|
226
|
+
}
|
|
227
|
+
if (upgrade.untracked.length) {
|
|
228
|
+
const shown = upgrade.untracked.slice(0, 5).join(", ") + (upgrade.untracked.length > 5 ? ", ..." : "");
|
|
229
|
+
out.push(`removed ${upgrade.untracked.length} newly ignored memory file(s) from the git index, kept on disk (commit the removal): ${shown}`);
|
|
230
|
+
}
|
|
231
|
+
return out;
|
|
232
|
+
}
|
|
147
233
|
//# sourceMappingURL=gitignore.js.map
|
|
@@ -1,7 +1,36 @@
|
|
|
1
|
+
/** Which hook manager (if any) owns the hook file git would run.
|
|
2
|
+
* - `none`: a plain local hooks dir Hunch may write into.
|
|
3
|
+
* - `pre-commit`: the pre-commit framework's generated hook (ends in `exec`).
|
|
4
|
+
* - `husky`: husky v9 (`core.hooksPath=.husky/_`, stubs source `h`, which exits).
|
|
5
|
+
* - `husky-legacy`: husky ≤8 (`core.hooksPath=.husky`, a tracked directory).
|
|
6
|
+
* - `tracked-hooks-path`: any other `core.hooksPath` inside the work tree that git tracks.
|
|
7
|
+
* - `exec-exit`: no known manager, but the existing hook ends in exec/exit before our block. */
|
|
8
|
+
export type HookManagerKind = "none" | "pre-commit" | "husky" | "husky-legacy" | "tracked-hooks-path" | "exec-exit";
|
|
1
9
|
export interface HookInstall {
|
|
10
|
+
/** The hook file written; for a non-writing result, the file the user should edit. */
|
|
2
11
|
path: string;
|
|
3
|
-
|
|
12
|
+
/** created/appended/updated/unchanged: the block is (now) in a file git reaches.
|
|
13
|
+
* managed-elsewhere: a hook manager owns the hook — nothing was written.
|
|
14
|
+
* unreachable: the existing hook ends in exec/exit before our block — nothing was written. */
|
|
15
|
+
action: "created" | "appended" | "updated" | "unchanged" | "managed-elsewhere" | "unreachable";
|
|
16
|
+
manager?: HookManagerKind;
|
|
17
|
+
/** Why nothing was written (non-writing actions only). */
|
|
18
|
+
reason?: string;
|
|
19
|
+
/** What to add to `path` by hand (non-writing actions only). */
|
|
20
|
+
snippet?: string;
|
|
4
21
|
}
|
|
22
|
+
/** Portable invocation for text that lands in a TRACKED file (a husky script, a
|
|
23
|
+
* committed hooks dir, .pre-commit-config.yaml): the same exact-version npx
|
|
24
|
+
* package reference the committed MCP/provider configs use — never this
|
|
25
|
+
* machine's absolute node/CLI path. */
|
|
26
|
+
export declare const PORTABLE_HOOK_INVOCATION: string;
|
|
27
|
+
/** The line that ends the script unconditionally before anything appended after
|
|
28
|
+
* it could run, or null. Deliberately conservative (a heuristic, not a shell
|
|
29
|
+
* parser): an `exec`/`exit` statement outside any if/case/loop/function block,
|
|
30
|
+
* or a trailing top-level `if … else … fi` whose every branch ends in
|
|
31
|
+
* `exec`/`exit` — the shape of the pre-commit framework's generated hook. */
|
|
32
|
+
export declare function terminalExitLine(content: string): string | null;
|
|
33
|
+
type BlockState = "installed" | "unreachable" | "missing";
|
|
5
34
|
export declare function installPostCommitHook(root: string, invocation: string, opts?: {
|
|
6
35
|
private?: boolean;
|
|
7
36
|
commit?: boolean;
|
|
@@ -29,15 +58,40 @@ export declare function installPreCommitHook(root: string, invocation: string, s
|
|
|
29
58
|
* gets the other appended rather than clobbered. */
|
|
30
59
|
export declare function installPostMergeHook(root: string, invocation: string): HookInstall;
|
|
31
60
|
export declare function installPostCheckoutHook(root: string, invocation: string): HookInstall;
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
61
|
+
export type HookState = BlockState;
|
|
62
|
+
export interface HookReportEntry {
|
|
63
|
+
state: HookState;
|
|
64
|
+
manager: HookManagerKind;
|
|
65
|
+
/** The file where the block lives (or should live). */
|
|
66
|
+
path: string;
|
|
67
|
+
/** Why an `unreachable` block never runs. */
|
|
68
|
+
reason?: string;
|
|
69
|
+
}
|
|
70
|
+
export interface HookReport {
|
|
71
|
+
postCommit: HookReportEntry;
|
|
72
|
+
preCommit: HookReportEntry;
|
|
73
|
+
postMerge: HookReportEntry;
|
|
74
|
+
postCheckout: HookReportEntry;
|
|
75
|
+
}
|
|
76
|
+
/** Read-only diagnostic (used by `hunch doctor`): each managed hook's state —
|
|
77
|
+
* `installed` (present where git will run it), `unreachable` (present, but
|
|
78
|
+
* after an exec/exit or in a manager-owned file git never reaches), or
|
|
79
|
+
* `missing`. postMerge requires BOTH halves (grounding-refresh and
|
|
80
|
+
* repair-provenance) — a repo carrying only one is a partial install, same as
|
|
81
|
+
* `installPostMergeHook` self-healing it. Never writes anything. */
|
|
82
|
+
export declare function hookReport(root: string): HookReport;
|
|
83
|
+
/** Boolean view of `hookReport`: a hook counts as installed only when its
|
|
84
|
+
* managed block is present where git will actually run it, regardless of
|
|
85
|
+
* whether the invocation inside it happens to be stale. An unreachable block
|
|
86
|
+
* (issue #311) is NOT installed. */
|
|
38
87
|
export declare function hookStatus(root: string): {
|
|
39
88
|
postCommit: boolean;
|
|
40
89
|
preCommit: boolean;
|
|
41
90
|
postMerge: boolean;
|
|
42
91
|
postCheckout: boolean;
|
|
43
92
|
};
|
|
93
|
+
/** CLI lines for one install result: the usual ✓ line when the block is in a
|
|
94
|
+
* file git runs, otherwise a warning with the reason and the snippet to add to
|
|
95
|
+
* the manager's own file. */
|
|
96
|
+
export declare function formatHookInstall(root: string, label: string, h: HookInstall, detail?: string): string[];
|
|
97
|
+
export {};
|