@event4u/agent-config 13.0.0 → 14.0.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 +160 -333
- 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 +224 -104
- 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 +2018 -1320
- package/dist/mcp/registry-manifest.json +3 -3
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +1652 -892
- 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 +2 -2
- 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/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/capture_skill_catalogue.ts +264 -4
- package/src/scripts/check_evaluator_schema.ts +249 -0
- package/src/scripts/check_pr_ci_current.ts +409 -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/hook_manifest.yaml +72 -5
- 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
|
@@ -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");
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* interruption_ledger — road-to-user-out-of-the-loop Phase 0 Step 1.
|
|
3
|
+
*
|
|
4
|
+
* CAPTURE ONLY. Every `stop`, classifies whether the turn that just ended put a
|
|
5
|
+
* synchronous contact to the user, and appends one line to
|
|
6
|
+
* `agents/runtime/state/interruptions.jsonl`. It never emits context, never
|
|
7
|
+
* warns, and always returns 0 — the whole point of Phase 0 is a window that no
|
|
8
|
+
* mechanism reacted to, so a ledger that could change a run would corrupt the
|
|
9
|
+
* baseline it exists to measure.
|
|
10
|
+
*
|
|
11
|
+
* WHY THIS EXISTS. The roadmap's Goal names two axes — synchronous contacts per
|
|
12
|
+
* delivered roadmap, and wall-clock to open PR — and pre-registers both as
|
|
13
|
+
* claims. Neither is derivable today: nothing in the tree records that a turn
|
|
14
|
+
* ended by asking the user something. `check_enforcement_coverage` counts
|
|
15
|
+
* carriers, `gate-metrics.jsonl` counts gate verdicts, and the conformance suite
|
|
16
|
+
* scores reply SHAPE without ever asking whether the reply stopped the run.
|
|
17
|
+
*
|
|
18
|
+
* REUSE, NOT REIMPLEMENTATION. Reply-shape detection is `turn_end_gate_hook`'s,
|
|
19
|
+
* imported rather than copied: `readTranscriptTail` for the last assistant text,
|
|
20
|
+
* `finalParagraph` for the closing paragraph, and its `HANDBACK` list for the
|
|
21
|
+
* yields-without-asking shape. That file is the measured corpus's detector and
|
|
22
|
+
* has an R2 history behind each regex; a second copy here would drift from it
|
|
23
|
+
* silently and the drift would land in the baseline, not in a test.
|
|
24
|
+
*
|
|
25
|
+
* WHAT COUNTS AS A CONTACT, and why it is three classes rather than two. A
|
|
26
|
+
* question mark is the obvious one. It is not the only one: `scope-control`
|
|
27
|
+
* requires a hand-back — "das entscheidest Du", "your call" — which ends the
|
|
28
|
+
* turn and waits for the user just as hard as a question does, with no `?`
|
|
29
|
+
* anywhere. Counting only questions would report the package's own preferred
|
|
30
|
+
* hand-back shape as zero contacts, which is precisely the metric-that-flatters-
|
|
31
|
+
* the-design failure the roadmap's Risk 6 names.
|
|
32
|
+
*
|
|
33
|
+
* ask — the closing paragraph ends in a question mark.
|
|
34
|
+
* handback — no question, but the closing paragraph yields the decision.
|
|
35
|
+
* none — the turn ended without putting anything to the user.
|
|
36
|
+
*
|
|
37
|
+
* `class` refines an `ask` by the shape the user has to answer in:
|
|
38
|
+
* `numbered-options` when the reply carries a numbered block (the
|
|
39
|
+
* `user-interaction` shape — one decision point, cheap to answer), or
|
|
40
|
+
* `open-question` when it does not (free text, the expensive shape). The split
|
|
41
|
+
* exists because the two are not equal contacts and the report separates them.
|
|
42
|
+
*
|
|
43
|
+
* DEDUPE is on the turn ordinal, the same identity `turn_end_gate_hook` derives
|
|
44
|
+
* and for the same reason: a host may fire `stop` more than once for one turn,
|
|
45
|
+
* and a per-turn ledger that double-counts inflates exactly the number the
|
|
46
|
+
* baseline is pre-registered on.
|
|
47
|
+
*
|
|
48
|
+
* The roadmap slug comes from this session's own claim file — the one
|
|
49
|
+
* `agent-config sessions:claim` writes — so a contact is attributable to a
|
|
50
|
+
* delivered roadmap without this hook knowing anything about roadmaps. No claim
|
|
51
|
+
* means `roadmap: null`, which is a real and common state (any session that is
|
|
52
|
+
* not a roadmap run) and never a reason to skip the line.
|
|
53
|
+
*/
|
|
54
|
+
|
|
55
|
+
import * as fs from 'node:fs';
|
|
56
|
+
import * as path from 'node:path';
|
|
57
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
58
|
+
|
|
59
|
+
import { derive_session_tag } from '../chat_history.js';
|
|
60
|
+
import { unwrap, type JsonObject, type JsonValue } from './envelope.js';
|
|
61
|
+
import { readHookStdin } from './hook_stdin.js';
|
|
62
|
+
import { HANDBACK, finalParagraph, readTranscriptTail } from './turn_end_gate_hook.js';
|
|
63
|
+
|
|
64
|
+
const EXIT_OK = 0;
|
|
65
|
+
|
|
66
|
+
/** Same cap the turn-end gate reads its tail under. */
|
|
67
|
+
const TRANSCRIPT_READ_MAX_BYTES = 2 * 1024 * 1024;
|
|
68
|
+
|
|
69
|
+
export const LEDGER_RELPATH = path.join('agents', 'runtime', 'state', 'interruptions.jsonl');
|
|
70
|
+
|
|
71
|
+
export type ContactKind = 'ask' | 'handback' | 'none';
|
|
72
|
+
export type ContactClass = 'numbered-options' | 'open-question' | 'handback' | 'none';
|
|
73
|
+
|
|
74
|
+
export interface InterruptionRecord {
|
|
75
|
+
run_id: string;
|
|
76
|
+
turn: number;
|
|
77
|
+
kind: ContactKind;
|
|
78
|
+
class: ContactClass;
|
|
79
|
+
roadmap: string | null;
|
|
80
|
+
at: string;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* A numbered-options block: two or more numbered lines. One numbered line is an
|
|
85
|
+
* ordinary enumeration inside prose and is not a decision surface — requiring
|
|
86
|
+
* two is what keeps "1. read the file" from reading as an ask.
|
|
87
|
+
*/
|
|
88
|
+
export function hasNumberedOptions(reply: string): boolean {
|
|
89
|
+
let count = 0;
|
|
90
|
+
for (const line of reply.split('\n')) {
|
|
91
|
+
if (/^[ \t]{0,3}\d+[.)]\s+\S/.test(line)) count += 1;
|
|
92
|
+
if (count >= 2) return true;
|
|
93
|
+
}
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Classify the reply. The question test is `turn_end_gate_hook`'s exactly — a
|
|
99
|
+
* question that ENDS the paragraph, never `includes('?')`, because a rhetorical
|
|
100
|
+
* or quoted question mid-reply is not a contact and counting it would inflate
|
|
101
|
+
* the baseline in the direction that makes the roadmap look successful later.
|
|
102
|
+
*/
|
|
103
|
+
export function classifyReply(reply: string): { kind: ContactKind; class: ContactClass } {
|
|
104
|
+
const tail = finalParagraph(reply);
|
|
105
|
+
if (!tail) return { kind: 'none', class: 'none' };
|
|
106
|
+
|
|
107
|
+
if (/\?["'’)\]]*\s*$/.test(tail)) {
|
|
108
|
+
return {
|
|
109
|
+
kind: 'ask',
|
|
110
|
+
class: hasNumberedOptions(reply) ? 'numbered-options' : 'open-question',
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
if (HANDBACK.some((re) => re.test(tail))) return { kind: 'handback', class: 'handback' };
|
|
114
|
+
return { kind: 'none', class: 'none' };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** The roadmap this session claimed, or null. Never throws. */
|
|
118
|
+
export function readClaimedRoadmap(workspaceRoot: string, sessionId: string): string | null {
|
|
119
|
+
if (!sessionId) return null;
|
|
120
|
+
try {
|
|
121
|
+
const file = path.join(
|
|
122
|
+
workspaceRoot,
|
|
123
|
+
'agents',
|
|
124
|
+
'runtime',
|
|
125
|
+
'state',
|
|
126
|
+
`roadmap-claim-${sessionId}.json`,
|
|
127
|
+
);
|
|
128
|
+
const parsed: unknown = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
129
|
+
if (parsed === null || typeof parsed !== 'object') return null;
|
|
130
|
+
const slug = (parsed as Record<string, unknown>)['slug'];
|
|
131
|
+
return typeof slug === 'string' && slug.length > 0 ? slug : null;
|
|
132
|
+
} catch {
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* True when this run has already recorded this turn. Reads only the tail of the
|
|
139
|
+
* ledger: the duplicate a repeated `stop` produces is always the most recent
|
|
140
|
+
* line, so scanning the whole file would cost more on every turn to catch
|
|
141
|
+
* nothing extra.
|
|
142
|
+
*/
|
|
143
|
+
export function alreadyRecorded(ledgerPath: string, runId: string, turn: number): boolean {
|
|
144
|
+
let raw: string;
|
|
145
|
+
try {
|
|
146
|
+
raw = fs.readFileSync(ledgerPath, 'utf8');
|
|
147
|
+
} catch {
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
150
|
+
const lines = raw.split('\n').filter((l) => l.trim().length > 0);
|
|
151
|
+
for (const line of lines.slice(-20)) {
|
|
152
|
+
try {
|
|
153
|
+
const rec: unknown = JSON.parse(line);
|
|
154
|
+
if (rec === null || typeof rec !== 'object') continue;
|
|
155
|
+
const r = rec as Record<string, unknown>;
|
|
156
|
+
if (r['run_id'] === runId && r['turn'] === turn) return true;
|
|
157
|
+
} catch {
|
|
158
|
+
// A corrupt line is not a reason to skip the write.
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function str(v: JsonValue | undefined): string {
|
|
165
|
+
return typeof v === 'string' ? v : '';
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function main(): number {
|
|
169
|
+
let envelope: JsonObject;
|
|
170
|
+
let payload: JsonObject;
|
|
171
|
+
try {
|
|
172
|
+
[envelope, payload] = unwrap(readHookStdin(), 'claude');
|
|
173
|
+
} catch {
|
|
174
|
+
return EXIT_OK;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const workspaceRoot = str(envelope['workspace_root'] as JsonValue | undefined) || process.cwd();
|
|
178
|
+
const transcriptPath = str(
|
|
179
|
+
(payload['transcript_path'] ?? payload['transcriptPath']) as JsonValue | undefined,
|
|
180
|
+
);
|
|
181
|
+
if (!transcriptPath) return EXIT_OK;
|
|
182
|
+
|
|
183
|
+
let lastAssistant = '';
|
|
184
|
+
let turnOrdinal = 0;
|
|
185
|
+
try {
|
|
186
|
+
const tail = readTranscriptTail(transcriptPath, { maxBytes: TRANSCRIPT_READ_MAX_BYTES });
|
|
187
|
+
lastAssistant = tail.lastAssistant;
|
|
188
|
+
turnOrdinal = tail.turnOrdinal;
|
|
189
|
+
} catch {
|
|
190
|
+
return EXIT_OK;
|
|
191
|
+
}
|
|
192
|
+
if (!lastAssistant) return EXIT_OK;
|
|
193
|
+
|
|
194
|
+
const sessionId =
|
|
195
|
+
str(payload['session_id'] as JsonValue | undefined) ||
|
|
196
|
+
str(envelope['session_id'] as JsonValue | undefined);
|
|
197
|
+
// `derive_session_tag`, NOT the turn-end gate's `deriveSessionKey`. Both
|
|
198
|
+
// hash the session id, and only this one produces the tag `chat_history`
|
|
199
|
+
// already writes as `s`. The wall-clock axis is a join between this ledger
|
|
200
|
+
// and those timestamps, so a 32-char key from the other derivation would
|
|
201
|
+
// have made the roadmap's second pre-registered claim underivable — while
|
|
202
|
+
// looking correct in every test that never crossed the two files.
|
|
203
|
+
const runId = sessionId ? derive_session_tag(sessionId) : 'unknown';
|
|
204
|
+
|
|
205
|
+
const ledgerPath = path.join(workspaceRoot, LEDGER_RELPATH);
|
|
206
|
+
if (alreadyRecorded(ledgerPath, runId, turnOrdinal)) return EXIT_OK;
|
|
207
|
+
|
|
208
|
+
const verdict = classifyReply(lastAssistant);
|
|
209
|
+
const record: InterruptionRecord = {
|
|
210
|
+
run_id: runId,
|
|
211
|
+
turn: turnOrdinal,
|
|
212
|
+
kind: verdict.kind,
|
|
213
|
+
class: verdict.class,
|
|
214
|
+
roadmap: readClaimedRoadmap(workspaceRoot, sessionId),
|
|
215
|
+
at: new Date().toISOString(),
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
try {
|
|
219
|
+
fs.mkdirSync(path.dirname(ledgerPath), { recursive: true });
|
|
220
|
+
fs.appendFileSync(ledgerPath, `${JSON.stringify(record)}\n`, 'utf8');
|
|
221
|
+
} catch {
|
|
222
|
+
// A ledger that cannot be written is a lost measurement, never a
|
|
223
|
+
// reason to affect the turn. Phase 0 is advisory by construction.
|
|
224
|
+
}
|
|
225
|
+
return EXIT_OK;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* The concern runs in-process via `CONCERN_REGISTRY`, but the manifest's
|
|
230
|
+
* `script:` path is also the SPAWN FALLBACK — and without this guard that path
|
|
231
|
+
* imports the module, calls nothing, and exits 0. A silent no-op on a
|
|
232
|
+
* capture-only concern is invisible by construction: the ledger simply stays
|
|
233
|
+
* empty and the baseline reads as "no contacts". Caught by running the hook
|
|
234
|
+
* against a real envelope rather than by any unit test, which is why the probe
|
|
235
|
+
* is worth the two minutes.
|
|
236
|
+
*/
|
|
237
|
+
function _isCliEntry(): boolean {
|
|
238
|
+
if (process.argv[1] === undefined) {
|
|
239
|
+
return false;
|
|
240
|
+
}
|
|
241
|
+
const argvUrl = pathToFileURL(path.resolve(process.argv[1])).href;
|
|
242
|
+
if (import.meta.url === argvUrl) {
|
|
243
|
+
return true;
|
|
244
|
+
}
|
|
245
|
+
try {
|
|
246
|
+
const here = fs.realpathSync(fileURLToPath(import.meta.url));
|
|
247
|
+
const argv = fs.realpathSync(path.resolve(process.argv[1]));
|
|
248
|
+
return here === argv;
|
|
249
|
+
} catch {
|
|
250
|
+
return false;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (_isCliEntry()) {
|
|
255
|
+
process.exit(main());
|
|
256
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* `ship-diff-volume` — a shipping-intent gate on diff VOLUME.
|
|
4
|
+
*
|
|
5
|
+
* Complementary to `minimal-safe-diff`, not a duplicate of it, and the
|
|
6
|
+
* distinction is the reason this exists: that concern counts unique FILES
|
|
7
|
+
* TOUCHED PER TURN, which is a within-session shape signal. This one measures
|
|
8
|
+
* total changed LINES on the branch AT A SHIP VERB — the moment the change
|
|
9
|
+
* leaves for review. A twelve-turn change of four files each time trips
|
|
10
|
+
* neither; a single-turn 3,000-line change trips only this one.
|
|
11
|
+
*
|
|
12
|
+
* THE THRESHOLD IS DERIVED FROM THIS REPOSITORY'S OWN HISTORY, never copied
|
|
13
|
+
* from the reference that suggested the gate. Replay of the 40 most recently
|
|
14
|
+
* merged PRs (agents/evidence/eval-findings/metric-loop-s04.md) put the
|
|
15
|
+
* corrected p90 at 1,695 lines.
|
|
16
|
+
*
|
|
17
|
+
* THE EXCLUSION SET IS PART OF THE DEFINITION, NOT A TUNING PARAMETER. The
|
|
18
|
+
* same replay found the naive metric double-counting: four of the five largest
|
|
19
|
+
* PRs were dominated by `*.review-input/diff.patch` — a committed copy of the
|
|
20
|
+
* very diff being measured — and the fifth by a generated index. Precision at
|
|
21
|
+
* the raw p90 was 2/5, below the pre-registered 0.50 floor; excluding the
|
|
22
|
+
* repository's own bookkeeping moved it to 5/5. Removing an input that was
|
|
23
|
+
* never shipping volume is a correction. Growing this set until a firing goes
|
|
24
|
+
* away would be tuning, and the kill criterion applies to that.
|
|
25
|
+
*
|
|
26
|
+
* WARN-LEVEL FIRST. It never blocks; escalation waits for one release of
|
|
27
|
+
* recorded firings, per the phase. Exit code is always 0 on the advisory path;
|
|
28
|
+
* 2 is the warn-in-context channel this estate's dispatcher reads.
|
|
29
|
+
*/
|
|
30
|
+
import { execFileSync } from 'node:child_process';
|
|
31
|
+
import fs from 'node:fs';
|
|
32
|
+
import path from 'node:path';
|
|
33
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
34
|
+
|
|
35
|
+
import { readHookStdin } from './hook_stdin.js';
|
|
36
|
+
import { atomic_write_json } from './state_io.js';
|
|
37
|
+
|
|
38
|
+
export const STATE_FILE = path.join('agents', 'state', 'ship-diff-volume.json');
|
|
39
|
+
|
|
40
|
+
/** Derived: corrected p90 over the last 40 merged PRs (spike s04). */
|
|
41
|
+
export const DEFAULT_THRESHOLD = 1695;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Paths excluded from the volume because they are not shipping volume.
|
|
45
|
+
*
|
|
46
|
+
* Each entry is a category of artefact the change did not author: a snapshot of
|
|
47
|
+
* the diff itself, a generated index, or a projection tree regenerated from
|
|
48
|
+
* source. None is a threshold in disguise.
|
|
49
|
+
*/
|
|
50
|
+
export const EXCLUDED = [
|
|
51
|
+
'.review-input/', // a committed copy of the diff being measured
|
|
52
|
+
'agents/roadmaps/archive/index.json', // generated index
|
|
53
|
+
'dist/', // projection of src/
|
|
54
|
+
'.augment/',
|
|
55
|
+
'.claude/',
|
|
56
|
+
'.cursor/',
|
|
57
|
+
'.clinerules/',
|
|
58
|
+
] as const;
|
|
59
|
+
|
|
60
|
+
/** Ship verbs — the moment a change leaves for review. */
|
|
61
|
+
const SHIP_PATTERNS = [/\bgit\s+push\b/, /\bgh\s+pr\s+create\b/] as const;
|
|
62
|
+
|
|
63
|
+
export function isShipCommand(cmd: string): boolean {
|
|
64
|
+
return SHIP_PATTERNS.some((re) => re.test(cmd));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function isExcluded(p: string): boolean {
|
|
68
|
+
return EXCLUDED.some((e) => (e.endsWith('/') ? p.startsWith(e) || p.includes(e) : p === e));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** `git diff --numstat` output → corrected volume plus what was excluded. */
|
|
72
|
+
export function correctedVolume(numstat: string): { volume: number; excluded: number; files: number } {
|
|
73
|
+
let volume = 0;
|
|
74
|
+
let excluded = 0;
|
|
75
|
+
let files = 0;
|
|
76
|
+
for (const line of numstat.split('\n')) {
|
|
77
|
+
const m = line.match(/^(\d+|-)\t(\d+|-)\t(.+)$/);
|
|
78
|
+
if (m === null) continue;
|
|
79
|
+
const add = m[1] === '-' ? 0 : Number(m[1]);
|
|
80
|
+
const del = m[2] === '-' ? 0 : Number(m[2]);
|
|
81
|
+
const p = m[3]!;
|
|
82
|
+
if (isExcluded(p)) {
|
|
83
|
+
excluded += add + del;
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
volume += add + del;
|
|
87
|
+
files += 1;
|
|
88
|
+
}
|
|
89
|
+
return { volume, excluded, files };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function extractCommand(envelope: Record<string, unknown>): string {
|
|
93
|
+
const ti = envelope['tool_input'];
|
|
94
|
+
if (ti !== null && typeof ti === 'object') {
|
|
95
|
+
const c = (ti as Record<string, unknown>)['command'];
|
|
96
|
+
if (typeof c === 'string') return c;
|
|
97
|
+
}
|
|
98
|
+
const c = envelope['command'];
|
|
99
|
+
return typeof c === 'string' ? c : '';
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function main(argv?: string[]): number {
|
|
103
|
+
const args = argv ?? process.argv.slice(2);
|
|
104
|
+
const idx = args.indexOf('--command');
|
|
105
|
+
let cmd = idx >= 0 && args[idx + 1] !== undefined ? args[idx + 1]! : '';
|
|
106
|
+
if (!cmd) {
|
|
107
|
+
const raw = readHookStdin();
|
|
108
|
+
let envelope: Record<string, unknown> = {};
|
|
109
|
+
if (raw.trim()) {
|
|
110
|
+
try {
|
|
111
|
+
envelope = JSON.parse(raw) as Record<string, unknown>;
|
|
112
|
+
} catch {
|
|
113
|
+
envelope = {};
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
cmd = extractCommand(envelope);
|
|
117
|
+
}
|
|
118
|
+
if (!isShipCommand(cmd)) return 0;
|
|
119
|
+
|
|
120
|
+
let numstat: string;
|
|
121
|
+
let base: string;
|
|
122
|
+
try {
|
|
123
|
+
base = execFileSync('git', ['merge-base', 'HEAD', 'origin/main'], { encoding: 'utf-8' }).trim();
|
|
124
|
+
numstat = execFileSync('git', ['diff', '--numstat', `${base}...HEAD`], {
|
|
125
|
+
encoding: 'utf-8',
|
|
126
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
127
|
+
});
|
|
128
|
+
} catch {
|
|
129
|
+
// No merge base, no origin, a detached probe — nothing to say. An
|
|
130
|
+
// advisory concern that guesses on missing input is worse than silent.
|
|
131
|
+
return 0;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const { volume, excluded, files } = correctedVolume(numstat);
|
|
135
|
+
const over = volume >= DEFAULT_THRESHOLD;
|
|
136
|
+
|
|
137
|
+
try {
|
|
138
|
+
fs.mkdirSync(path.dirname(STATE_FILE), { recursive: true });
|
|
139
|
+
atomic_write_json(STATE_FILE, {
|
|
140
|
+
schema_version: 1,
|
|
141
|
+
base,
|
|
142
|
+
corrected_volume: volume,
|
|
143
|
+
excluded_volume: excluded,
|
|
144
|
+
files_counted: files,
|
|
145
|
+
threshold: DEFAULT_THRESHOLD,
|
|
146
|
+
warning: over,
|
|
147
|
+
checked_at: new Date().toISOString(),
|
|
148
|
+
});
|
|
149
|
+
} catch {
|
|
150
|
+
// State is observability, never the gate. A read-only tree must not
|
|
151
|
+
// turn an advisory warning into a failed ship.
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (!over) return 0;
|
|
155
|
+
|
|
156
|
+
process.stderr.write(
|
|
157
|
+
`ship-diff-volume: ${String(volume)} changed lines against ${DEFAULT_THRESHOLD} ` +
|
|
158
|
+
`(${String(files)} file(s); ${String(excluded)} line(s) excluded as bookkeeping).\n` +
|
|
159
|
+
`Every PR above this line in the last 40 named two or more concerns in its own title. ` +
|
|
160
|
+
`Splitting is a suggestion, not a requirement — this warns and never blocks.\n`,
|
|
161
|
+
);
|
|
162
|
+
return 2; // warn-in-context; advisory severity means the dispatcher does not block
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function _isCliEntry(): boolean {
|
|
166
|
+
if (process.argv[1] === undefined) return false;
|
|
167
|
+
if (import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) return true;
|
|
168
|
+
try {
|
|
169
|
+
return (
|
|
170
|
+
fs.realpathSync(fileURLToPath(import.meta.url)) ===
|
|
171
|
+
fs.realpathSync(path.resolve(process.argv[1]))
|
|
172
|
+
);
|
|
173
|
+
} catch {
|
|
174
|
+
return false;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (_isCliEntry()) {
|
|
179
|
+
process.exit(main());
|
|
180
|
+
}
|