@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
@@ -49,16 +49,59 @@ function edits(value) {
49
49
  .map((item) => ({ new_string: stringAt(item, "new_string", "newString", "ReplacementContent", "replacementContent") }));
50
50
  return normalized.length ? normalized : undefined;
51
51
  }
52
+ /** Parse Codex `apply_patch` text into one entry per touched file. Only `+` lines
53
+ * inside a file section count as added: removed (`-`) and context (` `) lines are
54
+ * text the edit takes away or leaves alone, so a content-matched gate must not read
55
+ * them as proposed content. Paths are returned exactly as written in the patch;
56
+ * the CLI normalizes them against the repository root. */
57
+ const PATCH_HEADER = /^\*\*\* (Update|Add|Delete) File: (.+?)\s*$/;
58
+ const PATCH_MOVE = /^\*\*\* Move to: (.+?)\s*$/;
59
+ export function parseApplyPatch(patch) {
60
+ const files = [];
61
+ let current;
62
+ for (const line of patch.split(/\r?\n/)) {
63
+ const header = PATCH_HEADER.exec(line);
64
+ if (header) {
65
+ current = { path: header[2], action: header[1].toLowerCase(), added_lines: [] };
66
+ files.push(current);
67
+ continue;
68
+ }
69
+ if (!current)
70
+ continue;
71
+ const move = PATCH_MOVE.exec(line);
72
+ if (move) {
73
+ current.moved_to = move[1];
74
+ continue;
75
+ }
76
+ // `*** End Patch` / `*** End of File` close sections; they carry no content.
77
+ if (line.startsWith("*** "))
78
+ continue;
79
+ // A unified-diff style file header is not content; apply_patch has none, but
80
+ // a model can still emit one.
81
+ if (/^\+\+\+ (?:[ab]\/|\/dev\/null)/.test(line))
82
+ continue;
83
+ if (line.startsWith("+"))
84
+ current.added_lines.push(line.slice(1));
85
+ }
86
+ return files;
87
+ }
52
88
  /** Codex edits files through `apply_patch`, whose input is the patch text itself
53
- * (`*** Update File: path`). The first touched path becomes the edit target so the
54
- * per-file pre-edit gate applies; the whole patch stands in for the new content. */
55
- const PATCH_FILE = /^\*\*\* (?:Update|Add|Delete) File: (.+?)\s*$/m;
89
+ * (`*** Update File: path`). Every touched file is listed in `patch_files` so the
90
+ * pre-edit gate runs per file; the first path stays `file_path` and the whole patch
91
+ * stays `content` for consumers that read the single-file shape.
92
+ *
93
+ * TODO(codex-exec-patch): a Codex code-mode `exec` tool call that embeds an
94
+ * apply_patch does NOT reach this parser: `.codex/hooks.json` (integrations/
95
+ * providers.ts) matches PreToolUse on `apply_patch` only, and `normalizeHookEvent`
96
+ * enables patch parsing only for tool names `apply_patch`/`patch`. No captured
97
+ * payload of such a call exists in the repo, so its shape is not guessed here;
98
+ * capture a real hook payload before extending the matcher or this parser. */
56
99
  function applyPatchInput(raw) {
57
100
  const patch = [raw.input, raw.patch, raw.content].find((v) => typeof v === "string" && /\*\*\* Begin Patch/.test(v));
58
101
  if (!patch)
59
102
  return undefined;
60
- const file = PATCH_FILE.exec(patch)?.[1];
61
- return file ? { file_path: file, content: patch } : undefined;
103
+ const files = parseApplyPatch(patch);
104
+ return files.length ? { file_path: files[0].path, content: patch, patch_files: files } : undefined;
62
105
  }
