@event4u/agent-config 9.28.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/AGENTS.md +6 -6
- package/CHANGELOG.md +172 -5
- package/README.md +2 -1
- package/agents/templates/.ai-council.yml.example +55 -24
- package/dist/agent-src/commands/analyze/inbox.md +22 -1
- package/dist/agent-src/commands/cost/report.md +2 -2
- package/dist/agent-src/commands/explain-run.md +31 -1
- package/dist/agent-src/commands/team/adversarial.md +8 -7
- package/dist/agent-src/commands/team/delegate.md +14 -12
- package/dist/agent-src/commands/team/review.md +9 -8
- package/dist/agent-src/commands/team/status.md +9 -8
- package/dist/agent-src/commands/team.md +29 -19
- package/dist/agent-src/contexts/execution/auto-dispatch-classification.md +88 -2
- package/dist/agent-src/contexts/execution/auto-orchestration-activation.md +29 -21
- package/dist/agent-src/contexts/execution/host-capability-manifest.md +26 -15
- package/dist/agent-src/contexts/execution/orchestration-telemetry.md +22 -1
- package/dist/agent-src/contexts/execution/roadmap-process-loop.md +17 -0
- package/dist/agent-src/contexts/execution/subagent-response-contract.md +65 -1
- package/dist/agent-src/contexts/execution/subagent-routing.md +13 -11
- package/dist/agent-src/contexts/execution/subagent-spawn-contract.md +25 -0
- package/dist/agent-src/contexts/execution/subagent-steering.md +6 -3
- package/dist/agent-src/rules/delegation-policy.md +11 -7
- package/dist/agent-src/rules/preservation-guard.md +9 -0
- package/dist/agent-src/rules/settings-ask-protocol.md +8 -7
- package/dist/agent-src/rules/token-budget-discipline.md +1 -1
- 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/forensics-report/SKILL.md +114 -0
- package/dist/agent-src/skills/forensics-report/evals/triggers.json +17 -0
- package/dist/agent-src/skills/premortem/SKILL.md +30 -1
- package/dist/agent-src/skills/reasoning-orchestrator/SKILL.md +2 -1
- package/dist/agent-src/skills/subagent-orchestration/SKILL.md +17 -5
- package/dist/agent-src/skills/subagent-orchestration/schemas/subagent-status.json +61 -3
- package/dist/agent-src/skills/test-case-discovery/SKILL.md +1 -1
- package/dist/agent-src/templates/AGENTS.md +4 -4
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/agent-src/templates/roadmaps.md +23 -0
- package/dist/cli/registry.js +2 -0
- package/dist/cli/registry.js.map +1 -1
- package/dist/cli-delegate/{chunk-22GCIJ2O.js → chunk-66OVWGB5.js} +1 -1
- package/dist/cli-delegate/{chunk-7OMQCH4V.js → chunk-BTJNRUX3.js} +22 -35
- package/dist/cli-delegate/{chunk-DVRS2KVR.js → chunk-DDUNNFYX.js} +24 -17
- package/dist/cli-delegate/{chunk-PQSAQKHW.js → chunk-EQY4FLJB.js} +3 -3
- package/dist/cli-delegate/{chunk-2BE5I2MN.js → chunk-IYTGIG2Y.js} +155 -91
- package/dist/cli-delegate/{chunk-GB2FSUEE.js → chunk-LCFJIQ4J.js} +32 -0
- package/dist/cli-delegate/{chunk-VSYT7OHO.js → chunk-LVRYBKTI.js} +31 -2
- package/dist/cli-delegate/chunk-MMJL4TIX.js +18 -0
- package/dist/cli-delegate/{chunk-JXARCGVN.js → chunk-MP3JT3IU.js} +1 -1
- 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_analyze_session.js +1 -1
- package/dist/cli-delegate/cmd_brand_status.js +1 -1
- package/dist/cli-delegate/cmd_conformance.js +2803 -137
- package/dist/cli-delegate/cmd_doctor.js +10 -5
- package/dist/cli-delegate/cmd_explain.js +2 -2
- package/dist/cli-delegate/cmd_export.js +4 -4
- package/dist/cli-delegate/cmd_handoff.js +88 -8
- package/dist/cli-delegate/cmd_mcp_available.js +1 -1
- package/dist/cli-delegate/cmd_migrate.js +2 -2
- package/dist/cli-delegate/cmd_packs_active.js +2 -2
- package/dist/cli-delegate/cmd_prune.js +4 -4
- package/dist/cli-delegate/cmd_route_audit.js +3 -2
- package/dist/cli-delegate/cmd_route_explain.js +1 -1
- package/dist/cli-delegate/cmd_session_recycle.js +177 -0
- package/dist/cli-delegate/cmd_settings_get.js +2 -8
- package/dist/cli-delegate/cmd_settings_migrate.js +4 -4
- package/dist/cli-delegate/cmd_settings_set.js +2 -2
- package/dist/cli-delegate/cmd_sync.js +4 -4
- package/dist/cli-delegate/cmd_uninstall.js +4 -4
- package/dist/cli-delegate/cmd_update.js +1 -1
- package/dist/cli-delegate/cmd_upgrade.js +1 -1
- package/dist/cli-delegate/cmd_validate.js +4 -4
- package/dist/cli-delegate/cmd_versions.js +1 -1
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +72 -23
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +3 -2
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +39 -2
- package/dist/discovery/trust-report.md +2 -2
- package/dist/discovery/workspaces.json +12 -4
- package/dist/hooks/dispatch.js +2278 -611
- package/dist/install/install.mjs +56 -17
- package/dist/mcp/registry-manifest.json +3 -3
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +4122 -699
- package/dist/router.json +1 -1
- package/dist/scripts/_lib/agent_settings.js +54 -0
- package/dist/scripts/_lib/agent_settings.js.map +1 -1
- package/dist/scripts/condense.js +147 -2
- package/dist/scripts/condense.js.map +1 -1
- package/dist/server/schemas/settings.js +12 -7
- package/dist/server/schemas/settings.js.map +1 -1
- package/dist/shared/settingsCarveOut.js +0 -9
- package/dist/shared/settingsCarveOut.js.map +1 -1
- package/dist/shared/settingsClasses.js +11 -9
- package/dist/shared/settingsClasses.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 +58 -12
- 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/agent-src/templates/AGENTS.md +4 -4
- package/src/config/agent-settings.template.yml +88 -66
- package/src/config/discovery/packs.yml +11 -0
- package/src/config/discovery/workspaces.yml +1 -1
- package/src/config/dispatch-economy-metrics.json +61 -0
- package/src/config/evaluator-budgets.json +4 -4
- package/src/config/gate-violation-baselines.json +3 -3
- 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 +31 -44
- package/src/scripts/_cli/cmd_doctor.ts +65 -48
- package/src/scripts/_cli/cmd_session_recycle.ts +226 -0
- package/src/scripts/_dispatch.bash +23 -0
- package/src/scripts/_lib/agent_settings.ts +58 -0
- package/src/scripts/_lib/auto_dispatch.corpus.test.ts +1 -1
- package/src/scripts/_lib/auto_dispatch.ts +23 -18
- package/src/scripts/_lib/capsule_trigger.ts +124 -0
- package/src/scripts/_lib/host_capability.ts +63 -6
- package/src/scripts/_lib/judgment_ladder.ts +634 -0
- package/src/scripts/_lib/orchestration_gate.ts +8 -0
- package/src/scripts/_lib/orchestration_record.ts +78 -10
- package/src/scripts/_lib/orchestration_savings.ts +1 -1
- package/src/scripts/_lib/recycle_envelope_paths.ts +35 -0
- package/src/scripts/_lib/self_repair.ts +354 -13
- package/src/scripts/_lib/self_repair_store.ts +28 -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 +341 -0
- package/src/scripts/_lib/subagent_response.ts +94 -0
- package/src/scripts/_lib/subagent_spawn.ts +13 -0
- package/src/scripts/_lib/subagent_steering.ts +9 -4
- package/src/scripts/_lib/tier_budget_routing.ts +6 -1
- package/src/scripts/_lib/worker_budget.ts +37 -2
- package/src/scripts/ai_council/clients.ts +126 -55
- package/src/scripts/ai_council/config.ts +94 -6
- package/src/scripts/ai_council/handoff.ts +113 -0
- package/src/scripts/ai_council/modes.ts +19 -4
- package/src/scripts/ai_council/orchestrator.ts +105 -0
- package/src/scripts/ai_council/quorum.ts +84 -0
- package/src/scripts/ai_council/session.ts +44 -0
- package/src/scripts/ai_council/transport_resolver.ts +127 -1
- package/src/scripts/ai_team/availability.ts +110 -0
- package/src/scripts/ai_team/config.ts +34 -14
- package/src/scripts/ai_team/team_dispatch.ts +71 -29
- package/src/scripts/ask_transport.ts +221 -0
- package/src/scripts/bench_critic_protocol.ts +396 -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 +219 -0
- package/src/scripts/check_gitignore_freshness.ts +42 -8
- package/src/scripts/check_hook_bundle_freshness.ts +24 -1
- 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/condense.ts +162 -2
- package/src/scripts/council_cli.ts +401 -25
- 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/forensics_report.ts +333 -0
- 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 +106 -35
- 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_discovery_vocabulary.ts +2 -0
- package/src/scripts/lint_hook_manifest.ts +82 -0
- package/src/scripts/lint_never_silent.ts +276 -0
- package/src/scripts/lint_no_activation_gates.ts +271 -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 +12 -1
- package/src/scripts/rebuild_hook_bundle.ts +178 -0
- package/src/scripts/release.ts +74 -2
- package/src/scripts/report_conformance_funnel.ts +284 -0
- package/src/scripts/report_imperative_density.ts +1 -0
- package/src/scripts/routing_doctor.ts +118 -36
- package/src/scripts/score_skill_selection.ts +19 -5
- package/src/scripts/self_repair_cli.ts +337 -59
- package/src/scripts/session_eol_report.ts +261 -0
- package/src/scripts/team_events_spike.ts +271 -0
- package/src/scripts/team_review_gate_hook.ts +31 -9
- package/src/server/schemas/settings.ts +24 -17
- package/src/shared/settingsCarveOut.ts +0 -10
- package/src/shared/settingsClasses.ts +11 -9
- package/src/templates/compact-instructions.md +40 -0
- package/dist/ui/assets/index-DmR0mbk4.js +0 -26
- package/dist/ui/assets/index-DmR0mbk4.js.map +0 -1
|
@@ -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.
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Re-read guard + post-edit scope hint — `pre_tool_use` concern
|
|
4
|
+
* (road-to-token-economy-cache Phase 6.1/6.2).
|
|
5
|
+
*
|
|
6
|
+
* Two signals, one session-scoped read ledger:
|
|
7
|
+
*
|
|
8
|
+
* 6.1 A full-file `Read` of a path that was ALREADY fully read this session
|
|
9
|
+
* AND is unchanged since (mtime + size compare against the ledger) gets
|
|
10
|
+
* one advisory line naming the earlier read and the ranged-read
|
|
11
|
+
* alternative. A ranged read (any `offset`/`limit` in the tool input)
|
|
12
|
+
* is already the cheap form and never fires — nor is it recorded as a
|
|
13
|
+
* full read.
|
|
14
|
+
* 6.2 A full-file `Read` immediately following THIS session's own
|
|
15
|
+
* `Edit`/`Write` to the same path (last-write tracked in the same
|
|
16
|
+
* ledger — PreToolUse sees those tool calls too) gets the same advisory
|
|
17
|
+
* shape pointing at a ranged re-read of the edited hunk.
|
|
18
|
+
*
|
|
19
|
+
* ## The ledger — paths and numbers only, never content
|
|
20
|
+
*
|
|
21
|
+
* `<workspace_root>/agents/runtime/state/reread-guard/<sha256(session)>.json`
|
|
22
|
+
* (gitignored — `agents/runtime/` is blanket-ignored), keyed by session id
|
|
23
|
+
* the same way `end_review_nudge_hook.deriveSessionKey` does (hashed, never
|
|
24
|
+
* raw). Per path it stores `{mtime, size, ts}` for reads, a `ts` for the last
|
|
25
|
+
* own write, and a per-path fired flag — NO field can hold file content, by
|
|
26
|
+
* construction (same PII/content-exclusion-by-construction discipline as the
|
|
27
|
+
* telemetry schemas).
|
|
28
|
+
*
|
|
29
|
+
* Population: PreToolUse sees the Read BEFORE it happens, so the ledger is
|
|
30
|
+
* populated in this same hook — after the staleness check, the current
|
|
31
|
+
* `{mtime, size}` of the path is recorded (the read is about to happen;
|
|
32
|
+
* recording pre-read is equivalent for every subsequent comparison).
|
|
33
|
+
*
|
|
34
|
+
* ## ADVISORY ONLY — never a block, never exit 1
|
|
35
|
+
*
|
|
36
|
+
* This concern binds on `pre_tool_use`, the slot that carries the blocking
|
|
37
|
+
* guards — but it is advisory by construction and never returns exit 1: a
|
|
38
|
+
* stale-ledger false positive on a BLOCK would corrupt work (the agent would
|
|
39
|
+
* be denied a read it genuinely needs over a bookkeeping artifact). The
|
|
40
|
+
* escalation decision waits on the 6.4 telemetry, per the roadmap.
|
|
41
|
+
*
|
|
42
|
+
* ## Path exemptions (committed constant — shared list with `edit-shape`)
|
|
43
|
+
*
|
|
44
|
+
* `dist/**`, any path containing `/generated/`, `*.lock`, `*.min.*`, and
|
|
45
|
+
* `agents/runtime/**`. Deliberately DUPLICATED from `edit_shape_hook.ts`
|
|
46
|
+
* (noted there too): the two concerns share the list by convention, and a
|
|
47
|
+
* shared module would couple two independently-rollback-able hooks.
|
|
48
|
+
*
|
|
49
|
+
* ## Delivery — `{decision:"warn", reason, additional_context}` at exit 2
|
|
50
|
+
*
|
|
51
|
+
* Mirrors `delegation_nudge_hook.ts` exactly. WHY EXIT 2 NEVER BLOCKS
|
|
52
|
+
* (copied from that hook's verified trace): `host_semantics.emitFor`'s
|
|
53
|
+
* `severity === "warn"` branch returns `{exit: 0, stdout:
|
|
54
|
+
* claudeAdditionalContext(event, reason)}` UNCONDITIONALLY — it does not
|
|
55
|
+
* consult `CLAUDE_BLOCK_CAPABLE_EVENTS` (that check only gates the `"block"`
|
|
56
|
+
* severity branch). On the verified `claude` platform the host-facing exit is
|
|
57
|
+
* always 0 for a warn verdict; `EXIT_WARN` is dispatcher-internal
|
|
58
|
+
* bookkeeping, never the byte that reaches the host.
|
|
59
|
+
*
|
|
60
|
+
* ROBUSTNESS: malformed payload → exit 0 silently; every fs error swallowed;
|
|
61
|
+
* an unreadable/corrupt ledger resets to empty (worst case: one missed or
|
|
62
|
+
* repeated advisory, never a lost read).
|
|
63
|
+
*/
|
|
64
|
+
import * as crypto from 'node:crypto';
|
|
65
|
+
import * as fs from 'node:fs';
|
|
66
|
+
import * as path from 'node:path';
|
|
67
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
68
|
+
|
|
69
|
+
import { unwrap, type JsonObject, type JsonValue } from './envelope.js';
|
|
70
|
+
import { readHookStdin } from './hook_stdin.js';
|
|
71
|
+
import { atomic_write_json } from './state_io.js';
|
|
72
|
+
|
|
73
|
+
const EXIT_ALLOW = 0;
|
|
74
|
+
// Severity is taken from the EXIT CODE, not from the `decision` field — see
|
|
75
|
+
// the file header for why this never blocks on `claude`. This concern never
|
|
76
|
+
// returns exit 1 on any path.
|
|
77
|
+
const EXIT_WARN = 2;
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Committed exemption list — deliberately duplicated from
|
|
81
|
+
* `edit_shape_hook.ts` (see the header note there and here).
|
|
82
|
+
*/
|
|
83
|
+
export const EXEMPT_PATH_PATTERNS: readonly RegExp[] = [
|
|
84
|
+
/(^|\/)dist\//i, // dist/**
|
|
85
|
+
/\/generated\//i, // any path containing /generated/
|
|
86
|
+
/\.lock$/i, // *.lock
|
|
87
|
+
/\.min\.[^/]+$/i, // *.min.*
|
|
88
|
+
/(^|\/)agents\/runtime\//i, // agents/runtime/**
|
|
89
|
+
];
|
|
90
|
+
|
|
91
|
+
/** Tool names this concern reacts to (manifest `tools:` twin). */
|
|
92
|
+
export const READ_TOOLS: ReadonlySet<string> = new Set(['Read']);
|
|
93
|
+
export const WRITE_TOOLS: ReadonlySet<string> = new Set(['Edit', 'Write']);
|
|
94
|
+
|
|
95
|
+
function isObject(v: unknown): v is JsonObject {
|
|
96
|
+
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function str(v: JsonValue | undefined): string {
|
|
100
|
+
return typeof v === 'string' ? v : '';
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** True when `filePath` (absolute or repo-relative) matches the exemption list. */
|
|
104
|
+
export function isExemptPath(filePath: string): boolean {
|
|
105
|
+
const normalized = filePath.replace(/\\/g, '/');
|
|
106
|
+
return EXEMPT_PATH_PATTERNS.some((re) => re.test(normalized));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Per-path read record — NUMBERS ONLY (mtime ms, byte size, epoch-ms ts). */
|
|
110
|
+
export interface ReadRecord {
|
|
111
|
+
mtime: number;
|
|
112
|
+
size: number;
|
|
113
|
+
ts: number;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** The session ledger — paths as keys, numbers as values, no content field. */
|
|
117
|
+
export interface Ledger {
|
|
118
|
+
reads: Record<string, ReadRecord>;
|
|
119
|
+
/** Last own Edit/Write per path, epoch ms. */
|
|
120
|
+
writes: Record<string, number>;
|
|
121
|
+
/** Per-path once-per-session advisory latch. */
|
|
122
|
+
fired: Record<string, boolean>;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function emptyLedger(): Ledger {
|
|
126
|
+
return { reads: {}, writes: {}, fired: {} };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Hashed session key — mirrors `end_review_nudge_hook.deriveSessionKey`. */
|
|
130
|
+
export function deriveSessionKey(envelope: JsonObject, payload: JsonObject): string {
|
|
131
|
+
const raw =
|
|
132
|
+
str(envelope['session_id'] as JsonValue | undefined) ||
|
|
133
|
+
str((payload['session_id'] ?? payload['sessionId']) as JsonValue | undefined);
|
|
134
|
+
const material = raw || 'unknown-session';
|
|
135
|
+
return crypto.createHash('sha256').update(material).digest('hex');
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function ledgerFile(workspaceRoot: string, sessionKey: string): string {
|
|
139
|
+
return path.join(workspaceRoot, 'agents', 'runtime', 'state', 'reread-guard', `${sessionKey}.json`);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function readLedger(workspaceRoot: string, sessionKey: string): Ledger {
|
|
143
|
+
try {
|
|
144
|
+
const parsed = JSON.parse(fs.readFileSync(ledgerFile(workspaceRoot, sessionKey), 'utf8'));
|
|
145
|
+
if (!isObject(parsed)) return emptyLedger();
|
|
146
|
+
return {
|
|
147
|
+
reads: isObject(parsed['reads']) ? (parsed['reads'] as unknown as Record<string, ReadRecord>) : {},
|
|
148
|
+
writes: isObject(parsed['writes']) ? (parsed['writes'] as Record<string, number>) : {},
|
|
149
|
+
fired: isObject(parsed['fired']) ? (parsed['fired'] as Record<string, boolean>) : {},
|
|
150
|
+
};
|
|
151
|
+
} catch {
|
|
152
|
+
return emptyLedger(); // missing/corrupt ledger → fresh; never a crash
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function writeLedger(workspaceRoot: string, sessionKey: string, ledger: Ledger): void {
|
|
157
|
+
try {
|
|
158
|
+
atomic_write_json(ledgerFile(workspaceRoot, sessionKey), ledger);
|
|
159
|
+
} catch {
|
|
160
|
+
// a state-write failure must never affect the tool call
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function extractToolName(payload: JsonObject): string {
|
|
165
|
+
return str((payload['tool_name'] ?? payload['toolName'] ?? payload['tool']) as JsonValue | undefined);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function extractToolInput(payload: JsonObject): JsonObject {
|
|
169
|
+
const ti = payload['tool_input'] ?? payload['toolInput'] ?? payload['input'];
|
|
170
|
+
return isObject(ti) ? ti : {};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function extractFilePath(toolInput: JsonObject): string {
|
|
174
|
+
return str((toolInput['file_path'] ?? toolInput['path'] ?? toolInput['filePath']) as JsonValue | undefined);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** True when the incoming Read is already narrowed (offset/limit present). */
|
|
178
|
+
export function isRangedRead(toolInput: JsonObject): boolean {
|
|
179
|
+
for (const key of ['offset', 'limit', 'start_line', 'end_line', 'startLine', 'endLine']) {
|
|
180
|
+
const v = toolInput[key];
|
|
181
|
+
if (typeof v === 'number' || (typeof v === 'string' && v !== '')) return true;
|
|
182
|
+
}
|
|
183
|
+
return false;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** 6.1 advisory — duplicate full read of an unchanged, already-read path. */
|
|
187
|
+
export function buildDuplicateReadLine(relPath: string, earlierTs: number): string {
|
|
188
|
+
const when = new Date(earlierTs).toISOString();
|
|
189
|
+
return (
|
|
190
|
+
`re-read guard: ${relPath} was already fully read this session (${when}) and is ` +
|
|
191
|
+
`unchanged since (same mtime+size) — the content is still in context; if you only ` +
|
|
192
|
+
`need part of it, a ranged Read (offset/limit) pays for the range, not the file.`
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** 6.2 advisory — full re-read right after this session's own edit. */
|
|
197
|
+
export function buildPostEditReadLine(relPath: string): string {
|
|
198
|
+
return (
|
|
199
|
+
`re-read guard: this session just edited ${relPath} — a full-file re-read re-pays ` +
|
|
200
|
+
`for content you already have; a ranged Read (offset/limit) over the edited hunk ` +
|
|
201
|
+
`is enough to verify the change.`
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export function main(): number {
|
|
206
|
+
try {
|
|
207
|
+
const [envelope, payload] = unwrap(readHookStdin(), 'claude');
|
|
208
|
+
|
|
209
|
+
const event = str(envelope['event'] as JsonValue | undefined);
|
|
210
|
+
if (event !== '' && event !== 'pre_tool_use') return EXIT_ALLOW;
|
|
211
|
+
|
|
212
|
+
const toolName = extractToolName(payload);
|
|
213
|
+
const isRead = READ_TOOLS.has(toolName);
|
|
214
|
+
const isWrite = WRITE_TOOLS.has(toolName);
|
|
215
|
+
if (!isRead && !isWrite) return EXIT_ALLOW;
|
|
216
|
+
|
|
217
|
+
const toolInput = extractToolInput(payload);
|
|
218
|
+
const filePath = extractFilePath(toolInput);
|
|
219
|
+
if (!filePath) return EXIT_ALLOW;
|
|
220
|
+
|
|
221
|
+
const workspaceRoot = str(envelope['workspace_root'] as JsonValue | undefined).trim() || process.cwd();
|
|
222
|
+
const absPath = path.isAbsolute(filePath) ? filePath : path.resolve(workspaceRoot, filePath);
|
|
223
|
+
const relPath = path.relative(workspaceRoot, absPath);
|
|
224
|
+
if (isExemptPath(relPath) || isExemptPath(absPath)) return EXIT_ALLOW;
|
|
225
|
+
|
|
226
|
+
const sessionKey = deriveSessionKey(envelope, payload);
|
|
227
|
+
const ledger = readLedger(workspaceRoot, sessionKey);
|
|
228
|
+
// Ledger keys are workspace-relative where possible (stable across
|
|
229
|
+
// absolute-vs-relative host payloads), absolute otherwise.
|
|
230
|
+
const key = relPath.startsWith('..') ? absPath : relPath;
|
|
231
|
+
|
|
232
|
+
if (isWrite) {
|
|
233
|
+
// 6.2 bookkeeping: remember this session's own write. The write has
|
|
234
|
+
// not happened yet, but for every later comparison "about to write"
|
|
235
|
+
// and "wrote" are equivalent.
|
|
236
|
+
ledger.writes[key] = Date.now();
|
|
237
|
+
writeLedger(workspaceRoot, sessionKey, ledger);
|
|
238
|
+
return EXIT_ALLOW;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// ── Read path ────────────────────────────────────────────────────
|
|
242
|
+
if (isRangedRead(toolInput)) {
|
|
243
|
+
return EXIT_ALLOW; // already the cheap form; not recorded as a full read
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
let mtime: number;
|
|
247
|
+
let size: number;
|
|
248
|
+
try {
|
|
249
|
+
const st = fs.statSync(absPath);
|
|
250
|
+
mtime = st.mtimeMs;
|
|
251
|
+
size = st.size;
|
|
252
|
+
} catch {
|
|
253
|
+
return EXIT_ALLOW; // unreadable/missing target — nothing to compare
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const prior = ledger.reads[key];
|
|
257
|
+
const ownWrite = ledger.writes[key];
|
|
258
|
+
const alreadyFired = ledger.fired[key] === true;
|
|
259
|
+
|
|
260
|
+
let advisory: string | null = null;
|
|
261
|
+
if (!alreadyFired && ownWrite !== undefined) {
|
|
262
|
+
advisory = buildPostEditReadLine(key); // 6.2 wins when both hold
|
|
263
|
+
} else if (!alreadyFired && prior !== undefined && prior.mtime === mtime && prior.size === size) {
|
|
264
|
+
advisory = buildDuplicateReadLine(key, prior.ts);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Record the read that is about to happen (numbers only), consume the
|
|
268
|
+
// one-shot own-write marker, and latch the per-path fire flag.
|
|
269
|
+
ledger.reads[key] = { mtime, size, ts: Date.now() };
|
|
270
|
+
if (ownWrite !== undefined) delete ledger.writes[key];
|
|
271
|
+
if (advisory !== null) ledger.fired[key] = true;
|
|
272
|
+
writeLedger(workspaceRoot, sessionKey, ledger);
|
|
273
|
+
|
|
274
|
+
if (advisory === null) return EXIT_ALLOW;
|
|
275
|
+
|
|
276
|
+
process.stdout.write(
|
|
277
|
+
`${JSON.stringify({
|
|
278
|
+
decision: 'warn',
|
|
279
|
+
reason: `reread-guard: full-file Read of ${key} the session already paid for`,
|
|
280
|
+
additional_context: advisory,
|
|
281
|
+
})}\n`,
|
|
282
|
+
);
|
|
283
|
+
return EXIT_WARN;
|
|
284
|
+
} catch {
|
|
285
|
+
return EXIT_ALLOW; // malformed payload / any error — never touch the tool call
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Bundle-safety: never auto-run when inlined into an esbuild bundle, where
|
|
290
|
+
// every module shares the bundle's `import.meta.url` (see cmd_migrate.ts).
|
|
291
|
+
declare const __AGENT_CONFIG_BUNDLE__: boolean | undefined;
|
|
292
|
+
function _isCliEntry(): boolean {
|
|
293
|
+
if (typeof __AGENT_CONFIG_BUNDLE__ !== 'undefined' && __AGENT_CONFIG_BUNDLE__) {
|
|
294
|
+
return false;
|
|
295
|
+
}
|
|
296
|
+
if (process.argv[1] === undefined) {
|
|
297
|
+
return false;
|
|
298
|
+
}
|
|
299
|
+
const argvUrl = pathToFileURL(path.resolve(process.argv[1])).href;
|
|
300
|
+
if (import.meta.url === argvUrl) {
|
|
301
|
+
return true;
|
|
302
|
+
}
|
|
303
|
+
try {
|
|
304
|
+
const here = fs.realpathSync(fileURLToPath(import.meta.url));
|
|
305
|
+
const argv = fs.realpathSync(path.resolve(process.argv[1]));
|
|
306
|
+
return here === argv;
|
|
307
|
+
} catch {
|
|
308
|
+
return false;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
if (_isCliEntry()) {
|
|
313
|
+
process.exit(main());
|
|
314
|
+
}
|