@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.
Files changed (48) hide show
  1. package/dist/cli/index.js +193 -65
  2. package/dist/cli/integrations.js +10 -0
  3. package/dist/client/state.d.ts +2 -1
  4. package/dist/client/state.js +1 -0
  5. package/dist/core/agenthook.d.ts +14 -0
  6. package/dist/core/agenthook.js +55 -8
  7. package/dist/core/capturetoken.d.ts +30 -3
  8. package/dist/core/capturetoken.js +29 -3
  9. package/dist/core/changeProof.js +5 -1
  10. package/dist/core/checkreport.d.ts +7 -0
  11. package/dist/core/checkreport.js +20 -3
  12. package/dist/core/compare.js +3 -2
  13. package/dist/core/correction.d.ts +10 -4
  14. package/dist/core/correction.js +7 -4
  15. package/dist/core/countersign.d.ts +28 -0
  16. package/dist/core/countersign.js +50 -0
  17. package/dist/core/reviewqueue.js +6 -1
  18. package/dist/core/spawnCommand.js +41 -9
  19. package/dist/core/stateHttp.d.ts +1 -1
  20. package/dist/core/stateHttp.js +3 -1
  21. package/dist/core/taskReportEvidence.js +31 -11
  22. package/dist/core/topics.js +1 -1
  23. package/dist/core/types.d.ts +1 -0
  24. package/dist/core/workspace.d.ts +23 -1
  25. package/dist/core/workspace.js +31 -7
  26. package/dist/extractors/diff.d.ts +34 -0
  27. package/dist/extractors/diff.js +147 -5
  28. package/dist/extractors/git.d.ts +40 -11
  29. package/dist/extractors/git.js +147 -43
  30. package/dist/extractors/workspaces.d.ts +10 -0
  31. package/dist/extractors/workspaces.js +92 -15
  32. package/dist/integrations/gitignore.d.ts +27 -2
  33. package/dist/integrations/gitignore.js +103 -17
  34. package/dist/integrations/hooks.d.ts +61 -7
  35. package/dist/integrations/hooks.js +330 -43
  36. package/dist/integrations/scaffold.js +1 -1
  37. package/dist/integrations/workspaceLedger.d.ts +23 -3
  38. package/dist/integrations/workspaceLedger.js +114 -8
  39. package/dist/mcp/server.js +115 -56
  40. package/dist/serve/app.d.ts +4 -0
  41. package/dist/serve/app.js +56 -36
  42. package/dist/store/hunchStore.d.ts +4 -2
  43. package/dist/store/hunchStore.js +23 -6
  44. package/dist/store/stateBinding.js +111 -42
  45. package/dist/wiki/wiki.d.ts +7 -0
  46. package/dist/wiki/wiki.js +19 -8
  47. package/package.json +1 -1
  48. 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; re-running is a no-op.
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
- /** Idempotent + merge-safe append of one marked block (con_8460b6770f): never
111
- * rewrites the user's existing entries, and re-running is a no-op once the block
112
- * (or an equivalent hand-written set of the same patterns) is present. */
113
- function appendBlock(root, mark, entries, end) {
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, block + "\n");
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" }; // already managed
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((l) => l.trim()));
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 sep = cur.endsWith("\n") || cur.length === 0 ? "" : "\n";
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}${sep}${block}\n`);
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
- export function ensureGitignore(root) {
136
- return appendBlock(root, MARK, ENTRIES, END);
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
- action: "created" | "appended" | "updated" | "unchanged";
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
- /** Read-only diagnostic (used by `hunch doctor`): which of the three managed
33
- * hooks are currently present. Never writes anything — a hook counts as
34
- * installed if its managed marker is present, regardless of whether the
35
- * invocation inside it happens to be stale. postMerge requires BOTH halves
36
- * (grounding-refresh and repair-provenance) present — a repo carrying only
37
- * one is a partial install, same as `installPostMergeHook` self-healing it. */
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 {};