@davesheffer/hunch 1.39.3 → 1.40.1

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 (43) hide show
  1. package/dist/cli/index.js +61 -17
  2. package/dist/constitution/g2BehaviorCandidates.js +6 -1
  3. package/dist/constitution/g2Candidates.js +11 -1
  4. package/dist/constitution/structural.js +10 -0
  5. package/dist/core/delivery.d.ts +34 -0
  6. package/dist/core/delivery.js +60 -1
  7. package/dist/core/docanchors.js +181 -11
  8. package/dist/core/format.js +6 -1
  9. package/dist/core/glob.d.ts +12 -6
  10. package/dist/core/glob.js +12 -6
  11. package/dist/core/hookcache.d.ts +9 -2
  12. package/dist/core/hookcache.js +10 -3
  13. package/dist/core/paths.d.ts +21 -0
  14. package/dist/core/paths.js +39 -1
  15. package/dist/core/taskDelivery.js +27 -1
  16. package/dist/core/taskReportHook.d.ts +19 -0
  17. package/dist/core/taskReportHook.js +40 -4
  18. package/dist/core/taskReportRender.d.ts +3 -0
  19. package/dist/core/taskReportRender.js +22 -12
  20. package/dist/core/verifyLauncher.d.ts +21 -0
  21. package/dist/core/verifyLauncher.js +37 -0
  22. package/dist/extractors/indexer.d.ts +5 -0
  23. package/dist/extractors/indexer.js +56 -11
  24. package/dist/extractors/k8sManifest.d.ts +13 -0
  25. package/dist/extractors/k8sManifest.js +103 -7
  26. package/dist/extractors/landscapeDiscovery.js +9 -1
  27. package/dist/extractors/nativeTreeSitter.d.ts +24 -0
  28. package/dist/extractors/nativeTreeSitter.js +54 -1
  29. package/dist/extractors/parse.d.ts +3 -1
  30. package/dist/extractors/parse.js +12 -3
  31. package/dist/integrations/claudemd.js +3 -3
  32. package/dist/integrations/hooks.d.ts +43 -4
  33. package/dist/integrations/hooks.js +309 -22
  34. package/dist/mcp/server.js +19 -11
  35. package/dist/mcp/taskReportTools.d.ts +5 -9
  36. package/dist/mcp/taskReportTools.js +9 -20
  37. package/dist/store/changeLedger.d.ts +9 -3
  38. package/dist/store/changeLedger.js +36 -10
  39. package/dist/store/hunchStore.d.ts +42 -2
  40. package/dist/store/hunchStore.js +74 -16
  41. package/dist/store/stateBinding.js +1 -1
  42. package/package.json +1 -1
  43. package/server.json +2 -2
@@ -37,11 +37,21 @@ interface K8sReferenceCandidate {
37
37
  * value (data-dependent, not statically known). */
38
38
  refKind: string;
39
39
  name: ManifestNameRef;
40
+ /** The namespace this reference resolves IN, or null for "unknown" (see
41
+ * literalNamespace). Kubernetes name references are same-namespace by
42
+ * definition, so this defaults to the referring DOCUMENT's own namespace;
43
+ * the one shape that can legitimately point elsewhere (a Gateway API
44
+ * `backendRefs[].namespace` sibling) overrides it. */
45
+ namespace: string | null;
40
46
  }
41
47
  type ManifestLabelMap = Record<string, string>;
42
48
  interface K8sResourceDoc {
43
49
  kind: string;
44
50
  name: ManifestNameRef;
51
+ /** `metadata.namespace` when it is a plain literal, else null = UNKNOWN (see
52
+ * literalNamespace). Cluster-scoped kinds need no special case: they simply
53
+ * never carry the field, so they are unknown and compatible with anything. */
54
+ namespace: string | null;
45
55
  startChar: number;
46
56
  endChar: number;
47
57
  }
@@ -51,6 +61,9 @@ export interface K8sManifestDocument {
51
61
  selector: ManifestLabelMap | null;
52
62
  labels: ManifestLabelMap | null;
53
63
  }
64
+ /** Namespace compatibility per the product rule: a MISSING (unknown) namespace
65
+ * matches anything; two DIFFERENT literal namespaces block the edge. */
66
+ export declare function namespacesCompatible(a: string | null, b: string | null): boolean;
54
67
  /** Where a kind's pod spec lives -- factored once so container/volume field
55
68
  * paths below aren't hand-duplicated per kind. */
