@event4u/agent-config 9.27.0 → 9.28.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 +12 -11
- package/CHANGELOG.md +99 -0
- package/README.md +1 -1
- package/dist/agent-src/commands/analyze/conformance.md +24 -0
- package/dist/agent-src/contexts/execution/orchestration-telemetry.md +10 -3
- package/dist/agent-src/rules/artifact-engagement-recording.md +4 -1
- package/dist/agent-src/rules/autonomous-execution.md +1 -1
- package/dist/agent-src/rules/brand-source-of-truth.md +33 -6
- package/dist/agent-src/rules/context-hygiene.md +3 -1
- package/dist/agent-src/rules/council-availability.md +93 -0
- package/dist/agent-src/rules/cross-source-consistency.md +3 -0
- package/dist/agent-src/rules/delegation-policy.md +29 -5
- package/dist/agent-src/rules/design-fidelity.md +3 -0
- package/dist/agent-src/rules/evaluator-independence.md +2 -1
- package/dist/agent-src/rules/external-code-graph-interop.md +2 -1
- package/dist/agent-src/rules/finance-safety-floor.md +1 -1
- package/dist/agent-src/rules/git-history-discipline.md +1 -1
- package/dist/agent-src/rules/history-discipline.md +3 -1
- package/dist/agent-src/rules/legal-safety-floor.md +1 -1
- package/dist/agent-src/rules/no-pr-progress-comments.md +3 -1
- package/dist/agent-src/rules/onboarding-gate.md +3 -1
- package/dist/agent-src/rules/roadmap-ci-steps-policy.md +4 -1
- package/dist/agent-src/rules/role-mode-adherence.md +1 -1
- package/dist/agent-src/rules/scale-discipline.md +3 -1
- package/dist/agent-src/rules/self-repair-loop.md +103 -0
- package/dist/agent-src/rules/session-canary.md +2 -1
- package/dist/agent-src/rules/strategy-safety-floor.md +1 -1
- package/dist/agent-src/rules/token-budget-discipline.md +3 -1
- package/dist/agent-src/rules/tool-safety.md +5 -1
- package/dist/agent-src/skills/adversarial-review/SKILL.md +1 -1
- package/dist/agent-src/skills/analysis-autonomous-mode/SKILL.md +1 -1
- package/dist/agent-src/skills/performance-analysis/SKILL.md +1 -1
- package/dist/agent-src/skills/persona-improvement/SKILL.md +1 -1
- package/dist/agent-src/skills/project-analyzer/SKILL.md +1 -1
- package/dist/agent-src/skills/security-audit/SKILL.md +1 -1
- package/dist/agent-src/skills/sequential-thinking/SKILL.md +1 -1
- package/dist/agent-src/skills/skill-improvement-pipeline/SKILL.md +1 -1
- package/dist/agent-src/skills/universal-project-analysis/SKILL.md +1 -1
- package/dist/agent-src/templates/AGENTS.md +6 -10
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/cli/registry.js +7 -0
- package/dist/cli/registry.js.map +1 -1
- package/dist/cli-delegate/chunk-22GCIJ2O.js +270 -0
- package/dist/cli-delegate/{chunk-BCPIOAGV.js → chunk-2BE5I2MN.js} +8 -8
- package/dist/cli-delegate/{chunk-AIIUU73N.js → chunk-7OMQCH4V.js} +1 -1
- package/dist/cli-delegate/{chunk-HJ6DAJHR.js → chunk-GB2FSUEE.js} +57 -7
- package/dist/cli-delegate/{chunk-WR6GTRHJ.js → chunk-JXARCGVN.js} +3 -3
- package/dist/cli-delegate/{chunk-3KJIMFTB.js → chunk-PQSAQKHW.js} +466 -304
- package/dist/cli-delegate/chunk-REKCMYOU.js +2991 -0
- package/dist/cli-delegate/cmd_analyze_session.js +1 -1
- package/dist/cli-delegate/cmd_brand_status.js +134 -0
- package/dist/cli-delegate/cmd_conformance.js +7 -7
- package/dist/cli-delegate/cmd_converge.js +3 -3
- package/dist/cli-delegate/cmd_doctor.js +4 -4
- package/dist/cli-delegate/cmd_explain.js +80 -330
- package/dist/cli-delegate/cmd_export.js +5 -5
- package/dist/cli-delegate/cmd_fleet.js +3 -3
- package/dist/cli-delegate/cmd_handoff.js +2 -2
- package/dist/cli-delegate/cmd_mcp_available.js +433 -0
- package/dist/cli-delegate/cmd_migrate.js +3 -3
- package/dist/cli-delegate/cmd_packs_active.js +153 -0
- package/dist/cli-delegate/cmd_preflight.js +1 -1
- package/dist/cli-delegate/cmd_prune.js +5 -5
- package/dist/cli-delegate/cmd_route_audit.js +8 -8
- package/dist/cli-delegate/cmd_route_explain.js +4 -4
- package/dist/cli-delegate/cmd_settings_get.js +272 -0
- package/dist/cli-delegate/cmd_settings_migrate.js +5 -5
- package/dist/cli-delegate/cmd_settings_set.js +7 -2967
- package/dist/cli-delegate/cmd_sync.js +5 -5
- package/dist/cli-delegate/cmd_uninstall.js +5 -5
- package/dist/cli-delegate/cmd_update.js +4 -4
- package/dist/cli-delegate/cmd_upgrade.js +4 -4
- package/dist/cli-delegate/cmd_validate.js +5 -5
- package/dist/cli-delegate/cmd_versions.js +4 -4
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +100 -40
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +3 -3
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +7 -5
- package/dist/discovery/trust-report.md +11 -11
- package/dist/discovery/workspaces.json +6 -4
- package/dist/hooks/dispatch.js +9004 -6132
- package/dist/install/install.mjs +697 -507
- package/dist/mcp/registry-manifest.json +3 -3
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +127 -20
- package/dist/router.json +1 -1
- package/dist/scripts/_lib/agent_settings.js +80 -15
- package/dist/scripts/_lib/agent_settings.js.map +1 -1
- package/docs/contracts/settings-classes.md +29 -8
- package/docs/guidelines/agent-infra/memory-access.md +14 -3
- package/package.json +1 -1
- package/src/agent-src/templates/AGENTS.md +6 -10
- package/src/config/budgets.yml +29 -0
- package/src/config/evaluator-budgets.json +4 -4
- package/src/config/gate-coverage.yml +26 -0
- package/src/scripts/_cli/cmd_brand_status.ts +174 -0
- package/src/scripts/_cli/cmd_mcp_available.ts +266 -0
- package/src/scripts/_cli/cmd_packs_active.ts +225 -0
- package/src/scripts/_cli/cmd_settings_get.ts +324 -0
- package/src/scripts/_dispatch.bash +57 -0
- package/src/scripts/_lib/agent_settings.ts +82 -16
- package/src/scripts/_lib/carrier_divergence.ts +135 -0
- package/src/scripts/_lib/host_capability.ts +84 -1
- package/src/scripts/_lib/prompt_shape.ts +88 -0
- package/src/scripts/_lib/review_skipped_record.ts +102 -0
- package/src/scripts/_lib/rule_layer_overlap.ts +254 -0
- package/src/scripts/_lib/self_repair.ts +342 -0
- package/src/scripts/_lib/self_repair_store.ts +100 -0
- package/src/scripts/_lib/transcript_entry.ts +80 -0
- package/src/scripts/check_branch_freshness.ts +255 -0
- package/src/scripts/check_council_config_location.ts +125 -1
- package/src/scripts/check_standing_rule_delivery.ts +314 -0
- package/src/scripts/config/profiles.ts +13 -3
- package/src/scripts/conformance_scan.ts +272 -24
- package/src/scripts/council_availability_hook.ts +188 -0
- package/src/scripts/council_cli.ts +126 -7
- package/src/scripts/hook_manifest.yaml +107 -26
- package/src/scripts/hooks/block_no_verify.ts +39 -2
- package/src/scripts/hooks/block_unauthorized_git.ts +156 -7
- package/src/scripts/hooks/concern_registry.ts +10 -0
- package/src/scripts/hooks/delegation_nudge_hook.ts +450 -0
- package/src/scripts/hooks/end_review_nudge_hook.ts +726 -0
- package/src/scripts/hooks/orchestration_record_hook.ts +342 -0
- package/src/scripts/install.ts +151 -0
- package/src/scripts/language_mirror_hook.ts +30 -37
- package/src/scripts/measure_scope_dedup.ts +19 -13
- package/src/scripts/onboarding_gate_hook.ts +40 -1
- package/src/scripts/orchestration_record.ts +4 -1
- package/src/scripts/preamble_byte_census.ts +9 -20
- package/src/scripts/report_carrier_divergence.ts +364 -0
- package/src/scripts/report_skill_activation.ts +257 -0
- package/src/scripts/report_skill_obligation_violations.ts +579 -0
- package/src/scripts/routing_doctor.ts +77 -6
- package/src/scripts/self_repair_cli.ts +240 -0
- package/src/scripts/self_repair_hook.ts +241 -0
|
@@ -0,0 +1,726 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* End-review nudge — `stop` concern
|
|
4
|
+
* (road-to-orchestrator-discipline-carriers Phase 5, F4-lite).
|
|
5
|
+
*
|
|
6
|
+
* Five measured session transcripts found ZERO neutral end-of-task reviews —
|
|
7
|
+
* every session self-attested "CI green, ready for review". This concern
|
|
8
|
+
* fires the advisory obligation at the exact moment a mutating session ends
|
|
9
|
+
* without one, and counts the skip deterministically (hook-carried, not
|
|
10
|
+
* model-carried) so a future blocking decision has a measured distribution to
|
|
11
|
+
* cite. That decision — its threshold AND the stop-slot delivery question
|
|
12
|
+
* underneath it — is open and maintainer-owned; it is recorded in
|
|
13
|
+
* `agents/settings/contexts/orchestrator-carriers-open-decisions.md` § 1.
|
|
14
|
+
* It used to be cited here as blocker `f4-full-blocking-decision`, which
|
|
15
|
+
* stopped resolving the moment its roadmap was archived.
|
|
16
|
+
*
|
|
17
|
+
* Fires ONLY when BOTH hold:
|
|
18
|
+
* (a) the session mutated tracked, non-doc files beyond
|
|
19
|
+
* `MUTATION_LINE_THRESHOLD` (default 50) changed lines, AND
|
|
20
|
+
* (b) no reviewer ran this session (no Agent/Task dispatch whose prompt is
|
|
21
|
+
* review/judge-shaped, and no review/judge-skill invocation observed
|
|
22
|
+
* in the transcript).
|
|
23
|
+
*
|
|
24
|
+
* ## Diff source — chosen, not assumed
|
|
25
|
+
*
|
|
26
|
+
* `git diff --numstat HEAD` at `workspace_root`: the working tree vs the
|
|
27
|
+
* last commit, TRACKED files only, PLUS every untracked non-doc file
|
|
28
|
+
* (`git ls-files --others --exclude-standard`, one `git diff --numstat
|
|
29
|
+
* --no-index /dev/null <file>` per file — see "Untracked files" below;
|
|
30
|
+
* fixes a review finding, F6, that a brand-new file was previously invisible
|
|
31
|
+
* to this count entirely). Chosen over a session-start SHA/timestamp
|
|
32
|
+
* baseline because no such baseline exists anywhere a `stop` concern can
|
|
33
|
+
* read it (recording one would be new session-wide state, out of this
|
|
34
|
+
* roadmap phase's one-concern scope). Two consequences, stated rather than
|
|
35
|
+
* hidden:
|
|
36
|
+
* - Uncommitted changes that PREDATE this session (a dirty working tree
|
|
37
|
+
* the user already had) are counted as if they were this session's
|
|
38
|
+
* mutation. Per `commit-policy`, an agent almost never commits without
|
|
39
|
+
* explicit permission, so "uncommitted since HEAD" is usually a good
|
|
40
|
+
* proxy for "mutated this session" in practice — but it is a proxy, not
|
|
41
|
+
* an exact measure.
|
|
42
|
+
* - A session that DID commit mid-turn (one of `commit-policy`'s four
|
|
43
|
+
* exceptions) moves HEAD, so mutations already committed are invisible
|
|
44
|
+
* to this diff. Declared, not silently assumed away.
|
|
45
|
+
* - No git repo, no HEAD commit yet, or `git` unavailable → the diff read
|
|
46
|
+
* fails closed to `0` lines (silent), never a crash.
|
|
47
|
+
*
|
|
48
|
+
* ### Untracked files (F6)
|
|
49
|
+
*
|
|
50
|
+
* `git diff --numstat HEAD` diffs the INDEX/HEAD against the working tree —
|
|
51
|
+
* it is blind to a file that was never `git add`-ed at all, so a session
|
|
52
|
+
* that authored a brand-new 300-line module was previously measured as a
|
|
53
|
+
* 0-line mutation. `untrackedNonDocFiles` lists the candidates
|
|
54
|
+
* (`git ls-files --others --exclude-standard`, filtered by the same
|
|
55
|
+
* `isDocPath` carve-out); `untrackedFileLineCount` sizes each one via
|
|
56
|
+
* `git diff --no-index /dev/null <file>` (the only way to diff a path git's
|
|
57
|
+
* index has never seen). Past `UNTRACKED_FILE_CAP` untracked non-doc files
|
|
58
|
+
* this hook refuses to spawn one subprocess per file — an unbounded loop is
|
|
59
|
+
* worse than an imprecise answer — and instead reports a line count
|
|
60
|
+
* GUARANTEED to be over `MUTATION_LINE_THRESHOLD`: "many new files this
|
|
61
|
+
* session" is itself sufficient signal to fire without the exact count.
|
|
62
|
+
*
|
|
63
|
+
* ## Reviewer detection — a heuristic over the transcript, honestly scoped
|
|
64
|
+
*
|
|
65
|
+
* `payload.transcript_path` (present on Claude Code / Cowork stop events,
|
|
66
|
+
* per `chat_history.ts`'s `_extract_cursor_text` / `_extract_gemini_text`
|
|
67
|
+
* sibling readers) is scanned for an assistant `tool_use` block that is
|
|
68
|
+
* either:
|
|
69
|
+
* - an Agent/Task dispatch whose description/subagent_type text looks
|
|
70
|
+
* review- or judge-shaped (matched against the broader
|
|
71
|
+
* `REVIEW_SHAPED_LABEL_RE` — a short, deliberately-named field, so a
|
|
72
|
+
* bare `review`/`judge` keyword IS itself the signal), or its free-form
|
|
73
|
+
* `prompt` text matches the NARROWER `REVIEW_SHAPED_PROMPT_RE` (F15,
|
|
74
|
+
* review: the broader pattern false-positived on ordinary prose like
|
|
75
|
+
* "update the review docs" when it leaked into prompt text — a bare
|
|
76
|
+
* `\breview\b` is too weak a signal once the field can hold ANY prose
|
|
77
|
+
* an author writes, so the prompt-text pattern requires a more specific
|
|
78
|
+
* phrase: "code review", "neutral review", "adversarial", "judge",
|
|
79
|
+
* "critique", "second opinion", "blind pass/review", "cross-model" —
|
|
80
|
+
* each names the SHAPE of a review rather than merely mentioning the
|
|
81
|
+
* word), or
|
|
82
|
+
* - a Skill invocation naming a review/judge skill
|
|
83
|
+
* (`review-changes`, `judge-*`, `adversarial-review`, `code-review`).
|
|
84
|
+
*
|
|
85
|
+
* What this does NOT observe, stated per `security-sensitive-stop` /
|
|
86
|
+
* `untrusted-input-defense`'s honesty convention rather than left implicit:
|
|
87
|
+
* - A review narrated in prose with no tool call is invisible.
|
|
88
|
+
* - No `transcript_path` on the envelope (a platform this hook is not
|
|
89
|
+
* wired to, or a stripped payload) means reviewer activity CANNOT be
|
|
90
|
+
* confirmed — this hook resolves that ambiguity toward firing the
|
|
91
|
+
* advisory (favours recall: a spurious one-line nudge is cheap, a
|
|
92
|
+
* silently missed no-review session is the defect this concern exists
|
|
93
|
+
* to catch).
|
|
94
|
+
*
|
|
95
|
+
* ### Transcript-path hardening (gate finding fd47df62)
|
|
96
|
+
*
|
|
97
|
+
* `payload.transcript_path` is HOST-SUPPLIED, untrusted input, and this
|
|
98
|
+
* concern would otherwise `fs.readFileSync` it unconditionally. Before any
|
|
99
|
+
* read, `isSafeTranscriptPath` requires ALL of: (a) the path ends in
|
|
100
|
+
* `.jsonl` — the one extension a real transcript ever carries; (b) the path
|
|
101
|
+
* RESOLVES (`fs.realpathSync`, following any symlink) to somewhere under
|
|
102
|
+
* `os.homedir()` — real transcripts live under `~/.claude/projects/**`
|
|
103
|
+
* (`cache_realization_report.ts`, `mine_session.ts`, `orchestration_backfill.ts`
|
|
104
|
+
* all hard-code that root), never under `workspace_root`; a `workspace_root`
|
|
105
|
+
* prefix check would reject EVERY legitimate transcript and make this
|
|
106
|
+
* concern fire on every qualifying Stop instead of only when a reviewer
|
|
107
|
+
* genuinely didn't run; (c) the resolved file's size
|
|
108
|
+
* (`fs.statSync`) is at or under `TRANSCRIPT_SCAN_MAX_BYTES` (50 MB) — past
|
|
109
|
+
* that the scan is skipped rather than reading an unbounded file into
|
|
110
|
+
* memory. ANY validation failure resolves the SAME way an unreadable
|
|
111
|
+
* transcript already resolved pre-hardening: the scan is skipped and
|
|
112
|
+
* `reviewerRan` stays `false` — i.e. it favours firing, exactly the recall
|
|
113
|
+
* bias stated above for the missing-`transcript_path` case, never a crash
|
|
114
|
+
* and never a silent "reviewer ran" false positive.
|
|
115
|
+
*
|
|
116
|
+
* A TOCTOU/symlink race between the `realpathSync` check and the later
|
|
117
|
+
* `readFileSync` call exists in principle, but opens no new channel: the
|
|
118
|
+
* once-per-session marker derived from this same path
|
|
119
|
+
* (`deriveSessionKey`) hashes the path STRING, never reads or emits file
|
|
120
|
+
* content, so the worst case a race could still produce is the same 1-bit
|
|
121
|
+
* fire/no-fire outcome this concern already exposes by design — never a
|
|
122
|
+
* content leak.
|
|
123
|
+
*
|
|
124
|
+
* ## Once-per-session fire gate (F2) — and the cost ordering that protects it (F12)
|
|
125
|
+
*
|
|
126
|
+
* Every `stop` event re-evaluates the SAME session's diff and transcript, so
|
|
127
|
+
* without a fire gate this concern injected the advisory (and appended a
|
|
128
|
+
* `review_skipped` telemetry line) on EVERY qualifying Stop in a long
|
|
129
|
+
* session — a "session mutated N lines without review" nudge is a
|
|
130
|
+
* session-scope fact, not a per-turn one, and repeating it past the first
|
|
131
|
+
* fire is exactly the per-turn-canary shape `delegation-nudge`'s own header
|
|
132
|
+
* warns against. A marker file under
|
|
133
|
+
* `<workspace_root>/agents/runtime/state/end-review-nudge/<key>.json`
|
|
134
|
+
* (gitignored — `agents/runtime/` is a blanket-ignored path) records that
|
|
135
|
+
* THIS session already fired; a later Stop in the same session sees the
|
|
136
|
+
* marker and returns silently, without re-running the transcript scan.
|
|
137
|
+
*
|
|
138
|
+
* `<key>` is a SHA-256 of `envelope.session_id` (present on every
|
|
139
|
+
* dispatcher-built envelope, `envelope.ts`), falling back to
|
|
140
|
+
* `payload.transcript_path` for a legacy/raw-payload invocation that
|
|
141
|
+
* carries no `session_id` at all — hashed, never the raw value, so the
|
|
142
|
+
* state filename never leaks a real transcript path (mirrors the
|
|
143
|
+
* PRIVACY-BY-CONSTRUCTION discipline `review_skipped_record.ts` already
|
|
144
|
+
* states for the telemetry line itself: the fire-gate marker carries a
|
|
145
|
+
* timestamp and nothing else that could identify a session by content).
|
|
146
|
+
*
|
|
147
|
+
* ORDERING (F12, review): the once-per-session check runs AFTER the cheap
|
|
148
|
+
* `git diff --numstat` mutation check but BEFORE the expensive transcript
|
|
149
|
+
* read + line-by-line JSON parse. A session that already fired stops at a
|
|
150
|
+
* single `fs.existsSync` call — it never re-reads a (potentially large)
|
|
151
|
+
* transcript file it already knows the answer for.
|
|
152
|
+
*
|
|
153
|
+
* DELIVERY PATH — fixed to mirror `language_mirror_hook.ts` (found while
|
|
154
|
+
* verifying this concern end-to-end; the original plain-text
|
|
155
|
+
* `process.stdout.write(buildAdvisoryLine(...))` at exit 0 below is
|
|
156
|
+
* superseded). Plain text on stdout at exit 0 never reached the model on ANY
|
|
157
|
+
* platform: `dispatch_hook._parse_concern_stdout` fails to `JSON.parse` it
|
|
158
|
+
* and returns `{_raw_stdout: …}`, which carries neither a `reason` nor an
|
|
159
|
+
* `additional_context` key, so the concern's message text is silently
|
|
160
|
+
* dropped before `host_semantics.emitFor` is even called — independent of
|
|
161
|
+
* severity or platform. This concern now reports
|
|
162
|
+
* `{decision:"warn", reason, additional_context}` at exit `EXIT_WARN` (2),
|
|
163
|
+
* the one shape `dispatch_hook.ts` forwards into
|
|
164
|
+
* `hookSpecificOutput.additionalContext` on the verified `claude` platform.
|
|
165
|
+
*
|
|
166
|
+
* WHY EXIT 2 NEVER BLOCKS (verified, not assumed). `host_semantics.emitFor`'s
|
|
167
|
+
* `severity === "warn"` branch returns `{exit: 0, stdout:
|
|
168
|
+
* claudeAdditionalContext(event, reason)}` UNCONDITIONALLY — it does not
|
|
169
|
+
* consult `CLAUDE_BLOCK_CAPABLE_EVENTS` at all (that set only gates the
|
|
170
|
+
* `"block"` severity branch, which this concern never reports). So on
|
|
171
|
+
* `claude` the real process exit handed back to the host is always 0 for a
|
|
172
|
+
* warn verdict on `stop`, exactly as it is on `user_prompt_submit` for
|
|
173
|
+
* `language_mirror_hook.ts` — the shipped concern this pattern is copied
|
|
174
|
+
* from, which returns the identical exit code on effectively every prompt
|
|
175
|
+
* carrying language markers without ever blocking a turn in production.
|
|
176
|
+
*
|
|
177
|
+
* `team_review_gate_hook.ts` — the other existing `stop` concern — was
|
|
178
|
+
* checked as a candidate second precedent and found to diverge in a way that
|
|
179
|
+
* is NOT worth following: it writes a plain-text `notice` at exit 0
|
|
180
|
+
* (`src/scripts/team_review_gate_hook.ts`), which the same
|
|
181
|
+
* `_parse_concern_stdout` / `severity==="allow"` discard applies to — its
|
|
182
|
+
* own advisory notice is, by the same trace, never delivered to the model on
|
|
183
|
+
* `claude` either. That is a pre-existing gap in a different concern, out of
|
|
184
|
+
* this task's scope to fix; this concern follows the ONE pattern verified to
|
|
185
|
+
* actually deliver (`language_mirror_hook.ts`), not the one that shares this
|
|
186
|
+
* concern's `stop` slot but does not deliver.
|
|
187
|
+
*
|
|
188
|
+
* PLATFORM SCOPE — bound only on `claude` in the manifest.
|
|
189
|
+
* `host_semantics.VERIFIED_PLATFORMS` covers only `claude` (the "never
|
|
190
|
+
* blocks" proof above). `cowork` is DELIBERATELY NOT bound (F10, review) —
|
|
191
|
+
* `scripts/hooks/cowork-dispatcher.sh` discards the dispatcher's exit code
|
|
192
|
+
* and stdout unconditionally (`>/dev/null 2>&1 || true; exit 0`), and this
|
|
193
|
+
* concern's entire delivery depends on `additional_context` reaching the
|
|
194
|
+
* model via stdout at the verified exit-2 warn path — a delivery that
|
|
195
|
+
* trampoline makes structurally impossible. The payload shape was never the
|
|
196
|
+
* blocker here: that same trampoline's own header comment states Cowork's
|
|
197
|
+
* `Stop` event does carry `transcript_path`, matching what this concern
|
|
198
|
+
* reads — the exit/stdout discard is what makes binding it dead weight, not
|
|
199
|
+
* a missing field. Cursor / Cline / Windsurf / Gemini are deliberately NOT
|
|
200
|
+
* added: none was inspected for the same discard property, their `stop`
|
|
201
|
+
* payload shape for `transcript_path` is undocumented here, and extending an
|
|
202
|
+
* exit-2 warn onto an unverified propagation path is exactly the
|
|
203
|
+
* speculative mapping `host_semantics.ts` exists to avoid.
|
|
204
|
+
*
|
|
205
|
+
* CONTRACT: never blocks THE ACTUAL TURN. The DISPATCHER-INTERNAL exit is 2
|
|
206
|
+
* on a fire (never 1/BLOCK, never >=3), `fail_closed: false`; the HOST-FACING
|
|
207
|
+
* exit on `claude` is 0 either way, per the proof above. Doc-only
|
|
208
|
+
* diffs, a reviewer having run, or any unreadable/malformed input all
|
|
209
|
+
* resolve to silence (dispatcher-internal exit 0), not a crash.
|
|
210
|
+
*/
|
|
211
|
+
import { spawnSync } from 'node:child_process';
|
|
212
|
+
import * as crypto from 'node:crypto';
|
|
213
|
+
import * as fs from 'node:fs';
|
|
214
|
+
import * as os from 'node:os';
|
|
215
|
+
import * as path from 'node:path';
|
|
216
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
217
|
+
|
|
218
|
+
import { buildReviewSkippedLine, type MutationMeasure } from '../_lib/review_skipped_record.js';
|
|
219
|
+
import { unwrap, type JsonObject, type JsonValue } from './envelope.js';
|
|
220
|
+
import { readHookStdin } from './hook_stdin.js';
|
|
221
|
+
import { atomic_write_json, is_replay_mode } from './state_io.js';
|
|
222
|
+
|
|
223
|
+
/** Default fire threshold — the council's high-risk shape (roadmap 5.1). */
|
|
224
|
+
export const MUTATION_LINE_THRESHOLD = 50;
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Untracked non-doc files past which this hook refuses to spawn one
|
|
228
|
+
* `git diff --no-index` subprocess per file and instead reports the
|
|
229
|
+
* mutation as certainly over threshold (F6, review — see the file header's
|
|
230
|
+
* "Untracked files" section).
|
|
231
|
+
*/
|
|
232
|
+
export const UNTRACKED_FILE_CAP = 20;
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Transcript-scan size cap (gate finding fd47df62 — see the file header's
|
|
236
|
+
* "Transcript-path hardening" section). Past this many bytes,
|
|
237
|
+
* `isSafeTranscriptPath` refuses the path and the scan is skipped.
|
|
238
|
+
*/
|
|
239
|
+
export const TRANSCRIPT_SCAN_MAX_BYTES = 50 * 1024 * 1024; // 50 MB
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Validate a `transcript_path` BEFORE any `fs.readFileSync` of it (gate
|
|
243
|
+
* finding fd47df62). All three checks must pass:
|
|
244
|
+
* (a) the path ends in `.jsonl`;
|
|
245
|
+
* (b) it RESOLVES (`fs.realpathSync`, following any symlink) to a
|
|
246
|
+
* location under `homeDir` (defaults to `os.homedir()` — injectable
|
|
247
|
+
* for tests; production callers never override it — real transcripts
|
|
248
|
+
* live under `~/.claude/projects/**`, never under `workspace_root`,
|
|
249
|
+
* see the file header for why the prefix check is home, not
|
|
250
|
+
* workspace);
|
|
251
|
+
* (c) its resolved size is at or under `maxBytes` (defaults to
|
|
252
|
+
* `TRANSCRIPT_SCAN_MAX_BYTES` — injectable for tests only).
|
|
253
|
+
*
|
|
254
|
+
* Never throws — a missing file, a broken symlink, or any `fs` error all
|
|
255
|
+
* resolve to `false` (refuse), matching this hook's fail-open-to-firing
|
|
256
|
+
* contract for every other unreadable-transcript case.
|
|
257
|
+
*/
|
|
258
|
+
export function isSafeTranscriptPath(
|
|
259
|
+
rawPath: string,
|
|
260
|
+
opts: { homeDir?: string; maxBytes?: number } = {},
|
|
261
|
+
): boolean {
|
|
262
|
+
if (!rawPath || !/\.jsonl$/i.test(rawPath)) return false;
|
|
263
|
+
const homeDir = opts.homeDir ?? os.homedir();
|
|
264
|
+
const maxBytes = opts.maxBytes ?? TRANSCRIPT_SCAN_MAX_BYTES;
|
|
265
|
+
try {
|
|
266
|
+
const realHome = fs.realpathSync(homeDir);
|
|
267
|
+
const realPath = fs.realpathSync(rawPath);
|
|
268
|
+
const relative = path.relative(realHome, realPath);
|
|
269
|
+
if (relative === '' || relative.startsWith('..') || path.isAbsolute(relative)) {
|
|
270
|
+
return false;
|
|
271
|
+
}
|
|
272
|
+
const { size } = fs.statSync(realPath);
|
|
273
|
+
return size <= maxBytes;
|
|
274
|
+
} catch {
|
|
275
|
+
return false;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Severity is taken from the EXIT CODE, not from the `decision` field in the
|
|
280
|
+
// stdout payload — mirrors `language_mirror_hook.ts`. A warn verdict is
|
|
281
|
+
// reported at exit 2 so `host_semantics.emitFor` reduces it to severity
|
|
282
|
+
// `"warn"` and forwards `additional_context`; see the file header for why
|
|
283
|
+
// this exit code never actually blocks the turn on `claude`.
|
|
284
|
+
const EXIT_WARN = 2;
|
|
285
|
+
|
|
286
|
+
/** One `git diff --numstat` row: added/deleted line counts + the path. */
|
|
287
|
+
export interface NumstatRow {
|
|
288
|
+
added: number;
|
|
289
|
+
deleted: number;
|
|
290
|
+
path: string;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Parse `git diff --numstat` output. Binary files print `-\t-\tpath` — those
|
|
295
|
+
* rows count as 0 lines (there is no line-level diff to weigh).
|
|
296
|
+
*/
|
|
297
|
+
export function parseNumstat(raw: string): NumstatRow[] {
|
|
298
|
+
const rows: NumstatRow[] = [];
|
|
299
|
+
for (const line of raw.split('\n')) {
|
|
300
|
+
const m = /^(\d+|-)\t(\d+|-)\t(.+)$/.exec(line);
|
|
301
|
+
if (!m) continue;
|
|
302
|
+
const addedStr = m[1] as string;
|
|
303
|
+
const deletedStr = m[2] as string;
|
|
304
|
+
rows.push({
|
|
305
|
+
added: addedStr === '-' ? 0 : Number(addedStr),
|
|
306
|
+
deleted: deletedStr === '-' ? 0 : Number(deletedStr),
|
|
307
|
+
path: m[3] as string,
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
return rows;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const DOC_PATH_RE = /\.md$/i;
|
|
314
|
+
|
|
315
|
+
/** True for the "outside `*.md`/docs" carve-out the roadmap's threshold names. */
|
|
316
|
+
export function isDocPath(p: string): boolean {
|
|
317
|
+
return DOC_PATH_RE.test(p);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** Sum of added+deleted lines across every NON-doc row. */
|
|
321
|
+
export function nonDocMutatedLines(rows: readonly NumstatRow[]): number {
|
|
322
|
+
return rows
|
|
323
|
+
.filter((r) => !isDocPath(r.path))
|
|
324
|
+
.reduce((sum, r) => sum + r.added + r.deleted, 0);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* `git diff --numstat HEAD` at `cwd`. Fails closed to `[]` (never throws) —
|
|
329
|
+
* no repo, no HEAD, or `git` missing all read as "nothing to measure".
|
|
330
|
+
*/
|
|
331
|
+
export function gitNumstatRows(cwd: string): NumstatRow[] {
|
|
332
|
+
try {
|
|
333
|
+
const r = spawnSync('git', ['diff', '--numstat', 'HEAD'], {
|
|
334
|
+
cwd,
|
|
335
|
+
encoding: 'utf8',
|
|
336
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
337
|
+
});
|
|
338
|
+
if (r.status !== 0) return [];
|
|
339
|
+
return parseNumstat(r.stdout ?? '');
|
|
340
|
+
} catch {
|
|
341
|
+
return [];
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Untracked, non-doc files at `cwd` (F6, review). `git diff --numstat HEAD`
|
|
347
|
+
* only ever diffs paths git's index already knows about — a brand-new file
|
|
348
|
+
* this session created is invisible to it. Fails closed to `[]` (never
|
|
349
|
+
* throws), matching `gitNumstatRows`'s own contract.
|
|
350
|
+
*/
|
|
351
|
+
export function untrackedNonDocFiles(cwd: string): string[] {
|
|
352
|
+
try {
|
|
353
|
+
const r = spawnSync('git', ['ls-files', '--others', '--exclude-standard'], {
|
|
354
|
+
cwd,
|
|
355
|
+
encoding: 'utf8',
|
|
356
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
357
|
+
});
|
|
358
|
+
if (r.status !== 0) return [];
|
|
359
|
+
return (r.stdout ?? '')
|
|
360
|
+
.split('\n')
|
|
361
|
+
.map((l) => l.trim())
|
|
362
|
+
.filter((l) => l.length > 0 && !isDocPath(l));
|
|
363
|
+
} catch {
|
|
364
|
+
return [];
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Added+deleted line count for ONE untracked file, via
|
|
370
|
+
* `git diff --numstat --no-index /dev/null <relPath>` — the only way to
|
|
371
|
+
* diff a path git's index has never seen. `--no-index` exits `1` whenever a
|
|
372
|
+
* diff exists (by design, unlike `gitNumstatRows`'s tracked-diff contract,
|
|
373
|
+
* where a nonzero status means failure) — only the parsed numstat rows
|
|
374
|
+
* matter here, never the exit code. Fails closed to `0`, never throws.
|
|
375
|
+
*/
|
|
376
|
+
export function untrackedFileLineCount(cwd: string, relPath: string): number {
|
|
377
|
+
try {
|
|
378
|
+
const r = spawnSync('git', ['diff', '--numstat', '--no-index', '/dev/null', relPath], {
|
|
379
|
+
cwd,
|
|
380
|
+
encoding: 'utf8',
|
|
381
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
382
|
+
});
|
|
383
|
+
return parseNumstat(r.stdout ?? '').reduce((sum, row) => sum + row.added + row.deleted, 0);
|
|
384
|
+
} catch {
|
|
385
|
+
return 0;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** Result of {@link totalNonDocMutatedLinesWithMeasure}. */
|
|
390
|
+
export interface MutationMeasurement {
|
|
391
|
+
lines: number;
|
|
392
|
+
/** Whether `lines` is exact, or a capped, guaranteed-over-threshold
|
|
393
|
+
* approximation (`UNTRACKED_FILE_CAP` path) — see that function. */
|
|
394
|
+
measure: MutationMeasure;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Total non-doc mutated lines across BOTH tracked (`git diff --numstat
|
|
399
|
+
* HEAD`) and untracked (`git ls-files --others --exclude-standard`) files —
|
|
400
|
+
* see the file header's "Untracked files (F6)" section for why both sources
|
|
401
|
+
* are required. Past `UNTRACKED_FILE_CAP` untracked non-doc files this
|
|
402
|
+
* function refuses to spawn one `git diff --no-index` subprocess per file
|
|
403
|
+
* (an unbounded loop) and instead returns a value GUARANTEED to be over
|
|
404
|
+
* `MUTATION_LINE_THRESHOLD` — "many new files this session" is itself
|
|
405
|
+
* sufficient signal, no exact count needed. `measure` records WHICH of
|
|
406
|
+
* those two shapes the returned `lines` value is, so a consumer (the
|
|
407
|
+
* `review_skipped` telemetry line, via `main()`) never mixes exact and
|
|
408
|
+
* approximated counts in one unlabeled stream.
|
|
409
|
+
*/
|
|
410
|
+
export function totalNonDocMutatedLinesWithMeasure(cwd: string): MutationMeasurement {
|
|
411
|
+
const trackedLines = nonDocMutatedLines(gitNumstatRows(cwd));
|
|
412
|
+
const untracked = untrackedNonDocFiles(cwd);
|
|
413
|
+
if (untracked.length > UNTRACKED_FILE_CAP) {
|
|
414
|
+
return { lines: MUTATION_LINE_THRESHOLD + 1 + trackedLines, measure: 'capped_approximation' };
|
|
415
|
+
}
|
|
416
|
+
const untrackedLines = untracked.reduce(
|
|
417
|
+
(sum, relPath) => sum + untrackedFileLineCount(cwd, relPath),
|
|
418
|
+
0,
|
|
419
|
+
);
|
|
420
|
+
return { lines: trackedLines + untrackedLines, measure: 'exact' };
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Backward-compatible, lines-only view of
|
|
425
|
+
* {@link totalNonDocMutatedLinesWithMeasure} — kept for callers (and tests)
|
|
426
|
+
* that only need the count, not which measure produced it.
|
|
427
|
+
*/
|
|
428
|
+
export function totalNonDocMutatedLines(cwd: string): number {
|
|
429
|
+
return totalNonDocMutatedLinesWithMeasure(cwd).lines;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function isObject(v: unknown): v is JsonObject {
|
|
433
|
+
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function str(v: JsonValue | undefined): string {
|
|
437
|
+
return typeof v === 'string' ? v : '';
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// Agent/Task dispatch tool names this environment's subagent-spawn primitive
|
|
441
|
+
// is known under. Matched case-insensitively.
|
|
442
|
+
const DISPATCH_TOOL_RE = /^(agent|task)$/i;
|
|
443
|
+
// `description`/`subagent_type` are short, deliberately-named fields (an
|
|
444
|
+
// author names a subagent's TYPE or writes a one-line label for it) — a
|
|
445
|
+
// bare `review`/`judge`/etc keyword there IS itself the signal.
|
|
446
|
+
const REVIEW_SHAPED_LABEL_RE =
|
|
447
|
+
/\b(review|judge|adversarial|critique|second[- ]opinion|blind\s+(pass|review)|cross-model)\b/i;
|
|
448
|
+
// F15 (review): `prompt` is free-form prose an author writes for ANY
|
|
449
|
+
// purpose, so the same bare `\breview\b` false-positived on ordinary work
|
|
450
|
+
// ("update the review docs"). Narrower on purpose — each phrase names the
|
|
451
|
+
// SHAPE of a review rather than merely mentioning the word.
|
|
452
|
+
const REVIEW_SHAPED_PROMPT_RE =
|
|
453
|
+
/\b(code\s+review|neutral\s+review|adversarial|judge|critique|second[- ]opinion|blind\s+(pass|review)|cross-model)\b/i;
|
|
454
|
+
// Skill invocation naming a review/judge skill (hyphen- or colon-separated
|
|
455
|
+
// namespace forms both seen across this suite's skill/command catalogue).
|
|
456
|
+
const REVIEW_SKILL_NAME_RE =
|
|
457
|
+
/^(review[-:]changes|judge[-:][a-z0-9-]+|adversarial-review|code-review)$/i;
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* True when at least one assistant `tool_use` block in `transcriptText`
|
|
461
|
+
* looks like a neutral review/judge pass — an Agent/Task dispatch with a
|
|
462
|
+
* review-shaped `description`/`subagent_type` label or `prompt` text, or a
|
|
463
|
+
* Skill invocation naming a review/judge skill.
|
|
464
|
+
*
|
|
465
|
+
* Scans the WHOLE transcript (session scope), not just the latest turn —
|
|
466
|
+
* "no reviewer ran this session" is a session-wide claim.
|
|
467
|
+
*/
|
|
468
|
+
export function scanTranscriptForReviewer(transcriptText: string): boolean {
|
|
469
|
+
for (const raw of transcriptText.split('\n')) {
|
|
470
|
+
const line = raw.trim();
|
|
471
|
+
if (!line) continue;
|
|
472
|
+
let obj: unknown;
|
|
473
|
+
try {
|
|
474
|
+
obj = JSON.parse(line);
|
|
475
|
+
} catch {
|
|
476
|
+
continue;
|
|
477
|
+
}
|
|
478
|
+
if (!isObject(obj) || obj['type'] !== 'assistant') continue;
|
|
479
|
+
const msg = obj['message'];
|
|
480
|
+
if (!isObject(msg)) continue;
|
|
481
|
+
const content = msg['content'];
|
|
482
|
+
if (!Array.isArray(content)) continue;
|
|
483
|
+
for (const blk of content) {
|
|
484
|
+
if (!isObject(blk) || blk['type'] !== 'tool_use') continue;
|
|
485
|
+
const name = str(blk['name'] as JsonValue | undefined);
|
|
486
|
+
const input = blk['input'];
|
|
487
|
+
const inputObj = isObject(input) ? input : {};
|
|
488
|
+
if (DISPATCH_TOOL_RE.test(name)) {
|
|
489
|
+
const label = [
|
|
490
|
+
str(inputObj['description'] as JsonValue | undefined),
|
|
491
|
+
str(inputObj['subagent_type'] as JsonValue | undefined),
|
|
492
|
+
]
|
|
493
|
+
.filter(Boolean)
|
|
494
|
+
.join(' ');
|
|
495
|
+
if (REVIEW_SHAPED_LABEL_RE.test(label)) return true;
|
|
496
|
+
const prompt = str(inputObj['prompt'] as JsonValue | undefined);
|
|
497
|
+
if (REVIEW_SHAPED_PROMPT_RE.test(prompt)) return true;
|
|
498
|
+
continue;
|
|
499
|
+
}
|
|
500
|
+
if (/^skill$/i.test(name)) {
|
|
501
|
+
const skillName =
|
|
502
|
+
str(inputObj['skill'] as JsonValue | undefined) ||
|
|
503
|
+
str(inputObj['name'] as JsonValue | undefined) ||
|
|
504
|
+
str(inputObj['command'] as JsonValue | undefined);
|
|
505
|
+
if (REVIEW_SKILL_NAME_RE.test(skillName)) return true;
|
|
506
|
+
continue;
|
|
507
|
+
}
|
|
508
|
+
// A tool_use literally named after a review/judge skill (some
|
|
509
|
+
// hosts expose a skill invocation as its own tool name).
|
|
510
|
+
if (REVIEW_SKILL_NAME_RE.test(name)) return true;
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
return false;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/** The exactly-one advisory line this concern injects when it fires. */
|
|
517
|
+
export function buildAdvisoryLine(diffLines: number): string {
|
|
518
|
+
return (
|
|
519
|
+
`this session mutated ${diffLines} lines without a neutral review; ` +
|
|
520
|
+
`spawn a cross-model reviewer before claiming done (delegation-policy / verify-budget)`
|
|
521
|
+
);
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* Append ONE `review_skipped` line to the monthly audit log. Never throws —
|
|
526
|
+
* a telemetry write failure must not cost the user their turn.
|
|
527
|
+
*
|
|
528
|
+
* Honours `AGENT_CONFIG_REPLAY=1` the same way `orchestration_record_hook.ts`
|
|
529
|
+
* does for its own per-call JSONL append (F5, review) — a fixture-replay run
|
|
530
|
+
* must never write real telemetry, and this write had no such guard.
|
|
531
|
+
*/
|
|
532
|
+
export function appendReviewSkippedTelemetry(
|
|
533
|
+
workspaceRoot: string,
|
|
534
|
+
diffLines: number,
|
|
535
|
+
mutationMeasure: MutationMeasure,
|
|
536
|
+
): void {
|
|
537
|
+
if (is_replay_mode()) return;
|
|
538
|
+
const ts = new Date().toISOString();
|
|
539
|
+
const { line, errors } = buildReviewSkippedLine({
|
|
540
|
+
diff_lines: diffLines,
|
|
541
|
+
mutation_measure: mutationMeasure,
|
|
542
|
+
ts,
|
|
543
|
+
id: crypto.randomUUID(),
|
|
544
|
+
});
|
|
545
|
+
if (errors.length || line === null) return; // refused input — never write a broken line
|
|
546
|
+
try {
|
|
547
|
+
const dir = path.join(workspaceRoot, 'agents', 'runtime', 'state', 'audit');
|
|
548
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
549
|
+
const file = path.join(dir, `${ts.slice(0, 7)}.jsonl`);
|
|
550
|
+
fs.appendFileSync(file, `${JSON.stringify(line)}\n`, 'utf8');
|
|
551
|
+
} catch {
|
|
552
|
+
// an unwritable audit dir must never break the Stop path
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/** Directory holding the once-per-session fire-gate markers (F2, review). */
|
|
557
|
+
function sessionStateDir(workspaceRoot: string): string {
|
|
558
|
+
return path.join(workspaceRoot, 'agents', 'runtime', 'state', 'end-review-nudge');
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
function sessionStateFile(workspaceRoot: string, sessionKey: string): string {
|
|
562
|
+
return path.join(sessionStateDir(workspaceRoot), `${sessionKey}.json`);
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/**
|
|
566
|
+
* Derive a stable, filesystem-safe once-per-session key (F2, review).
|
|
567
|
+
* Prefers the dispatcher envelope's own `session_id` (present on every
|
|
568
|
+
* envelope the dispatcher itself builds — `envelope.ts`'s `unwrap`); falls
|
|
569
|
+
* back to `payload.transcript_path` for a legacy/raw-payload invocation
|
|
570
|
+
* that carries no `session_id` at all. HASHED, never stored raw, so the
|
|
571
|
+
* marker filename can never leak a real transcript path.
|
|
572
|
+
*
|
|
573
|
+
* FULL digest, not truncated (council finding, this task): a 64-bit
|
|
574
|
+
* truncation (16 hex chars) makes birthday collisions between unrelated
|
|
575
|
+
* sessions non-negligible at the population sizes this hook will
|
|
576
|
+
* accumulate over time — a collision silently merges two sessions' fire
|
|
577
|
+
* gates, undercounting `review_skipped` telemetry and biasing any future
|
|
578
|
+
* blocking-threshold calibration downward. The full 256-bit hex digest
|
|
579
|
+
* makes that collision probability negligible; the classification risk
|
|
580
|
+
* this key carries is a 1-bit fire/no-fire outcome (see the file header's
|
|
581
|
+
* "Transcript-path hardening" section), never file content, so there is no
|
|
582
|
+
* privacy cost to keeping the full digest.
|
|
583
|
+
*/
|
|
584
|
+
export function deriveSessionKey(envelope: JsonObject, payload: JsonObject): string {
|
|
585
|
+
const raw =
|
|
586
|
+
str(envelope['session_id'] as JsonValue | undefined) ||
|
|
587
|
+
str((payload['transcript_path'] ?? payload['transcriptPath']) as JsonValue | undefined);
|
|
588
|
+
const material = raw || 'unknown-session';
|
|
589
|
+
return crypto.createHash('sha256').update(material).digest('hex');
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/**
|
|
593
|
+
* True when THIS session already fired the nudge (F2, review). Fails
|
|
594
|
+
* closed to `false` on a read error — "not yet fired" is the safer
|
|
595
|
+
* default: worst case it repeats a nudge, it never silences a genuine one
|
|
596
|
+
* over a merely-unreadable state directory.
|
|
597
|
+
*/
|
|
598
|
+
export function hasFiredThisSession(workspaceRoot: string, sessionKey: string): boolean {
|
|
599
|
+
try {
|
|
600
|
+
return fs.existsSync(sessionStateFile(workspaceRoot, sessionKey));
|
|
601
|
+
} catch {
|
|
602
|
+
return false;
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/**
|
|
607
|
+
* Record that THIS session has fired. `atomic_write_json` is already
|
|
608
|
+
* `AGENT_CONFIG_REPLAY`-aware (a no-op under replay), so a fixture-replay
|
|
609
|
+
* run leaves no marker behind either — consistent with the telemetry-write
|
|
610
|
+
* guard above.
|
|
611
|
+
*/
|
|
612
|
+
function markFiredThisSession(workspaceRoot: string, sessionKey: string, ts: string): void {
|
|
613
|
+
try {
|
|
614
|
+
atomic_write_json(sessionStateFile(workspaceRoot, sessionKey), { fired_at: ts });
|
|
615
|
+
} catch {
|
|
616
|
+
// a state-write failure must never block the Stop path
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
function _read_stdin(): string {
|
|
621
|
+
return readHookStdin();
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
export function main(): number {
|
|
625
|
+
const [envelope, payload] = unwrap(_read_stdin(), 'claude');
|
|
626
|
+
|
|
627
|
+
const event = String(envelope['event'] ?? '');
|
|
628
|
+
if (event !== '' && event !== 'stop') {
|
|
629
|
+
return 0;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
const workspace_root =
|
|
633
|
+
String(envelope['workspace_root'] ?? '').trim() || process.cwd();
|
|
634
|
+
|
|
635
|
+
let diffLines: number;
|
|
636
|
+
let mutationMeasure: MutationMeasure;
|
|
637
|
+
try {
|
|
638
|
+
const measured = totalNonDocMutatedLinesWithMeasure(workspace_root);
|
|
639
|
+
diffLines = measured.lines;
|
|
640
|
+
mutationMeasure = measured.measure;
|
|
641
|
+
} catch {
|
|
642
|
+
return 0; // fail-open — never block the agent loop
|
|
643
|
+
}
|
|
644
|
+
if (diffLines <= MUTATION_LINE_THRESHOLD) {
|
|
645
|
+
return 0; // below threshold, or a doc-only diff — nothing to nudge
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
// F2/F12 (review): the once-per-session fire gate runs on the CHEAP
|
|
649
|
+
// git-diff result, BEFORE the expensive transcript read + line-by-line
|
|
650
|
+
// JSON parse below. A session that already fired stops here, at a
|
|
651
|
+
// single `fs.existsSync` call, without re-reading a (potentially
|
|
652
|
+
// large) transcript file it already knows the answer for.
|
|
653
|
+
const sessionKey = deriveSessionKey(envelope, payload);
|
|
654
|
+
if (hasFiredThisSession(workspace_root, sessionKey)) {
|
|
655
|
+
return 0;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
const transcriptPath = str(
|
|
659
|
+
(payload['transcript_path'] ?? payload['transcriptPath']) as JsonValue | undefined,
|
|
660
|
+
).trim();
|
|
661
|
+
let reviewerRan = false;
|
|
662
|
+
// Gate finding fd47df62: validate BEFORE reading — see the file header's
|
|
663
|
+
// "Transcript-path hardening" section. A path that fails validation
|
|
664
|
+
// (wrong extension, resolves outside home, oversized) is treated exactly
|
|
665
|
+
// like an unreadable transcript below: the scan is skipped and
|
|
666
|
+
// `reviewerRan` stays `false`, resolving toward firing, not silence.
|
|
667
|
+
if (transcriptPath && isSafeTranscriptPath(transcriptPath)) {
|
|
668
|
+
try {
|
|
669
|
+
reviewerRan = scanTranscriptForReviewer(fs.readFileSync(transcriptPath, 'utf-8'));
|
|
670
|
+
} catch {
|
|
671
|
+
// unreadable transcript — cannot confirm a reviewer ran; see the
|
|
672
|
+
// header note on why that resolves toward firing, not silence.
|
|
673
|
+
reviewerRan = false;
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
if (reviewerRan) {
|
|
677
|
+
return 0;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
// Only mark THIS session as fired once the nudge is actually about to
|
|
681
|
+
// be injected — a turn that stopped above (below threshold, already
|
|
682
|
+
// fired, or a reviewer ran) never claims the once-per-session slot.
|
|
683
|
+
markFiredThisSession(workspace_root, sessionKey, new Date().toISOString());
|
|
684
|
+
|
|
685
|
+
try {
|
|
686
|
+
appendReviewSkippedTelemetry(workspace_root, diffLines, mutationMeasure);
|
|
687
|
+
} catch {
|
|
688
|
+
// never let a telemetry failure block or fail the turn
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
process.stdout.write(
|
|
692
|
+
`${JSON.stringify({
|
|
693
|
+
decision: 'warn',
|
|
694
|
+
reason: `end-review-nudge: session mutated ${diffLines} non-doc lines without a neutral review`,
|
|
695
|
+
additional_context: buildAdvisoryLine(diffLines),
|
|
696
|
+
})}\n`,
|
|
697
|
+
);
|
|
698
|
+
return EXIT_WARN;
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
// Bundle-safety: never auto-run when inlined into an esbuild bundle, where
|
|
702
|
+
// every module shares the bundle's `import.meta.url` (see cmd_migrate.ts).
|
|
703
|
+
declare const __AGENT_CONFIG_BUNDLE__: boolean | undefined;
|
|
704
|
+
function _isCliEntry(): boolean {
|
|
705
|
+
if (typeof __AGENT_CONFIG_BUNDLE__ !== 'undefined' && __AGENT_CONFIG_BUNDLE__) {
|
|
706
|
+
return false;
|
|
707
|
+
}
|
|
708
|
+
if (process.argv[1] === undefined) {
|
|
709
|
+
return false;
|
|
710
|
+
}
|
|
711
|
+
const argvUrl = pathToFileURL(path.resolve(process.argv[1])).href;
|
|
712
|
+
if (import.meta.url === argvUrl) {
|
|
713
|
+
return true;
|
|
714
|
+
}
|
|
715
|
+
try {
|
|
716
|
+
const here = fs.realpathSync(fileURLToPath(import.meta.url));
|
|
717
|
+
const argv = fs.realpathSync(path.resolve(process.argv[1]));
|
|
718
|
+
return here === argv;
|
|
719
|
+
} catch {
|
|
720
|
+
return false;
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
if (_isCliEntry()) {
|
|
725
|
+
process.exit(main());
|
|
726
|
+
}
|