@event4u/agent-config 9.29.0 → 9.30.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 +108 -7
- package/README.md +1 -0
- package/dist/agent-src/commands/analyze/inbox.md +22 -1
- package/dist/agent-src/commands/explain-run.md +31 -1
- package/dist/agent-src/commands/team/delegate.md +1 -0
- package/dist/agent-src/contexts/execution/orchestration-telemetry.md +4 -1
- package/dist/agent-src/contexts/execution/roadmap-process-loop.md +17 -0
- package/dist/agent-src/contexts/execution/subagent-response-contract.md +11 -1
- package/dist/agent-src/rules/preservation-guard.md +9 -0
- package/dist/agent-src/scripts/roadmap_gates.ts +312 -0
- package/dist/agent-src/scripts/update_roadmap_progress.ts +72 -4
- package/dist/agent-src/skills/subagent-orchestration/SKILL.md +10 -0
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/cli/registry.js +2 -0
- package/dist/cli/registry.js.map +1 -1
- package/dist/cli-delegate/{chunk-OJM3MBJM.js → chunk-BTJNRUX3.js} +21 -34
- package/dist/cli-delegate/{chunk-MMXA5GY7.js → chunk-DDUNNFYX.js} +14 -0
- package/dist/cli-delegate/{chunk-NZ5N2XQL.js → chunk-EQY4FLJB.js} +1 -1
- package/dist/cli-delegate/{chunk-RCIA67XN.js → chunk-IYTGIG2Y.js} +44 -1
- package/dist/cli-delegate/chunk-MMJL4TIX.js +18 -0
- package/dist/cli-delegate/chunk-O3CO5BFB.js +161 -0
- package/dist/cli-delegate/{chunk-REKCMYOU.js → chunk-PFBV6XRT.js} +36 -20
- package/dist/cli-delegate/chunk-RVGBH4XK.js +142 -0
- package/dist/cli-delegate/cmd_conformance.js +2798 -95
- package/dist/cli-delegate/cmd_doctor.js +2 -1
- package/dist/cli-delegate/cmd_export.js +2 -2
- package/dist/cli-delegate/cmd_handoff.js +86 -6
- package/dist/cli-delegate/cmd_prune.js +2 -2
- package/dist/cli-delegate/cmd_route_audit.js +2 -1
- package/dist/cli-delegate/cmd_session_recycle.js +177 -0
- package/dist/cli-delegate/cmd_settings_get.js +1 -1
- package/dist/cli-delegate/cmd_settings_migrate.js +2 -2
- package/dist/cli-delegate/cmd_settings_set.js +2 -2
- package/dist/cli-delegate/cmd_sync.js +2 -2
- package/dist/cli-delegate/cmd_uninstall.js +2 -2
- package/dist/cli-delegate/cmd_validate.js +2 -2
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +7 -7
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +1 -1
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +2 -2
- package/dist/discovery/trust-report.md +1 -1
- package/dist/discovery/workspaces.json +2 -2
- package/dist/hooks/dispatch.js +1904 -575
- package/dist/install/install.mjs +14 -0
- package/dist/mcp/registry-manifest.json +2 -2
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +3760 -726
- package/dist/server/schemas/settings.js +6 -0
- package/dist/server/schemas/settings.js.map +1 -1
- package/dist/ui/assets/index-B5qGYOJo.js +26 -0
- package/dist/ui/assets/index-B5qGYOJo.js.map +1 -0
- package/dist/ui/index.html +1 -1
- package/docs/contracts/settings-classes.md +21 -5
- package/package.json +1 -1
- package/src/agent-src/scripts/roadmap_gates.ts +312 -0
- package/src/agent-src/scripts/update_roadmap_progress.ts +72 -4
- package/src/config/agent-settings.template.yml +36 -0
- package/src/config/dispatch-economy-metrics.json +61 -0
- package/src/config/evaluator-budgets.json +4 -4
- package/src/config/hook-token-budget.json +91 -0
- package/src/config/recycle-threshold-budget.json +12 -0
- package/src/config/rule-enforcement-baseline.json +86 -0
- package/src/scripts/_cli/cmd_conformance.ts +17 -2
- package/src/scripts/_cli/cmd_session_recycle.ts +226 -0
- package/src/scripts/_dispatch.bash +23 -0
- package/src/scripts/_lib/judgment_ladder.ts +254 -2
- package/src/scripts/_lib/orchestration_record.ts +41 -10
- package/src/scripts/_lib/recycle_envelope_paths.ts +35 -0
- package/src/scripts/_lib/self_repair.ts +194 -0
- package/src/scripts/_lib/session_eol.ts +230 -0
- package/src/scripts/_lib/session_role.ts +42 -0
- package/src/scripts/_lib/subagent_capsule.ts +159 -0
- package/src/scripts/_lib/subagent_response.ts +75 -0
- package/src/scripts/ai_council/clients.ts +50 -1
- package/src/scripts/ask_transport.ts +221 -0
- package/src/scripts/bench_hook_injection.ts +234 -0
- package/src/scripts/check_adr_frontmatter.ts +84 -40
- package/src/scripts/check_branch_freshness.ts +1 -0
- package/src/scripts/check_corpus_staleness.ts +18 -4
- package/src/scripts/check_detector_corpus.ts +17 -0
- package/src/scripts/check_gitignore_freshness.ts +42 -8
- package/src/scripts/check_hook_bundle_freshness.ts +20 -0
- package/src/scripts/check_kernel_prefix_stability.ts +1 -0
- package/src/scripts/check_standing_rule_delivery.ts +73 -32
- package/src/scripts/check_static_layer_stability.ts +154 -0
- package/src/scripts/check_suppression_hygiene.ts +48 -0
- package/src/scripts/dispatch_economy_report.ts +448 -0
- package/src/scripts/dispatch_r2_reviewer.ts +15 -2
- package/src/scripts/explain_run.ts +206 -4
- package/src/scripts/generate_settings_reference.ts +1 -0
- package/src/scripts/handoff_context_hook.ts +112 -4
- package/src/scripts/hook_manifest.yaml +117 -10
- package/src/scripts/hooks/concern_registry.ts +8 -0
- package/src/scripts/hooks/delegation_nudge_hook.ts +40 -3
- package/src/scripts/hooks/dispatch_hook.ts +39 -2
- package/src/scripts/hooks/edit_shape_hook.ts +303 -0
- package/src/scripts/hooks/orchestration_record_hook.ts +19 -1
- package/src/scripts/hooks/reread_guard_hook.ts +314 -0
- package/src/scripts/hooks/rtk_wrap_hook.ts +133 -14
- package/src/scripts/hooks/session_eol_hook.ts +268 -0
- package/src/scripts/hooks/turn_end_gate_hook.ts +507 -0
- package/src/scripts/install-hooks.sh +1 -1
- package/src/scripts/language_mirror_hook.ts +89 -0
- package/src/scripts/lint_decision_impact.ts +14 -1
- package/src/scripts/lint_hook_manifest.ts +82 -0
- package/src/scripts/lint_readme_serial_comma.ts +1 -0
- package/src/scripts/lint_rule_enforcement_declaration.ts +347 -0
- package/src/scripts/measure_turn_end_gate.ts +194 -0
- package/src/scripts/orchestration_record.ts +4 -1
- package/src/scripts/rebuild_hook_bundle.ts +1 -0
- package/src/scripts/report_conformance_funnel.ts +284 -0
- package/src/scripts/report_imperative_density.ts +1 -0
- package/src/scripts/routing_doctor.ts +97 -0
- package/src/scripts/self_repair_cli.ts +51 -3
- package/src/scripts/session_eol_report.ts +261 -0
- package/src/server/schemas/settings.ts +14 -0
- package/src/templates/compact-instructions.md +40 -0
- package/dist/ui/assets/index-JDAF00bC.js +0 -26
- package/dist/ui/assets/index-JDAF00bC.js.map +0 -1
|
@@ -99,6 +99,7 @@ import { load_agent_settings } from "../_lib/agent_settings.js";
|
|
|
99
99
|
import { probeHostCapabilities } from "../_lib/host_capability.js";
|
|
100
100
|
import type { ActivationInputs, Classification, TaskSignals } from "../_lib/auto_dispatch.js";
|
|
101
101
|
import { classifyLadder, type LadderRung } from "../_lib/judgment_ladder.js";
|
|
102
|
+
import { resolveSessionRole } from "../_lib/session_role.js";
|
|
102
103
|
import { resolveSubagentRouting, type Tier } from "../_lib/subagent_routing.js";
|
|
103
104
|
import { isSyntheticPrompt } from "../_lib/prompt_shape.js";
|
|
104
105
|
|
|
@@ -338,6 +339,17 @@ export function buildNudgeLine(
|
|
|
338
339
|
): string {
|
|
339
340
|
const unit = sliceCount === 1 ? "slice" : "slices";
|
|
340
341
|
const rungLabel = rung === null ? "rung-?" : `rung-${rung}`;
|
|
342
|
+
// Rung 0.5 (token-economy-dispatch 4.3): cite the sub-spawn ask path —
|
|
343
|
+
// never a spawn recommendation for a question-shaped slice.
|
|
344
|
+
if (rung === 0.5) {
|
|
345
|
+
return (
|
|
346
|
+
`<delegation-nudge>rung-0.5: ask, est. <1k tokens. This is a single bounded ` +
|
|
347
|
+
`question — answer it via ONE completion ` +
|
|
348
|
+
"(`./scripts-run src/scripts/ask_transport \"<question>\"`) instead of a " +
|
|
349
|
+
`subagent spawn (measured spawn floor: ~251k tokens) — ` +
|
|
350
|
+
`${classification.reason}.</delegation-nudge>`
|
|
351
|
+
);
|
|
352
|
+
}
|
|
341
353
|
// Rung 1 (single bounded slice) carries no do-in-steps/do-in-parallel
|
|
342
354
|
// mode of its own — `classification.mode` is `null` for that shape.
|
|
343
355
|
const modeLabel = classification.mode ?? "single-slice";
|
|
@@ -353,7 +365,11 @@ export interface NudgeResult {
|
|
|
353
365
|
classification: Classification;
|
|
354
366
|
sliceCount: number;
|
|
355
367
|
tier: Tier;
|
|
356
|
-
/** The judgment-ladder rung this verdict resolved to (2.4) — 1 or 2
|
|
368
|
+
/** The judgment-ladder rung this verdict resolved to (2.4) — 1 or 2 in
|
|
369
|
+
* production today: classifyPrompt returns null for every non-subagent
|
|
370
|
+
* verdict including rung 0.5 (the carrier stays silent on questions);
|
|
371
|
+
* buildNudgeLine's 0.5 branch is vocabulary for orchestrator-facing
|
|
372
|
+
* surfaces and direct callers. */
|
|
357
373
|
rung: LadderRung;
|
|
358
374
|
}
|
|
359
375
|
|
|
@@ -374,9 +390,30 @@ export function classifyPrompt(
|
|
|
374
390
|
workspace_root,
|
|
375
391
|
hostId,
|
|
376
392
|
);
|
|
377
|
-
|
|
393
|
+
// Recursive-dispatch guard input — the SHARED session-role detector
|
|
394
|
+
// (_lib/session_role.ts, "one detector, two consumers, test-pinned
|
|
395
|
+
// equal"): a session marked worker/reviewer by a suite-owned spawn
|
|
396
|
+
// wrapper never receives a delegation nudge. Unmarked sessions keep
|
|
397
|
+
// the documented gap (no host discriminator exists) — fail-open.
|
|
398
|
+
const sessionRole = resolveSessionRole(process.env);
|
|
399
|
+
const ladder = classifyLadder({
|
|
400
|
+
taskText: prompt,
|
|
401
|
+
signals,
|
|
402
|
+
activation,
|
|
403
|
+
agentTeams,
|
|
404
|
+
insideSubagentSession: sessionRole !== "orchestrator",
|
|
405
|
+
});
|
|
406
|
+
// Rung 0.5 (token-economy-dispatch 4.3): on a USER prompt the carrier
|
|
407
|
+
// stays SILENT for a bounded question — the agent answers in-session at
|
|
408
|
+
// zero marginal cost, and injecting an ask_transport citation here would
|
|
409
|
+
// be the cosmetic-injection failure this repo measured at 24/29 misses.
|
|
410
|
+
// The 0.5 verdict is an ORCHESTRATOR-facing routing datum (slice
|
|
411
|
+
// resolution); buildNudgeLine carries the rung-0.5 vocabulary for those
|
|
412
|
+
// surfaces. Silence here also guarantees a question-shaped prompt can
|
|
413
|
+
// never produce a spawn nudge ("the carrier stops nudging full spawns
|
|
414
|
+
// for question-shaped slices").
|
|
378
415
|
if (ladder.verdict !== "subagent") {
|
|
379
|
-
return null; // rung-0 script, rung-3/4 team/council, or ∅ ask/in-session
|
|
416
|
+
return null; // rung-0 script, rung-0.5 ask-transport, rung-3/4 team/council, or ∅ user-ask/in-session
|
|
380
417
|
}
|
|
381
418
|
const tier = recommendSliceTier(downshift, separate_quota_pool);
|
|
382
419
|
// Rung 1 (single bounded slice) has no explicit slice count of its own —
|
|
@@ -48,6 +48,7 @@ import {
|
|
|
48
48
|
clearHookStdinOverride,
|
|
49
49
|
} from "./hook_stdin.js";
|
|
50
50
|
import { emitFor, type Severity } from "./host_semantics.js";
|
|
51
|
+
import { resolveSessionRole, type SessionRole } from "../_lib/session_role.js";
|
|
51
52
|
|
|
52
53
|
// Free-form JSON values flow through every helper here; a documented
|
|
53
54
|
// alias keeps the surface honest without `any` (ADR-200 § strict TS).
|
|
@@ -305,12 +306,42 @@ export function _concern_matches_tool(concern: JsonObject, tool_name: string): b
|
|
|
305
306
|
}
|
|
306
307
|
|
|
307
308
|
/**
|
|
308
|
-
*
|
|
309
|
+
* Role axis (road-to-token-economy-dispatch Phase 2): the set of concern
|
|
310
|
+
* names the given role drops. Empty for `orchestrator`, for a role without
|
|
311
|
+
* a manifest entry (fail-open, 2.4), and ALWAYS for `pre_tool_use` — the
|
|
312
|
+
* safety-guard slot is structurally undroppable (2.3); `lint_hook_manifest`
|
|
313
|
+
* additionally rejects a drop entry bound to that slot at CI time.
|
|
314
|
+
*/
|
|
315
|
+
export function _role_drop_set(
|
|
316
|
+
manifest: JsonObject,
|
|
317
|
+
role: string,
|
|
318
|
+
event: string,
|
|
319
|
+
): Set<string> {
|
|
320
|
+
if (!role || role === "orchestrator" || event === "pre_tool_use") {
|
|
321
|
+
return new Set();
|
|
322
|
+
}
|
|
323
|
+
const rolesRaw = manifest["roles"];
|
|
324
|
+
const roleSpec = _isObject(rolesRaw) ? rolesRaw[role] : undefined;
|
|
325
|
+
if (!_isObject(roleSpec)) {
|
|
326
|
+
return new Set(); // unknown role → full chain (fail-open)
|
|
327
|
+
}
|
|
328
|
+
const drop = roleSpec["drop"];
|
|
329
|
+
if (!Array.isArray(drop)) {
|
|
330
|
+
return new Set();
|
|
331
|
+
}
|
|
332
|
+
return new Set(drop.filter((d): d is string => typeof d === "string"));
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Return the ordered concern definitions for (platform, event) under the
|
|
337
|
+
* given session role (default `orchestrator` — byte-identical to the
|
|
338
|
+
* pre-role-axis behaviour).
|
|
309
339
|
*/
|
|
310
340
|
export function _resolve_concerns(
|
|
311
341
|
manifest: JsonObject,
|
|
312
342
|
platform: string,
|
|
313
343
|
event: string,
|
|
344
|
+
role: SessionRole = "orchestrator",
|
|
314
345
|
): ConcernDef[] {
|
|
315
346
|
const platformsRaw = manifest["platforms"];
|
|
316
347
|
const platforms = _isObject(platformsRaw) ? platformsRaw : {};
|
|
@@ -325,10 +356,14 @@ export function _resolve_concerns(
|
|
|
325
356
|
if (!Array.isArray(names)) {
|
|
326
357
|
return [];
|
|
327
358
|
}
|
|
359
|
+
const dropped = _role_drop_set(manifest, role, event);
|
|
328
360
|
const concernsRaw = manifest["concerns"];
|
|
329
361
|
const concerns_def = _isObject(concernsRaw) ? concernsRaw : {};
|
|
330
362
|
const out: ConcernDef[] = [];
|
|
331
363
|
for (const name of names) {
|
|
364
|
+
if (typeof name === "string" && dropped.has(name)) {
|
|
365
|
+
continue; // role-dropped orchestrator-only concern
|
|
366
|
+
}
|
|
332
367
|
const spec = typeof name === "string" ? concerns_def[name] : undefined;
|
|
333
368
|
if (!spec || !_isObject(spec)) {
|
|
334
369
|
process.stderr.write(
|
|
@@ -1035,12 +1070,14 @@ export function main(argv?: string[]): number {
|
|
|
1035
1070
|
|
|
1036
1071
|
const payload_text = process.stdin.isTTY ? "" : _readStdin();
|
|
1037
1072
|
_maybe_capture_payload(args, payload_text);
|
|
1038
|
-
const
|
|
1073
|
+
const session_role = resolveSessionRole(process.env);
|
|
1074
|
+
const concerns = _resolve_concerns(manifest, args.platform, args.event, session_role);
|
|
1039
1075
|
|
|
1040
1076
|
if (args.dry_run) {
|
|
1041
1077
|
const plan = {
|
|
1042
1078
|
platform: args.platform,
|
|
1043
1079
|
event: args.event,
|
|
1080
|
+
role: session_role,
|
|
1044
1081
|
concerns: concerns.map((c) => c["name"]),
|
|
1045
1082
|
};
|
|
1046
1083
|
process.stdout.write(_py_json_dumps(plan, 2) + "\n");
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Edit-shape advisory — `post_tool_use` concern
|
|
4
|
+
* (road-to-token-economy-cache Phase 5.1/5.2).
|
|
5
|
+
*
|
|
6
|
+
* Fires when a `Write` tool call REPLACED an existing, git-tracked file of at
|
|
7
|
+
* least `MIN_FILE_LINES` lines while the effective change was at most
|
|
8
|
+
* `MAX_DIFF_RATIO` of the file — the wasteful shape where the agent re-emits
|
|
9
|
+
* a whole file to change a few lines. One advisory line names the cheaper
|
|
10
|
+
* primitive: a targeted Edit pays for the diff, not the file.
|
|
11
|
+
*
|
|
12
|
+
* ## Committed thresholds (roadmap 5.1 — committed in this concern header)
|
|
13
|
+
*
|
|
14
|
+
* `MIN_FILE_LINES = 50` and `MAX_DIFF_RATIO = 0.20` are COMMITTED constants,
|
|
15
|
+
* not tunables: the roadmap requires the thresholds to live in the concern
|
|
16
|
+
* header so a threshold change is a reviewed diff, never a silent drift.
|
|
17
|
+
* Ratio definition: `(added + deleted) / current file line count` from
|
|
18
|
+
* `git diff --numstat` — a 5-line in-place change on a 60-line file measures
|
|
19
|
+
* as 10/60 ≈ 17 % (numstat counts a changed line once on each side).
|
|
20
|
+
*
|
|
21
|
+
* ## Honest mechanism — why git, not the payload
|
|
22
|
+
*
|
|
23
|
+
* At PostToolUse time the target file is ALREADY overwritten on disk, and the
|
|
24
|
+
* PostToolUse payload carries only the NEW content (`tool_input.content`) —
|
|
25
|
+
* the pre-image is not observable from the event itself (verified against the
|
|
26
|
+
* payload shapes `design_slop_hook.ts` / `orchestration_record_hook.ts`
|
|
27
|
+
* read). The pre-image that IS still observable is git's: for a tracked file,
|
|
28
|
+
* `git diff HEAD --numstat -- <path>` diffs the working tree against the last
|
|
29
|
+
* commit, so `added + deleted` is a deterministic effective-diff measure.
|
|
30
|
+
* Two consequences, stated rather than hidden:
|
|
31
|
+
* - The measured diff is CUMULATIVE since HEAD for that path, not the delta
|
|
32
|
+
* of this one Write. A session's second small Write to the same file
|
|
33
|
+
* measures both writes together — an over-count that only makes the
|
|
34
|
+
* ratio LARGER, i.e. biases toward silence, never toward a false fire.
|
|
35
|
+
* - An untracked (brand-new) file has no HEAD pre-image and produces no
|
|
36
|
+
* numstat row → silent. Correct: writing a new file is not a wasteful
|
|
37
|
+
* replace, it is the only primitive that creates one.
|
|
38
|
+
* A path with NO unstaged/staged modification vs HEAD (diff of 0 lines) is
|
|
39
|
+
* also silent — the fire condition per the roadmap is "tracked in git with an
|
|
40
|
+
* observable modification whose ratio is small", and a 0-row diff cannot
|
|
41
|
+
* distinguish a no-op Write from an already-committed state.
|
|
42
|
+
*
|
|
43
|
+
* ## Once-per-session gate (roadmap 5.2)
|
|
44
|
+
*
|
|
45
|
+
* Mirrors `end_review_nudge_hook.ts`'s F2 marker: a JSON marker under
|
|
46
|
+
* `<workspace_root>/agents/runtime/state/edit-shape/<sha256(session)>.json`
|
|
47
|
+
* (gitignored — `agents/runtime/` is blanket-ignored) records that THIS
|
|
48
|
+
* session already fired; later qualifying Writes in the same session return
|
|
49
|
+
* silently. The marker carries a timestamp and nothing else — counts/flags
|
|
50
|
+
* only, never content. The economy nudge must not itself become an economy
|
|
51
|
+
* problem.
|
|
52
|
+
*
|
|
53
|
+
* ## Path exemptions (committed constant — shared list with `reread-guard`)
|
|
54
|
+
*
|
|
55
|
+
* `dist/**`, any path containing `/generated/`, `*.lock`, `*.min.*`, and
|
|
56
|
+
* `agents/runtime/**` are exempt: generated/derived surfaces are legitimately
|
|
57
|
+
* re-emitted wholesale, and nudging there is pure noise.
|
|
58
|
+
*
|
|
59
|
+
* ## Delivery — `{decision:"warn", reason, additional_context}` at exit 2
|
|
60
|
+
*
|
|
61
|
+
* Mirrors `delegation_nudge_hook.ts` exactly. Severity is taken from the
|
|
62
|
+
* EXIT CODE, not the `decision` field: a warn verdict is reported at exit
|
|
63
|
+
* `EXIT_WARN` (2) so `host_semantics.emitFor` reduces it to severity `"warn"`
|
|
64
|
+
* and forwards `additional_context` into
|
|
65
|
+
* `hookSpecificOutput.additionalContext`.
|
|
66
|
+
*
|
|
67
|
+
* WHY EXIT 2 NEVER BLOCKS (copied from `delegation_nudge_hook.ts`, verified
|
|
68
|
+
* there against the real dispatcher): `host_semantics.emitFor`'s
|
|
69
|
+
* `severity === "warn"` branch returns `{exit: 0, stdout:
|
|
70
|
+
* claudeAdditionalContext(event, reason)}` UNCONDITIONALLY — it does not
|
|
71
|
+
* consult `CLAUDE_BLOCK_CAPABLE_EVENTS` at all (that check only gates the
|
|
72
|
+
* `"block"` severity branch). On the verified `claude` platform the real
|
|
73
|
+
* process exit handed back to the host is always 0 for a warn verdict; the
|
|
74
|
+
* internal `EXIT_WARN` is dispatcher-internal bookkeeping, never the byte
|
|
75
|
+
* that reaches Claude Code.
|
|
76
|
+
*
|
|
77
|
+
* ROBUSTNESS: malformed payload → exit 0 silently; every fs/git error is
|
|
78
|
+
* swallowed. This concern never blocks a tool call on any path.
|
|
79
|
+
*/
|
|
80
|
+
import { spawnSync } from 'node:child_process';
|
|
81
|
+
import * as crypto from 'node:crypto';
|
|
82
|
+
import * as fs from 'node:fs';
|
|
83
|
+
import * as path from 'node:path';
|
|
84
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
85
|
+
|
|
86
|
+
import { unwrap, type JsonObject, type JsonValue } from './envelope.js';
|
|
87
|
+
import { readHookStdin } from './hook_stdin.js';
|
|
88
|
+
import { atomic_write_json } from './state_io.js';
|
|
89
|
+
|
|
90
|
+
const EXIT_ALLOW = 0;
|
|
91
|
+
// Severity is taken from the EXIT CODE, not from the `decision` field in the
|
|
92
|
+
// stdout payload — see the file header for why this never blocks on `claude`.
|
|
93
|
+
const EXIT_WARN = 2;
|
|
94
|
+
|
|
95
|
+
/** Committed threshold (roadmap 5.1): minimum current line count of the
|
|
96
|
+
* replaced file before this concern has anything to say. */
|
|
97
|
+
export const MIN_FILE_LINES = 50;
|
|
98
|
+
|
|
99
|
+
/** Committed threshold (roadmap 5.1): maximum `(added+deleted)/fileLines`
|
|
100
|
+
* ratio for the Write to count as a full-rewrite-with-small-diff. */
|
|
101
|
+
export const MAX_DIFF_RATIO = 0.2;
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Committed exemption list (roadmap 5.2/6.2 — deliberately duplicated in
|
|
105
|
+
* `reread_guard_hook.ts`; the two concerns share the list by convention, and
|
|
106
|
+
* a shared module would couple two independently-rollback-able hooks).
|
|
107
|
+
*/
|
|
108
|
+
export const EXEMPT_PATH_PATTERNS: readonly RegExp[] = [
|
|
109
|
+
/(^|\/)dist\//i, // dist/**
|
|
110
|
+
/\/generated\//i, // any path containing /generated/
|
|
111
|
+
/\.lock$/i, // *.lock
|
|
112
|
+
/\.min\.[^/]+$/i, // *.min.*
|
|
113
|
+
/(^|\/)agents\/runtime\//i, // agents/runtime/**
|
|
114
|
+
];
|
|
115
|
+
|
|
116
|
+
function isObject(v: unknown): v is JsonObject {
|
|
117
|
+
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function str(v: JsonValue | undefined): string {
|
|
121
|
+
return typeof v === 'string' ? v : '';
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** True when `filePath` (absolute or repo-relative) matches the exemption list. */
|
|
125
|
+
export function isExemptPath(filePath: string): boolean {
|
|
126
|
+
const normalized = filePath.replace(/\\/g, '/');
|
|
127
|
+
return EXEMPT_PATH_PATTERNS.some((re) => re.test(normalized));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** One `git diff --numstat` row reduced to its added+deleted line count. */
|
|
131
|
+
export function parseNumstatLines(raw: string): number {
|
|
132
|
+
let total = 0;
|
|
133
|
+
for (const line of raw.split('\n')) {
|
|
134
|
+
const m = /^(\d+|-)\t(\d+|-)\t.+$/.exec(line);
|
|
135
|
+
if (!m) continue;
|
|
136
|
+
const added = m[1] === '-' ? 0 : Number(m[1]);
|
|
137
|
+
const deleted = m[2] === '-' ? 0 : Number(m[2]);
|
|
138
|
+
total += added + deleted;
|
|
139
|
+
}
|
|
140
|
+
return total;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Effective-diff line count for ONE path: `git diff HEAD --numstat -- <path>`
|
|
145
|
+
* at `cwd` (working tree + index vs the last commit — git still holds the
|
|
146
|
+
* pre-image the PostToolUse payload no longer carries). Fails closed to `0`
|
|
147
|
+
* (silent) — no repo, no HEAD, untracked path, or `git` missing all read as
|
|
148
|
+
* "nothing observable to nudge about".
|
|
149
|
+
*/
|
|
150
|
+
export function effectiveDiffLines(cwd: string, relPath: string): number {
|
|
151
|
+
try {
|
|
152
|
+
const r = spawnSync('git', ['diff', 'HEAD', '--numstat', '--', relPath], {
|
|
153
|
+
cwd,
|
|
154
|
+
encoding: 'utf8',
|
|
155
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
156
|
+
});
|
|
157
|
+
if (r.status !== 0) return 0;
|
|
158
|
+
return parseNumstatLines(r.stdout ?? '');
|
|
159
|
+
} catch {
|
|
160
|
+
return 0;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Current line count of the file on disk. Fails closed to `0` (silent). */
|
|
165
|
+
export function fileLineCount(absPath: string): number {
|
|
166
|
+
try {
|
|
167
|
+
const text = fs.readFileSync(absPath, 'utf8');
|
|
168
|
+
if (text.length === 0) return 0;
|
|
169
|
+
// Trailing newline does not open a new line.
|
|
170
|
+
return text.split('\n').length - (text.endsWith('\n') ? 1 : 0);
|
|
171
|
+
} catch {
|
|
172
|
+
return 0;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** The exactly-one advisory line this concern injects when it fires. */
|
|
177
|
+
export function buildAdvisoryLine(fileLines: number, diffLines: number): string {
|
|
178
|
+
const pct = Math.round((diffLines / fileLines) * 100);
|
|
179
|
+
return (
|
|
180
|
+
`this Write replaced ${fileLines} lines for an effective diff of ~${pct}% ` +
|
|
181
|
+
`(${diffLines} changed lines) — a targeted Edit pays for the diff, not the file.`
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Stable, filesystem-safe once-per-session key — mirrors
|
|
187
|
+
* `end_review_nudge_hook.deriveSessionKey`: the envelope's `session_id`
|
|
188
|
+
* (falling back to the payload's) is HASHED, never stored raw.
|
|
189
|
+
*/
|
|
190
|
+
export function deriveSessionKey(envelope: JsonObject, payload: JsonObject): string {
|
|
191
|
+
const raw =
|
|
192
|
+
str(envelope['session_id'] as JsonValue | undefined) ||
|
|
193
|
+
str((payload['session_id'] ?? payload['sessionId']) as JsonValue | undefined);
|
|
194
|
+
const material = raw || 'unknown-session';
|
|
195
|
+
return crypto.createHash('sha256').update(material).digest('hex');
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function sessionStateFile(workspaceRoot: string, sessionKey: string): string {
|
|
199
|
+
return path.join(workspaceRoot, 'agents', 'runtime', 'state', 'edit-shape', `${sessionKey}.json`);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** True when THIS session already fired. Fails closed to `false` (may repeat,
|
|
203
|
+
* never silences over an unreadable state dir). */
|
|
204
|
+
export function hasFiredThisSession(workspaceRoot: string, sessionKey: string): boolean {
|
|
205
|
+
try {
|
|
206
|
+
return fs.existsSync(sessionStateFile(workspaceRoot, sessionKey));
|
|
207
|
+
} catch {
|
|
208
|
+
return false;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function markFiredThisSession(workspaceRoot: string, sessionKey: string): void {
|
|
213
|
+
try {
|
|
214
|
+
atomic_write_json(sessionStateFile(workspaceRoot, sessionKey), {
|
|
215
|
+
fired_at: new Date().toISOString(),
|
|
216
|
+
});
|
|
217
|
+
} catch {
|
|
218
|
+
// a state-write failure must never affect the tool call
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function extractToolName(payload: JsonObject): string {
|
|
223
|
+
return str((payload['tool_name'] ?? payload['toolName'] ?? payload['tool']) as JsonValue | undefined);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function extractFilePath(payload: JsonObject): string {
|
|
227
|
+
const ti = payload['tool_input'] ?? payload['toolInput'] ?? payload['input'];
|
|
228
|
+
if (!isObject(ti)) return '';
|
|
229
|
+
return str((ti['file_path'] ?? ti['path'] ?? ti['filePath']) as JsonValue | undefined);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export function main(): number {
|
|
233
|
+
try {
|
|
234
|
+
const [envelope, payload] = unwrap(readHookStdin(), 'claude');
|
|
235
|
+
|
|
236
|
+
const event = str(envelope['event'] as JsonValue | undefined);
|
|
237
|
+
if (event !== '' && event !== 'post_tool_use') return EXIT_ALLOW;
|
|
238
|
+
|
|
239
|
+
const toolName = extractToolName(payload);
|
|
240
|
+
if (toolName !== 'Write') return EXIT_ALLOW; // manifest `tools: [Write]` twin — belt and braces
|
|
241
|
+
|
|
242
|
+
const filePath = extractFilePath(payload);
|
|
243
|
+
if (!filePath) return EXIT_ALLOW;
|
|
244
|
+
|
|
245
|
+
const workspaceRoot = str(envelope['workspace_root'] as JsonValue | undefined).trim() || process.cwd();
|
|
246
|
+
const absPath = path.isAbsolute(filePath) ? filePath : path.resolve(workspaceRoot, filePath);
|
|
247
|
+
const relPath = path.relative(workspaceRoot, absPath);
|
|
248
|
+
if (isExemptPath(relPath) || isExemptPath(absPath)) return EXIT_ALLOW;
|
|
249
|
+
|
|
250
|
+
const fileLines = fileLineCount(absPath);
|
|
251
|
+
if (fileLines < MIN_FILE_LINES) return EXIT_ALLOW;
|
|
252
|
+
|
|
253
|
+
const diffLines = effectiveDiffLines(workspaceRoot, relPath.startsWith('..') ? absPath : relPath);
|
|
254
|
+
// 0 = no observable modification vs HEAD (untracked file, clean path,
|
|
255
|
+
// no repo) — silent per the header's honest-mechanism note.
|
|
256
|
+
if (diffLines === 0) return EXIT_ALLOW;
|
|
257
|
+
if (diffLines / fileLines > MAX_DIFF_RATIO) return EXIT_ALLOW;
|
|
258
|
+
|
|
259
|
+
// Cheap checks first, then the once-per-session gate (same ordering
|
|
260
|
+
// discipline as end_review_nudge's F12).
|
|
261
|
+
const sessionKey = deriveSessionKey(envelope, payload);
|
|
262
|
+
if (hasFiredThisSession(workspaceRoot, sessionKey)) return EXIT_ALLOW;
|
|
263
|
+
markFiredThisSession(workspaceRoot, sessionKey);
|
|
264
|
+
|
|
265
|
+
process.stdout.write(
|
|
266
|
+
`${JSON.stringify({
|
|
267
|
+
decision: 'warn',
|
|
268
|
+
reason: `edit-shape: Write replaced a ${fileLines}-line file for a ${diffLines}-line effective diff`,
|
|
269
|
+
additional_context: buildAdvisoryLine(fileLines, diffLines),
|
|
270
|
+
})}\n`,
|
|
271
|
+
);
|
|
272
|
+
return EXIT_WARN;
|
|
273
|
+
} catch {
|
|
274
|
+
return EXIT_ALLOW; // malformed payload / any error — never touch the tool call
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Bundle-safety: never auto-run when inlined into an esbuild bundle, where
|
|
279
|
+
// every module shares the bundle's `import.meta.url` (see cmd_migrate.ts).
|
|
280
|
+
declare const __AGENT_CONFIG_BUNDLE__: boolean | undefined;
|
|
281
|
+
function _isCliEntry(): boolean {
|
|
282
|
+
if (typeof __AGENT_CONFIG_BUNDLE__ !== 'undefined' && __AGENT_CONFIG_BUNDLE__) {
|
|
283
|
+
return false;
|
|
284
|
+
}
|
|
285
|
+
if (process.argv[1] === undefined) {
|
|
286
|
+
return false;
|
|
287
|
+
}
|
|
288
|
+
const argvUrl = pathToFileURL(path.resolve(process.argv[1])).href;
|
|
289
|
+
if (import.meta.url === argvUrl) {
|
|
290
|
+
return true;
|
|
291
|
+
}
|
|
292
|
+
try {
|
|
293
|
+
const here = fs.realpathSync(fileURLToPath(import.meta.url));
|
|
294
|
+
const argv = fs.realpathSync(path.resolve(process.argv[1]));
|
|
295
|
+
return here === argv;
|
|
296
|
+
} catch {
|
|
297
|
+
return false;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
if (_isCliEntry()) {
|
|
302
|
+
process.exit(main());
|
|
303
|
+
}
|
|
@@ -131,6 +131,11 @@ export interface DispatchFacts {
|
|
|
131
131
|
/** Absolute measured tokens the dispatched slice consumed. `null` = not observed (async). */
|
|
132
132
|
totalTokens: number | null;
|
|
133
133
|
totalDurationMs: number | null;
|
|
134
|
+
/** Serialized LENGTH of the tool result — the chars this dispatch returned
|
|
135
|
+
* into the orchestrator context (Phase 6.3 return-channel detector).
|
|
136
|
+
* A COUNT derived from the result object; the content itself is never
|
|
137
|
+
* read into the record (privacy by construction). `null` = async ack. */
|
|
138
|
+
returnChannelChars: number | null;
|
|
134
139
|
isAsync: boolean;
|
|
135
140
|
isError: boolean;
|
|
136
141
|
}
|
|
@@ -199,7 +204,19 @@ export function extractDispatchFacts(payload: JsonObject): DispatchFacts {
|
|
|
199
204
|
const isAsync = result !== null && (result['isAsync'] === true || result['status'] === 'async_launched');
|
|
200
205
|
const isError = payload['is_error'] === true || payload['isError'] === true || (result !== null && result['is_error'] === true);
|
|
201
206
|
|
|
202
|
-
return
|
|
207
|
+
// Phase 6.3 return-channel size: measured as the serialized LENGTH of the
|
|
208
|
+
// result object — a count; the content never enters the record. Async
|
|
209
|
+
// acks carry no result payload worth measuring (null, never fabricated).
|
|
210
|
+
let returnChannelChars: number | null = null;
|
|
211
|
+
if (result !== null && !isAsync) {
|
|
212
|
+
try {
|
|
213
|
+
returnChannelChars = JSON.stringify(result).length;
|
|
214
|
+
} catch {
|
|
215
|
+
returnChannelChars = null;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return { subagentType, resolvedModel, totalTokens, totalDurationMs, returnChannelChars, isAsync, isError };
|
|
203
220
|
}
|
|
204
221
|
|
|
205
222
|
/**
|
|
@@ -231,6 +248,7 @@ export function buildRecordInput(facts: DispatchFacts, ts: string, id: string):
|
|
|
231
248
|
}
|
|
232
249
|
if (facts.totalTokens !== null) input.dispatch_tokens = facts.totalTokens;
|
|
233
250
|
if (facts.totalDurationMs !== null) input.wall_clock_ms = facts.totalDurationMs;
|
|
251
|
+
if (facts.returnChannelChars !== null) input.return_channel_chars = facts.returnChannelChars;
|
|
234
252
|
}
|
|
235
253
|
// Async launch ack: every metric field stays absent (undefined → the
|
|
236
254
|
// schema's own null/0 default) rather than fabricated from nothing.
|