@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
package/dist/core/agenthook.js
CHANGED
|
@@ -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`).
|
|
54
|
-
*
|
|
55
|
-
|
|
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
|
|
61
|
-
return
|
|
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
|
|
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
|
|
137
|
-
//
|
|
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.
|
|
7
|
-
* MCP server is long-lived); tokens are one-time-use and expire so an
|
|
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.
|
|
7
|
-
* MCP server is long-lived); tokens are one-time-use and expire so an
|
|
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
|
package/dist/core/changeProof.js
CHANGED
|
@@ -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
|
-
"
|
|
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
|
}
|
package/dist/core/checkreport.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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)
|
package/dist/core/compare.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { verdict } from "./checkreport.js";
|
|
2
|
-
import { revExists, rangeFiles,
|
|
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
|
|
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
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
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
|
package/dist/core/correction.js
CHANGED
|
@@ -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
|
-
//
|
|
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
|
package/dist/core/reviewqueue.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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
|
-
|
|
19
|
-
if (
|
|
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
|
-
|
|
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
|
package/dist/core/stateHttp.d.ts
CHANGED
|
@@ -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<{
|
package/dist/core/stateHttp.js
CHANGED
|
@@ -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),
|
|
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 {
|
|
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
|
|
108
|
-
const analysis = analyzeDiff(diff);
|
|
109
|
-
const
|
|
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 (
|
|
145
|
-
note("constraint-forbids", "unavailable", "The working diff
|
|
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
|
-
|
|
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);
|
package/dist/core/topics.js
CHANGED
|
@@ -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
|
-
?
|
|
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
|
});
|