@davesheffer/hunch 1.39.1 → 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 +67 -5
- package/dist/client/state.d.ts +2 -1
- package/dist/client/state.js +1 -0
- package/dist/core/agenthook.js +7 -3
- package/dist/core/capturetoken.d.ts +30 -3
- package/dist/core/capturetoken.js +29 -3
- 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 +25 -5
- package/dist/core/topics.js +1 -1
- package/dist/integrations/scaffold.js +1 -1
- package/dist/mcp/server.js +110 -51
- package/dist/serve/app.d.ts +4 -0
- package/dist/serve/app.js +56 -36
- package/dist/store/stateBinding.js +28 -7
- 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/cli/index.js
CHANGED
|
@@ -102,10 +102,11 @@ import { loadGoldenSet, evaluateRetrieval, evaluateTraversalLift } from "../eval
|
|
|
102
102
|
import { loadGuardCases, evalGuards, generateGuardCases } from "../eval/guards.js";
|
|
103
103
|
import { DRIFT_KINDS, computeDrift } from "../core/drift.js";
|
|
104
104
|
import { renderCompilerScorecard, scoreCompilerCaseBank } from "../constitution/scorecard.js";
|
|
105
|
-
import { generateWiki, wikiStatus, wikiPrompt, publicHome, privateHome, readWikiManifestAt, nowData } from "../wiki/wiki.js";
|
|
105
|
+
import { generateWiki, wikiStatus, wikiPrompt, publicHome, privateHome, readWikiManifestAt, nowData, unconfirmedRoadmapMarker } from "../wiki/wiki.js";
|
|
106
106
|
import { adoptProsePrompt } from "../wiki/adopt.js";
|
|
107
107
|
import { topicCollisions, isInForce, liveForTopic } from "../core/topics.js";
|
|
108
108
|
import { ADR_DIR_CANDIDATES, ADR_FILE_RE, mapAdrCorpus } from "../extractors/adrImport.js";
|
|
109
|
+
import { countersignConstraint, countersignDecision } from "../core/countersign.js";
|
|
109
110
|
import { applyImportedAdrReview, carryImportedAdrReview, importedAdrReviewHash, importedAdrSourceHash, isImportedAdrDecision, pendingImportedAdrReviews } from "../core/importReview.js";
|
|
110
111
|
import { exportMadrCorpus, isRegenerableMadr } from "../integrations/madrExport.js";
|
|
111
112
|
import { buildMadrManifest, writeMadrManifest, refreshMadrCorpus } from "../integrations/madrManifest.js";
|
|
@@ -5042,7 +5043,10 @@ program
|
|
|
5042
5043
|
L.push(` ${r.date} [${r.status}] ${r.title} (${r.id})`);
|
|
5043
5044
|
}
|
|
5044
5045
|
if (roadmap.length) {
|
|
5045
|
-
L.push(`Roadmap (${roadmap.length} live proposed): ${roadmap.slice(0, 3).map((r) => r.title).join(" · ")}${roadmap.length > 3 ? " · …" : ""}`);
|
|
5046
|
+
L.push(`Roadmap (${roadmap.length} live proposed): ${roadmap.slice(0, 3).map((r) => (r.unconfirmed ? `${r.title} [unconfirmed, ${r.id}]` : r.title)).join(" · ")}${roadmap.length > 3 ? " · …" : ""}`);
|
|
5047
|
+
const unconfirmed = roadmap.filter((r) => r.unconfirmed).length;
|
|
5048
|
+
if (unconfirmed)
|
|
5049
|
+
L.push(`${unconfirmed} roadmap item(s) are unconfirmed agent testimony — the human confirms each with \`hunch review --confirm <id>${s.unified ? " --private" : ""}\`.`);
|
|
5046
5050
|
}
|
|
5047
5051
|
if (pendingReview > 0)
|
|
5048
5052
|
L.push(`${pendingReview} legacy un-vouched draft(s) — adopt as advisory memory with \`hunch adopt-drafts\` (new captures auto-trust).`);
|
|
@@ -5358,6 +5362,8 @@ program
|
|
|
5358
5362
|
.description("Answer hash-bound imported-ADR questions, or triage deliberate proposed drafts.")
|
|
5359
5363
|
.option("--accept <id>", "promote a decision to accepted/human-confirmed (confirms its tripwires)")
|
|
5360
5364
|
.option("--reject <id>", "reject a draft decision with a durable lifecycle tombstone")
|
|
5365
|
+
.option("--confirm <id>", "countersign an agent-recorded decision (dec_…) or correction (con_…) as human-confirmed WITHOUT changing its status or content — the human act a capture token alone cannot stand in for")
|
|
5366
|
+
.option("--severity <s>", "with --confirm on a correction: the severity you grant (advisory | warning | blocking); default keeps its current severity")
|
|
5361
5367
|
.option("--approve-import <id>", "approve one exact imported ADR as human-confirmed authority")
|
|
5362
5368
|
.option("--decline-import <id>", "record that one exact imported ADR was reviewed and must stay advisory")
|
|
5363
5369
|
.option("--expected-source-hash <hash>", "exact sha256 source hash printed with the imported-ADR question")
|
|
@@ -5374,11 +5380,15 @@ program
|
|
|
5374
5380
|
store.close();
|
|
5375
5381
|
return fail("--private needs HUNCH_PRIVATE_DIR set to a private store");
|
|
5376
5382
|
}
|
|
5377
|
-
const actionCount = [opts.accept, opts.reject, opts.approveImport, opts.declineImport, opts.acceptVerified, opts.rejectDuplicates].filter(Boolean).length;
|
|
5383
|
+
const actionCount = [opts.accept, opts.reject, opts.confirm, opts.approveImport, opts.declineImport, opts.acceptVerified, opts.rejectDuplicates].filter(Boolean).length;
|
|
5378
5384
|
if (actionCount > 1) {
|
|
5379
5385
|
store.close();
|
|
5380
5386
|
return fail("choose exactly one review action at a time");
|
|
5381
5387
|
}
|
|
5388
|
+
if (opts.severity && !opts.confirm) {
|
|
5389
|
+
store.close();
|
|
5390
|
+
return fail("--severity applies only with --confirm on a correction");
|
|
5391
|
+
}
|
|
5382
5392
|
const decisions = () => opts.private ? store.recs("decisions") : store.json.loadAll("decisions");
|
|
5383
5393
|
let publicGroundingChanged = false;
|
|
5384
5394
|
const touchedHomes = new Set();
|
|
@@ -5418,6 +5428,58 @@ program
|
|
|
5418
5428
|
return fail(error instanceof Error ? error.message : String(error));
|
|
5419
5429
|
}
|
|
5420
5430
|
}
|
|
5431
|
+
else if (opts.confirm) {
|
|
5432
|
+
// Human countersign of agent testimony. A capture token proves only that the
|
|
5433
|
+
// interview tool was called (inside the agent's channel), so agent-written records
|
|
5434
|
+
// land as agent_recorded; this is the out-of-channel human act that signs them.
|
|
5435
|
+
// Content and status are untouched: confirming a proposed decision is not shipping it.
|
|
5436
|
+
const id = opts.confirm;
|
|
5437
|
+
const SEV = ["advisory", "warning", "blocking"];
|
|
5438
|
+
if (opts.severity && !SEV.includes(opts.severity)) {
|
|
5439
|
+
store.close();
|
|
5440
|
+
return fail(`--severity must be one of: ${SEV.join(", ")}`);
|
|
5441
|
+
}
|
|
5442
|
+
const now = new Date().toISOString();
|
|
5443
|
+
const d = opts.private ? store.getRec("decisions", id) : store.json.get("decisions", id);
|
|
5444
|
+
const c = d ? undefined : opts.private ? store.getRec("constraints", id) : store.json.get("constraints", id);
|
|
5445
|
+
if (!d && !c) {
|
|
5446
|
+
store.close();
|
|
5447
|
+
return fail(`no decision or constraint ${id} found${opts.private ? "" : " in the public home (add --private for overlay records)"}`);
|
|
5448
|
+
}
|
|
5449
|
+
let home;
|
|
5450
|
+
if (d) {
|
|
5451
|
+
if (opts.severity) {
|
|
5452
|
+
store.close();
|
|
5453
|
+
return fail("--severity applies only when confirming a correction (con_…), not a decision");
|
|
5454
|
+
}
|
|
5455
|
+
if (isImportedAdrDecision(d)) {
|
|
5456
|
+
store.close();
|
|
5457
|
+
return fail(`imported ADR ${d.id} requires the hash-bound --approve-import flow shown by hunch review`);
|
|
5458
|
+
}
|
|
5459
|
+
home = opts.private ? decisionMemoryHome(store, d.id) : "public";
|
|
5460
|
+
putDecisionInHome(store, countersignDecision(d, now), home);
|
|
5461
|
+
console.log(`✓ confirmed decision ${id} as human_confirmed (status ${d.status} unchanged)`);
|
|
5462
|
+
}
|
|
5463
|
+
else {
|
|
5464
|
+
home = opts.private && store.getPrivateRec("constraints", id) ? "private" : "public";
|
|
5465
|
+
const next = countersignConstraint(c, now, opts.severity);
|
|
5466
|
+
if (home === "private")
|
|
5467
|
+
store.putPrivate("constraints", next);
|
|
5468
|
+
else
|
|
5469
|
+
store.json.put("constraints", next);
|
|
5470
|
+
const repoWide = next.scope.length === 1 && next.scope[0] === "**";
|
|
5471
|
+
console.log(`✓ confirmed ${next.severity} constraint ${id} as human_confirmed (scope: ${next.scope.join(", ")})`);
|
|
5472
|
+
if (next.severity === "blocking" && repoWide)
|
|
5473
|
+
console.log(" ⚠ repo-wide blocking rule: it can deny any edit at strict firmness and fail hunch check --strict.");
|
|
5474
|
+
}
|
|
5475
|
+
touchedHomes.add(home);
|
|
5476
|
+
store.reindex();
|
|
5477
|
+
if (home === "public") {
|
|
5478
|
+
publicGroundingChanged = true;
|
|
5479
|
+
if (!store.autoCommit)
|
|
5480
|
+
refreshExistingGrounding(root, store);
|
|
5481
|
+
}
|
|
5482
|
+
}
|
|
5421
5483
|
else if (opts.accept) {
|
|
5422
5484
|
const d = opts.private ? store.getRec("decisions", opts.accept) : store.json.get("decisions", opts.accept);
|
|
5423
5485
|
if (!d) {
|
|
@@ -5541,7 +5603,7 @@ program
|
|
|
5541
5603
|
if (dupCount)
|
|
5542
5604
|
console.log(`\n Batch-reject the ${dupCount} duplicate(s): hunch review --reject-duplicates`);
|
|
5543
5605
|
}
|
|
5544
|
-
console.log(`\nAccept: hunch review --accept <id> Reject: hunch review --reject <id>`);
|
|
5606
|
+
console.log(`\nAccept: hunch review --accept <id> Reject: hunch review --reject <id> Confirm (sign, keep status): hunch review --confirm <id>`);
|
|
5545
5607
|
}
|
|
5546
5608
|
}
|
|
5547
5609
|
if (touchedHomes.size)
|
|
@@ -6741,7 +6803,7 @@ program
|
|
|
6741
6803
|
if (!roadmap.length)
|
|
6742
6804
|
console.log(" (empty — record what's next as a PROPOSED decision via /capture and it appears here)");
|
|
6743
6805
|
for (const r of roadmap)
|
|
6744
|
-
console.log(` • ${r.title} (${r.id}${r.topic ? `, ${r.topic}` : ""}, since ${r.date})\n ${r.note}`);
|
|
6806
|
+
console.log(` • ${r.title} (${r.id}${r.topic ? `, ${r.topic}` : ""}, since ${r.date})${r.unconfirmed ? `\n ${unconfirmedRoadmapMarker(r, { private: !!opts.private })}` : ""}\n ${r.note}`);
|
|
6745
6807
|
if (pendingReview > 0)
|
|
6746
6808
|
console.log(`\n (${pendingReview} legacy un-vouched draft(s) — \`hunch adopt-drafts\` to auto-trust them as advisory)`);
|
|
6747
6809
|
// Task-record ranking: evaluated automatically on every task write; the kill rule applies itself.
|
package/dist/client/state.d.ts
CHANGED
|
@@ -306,11 +306,12 @@ export declare function createStateClient(opts: StateClientOptions): {
|
|
|
306
306
|
missing: string[];
|
|
307
307
|
denied: string[];
|
|
308
308
|
}>;
|
|
309
|
+
/** Liveness; `partitions` is present because this client sends its credential. */
|
|
309
310
|
health: () => Promise<{
|
|
310
311
|
ok: boolean;
|
|
311
312
|
version: string;
|
|
312
313
|
protocol: string;
|
|
313
|
-
partitions
|
|
314
|
+
partitions?: string[];
|
|
314
315
|
}>;
|
|
315
316
|
};
|
|
316
317
|
export type StateClient = ReturnType<typeof createStateClient>;
|
package/dist/client/state.js
CHANGED
|
@@ -59,6 +59,7 @@ export function createStateClient(opts) {
|
|
|
59
59
|
captureBatch: (request) => call("POST", "/nuryel/v1/capture-batch", request),
|
|
60
60
|
subscribe: (request) => call("POST", "/nuryel/v1/subscribe", request),
|
|
61
61
|
records: (request) => call("POST", "/nuryel/v1/records", request),
|
|
62
|
+
/** Liveness; `partitions` is present because this client sends its credential. */
|
|
62
63
|
health: () => call("GET", "/nuryel/v1/health"),
|
|
63
64
|
};
|
|
64
65
|
}
|
package/dist/core/agenthook.js
CHANGED
|
@@ -152,8 +152,12 @@ function toolOutput(value) {
|
|
|
152
152
|
}
|
|
153
153
|
function explicitToolOutcome(response) {
|
|
154
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.
|
|
155
159
|
if (!raw)
|
|
156
|
-
return
|
|
160
|
+
return "unknown";
|
|
157
161
|
if (raw.success === false || raw.is_error === true || raw.isError === true || (raw.error !== undefined && raw.error !== null))
|
|
158
162
|
return "failure";
|
|
159
163
|
const status = raw.status;
|
|
@@ -176,8 +180,8 @@ function explicitToolOutcome(response) {
|
|
|
176
180
|
if (explicitSuccess)
|
|
177
181
|
return "success";
|
|
178
182
|
// Common successful tool-result shapes carry output fields even when the
|
|
179
|
-
// output is empty
|
|
180
|
-
//
|
|
183
|
+
// output is empty (Claude Code routes failed calls to PostToolUseFailure
|
|
184
|
+
// instead). An unstructured string remains unknown (see above).
|
|
181
185
|
if (["stdout", "stderr", "output", "content"].some(key => Object.prototype.hasOwnProperty.call(raw, key)))
|
|
182
186
|
return "success";
|
|
183
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
|
|
@@ -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()) });
|
|
@@ -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
|
});
|
|
@@ -80,7 +80,7 @@ Capture the decision for **$ARGUMENTS** into Hunch's graph.
|
|
|
80
80
|
2. Run the GRILLING LOOP: one focused question at a time. Push back on hand-wavy answers. Resolve every branch before committing — an unexamined decision poisons the graph.
|
|
81
81
|
3. Confirm the TOPIC anchor with me before committing. One topic per decision; if it spans two, split into two captures.
|
|
82
82
|
4. Capture REJECTED alternatives explicitly (what, and why not) — this is what makes the decision enforceable (Veto/drift check against it).
|
|
83
|
-
5. Commit with \`hunch_record_decision\`, passing \`capture_token\` (from step 1) and the confirmed \`topic\`. The artifact is the graph write, not prose.
|
|
83
|
+
5. Commit with \`hunch_record_decision\`, passing \`capture_token\` (from step 1) and the confirmed \`topic\`. The artifact is the graph write, not prose. The token is not my signature: confirm the record in the client prompt if one appears; otherwise it stays agent testimony until I run the \`hunch review --confirm <id>\` command the response prints.
|
|
84
84
|
6. On CONFLICT for the topic, do NOT auto-supersede — Hunch refuses and presents both; let me choose supersede (link) / split the topic / discard.
|
|
85
85
|
`;
|
|
86
86
|
const WORKTREES_CMD = `---
|