56
69
  export declare const POD_SPEC_PATH_BY_KIND: Record<string, string>;
@@ -390,6 +390,75 @@ function scanFieldPaths(text, baseChar) {
390
390
  function findEntry(entries, path) {
391
391
  return entries.find((e) => e.path === path);
392
392
  }
393
+ /** The RFC 1123 DNS label a real Kubernetes namespace name must be -- the
394
+ * positive test literalNamespace applies (see there for why). */
395
+ const DNS_LABEL = /^[a-z0-9]([-a-z0-9]{0,61}[a-z0-9])?$/;
396
+ /** A namespace a reference can be MATCHED on, or null meaning UNKNOWN.
397
+ * Deliberately narrower than the ManifestNameRef literal/template split that
398
+ * names get: a name is a resolution KEY (a template's raw text matches
399
+ * another identical template's raw text, which is real evidence), whereas a
400
+ * namespace here is only ever a FILTER, and the product rule is that unknown
401
+ * matches anything. So a templated `namespace: {{ .Release.Namespace }}` is
402
+ * unknown rather than a `T:`-keyed literal -- two documents whose namespace
403
+ * renders identically must not be blocked from linking just because one
404
+ * spells it via a template and the other spells it out, and two DIFFERENT
405
+ * template expressions are not evidence of different namespaces either.
406
+ * Absent (no entry at all) and an explicit empty string are unknown for the
407
+ * same reason: neither names a namespace this scanner can compare.
408
+ *
409
+ * The literal-ness test is therefore POSITIVE, not a list of excluded
410
+ * spellings: a value counts only when it already looks like the RFC 1123 DNS
411
+ * label a real namespace must be. One test then covers every shape the
412
+ * scanner cannot resolve, without a special case per spelling -- the scanner
413
+ * only tags a value "template" when the template action starts the value, so
414
+ * `namespace: app-{{ .Values.env }}` arrives here as a "literal" and would
415
+ * otherwise block edges against the very namespace it renders to; likewise
416
+ * YAML null spellings (`~`, `Null`), anchors and tags (`&ns prod`,
417
+ * `!!str prod`), uppercase, whitespace and empty. Blocking is the damaging
418
+ * direction (unknown never blocks an edge), so anything uncomparable is
419
+ * unknown.
420
+ *
421
+ * `null` is the one spelling the regex admits but must still be rejected,
422
+ * since it is also the bare YAML null word. It is excluded explicitly rather
423
+ * than by inspecting quotes: stripQuotes runs in the scanner, so a genuinely
424
+ * quoted `namespace: "null"` is indistinguishable here -- and reading it as
425
+ * unknown errs in the conservative direction. */
426
+ function literalNamespace(entry) {
427
+ if (!entry || entry.value.form !== "literal")
428
+ return null;
429
+ const value = entry.value.value;
430
+ if (value === "null" || !DNS_LABEL.test(value))
431
+ return null;
432
+ return value;
433
+ }
434
+ /** The namespace named at `path`, reading EVERY entry there rather than the
435
+ * first. A Helm `{{- if }} namespace: prod {{- else }} namespace: staging
436
+ * {{- end }}` emits both branches as entries on the same path, and taking
437
+ * findEntry's first one would read the document as confidently `prod` and
438
+ * block every edge to `staging`. Only an unambiguous agreement -- at least
439
+ * one entry, all of them the same literal -- is a namespace this scanner can
440
+ * compare; disagreement is unknown, like any other unresolved shape. */
441
+ function namespaceAt(entries, path) {
442
+ let agreed = null;
443
+ let seen = false;
444
+ for (const e of entries) {
445
+ if (e.path !== path)
446
+ continue;
447
+ const ns = literalNamespace(e);
448
+ if (ns === null)
449
+ return null;
450
+ if (seen && ns !== agreed)
451
+ return null;
452
+ agreed = ns;
453
+ seen = true;
454
+ }
455
+ return agreed;
456
+ }
457
+ /** Namespace compatibility per the product rule: a MISSING (unknown) namespace
458
+ * matches anything; two DIFFERENT literal namespaces block the edge. */
459
+ export function namespacesCompatible(a, b) {
460
+ return a === null || b === null || a === b;
461
+ }
393
462
  /** Where a kind's pod spec lives -- factored once so container/volume field
394
463
  * paths below aren't hand-duplicated per kind. */
395
464
  export const POD_SPEC_PATH_BY_KIND = {
@@ -448,14 +517,32 @@ function fieldSpecsForKind(kind) {
448
517
  specs.push({ path: "spec.rules[].backendRefs[].name", refKind: "Service" });
449
518
  return specs;
450
519
  }
451
- function extractFieldReferences(kind, entries) {
520
+ /** The ONE reference shape in fieldSpecsForKind that Kubernetes lets point at
521
+ * another namespace: a Gateway API `backendRefs[]` entry carries an optional
522
+ * `namespace` SIBLING of its own `name`. Every other shape here
523
+ * (configMapRef/secretRef/volumes/imagePullSecrets/Ingress backend/
524
+ * ownerReferences) is same-namespace by API definition and has no namespace
525
+ * field at all, so none of them needs an override. Read off the concrete
526
+ * (non-wildcarded) entry list by exact parentPath -- the same
527
+ * pair-siblings-by-index technique extractOwnerReferenceCandidates uses --
528
+ * rather than new parsing machinery. */
529
+ const NAMESPACE_SIBLING_PATHS = new Set(["spec.rules[].backendRefs[]"]);
530
+ function extractFieldReferences(kind, entries, docNamespace) {
452
531
  const specs = fieldSpecsForKind(kind);
453
532
  const out = [];
454
533
  for (const e of entries) {
455
534
  const wp = wildcardPath(e.path);
456
535
  const spec = specs.find((s) => s.path === wp);
457
- if (spec)
458
- out.push({ refKind: spec.refKind, name: e.value });
536
+ if (!spec)
537
+ continue;
538
+ // A sibling that EXISTS but isn't a comparable literal (templated, empty)
539
+ // must read as unknown, NOT fall back to the document's namespace: it is
540
+ // an explicit statement that the target lives somewhere this scanner
541
+ // cannot name, which is the opposite of "same namespace as me". So the
542
+ // sibling's existence is checked before its literal-ness.
543
+ const siblingPath = `${e.parentPath}.namespace`;
544
+ const hasSibling = NAMESPACE_SIBLING_PATHS.has(wildcardPath(e.parentPath)) && entries.some((c) => c.path === siblingPath);
545
+ out.push({ refKind: spec.refKind, name: e.value, namespace: hasSibling ? namespaceAt(entries, siblingPath) : docNamespace });
459
546
  }
460
547
  return out;
461
548
  }
@@ -465,7 +552,7 @@ function extractFieldReferences(kind, entries) {
465
552
  * entries never get cross-paired. Not expressible via FieldPathSpec's
466
553
  * single-fixed-refKind model, so it's a dedicated pass over the concrete
467
554
  * (non-wildcarded) entries. */
468
- function extractOwnerReferenceCandidates(entries) {
555
+ function extractOwnerReferenceCandidates(entries, docNamespace) {
469
556
  const byIndex = new Map();
470
557
  for (const e of entries) {
471
558
  const m = /^metadata\.ownerReferences(\[\d+\])\.(name|kind)$/.exec(e.path);
@@ -479,7 +566,10 @@ function extractOwnerReferenceCandidates(entries) {
479
566
  for (const { name, kind } of byIndex.values()) {
480
567
  if (!name || !kind || kind.value.form !== "literal")
481
568
  continue; // an owner's kind must be a literal to type the reference at all
482
- out.push({ refKind: kind.value.value, name: name.value });
569
+ // An ownerReferences entry has NO namespace field in the API at all -- an
570
+ // owner is always in the owned object's own namespace (or cluster-scoped),
571
+ // so the document's namespace is the only correct answer here.
572
+ out.push({ refKind: kind.value.value, name: name.value, namespace: docNamespace });
483
573
  }
484
574
  return out;
485
575
  }
@@ -550,11 +640,17 @@ function buildDocument(text, docStartChar, entries, unresolvedContainers, valuel
550
640
  const kind = kindEntry?.value.form === "literal" ? kindEntry.value.value : null;
551
641
  if (!kind || !ALLOWED_KINDS.has(kind))
552
642
  return { resource: null, references: [], selector: null, labels: null };
643
+ // Found with the same entry machinery as metadata.name, so it inherits the
644
+ // scanner's quote-stripping, comment-stripping and CRLF tolerance for free.
645
+ // Read OUTSIDE the `resource` branch below: a document with no
646
+ // metadata.name still emits references, and those references carry this
647
+ // document's namespace.
648
+ const namespace = namespaceAt(entries, "metadata.namespace");
553
649
  const nameEntry = findEntry(entries, "metadata.name");
554
650
  const resource = nameEntry
555
- ? { kind, name: nameEntry.value, startChar: docStartChar, endChar: docStartChar + text.length }
651
+ ? { kind, name: nameEntry.value, namespace, startChar: docStartChar, endChar: docStartChar + text.length }
556
652
  : null;
557
- const references = [...extractFieldReferences(kind, entries), ...extractOwnerReferenceCandidates(entries)];
653
+ const references = [...extractFieldReferences(kind, entries, namespace), ...extractOwnerReferenceCandidates(entries, namespace)];
558
654
  const selector = kind === "Service" ? extractLiteralLabelMap("spec.selector", entries, unresolvedContainers, valuelessKeyParents) : null;
559
655
  const labelsPath = LABELS_PATH_BY_KIND[kind];
560
656
  const labels = labelsPath ? extractLiteralLabelMap(labelsPath, entries, unresolvedContainers, valuelessKeyParents) : null;
@@ -6,6 +6,7 @@ import { compareCodeUnits } from "../core/canonicalOrder.js";
6
6
  import { resourceId, resourceRelationshipId } from "../core/ids.js";
7
7
  import { parseJsonc } from "../core/jsonc.js";
8
8
  import { parseSource } from "./parse.js";
9
+ import { isParserLoadError } from "./nativeTreeSitter.js";
9
10
  import { EdgeSchema, ResourceSchema, isCredentialFreeText, } from "../core/types.js";
10
11
  import { canonicalRemoteRepositoryIdentity, foreignRepoEnv, gitNullDevice, } from "./git.js";
11
12
  export const LANDSCAPE_DISCOVERY_SCHEMA_VERSION = "hunch.landscape-discovery/1";
@@ -2026,7 +2027,14 @@ function sloDeclarations(root, tree, issues) {
2026
2027
  try {
2027
2028
  valid = blob.format === "json" ? validJsonOpenSlo(source) : validYamlOpenSlo(blob.path, source);
2028
2029
  }
2029
- catch {
2030
+ catch (error) {
2031
+ // A malformed declaration is a bad file and stays one issue. A dead
2032
+ // native parser is not: swallowing it made `landscape review` report
2033
+ // every valid SLO as slo_declaration_invalid, drop the resource, and
2034
+ // still exit 0 with a ready-made `landscape adopt --acknowledge-issues`
2035
+ // line — a store write of a false verdict.
2036
+ if (isParserLoadError(error))
2037
+ throw error;
2030
2038
  valid = false;
2031
2039
  }
2032
2040
  if (!valid) {
@@ -8,6 +8,30 @@ export interface NativeTreeSitterRuntime {
8
8
  php: unknown;
9
9
  yaml: unknown;
10
10
  }
11
+ /** The parser runtime itself is unavailable — the addons could not be copied or
12
+ * dlopen'd (unwritable/full TMPDIR, a missing or wrong-arch prebuild, npm
13
+ * replacing the package mid-session, or the fail-closed "preloaded addon"
14
+ * guard). Distinct from a per-file parse error on purpose: now that the load
15
+ * happens on first parse rather than at import, every swallow-and-continue
16
+ * catch on a parse path would otherwise read this as "this one file is bad"
17
+ * and, in a whole-repo scan, mark EVERY file parse_failed and overwrite the
18
+ * graph with nothing. Callers rethrow it so the run dies before any write,
19
+ * the way the import-time load used to. The original error is kept as `cause`
20
+ * and its text is carried in the message so the EACCES/dlopen detail a user
21
+ * needs is never lost. */
22
+ export declare class NativeTreeSitterLoadError extends Error {
23
+ constructor(message: string, options?: {
24
+ cause?: unknown;
25
+ });
26
+ }
27
+ /** The ONE predicate every rethrow site uses — a dead parser is recognised the
28
+ * same way everywhere, so no catch can quietly disagree about what counts.
29
+ * `instanceof` alone is not enough: a duplicated module instance (two copies of
30
+ * this file in one process, e.g. dist/ + src/ under tsx, or a worker that
31
+ * re-resolves it) gives a different class identity, and an error rebuilt across
32
+ * a worker/IPC boundary keeps only its plain properties. The name check
33
+ * survives both. */
34
+ export declare function isParserLoadError(e: unknown): boolean;
11
35
  /** Load all native tree-sitter addons (the parser runtime + every grammar) from
12
36
  * process-owned temp copies. Windows keeps loaded `.node` files locked for the
13
37
  * process lifetime; redirecting the upstream loaders means npm can replace the
@@ -13,6 +13,40 @@ const NATIVE_PACKAGES = [
13
13
  "@tree-sitter-grammars/tree-sitter-yaml",
14
14
  ];
15
15
  let runtime = null;
16
+ /** The parser runtime itself is unavailable — the addons could not be copied or
17
+ * dlopen'd (unwritable/full TMPDIR, a missing or wrong-arch prebuild, npm
18
+ * replacing the package mid-session, or the fail-closed "preloaded addon"
19
+ * guard). Distinct from a per-file parse error on purpose: now that the load
20
+ * happens on first parse rather than at import, every swallow-and-continue
21
+ * catch on a parse path would otherwise read this as "this one file is bad"
22
+ * and, in a whole-repo scan, mark EVERY file parse_failed and overwrite the
23
+ * graph with nothing. Callers rethrow it so the run dies before any write,
24
+ * the way the import-time load used to. The original error is kept as `cause`
25
+ * and its text is carried in the message so the EACCES/dlopen detail a user
26
+ * needs is never lost. */
27
+ export class NativeTreeSitterLoadError extends Error {
28
+ constructor(message, options) {
29
+ super(message, options);
30
+ this.name = "NativeTreeSitterLoadError";
31
+ }
32
+ }
33
+ /** The ONE predicate every rethrow site uses — a dead parser is recognised the
34
+ * same way everywhere, so no catch can quietly disagree about what counts.
35
+ * `instanceof` alone is not enough: a duplicated module instance (two copies of
36
+ * this file in one process, e.g. dist/ + src/ under tsx, or a worker that
37
+ * re-resolves it) gives a different class identity, and an error rebuilt across
38
+ * a worker/IPC boundary keeps only its plain properties. The name check
39
+ * survives both. */
40
+ export function isParserLoadError(e) {
41
+ return e instanceof NativeTreeSitterLoadError || e?.name === "NativeTreeSitterLoadError";
42
+ }
43
+ /** Wrap any loader failure once, preserving an already-typed one. */
44
+ function loadFailure(error) {
45
+ if (error instanceof NativeTreeSitterLoadError)
46
+ return error;
47
+ const detail = error instanceof Error ? error.message : String(error);
48
+ return new NativeTreeSitterLoadError(`native tree-sitter parser unavailable: ${detail}`, { cause: error });
49
+ }
16
50
  function processIsAlive(pid) {
17
51
  if (pid === process.pid)
18
52
  return true;
@@ -71,6 +105,19 @@ function copyNativeBinding(packageName, copyRoot, nodeGypBuild) {
71
105
  export function loadNativeTreeSitter() {
72
106
  if (runtime)
73
107
  return runtime;
108
+ // Only SUCCESS is memoized (in `runtime`), never the failure: the conditions
109
+ // that break the load are transient — a full or read-only TMPDIR, an npm
110
+ // install swapping the package out from under a live process. A long-lived
111
+ // MCP server must be able to parse again once the condition clears, so every
112
+ // call retries the load from scratch.
113
+ try {
114
+ return loadRuntime();
115
+ }
116
+ catch (error) {
117
+ throw loadFailure(error);
118
+ }
119
+ }
120
+ function loadRuntime() {
74
121
  // Three binding spellings: prebuilds ship as tree-sitter[-typescript|-python].node
75
122
  // or, for a scoped package like @tree-sitter-grammars/tree-sitter-yaml, as
76
123
  // @scope+name.node; from-source builds are named after the binding.gyp target
@@ -85,9 +132,15 @@ export function loadNativeTreeSitter() {
85
132
  }
86
133
  removeStaleCopies();
87
134
  const copyRoot = mkdtempSync(join(tmpdir(), `${COPY_PREFIX}${process.pid}-`));
88
- const nodeGypBuild = runtimeRequire("node-gyp-build");
89
135
  const previous = new Map();
90
136
  try {
137
+ // Resolved INSIDE the try: if node-gyp-build cannot be resolved (a partial
138
+ // install, npm mid-swap) the catch below still removes the copy dir we just
139
+ // created. Outside it, every retry — and failure is deliberately not
140
+ // memoized, so a long-lived MCP server retries forever — leaked one empty
141
+ // hunch-tree-sitter-<pid>-* dir that removeStaleCopies can never prune,
142
+ // because it only prunes dirs whose pid is dead.
143
+ const nodeGypBuild = runtimeRequire("node-gyp-build");
91
144
  for (const packageName of NATIVE_PACKAGES) {
92
145
  const key = environmentKey(packageName);
93
146
  previous.set(key, process.env[key]);
@@ -34,7 +34,9 @@ export interface ParsedFile {
34
34
  /** Cap on a stored symbol's bodyText — large enough for review context, small
35
35
  * enough that a huge function/file doesn't bloat every JSON symbol record. */
36
36
  export declare const MAX_BODY_TEXT_CHARS = 4000;
37
- export declare function parseSource(file: string, source: string): ParsedFile | null;
37
+ export declare function parseSource(file: string, source: string, opts?: {
38
+ throwOnParseError?: boolean;
39
+ }): ParsedFile | null;
38
40
  /** Map each call site to the innermost symbol whose byte-range contains it.
39
41
  * Keyed by the symbol's position (index) in `parsed.symbols` — NOT its
40
42
  * startByte, which is not a reliable per-symbol identity: a language whose
@@ -1,10 +1,15 @@
1
1
  import { languageFor } from "./languages.js";
2
2
  import { loadNativeTreeSitter } from "./nativeTreeSitter.js";
3
- const { Parser } = loadNativeTreeSitter();
3
+ /** Resolved on first parse, not at import: loading the native addons copies six
4
+ * `.node` files into a temp dir and dlopens them (~1.5s+ cold), which every CLI
5
+ * command and every editor hook would otherwise pay just for importing this
6
+ * module. loadNativeTreeSitter() memoizes the runtime itself. */
7
+ const parserRuntime = () => loadNativeTreeSitter().Parser;
4
8
  const cache = new Map();
5
9
  function bundleFor(spec) {
6
10
  let b = cache.get(spec.grammarKey);
7
11
  if (!b) {
12
+ const Parser = parserRuntime();
8
13
  const parser = new Parser();
9
14
  const grammar = spec.loadGrammar();
10
15
  parser.setLanguage(grammar);
@@ -18,7 +23,7 @@ const STR_QUOTES = /^['"`]|['"`]$/g;
18
23
  /** Cap on a stored symbol's bodyText — large enough for review context, small
19
24
  * enough that a huge function/file doesn't bloat every JSON symbol record. */
20
25
  export const MAX_BODY_TEXT_CHARS = 4000;
21
- export function parseSource(file, source) {
26
+ export function parseSource(file, source, opts = {}) {
22
27
  const spec = languageFor(file);
23
28
  if (!spec)
24
29
  return null;
@@ -44,7 +49,11 @@ export function parseSource(file, source) {
44
49
  try {
45
50
  tree = parser.parse(source, undefined, { bufferSize: Math.max(32 * 1024, source.length * 2 + 1024) });
46
51
  }
47
- catch {
52
+ catch (error) {
53
+ // Index scans need the underlying diagnostic for whole-language failures.
54
+ // Other callers retain the historical best-effort null result.
55
+ if (opts.throwOnParseError)
56
+ throw error;
48
57
  return null;
49
58
  }
50
59
  const symbols = [];
@@ -47,7 +47,7 @@ export function renderHunchSection(store, root) {
47
47
  lines.push("**Consult Hunch via the `hunch_*` MCP tools — pick by MOMENT, not from memory:**");
48
48
  lines.push("");
49
49
  lines.push("**Orient (session/task start):**");
50
- lines.push("- For a new user task, call `hunch_task(action: \"start\", title: <short task title>)` once and retain its `task_id`. Claude Code's prompt hook supplies a task ID natively — reuse its exact start arguments instead of creating another task (each new prompt has its own ID). Codex supplies one the same way once its `.codex/hooks.json` is trusted (`/hooks`). Hosts without prompt hooks (Windsurf, Cursor) never receive one: start the task yourself. Reuse the ID for follow-up work on the same task; never borrow another task's ID. This is task bookkeeping; `hunch_context` remains the first memory lookup. If reporting fails, continue the work and disclose the gap.");
50
+ lines.push("- If the host's prompt hook already opened the task and printed a task ID plus a `task verify` command, reuse that exact ID and command — do NOT call `hunch_task(action: \"start\")` for it. Otherwise start the task yourself: call `hunch_task(action: \"start\", title: <short task title>)` once and take `verification_argv` from its result. Each new prompt has its own ID; reuse the ID for follow-up work on the same task and never borrow another task's ID. This is task bookkeeping; `hunch_context` remains the first memory lookup. If reporting fails, continue the work and disclose the gap.");
51
51
  lines.push("- When the user asks to **update Hunch**, run `hunch update` from this repository root. It updates to the latest release and repairs all configured harness pins. Use `hunch update --global` to also update a global CLI alongside a repository dependency; reconnect active MCP sessions afterward.");
52
52
  lines.push("- `hunch_context(target, task_id)` — the minimal relevant slice for what you're about to do; a task phrase falls back to the closest graph matches. **Call FIRST** for memory. Include the current task ID on each context call so its contribution is inspectable.");
53
53
  lines.push("- `hunch_structure(target?)` — the indexed shape of the repo/dir/file/symbol — orient from the graph, not grep rounds.");
@@ -73,10 +73,10 @@ export function renderHunchSection(store, root) {
73
73
  lines.push("- `hunch_pr_impact(base?)` / `hunch_merge_verdict(...)` — a change's memory surface; would it re-open a closed bug?");
74
74
  lines.push("");
75
75
  lines.push("**Before the final response — make Hunch's contribution visible:**");
76
- lines.push("- When running a relevant check, use the exact verification_argv launcher returned by hunch_task start, followed by the check command and its arguments, from this worktree. It runs `hunch task verify <task_id> -- <command> [arguments]` using the same installation as MCP, avoiding stale global binaries. This retains the actual exit result and source snapshot; raw output is not stored. Do not rerun an expensive check solely for reporting; missing evidence stays unverified.");
76
+ lines.push("- When running a relevant check, use the exact launcher the prompt hook printed — or, on a host without one, the `verification_argv` returned by hunch_task start — followed by the check command and its arguments, from this worktree. It runs `hunch task verify <task_id> -- <command> [arguments]` using the same installation as MCP, avoiding stale global binaries. This retains the actual exit result and source snapshot; raw output is not stored. Do not rerun an expensive check solely for reporting; missing evidence stays unverified.");
77
77
  lines.push("- Include the current task_id when calling hunch_record_decision, hunch_record_correction, or hunch_record_finding. The save path records its actual memory home and verifies exact Git revisions when committing or pushing; never infer publication from a successful capture alone.");
78
78
  lines.push("- Before claiming an application, call `hunch_report(task_id)` and copy the exact occurrence_id, record_id and content_hash from application_references, adding an action you actually took. Never derive an occurrence ID by replacing a receipt prefix or use the task's scope hash as a record hash. If you did not apply a lesson, omit applications.");
79
- lines.push("- Call `hunch_task(action: \"finish\", task_id, applications?)` and include the returned contribution_card in your final response without the user asking. Copy the card verbatim, including its Evidence line (the command that renders the local report on demand) and the agent-reported label; the structured result contains the card even when the host hides text blocks. Do not replace it with a generic claim that Hunch helped. If presentation_enabled is false, omit the card. A delivered lesson or passing command alone does not prove causal impact.");
79
+ lines.push("- When this task actually used Hunch (a `hunch_*` call carrying the task_id, a verified check, Hunch hook context you acted on, or an application to claim), call `hunch_task(action: \"finish\", task_id, applications?)` and include the returned contribution_card in your final response without the user asking. Skip the finish call only when it used none of those AND the host's own stop hook closes the task and shows the evidence for you (its prompt-hook instruction says so); where no host hook closes the task, and for a task you started yourself with `hunch_task(action: \"start\")`, always finish it yourself. Copy the card verbatim, including its Evidence line (the command that renders the local report on demand) and the agent-reported label; the structured result contains the card even when the host hides text blocks. Do not replace it with a generic claim that Hunch helped. If presentation_enabled is false, omit the card. A delivered lesson or passing command alone does not prove causal impact.");
80
80
  lines.push("- If interrupted, finish with `outcome: \"interrupted\"` when possible. `hunch_report(task_id, html: true)` opens the evidence trail by generating a local file; it may contain private memory and is not a public export. If report tools are unavailable after an update, say so and reconnect the host rather than inventing a report.");
81
81
  lines.push("");
82
82
  lines.push("**Build the Constitution review queue:**");
@@ -10,17 +10,45 @@ export interface HookInstall {
10
10
  /** The hook file written; for a non-writing result, the file the user should edit. */
11
11
  path: string;
12
12
  /** created/appended/updated/unchanged: the block is (now) in a file git reaches.
13
+ * kept-shared: the shared hook already runs a Hunch that is not inside a
14
+ * linked worktree, so it was deliberately left as it is rather than
15
+ * re-pointed at one (issue #316); a success, not a failure.
13
16
  * 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";
17
+ * unreachable: the existing hook ends in exec/exit before our block, or the
18
+ * block in it has no closing marker and so cannot be replaced in place —
19
+ * nothing was written. */
20
+ action: "created" | "appended" | "updated" | "unchanged" | "kept-shared" | "managed-elsewhere" | "unreachable";
16
21
  manager?: HookManagerKind;
17
22
  /** Why nothing was written (non-writing actions only). */
18
23
  reason?: string;
19
24
  /** What to add to `path` by hand (non-writing actions only). */
20
25
  snippet?: string;
21
- /** The file already carries a Hunch block, but a dead one: the snippet
22
- * REPLACES it rather than being added (non-writing actions only). */
26
+ /** The file already carries a Hunch block — a dead one, or one that cannot be
27
+ * updated in place: the snippet REPLACES it rather than being added
28
+ * (non-writing actions only). */
23
29
  stale?: boolean;
30
+ /** The block that could not be updated still RUNS as it is (it only lacks its
31
+ * closing marker), so the failure is this run's update, not the install. */
32
+ live?: boolean;
33
+ /** The block in the file deliberately runs the repo's EXISTING shared
34
+ * invocation instead of the caller's worktree-bound one (issue #316). Set on
35
+ * kept-shared, and on created/appended/updated when that substitution happened. */
36
+ sharedInvocation?: string;
37
+ /** The options the existing SHARED block carries that this run did not ask for
38
+ * and that were kept anyway — its own flags (BLOCK_FLAGS), e.g. a strict
39
+ * pre-commit guard that a worktree's advisory re-run must not silently
40
+ * downgrade, and the post-commit block's local-only provider line (named
41
+ * `HUNCH_SYNTH_PROVIDER=deterministic`). Set on kept-shared, and on updated
42
+ * when the run also added an option of its own. */
43
+ keptFlags?: string[];
44
+ /** The options this run ADDED to the existing shared block (updated only). */
45
+ addedFlags?: string[];
46
+ /** Set when a DEAD block was rebuilt from the repo's shared launcher and the
47
+ * previous block's own option flags were not requested again. Reported, not
48
+ * restored: the flags come from the caller's options, so re-adding one would
49
+ * invent a request nobody made — but losing a strict guard in silence is
50
+ * exactly the issue-#316 failure mode. */
51
+ droppedFlags?: string[];
24
52
  }
25
53
  /** Portable invocation for text that lands in a TRACKED file (a husky script, a
26
54
  * committed hooks dir, .pre-commit-config.yaml): the same exact-version npx
@@ -125,4 +153,15 @@ export declare function hookInvocationLines(report: HookReport, running: string)
125
153
  * file git runs, otherwise a warning with the reason and the snippet to add to
126
154
  * the manager's own file. */
127
155
  export declare function formatHookInstall(root: string, label: string, h: HookInstall, detail?: string): string[];
156
+ /** The closing line `hunch init` prints about the SHARED hooks dir (issue #316).
157
+ * Setup used to claim "sharing the repo's hooks + memory" unconditionally —
158
+ * true of memory, but the hooks dir is genuinely shared, so a block pointing
159
+ * inside a linked worktree dies for every checkout when that worktree goes.
160
+ *
161
+ * The verdict is read from what the shared blocks ACTUALLY run, not from this
162
+ * run's actions: a second `hunch init` writes nothing yet the hooks are just as
163
+ * broken, and the main checkout — which never used to get a note at all — is
164
+ * where the user can most easily fix it. `installs` only decides between the
165
+ * healthy variants. `[]` when the repo has no linked worktree. Read-only. */
166
+ export declare function sharedHooksNote(root: string, installs: HookInstall[]): string[];
128
167
  export {};