63
106
  function normalizeToolInput(value, allowPatch = false) {
64
107
  const raw = obj(value);
@@ -109,8 +152,12 @@ function toolOutput(value) {
109
152
  }
110
153
  function explicitToolOutcome(response) {
111
154
  const raw = obj(response);
155
+ // A bare string carries no status. Codex sends a Bash call's raw output this
156
+ // way, without its exit code, and a failing test run prints output as readily
157
+ // as a passing one. Status-looking text inside it ("Exit code: 0") is output
158
+ // the command itself can print, so it is never parsed as a status either.
112
159
  if (!raw)
113
- return typeof response === "string" && response.trim() ? "success" : "unknown";
160
+ return "unknown";
114
161
  if (raw.success === false || raw.is_error === true || raw.isError === true || (raw.error !== undefined && raw.error !== null))
115
162
  return "failure";
116
163
  const status = raw.status;
@@ -133,8 +180,8 @@ function explicitToolOutcome(response) {
133
180
  if (explicitSuccess)
134
181
  return "success";
135
182
  // Common successful tool-result shapes carry output fields even when the
136
- // output is empty. An unstructured empty string (Codex's native failure
137
- // payload) remains unknown until the host supplies an explicit status.
183
+ // output is empty (Claude Code routes failed calls to PostToolUseFailure
184
+ // instead). An unstructured string remains unknown (see above).
138
185
  if (["stdout", "stderr", "output", "content"].some(key => Object.prototype.hasOwnProperty.call(raw, key)))
139
186
  return "success";
140
187
  return "unknown";
@@ -3,10 +3,18 @@
3
3
  *
4
4
  * hunch_capture_decision issues a short-lived token; the commit path consumes it, so a
5
5
  * decision written through the capture front door is provably the tail of an interview
6
- * — the identity-principle guard against a silent, un-interviewed write. In-memory (the
7
- * MCP server is long-lived); tokens are one-time-use and expire so an abandoned
8
- * interview can't leak. Absence of a token never BLOCKS a write yet (staged
6
+ * PROTOCOL — the identity-principle guard against a silent, un-interviewed write.
7
+ * In-memory (the MCP server is long-lived); tokens are one-time-use and expire so an
8
+ * abandoned interview can't leak. Absence of a token never BLOCKS a write yet (staged
9
9
  * deprecation §9.3) — the caller decides how to treat an un-gated write.
10
+ *
11
+ * WHAT A TOKEN DOES NOT PROVE: that a human answered. Any agent — or content steering
12
+ * one — can call hunch_capture_decision and consume the token it gets back, entirely
13
+ * inside the agent's own MCP channel. So a consumed token never confers
14
+ * `human_confirmed` on its own (the stamp the strict gate and the edit hook trust). It
15
+ * only licenses ASKING the human through a channel the agent does not control: an MCP
16
+ * elicitation answered in the client UI (`isHumanConfirmationAnswer`), or a human
17
+ * running `hunch review --confirm <id>`. Without one of those, the write is testimony.
10
18
  */
11
19
  declare const CAPTURE_TOKEN_TTL_MS: number;
12
20
  /** Issue a token stamped `now` (epoch ms). Prunes expired tokens first so the map can't
@@ -16,4 +24,23 @@ export declare function issueCaptureToken(mint: () => string, now: number): stri
16
24
  /** Consume a token iff it is a live, unexpired capture session. One-time use: a second
17
25
  * consume of the same token returns false. */
18
26
  export declare function consumeCaptureToken(token: string | undefined, now: number): boolean;
27
+ /** The form a human answers in the client UI (MCP `elicitation/create`, form mode). One
28
+ * required boolean, so a bare "accept" (an empty submit) is never read as a yes. */
29
+ export declare const HUMAN_CONFIRMATION_SCHEMA: {
30
+ type: "object";
31
+ properties: {
32
+ confirm: {
33
+ type: "boolean";
34
+ title: string;
35
+ description: string;
36
+ };
37
+ };
38
+ required: string[];
39
+ };
40
+ /** Did the human affirmatively confirm? Only an explicit accept WITH confirm === true
41
+ * counts; decline, cancel, and a missing or false checkbox all mean "no signature". */
42
+ export declare function isHumanConfirmationAnswer(result: {
43
+ action?: string;
44
+ content?: Record<string, unknown>;
45
+ } | null | undefined): boolean;
19
46
  export { CAPTURE_TOKEN_TTL_MS };
@@ -3,10 +3,18 @@
3
3
  *
4
4
  * hunch_capture_decision issues a short-lived token; the commit path consumes it, so a
5
5
  * decision written through the capture front door is provably the tail of an interview
6
- * — the identity-principle guard against a silent, un-interviewed write. In-memory (the
7
- * MCP server is long-lived); tokens are one-time-use and expire so an abandoned
8
- * interview can't leak. Absence of a token never BLOCKS a write yet (staged
6
+ * PROTOCOL — the identity-principle guard against a silent, un-interviewed write.
7
+ * In-memory (the MCP server is long-lived); tokens are one-time-use and expire so an
8
+ * abandoned interview can't leak. Absence of a token never BLOCKS a write yet (staged
9
9
  * deprecation §9.3) — the caller decides how to treat an un-gated write.
10
+ *
11
+ * WHAT A TOKEN DOES NOT PROVE: that a human answered. Any agent — or content steering
12
+ * one — can call hunch_capture_decision and consume the token it gets back, entirely
13
+ * inside the agent's own MCP channel. So a consumed token never confers
14
+ * `human_confirmed` on its own (the stamp the strict gate and the edit hook trust). It
15
+ * only licenses ASKING the human through a channel the agent does not control: an MCP
16
+ * elicitation answered in the client UI (`isHumanConfirmationAnswer`), or a human
17
+ * running `hunch review --confirm <id>`. Without one of those, the write is testimony.
10
18
  */
11
19
  const CAPTURE_TOKEN_TTL_MS = 30 * 60 * 1000; // 30 min
12
20
  const sessions = new Map(); // token -> issuedAt (epoch ms)
@@ -32,5 +40,23 @@ export function consumeCaptureToken(token, now) {
32
40
  sessions.delete(token);
33
41
  return now - at <= CAPTURE_TOKEN_TTL_MS;
34
42
  }
43
+ /** The form a human answers in the client UI (MCP `elicitation/create`, form mode). One
44
+ * required boolean, so a bare "accept" (an empty submit) is never read as a yes. */
45
+ export const HUMAN_CONFIRMATION_SCHEMA = {
46
+ type: "object",
47
+ properties: {
48
+ confirm: {
49
+ type: "boolean",
50
+ title: "I confirm this myself",
51
+ description: "Check only if YOU stated this. Unchecked, it is kept as agent testimony that cannot block.",
52
+ },
53
+ },
54
+ required: ["confirm"],
55
+ };
56
+ /** Did the human affirmatively confirm? Only an explicit accept WITH confirm === true
57
+ * counts; decline, cancel, and a missing or false checkbox all mean "no signature". */
58
+ export function isHumanConfirmationAnswer(result) {
59
+ return result?.action === "accept" && result.content?.confirm === true;
60
+ }
35
61
  export { CAPTURE_TOKEN_TTL_MS };
36
62
  //# sourceMappingURL=capturetoken.js.map
@@ -94,7 +94,8 @@ function exactChangedPaths(root, baseRevision, resultRevision) {
94
94
  }
95
95
  function exactDiff(root, baseRevision, resultRevision) {
96
96
  const raw = gitBytes(root, [
97
- "diff", "--no-ext-diff", "--no-textconv", "--no-color", "--no-renames", "--unified=2",
97
+ "-c", "core.quotePath=false",
98
+ "diff", "--no-ext-diff", "--no-textconv", "--no-color", "--no-renames", "--unified=2", "--src-prefix=a/", "--dst-prefix=b/",
98
99
  baseRevision, resultRevision, "--",
99
100
  ]);
100
101
  if (raw.byteLength <= MAX_DIFF_BYTES)
@@ -309,6 +310,9 @@ export function deriveChangeProof(root, store, baseRef, resultRef = "HEAD", opti
309
310
  const guardReport = store.buildCheckReport(changed.paths, diff.diff, {
310
311
  strict: true,
311
312
  publicOnly,
313
+ // A diff cut at MAX_DIFF_BYTES is a prefix: content-matched blocking rules over the
314
+ // files it omits fail closed rather than read as compliant (dec_20db57c576).
315
+ diffStatus: diff.gaps.length ? { incomplete: `the guard diff exceeded ${MAX_DIFF_BYTES} bytes and was cut`, truncated: true } : undefined,
312
316
  lastChange: (path) => lastChangeAt(root, change.head_revision, path),
313
317
  });
314
318
  const allStrictBlockerIds = sortedUnique([
@@ -29,6 +29,13 @@ export interface CheckDirect {
29
29
  strictBlocks: boolean;
30
30
  /** If a blocking invariant is downgraded to advisory under strict, why. */
31
31
  downgrade?: "stale" | "low-confidence";
32
+ /** Set when a content-matched blocking invariant could NOT be evaluated because the
33
+ * added lines of some scoped files are missing from the diff (truncated diff, git
34
+ * failure, unreadable file). Reported as a hit, never as compliance: it fails closed. */
35
+ unevaluable?: {
36
+ reason: string;
37
+ files: string[];
38
+ };
32
39
  /** The causal citation (the "why this guard exists") — present when the graph links it. */
33
40
  why?: CausalWhy;
34
41
  }
@@ -44,6 +44,16 @@ export function renderImpact(im, scope) {
44
44
  }
45
45
  return out.join("\n");
46
46
  }
47
+ /** Strict-failure reason fragments for direct invariants: proven hits and the
48
+ * content-matched invariants that could not be evaluated are named separately. */
49
+ function strictBlockerReasons(r) {
50
+ const unevaluable = r.direct.filter((d) => d.strictBlocks && d.unevaluable).length;
51
+ const proven = r.strictBlockers - unevaluable;
52
+ return [
53
+ proven > 0 ? `${proven} high-confidence blocking invariant(s) directly in scope` : "",
54
+ unevaluable > 0 ? `${unevaluable} blocking invariant(s) that could not be evaluated against the complete diff` : "",
55
+ ];
56
+ }
47
57
  /** True when --strict should FAIL the commit/PR. */
48
58
  export function reportFailsStrict(r) {
49
59
  return r.strict && (r.strictBlockers > 0 || r.regBlocking > 0 || r.vetoBlocking > 0);
@@ -93,7 +103,10 @@ export function renderText(r) {
93
103
  const note = r.strict && c.severity === "blocking" && !c.strictBlocks
94
104
  ? c.downgrade === "stale" ? " (advisory: stale)" : " (advisory: low confidence)"
95
105
  : "";
96
- out.push(` ${mark(c.severity)} [${c.severity}] ${c.statement}${note}\n ${c.id} · in: ${c.files.join(", ")}\n rationale: ${c.rationale || "—"}${whyText(c.why)}`);
106
+ const unevaluable = c.unevaluable
107
+ ? `\n ‼ NOT EVALUATED — ${c.unevaluable.reason}; added lines unavailable for: ${c.unevaluable.files.join(", ")} (fails closed)`
108
+ : "";
109
+ out.push(` ${mark(c.severity)} [${c.severity}] ${c.statement}${note}\n ${c.id} · in: ${c.files.join(", ")}\n rationale: ${c.rationale || "—"}${unevaluable}${whyText(c.why)}`);
97
110
  }
98
111
  }
99
112
  if (r.near.length) {
@@ -122,7 +135,7 @@ export function renderText(r) {
122
135
  }
123
136
  if (reportFailsStrict(r)) {
124
137
  const reasons = [
125
- r.strictBlockers ? `${r.strictBlockers} high-confidence blocking invariant(s) directly in scope` : "",
138
+ ...strictBlockerReasons(r),
126
139
  r.regBlocking ? `${r.regBlocking} blocking-linked regression(s)` : "",
127
140
  r.vetoBlocking ? `${r.vetoBlocking} reversed-decision veto(es)` : "",
128
141
  ].filter(Boolean).join(" + ");
@@ -153,6 +166,8 @@ export function renderMarkdown(r) {
153
166
  : "";
154
167
  out.push(`- **[${c.severity}] ${c.statement}** — \`${c.id}\`${note}`);
155
168
  out.push(` - in: ${c.files.map((f) => `\`${f}\``).join(", ")}`);
169
+ if (c.unevaluable)
170
+ out.push(` - ‼ **Not evaluated** — ${c.unevaluable.reason}; added lines unavailable for ${c.unevaluable.files.map((f) => `\`${f}\``).join(", ")} _(fails closed)_`);
156
171
  if (c.rationale)
157
172
  out.push(` - _${c.rationale}_`);
158
173
  for (const line of whyMd(c.why))
@@ -196,7 +211,7 @@ export function renderMarkdown(r) {
196
211
  out.push("---");
197
212
  if (reportFailsStrict(r)) {
198
213
  const reasons = [
199
- r.strictBlockers ? `${r.strictBlockers} high-confidence blocking invariant(s) directly in scope` : "",
214
+ ...strictBlockerReasons(r),
200
215
  r.regBlocking ? `${r.regBlocking} blocking-linked regression(s)` : "",
201
216
  r.vetoBlocking ? `${r.vetoBlocking} reversed-decision veto(es)` : "",
202
217
  ].filter(Boolean).join(" + ");
@@ -233,6 +248,8 @@ export function renderSarif(r, version, extras = {}) {
233
248
  text += ` — ${c.rationale}`;
234
249
  if (c.downgrade)
235
250
  text += ` (advisory under strict: ${c.downgrade})`;
251
+ if (c.unevaluable)
252
+ text += `\nNOT EVALUATED (fails closed): ${c.unevaluable.reason}; added lines unavailable for ${c.unevaluable.files.join(", ")}`;
236
253
  if (c.why?.decision)
237
254
  text += `\nwhy: “${c.why.decision.title}” (${c.why.decision.id})`;
238
255
  if (c.why?.bug)
@@ -1,5 +1,5 @@
1
1
  import { verdict } from "./checkreport.js";
2
- import { revExists, rangeFiles, rangeDiff } from "../extractors/git.js";
2
+ import { revExists, rangeFiles, rangeGateDiff } from "../extractors/git.js";
3
3
  /** A lower fit score is better. Verdict dominates (pass < warn < block), then the
4
4
  * blocking count, then total advisory hits. Errored candidates sort last. */
5
5
  function fitKey(c) {
@@ -14,7 +14,8 @@ export function compareCandidates(store, root, base, candidates) {
14
14
  const files = rangeFiles(base, root, ref);
15
15
  if (!files.length)
16
16
  return { ...zero, verdict: "pass", error: `no changes vs ${base}` };
17
- const r = store.buildCheckReport(files, rangeDiff(base, root, ref), { strict: true });
17
+ const gate = rangeGateDiff(base, root, ref);
18
+ const r = store.buildCheckReport(files, gate.diff, { strict: true, diffStatus: gate });
18
19
  return {
19
20
  ref,
20
21
  verdict: verdict(r),
@@ -25,12 +25,18 @@ export interface CorrectionInput {
25
25
  * consumer matches repo-relative paths — so without this an absolute hint mints a
26
26
  * scope that can never match. */
27
27
  root?: string;
28
- /** True when a capture token was CONSUMED for this write — proof a grilling interview
29
- * preceded it. Determines the TIER, never whether the write lands: an un-vouched
30
- * correction still records immediately and still surfaces at edit time and in CI.
31
- * Only the authority to DENY waits for a countersign. */
28
+ /** True only when a HUMAN confirmed this write outside the agent's channel (an MCP
29
+ * elicitation answered in the client UI). A consumed capture token alone is NOT
30
+ * enough — any agent can mint and consume one. Determines the TIER, never whether
31
+ * the write lands: an un-vouched correction still records immediately and still
32
+ * surfaces at edit time and in CI. Only the authority to DENY waits for a countersign
33
+ * (`hunch review --confirm <id>`). */
32
34
  vouched?: boolean;
33
35
  }
36
+ /** Default rationales, exported so a later human countersign can replace the testimony
37
+ * wording without touching a rationale a person actually wrote. */
38
+ export declare const VOUCHED_CORRECTION_RATIONALE = "Captured from a human correction of the agent (Never Twice).";
39
+ export declare const TESTIMONY_CORRECTION_RATIONALE = "Recorded by the agent as a correction, without a human confirmation \u2014 advisory testimony until a human countersigns it (`hunch review --confirm`) (Never Twice).";
34
40
  /**
35
41
  * Build the Constraint a correction mints. Pure (caller passes `now`), so the
36
42
  * scope/severity policy is testable in isolation. Key safety rule (research
@@ -41,6 +41,10 @@ export const CORRECTION_NUDGE = "This looks like a correction. If it's a rule th
41
41
  "call hunch_record_correction({ rule, scope_hint_file, severity, applies_to_all }) so it " +
42
42
  "becomes an enforced, scoped constraint (held at edit-time and in CI) — not a one-off the next session forgets. " +
43
43
  "Use severity:\"blocking\" only when the human said never/must; set applies_to_all:true only if the rule is genuinely repo-wide.";
44
+ /** Default rationales, exported so a later human countersign can replace the testimony
45
+ * wording without touching a rationale a person actually wrote. */
46
+ export const VOUCHED_CORRECTION_RATIONALE = "Captured from a human correction of the agent (Never Twice).";
47
+ export const TESTIMONY_CORRECTION_RATIONALE = "Recorded by the agent as a correction, without a human confirmation — advisory testimony until a human countersigns it (`hunch review --confirm`) (Never Twice).";
44
48
  /** Normalize a scope hint to a repo-relative POSIX path.
45
49
  *
46
50
  * An ABSOLUTE hint is the shape an agent naturally sends, but `checkConstraints`
@@ -94,7 +98,8 @@ export function buildCorrectionConstraint(input, now) {
94
98
  // the highest-authority write path the least gated one, and strictly worse than
95
99
  // hunch_record_decision, which only ever produced advisory memory and is now tiered.
96
100
  //
97
- // The token sets the TIER, never whether the write lands. An un-vouched correction is
101
+ // A HUMAN confirmation sets the TIER (a capture token alone is not one — any agent can
102
+ // mint and consume a token), never whether the write lands. An un-vouched correction is
98
103
  // still recorded immediately and still held against every assistant at edit time and
99
104
  // in CI — Never Twice keeps its promise. What waits for a countersign is only the
100
105
  // authority to DENY.
@@ -114,9 +119,7 @@ export function buildCorrectionConstraint(input, now) {
114
119
  // rule that goes stale. Validated against the repo's real deps when supplied → never mints a
115
120
  // never-firing rule for a non-dependency. null when nothing derivable → falls back to scope.
116
121
  forbids: deriveForbids(rule, input.knownDeps),
117
- rationale: input.rationale ?? (vouched
118
- ? "Captured from a human correction of the agent (Never Twice)."
119
- : "Recorded by the agent as a correction, WITHOUT a capture interview — advisory testimony until a human countersigns it via /capture (Never Twice)."),
122
+ rationale: input.rationale ?? (vouched ? VOUCHED_CORRECTION_RATIONALE : TESTIMONY_CORRECTION_RATIONALE),
120
123
  source_decision: input.source_decision ?? null,
121
124
  violations: [],
122
125
  status: "active",
@@ -0,0 +1,28 @@
1
+ /** Human countersign: turn agent testimony into a human-confirmed record.
2
+ *
3
+ * A capture token proves an interview protocol was issued, not that a human answered
4
+ * (see capturetoken.ts), so agent-written decisions and corrections land as
5
+ * `agent_recorded` testimony. This is the pure record transform behind the human act
6
+ * that upgrades them — `hunch review --confirm <id>` (and, for a correction, the
7
+ * severity the human grants). It never changes a record's content or status: confirming
8
+ * a proposed decision is not shipping it. Pure (caller passes `now`) so it is testable. */
9
+ import type { Constraint, Decision } from "./types.js";
10
+ /** Is this record agent testimony awaiting a human countersign? Token-aware ("+"-joined
11
+ * sources), and a record carrying a human signature is never testimony. */
12
+ export declare function isAgentTestimony(source: string | undefined): boolean;
13
+ /** The exact command a HUMAN runs to countersign agent testimony (outside the agent
14
+ * channel). `private` targets the overlay home; `severity` grants a correction's authority. */
15
+ export declare function confirmCommand(id: string, opts?: {
16
+ private?: boolean;
17
+ severity?: string;
18
+ }): string;
19
+ /** Replace the agent testimony tier with the human signature, keeping every other
20
+ * "+"-joined source token ("llm_draft+agent_recorded" → "llm_draft+human_confirmed"). */
21
+ export declare function withHumanSignature(source: string): string;
22
+ /** Countersign a decision. Same tier + confidence the capture path grants a human-confirmed
23
+ * write; status, content, and tripwires are untouched (`hunch review --accept` is the path
24
+ * that ships a draft and arms its tripwires). */
25
+ export declare function countersignDecision(d: Decision, now: string): Decision;
26
+ /** Countersign a correction. `severity`, when given, is the authority the human grants
27
+ * (an unconfirmed "blocking" request was capped to "warning"); otherwise it is kept. */
28
+ export declare function countersignConstraint(c: Constraint, now: string, severity?: Constraint["severity"]): Constraint;
@@ -0,0 +1,50 @@
1
+ import { TESTIMONY_CORRECTION_RATIONALE, VOUCHED_CORRECTION_RATIONALE } from "./correction.js";
2
+ /** Is this record agent testimony awaiting a human countersign? Token-aware ("+"-joined
3
+ * sources), and a record carrying a human signature is never testimony. */
4
+ export function isAgentTestimony(source) {
5
+ const tokens = (source ?? "").split("+");
6
+ return tokens.includes("agent_recorded") && !tokens.includes("human_confirmed");
7
+ }
8
+ /** The exact command a HUMAN runs to countersign agent testimony (outside the agent
9
+ * channel). `private` targets the overlay home; `severity` grants a correction's authority. */
10
+ export function confirmCommand(id, opts = {}) {
11
+ return `hunch review --confirm ${id}${opts.severity ? ` --severity ${opts.severity}` : ""}${opts.private ? " --private" : ""}`;
12
+ }
13
+ /** Replace the agent testimony tier with the human signature, keeping every other
14
+ * "+"-joined source token ("llm_draft+agent_recorded" → "llm_draft+human_confirmed"). */
15
+ export function withHumanSignature(source) {
16
+ const tokens = source.split("+").filter((t) => t && t !== "agent_recorded");
17
+ if (!tokens.includes("human_confirmed"))
18
+ tokens.push("human_confirmed");
19
+ return tokens.join("+");
20
+ }
21
+ /** Countersign a decision. Same tier + confidence the capture path grants a human-confirmed
22
+ * write; status, content, and tripwires are untouched (`hunch review --accept` is the path
23
+ * that ships a draft and arms its tripwires). */
24
+ export function countersignDecision(d, now) {
25
+ return {
26
+ ...d,
27
+ provenance: {
28
+ ...d.provenance,
29
+ source: withHumanSignature(d.provenance.source),
30
+ confidence: Math.max(d.provenance.confidence ?? 0, 0.95),
31
+ last_verified: now,
32
+ },
33
+ };
34
+ }
35
+ /** Countersign a correction. `severity`, when given, is the authority the human grants
36
+ * (an unconfirmed "blocking" request was capped to "warning"); otherwise it is kept. */
37
+ export function countersignConstraint(c, now, severity) {
38
+ return {
39
+ ...c,
40
+ severity: severity ?? c.severity,
41
+ rationale: c.rationale === TESTIMONY_CORRECTION_RATIONALE ? VOUCHED_CORRECTION_RATIONALE : c.rationale,
42
+ provenance: {
43
+ ...c.provenance,
44
+ source: withHumanSignature(c.provenance.source),
45
+ confidence: 1,
46
+ last_verified: now,
47
+ },
48
+ };
49
+ }
50
+ //# sourceMappingURL=countersign.js.map
@@ -33,7 +33,12 @@ export const READY_MIN_GROUNDED = 0.7;
33
33
  * explicit roadmap/intent entry a human hasn't confirmed — counts as a review draft.
34
34
  * (Enforcement authority is granted INLINE, not by draining a background queue.) */
35
35
  export function isReviewDraft(d) {
36
- return d.status === "proposed" && !d.provenance.source.includes("human_confirmed");
36
+ // Agent testimony (agent_recorded) is deliberate intent, not a machine draft: it shows
37
+ // on the roadmap marked unconfirmed, and a human confirms it with `hunch review
38
+ // --confirm`. Keeping it out of the draft queue keeps `adopt-drafts` / `auto-review`
39
+ // from accepting or rejecting it in bulk.
40
+ return d.status === "proposed" && !d.provenance.source.includes("human_confirmed")
41
+ && !d.provenance.source.split("+").includes("agent_recorded");
37
42
  }
38
43
  /** A draft is "ready to confirm" only when the Critic actually audited it (source
39
44
  * includes "verified") AND judged it well-grounded. A high confidence number alone
@@ -9,16 +9,48 @@
9
9
  * npm/npx launchers run as plain Node scripts (no shell at all). */
10
10
  import { existsSync } from "node:fs";
11
11
  import { posix, win32 } from "node:path";
12
- /** cmd.exe quoting for one argument: wrap when it has whitespace or shell
13
- * metacharacters; double embedded quotes. Good for test/build commands; a
14
- * deliberately hostile argument still cannot escape because the whole line is
15
- * passed as one `/s /c "..."` token. */
12
+ /** Quote one argument for a `cmd.exe /d /v:off /s /c "<line>"` launch of a
13
+ * batch file whose target program parses its command line with the MSVCRT
14
+ * rules (Node, Python, most native tools).
15
+ *
16
+ * Two parsers read the line, and a batch shim that forwards `%*` makes cmd.exe
17
+ * read it again, so the quoting must mean the same thing to both on every pass:
18
+ *
19
+ * - Every non-trivial argument is wrapped in quotes. An embedded quote becomes
20
+ * `""` (not `\"`): MSVCRT reads `""` inside a quoted argument as one literal
21
+ * quote, and cmd.exe sees two toggles, so its quote state never drifts from
22
+ * the argument boundaries and `& | < > ( ) ^` always stay inside quotes.
23
+ * A `\"` would look escaped to MSVCRT but end the quoted region for cmd.exe.
24
+ * - Backslashes are literal except before a quote, so a run of backslashes that
25
+ * precedes an embedded or closing quote is doubled.
26
+ * - `%` expands even inside quotes. It is emitted as `"^%"`: the quote closes,
27
+ * the caret escapes the percent outside quotes (cmd.exe removes the caret),
28
+ * and the quote reopens. MSVCRT joins the pieces back into one argument.
29
+ * Expansion of a forwarded `%*` is not rescanned, so a shim pass is safe too.
30
+ *
31
+ * A line break cannot be carried: cmd.exe ends the command at it and silently
32
+ * drops the rest, so such an argument is refused rather than truncated. */
16
33
  function quoteForCmd(arg) {
17
- if (arg === "")
18
- return '""';
19
- if (!/[\s"&|<>^()%!]/.test(arg))
34
+ if (/[\r\n]/.test(arg))
35
+ throw new Error("a .cmd/.bat launcher cannot receive an argument containing a line break");
36
+ if (arg !== "" && /^[A-Za-z0-9_\-.:/\\@+]+$/.test(arg))
20
37
  return arg;
21
- return `"${arg.replace(/"/g, '""')}"`;
38
+ let out = '"';
39
+ let backslashes = 0;
40
+ for (const ch of arg) {
41
+ if (ch === "\\") {
42
+ backslashes++;
43
+ continue;
44
+ }
45
+ if (ch === '"')
46
+ out += "\\".repeat(backslashes * 2) + '""';
47
+ else if (ch === "%")
48
+ out += "\\".repeat(backslashes * 2) + '"^%"';
49
+ else
50
+ out += "\\".repeat(backslashes) + ch;
51
+ backslashes = 0;
52
+ }
53
+ return out + "\\".repeat(backslashes * 2) + '"';
22
54
  }
23
55
  export function resolveSpawnCommand(command, options = {}) {
24
56
  const platform = options.platform ?? process.platform;
@@ -49,7 +81,7 @@ export function resolveSpawnCommand(command, options = {}) {
49
81
  continue;
50
82
  if (/\.(cmd|bat)$/i.test(candidate)) {
51
83
  const line = [candidate, ...args].map(quoteForCmd).join(" ");
52
- return { file: env.ComSpec ?? "cmd.exe", args: ["/d", "/s", "/c", `"${line}"`], windowsVerbatimArguments: true, how: "cmd-shim" };
84
+ return { file: env.ComSpec ?? "cmd.exe", args: ["/d", "/v:off", "/s", "/c", `"${line}"`], windowsVerbatimArguments: true, how: "cmd-shim" };
53
85
  }
54
86
  if (ext === "" && !/\.(exe|com)$/i.test(candidate))
55
87
  continue; // an extensionless file is not runnable on Windows
@@ -40,7 +40,7 @@ export declare const HttpHealthSchema: z.ZodObject<{
40
40
  ok: z.ZodBoolean;
41
41
  version: z.ZodString;
42
42
  protocol: z.ZodLiteral<"nuryel.state/1">;
43
- partitions: z.ZodArray<z.ZodString>;
43
+ partitions: z.ZodOptional<z.ZodArray<z.ZodString>>;
44
44
  }, z.core.$strict>;
45
45
  export declare const HttpReadResponseSchema: z.ZodObject<{
46
46
  conventions: z.ZodOptional<z.ZodObject<{
@@ -6,7 +6,9 @@ export const HttpCapabilitiesSchema = CapabilityNegotiationSchema.extend({
6
6
  principal: PrincipalSchema.pick({ id: true, kind: true, grants: true }),
7
7
  }).strict();
8
8
  export const HttpHealthSchema = z.object({
9
- ok: z.boolean(), version: z.string(), protocol: z.literal(STATE_CONTRACT_VERSION), partitions: z.array(z.string()),
9
+ ok: z.boolean(), version: z.string(), protocol: z.literal(STATE_CONTRACT_VERSION),
10
+ // Present only when the request carried a valid credential.
11
+ partitions: z.array(z.string()).optional(),
10
12
  }).strict();
11
13
  // Delivery has its own richer assertion and receipt checks in delivery.ts.
12
14
  export const HttpReadResponseSchema = ReadResponseSchema.extend({ envelope: z.record(z.string(), z.unknown()) });
@@ -5,7 +5,7 @@ import { canonicalReportRoot } from "./taskReportPaths.js";
5
5
  import { resolveSpawnCommand } from "./spawnCommand.js";
6
6
  import { dirname, isAbsolute, relative, resolve } from "node:path";
7
7
  import { analyzeDiff } from "../extractors/diff.js";
8
- import { workingDiff, workingFiles } from "../extractors/git.js";
8
+ import { workingGateDiff, workingFiles } from "../extractors/git.js";
9
9
  import { assertCompleteRepoScan, scanRepo } from "../extractors/indexer.js";
10
10
  import { checkConformance } from "./conformance.js";
11
11
  import { effectiveForbids, matchForbids } from "./constraintmatch.js";
@@ -104,9 +104,9 @@ export function runReportConformance(root, store, taskId) {
104
104
  const before = reportSourceSnapshot(root).hash;
105
105
  const files = workingFiles(root);
106
106
  const changed = new Set(files);
107
- const diff = workingDiff(root);
108
- const analysis = analyzeDiff(diff);
109
- const truncated = diff.endsWith("…(diff truncated)…");
107
+ const gate = workingGateDiff(root);
108
+ const analysis = analyzeDiff(gate.diff);
109
+ const unread = new Set(gate.unreadFiles ?? []);
110
110
  let graph;
111
111
  const workingGraph = () => {
112
112
  if (graph !== undefined)
@@ -141,8 +141,8 @@ export function runReportConformance(root, store, taskId) {
141
141
  note("constraint-forbids", "not-exercised", "No changed file falls in this constraint's scope.");
142
142
  continue;
143
143
  }
144
- if (truncated) {
145
- note("constraint-forbids", "unavailable", "The working diff exceeds the bounded analysis budget; added lines were not fully inspected.", scoped);
144
+ if (gate.incomplete || scoped.some(f => unread.has(f))) {
145
+ note("constraint-forbids", "unavailable", "The complete working diff could not be read; added lines were not fully inspected.", scoped);
146
146
  continue;
147
147
  }
148
148
  const match = matchForbids(forbids, new Set(analysis.addedDeps), scoped.flatMap(f => analysis.addedLinesByFile.get(f) ?? []));
@@ -216,9 +216,31 @@ export async function runReportCheck(root, taskId, command, label, timeoutMs = D
216
216
  // Windows launchers (npx.cmd, npm.cmd, other .cmd/.bat shims) cannot be spawned
217
217
  // without a shell; resolve them first so a check actually runs instead of
218
218
  // silently recording exit_code null (fnd: every Windows card said "no result").
219
- const resolved = resolveSpawnCommand(command);
220
- const child = spawn(resolved.file, resolved.args, { cwd: root, shell: false, stdio: ["ignore", "pipe", "pipe"], windowsHide: true, detached: process.platform !== "win32", windowsVerbatimArguments: resolved.windowsVerbatimArguments === true });
221
219
  const stdout = createHash("sha256"), stderr = createHash("sha256");
220
+ const launchFailure = (error) => {
221
+ // A launch failure is a result the user must see (ENOENT is the common
222
+ // one); it is hashed like any other stderr and streamed to the caller.
223
+ const text = error instanceof Error ? error.message : String(error);
224
+ return Buffer.from(`hunch: could not start ${JSON.stringify(command[0])}: ${text}\n`);
225
+ };
226
+ const started = (() => {
227
+ try {
228
+ const resolved = resolveSpawnCommand(command);
229
+ return spawn(resolved.file, resolved.args, { cwd: root, shell: false, stdio: ["ignore", "pipe", "pipe"], windowsHide: true, detached: process.platform !== "win32", windowsVerbatimArguments: resolved.windowsVerbatimArguments === true });
230
+ }
231
+ catch (error) {
232
+ // An argument the launcher cannot carry, or a synchronous spawn refusal
233
+ // (EINVAL for a batch file), is the same visible failure as ENOENT.
234
+ return launchFailure(error);
235
+ }
236
+ })();
237
+ if (Buffer.isBuffer(started)) {
238
+ stderr.update(started);
239
+ options.onStderr?.(started);
240
+ resolveResult({ code: null, timedOut: false, cancelled: false, hash: reportHash({ stdout: stdout.digest("hex"), stderr: stderr.digest("hex") }) });
241
+ return;
242
+ }
243
+ const child = started;
222
244
  let timedOut = false, cancelled = false, settled = false;
223
245
  let cleanupTimer;
224
246
  const settle = (code) => {
@@ -267,9 +289,7 @@ export async function runReportCheck(root, taskId, command, label, timeoutMs = D
267
289
  options.onStderr?.(chunk);
268
290
  } });
269
291
  child.once("error", (error) => {
270
- // A launch failure is a result the user must see (ENOENT is the common
271
- // one); it is hashed like any other stderr and streamed to the caller.
272
- const message = Buffer.from(`hunch: could not start ${JSON.stringify(command[0])}: ${error.message}\n`);
292
+ const message = launchFailure(error);
273
293
  if (!settled) {
274
294
  stderr.update(message);
275
295
  options.onStderr?.(message);
@@ -86,7 +86,7 @@ export function renderGrounding(fileDecisions, allDecisions = fileDecisions) {
86
86
  // Token-aware match (mirrors strictgate.isHumanConfirmed; not imported — that
87
87
  // module imports this one).
88
88
  const testimony = d.provenance.source.split("+").includes("agent_recorded")
89
- ? " — ⚠ agent-recorded testimony, no human countersign yet (/capture confirms it)"
89
+ ? ` — ⚠ agent-recorded testimony, no human countersign yet (a human confirms it: hunch review --confirm ${d.id})`
90
90
  : "";
91
91
  return `• "${d.topic}": ${d.decision || d.title} [${d.id}]${rej}${testimony}`;
92
92
  });