@event4u/agent-config 13.0.0 → 14.1.0
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/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +195 -315
- package/README.md +1 -1
- package/dist/agent-src/commands/evals/roadmap.json +8 -0
- package/dist/agent-src/commands/fix/ci.md +39 -1
- package/dist/agent-src/commands/pr/create.md +65 -4
- package/dist/agent-src/commands/roadmap/next.md +28 -5
- package/dist/agent-src/commands/work.md +16 -0
- package/dist/agent-src/contexts/contracts/command-suggestion-flow.md +47 -0
- package/dist/agent-src/contexts/execution/contract-decision-sheet.md +94 -0
- package/dist/agent-src/contexts/execution/plan-confidence-gate.md +30 -0
- package/dist/agent-src/contexts/execution/roadmap-execution-contract.md +21 -8
- package/dist/agent-src/contexts/execution/roadmap-process-loop.md +55 -8
- package/dist/agent-src/contexts/execution/subagent-response-contract.md +42 -0
- package/dist/agent-src/contexts/execution/subagent-spawn-contract.md +29 -0
- package/dist/agent-src/rules/command-suggestion-policy.md +33 -0
- package/dist/agent-src/rules/design-review-after-ui-write.md +25 -8
- package/dist/agent-src/rules/ui-audit-gate.md +25 -8
- package/dist/agent-src/scripts/gate_execute.ts +395 -0
- package/dist/agent-src/scripts/resume_probe.ts +422 -0
- package/dist/agent-src/scripts/roadmap_gates.ts +138 -4
- package/dist/agent-src/scripts/update_roadmap_progress.ts +53 -2
- package/dist/agent-src/skills/ai-council/SKILL.md +61 -0
- package/dist/agent-src/skills/experiment-loop/SKILL.md +116 -0
- package/dist/agent-src/skills/experiment-loop/evals/output-schema.yml +27 -0
- package/dist/agent-src/skills/experiment-loop/evals/triggers.json +17 -0
- package/dist/agent-src/skills/experiment-loop/references/pivot-ladder.md +54 -0
- package/dist/agent-src/skills/experiment-loop/references/protocol.md +61 -0
- package/dist/agent-src/skills/experiment-loop/references/register-format.md +66 -0
- package/dist/agent-src/skills/roadmap-management/SKILL.md +2 -2
- package/dist/agent-src/skills/roadmap-writing/SKILL.md +1 -1
- package/dist/agent-src/skills/subagent-orchestration/schemas/team-review-status.json +8 -3
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/agent-src/templates/roadmaps.md +43 -3
- package/dist/cli/registry.js +1 -0
- package/dist/cli/registry.js.map +1 -1
- package/dist/cli-delegate/{chunk-N7FEST6D.js → chunk-7PZ4FSZB.js} +351 -174
- package/dist/cli-delegate/{chunk-3B72LSZL.js → chunk-F3YUF7SA.js} +1 -1
- package/dist/cli-delegate/cmd_conformance.js +336 -112
- package/dist/cli-delegate/cmd_doctor.js +2 -2
- package/dist/cli-delegate/cmd_handoff.js +1 -1
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +40 -18
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +3 -3
- package/dist/discovery/dormancy-report.md +1 -1
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +6 -5
- package/dist/discovery/trust-report.md +2 -2
- package/dist/discovery/workspaces.json +5 -4
- package/dist/hooks/dispatch.js +2128 -1326
- package/dist/mcp/registry-manifest.json +3 -3
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +1764 -899
- package/dist/router.json +1 -1
- package/dist/scripts/condense.js +1 -1
- package/dist/scripts/condense.js.map +1 -1
- package/dist/ui/assets/{index-B0aMJnuz.js → index-C1pBRm1f.js} +2 -2
- package/dist/ui/assets/index-C1pBRm1f.js.map +1 -0
- package/dist/ui/index.html +1 -1
- package/package.json +1 -1
- package/src/agent-src/scripts/gate_execute.ts +395 -0
- package/src/agent-src/scripts/resume_probe.ts +422 -0
- package/src/agent-src/scripts/roadmap_gates.ts +138 -4
- package/src/agent-src/scripts/update_roadmap_progress.ts +53 -2
- package/src/config/ci-local-parity.yml +13 -0
- package/src/config/evaluator-budgets.json +4 -4
- package/src/config/gate-coverage.yml +111 -5
- package/src/config/gate-violation-baselines.json +5 -5
- package/src/config/rule-activation-census.json +41 -0
- package/src/config/rule-stub-ceilings.json +52 -0
- package/src/scripts/_dispatch.bash +4 -0
- package/src/scripts/_lib/evaluator_contract.ts +159 -0
- package/src/scripts/_lib/gate_ledger.ts +5 -1
- package/src/scripts/_lib/gh_transient.ts +80 -0
- package/src/scripts/_lib/release_material.ts +16 -5
- package/src/scripts/_lib/review_independence.ts +111 -0
- package/src/scripts/_lib/session_register.ts +14 -0
- package/src/scripts/_lib/skill_catalogue.ts +20 -0
- package/src/scripts/_lib/skill_catalogue_series.ts +411 -0
- package/src/scripts/_lib/turn_end_refusals.ts +482 -0
- package/src/scripts/ai_council/_py_json.ts +96 -0
- package/src/scripts/ai_council/cli_call_budget.ts +357 -0
- package/src/scripts/ai_council/clients.ts +80 -143
- package/src/scripts/ai_council/config.ts +25 -11
- package/src/scripts/ai_council/probe_store.ts +196 -0
- package/src/scripts/ai_council/qualification.ts +425 -0
- package/src/scripts/ai_council/qualification_wiring.ts +243 -0
- package/src/scripts/ai_council/quorum_wiring.ts +211 -0
- package/src/scripts/ai_team/team_dispatch.ts +40 -6
- package/src/scripts/build_archive_index.ts +62 -3
- package/src/scripts/capture_skill_catalogue.ts +264 -4
- package/src/scripts/check_evaluator_schema.ts +249 -0
- package/src/scripts/check_gate_coverage.ts +93 -0
- package/src/scripts/check_pr_ci_current.ts +492 -0
- package/src/scripts/check_review_schema.ts +195 -0
- package/src/scripts/check_rule_activation_census.ts +451 -0
- package/src/scripts/check_rule_stub_ceiling.ts +1128 -0
- package/src/scripts/condense.ts +1 -1
- package/src/scripts/council_cli.ts +141 -191
- package/src/scripts/dispatch_r2_reviewer.ts +8 -1
- package/src/scripts/git_authorization_hook.ts +186 -1
- package/src/scripts/hook_manifest.yaml +72 -5
- package/src/scripts/hooks/block_unauthorized_git.ts +62 -6
- package/src/scripts/hooks/concern_registry.ts +4 -0
- package/src/scripts/hooks/dispatch_hook.ts +43 -1
- package/src/scripts/hooks/interruption_ledger_hook.ts +256 -0
- package/src/scripts/hooks/ship_diff_volume_hook.ts +180 -0
- package/src/scripts/hooks/turn_end_gate_hook.ts +119 -26
- package/src/scripts/hooks_doctor.ts +112 -0
- package/src/scripts/interruption_report.ts +443 -0
- package/src/scripts/lint_evidence_artifacts.ts +359 -0
- package/src/scripts/lint_roadmap_blockers.ts +65 -0
- package/src/scripts/lint_roadmap_complexity.ts +7 -1
- package/src/scripts/release.ts +38 -53
- package/src/scripts/rule_activation_census.ts +292 -0
- package/src/scripts/schemas/evaluator-output.schema.json +60 -0
- package/src/scripts/schemas/review-findings.schema.json +31 -0
- package/src/scripts/self_review_gate.ts +6 -0
- package/src/scripts/session_register_hook.ts +30 -1
- package/src/scripts/sync_pr_branch.ts +271 -0
- package/src/scripts/test_council_qualification.ts +213 -0
- package/dist/ui/assets/index-B0aMJnuz.js.map +0 -1
|
@@ -43,8 +43,102 @@ import { readHookStdin } from "./hooks/hook_stdin.js";
|
|
|
43
43
|
|
|
44
44
|
const EXIT_ALLOW = 0;
|
|
45
45
|
|
|
46
|
+
/**
|
|
47
|
+
* Legacy single-file ledger path — one file for the whole project root.
|
|
48
|
+
*
|
|
49
|
+
* Kept for direct invocations and for reading a ledger written by an older
|
|
50
|
+
* build, NOT as the write target when a session id is known. See
|
|
51
|
+
* `ledgerFileFor` for why one shared file was the defect.
|
|
52
|
+
*/
|
|
46
53
|
export const STATE_FILE = path.join("agents", "state", "git-authorization.json");
|
|
47
54
|
|
|
55
|
+
/** Per-session ledger directory. */
|
|
56
|
+
export const STATE_DIR = path.join("agents", "state", "git-authorization");
|
|
57
|
+
|
|
58
|
+
/** Per-session pending-refusal directory. */
|
|
59
|
+
export const PENDING_DIR = path.join("agents", "state", "git-authorization-pending");
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* A session id reduced to a safe path component.
|
|
63
|
+
*
|
|
64
|
+
* The id reaches this from the host's envelope, so it is untrusted input on a
|
|
65
|
+
* path — `..` or a separator in it would place the ledger outside the state
|
|
66
|
+
* directory. Anything outside the allowed set collapses to `_`.
|
|
67
|
+
*/
|
|
68
|
+
export function sessionSlug(session_id: string): string {
|
|
69
|
+
return session_id.replace(/[^A-Za-z0-9._-]/gu, "_").slice(0, 80);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Where THIS session's ledger lives.
|
|
74
|
+
*
|
|
75
|
+
* MEASURED DEFECT (2026-08-18, during the 14.0.0 release): the ledger was a
|
|
76
|
+
* single file per project root, so every prompt from every concurrent session
|
|
77
|
+
* in the repo overwrote it. A second session typed an 11-character prompt
|
|
78
|
+
* between the user's `merge den pr` and the guard's read; the guard found a
|
|
79
|
+
* foreign session id, discarded the ledger as another conversation's consent,
|
|
80
|
+
* and refused. The refusal then said "no authorization in this turn's prompt",
|
|
81
|
+
* which was false and unfalsifiable from the message alone — the authorization
|
|
82
|
+
* had existed and been clobbered. Three consecutive refusals, and the user
|
|
83
|
+
* re-authorized each time.
|
|
84
|
+
*
|
|
85
|
+
* This repo runs dozens of worktrees against one root, so the race is not an
|
|
86
|
+
* edge case there; it is the normal condition. Scoping the file by session is
|
|
87
|
+
* what makes "this turn's prompt" mean this conversation's turn.
|
|
88
|
+
*/
|
|
89
|
+
export function ledgerFileFor(session_id: string): string {
|
|
90
|
+
if (!session_id) {
|
|
91
|
+
return STATE_FILE;
|
|
92
|
+
}
|
|
93
|
+
return path.join(STATE_DIR, `${sessionSlug(session_id)}.json`);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Where THIS session's refused-operation record lives. */
|
|
97
|
+
export function pendingFileFor(session_id: string): string {
|
|
98
|
+
if (!session_id) {
|
|
99
|
+
return PENDING_FILE;
|
|
100
|
+
}
|
|
101
|
+
return path.join(PENDING_DIR, `${sessionSlug(session_id)}.json`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* One refused irreversible operation, waiting for the answer to the question
|
|
106
|
+
* the guard just told the agent to ask.
|
|
107
|
+
*
|
|
108
|
+
* WHY THIS FILE EXISTS — the measured deadlock (2026-08-18, 14.0.0 release):
|
|
109
|
+
* `user-interaction` requires a decision to be handed to the user as a
|
|
110
|
+
* NUMBERED-OPTIONS block, and the answer to one is a bare `1`. The classifier
|
|
111
|
+
* below reads the prompt in isolation, and `1` carries no operation, so the
|
|
112
|
+
* user's consent was real, visible, and unrecordable. The release deadlocked
|
|
113
|
+
* for three turns: the guard refused, the agent asked as instructed, the user
|
|
114
|
+
* answered, and the next attempt refused identically. Two mechanisms this repo
|
|
115
|
+
* mandates were structurally incompatible on exactly the irreversible subset.
|
|
116
|
+
*
|
|
117
|
+
* The fix is deliberately the narrowest thing that resolves it. Widening the
|
|
118
|
+
* prose list with bare affirmatives was rejected: `ja` would then authorize
|
|
119
|
+
* `npm publish` on any turn, which is reach, not severity. Instead the guard
|
|
120
|
+
* records WHICH op it refused, and only that one op can be confirmed, once, by
|
|
121
|
+
* the immediately following prompt. Everything the user is consenting to has
|
|
122
|
+
* already been named to them in the refusal they just read.
|
|
123
|
+
*/
|
|
124
|
+
export const PENDING_FILE = path.join("agents", "state", "git-authorization-pending.json");
|
|
125
|
+
|
|
126
|
+
/** A refused operation carried to exactly one following prompt. */
|
|
127
|
+
export interface PendingRefusal {
|
|
128
|
+
op: GitOp;
|
|
129
|
+
session_id: string;
|
|
130
|
+
refused_at: string;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* The window in which a refusal is still the thing the user is answering.
|
|
135
|
+
*
|
|
136
|
+
* Short on purpose: this is "the user read the refusal and replied", not "the
|
|
137
|
+
* user said yes to something at some point today". A stale record expires into
|
|
138
|
+
* the same not-authorized state the guard starts from.
|
|
139
|
+
*/
|
|
140
|
+
export const PENDING_MAX_AGE_MS = 10 * 60 * 1000;
|
|
141
|
+
|
|
48
142
|
type JsonValue = string | number | boolean | null | JsonValue[] | { [k: string]: JsonValue };
|
|
49
143
|
type JsonObject = { [k: string]: JsonValue };
|
|
50
144
|
|
|
@@ -142,6 +236,36 @@ export function isInterrogative(prose: string): boolean {
|
|
|
142
236
|
);
|
|
143
237
|
}
|
|
144
238
|
|
|
239
|
+
/**
|
|
240
|
+
* Is this prompt an ANSWER to the refusal the user was just shown?
|
|
241
|
+
*
|
|
242
|
+
* Accepts the two shapes a numbered-options block actually produces — a bare
|
|
243
|
+
* selection (`1`, `1.`, `Option 2`) and a bare affirmative (`ja`, `ok`, `mach`,
|
|
244
|
+
* `go`, `do it`) — and nothing else. The bar is that the prompt says yes and
|
|
245
|
+
* says NOTHING ELSE: a turn that also carries new instructions is a new turn,
|
|
246
|
+
* not an answer, and must name its operation like any other.
|
|
247
|
+
*
|
|
248
|
+
* That last clause is the whole safety argument, so it is enforced by length
|
|
249
|
+
* rather than by good intentions. `release und fixe auch den schess bug` reads
|
|
250
|
+
* as consent to a human and is correctly NOT an affirmative here — it opens new
|
|
251
|
+
* work, and the op it authorizes is the one it names.
|
|
252
|
+
*/
|
|
253
|
+
export function isAffirmative(prose: string): boolean {
|
|
254
|
+
const t = prose.trim().replace(/[.!]+$/u, "");
|
|
255
|
+
if (!t || t.length > 24) {
|
|
256
|
+
return false;
|
|
257
|
+
}
|
|
258
|
+
if (isInterrogative(t)) {
|
|
259
|
+
return false;
|
|
260
|
+
}
|
|
261
|
+
return (
|
|
262
|
+
/^(?:option\s*)?\d{1,2}[.)]?$/iu.test(t) ||
|
|
263
|
+
/^(ja|jo|jep|yes|yep|yeah|ok|okay|okey|klar|passt|gut|los|go|go ahead|mach|mach das|mach es|leg los|do it|proceed|bitte)$/iu.test(
|
|
264
|
+
t,
|
|
265
|
+
)
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
|
|
145
269
|
/** Executable commands a user may paste, mapped to the op they authorize. */
|
|
146
270
|
const PASTED_COMMANDS: ReadonlyArray<{ op: GitOp; re: RegExp }> = [
|
|
147
271
|
{ op: "publish", re: /\bnpm\s+publish\b/i },
|
|
@@ -242,6 +366,55 @@ export function classifyAuthorization(prompt: string): {
|
|
|
242
366
|
return { authorized: [...authorized], evidence };
|
|
243
367
|
}
|
|
244
368
|
|
|
369
|
+
/**
|
|
370
|
+
* Read the refused operation, and consume it in the same breath.
|
|
371
|
+
*
|
|
372
|
+
* Consuming unconditionally is the single-use guarantee: the record survives
|
|
373
|
+
* exactly one following prompt whether or not that prompt confirmed it, so a
|
|
374
|
+
* `ja` three turns later cannot reach back to a refusal the user has long
|
|
375
|
+
* stopped thinking about.
|
|
376
|
+
*/
|
|
377
|
+
export function takePending(
|
|
378
|
+
consumer_root: string,
|
|
379
|
+
session_id: string,
|
|
380
|
+
now: number,
|
|
381
|
+
): PendingRefusal | null {
|
|
382
|
+
const file = path.join(consumer_root, pendingFileFor(session_id));
|
|
383
|
+
let raw: string;
|
|
384
|
+
try {
|
|
385
|
+
raw = fs.readFileSync(file, "utf8");
|
|
386
|
+
} catch {
|
|
387
|
+
return null;
|
|
388
|
+
}
|
|
389
|
+
try {
|
|
390
|
+
fs.rmSync(file, { force: true });
|
|
391
|
+
} catch {
|
|
392
|
+
/* consumed in memory either way — a stale file expires by age below */
|
|
393
|
+
}
|
|
394
|
+
try {
|
|
395
|
+
const decoded = JSON.parse(raw) as unknown;
|
|
396
|
+
if (!_isObject(decoded)) {
|
|
397
|
+
return null;
|
|
398
|
+
}
|
|
399
|
+
const op = decoded["op"];
|
|
400
|
+
if (typeof op !== "string" || !(ALL_OPS as readonly string[]).includes(op)) {
|
|
401
|
+
return null;
|
|
402
|
+
}
|
|
403
|
+
const recorded = typeof decoded["session_id"] === "string" ? decoded["session_id"] : "";
|
|
404
|
+
// A refusal from another conversation is another conversation's question.
|
|
405
|
+
if (session_id && recorded && recorded !== session_id) {
|
|
406
|
+
return null;
|
|
407
|
+
}
|
|
408
|
+
const at = Date.parse(String(decoded["refused_at"] ?? ""));
|
|
409
|
+
if (!Number.isFinite(at) || now - at > PENDING_MAX_AGE_MS) {
|
|
410
|
+
return null;
|
|
411
|
+
}
|
|
412
|
+
return { op: op as GitOp, session_id: recorded, refused_at: String(decoded["refused_at"]) };
|
|
413
|
+
} catch {
|
|
414
|
+
return null;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
245
418
|
export function run(stdin_text: string, options: { consumer_root: string }): number {
|
|
246
419
|
let envelope: JsonObject = {};
|
|
247
420
|
if (stdin_text.trim()) {
|
|
@@ -269,6 +442,18 @@ export function run(stdin_text: string, options: { consumer_root: string }): num
|
|
|
269
442
|
}
|
|
270
443
|
|
|
271
444
|
const { authorized, evidence } = classifyAuthorization(prompt);
|
|
445
|
+
|
|
446
|
+
// The answer to the guard's own question. Read (and consume) on EVERY prompt,
|
|
447
|
+
// so the record cannot outlive the turn it was raised in.
|
|
448
|
+
const session = typeof envelope["session_id"] === "string" ? envelope["session_id"] : "";
|
|
449
|
+
const pending = takePending(options.consumer_root, session, Date.now());
|
|
450
|
+
if (pending && !authorized.includes(pending.op) && isAffirmative(prompt)) {
|
|
451
|
+
authorized.push(pending.op);
|
|
452
|
+
evidence[pending.op] =
|
|
453
|
+
`confirmation of the refused \`${pending.op}\` (refused ${pending.refused_at}): ` +
|
|
454
|
+
`"${prompt.trim().slice(0, 40)}"`;
|
|
455
|
+
}
|
|
456
|
+
|
|
272
457
|
const ledger: Ledger = {
|
|
273
458
|
session_id: typeof envelope["session_id"] === "string" ? envelope["session_id"] : "",
|
|
274
459
|
detected_at: new Date().toISOString(),
|
|
@@ -278,7 +463,7 @@ export function run(stdin_text: string, options: { consumer_root: string }): num
|
|
|
278
463
|
};
|
|
279
464
|
|
|
280
465
|
try {
|
|
281
|
-
atomic_write_json(path.join(options.consumer_root,
|
|
466
|
+
atomic_write_json(path.join(options.consumer_root, ledgerFileFor(session)), ledger);
|
|
282
467
|
} catch {
|
|
283
468
|
// Observability only — a failed write degrades the gate to "no ledger",
|
|
284
469
|
// which the pre_tool_use concern treats as "not authorized" for the
|
|
@@ -74,6 +74,21 @@ concerns:
|
|
|
74
74
|
# Phase 5 — Tier-1 hook for the minimal-safe-diff rule. Counts unique
|
|
75
75
|
# files touched per turn into agents/runtime/state/minimal-safe-diff.json and
|
|
76
76
|
# flips warning=true past hooks.minimal_safe_diff.threshold.
|
|
77
|
+
# road-to-metric-loop-and-review-integrity Phase 4 — shipping-intent gate on
|
|
78
|
+
# diff VOLUME, complementary to `minimal-safe-diff` rather than a duplicate:
|
|
79
|
+
# that concern counts unique files touched PER TURN, this one counts changed
|
|
80
|
+
# lines on the branch AT A SHIP VERB. Threshold 1695 is the corrected p90 of
|
|
81
|
+
# this repository's own last 40 merged PRs, and the exclusion set (review-input
|
|
82
|
+
# snapshots, generated indexes, projection trees) is part of the metric's
|
|
83
|
+
# definition, not a tuning knob — the replay measured the naive metric
|
|
84
|
+
# double-counting the diff it was measuring. Warn-level first; escalation waits
|
|
85
|
+
# for one release of recorded firings. Evidence:
|
|
86
|
+
# agents/evidence/eval-findings/metric-loop-s04.md
|
|
87
|
+
ship-diff-volume:
|
|
88
|
+
script: src/scripts/hooks/ship_diff_volume_hook.ts
|
|
89
|
+
args: []
|
|
90
|
+
fail_closed: false
|
|
91
|
+
severity: advisory
|
|
77
92
|
minimal-safe-diff:
|
|
78
93
|
script: src/scripts/minimal_safe_diff_hook.ts
|
|
79
94
|
args: []
|
|
@@ -474,6 +489,16 @@ concerns:
|
|
|
474
489
|
args: []
|
|
475
490
|
fail_closed: false
|
|
476
491
|
severity: advisory
|
|
492
|
+
# road-to-stop-gate-honesty step 3.1 — skipped on a refusal RETRY.
|
|
493
|
+
# ARGUMENT, per concern as that step requires: the F2 fire gate is a
|
|
494
|
+
# once-per-session marker, and this concern's own header records that a
|
|
495
|
+
# second run "reads the marker and returns silently, without re-running the
|
|
496
|
+
# transcript scan". The retry therefore cannot change its artefact — the
|
|
497
|
+
# telemetry line was already written, or already suppressed, on the first
|
|
498
|
+
# Stop. Idempotence is the concern's own construction, not our assumption
|
|
499
|
+
# about it. The saving is the module load rather than a large read, which is
|
|
500
|
+
# the smaller of the two flags below it and is stated as such.
|
|
501
|
+
skip_on_refusal_retry: true
|
|
477
502
|
# road-to-conformance-round5 Phase 3 — the FIRST concern that can refuse a
|
|
478
503
|
# turn-END. Round 5 measured the reason: both blocking carriers reached zero
|
|
479
504
|
# violations, neither advisory carrier did, and 19 language violations
|
|
@@ -557,6 +582,43 @@ concerns:
|
|
|
557
582
|
args: []
|
|
558
583
|
fail_closed: false
|
|
559
584
|
severity: advisory
|
|
585
|
+
# road-to-user-out-of-the-loop Phase 0 Step 1 — the interruption ledger.
|
|
586
|
+
# Every Stop, classifies whether the turn that just ended put a synchronous
|
|
587
|
+
# contact to the user and appends one `{run_id, turn, kind, class, roadmap}`
|
|
588
|
+
# line to gitignored runtime state (agents/runtime/state/interruptions.jsonl,
|
|
589
|
+
# file shape after gate-metrics.jsonl). Three classes, not two: a hand-back
|
|
590
|
+
# ("das entscheidest Du", "your call") ends the turn and waits for the user
|
|
591
|
+
# exactly as a question does, so counting only `?` would report this
|
|
592
|
+
# package's own preferred shape as zero contacts.
|
|
593
|
+
#
|
|
594
|
+
# CAPTURE ONLY, and that is the point rather than caution: Phase 0's two
|
|
595
|
+
# pre-registered claims are baselines, and a baseline can only be read off a
|
|
596
|
+
# window no mechanism reacted to. It emits nothing, never warns, and always
|
|
597
|
+
# returns 0 — so it carries no src/config/hook-token-budget.json row, the
|
|
598
|
+
# same as subagent-ledger and orchestration-record.
|
|
599
|
+
#
|
|
600
|
+
# Reply-shape detection is IMPORTED from turn-end-gate (readTranscriptTail,
|
|
601
|
+
# finalParagraph, HANDBACK) rather than copied — a second copy would drift
|
|
602
|
+
# from the measured corpus's detector and the drift would land silently in
|
|
603
|
+
# the baseline. Dedupe on the turn ordinal, the same identity that hook
|
|
604
|
+
# derives, because a host may fire Stop twice for one turn. fail_closed: false.
|
|
605
|
+
interruption-ledger:
|
|
606
|
+
script: src/scripts/hooks/interruption_ledger_hook.ts
|
|
607
|
+
args: []
|
|
608
|
+
fail_closed: false
|
|
609
|
+
severity: advisory
|
|
610
|
+
# road-to-stop-gate-honesty step 3.1 — skipped on a refusal RETRY, and this
|
|
611
|
+
# is the flag with the real saving.
|
|
612
|
+
# ARGUMENT, per concern: `alreadyRecorded(ledgerPath, run_id, turn)` dedupes
|
|
613
|
+
# on the TURN ORDINAL, and a refusal retry is the same turn by construction
|
|
614
|
+
# — the gate's layer-2 marker is keyed on that same ordinal. So the ledger
|
|
615
|
+
# already writes nothing on the retry and the artefact is provably identical.
|
|
616
|
+
# What it does NOT already avoid is the cost: `main()` calls
|
|
617
|
+
# `readTranscriptTail` (up to 8 MB, walking the whole file) BEFORE reaching
|
|
618
|
+
# `alreadyRecorded`, so today every refused Stop pays a full transcript walk
|
|
619
|
+
# and then discards the result. That is the "pure duplicate cost" § D-3
|
|
620
|
+
# names, measured in the source rather than assumed.
|
|
621
|
+
skip_on_refusal_retry: true
|
|
560
622
|
# road-to-subagent-lifecycle-integrity Phase 1 (Steps 2+3) — the subagent
|
|
561
623
|
# lifecycle ledger. Binds ONLY the two new events; on every other slot it is
|
|
562
624
|
# not listed at all. Appends `subagent_start` / `subagent_stop` lines to
|
|
@@ -665,22 +727,27 @@ roles:
|
|
|
665
727
|
# is on it: a worker was handed a bounded slice by an orchestrator that
|
|
666
728
|
# already chose the approach, so a routing pointer there advises nobody who
|
|
667
729
|
# can act on it and costs the dispatch anyway.
|
|
668
|
-
|
|
730
|
+
# `interruption-ledger` joins it on the first clause verbatim — "a worker
|
|
731
|
+
# never talks to the user". Its whole measurement is synchronous user
|
|
732
|
+
# contacts, so a worker turn that ends in a question to its orchestrator
|
|
733
|
+
# would be recorded as a contact that never reached a human, inflating the
|
|
734
|
+
# exact baseline Phase 0 pre-registers.
|
|
735
|
+
drop: [delegation-nudge, skill-route, end-review-nudge, council-availability, team-review-gate, self-repair, session-eol, interruption-ledger]
|
|
669
736
|
|
|
670
737
|
platforms:
|
|
671
738
|
augment:
|
|
672
739
|
session_start: [chat-history, hot-context, handoff-context, session-canary, council-availability, first-run-gate, onboarding-gate, verify-before-complete, minimal-safe-diff, profile-staleness, wrapper-freshness, surface-probe, session-register]
|
|
673
740
|
session_end: [chat-history, memory-learn, session-register]
|
|
674
741
|
stop: [chat-history, hot-context, verify-before-complete, self-repair, session-register]
|
|
675
|
-
pre_tool_use: [block-no-verify, block-unauthorized-git, evidence-independence, block-kernel-rule-writes, block-config-weakening, rtk-wrap, design-slop, ui-route-nudge, code-graph-nudge, reread-guard]
|
|
742
|
+
pre_tool_use: [block-no-verify, block-unauthorized-git, evidence-independence, block-kernel-rule-writes, block-config-weakening, rtk-wrap, design-slop, ui-route-nudge, code-graph-nudge, ship-diff-volume, reread-guard]
|
|
676
743
|
post_tool_use: [chat-history, roadmap-progress, context-hygiene, verify-before-complete, minimal-safe-diff, injection-scan, pr-url-reminder, orchestration-record, tool-result-bytes, edit-shape]
|
|
677
744
|
|
|
678
745
|
claude:
|
|
679
746
|
session_start: [chat-history, hot-context, handoff-context, session-canary, council-availability, first-run-gate, onboarding-gate, verify-before-complete, minimal-safe-diff, profile-staleness, wrapper-freshness, surface-probe, session-register]
|
|
680
747
|
session_end: [chat-history, memory-learn, session-register]
|
|
681
|
-
stop: [chat-history, hot-context, verify-before-complete, team-review-gate, end-review-nudge, turn-end-gate, self-repair, session-register, session-eol]
|
|
748
|
+
stop: [chat-history, hot-context, verify-before-complete, team-review-gate, end-review-nudge, turn-end-gate, self-repair, session-register, session-eol, interruption-ledger]
|
|
682
749
|
user_prompt_submit: [chat-history, verify-before-complete, minimal-safe-diff, language-mirror, delegation-nudge, skill-route, git-authorization, session-canary, self-repair, session-register]
|
|
683
|
-
pre_tool_use: [block-no-verify, block-unauthorized-git, evidence-independence, block-kernel-rule-writes, block-config-weakening, rtk-wrap, design-slop, ui-route-nudge, code-graph-nudge, reread-guard, spawn-guard-shadow]
|
|
750
|
+
pre_tool_use: [block-no-verify, block-unauthorized-git, evidence-independence, block-kernel-rule-writes, block-config-weakening, rtk-wrap, design-slop, ui-route-nudge, code-graph-nudge, ship-diff-volume, reread-guard, spawn-guard-shadow]
|
|
684
751
|
post_tool_use: [chat-history, roadmap-progress, context-hygiene, verify-before-complete, minimal-safe-diff, injection-scan, pr-url-reminder, orchestration-record, tool-result-bytes, edit-shape, language-mirror]
|
|
685
752
|
# `language-mirror` — round-5 § 6.1. `pre_compact` sets a pin-lost marker;
|
|
686
753
|
# the `post_tool_use` entry above re-emits the pin exactly once while that
|
|
@@ -734,7 +801,7 @@ platforms:
|
|
|
734
801
|
# still costs the dispatch, and its entire output is thrown away.
|
|
735
802
|
stop: [chat-history, hot-context, verify-before-complete, self-repair, session-register]
|
|
736
803
|
user_prompt_submit: [chat-history, verify-before-complete, minimal-safe-diff, language-mirror, git-authorization, session-canary, self-repair, session-register]
|
|
737
|
-
pre_tool_use: [block-no-verify, block-unauthorized-git, evidence-independence, block-kernel-rule-writes, block-config-weakening, rtk-wrap, design-slop, ui-route-nudge, code-graph-nudge, reread-guard, spawn-guard-shadow]
|
|
804
|
+
pre_tool_use: [block-no-verify, block-unauthorized-git, evidence-independence, block-kernel-rule-writes, block-config-weakening, rtk-wrap, design-slop, ui-route-nudge, code-graph-nudge, ship-diff-volume, reread-guard, spawn-guard-shadow]
|
|
738
805
|
post_tool_use: [chat-history, roadmap-progress, context-hygiene, verify-before-complete, minimal-safe-diff, injection-scan, pr-url-reminder, orchestration-record, tool-result-bytes, edit-shape]
|
|
739
806
|
# subagent-ledger IS bound here, unlike the two nudges above, and the
|
|
740
807
|
# difference is the delivery channel rather than the platform: the nudges
|
|
@@ -61,7 +61,13 @@ import path from "node:path";
|
|
|
61
61
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
62
62
|
|
|
63
63
|
import { readHookStdin } from "./hook_stdin.js";
|
|
64
|
-
import {
|
|
64
|
+
import { atomic_write_json } from "./state_io.js";
|
|
65
|
+
import {
|
|
66
|
+
ledgerFileFor,
|
|
67
|
+
pendingFileFor,
|
|
68
|
+
STATE_FILE,
|
|
69
|
+
type GitOp,
|
|
70
|
+
} from "../git_authorization_hook.js";
|
|
65
71
|
|
|
66
72
|
const EXIT_ALLOW = 0;
|
|
67
73
|
// MUST equal dispatch_hook.EXIT_BLOCK. The dispatcher's internal ladder is
|
|
@@ -497,18 +503,45 @@ function _loadLedger(
|
|
|
497
503
|
session_id: string,
|
|
498
504
|
now: number,
|
|
499
505
|
): { authorized: Set<GitOp>; present: boolean } {
|
|
506
|
+
// This session's own ledger first; the flat legacy file only as a fallback,
|
|
507
|
+
// so a ledger written by an older build still counts. Reading the flat file
|
|
508
|
+
// FIRST was the defect: any concurrent session in the repo overwrites it, and
|
|
509
|
+
// the session check below then discarded a real authorization as foreign.
|
|
510
|
+
const candidates = [ledgerFileFor(session_id)];
|
|
511
|
+
if (!candidates.includes(STATE_FILE)) {
|
|
512
|
+
candidates.push(STATE_FILE);
|
|
513
|
+
}
|
|
514
|
+
for (const rel of candidates) {
|
|
515
|
+
const found = _readLedgerFile(path.join(consumer_root, rel), session_id, now);
|
|
516
|
+
if (found !== null) {
|
|
517
|
+
return found;
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
return { authorized: new Set<GitOp>(), present: false };
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* Read one ledger file. `null` means "this file does not answer the question" —
|
|
525
|
+
* absent, malformed, foreign session, or expired — so the caller may try the
|
|
526
|
+
* next candidate. A parsed, in-session, fresh ledger answers definitively.
|
|
527
|
+
*/
|
|
528
|
+
function _readLedgerFile(
|
|
529
|
+
file: string,
|
|
530
|
+
session_id: string,
|
|
531
|
+
now: number,
|
|
532
|
+
): { authorized: Set<GitOp>; present: boolean } | null {
|
|
500
533
|
try {
|
|
501
|
-
const raw = fs.readFileSync(
|
|
534
|
+
const raw = fs.readFileSync(file, "utf8");
|
|
502
535
|
const decoded = JSON.parse(raw) as unknown;
|
|
503
536
|
if (_isObject(decoded) && Array.isArray(decoded["authorized"])) {
|
|
504
537
|
const ledgerSession = typeof decoded["session_id"] === "string" ? decoded["session_id"] : "";
|
|
505
538
|
// A ledger from a different session is another conversation's consent.
|
|
506
539
|
if (session_id && ledgerSession && ledgerSession !== session_id) {
|
|
507
|
-
return
|
|
540
|
+
return null;
|
|
508
541
|
}
|
|
509
542
|
const at = Date.parse(String(decoded["detected_at"] ?? ""));
|
|
510
543
|
if (Number.isFinite(at) && now - at > LEDGER_MAX_AGE_MS) {
|
|
511
|
-
return
|
|
544
|
+
return null;
|
|
512
545
|
}
|
|
513
546
|
return {
|
|
514
547
|
authorized: new Set((decoded["authorized"] as string[]).filter(Boolean) as GitOp[]),
|
|
@@ -518,13 +551,21 @@ function _loadLedger(
|
|
|
518
551
|
} catch {
|
|
519
552
|
/* fall through */
|
|
520
553
|
}
|
|
521
|
-
return
|
|
554
|
+
return null;
|
|
522
555
|
}
|
|
523
556
|
|
|
524
557
|
export interface Decision {
|
|
525
558
|
exit: number;
|
|
526
559
|
stdout: string;
|
|
527
560
|
stderr: string;
|
|
561
|
+
/**
|
|
562
|
+
* The op this decision refused, when it refused one.
|
|
563
|
+
*
|
|
564
|
+
* `decide` stays pure; `run` turns this into the pending record that lets the
|
|
565
|
+
* user's answer to the refusal count as authorization for that one op. See
|
|
566
|
+
* `git_authorization_hook.PENDING_FILE` for why that record exists at all.
|
|
567
|
+
*/
|
|
568
|
+
blockedOp?: GitOp;
|
|
528
569
|
}
|
|
529
570
|
|
|
530
571
|
export function decide(
|
|
@@ -547,7 +588,7 @@ export function decide(
|
|
|
547
588
|
`This operation is irreversible and non-destructive-by-default already declares it ` +
|
|
548
589
|
`never-autonomous. Command: ${shown}. ` +
|
|
549
590
|
`Ask the user, in one numbered-options block, and run it only after they answer this turn.`;
|
|
550
|
-
return { exit: EXIT_BLOCK, stdout: "", stderr: `${reason}\n
|
|
591
|
+
return { exit: EXIT_BLOCK, stdout: "", stderr: `${reason}\n`, blockedOp: op };
|
|
551
592
|
}
|
|
552
593
|
|
|
553
594
|
if (WARN_OPS.has(op)) {
|
|
@@ -582,6 +623,21 @@ export function run(stdin_text: string, options: { consumer_root: string }): num
|
|
|
582
623
|
extractCommand(envelope),
|
|
583
624
|
_loadLedger(options.consumer_root, session_id, Date.now()),
|
|
584
625
|
);
|
|
626
|
+
if (decision.blockedOp) {
|
|
627
|
+
// Record WHAT was refused, so the user's answer to the question this
|
|
628
|
+
// refusal just told the agent to ask can authorize that one operation.
|
|
629
|
+
// Best-effort: a failed write degrades to the previous behaviour, where a
|
|
630
|
+
// numbered answer is unrecordable and the refusal simply repeats.
|
|
631
|
+
try {
|
|
632
|
+
atomic_write_json(path.join(options.consumer_root, pendingFileFor(session_id)), {
|
|
633
|
+
op: decision.blockedOp,
|
|
634
|
+
session_id,
|
|
635
|
+
refused_at: new Date().toISOString(),
|
|
636
|
+
});
|
|
637
|
+
} catch {
|
|
638
|
+
/* observability only — see above */
|
|
639
|
+
}
|
|
640
|
+
}
|
|
585
641
|
if (decision.stdout) {
|
|
586
642
|
process.stdout.write(decision.stdout);
|
|
587
643
|
}
|
|
@@ -32,6 +32,7 @@ import { main as onboardingGateMain } from '../onboarding_gate_hook.js';
|
|
|
32
32
|
import { main as contextHygieneMain } from '../context_hygiene_hook.js';
|
|
33
33
|
import { main as verifyBeforeCompleteMain } from '../before_complete_hook.js';
|
|
34
34
|
import { main as minimalSafeDiffMain } from '../minimal_safe_diff_hook.js';
|
|
35
|
+
import { main as shipDiffVolumeMain } from './ship_diff_volume_hook.js';
|
|
35
36
|
import { main as injectionScanMain } from '../injection_scan_hook.js';
|
|
36
37
|
import { main as memoryLearnMain } from '../memory_learn_hook.js';
|
|
37
38
|
import { main as firstRunGateMain } from '../first_run_gate_hook.js';
|
|
@@ -66,6 +67,7 @@ import { main as sessionEolMain } from './session_eol_hook.js';
|
|
|
66
67
|
import { main as subagentLedgerMain } from './subagent_ledger_hook.js';
|
|
67
68
|
import { main as toolResultBytesMain } from './tool_result_bytes_hook.js';
|
|
68
69
|
import { main as spawnGuardShadowMain } from './spawn_guard_shadow_hook.js';
|
|
70
|
+
import { main as interruptionLedgerMain } from './interruption_ledger_hook.js';
|
|
69
71
|
|
|
70
72
|
/** A concern `main` — argv-taking or not; both shapes exist. */
|
|
71
73
|
export type ConcernMain = (argv?: string[]) => number | undefined | void;
|
|
@@ -80,6 +82,7 @@ export const CONCERN_REGISTRY: Readonly<Record<string, ConcernMain>> = {
|
|
|
80
82
|
'src/scripts/context_hygiene_hook.ts': contextHygieneMain as ConcernMain,
|
|
81
83
|
'src/scripts/before_complete_hook.ts': verifyBeforeCompleteMain as ConcernMain,
|
|
82
84
|
'src/scripts/minimal_safe_diff_hook.ts': minimalSafeDiffMain as ConcernMain,
|
|
85
|
+
'src/scripts/hooks/ship_diff_volume_hook.ts': shipDiffVolumeMain as ConcernMain,
|
|
83
86
|
'src/scripts/injection_scan_hook.ts': injectionScanMain as ConcernMain,
|
|
84
87
|
'src/scripts/memory_learn_hook.ts': memoryLearnMain as ConcernMain,
|
|
85
88
|
'src/scripts/first_run_gate_hook.ts': firstRunGateMain as ConcernMain,
|
|
@@ -114,4 +117,5 @@ export const CONCERN_REGISTRY: Readonly<Record<string, ConcernMain>> = {
|
|
|
114
117
|
'src/scripts/hooks/subagent_ledger_hook.ts': subagentLedgerMain as ConcernMain,
|
|
115
118
|
'src/scripts/hooks/tool_result_bytes_hook.ts': toolResultBytesMain as ConcernMain,
|
|
116
119
|
'src/scripts/hooks/spawn_guard_shadow_hook.ts': spawnGuardShadowMain as ConcernMain,
|
|
120
|
+
'src/scripts/hooks/interruption_ledger_hook.ts': interruptionLedgerMain as ConcernMain,
|
|
117
121
|
};
|
|
@@ -342,16 +342,51 @@ export function _role_drop_set(
|
|
|
342
342
|
return new Set(drop.filter((d): d is string => typeof d === "string"));
|
|
343
343
|
}
|
|
344
344
|
|
|
345
|
+
/**
|
|
346
|
+
* True when this `stop` is the RETRY of a turn a stop-hook already refused.
|
|
347
|
+
*
|
|
348
|
+
* The host sets `stop_hook_active` on exactly that Stop; `turn_end_gate_hook`
|
|
349
|
+
* reads the same field as its layer-1 re-entrancy guard, so this is the host's
|
|
350
|
+
* own answer rather than an inference of ours. Anything unparseable is `false`,
|
|
351
|
+
* which is the full chain — the safe direction, since the cost of running a
|
|
352
|
+
* concern twice is duplicate work and the cost of skipping one wrongly is a lost
|
|
353
|
+
* write.
|
|
354
|
+
*/
|
|
355
|
+
export function _is_refusal_retry(event: string, payload_text: string): boolean {
|
|
356
|
+
if (event !== "stop") return false;
|
|
357
|
+
try {
|
|
358
|
+
const parsed: unknown = JSON.parse(payload_text);
|
|
359
|
+
if (!_isObject(parsed)) return false;
|
|
360
|
+
const payload = parsed["payload"];
|
|
361
|
+
const source = _isObject(payload) ? payload : parsed;
|
|
362
|
+
return source["stop_hook_active"] === true;
|
|
363
|
+
} catch {
|
|
364
|
+
return false;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
345
368
|
/**
|
|
346
369
|
* Return the ordered concern definitions for (platform, event) under the
|
|
347
370
|
* given session role (default `orchestrator` — byte-identical to the
|
|
348
371
|
* pre-role-axis behaviour).
|
|
372
|
+
*
|
|
373
|
+
* `opts.refusal_retry` drops concerns that opted in with
|
|
374
|
+
* `skip_on_refusal_retry: true` — `road-to-stop-gate-honesty` step 3.1. Stop is
|
|
375
|
+
* the heaviest slot on claude and **every refused Stop runs the whole slot again
|
|
376
|
+
* on the retry** (§ D-3), so the concerns whose second run provably produces the
|
|
377
|
+
* identical artefact are pure duplicate cost.
|
|
378
|
+
*
|
|
379
|
+
* Opt-in per concern, default off, never a blanket skip: Risk 3 of that roadmap
|
|
380
|
+
* is that a concern skipped on the retry might have been the one that needed the
|
|
381
|
+
* second pass, and the loss would be silent. The manifest carries the per-concern
|
|
382
|
+
* argument beside each flag.
|
|
349
383
|
*/
|
|
350
384
|
export function _resolve_concerns(
|
|
351
385
|
manifest: JsonObject,
|
|
352
386
|
platform: string,
|
|
353
387
|
event: string,
|
|
354
388
|
role: SessionRole = "orchestrator",
|
|
389
|
+
opts: { refusal_retry?: boolean } = {},
|
|
355
390
|
): ConcernDef[] {
|
|
356
391
|
const platformsRaw = manifest["platforms"];
|
|
357
392
|
const platforms = _isObject(platformsRaw) ? platformsRaw : {};
|
|
@@ -381,6 +416,9 @@ export function _resolve_concerns(
|
|
|
381
416
|
);
|
|
382
417
|
continue;
|
|
383
418
|
}
|
|
419
|
+
if (opts.refusal_retry && spec["skip_on_refusal_retry"] === true) {
|
|
420
|
+
continue;
|
|
421
|
+
}
|
|
384
422
|
out.push({ name: String(name), ...spec });
|
|
385
423
|
}
|
|
386
424
|
return out;
|
|
@@ -1081,13 +1119,17 @@ export function main(argv?: string[]): number {
|
|
|
1081
1119
|
const payload_text = process.stdin.isTTY ? "" : _readStdin();
|
|
1082
1120
|
_maybe_capture_payload(args, payload_text);
|
|
1083
1121
|
const session_role = resolveSessionRole(process.env);
|
|
1084
|
-
const
|
|
1122
|
+
const refusal_retry = _is_refusal_retry(args.event, payload_text);
|
|
1123
|
+
const concerns = _resolve_concerns(manifest, args.platform, args.event, session_role, {
|
|
1124
|
+
refusal_retry,
|
|
1125
|
+
});
|
|
1085
1126
|
|
|
1086
1127
|
if (args.dry_run) {
|
|
1087
1128
|
const plan = {
|
|
1088
1129
|
platform: args.platform,
|
|
1089
1130
|
event: args.event,
|
|
1090
1131
|
role: session_role,
|
|
1132
|
+
refusal_retry,
|
|
1091
1133
|
concerns: concerns.map((c) => c["name"]),
|
|
1092
1134
|
};
|
|
1093
1135
|
process.stdout.write(_py_json_dumps(plan, 2) + "\n");
|