@event4u/agent-config 14.2.0 → 14.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +33 -252
- package/agents/templates/.ai-council.yml.example +35 -0
- package/dist/agent-src/contexts/execution/roadmap-process-loop.md +83 -12
- package/dist/agent-src/rules/roadmap-progress-sync.md +48 -4
- package/dist/agent-src/scripts/resume_probe.ts +154 -9
- package/dist/agent-src/scripts/roadmap_gates.ts +210 -6
- package/dist/agent-src/skills/roadmap-management/SKILL.md +9 -4
- package/dist/agent-src/templates/agent-settings.md +70 -0
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/agent-src/templates/scripts/telemetry/remote.ts +571 -0
- package/dist/agent-src/templates/scripts/telemetry/settings.ts +222 -44
- package/dist/cli/python/workspace_drive.js +8 -3
- package/dist/cli/python/workspace_drive.js.map +1 -1
- package/dist/cli/python/workspace_hosts.js +16 -12
- package/dist/cli/python/workspace_hosts.js.map +1 -1
- package/dist/cli/registry.js +2 -0
- package/dist/cli/registry.js.map +1 -1
- package/dist/cli-delegate/{chunk-F3YUF7SA.js → chunk-3UCJWMMP.js} +1 -1
- package/dist/cli-delegate/chunk-F7DQWYR5.js +69 -0
- package/dist/cli-delegate/{chunk-7PZ4FSZB.js → chunk-FBKL6JWT.js} +61 -2
- package/dist/cli-delegate/{chunk-MNWRDMR5.js → chunk-PKOBQADP.js} +1 -1
- package/dist/cli-delegate/cmd_conformance.js +26 -11
- package/dist/cli-delegate/cmd_doctor.js +2 -2
- package/dist/cli-delegate/cmd_handoff.js +3 -3
- package/dist/cli-delegate/cmd_route_audit.js +2 -2
- package/dist/cli-delegate/cmd_settings_set.js +17 -3
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +4 -4
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +1 -1
- package/dist/discovery/dormancy-report.md +1 -1
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +2 -2
- package/dist/discovery/trust-report.md +1 -1
- package/dist/discovery/workspaces.json +2 -2
- package/dist/hooks/dispatch.js +3754 -1721
- package/dist/mcp/registry-manifest.json +2 -2
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +1070 -408
- package/dist/scripts/_lib/host_launch.js +66 -0
- package/dist/scripts/_lib/host_launch.js.map +1 -0
- package/dist/server/io/yamlIO.js +175 -3
- package/dist/server/io/yamlIO.js.map +1 -1
- package/dist/server/routes/wizard.js +20 -1
- package/dist/server/routes/wizard.js.map +1 -1
- package/dist/shared/settingsConsent.js +25 -0
- package/dist/shared/settingsConsent.js.map +1 -1
- package/docs/contracts/settings-classes.md +11 -0
- package/docs/guidelines/agent-infra/roadmap-progress-mechanics.md +2 -1
- package/package.json +1 -1
- package/src/agent-src/scripts/resume_probe.ts +154 -9
- package/src/agent-src/scripts/roadmap_gates.ts +210 -6
- package/src/agent-src/templates/scripts/telemetry/remote.ts +571 -0
- package/src/agent-src/templates/scripts/telemetry/settings.ts +222 -44
- package/src/config/estate-count-budget.json +127 -0
- package/src/config/evaluator-budgets.json +4 -4
- package/src/config/gate-coverage.yml +32 -4
- package/src/config/gate-violation-baselines.json +63 -63
- package/src/config/gitignore-block.txt +5 -0
- package/src/config/hook-latency-budget.json +40 -1
- package/src/config/hook-token-budget.json +16 -0
- package/src/config/rule-stub-ceilings.json +11 -3
- package/src/scripts/_dispatch.bash +42 -1
- package/src/scripts/_lib/council_fallback_posture.ts +81 -0
- package/src/scripts/_lib/council_fallback_wiring.ts +126 -0
- package/src/scripts/_lib/council_settings_block.ts +152 -0
- package/src/scripts/_lib/headless_invocation.ts +285 -0
- package/src/scripts/_lib/host_launch.ts +72 -0
- package/src/scripts/_lib/map_to_object.ts +16 -0
- package/src/scripts/_lib/roadmap_checkboxes.ts +88 -0
- package/src/scripts/_lib/run_checkpoint.ts +409 -0
- package/src/scripts/_lib/session_register.ts +32 -1
- package/src/scripts/_lib/stdin.ts +35 -1
- package/src/scripts/_lib/turn_end_refusals.ts +12 -4
- package/src/scripts/_lib/unattended_guard.ts +371 -0
- package/src/scripts/ai_council/config.ts +27 -1
- package/src/scripts/ai_council/events_log.ts +12 -1
- package/src/scripts/ai_council/fallback_config.ts +114 -0
- package/src/scripts/ai_council/mid_flight_fallback.ts +295 -0
- package/src/scripts/ai_council/orchestrator.ts +251 -292
- package/src/scripts/ai_council/py_parity.ts +139 -0
- package/src/scripts/ai_council/response_render.ts +99 -0
- package/src/scripts/ai_council/spend_gate.ts +123 -0
- package/src/scripts/ai_council/transport_resolver.ts +81 -13
- package/src/scripts/bench_hook_injection.ts +118 -7
- package/src/scripts/bench_hook_latency.ts +442 -16
- package/src/scripts/check_estate_count.ts +924 -0
- package/src/scripts/compile_hook_manifest.ts +67 -0
- package/src/scripts/council_cli.ts +100 -125
- package/src/scripts/decision_memo.ts +325 -0
- package/src/scripts/hook_manifest.json +1 -0
- package/src/scripts/hook_manifest.yaml +172 -25
- package/src/scripts/hooks/concern_registry.ts +6 -0
- package/src/scripts/hooks/dispatch_hook.ts +183 -10
- package/src/scripts/hooks/dispatch_issues.ts +26 -0
- package/src/scripts/hooks/end_review_nudge_hook.ts +97 -24
- package/src/scripts/hooks/hook_stdin.ts +111 -5
- package/src/scripts/hooks/host_semantics.ts +28 -0
- package/src/scripts/hooks/injection_budget.ts +731 -0
- package/src/scripts/hooks/payload_stub.ts +446 -0
- package/src/scripts/hooks/run_continuation_hook.ts +683 -0
- package/src/scripts/hooks/session_eol_hook.ts +52 -0
- package/src/scripts/hooks/telemetry_usage_hook.ts +339 -0
- package/src/scripts/hooks/tool_result_bytes_hook.ts +27 -0
- package/src/scripts/hooks/turn_end_gate_hook.ts +45 -7
- package/src/scripts/hooks_doctor.ts +16 -2
- package/src/scripts/interruption_report.ts +330 -13
- package/src/scripts/lint_hook_manifest.ts +167 -0
- package/src/scripts/roadmap_progress_hook.ts +254 -46
- package/src/scripts/run_supervise.ts +623 -0
- package/src/scripts/session_register_hook.ts +146 -12
- package/src/scripts/sessions_cli.ts +18 -6
- package/src/scripts/telemetry_disclosure_hook.ts +244 -0
- package/src/server/io/yamlIO.ts +167 -3
- package/src/shared/settingsConsent.ts +49 -2
- package/dist/cli-delegate/chunk-MMJL4TIX.js +0 -18
|
@@ -54,6 +54,8 @@ import {
|
|
|
54
54
|
scanEolSlice,
|
|
55
55
|
type EolCounters,
|
|
56
56
|
} from '../_lib/session_eol.js';
|
|
57
|
+
import { buildCheckpoint, writeCheckpoint } from '../_lib/run_checkpoint.js';
|
|
58
|
+
import { read_claimed_slug } from '../session_register_hook.js';
|
|
57
59
|
import { unwrap, type JsonObject, type JsonValue } from './envelope.js';
|
|
58
60
|
import { readHookStdin } from './hook_stdin.js';
|
|
59
61
|
import { atomic_write_json, is_replay_mode } from './state_io.js';
|
|
@@ -107,6 +109,23 @@ export function deriveSessionKey(envelope: JsonObject, payload: JsonObject): str
|
|
|
107
109
|
);
|
|
108
110
|
}
|
|
109
111
|
|
|
112
|
+
/**
|
|
113
|
+
* The RAW session id, not the hashed key.
|
|
114
|
+
*
|
|
115
|
+
* `read_claimed_slug` keys the per-session claim file off the id the host
|
|
116
|
+
* exported, so handing it `deriveSessionKey`'s hash would look up a file that
|
|
117
|
+
* cannot exist and silently report "no contract" for every claimed run.
|
|
118
|
+
* Falls back to the envelope, which is where the dispatcher puts it, and
|
|
119
|
+
* deliberately does NOT fall back to the transcript path: that is a filename,
|
|
120
|
+
* not an identity, and `sessions:claim` never wrote a claim under one.
|
|
121
|
+
*/
|
|
122
|
+
export function payloadSessionId(payload: JsonObject, envelope: JsonObject): string {
|
|
123
|
+
return (
|
|
124
|
+
str(payload['session_id'] as JsonValue | undefined) ||
|
|
125
|
+
str(envelope['session_id'] as JsonValue | undefined)
|
|
126
|
+
).trim();
|
|
127
|
+
}
|
|
128
|
+
|
|
110
129
|
/** Load prior per-session state; any read/shape error reads as "fresh session". */
|
|
111
130
|
export function readState(file: string): SessionEolState {
|
|
112
131
|
try {
|
|
@@ -337,6 +356,39 @@ export function main(): number {
|
|
|
337
356
|
|
|
338
357
|
writeContextFill(workspaceRoot, tokens, threshold, now);
|
|
339
358
|
|
|
359
|
+
// ── UOTL Phase 6.1 — the deterministic half of the handoff ───────
|
|
360
|
+
//
|
|
361
|
+
// Above the threshold AND inside a running contract, leave a derived
|
|
362
|
+
// checkpoint behind. Two properties the advisory alone cannot give:
|
|
363
|
+
//
|
|
364
|
+
// · It costs no judgement. Every field is recomputed from the
|
|
365
|
+
// roadmap on disk, so a dying session produces it correctly even
|
|
366
|
+
// when it has no context left to summarise anything.
|
|
367
|
+
// · It is re-verifiable. A resumed run recomputes the same fields
|
|
368
|
+
// and can name WHICH claim went stale rather than trusting the
|
|
369
|
+
// record, which is the deliberate departure from resuming by
|
|
370
|
+
// bookkeeping (Phase 3.2).
|
|
371
|
+
//
|
|
372
|
+
// "Inside a running contract" is the same carrier `run-continuation`
|
|
373
|
+
// uses — a `sessions:claim` for this session — and no second one is
|
|
374
|
+
// invented. Outside a contract this is silent: a checkpoint for a
|
|
375
|
+
// conversational session names work nobody is executing.
|
|
376
|
+
//
|
|
377
|
+
// Best-effort throughout. A checkpoint is a recovery aid, and a
|
|
378
|
+
// recovery aid that can fail a Stop is a liability.
|
|
379
|
+
const checkpointRunId = payloadSessionId(payload, envelope);
|
|
380
|
+
if (shouldAdvise && checkpointRunId !== '') {
|
|
381
|
+
try {
|
|
382
|
+
const slug = read_claimed_slug(workspaceRoot, checkpointRunId);
|
|
383
|
+
if (slug !== null) {
|
|
384
|
+
const cp = buildCheckpoint(workspaceRoot, eolSessionKey(checkpointRunId), slug);
|
|
385
|
+
if (cp !== null) writeCheckpoint(workspaceRoot, cp);
|
|
386
|
+
}
|
|
387
|
+
} catch {
|
|
388
|
+
// never block the Stop path
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
340
392
|
if (shouldAdvise && threshold !== null && tokens !== null) {
|
|
341
393
|
process.stdout.write(
|
|
342
394
|
`${JSON.stringify({
|
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
#!/usr/bin/env tsx
|
|
2
|
+
/**
|
|
3
|
+
* Deterministic PostToolUse Class-A usage capture
|
|
4
|
+
* (road-to-org-telemetry Phase 1, step 1.2).
|
|
5
|
+
*
|
|
6
|
+
* Why a hook rather than the existing collector. `skill_usage_collect.ts`
|
|
7
|
+
* scans the maintainer's own transcript directory and derives its project
|
|
8
|
+
* slug from this repository, so a consumer session is invisible to it by
|
|
9
|
+
* construction — the roadmap's Context table records that as still true
|
|
10
|
+
* after 479 commits. Phase 0's third spike then measured the size of the
|
|
11
|
+
* gap on the set the collector DOES read: **0 of 89** invocations detected,
|
|
12
|
+
* against 163 of 164 across every worktree slug. The zero in the usage
|
|
13
|
+
* report is an instrumentation artefact, not an adoption measurement.
|
|
14
|
+
*
|
|
15
|
+
* Phase 0's first spike settled the event this concern binds to:
|
|
16
|
+
* `post_tool_use` with `tool_name === "Skill"`, the skill name at
|
|
17
|
+
* `tool_input.skill` — present in 164 of 164 real invocations across 14,171
|
|
18
|
+
* live records. The transcript-scan fallback the roadmap pre-registered does
|
|
19
|
+
* not fire.
|
|
20
|
+
*
|
|
21
|
+
* DEFAULT-OFF, AND THE SWITCH IS NOT `enabled`. `read_remote_settings`
|
|
22
|
+
* activates only when `enabled` is true AND the org pack supplied an
|
|
23
|
+
* endpoint, an org id, and a salt. An external clone of this repository
|
|
24
|
+
* carries the key names and no values, so copying the tree cannot reach the
|
|
25
|
+
* write path. When inactive this concern performs zero telemetry file
|
|
26
|
+
* operations — no log write, no directory creation — and zero network calls.
|
|
27
|
+
* (It does read `.agent-settings.yml`, once per process and memoised: the
|
|
28
|
+
* settings file is how it learns it is off, the same as the artefact-
|
|
29
|
+
* engagement surface it mirrors.)
|
|
30
|
+
*
|
|
31
|
+
* NO OUTBOUND CALL EXISTS HERE, DELIBERATELY. Transport is Phase 2 and is
|
|
32
|
+
* blocked on `sink-choice`; org-wide enablement is Phase 3 and is blocked on
|
|
33
|
+
* `dpo-signoff`. This concern appends to a local JSONL file and stops.
|
|
34
|
+
*
|
|
35
|
+
* Exit code is ALWAYS 0 — malformed envelope, unreadable settings, failed
|
|
36
|
+
* write, anything. A hook `warn` (exit 2) is read as a hard BLOCK on this
|
|
37
|
+
* host (recorded trap), and a telemetry concern has nothing to say to the
|
|
38
|
+
* model, so it never warns either.
|
|
39
|
+
*/
|
|
40
|
+
import * as os from 'node:os';
|
|
41
|
+
import * as path from 'node:path';
|
|
42
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
43
|
+
import * as fs from 'node:fs';
|
|
44
|
+
|
|
45
|
+
import {
|
|
46
|
+
read_remote_settings,
|
|
47
|
+
type RemoteTelemetrySettings,
|
|
48
|
+
} from '../../agent-src/templates/scripts/telemetry/settings.js';
|
|
49
|
+
import {
|
|
50
|
+
append_class_a_record,
|
|
51
|
+
build_class_a_record,
|
|
52
|
+
profile_from_legacy_tier,
|
|
53
|
+
} from '../../agent-src/templates/scripts/telemetry/remote.js';
|
|
54
|
+
import { is_replay_mode } from './state_io.js';
|
|
55
|
+
import { readHookStdin } from './hook_stdin.js';
|
|
56
|
+
|
|
57
|
+
const EXIT_ALLOW = 0;
|
|
58
|
+
|
|
59
|
+
/** The tool name Phase 0 spike 1 confirmed for a skill invocation. */
|
|
60
|
+
export const SKILL_TOOL_NAME = 'Skill';
|
|
61
|
+
|
|
62
|
+
const SETTINGS_FILENAME = '.agent-settings.yml';
|
|
63
|
+
|
|
64
|
+
type JsonValue = string | number | boolean | null | JsonValue[] | { [k: string]: JsonValue };
|
|
65
|
+
type JsonObject = { [k: string]: JsonValue };
|
|
66
|
+
|
|
67
|
+
function isObject(v: unknown): v is JsonObject {
|
|
68
|
+
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Unwrap the dispatcher envelope down to the platform-native payload. */
|
|
72
|
+
export function unwrapPayload(envelope: JsonObject): JsonObject {
|
|
73
|
+
const inner = envelope['payload'];
|
|
74
|
+
return isObject(inner) ? inner : envelope;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Best-effort read of the tool name across host key variants. */
|
|
78
|
+
export function extractToolName(payload: JsonObject): string | null {
|
|
79
|
+
const v = payload['tool_name'] ?? payload['toolName'] ?? payload['tool'];
|
|
80
|
+
return typeof v === 'string' && v ? v : null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The invoked skill's name, off `tool_input.skill`.
|
|
85
|
+
*
|
|
86
|
+
* Spike 1 observed this key on 164 of 164 real invocations; the camelCase
|
|
87
|
+
* variant is accepted for the same reason `extractToolName` accepts one —
|
|
88
|
+
* host key casing has varied across versions in this tree before.
|
|
89
|
+
*/
|
|
90
|
+
export function extractSkillName(payload: JsonObject): string | null {
|
|
91
|
+
const input = payload['tool_input'] ?? payload['toolInput'];
|
|
92
|
+
if (!isObject(input)) return null;
|
|
93
|
+
const v = input['skill'] ?? input['skill_name'] ?? input['name'];
|
|
94
|
+
return typeof v === 'string' && v ? v : null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Host id off the dispatcher envelope (`claude`, `augment`, …). */
|
|
98
|
+
export function extractHost(envelope: JsonObject): string | null {
|
|
99
|
+
const v = envelope['platform'] ?? envelope['host'];
|
|
100
|
+
return typeof v === 'string' && v ? v : null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The host session id. Hashed before it is recorded, never stored raw —
|
|
105
|
+
* it is a high-entropy opaque token that `check_secret_leak` reads as a
|
|
106
|
+
* candidate credential.
|
|
107
|
+
*/
|
|
108
|
+
export function extractSessionId(envelope: JsonObject, payload: JsonObject): string {
|
|
109
|
+
for (const source of [envelope, payload]) {
|
|
110
|
+
const v = source['session_id'] ?? source['sessionId'];
|
|
111
|
+
if (typeof v === 'string' && v) return v;
|
|
112
|
+
}
|
|
113
|
+
return '';
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Read one top-level scalar out of the settings text.
|
|
118
|
+
*
|
|
119
|
+
* Deliberately a raw line scan rather than a YAML parse: this runs on the
|
|
120
|
+
* post-tool hot path, both values wanted here are top-level scalars from
|
|
121
|
+
* closed enums, and `build_class_a_record` rejects anything outside those
|
|
122
|
+
* enums — so a mis-parse produces a dropped field, never a wrong one.
|
|
123
|
+
*
|
|
124
|
+
* Accepts a trailing inline comment, which this file's own settings
|
|
125
|
+
* template uses heavily (`discipline_profile: essential # ~3.3x kernel
|
|
126
|
+
* tokens`); rejecting it would have silently reported "not declared" for a
|
|
127
|
+
* perfectly ordinary line.
|
|
128
|
+
*/
|
|
129
|
+
function _scalar(settingsText: string, key: string): string | null {
|
|
130
|
+
const re = new RegExp(`^${key}:[ \\t]*([^#\\r\\n]+?)[ \\t]*(?:#.*)?$`, 'u');
|
|
131
|
+
for (const line of settingsText.split('\n')) {
|
|
132
|
+
const m = re.exec(line);
|
|
133
|
+
if (m && m[1]) {
|
|
134
|
+
const value = m[1].trim().replace(/^["']|["']$/gu, '');
|
|
135
|
+
if (!value) return null;
|
|
136
|
+
// The shipped template carries a placeholder until `install`
|
|
137
|
+
// fills it in; that is "not yet resolved", not a value.
|
|
138
|
+
if (value.startsWith('__')) return null;
|
|
139
|
+
return value;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* The DECLARED discipline profile — the knob that actually decides which
|
|
147
|
+
* rule surfaces load.
|
|
148
|
+
*
|
|
149
|
+
* The first version of this function read `rule_loading_tier`, and that was
|
|
150
|
+
* wrong: the settings template calls that key a "legacy knob — superseded by
|
|
151
|
+
* discipline_profile above", and `resolve_discipline_profile` confirms the
|
|
152
|
+
* precedence — explicit `discipline_profile` wins outright and the legacy
|
|
153
|
+
* key is consulted only in its absence. So a default install would have had
|
|
154
|
+
* a knob recorded that does not decide the thing the field implies.
|
|
155
|
+
*
|
|
156
|
+
* The fallback mirrors the resolver's own legacy mapping, which needs no
|
|
157
|
+
* model id and is therefore safe to reproduce here. What is NOT reproduced
|
|
158
|
+
* is the resolver's both-absent default (`essential`): recording it would
|
|
159
|
+
* claim a declaration the install never made. Absent stays `null`, and a
|
|
160
|
+
* reader applies the documented default themselves.
|
|
161
|
+
*/
|
|
162
|
+
export function extractDisciplineProfile(settingsText: string): string | null {
|
|
163
|
+
const explicit = _scalar(settingsText, 'discipline_profile');
|
|
164
|
+
if (explicit !== null) return explicit;
|
|
165
|
+
return profile_from_legacy_tier(_scalar(settingsText, 'rule_loading_tier'));
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Installed package version, from the env the installer sets. Never
|
|
170
|
+
* fabricated: an install that did not report one records `null`.
|
|
171
|
+
*/
|
|
172
|
+
export function extractPackageVersion(env: NodeJS.ProcessEnv): string | null {
|
|
173
|
+
const v = env['AGENT_CONFIG_PACKAGE_VERSION'];
|
|
174
|
+
return typeof v === 'string' && v.trim() ? v.trim() : null;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
interface SettingsRead {
|
|
178
|
+
settings: RemoteTelemetrySettings;
|
|
179
|
+
text: string;
|
|
180
|
+
/**
|
|
181
|
+
* The directory the settings file was found in — the project root, and
|
|
182
|
+
* the base a relative `output.path` resolves against. NOT the session
|
|
183
|
+
* cwd: a session started in a subdirectory would otherwise scatter one
|
|
184
|
+
* log file per directory it happened to start in.
|
|
185
|
+
*/
|
|
186
|
+
root: string;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Read + memoise the settings for one process. The in-process dispatcher
|
|
191
|
+
* runs every concern in one `node`, so without this the file would be read
|
|
192
|
+
* once per concern per tool call.
|
|
193
|
+
*/
|
|
194
|
+
const _settingsCache = new Map<string, SettingsRead>();
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Walk up from `start` to the first directory holding a settings file.
|
|
198
|
+
*
|
|
199
|
+
* A host may report a session `cwd` inside a subdirectory of the project,
|
|
200
|
+
* and reading only that directory made the concern silently inactive on a
|
|
201
|
+
* legitimately enabled install — a failure with no message, which is the
|
|
202
|
+
* worst shape for a default-off surface. Bounded by the filesystem root, so
|
|
203
|
+
* it terminates on every path; no hit returns `start` unchanged, and the
|
|
204
|
+
* settings read then fails closed exactly as before.
|
|
205
|
+
*/
|
|
206
|
+
export function resolveSettingsPath(start: string): string {
|
|
207
|
+
let dir = path.resolve(start);
|
|
208
|
+
for (;;) {
|
|
209
|
+
const candidate = path.join(dir, SETTINGS_FILENAME);
|
|
210
|
+
try {
|
|
211
|
+
if (fs.statSync(candidate).isFile()) return candidate;
|
|
212
|
+
} catch {
|
|
213
|
+
// Not here — keep walking.
|
|
214
|
+
}
|
|
215
|
+
const parent = path.dirname(dir);
|
|
216
|
+
if (parent === dir) return path.join(path.resolve(start), SETTINGS_FILENAME);
|
|
217
|
+
dir = parent;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export function readSettingsFor(consumerRoot: string): SettingsRead {
|
|
222
|
+
const p = resolveSettingsPath(consumerRoot);
|
|
223
|
+
const cached = _settingsCache.get(p);
|
|
224
|
+
if (cached !== undefined) return cached;
|
|
225
|
+
|
|
226
|
+
let text = '';
|
|
227
|
+
try {
|
|
228
|
+
text = fs.readFileSync(p, 'utf-8');
|
|
229
|
+
} catch {
|
|
230
|
+
text = '';
|
|
231
|
+
}
|
|
232
|
+
const read: SettingsRead = { settings: read_remote_settings(p), text, root: path.dirname(p) };
|
|
233
|
+
_settingsCache.set(p, read);
|
|
234
|
+
return read;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Test seam — the cache is process-global and tests write real files. */
|
|
238
|
+
export function _resetSettingsCache(): void {
|
|
239
|
+
_settingsCache.clear();
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function processEnvelope(envelope: JsonValue, consumer_root: string): number {
|
|
243
|
+
try {
|
|
244
|
+
if (!isObject(envelope)) return EXIT_ALLOW;
|
|
245
|
+
|
|
246
|
+
const payload = unwrapPayload(envelope);
|
|
247
|
+
if (extractToolName(payload) !== SKILL_TOOL_NAME) return EXIT_ALLOW;
|
|
248
|
+
|
|
249
|
+
const { settings, text, root } = readSettingsFor(consumer_root);
|
|
250
|
+
if (!settings.active) return EXIT_ALLOW;
|
|
251
|
+
if (is_replay_mode()) return EXIT_ALLOW;
|
|
252
|
+
|
|
253
|
+
const skill = extractSkillName(payload);
|
|
254
|
+
if (skill === null) return EXIT_ALLOW;
|
|
255
|
+
|
|
256
|
+
const record = build_class_a_record({
|
|
257
|
+
skill,
|
|
258
|
+
host: extractHost(envelope),
|
|
259
|
+
org_id: settings.org_id,
|
|
260
|
+
salt: settings.salt,
|
|
261
|
+
hostname: os.hostname(),
|
|
262
|
+
username: os.userInfo().username,
|
|
263
|
+
session_id: extractSessionId(envelope, payload),
|
|
264
|
+
package_version: extractPackageVersion(process.env),
|
|
265
|
+
discipline_profile: extractDisciplineProfile(text),
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
// Relative to the PROJECT root (the directory holding the settings
|
|
269
|
+
// file), never to the session cwd — otherwise a session started in a
|
|
270
|
+
// subdirectory writes its own stray log there.
|
|
271
|
+
const logPath = path.isAbsolute(settings.log_path)
|
|
272
|
+
? settings.log_path
|
|
273
|
+
: path.join(root, settings.log_path);
|
|
274
|
+
// The growth budget travels from the settings the install actually
|
|
275
|
+
// declared, not from the appender's own defaults — a retention key
|
|
276
|
+
// the write path never reads would be a setting that decorates
|
|
277
|
+
// rather than decides.
|
|
278
|
+
append_class_a_record(logPath, record, {
|
|
279
|
+
max_age_days: settings.retention_max_age_days,
|
|
280
|
+
max_bytes: settings.retention_max_bytes,
|
|
281
|
+
});
|
|
282
|
+
} catch {
|
|
283
|
+
// Malformed payload, unreadable disk, a rejected id — never block
|
|
284
|
+
// the tool call, and never write a degraded record.
|
|
285
|
+
return EXIT_ALLOW;
|
|
286
|
+
}
|
|
287
|
+
return EXIT_ALLOW;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
export function run(stdin_text: string, options: { consumer_root: string }): number {
|
|
291
|
+
let envelope: JsonValue;
|
|
292
|
+
try {
|
|
293
|
+
const raw = stdin_text.trim();
|
|
294
|
+
if (!raw) return EXIT_ALLOW;
|
|
295
|
+
envelope = JSON.parse(raw) as JsonValue;
|
|
296
|
+
} catch {
|
|
297
|
+
return EXIT_ALLOW;
|
|
298
|
+
}
|
|
299
|
+
return processEnvelope(envelope, options.consumer_root);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
export function _resolveRoot(envelope: JsonValue): string {
|
|
303
|
+
if (isObject(envelope)) {
|
|
304
|
+
const cwd = envelope['cwd'];
|
|
305
|
+
if (typeof cwd === 'string' && cwd) return cwd;
|
|
306
|
+
const pr = envelope['workspace_root'] ?? envelope['project_root'];
|
|
307
|
+
if (typeof pr === 'string' && pr) return pr;
|
|
308
|
+
}
|
|
309
|
+
return process.cwd();
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
export function main(): number {
|
|
313
|
+
const raw = readHookStdin();
|
|
314
|
+
let envelope: JsonValue = {};
|
|
315
|
+
try {
|
|
316
|
+
envelope = raw.trim() ? (JSON.parse(raw) as JsonValue) : {};
|
|
317
|
+
} catch {
|
|
318
|
+
return EXIT_ALLOW;
|
|
319
|
+
}
|
|
320
|
+
return processEnvelope(envelope, _resolveRoot(envelope));
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Bundle-safety: never auto-run when inlined into an esbuild bundle, where
|
|
324
|
+
// every module shares the bundle's `import.meta.url` (see cmd_migrate.ts).
|
|
325
|
+
declare const __AGENT_CONFIG_BUNDLE__: boolean | undefined;
|
|
326
|
+
function _isCliEntry(): boolean {
|
|
327
|
+
if (typeof __AGENT_CONFIG_BUNDLE__ !== 'undefined' && __AGENT_CONFIG_BUNDLE__) {
|
|
328
|
+
return false;
|
|
329
|
+
}
|
|
330
|
+
if (process.argv[1] === undefined) return false;
|
|
331
|
+
const argvUrl = pathToFileURL(path.resolve(process.argv[1])).href;
|
|
332
|
+
if (import.meta.url === argvUrl) return true;
|
|
333
|
+
try {
|
|
334
|
+
return fs.realpathSync(fileURLToPath(import.meta.url)) === fs.realpathSync(path.resolve(process.argv[1]));
|
|
335
|
+
} catch {
|
|
336
|
+
return false;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
if (_isCliEntry()) process.exit(main());
|
|
@@ -38,6 +38,15 @@
|
|
|
38
38
|
* The result is measured, never read: `_resultBytes` computes a length and
|
|
39
39
|
* discards the value in the same expression.
|
|
40
40
|
*
|
|
41
|
+
* payload-bodies-waiver: result — measured, never read. This concern
|
|
42
|
+
* deliberately declares NO `needs_payload_bodies` in `hook_manifest.yaml`: the
|
|
43
|
+
* dispatcher omits the body and passes its exact UTF-8 byte length in the stub,
|
|
44
|
+
* which is the only thing this file ever wanted. Declaring `result` here would
|
|
45
|
+
* silently restore the 2 MB payload for the one concern the omission was built
|
|
46
|
+
* for (`road-to-per-turn-hook-economy` step 2.2), and the census would look
|
|
47
|
+
* identical while the cost came back. The `RESULT_KEYS` literals below are the
|
|
48
|
+
* key NAMES this concern looks under, not a read of the body.
|
|
49
|
+
*
|
|
41
50
|
* ── Always exit 0 ─────────────────────────────────────────────────────
|
|
42
51
|
*
|
|
43
52
|
* A `warn` (exit 2) is read as a hard BLOCK on this host, and this concern
|
|
@@ -62,6 +71,7 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
|
62
71
|
|
|
63
72
|
import { is_replay_mode } from './state_io.js';
|
|
64
73
|
import { readHookStdin } from './hook_stdin.js';
|
|
74
|
+
import { stubbedBytes } from './payload_stub.js';
|
|
65
75
|
|
|
66
76
|
const EXIT_ALLOW = 0;
|
|
67
77
|
|
|
@@ -98,11 +108,28 @@ const RESULT_KEYS = ['tool_response', 'toolResponse', 'tool_result', 'toolUseRes
|
|
|
98
108
|
*
|
|
99
109
|
* Bytes, not characters: a multibyte result costs what it costs on the
|
|
100
110
|
* wire, and `String.length` would under-report it.
|
|
111
|
+
*
|
|
112
|
+
* ## The dispatcher may omit the body, and then it passes the length instead
|
|
113
|
+
*
|
|
114
|
+
* `road-to-per-turn-hook-economy` step 2.2. This concern declares no
|
|
115
|
+
* `needs_payload_bodies` in `hook_manifest.yaml` — its own header says the
|
|
116
|
+
* result is "measured, never read", and that is exactly the concern the
|
|
117
|
+
* payload opt-in exists for. So the dispatcher replaces the body with a stub
|
|
118
|
+
* carrying its UTF-8 byte length, computed the same way this function
|
|
119
|
+
* computes it, and this function reads it back.
|
|
120
|
+
*
|
|
121
|
+
* Without the stub branch the census would keep filling — with the ~120-byte
|
|
122
|
+
* length of the STUB, silently, forever. An instrument that reports a wrong
|
|
123
|
+
* number is worse than one that reports none, which is why the branch is
|
|
124
|
+
* first and why `stubbedBytes` distinguishes "omitted, this many bytes" from
|
|
125
|
+
* "present, measure it".
|
|
101
126
|
*/
|
|
102
127
|
export function _resultBytes(payload: JsonObject): number | null {
|
|
103
128
|
for (const key of RESULT_KEYS) {
|
|
104
129
|
const v = payload[key];
|
|
105
130
|
if (v === undefined || v === null) continue;
|
|
131
|
+
const omitted = stubbedBytes(v);
|
|
132
|
+
if (omitted !== undefined) return omitted;
|
|
106
133
|
if (typeof v === 'string') return Buffer.byteLength(v, 'utf8');
|
|
107
134
|
try {
|
|
108
135
|
return Buffer.byteLength(JSON.stringify(v), 'utf8');
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* is deliberately not another reminder. It is a check at the point of
|
|
10
10
|
* delivery that can say no.
|
|
11
11
|
*
|
|
12
|
-
*
|
|
12
|
+
* FOUR detectors ride on one guard, because building the unsafe part twice
|
|
13
13
|
* is how a second detector becomes a second outage:
|
|
14
14
|
*
|
|
15
15
|
* A — promissory closing (FC-5, 20 measured occurrences)
|
|
@@ -20,6 +20,33 @@
|
|
|
20
20
|
* prose, which is why `readTranscriptTail` collects
|
|
21
21
|
* them: `_messageText` keeps `type === 'text'` blocks
|
|
22
22
|
* only, so tool activity is invisible to A and B.)
|
|
23
|
+
* D — completion claim (a claim of done carrying no fresh evidence; landed
|
|
24
|
+
* under conformance round 7 § Phase 1.)
|
|
25
|
+
*
|
|
26
|
+
* TWO of the four are CONDITIONAL, and saying "unconditional" here would be the
|
|
27
|
+
* same stale-header defect this block corrects below. A and D are the
|
|
28
|
+
* completion-adjacent pair an open subagent dispatch excuses, so `main()` runs
|
|
29
|
+
* them only when `dispatchOpen` is false; B and C run on every turn-end. That
|
|
30
|
+
* narrowing is deliberate (Phase 3 Step 2, narrowed again by R2 round 2) and is
|
|
31
|
+
* the third allow path, alongside the two re-entrancy layers — see the
|
|
32
|
+
* per-detector list in `main()`.
|
|
33
|
+
*
|
|
34
|
+
* This block said "Three" and listed A/B/C until 2026-08-18, while `DetectorId`
|
|
35
|
+
* below has carried four since round 7. `DETECTOR_IDS` in
|
|
36
|
+
* `_lib/turn_end_refusals.ts` is read off that union rather than off this
|
|
37
|
+
* comment for exactly that reason, and the count is corrected here rather than
|
|
38
|
+
* left as a stale header the next reader has to disbelieve.
|
|
39
|
+
*
|
|
40
|
+
* ## Removal condition
|
|
41
|
+
*
|
|
42
|
+
* This is a BLOCKING concern, so it owes one — see
|
|
43
|
+
* `docs/contracts/turn-end-detector-demotion.md` for the pre-registered
|
|
44
|
+
* per-detector bars, their sample floors, and the reason a crossed bar
|
|
45
|
+
* authorises a staged study rather than a demotion. Two things that contract
|
|
46
|
+
* says about the guard below are worth meeting here: the re-entrancy layers cap
|
|
47
|
+
* a turn at ONE refusal, which makes a re-refusal share unobservable and the
|
|
48
|
+
* three-strikes non-termination valve unreachable. Both are properties of this
|
|
49
|
+
* design, not gaps in the counters.
|
|
23
50
|
*
|
|
24
51
|
* ## Re-entrancy — the shape, stated before registration
|
|
25
52
|
*
|
|
@@ -997,10 +1024,18 @@ export function main(): number {
|
|
|
997
1024
|
);
|
|
998
1025
|
if (alreadyRefusedTurn(workspaceRoot, sessionKey, turnOrdinal)) return EXIT_ALLOW;
|
|
999
1026
|
|
|
1000
|
-
//
|
|
1001
|
-
//
|
|
1002
|
-
//
|
|
1003
|
-
//
|
|
1027
|
+
// B and C run on every turn-end; A and D run only when no dispatch is open
|
|
1028
|
+
// (the ternaries below). For the detectors that DO run, the gating is INSIDE
|
|
1029
|
+
// each one — no promise, no pin mismatch, no unverified edit, no unsettled
|
|
1030
|
+
// completion claim ⇒ no finding ⇒ the turn ends. That is the whole of "fires
|
|
1031
|
+
// when it is warranted"; there is no second, configurable notion of warranted
|
|
1032
|
+
// layered on top of it.
|
|
1033
|
+
//
|
|
1034
|
+
// This said "Every detector runs on every turn-end" and enumerated three of
|
|
1035
|
+
// four, three lines above the two `dispatchOpen` ternaries that refute it.
|
|
1036
|
+
// Corrected 2026-08-19 with the header block at the top of this file: a reader
|
|
1037
|
+
// arriving here met the false claim first, and the D comment below deferred to
|
|
1038
|
+
// "the note at the top of this loop" — which was this sentence.
|
|
1004
1039
|
const findings: Finding[] = [];
|
|
1005
1040
|
for (const f of [
|
|
1006
1041
|
// A and D are the completion-adjacent pair a pending dispatch excuses
|
|
@@ -1009,8 +1044,11 @@ export function main(): number {
|
|
|
1009
1044
|
dispatchOpen ? null : detectPromissory(lastAssistant),
|
|
1010
1045
|
detectLanguage(lastAssistant, readLanguagePin(workspaceRoot)),
|
|
1011
1046
|
detectUnverifiedEdit(toolCalls),
|
|
1012
|
-
// Round 7 § Phase 1 — detector D
|
|
1013
|
-
//
|
|
1047
|
+
// Round 7 § Phase 1 — detector D. It is NOT unconditional, and this
|
|
1048
|
+
// comment said it was while sitting one line above the `dispatchOpen`
|
|
1049
|
+
// ternary that conditions it: A and D are both excused by an open
|
|
1050
|
+
// dispatch, per the note at the top of this loop. Corrected 2026-08-18.
|
|
1051
|
+
// It shipped with its own settings flag one commit earlier
|
|
1014
1052
|
// and that flag is gone: `hooks.turn_end_gate.*` was deleted on
|
|
1015
1053
|
// 2026-08-12 because a default-off safety gate is an absent one. Its
|
|
1016
1054
|
// gating is where the comment above says gating belongs — inside the
|
|
@@ -31,7 +31,10 @@ import fs from "node:fs";
|
|
|
31
31
|
import path from "node:path";
|
|
32
32
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
33
33
|
|
|
34
|
-
import {
|
|
34
|
+
import {
|
|
35
|
+
POLICY_OUTCOME_ISSUES,
|
|
36
|
+
read_dispatch_issues,
|
|
37
|
+
} from "./hooks/dispatch_issues.js";
|
|
35
38
|
import { type JsonObject, type JsonValue, _load_yaml } from "./hooks/dispatch_hook.js";
|
|
36
39
|
import * as hooks_status from "./hooks_status.js";
|
|
37
40
|
import {
|
|
@@ -240,9 +243,20 @@ export function collect(
|
|
|
240
243
|
|
|
241
244
|
// Phase 1 of road-to-hooks-actually-fire-in-consumers: surface
|
|
242
245
|
// the dispatch-issues log so users see hooks that tried and failed.
|
|
246
|
+
//
|
|
247
|
+
// POLICY OUTCOMES ARE FILTERED OUT, and the filter is the point: this view
|
|
248
|
+
// means "a hook tried to fire and couldn't", and its CTA is a reinstall. The
|
|
249
|
+
// two emission-shaping codes mean the opposite — the concern ran correctly and
|
|
250
|
+
// the dispatcher chose not to emit it — so they are neither a failure nor
|
|
251
|
+
// fixable by reinstalling. They fire as a routine per-dispatch outcome, so
|
|
252
|
+
// leaving them in would let normal traffic push a real `script_not_found` out
|
|
253
|
+
// of the last-20 window. They stay in the log, which is where the "why did
|
|
254
|
+
// that advisory not appear" question is answered. (R2 review, 2026-08-19.)
|
|
243
255
|
let issues: JsonObject[] = [];
|
|
244
256
|
try {
|
|
245
|
-
issues = read_dispatch_issues(REPO_ROOT)
|
|
257
|
+
issues = read_dispatch_issues(REPO_ROOT)
|
|
258
|
+
.filter((e) => !POLICY_OUTCOME_ISSUES.has(String(e["issue"])))
|
|
259
|
+
.slice(-20) as unknown as JsonObject[];
|
|
246
260
|
} catch {
|
|
247
261
|
issues = [];
|
|
248
262
|
}
|