@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,342 @@
|
|
|
1
|
+
#!/usr/bin/env tsx
|
|
2
|
+
/**
|
|
3
|
+
* Deterministic PostToolUse orchestration-telemetry capture
|
|
4
|
+
* (road-to-orchestrator-discipline-carriers Phase 3 / F6).
|
|
5
|
+
*
|
|
6
|
+
* Why this exists. `orchestration_record` (the CLI) is a model-carried emit
|
|
7
|
+
* step: the orchestrating agent runs it by hand after a dispatch. Measured
|
|
8
|
+
* 2026-08-07: the agent was used on real delegable work 370 times in this
|
|
9
|
+
* repo over one month and the CLI captured exactly 1 of them (0.27%). The
|
|
10
|
+
* capture rate is a hook-reachability defect, not a usage defect — the same
|
|
11
|
+
* data `orchestration_backfill.ts` already reads out of the host transcript
|
|
12
|
+
* (`toolUseResult.{resolvedModel,totalTokens,totalDurationMs,isAsync,status}`)
|
|
13
|
+
* is available live, on the `post_tool_use` slot, the moment the dispatch
|
|
14
|
+
* completes. This concern makes the capture structural instead of hoped-for.
|
|
15
|
+
*
|
|
16
|
+
* Fires only on an `Agent` / `Task` tool-use completion (the two names this
|
|
17
|
+
* repo has observed for the subagent-dispatch tool across hosts/versions).
|
|
18
|
+
* Every other tool call is a silent no-op — same tool-filter discipline as
|
|
19
|
+
* `code-graph-nudge` (`tools: [Agent, Task]` in the manifest entry keeps the
|
|
20
|
+
* dispatcher from paying this concern's cost on unrelated calls).
|
|
21
|
+
*
|
|
22
|
+
* Sync vs async (road-to-orchestrator-first-execution measured: 326 of 370
|
|
23
|
+
* dispatches are async launches that never write cost back to the parent
|
|
24
|
+
* transcript):
|
|
25
|
+
* - sync completion → the tool_response carries real usage fields
|
|
26
|
+
* (`resolvedModel`, `totalTokens`, `totalDurationMs`) — recorded as
|
|
27
|
+
* measured numbers (`dispatch_tokens`, `wall_clock_ms`, `tiers`).
|
|
28
|
+
* - async launch ack → `isAsync: true` / `status: "async_launched"` and NO
|
|
29
|
+
* usage fields — the dispatch FACT is still recorded (`spawn_count: 1`)
|
|
30
|
+
* but every metric field is left absent (null/omitted). Never fabricated.
|
|
31
|
+
*
|
|
32
|
+
* `token_delta` has no honest value at this layer: it is the net cost of the
|
|
33
|
+
* dispatch versus an in-session baseline, and this hook has no baseline to
|
|
34
|
+
* compare against (same limitation `orchestration_backfill.ts` documents for
|
|
35
|
+
* its own read-only pass — "No in-session counterfactual exists on disk").
|
|
36
|
+
* It is therefore always written as `0` with `token_delta_provenance:
|
|
37
|
+
* "estimated"` — an explicit "no delta claimed" rather than a silent zero
|
|
38
|
+
* that could be misread as "measured no-op". The real absolute cost (when
|
|
39
|
+
* known) rides `dispatch_tokens`, which exists precisely for this case.
|
|
40
|
+
*
|
|
41
|
+
* PRIVACY BY CONSTRUCTION — never widen this file to read/emit free text.
|
|
42
|
+
* The host's own tool_use_id / session_id are high-entropy opaque tokens
|
|
43
|
+
* that `check_secret_leak` correctly flags as candidate credentials (hit
|
|
44
|
+
* during `orchestration_backfill.ts` authoring); this hook never reads them
|
|
45
|
+
* into the record. `id` is generated locally via `crypto.randomUUID()` —
|
|
46
|
+
* our own randomness, not a host-supplied value. `subagent_type` is the one
|
|
47
|
+
* host string recorded, and it is an id-shaped enum
|
|
48
|
+
* (`Explore` / `general-purpose` / `production-validator` / …), never a
|
|
49
|
+
* prompt or description.
|
|
50
|
+
*
|
|
51
|
+
* Uses the existing `_lib/orchestration_record.js` validation
|
|
52
|
+
* (`buildOrchestrationLine`) and the existing CLI's write convention
|
|
53
|
+
* (`orchestration_record.ts`'s `DEFAULT_DIR` + `${ts.slice(0,7)}.jsonl` +
|
|
54
|
+
* plain `fs.appendFileSync`, no per-line schema of its own). `chat_history.ts`
|
|
55
|
+
* — the other post_tool_use concern that appends a JSONL line per call —
|
|
56
|
+
* establishes the precedent that a hook-owned append checks
|
|
57
|
+
* `is_replay_mode()` and skips the dispatcher's shared state lock (the lock
|
|
58
|
+
* exists for atomic *overwrite*; an append is not a torn-write risk the way
|
|
59
|
+
* a rewritten JSON state file is).
|
|
60
|
+
*
|
|
61
|
+
* Exit code is ALWAYS 0 — on every path, including a malformed envelope, an
|
|
62
|
+
* unparseable tool_response, or a write failure. A hook `warn` (exit 2) is
|
|
63
|
+
* read as a hard BLOCK on this host (recorded trap); this concern has
|
|
64
|
+
* nothing to say to the model, so it never warns either.
|
|
65
|
+
*/
|
|
66
|
+
import * as crypto from 'node:crypto';
|
|
67
|
+
import * as fs from 'node:fs';
|
|
68
|
+
import * as path from 'node:path';
|
|
69
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
70
|
+
|
|
71
|
+
import { buildOrchestrationLine, type RecordInput } from '../_lib/orchestration_record.js';
|
|
72
|
+
import { DEFAULT_DIR } from '../orchestration_record.js';
|
|
73
|
+
import { is_replay_mode } from './state_io.js';
|
|
74
|
+
import { readHookStdin } from './hook_stdin.js';
|
|
75
|
+
|
|
76
|
+
const EXIT_ALLOW = 0;
|
|
77
|
+
|
|
78
|
+
type JsonValue = string | number | boolean | null | JsonValue[] | { [k: string]: JsonValue };
|
|
79
|
+
type JsonObject = { [k: string]: JsonValue };
|
|
80
|
+
|
|
81
|
+
function isObject(v: unknown): v is JsonObject {
|
|
82
|
+
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** The two subagent-dispatch tool names observed across hosts/versions. */
|
|
86
|
+
export const DISPATCH_TOOL_NAMES: ReadonlySet<string> = new Set(['Agent', 'Task']);
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Unwrap the dispatcher envelope (`{schema_version, platform, event,
|
|
90
|
+
* payload}`) down to the platform-native payload. Falls back to the
|
|
91
|
+
* top-level object for direct/legacy invocation (same shape both
|
|
92
|
+
* `pr_url_reminder_hook.ts` and `code_graph_nudge_hook.ts` handle).
|
|
93
|
+
*/
|
|
94
|
+
export function unwrapPayload(envelope: JsonObject): JsonObject {
|
|
95
|
+
const inner = envelope['payload'];
|
|
96
|
+
return isObject(inner) ? inner : envelope;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Best-effort read of the tool name off a payload, across host key variants. */
|
|
100
|
+
export function extractToolName(payload: JsonObject): string | null {
|
|
101
|
+
const v = payload['tool_name'] ?? payload['toolName'] ?? payload['tool'];
|
|
102
|
+
return typeof v === 'string' && v ? v : null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Best-effort read of the tool_input object (the ORIGINAL dispatch request). */
|
|
106
|
+
function extractToolInput(payload: JsonObject): JsonObject | null {
|
|
107
|
+
const v = payload['tool_input'] ?? payload['toolInput'];
|
|
108
|
+
return isObject(v) ? v : null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Best-effort read of the tool's result object off a payload. Tries the
|
|
113
|
+
* established hook-envelope keys first, then the raw transcript key
|
|
114
|
+
* (`toolUseResult`) `orchestration_backfill.ts` reads from the JSONL corpus —
|
|
115
|
+
* some hosts may preserve that shape verbatim in the dispatcher payload.
|
|
116
|
+
* A string value (e.g. shell stdout on unrelated tools) is never parsed as
|
|
117
|
+
* dispatch metrics — only an already-decoded object counts.
|
|
118
|
+
*/
|
|
119
|
+
export function extractToolResult(payload: JsonObject): JsonObject | null {
|
|
120
|
+
for (const key of ['tool_response', 'toolResponse', 'tool_result', 'toolUseResult']) {
|
|
121
|
+
const v = payload[key];
|
|
122
|
+
if (isObject(v)) return v;
|
|
123
|
+
}
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** One dispatch-completion event, reduced to what this hook will record. */
|
|
128
|
+
export interface DispatchFacts {
|
|
129
|
+
subagentType: string | null;
|
|
130
|
+
resolvedModel: string | null;
|
|
131
|
+
/** Absolute measured tokens the dispatched slice consumed. `null` = not observed (async). */
|
|
132
|
+
totalTokens: number | null;
|
|
133
|
+
totalDurationMs: number | null;
|
|
134
|
+
isAsync: boolean;
|
|
135
|
+
isError: boolean;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function numOrNull(v: JsonValue | undefined): number | null {
|
|
139
|
+
return typeof v === 'number' && Number.isFinite(v) ? v : null;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Model FAMILY names the `audit-log-v1` `orchestration.tiers` contract
|
|
144
|
+
* expects (`orchestration-telemetry.md`: `["sonnet","opus"]`, "Tier names
|
|
145
|
+
* only, no prompts") — never a full model id like
|
|
146
|
+
* `claude-sonnet-4-5-20250929` or `us.anthropic.claude-opus-4-…` (F9,
|
|
147
|
+
* review). Ordered so a longer/more-specific family never loses to a
|
|
148
|
+
* substring of another (none currently overlap, but `haiku`/`sonnet`/`opus`/
|
|
149
|
+
* `fable` are checked as whole tokens via case-insensitive substring, not
|
|
150
|
+
* prefix, so any future id shape that embeds the family name anywhere still
|
|
151
|
+
* resolves).
|
|
152
|
+
*/
|
|
153
|
+
const MODEL_FAMILIES: readonly string[] = ['haiku', 'sonnet', 'opus', 'fable'];
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Reduce a host-reported `resolvedModel` (a full model id) to the tier-name
|
|
157
|
+
* vocabulary the contract expects. No match → `null`, never the raw id —
|
|
158
|
+
* `buildRecordInput` below treats `null` as "omit `tiers` entirely" rather
|
|
159
|
+
* than writing a value the schema does not define.
|
|
160
|
+
*/
|
|
161
|
+
export function extractModelFamily(resolvedModel: string): string | null {
|
|
162
|
+
const lower = resolvedModel.toLowerCase();
|
|
163
|
+
for (const family of MODEL_FAMILIES) {
|
|
164
|
+
if (lower.includes(family)) return family;
|
|
165
|
+
}
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Read dispatch facts from an already-identified Agent/Task tool-use payload.
|
|
171
|
+
* Returns `null` when the payload carries no result object at all — this
|
|
172
|
+
* still counts as a completion (e.g. a bare async ack with an empty body
|
|
173
|
+
* would be unusual, but a missing result should never fabricate metrics).
|
|
174
|
+
*/
|
|
175
|
+
export function extractDispatchFacts(payload: JsonObject): DispatchFacts {
|
|
176
|
+
const input = extractToolInput(payload);
|
|
177
|
+
const subagentType =
|
|
178
|
+
input && typeof input['subagent_type'] === 'string' ? (input['subagent_type'] as string) : null;
|
|
179
|
+
|
|
180
|
+
const result = extractToolResult(payload);
|
|
181
|
+
const resolvedModel =
|
|
182
|
+
result && typeof result['resolvedModel'] === 'string' ? (result['resolvedModel'] as string) : null;
|
|
183
|
+
|
|
184
|
+
// `usage.input_tokens` + `usage.output_tokens` is the fallback source the
|
|
185
|
+
// orchestration-telemetry contract names when `totalTokens` itself is
|
|
186
|
+
// absent (§ "token_delta sourcing priority" — the same host usage object,
|
|
187
|
+
// read here for the absolute count rather than a delta).
|
|
188
|
+
let totalTokens = result ? numOrNull(result['totalTokens']) : null;
|
|
189
|
+
if (totalTokens === null && result && isObject(result['usage'])) {
|
|
190
|
+
const usage = result['usage'] as JsonObject;
|
|
191
|
+
const inTok = numOrNull(usage['input_tokens']);
|
|
192
|
+
const outTok = numOrNull(usage['output_tokens']);
|
|
193
|
+
if (inTok !== null || outTok !== null) {
|
|
194
|
+
totalTokens = (inTok ?? 0) + (outTok ?? 0);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const totalDurationMs = result ? numOrNull(result['totalDurationMs']) : null;
|
|
199
|
+
const isAsync = result !== null && (result['isAsync'] === true || result['status'] === 'async_launched');
|
|
200
|
+
const isError = payload['is_error'] === true || payload['isError'] === true || (result !== null && result['is_error'] === true);
|
|
201
|
+
|
|
202
|
+
return { subagentType, resolvedModel, totalTokens, totalDurationMs, isAsync, isError };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Build the `RecordInput` for one dispatch-completion event. Pure — no I/O,
|
|
207
|
+
* no `Date.now()` / random call inside (both are supplied by the caller so
|
|
208
|
+
* the mapping itself stays independently testable).
|
|
209
|
+
*/
|
|
210
|
+
export function buildRecordInput(facts: DispatchFacts, ts: string, id: string): RecordInput {
|
|
211
|
+
const input: RecordInput = {
|
|
212
|
+
spawn_count: 1,
|
|
213
|
+
// No in-session baseline exists at this layer to compute a delta
|
|
214
|
+
// against (see file header) — 0/"estimated" is an explicit
|
|
215
|
+
// "no delta claimed", never a measured zero.
|
|
216
|
+
token_delta: 0,
|
|
217
|
+
token_delta_provenance: 'estimated',
|
|
218
|
+
// This concern performs no verification of the subagent's output —
|
|
219
|
+
// "none" is the honest value, not the schema's "deterministic" default.
|
|
220
|
+
verify_mode: 'none',
|
|
221
|
+
ts,
|
|
222
|
+
id,
|
|
223
|
+
};
|
|
224
|
+
if (facts.subagentType) input.agent_combo = [facts.subagentType];
|
|
225
|
+
if (facts.isError) input.dispatch_outcome = 'killed';
|
|
226
|
+
|
|
227
|
+
if (!facts.isAsync) {
|
|
228
|
+
if (facts.resolvedModel) {
|
|
229
|
+
const family = extractModelFamily(facts.resolvedModel);
|
|
230
|
+
if (family) input.tiers = [family];
|
|
231
|
+
}
|
|
232
|
+
if (facts.totalTokens !== null) input.dispatch_tokens = facts.totalTokens;
|
|
233
|
+
if (facts.totalDurationMs !== null) input.wall_clock_ms = facts.totalDurationMs;
|
|
234
|
+
}
|
|
235
|
+
// Async launch ack: every metric field stays absent (undefined → the
|
|
236
|
+
// schema's own null/0 default) rather than fabricated from nothing.
|
|
237
|
+
|
|
238
|
+
return input;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** `${ts.slice(0,7)}.jsonl` — identical file-naming convention to the CLI. */
|
|
242
|
+
function auditFileFor(consumerRoot: string, ts: string): string {
|
|
243
|
+
return path.join(consumerRoot, DEFAULT_DIR, `${ts.slice(0, 7)}.jsonl`);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Append one built line to the monthly audit file. Mirrors the CLI's own
|
|
248
|
+
* write path (`mkdirSync` + `appendFileSync`, no dispatcher lock — appends
|
|
249
|
+
* are not the torn-write risk `atomic_write_json`/`atomic_write_text` guard
|
|
250
|
+
* against) and honours `AGENT_CONFIG_REPLAY=1` the same way `chat_history.ts`
|
|
251
|
+
* does for its own per-call JSONL append.
|
|
252
|
+
*/
|
|
253
|
+
function appendLine(consumerRoot: string, ts: string, line: Record<string, unknown>): void {
|
|
254
|
+
if (is_replay_mode()) return;
|
|
255
|
+
const file = auditFileFor(consumerRoot, ts);
|
|
256
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
257
|
+
fs.appendFileSync(file, `${JSON.stringify(line)}\n`, 'utf8');
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Process an ALREADY-PARSED envelope. Both `run` (the test-facing,
|
|
262
|
+
* string-in entry point) and `main` (the real stdin path) fun this — F13
|
|
263
|
+
* (review): `main` used to parse stdin once to resolve `consumer_root`
|
|
264
|
+
* and then hand the same raw string to `run`, which parsed it a SECOND
|
|
265
|
+
* time. Parsing here, once, and passing the decoded value through removes
|
|
266
|
+
* the duplicate `JSON.parse` on the hot path without touching `run`'s
|
|
267
|
+
* public string-in signature (tests call it directly with a raw string).
|
|
268
|
+
*/
|
|
269
|
+
function processEnvelope(envelope: JsonValue, consumer_root: string): number {
|
|
270
|
+
try {
|
|
271
|
+
if (!isObject(envelope)) return EXIT_ALLOW;
|
|
272
|
+
|
|
273
|
+
const payload = unwrapPayload(envelope);
|
|
274
|
+
const toolName = extractToolName(payload);
|
|
275
|
+
if (toolName === null || !DISPATCH_TOOL_NAMES.has(toolName)) return EXIT_ALLOW;
|
|
276
|
+
|
|
277
|
+
const facts = extractDispatchFacts(payload);
|
|
278
|
+
const ts = new Date().toISOString();
|
|
279
|
+
const id = crypto.randomUUID();
|
|
280
|
+
const input = buildRecordInput(facts, ts, id);
|
|
281
|
+
|
|
282
|
+
const { line, errors } = buildOrchestrationLine(input);
|
|
283
|
+
if (errors.length || line === null) return EXIT_ALLOW; // never block on our own validation
|
|
284
|
+
|
|
285
|
+
appendLine(consumer_root, ts, line);
|
|
286
|
+
} catch {
|
|
287
|
+
// Malformed payload, unreadable disk, anything — never block the tool call.
|
|
288
|
+
return EXIT_ALLOW;
|
|
289
|
+
}
|
|
290
|
+
return EXIT_ALLOW;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
export function run(stdin_text: string, options: { consumer_root: string }): number {
|
|
294
|
+
let envelope: JsonValue;
|
|
295
|
+
try {
|
|
296
|
+
const raw = stdin_text.trim();
|
|
297
|
+
if (!raw) return EXIT_ALLOW;
|
|
298
|
+
envelope = JSON.parse(raw) as JsonValue;
|
|
299
|
+
} catch {
|
|
300
|
+
return EXIT_ALLOW;
|
|
301
|
+
}
|
|
302
|
+
return processEnvelope(envelope, options.consumer_root);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function _resolveRoot(envelope: JsonValue): string {
|
|
306
|
+
if (isObject(envelope)) {
|
|
307
|
+
const cwd = envelope['cwd'];
|
|
308
|
+
if (typeof cwd === 'string' && cwd) return cwd;
|
|
309
|
+
const pr = envelope['workspace_root'] ?? envelope['project_root'];
|
|
310
|
+
if (typeof pr === 'string' && pr) return pr;
|
|
311
|
+
}
|
|
312
|
+
return process.cwd();
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
export function main(): number {
|
|
316
|
+
const raw = readHookStdin();
|
|
317
|
+
let envelope: JsonValue = {};
|
|
318
|
+
try {
|
|
319
|
+
envelope = raw.trim() ? (JSON.parse(raw) as JsonValue) : {};
|
|
320
|
+
} catch {
|
|
321
|
+
return EXIT_ALLOW;
|
|
322
|
+
}
|
|
323
|
+
return processEnvelope(envelope, _resolveRoot(envelope));
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// Bundle-safety: never auto-run when inlined into an esbuild bundle, where
|
|
327
|
+
// every module shares the bundle's `import.meta.url` (see cmd_migrate.ts).
|
|
328
|
+
declare const __AGENT_CONFIG_BUNDLE__: boolean | undefined;
|
|
329
|
+
function _isCliEntry(): boolean {
|
|
330
|
+
if (typeof __AGENT_CONFIG_BUNDLE__ !== 'undefined' && __AGENT_CONFIG_BUNDLE__) {
|
|
331
|
+
return false;
|
|
332
|
+
}
|
|
333
|
+
if (process.argv[1] === undefined) return false;
|
|
334
|
+
const argvUrl = pathToFileURL(path.resolve(process.argv[1])).href;
|
|
335
|
+
if (import.meta.url === argvUrl) return true;
|
|
336
|
+
try {
|
|
337
|
+
return fs.realpathSync(fileURLToPath(import.meta.url)) === fs.realpathSync(path.resolve(process.argv[1]));
|
|
338
|
+
} catch {
|
|
339
|
+
return false;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
if (_isCliEntry()) process.exit(main());
|
package/src/scripts/install.ts
CHANGED
|
@@ -77,6 +77,7 @@ import * as installed_lock from './_lib/installed_lock.js';
|
|
|
77
77
|
import * as scoped_projection from './_lib/scoped_projection.js';
|
|
78
78
|
import * as surface_tiers from './_lib/surface_tiers.js';
|
|
79
79
|
import * as global_deploy_inventory from './_lib/global_deploy_inventory.js';
|
|
80
|
+
import * as rule_layer_overlap from './_lib/rule_layer_overlap.js';
|
|
80
81
|
import * as installed_tools from './_lib/installed_tools.js';
|
|
81
82
|
import { collect_drift, format_drift_report } from './_lib/install_drift.js';
|
|
82
83
|
import * as user_global_paths from './_lib/user_global_paths.js';
|
|
@@ -2113,6 +2114,137 @@ interface Options {
|
|
|
2113
2114
|
no_ui: boolean;
|
|
2114
2115
|
dry_run: boolean;
|
|
2115
2116
|
apply_payload: string | null;
|
|
2117
|
+
/**
|
|
2118
|
+
* Which rule layer owns the corpus when both `~/.claude/rules/` and
|
|
2119
|
+
* `<project>/.claude/rules/` would carry it.
|
|
2120
|
+
*
|
|
2121
|
+
* `global` | `project` keep that layer and suppress the other via the host's
|
|
2122
|
+
* `claudeMdExcludes`; `both-acknowledged` keeps both and pays the doubled
|
|
2123
|
+
* standing context knowingly. `null` (absent) with an overlap detected is a
|
|
2124
|
+
* refusal, not a default — see `_gate_rule_layer_overlap`.
|
|
2125
|
+
*/
|
|
2126
|
+
layer: string | null;
|
|
2127
|
+
}
|
|
2128
|
+
|
|
2129
|
+
/** Valid `--layer` values. Mirrors `rule_layer_overlap.LayerChoice`. */
|
|
2130
|
+
export const RULE_LAYER_CHOICES: readonly string[] = ['global', 'project', 'both-acknowledged'];
|
|
2131
|
+
|
|
2132
|
+
/**
|
|
2133
|
+
* Suppress one rules directory via the host's own `claudeMdExcludes`, written
|
|
2134
|
+
* into `.claude/settings.local.json`.
|
|
2135
|
+
*
|
|
2136
|
+
* `local`, not `settings.json`: the choice is per-machine (it depends on what
|
|
2137
|
+
* else that machine has installed), so it must not land on the shared tree.
|
|
2138
|
+
* The merge APPENDS — `claudeMdExcludes` arrays merge across settings layers, so
|
|
2139
|
+
* replacing the array would drop another layer's exclusions. Re-running is a
|
|
2140
|
+
* no-op. **Nothing is deleted or rewritten outside this one JSON key.**
|
|
2141
|
+
*/
|
|
2142
|
+
function _suppress_rule_layer(project_root: string, suppress_dir: string): void {
|
|
2143
|
+
const target = path.join(project_root, '.claude', 'settings.local.json');
|
|
2144
|
+
const existing = pathExists(target) ? read_json_file(target) : {};
|
|
2145
|
+
// Realpath before building the glob. `claudeMdExcludesGlob` stays pure and
|
|
2146
|
+
// only normalizes, and `path.resolve` does NOT dereference symlinks — so on a
|
|
2147
|
+
// symlinked home or checkout (macOS `/var` → `/private/var`; the host docs
|
|
2148
|
+
// explicitly support reaching a file through a symlinked project path) the
|
|
2149
|
+
// glob would name a path the host never reports and the exclusion would
|
|
2150
|
+
// silently match nothing. An unresolvable path falls back to the literal.
|
|
2151
|
+
let real_dir = suppress_dir;
|
|
2152
|
+
try {
|
|
2153
|
+
real_dir = fs.realpathSync(suppress_dir);
|
|
2154
|
+
} catch {
|
|
2155
|
+
real_dir = suppress_dir;
|
|
2156
|
+
}
|
|
2157
|
+
const entry = rule_layer_overlap.claudeMdExcludesGlob(real_dir);
|
|
2158
|
+
const merged = rule_layer_overlap.mergeClaudeMdExcludes(existing['claudeMdExcludes'], entry);
|
|
2159
|
+
if (JSON.stringify(existing['claudeMdExcludes'] ?? null) === JSON.stringify(merged)) return;
|
|
2160
|
+
write_json_file(target, { ...existing, claudeMdExcludes: merged });
|
|
2161
|
+
success(`.claude/settings.local.json: claudeMdExcludes += ${entry}`);
|
|
2162
|
+
}
|
|
2163
|
+
|
|
2164
|
+
/**
|
|
2165
|
+
* Refuse to create a silently doubled rule corpus.
|
|
2166
|
+
*
|
|
2167
|
+
* Claude Code loads `~/.claude/rules/` and `<project>/.claude/rules/` **both**,
|
|
2168
|
+
* user layer first, with no dedup — a rule in both sits in standing context
|
|
2169
|
+
* twice. Measured on a maintainer machine 2026-08-08: 91 shared rules, 176,354
|
|
2170
|
+
* exact-BPE tokens standing, 74,137 of them redundant. Evidence +
|
|
2171
|
+
* host-contract citations:
|
|
2172
|
+
* `agents/evidence/analysis/standing-rule-delivery-topologies.md`.
|
|
2173
|
+
*
|
|
2174
|
+
* Contract:
|
|
2175
|
+
*
|
|
2176
|
+
* - **No overlap** → silent pass. The common case pays nothing.
|
|
2177
|
+
* - **Overlap and no `--layer`** → print the overlap and every flag value, then
|
|
2178
|
+
* return `false`. The caller exits non-zero. An overlap is not something to
|
|
2179
|
+
* resolve by guessing; a wrong guess either doubles the context or hides a
|
|
2180
|
+
* layer the user wanted.
|
|
2181
|
+
* - **Overlap and `--layer`** → apply it. `global` / `project` keep that layer
|
|
2182
|
+
* and suppress the other; `both-acknowledged` keeps both and states the cost.
|
|
2183
|
+
* - **Divergent bodies** → warn and refuse even WITH a `--layer`, unless the
|
|
2184
|
+
* choice is `both-acknowledged`. Suppressing a layer whose copies differ
|
|
2185
|
+
* drops whatever only the suppressed copy carried; that is a lost obligation,
|
|
2186
|
+
* not a saved token.
|
|
2187
|
+
*
|
|
2188
|
+
* This function never deletes and never rewrites a rule file. Deleting a user's
|
|
2189
|
+
* `~/.claude/rules/` would be a Hard-Floor action per
|
|
2190
|
+
* `non-destructive-by-default`, so no path here can reach one.
|
|
2191
|
+
*
|
|
2192
|
+
* **Known limitation — `--dry-run` does not exercise this gate.** `main()`
|
|
2193
|
+
* returns from `_dry_run_summary` before scope and tools are resolved, so the
|
|
2194
|
+
* call site below is unreachable in a dry run. The `dry_run` parameter is
|
|
2195
|
+
* therefore always `false` today; it is kept because it states the correct
|
|
2196
|
+
* contract for the day the dry-run summary moves, and because a suppression
|
|
2197
|
+
* write during a dry run would be the wrong behaviour whatever the ordering.
|
|
2198
|
+
* Surfacing the overlap in the dry-run report needs scope resolution hoisted
|
|
2199
|
+
* above that early return — a `main()` restructure, deliberately not bundled
|
|
2200
|
+
* here. Tests therefore call this function directly rather than through the CLI.
|
|
2201
|
+
*/
|
|
2202
|
+
export function _gate_rule_layer_overlap(
|
|
2203
|
+
project_root: string,
|
|
2204
|
+
layer: string | null,
|
|
2205
|
+
dry_run: boolean,
|
|
2206
|
+
): boolean {
|
|
2207
|
+
const global_dir = path.join(os.homedir(), '.claude', 'rules');
|
|
2208
|
+
const project_dir = path.join(project_root, '.claude', 'rules');
|
|
2209
|
+
const g = rule_layer_overlap.readRuleLayer(global_dir);
|
|
2210
|
+
const p = rule_layer_overlap.readRuleLayer(project_dir);
|
|
2211
|
+
if (g === null || p === null) return true; // only one layer exists — nothing to double
|
|
2212
|
+
const report = rule_layer_overlap.compareLayers(g.files, p.files);
|
|
2213
|
+
if (report.overlap.length === 0) return true;
|
|
2214
|
+
|
|
2215
|
+
if (layer === null) {
|
|
2216
|
+
warn(
|
|
2217
|
+
`rule layers overlap: ${report.overlap.length} rule(s) are in BOTH `
|
|
2218
|
+
+ `${global_dir} and ${project_dir}, so Claude Code loads them twice `
|
|
2219
|
+
+ `(${report.redundant_chars} chars of standing context per session).`,
|
|
2220
|
+
);
|
|
2221
|
+
info('Pick which layer owns the corpus — no file is deleted either way:');
|
|
2222
|
+
info(` --layer=global keep ${global_dir}, suppress the project one`);
|
|
2223
|
+
info(` --layer=project keep ${project_dir}, suppress the global one`);
|
|
2224
|
+
info(' --layer=both-acknowledged keep both and pay the doubled context knowingly');
|
|
2225
|
+
return false;
|
|
2226
|
+
}
|
|
2227
|
+
|
|
2228
|
+
const action = rule_layer_overlap.decideLayerAction(
|
|
2229
|
+
report,
|
|
2230
|
+
layer as rule_layer_overlap.LayerChoice,
|
|
2231
|
+
global_dir,
|
|
2232
|
+
project_dir,
|
|
2233
|
+
);
|
|
2234
|
+
if (action.refresh_required && action.suppress_dir !== null) {
|
|
2235
|
+
warn(
|
|
2236
|
+
`${report.divergent.length} shared rule(s) differ in body between the two layers `
|
|
2237
|
+
+ `(${report.divergent.slice(0, 5).join(', ')}${report.divergent.length > 5 ? ', …' : ''}). `
|
|
2238
|
+
+ 'Suppressing a layer now would drop whatever only that copy carries. '
|
|
2239
|
+
+ 'Re-run the projection first, or pass --layer=both-acknowledged.',
|
|
2240
|
+
);
|
|
2241
|
+
return false;
|
|
2242
|
+
}
|
|
2243
|
+
info(action.note);
|
|
2244
|
+
if (action.suppress_dir !== null && !dry_run) {
|
|
2245
|
+
_suppress_rule_layer(project_root, action.suppress_dir);
|
|
2246
|
+
}
|
|
2247
|
+
return true;
|
|
2116
2248
|
}
|
|
2117
2249
|
|
|
2118
2250
|
function _resolve_scope(
|
|
@@ -3849,6 +3981,7 @@ const USAGE =
|
|
|
3849
3981
|
' [--tools TOOLS] [--ai AI] [--packs PACKS] [--core-only]\n' +
|
|
3850
3982
|
' [--no-smoke] [--global]\n' +
|
|
3851
3983
|
' [--scope {project,global,prompt,auto}]\n' +
|
|
3984
|
+
' [--layer {global,project,both-acknowledged}]\n' +
|
|
3852
3985
|
' [--custom-path CUSTOM_PATH] [--offline] [--minimal]\n' +
|
|
3853
3986
|
' [--interactive] [--no-ui] [--dry-run]\n' +
|
|
3854
3987
|
' [--apply-payload APPLY_PAYLOAD]\n';
|
|
@@ -3884,6 +4017,7 @@ const _VALUE_FLAGS: Record<string, keyof Options> = {
|
|
|
3884
4017
|
'--scope': 'scope',
|
|
3885
4018
|
'--custom-path': 'custom_path',
|
|
3886
4019
|
'--apply-payload': 'apply_payload',
|
|
4020
|
+
'--layer': 'layer',
|
|
3887
4021
|
};
|
|
3888
4022
|
|
|
3889
4023
|
function _argError(msg: string): never {
|
|
@@ -3920,6 +4054,7 @@ function parse_options(argv: string[]): Options {
|
|
|
3920
4054
|
no_ui: false,
|
|
3921
4055
|
dry_run: false,
|
|
3922
4056
|
apply_payload: null,
|
|
4057
|
+
layer: null,
|
|
3923
4058
|
};
|
|
3924
4059
|
|
|
3925
4060
|
const positionals: string[] = [];
|
|
@@ -3960,6 +4095,12 @@ function parse_options(argv: string[]): Options {
|
|
|
3960
4095
|
"(choose from 'project', 'global', 'prompt', 'auto')",
|
|
3961
4096
|
);
|
|
3962
4097
|
}
|
|
4098
|
+
if (flag === '--layer' && !RULE_LAYER_CHOICES.includes(value)) {
|
|
4099
|
+
_argError(
|
|
4100
|
+
`argument --layer: invalid choice: '${value}' ` +
|
|
4101
|
+
`(choose from ${RULE_LAYER_CHOICES.map((c) => `'${c}'`).join(', ')})`,
|
|
4102
|
+
);
|
|
4103
|
+
}
|
|
3963
4104
|
opts[valueDest] = value;
|
|
3964
4105
|
i += 1;
|
|
3965
4106
|
continue;
|
|
@@ -4731,6 +4872,16 @@ function main(argv: string[]): number {
|
|
|
4731
4872
|
const tools_was_all = _tools_was_all(opts.tools);
|
|
4732
4873
|
parsed_tools = _validate_scope(parsed_tools, scope, tools_was_all);
|
|
4733
4874
|
|
|
4875
|
+
// Refuse a silently doubled Claude Code rule corpus. Runs before EITHER
|
|
4876
|
+
// scope branch writes: a global install can double an existing project layer
|
|
4877
|
+
// and a project install can double an existing global one, and the gate
|
|
4878
|
+
// reads both directories regardless of which one this run is about to write.
|
|
4879
|
+
// Claude-only, because `claudeMdExcludes` and the two-rules-dir load order
|
|
4880
|
+
// are that host's semantics — a cursor-only install is not affected.
|
|
4881
|
+
if (parsed_tools.has('claude-code') && !_gate_rule_layer_overlap(detect_root, opts.layer, opts.dry_run)) {
|
|
4882
|
+
return 2;
|
|
4883
|
+
}
|
|
4884
|
+
|
|
4734
4885
|
const wizard_handoff = _wizard_should_launch(opts)[0];
|
|
4735
4886
|
|
|
4736
4887
|
if (scope === 'global') {
|
|
@@ -74,6 +74,7 @@ import { fileURLToPath, pathToFileURL } from "node:url";
|
|
|
74
74
|
|
|
75
75
|
import { atomic_write_json } from "./hooks/state_io.js";
|
|
76
76
|
import { readHookStdin } from "./hooks/hook_stdin.js";
|
|
77
|
+
import { humanAuthoredLead, isSyntheticPrompt } from "./_lib/prompt_shape.js";
|
|
77
78
|
|
|
78
79
|
const EXIT_ALLOW = 0;
|
|
79
80
|
// Severity is taken from the EXIT CODE, not from the `decision` field in the
|
|
@@ -162,8 +163,32 @@ export function instructionText(prompt: string): string {
|
|
|
162
163
|
}
|
|
163
164
|
|
|
164
165
|
|
|
166
|
+
/**
|
|
167
|
+
* Classify a prompt, reading the TYPED LEAD before the whole body.
|
|
168
|
+
*
|
|
169
|
+
* `instructionText` removes output-shaped pastes — traces, diffs, `remote:`
|
|
170
|
+
* lines. It does not remove the other paste shape, which looks like prose: a
|
|
171
|
+
* document pasted whole under its own headings below one typed sentence. That
|
|
172
|
+
* gap is not hypothetical; it fired on the session that received the round-6
|
|
173
|
+
* review, pinning English because an English draft sat under German prose.
|
|
174
|
+
*
|
|
175
|
+
* So the lead is classified first, and only if it is undetermined does the full
|
|
176
|
+
* text decide. This is bidirectional by construction — it names no language, and
|
|
177
|
+
* an English instruction over a German paste resolves to English by the same
|
|
178
|
+
* step. The fallback is what keeps it conservative: a prompt with no document
|
|
179
|
+
* marker classifies exactly as it did before.
|
|
180
|
+
*/
|
|
165
181
|
export function classify(prompt: string): Classification {
|
|
166
182
|
const text = instructionText(prompt);
|
|
183
|
+
const lead = humanAuthoredLead(text);
|
|
184
|
+
const leadVerdict = _score(lead);
|
|
185
|
+
if (leadVerdict.language !== "und") {
|
|
186
|
+
return leadVerdict;
|
|
187
|
+
}
|
|
188
|
+
return _score(text);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function _score(text: string): Classification {
|
|
167
192
|
const de = (text.match(DE_MARKERS) ?? []).length;
|
|
168
193
|
const en = (text.match(EN_MARKERS) ?? []).length;
|
|
169
194
|
if (de < MIN_MARKERS && en < MIN_MARKERS) {
|
|
@@ -424,44 +449,12 @@ export function run(
|
|
|
424
449
|
}
|
|
425
450
|
|
|
426
451
|
/**
|
|
427
|
-
*
|
|
428
|
-
*
|
|
429
|
-
*
|
|
430
|
-
*
|
|
431
|
-
*
|
|
432
|
-
* { "language": "en", "source": "prompt", "prompt_chars": 6627,
|
|
433
|
-
* "de_markers": 0, "en_markers": 63 }
|
|
434
|
-
*
|
|
435
|
-
* No human wrote those 6,627 characters. They were a background-task completion
|
|
436
|
-
* notification, injected as a user turn, which `user_prompt_submit` sees and this
|
|
437
|
-
* hook classified as the trigger — flipping a German session to `en` for every
|
|
438
|
-
* later turn, and recording `source: "prompt"`, a false provenance claim.
|
|
439
|
-
*
|
|
440
|
-
* This is the SAME defect the hook was built to remove: non-chat content read as
|
|
441
|
-
* the trigger. The header above explains at length why a skill body must not set
|
|
442
|
-
* the pin; `user_prompt_submit` was chosen because "a skill body never reaches
|
|
443
|
-
* it". Synthetic turns do reach it, so the event alone was never sufficient — the
|
|
444
|
-
* content test has to exist too.
|
|
445
|
-
*
|
|
446
|
-
* The markers are structural, not linguistic: the harness stamps these turns
|
|
447
|
-
* with fixed envelope tags. Matching prose would misfire on a human quoting a
|
|
448
|
-
* notification; matching the tags cannot, because a human typing `<task-notification>`
|
|
449
|
-
* is quoting, and a quote does not open at character zero of the turn.
|
|
450
|
-
*
|
|
451
|
-
* Deliberately conservative: an unrecognised synthetic shape falls through and
|
|
452
|
-
* pins as before. Under-filtering keeps today's behaviour; over-filtering would
|
|
453
|
-
* silently stop pinning real prompts, which is the worse failure.
|
|
452
|
+
* Re-exported from the shared module so this hook and `conformance_scan` cannot
|
|
453
|
+
* classify the same entry differently. It lived here, and the scanner never
|
|
454
|
+
* imported it — that divergence is what Phase 2.1 of round 6 closes. Kept as a
|
|
455
|
+
* named export because it is part of this module's tested surface.
|
|
454
456
|
*/
|
|
455
|
-
export
|
|
456
|
-
const head = prompt.trimStart().slice(0, 400);
|
|
457
|
-
return (
|
|
458
|
-
head.startsWith("[SYSTEM NOTIFICATION - NOT USER INPUT]") ||
|
|
459
|
-
head.startsWith("<task-notification>") ||
|
|
460
|
-
head.startsWith("<system-reminder>") ||
|
|
461
|
-
head.startsWith("<local-command-caveat>") ||
|
|
462
|
-
/^\[SYSTEM NOTIFICATION\b/.test(head)
|
|
463
|
-
);
|
|
464
|
-
}
|
|
457
|
+
export { isSyntheticPrompt };
|
|
465
458
|
|
|
466
459
|
export function main(argv?: string[]): number {
|
|
467
460
|
const args = argv ?? process.argv.slice(2);
|