@event4u/agent-config 9.29.0 → 9.30.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +108 -7
- package/README.md +1 -0
- package/dist/agent-src/commands/analyze/inbox.md +22 -1
- package/dist/agent-src/commands/explain-run.md +31 -1
- package/dist/agent-src/commands/team/delegate.md +1 -0
- package/dist/agent-src/contexts/execution/orchestration-telemetry.md +4 -1
- package/dist/agent-src/contexts/execution/roadmap-process-loop.md +17 -0
- package/dist/agent-src/contexts/execution/subagent-response-contract.md +11 -1
- package/dist/agent-src/rules/preservation-guard.md +9 -0
- package/dist/agent-src/scripts/roadmap_gates.ts +312 -0
- package/dist/agent-src/scripts/update_roadmap_progress.ts +72 -4
- package/dist/agent-src/skills/subagent-orchestration/SKILL.md +10 -0
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/cli/registry.js +2 -0
- package/dist/cli/registry.js.map +1 -1
- package/dist/cli-delegate/{chunk-OJM3MBJM.js → chunk-BTJNRUX3.js} +21 -34
- package/dist/cli-delegate/{chunk-MMXA5GY7.js → chunk-DDUNNFYX.js} +14 -0
- package/dist/cli-delegate/{chunk-NZ5N2XQL.js → chunk-EQY4FLJB.js} +1 -1
- package/dist/cli-delegate/{chunk-RCIA67XN.js → chunk-IYTGIG2Y.js} +44 -1
- package/dist/cli-delegate/chunk-MMJL4TIX.js +18 -0
- package/dist/cli-delegate/chunk-O3CO5BFB.js +161 -0
- package/dist/cli-delegate/{chunk-REKCMYOU.js → chunk-PFBV6XRT.js} +36 -20
- package/dist/cli-delegate/chunk-RVGBH4XK.js +142 -0
- package/dist/cli-delegate/cmd_conformance.js +2798 -95
- package/dist/cli-delegate/cmd_doctor.js +2 -1
- package/dist/cli-delegate/cmd_export.js +2 -2
- package/dist/cli-delegate/cmd_handoff.js +86 -6
- package/dist/cli-delegate/cmd_prune.js +2 -2
- package/dist/cli-delegate/cmd_route_audit.js +2 -1
- package/dist/cli-delegate/cmd_session_recycle.js +177 -0
- package/dist/cli-delegate/cmd_settings_get.js +1 -1
- package/dist/cli-delegate/cmd_settings_migrate.js +2 -2
- package/dist/cli-delegate/cmd_settings_set.js +2 -2
- package/dist/cli-delegate/cmd_sync.js +2 -2
- package/dist/cli-delegate/cmd_uninstall.js +2 -2
- package/dist/cli-delegate/cmd_validate.js +2 -2
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +7 -7
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +1 -1
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +2 -2
- package/dist/discovery/trust-report.md +1 -1
- package/dist/discovery/workspaces.json +2 -2
- package/dist/hooks/dispatch.js +1904 -575
- package/dist/install/install.mjs +14 -0
- package/dist/mcp/registry-manifest.json +2 -2
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +3760 -726
- package/dist/server/schemas/settings.js +6 -0
- package/dist/server/schemas/settings.js.map +1 -1
- package/dist/ui/assets/index-B5qGYOJo.js +26 -0
- package/dist/ui/assets/index-B5qGYOJo.js.map +1 -0
- package/dist/ui/index.html +1 -1
- package/docs/contracts/settings-classes.md +21 -5
- package/package.json +1 -1
- package/src/agent-src/scripts/roadmap_gates.ts +312 -0
- package/src/agent-src/scripts/update_roadmap_progress.ts +72 -4
- package/src/config/agent-settings.template.yml +36 -0
- package/src/config/dispatch-economy-metrics.json +61 -0
- package/src/config/evaluator-budgets.json +4 -4
- package/src/config/hook-token-budget.json +91 -0
- package/src/config/recycle-threshold-budget.json +12 -0
- package/src/config/rule-enforcement-baseline.json +86 -0
- package/src/scripts/_cli/cmd_conformance.ts +17 -2
- package/src/scripts/_cli/cmd_session_recycle.ts +226 -0
- package/src/scripts/_dispatch.bash +23 -0
- package/src/scripts/_lib/judgment_ladder.ts +254 -2
- package/src/scripts/_lib/orchestration_record.ts +41 -10
- package/src/scripts/_lib/recycle_envelope_paths.ts +35 -0
- package/src/scripts/_lib/self_repair.ts +194 -0
- package/src/scripts/_lib/session_eol.ts +230 -0
- package/src/scripts/_lib/session_role.ts +42 -0
- package/src/scripts/_lib/subagent_capsule.ts +159 -0
- package/src/scripts/_lib/subagent_response.ts +75 -0
- package/src/scripts/ai_council/clients.ts +50 -1
- package/src/scripts/ask_transport.ts +221 -0
- package/src/scripts/bench_hook_injection.ts +234 -0
- package/src/scripts/check_adr_frontmatter.ts +84 -40
- package/src/scripts/check_branch_freshness.ts +1 -0
- package/src/scripts/check_corpus_staleness.ts +18 -4
- package/src/scripts/check_detector_corpus.ts +17 -0
- package/src/scripts/check_gitignore_freshness.ts +42 -8
- package/src/scripts/check_hook_bundle_freshness.ts +20 -0
- package/src/scripts/check_kernel_prefix_stability.ts +1 -0
- package/src/scripts/check_standing_rule_delivery.ts +73 -32
- package/src/scripts/check_static_layer_stability.ts +154 -0
- package/src/scripts/check_suppression_hygiene.ts +48 -0
- package/src/scripts/dispatch_economy_report.ts +448 -0
- package/src/scripts/dispatch_r2_reviewer.ts +15 -2
- package/src/scripts/explain_run.ts +206 -4
- package/src/scripts/generate_settings_reference.ts +1 -0
- package/src/scripts/handoff_context_hook.ts +112 -4
- package/src/scripts/hook_manifest.yaml +117 -10
- package/src/scripts/hooks/concern_registry.ts +8 -0
- package/src/scripts/hooks/delegation_nudge_hook.ts +40 -3
- package/src/scripts/hooks/dispatch_hook.ts +39 -2
- package/src/scripts/hooks/edit_shape_hook.ts +303 -0
- package/src/scripts/hooks/orchestration_record_hook.ts +19 -1
- package/src/scripts/hooks/reread_guard_hook.ts +314 -0
- package/src/scripts/hooks/rtk_wrap_hook.ts +133 -14
- package/src/scripts/hooks/session_eol_hook.ts +268 -0
- package/src/scripts/hooks/turn_end_gate_hook.ts +507 -0
- package/src/scripts/install-hooks.sh +1 -1
- package/src/scripts/language_mirror_hook.ts +89 -0
- package/src/scripts/lint_decision_impact.ts +14 -1
- package/src/scripts/lint_hook_manifest.ts +82 -0
- package/src/scripts/lint_readme_serial_comma.ts +1 -0
- package/src/scripts/lint_rule_enforcement_declaration.ts +347 -0
- package/src/scripts/measure_turn_end_gate.ts +194 -0
- package/src/scripts/orchestration_record.ts +4 -1
- package/src/scripts/rebuild_hook_bundle.ts +1 -0
- package/src/scripts/report_conformance_funnel.ts +284 -0
- package/src/scripts/report_imperative_density.ts +1 -0
- package/src/scripts/routing_doctor.ts +97 -0
- package/src/scripts/self_repair_cli.ts +51 -3
- package/src/scripts/session_eol_report.ts +261 -0
- package/src/server/schemas/settings.ts +14 -0
- package/src/templates/compact-instructions.md +40 -0
- package/dist/ui/assets/index-JDAF00bC.js +0 -26
- package/dist/ui/assets/index-JDAF00bC.js.map +0 -1
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session end-of-life scanner (road-to-token-economy-recycling Phase 1).
|
|
3
|
+
*
|
|
4
|
+
* Pure counting over Claude Code transcript JSONL: per session, the FINAL
|
|
5
|
+
* main-chain context size in parsed tokens, the turn count, and every
|
|
6
|
+
* compaction event — the three numbers the recycle roadmap's baseline and
|
|
7
|
+
* threshold are built from. Token math delegates to `cc_transcript.ts`
|
|
8
|
+
* (`billableInputTokens` — input + cache_read + cache_creation); this module
|
|
9
|
+
* adds no second token definition ("no new parser", roadmap prerequisite).
|
|
10
|
+
*
|
|
11
|
+
* Compaction detector — pinned to OBSERVED reality (blocker
|
|
12
|
+
* `compaction-marker-shape`): a real auto-compaction recorded 2026-08-06 on
|
|
13
|
+
* host v2.1.222 writes TWO markers into the transcript:
|
|
14
|
+
*
|
|
15
|
+
* 1. `{"type":"system","subtype":"compact_boundary","compactMetadata":
|
|
16
|
+
* {"trigger":"auto","preTokens":1000410,"postTokens":15870,...}}`
|
|
17
|
+
* 2. a `{"type":"user","isCompactSummary":true,...}` record carrying the
|
|
18
|
+
* summary the session was restarted from.
|
|
19
|
+
*
|
|
20
|
+
* The fixture in `tests/scripts/_lib_session_eol.test.ts` freezes both
|
|
21
|
+
* shapes. Never-silent discipline: the scanner counts BOTH markers
|
|
22
|
+
* independently — `compactions[]` from the boundary records,
|
|
23
|
+
* `compact_summaries` from the summary records. A host update that changes
|
|
24
|
+
* one shape but not the other makes the two counters diverge, which the
|
|
25
|
+
* report surfaces as marker drift instead of silently zeroing the metric.
|
|
26
|
+
*
|
|
27
|
+
* Incremental by design: the Stop-slot carrier re-runs every turn, so
|
|
28
|
+
* `scanEolSlice` accepts prior counters and only the newly appended complete
|
|
29
|
+
* lines (`readNewCompleteLines`). A full rescan is only ever the bounded
|
|
30
|
+
* 50 MB `isSafeTranscriptPath` cap away.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import * as fs from 'node:fs';
|
|
34
|
+
|
|
35
|
+
import { billableInputTokens } from './cc_transcript.js';
|
|
36
|
+
|
|
37
|
+
/** The observed compaction boundary marker (host v2.1.222, 2026-08-06). */
|
|
38
|
+
export const COMPACT_BOUNDARY_SUBTYPE = 'compact_boundary';
|
|
39
|
+
|
|
40
|
+
/** One compaction event, read from a `compact_boundary` system record. */
|
|
41
|
+
export interface CompactionEvent {
|
|
42
|
+
/** `"auto"` observed; the field is host territory — kept as an open string. */
|
|
43
|
+
trigger: string;
|
|
44
|
+
/** Context tokens immediately before compaction (`compactMetadata.preTokens`). */
|
|
45
|
+
pre_tokens: number | null;
|
|
46
|
+
/** Context tokens immediately after (`compactMetadata.postTokens`). */
|
|
47
|
+
post_tokens: number | null;
|
|
48
|
+
timestamp: string | null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Accumulated per-session end-of-life counters. Counts and refs only — never content. */
|
|
52
|
+
export interface EolCounters {
|
|
53
|
+
schema_version: 1;
|
|
54
|
+
/** Byte offset up to which the transcript has been consumed (end of last complete line). */
|
|
55
|
+
scanned_bytes: number;
|
|
56
|
+
/** External user prompts on the main chain (excl. meta / compact-summary records). */
|
|
57
|
+
turns: number;
|
|
58
|
+
/** Main-chain assistant records carrying a `usage` block. */
|
|
59
|
+
assistant_records: number;
|
|
60
|
+
/** Billable input tokens of the LAST main-chain assistant record — the fill level. */
|
|
61
|
+
final_context_tokens: number | null;
|
|
62
|
+
/** `timestamp` of that record. */
|
|
63
|
+
final_context_at: string | null;
|
|
64
|
+
/** Every `compact_boundary` event seen, in order. */
|
|
65
|
+
compactions: CompactionEvent[];
|
|
66
|
+
/** `isCompactSummary` user records seen — the drift twin of `compactions`. */
|
|
67
|
+
compact_summaries: number;
|
|
68
|
+
/** Lines that failed `JSON.parse`. */
|
|
69
|
+
bad_lines: number;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function emptyCounters(): EolCounters {
|
|
73
|
+
return {
|
|
74
|
+
schema_version: 1,
|
|
75
|
+
scanned_bytes: 0,
|
|
76
|
+
turns: 0,
|
|
77
|
+
assistant_records: 0,
|
|
78
|
+
final_context_tokens: null,
|
|
79
|
+
final_context_at: null,
|
|
80
|
+
compactions: [],
|
|
81
|
+
compact_summaries: 0,
|
|
82
|
+
bad_lines: 0,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function numOrNull(value: unknown): number | null {
|
|
87
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function numOr0(value: unknown): number {
|
|
91
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : 0;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Detect the observed compaction boundary marker on one parsed record.
|
|
96
|
+
* Returns the event, or `null` for every other record shape.
|
|
97
|
+
*/
|
|
98
|
+
export function detectCompaction(record: Record<string, unknown>): CompactionEvent | null {
|
|
99
|
+
if (record['type'] !== 'system' || record['subtype'] !== COMPACT_BOUNDARY_SUBTYPE) {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
const md = (record['compactMetadata'] ?? {}) as Record<string, unknown>;
|
|
103
|
+
return {
|
|
104
|
+
trigger: typeof md['trigger'] === 'string' ? md['trigger'] : 'unknown',
|
|
105
|
+
pre_tokens: numOrNull(md['preTokens']),
|
|
106
|
+
post_tokens: numOrNull(md['postTokens']),
|
|
107
|
+
timestamp: typeof record['timestamp'] === 'string' ? record['timestamp'] : null,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** True for a main-chain external user PROMPT record (a turn), per the baseline heuristic. */
|
|
112
|
+
function isTurnRecord(record: Record<string, unknown>): boolean {
|
|
113
|
+
if (record['type'] !== 'user') return false;
|
|
114
|
+
if (record['isCompactSummary'] === true || record['isMeta'] === true) return false;
|
|
115
|
+
const message = record['message'];
|
|
116
|
+
if (typeof message !== 'object' || message === null) return false;
|
|
117
|
+
const content = (message as Record<string, unknown>)['content'];
|
|
118
|
+
if (typeof content === 'string') return content.trim().length > 0;
|
|
119
|
+
if (Array.isArray(content)) {
|
|
120
|
+
return content.some(
|
|
121
|
+
(b) => typeof b === 'object' && b !== null && (b as Record<string, unknown>)['type'] === 'text',
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Fold newly appended COMPLETE transcript lines into the counters. Pure —
|
|
129
|
+
* returns a new counters object; `scanned_bytes` is the caller's business
|
|
130
|
+
* (it knows the byte offsets, this function only sees text).
|
|
131
|
+
*/
|
|
132
|
+
export function scanEolSlice(text: string, prior: EolCounters): EolCounters {
|
|
133
|
+
const next: EolCounters = {
|
|
134
|
+
...prior,
|
|
135
|
+
compactions: [...prior.compactions],
|
|
136
|
+
};
|
|
137
|
+
for (const line of text.split('\n')) {
|
|
138
|
+
if (line.trim().length === 0) continue;
|
|
139
|
+
let record: Record<string, unknown>;
|
|
140
|
+
try {
|
|
141
|
+
const parsed = JSON.parse(line) as unknown;
|
|
142
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
143
|
+
next.bad_lines += 1;
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
record = parsed as Record<string, unknown>;
|
|
147
|
+
} catch {
|
|
148
|
+
next.bad_lines += 1;
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
if (record['isSidechain'] === true) continue; // subagent legs are not this session's context
|
|
152
|
+
|
|
153
|
+
const compaction = detectCompaction(record);
|
|
154
|
+
if (compaction) {
|
|
155
|
+
next.compactions.push(compaction);
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
if (record['type'] === 'user' && record['isCompactSummary'] === true) {
|
|
159
|
+
next.compact_summaries += 1;
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
if (isTurnRecord(record)) {
|
|
163
|
+
next.turns += 1;
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
if (record['type'] === 'assistant') {
|
|
167
|
+
const message = record['message'];
|
|
168
|
+
const usage =
|
|
169
|
+
typeof message === 'object' && message !== null
|
|
170
|
+
? ((message as Record<string, unknown>)['usage'] as Record<string, unknown> | undefined)
|
|
171
|
+
: undefined;
|
|
172
|
+
if (typeof usage === 'object' && usage !== null && usage['input_tokens'] !== undefined) {
|
|
173
|
+
next.assistant_records += 1;
|
|
174
|
+
next.final_context_tokens = billableInputTokens({
|
|
175
|
+
input_tokens: numOr0(usage['input_tokens']),
|
|
176
|
+
cache_read_input_tokens: numOr0(usage['cache_read_input_tokens']),
|
|
177
|
+
cache_creation_input_tokens: numOr0(usage['cache_creation_input_tokens']),
|
|
178
|
+
});
|
|
179
|
+
next.final_context_at =
|
|
180
|
+
typeof record['timestamp'] === 'string' ? record['timestamp'] : null;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return next;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export interface NewLinesRead {
|
|
188
|
+
/** The complete lines appended since `fromByte` (possibly empty). */
|
|
189
|
+
text: string;
|
|
190
|
+
/** The offset to persist — end of the last complete line consumed. */
|
|
191
|
+
nextOffset: number;
|
|
192
|
+
/** True when the file shrank below `fromByte` (rotation/truncation) — caller rescans from 0. */
|
|
193
|
+
truncated: boolean;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Read the bytes appended to `filePath` since `fromByte`, trimmed to the
|
|
198
|
+
* last complete line (a JSONL writer may be mid-line). Never throws — any
|
|
199
|
+
* fs error reads as "nothing new".
|
|
200
|
+
*/
|
|
201
|
+
export function readNewCompleteLines(filePath: string, fromByte: number): NewLinesRead {
|
|
202
|
+
try {
|
|
203
|
+
const { size } = fs.statSync(filePath);
|
|
204
|
+
if (size < fromByte) {
|
|
205
|
+
return { text: '', nextOffset: 0, truncated: true };
|
|
206
|
+
}
|
|
207
|
+
if (size === fromByte) {
|
|
208
|
+
return { text: '', nextOffset: fromByte, truncated: false };
|
|
209
|
+
}
|
|
210
|
+
const length = size - fromByte;
|
|
211
|
+
const buffer = Buffer.alloc(length);
|
|
212
|
+
const fd = fs.openSync(filePath, 'r');
|
|
213
|
+
try {
|
|
214
|
+
fs.readSync(fd, buffer, 0, length, fromByte);
|
|
215
|
+
} finally {
|
|
216
|
+
fs.closeSync(fd);
|
|
217
|
+
}
|
|
218
|
+
const lastNewline = buffer.lastIndexOf(0x0a);
|
|
219
|
+
if (lastNewline === -1) {
|
|
220
|
+
return { text: '', nextOffset: fromByte, truncated: false };
|
|
221
|
+
}
|
|
222
|
+
return {
|
|
223
|
+
text: buffer.subarray(0, lastNewline + 1).toString('utf-8'),
|
|
224
|
+
nextOffset: fromByte + lastNewline + 1,
|
|
225
|
+
truncated: false,
|
|
226
|
+
};
|
|
227
|
+
} catch {
|
|
228
|
+
return { text: '', nextOffset: fromByte, truncated: false };
|
|
229
|
+
}
|
|
230
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session-role detection — the ONE shared detector (road-to-token-economy-
|
|
3
|
+
* dispatch Phase 2.2: "one detector, two consumers, test-pinned equal").
|
|
4
|
+
*
|
|
5
|
+
* Consumers:
|
|
6
|
+
* - `hooks/dispatch_hook.ts` — applies the manifest's role axis (a marked
|
|
7
|
+
* worker session runs a shorter concern chain).
|
|
8
|
+
* - `hooks/delegation_nudge_hook.ts` — feeds the judgment ladder's
|
|
9
|
+
* recursive-dispatch guard (`insideSubagentSession`), whose own header
|
|
10
|
+
* documents the fact as CALLER-SUPPLIED: no verified host discriminator
|
|
11
|
+
* exists (upstream agent-identity request closed NOT_PLANNED), and
|
|
12
|
+
* probing for invented host variable names is the hallucinated-field
|
|
13
|
+
* failure `source-discovery-gate` exists to stop.
|
|
14
|
+
*
|
|
15
|
+
* `AGENT_CONFIG_SESSION_ROLE` is therefore this suite's OWN variable, set
|
|
16
|
+
* only by suite-owned spawn wrappers that launch a separate CLI session
|
|
17
|
+
* (council transports, bench runners, future worker wrappers). Host
|
|
18
|
+
* limitation, pre-registered in the roadmap's `worker-chain-host-delivery`
|
|
19
|
+
* blocker: an in-process Agent-tool subagent shares the host process env
|
|
20
|
+
* and CANNOT be marked per-spawn — on that path the role resolves
|
|
21
|
+
* `orchestrator` and the full chain runs (fail-open, Phase 2.4).
|
|
22
|
+
*
|
|
23
|
+
* Fail-open is the contract: unset, empty, or unknown values resolve to
|
|
24
|
+
* `orchestrator` — the thin path is the opt-in of a marked spawn, never
|
|
25
|
+
* the accident of a missing or typo'd variable.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
export const SESSION_ROLE_ENV = 'AGENT_CONFIG_SESSION_ROLE';
|
|
29
|
+
|
|
30
|
+
/** `reviewer` is enum-reserved for Phase 3.2; until a `roles.reviewer`
|
|
31
|
+
* manifest entry exists it resolves to the full chain like any role
|
|
32
|
+
* without a manifest entry. */
|
|
33
|
+
export type SessionRole = 'orchestrator' | 'worker' | 'reviewer';
|
|
34
|
+
|
|
35
|
+
const KNOWN_ROLES: ReadonlySet<string> = new Set(['orchestrator', 'worker', 'reviewer']);
|
|
36
|
+
|
|
37
|
+
export function resolveSessionRole(
|
|
38
|
+
env: Record<string, string | undefined> = process.env,
|
|
39
|
+
): SessionRole {
|
|
40
|
+
const raw = (env[SESSION_ROLE_ENV] ?? '').trim().toLowerCase();
|
|
41
|
+
return (KNOWN_ROLES.has(raw) ? raw : 'orchestrator') as SessionRole;
|
|
42
|
+
}
|
|
@@ -82,6 +82,23 @@ export interface WorkerCapsule {
|
|
|
82
82
|
assumptions: CapsuleAssumption[];
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
+
/**
|
|
86
|
+
* Capsule schema version (road-to-token-economy-recycling Phase 2.1).
|
|
87
|
+
* Versioning is INTRODUCED here — the Phase-0 worker capsule shipped
|
|
88
|
+
* unversioned and stays valid without a version field (implicit v1, wire
|
|
89
|
+
* shape unchanged, `subagent-status.json` untouched). Version 2 adds the
|
|
90
|
+
* explicit `capsule_version` + `variant` discriminator that the
|
|
91
|
+
* main-session recycle envelope requires. Policy: additive only — a new
|
|
92
|
+
* version may add fields or variants, never repurpose or remove one; both
|
|
93
|
+
* variants validate through THIS module (one schema file, one validator
|
|
94
|
+
* family — roadmap 5.6's anti-fork rule).
|
|
95
|
+
*/
|
|
96
|
+
export const CAPSULE_SCHEMA_VERSION = 2;
|
|
97
|
+
|
|
98
|
+
/** The two CHECKPOINT variants sharing this schema. */
|
|
99
|
+
export const CAPSULE_VARIANTS = ['worker', 'main_session'] as const;
|
|
100
|
+
export type CapsuleVariant = (typeof CAPSULE_VARIANTS)[number];
|
|
101
|
+
|
|
85
102
|
/** A single-line token no longer than `max` chars. Rejects any body-shaped value. */
|
|
86
103
|
function isShortLine(value: unknown, max: number): value is string {
|
|
87
104
|
return typeof value === 'string' && value.length > 0 && value.length <= max && !value.includes('\n');
|
|
@@ -158,6 +175,148 @@ export function validateCapsule(input: unknown): string[] {
|
|
|
158
175
|
return errors;
|
|
159
176
|
}
|
|
160
177
|
|
|
178
|
+
/**
|
|
179
|
+
* The main-session recycle envelope (road-to-token-economy-recycling
|
|
180
|
+
* Phase 2.1) — the `main_session` CHECKPOINT variant. Unlike the worker
|
|
181
|
+
* capsule there is NO surviving orchestrator to receive it: the successor
|
|
182
|
+
* session bootstraps from the envelope ALONE, so the required set is
|
|
183
|
+
* stricter — the active task, its acceptance criteria, the open work, and
|
|
184
|
+
* the explicit NOT-carried-forward list (what the successor must re-derive
|
|
185
|
+
* from source rather than trust) are all mandatory.
|
|
186
|
+
*
|
|
187
|
+
* Same transcript-exclusion-by-construction as the worker capsule: every
|
|
188
|
+
* field is a ref token or a single short line, validated by the SAME
|
|
189
|
+
* primitives. A prose transcript summary is schema-invalid twice over —
|
|
190
|
+
* any multi-line value fails `isShortLine`, and any field not listed below
|
|
191
|
+
* (e.g. `transcript_summary`) fails the unknown-key sweep. The strict
|
|
192
|
+
* sweep is deliberate where the worker validator stays open: the worker
|
|
193
|
+
* side has `subagent-status.json` (`additionalProperties: false`) as its
|
|
194
|
+
* wire gate; this variant is consumed from a FILE by
|
|
195
|
+
* `handoff_context_hook`, so the module itself must be the strict gate.
|
|
196
|
+
*/
|
|
197
|
+
export interface MainSessionRecycleEnvelope {
|
|
198
|
+
/** Must be {@link CAPSULE_SCHEMA_VERSION}. */
|
|
199
|
+
capsule_version: number;
|
|
200
|
+
variant: 'main_session';
|
|
201
|
+
/** One- or two-sentence outcome so far. */
|
|
202
|
+
summary: string;
|
|
203
|
+
/** The active task, one line. */
|
|
204
|
+
task: string;
|
|
205
|
+
/** Workspace root this envelope belongs to — the consumer's identity check (Risk 4). */
|
|
206
|
+
workspace: string;
|
|
207
|
+
/** ISO-8601 stamp of the write — the consumer's staleness guard. */
|
|
208
|
+
written_at: string;
|
|
209
|
+
/** What "done" means — one short line each. Required, ≥ 1. */
|
|
210
|
+
acceptance_criteria: string[];
|
|
211
|
+
/** Open work, one short line each. Required (may be empty when only verification remains). */
|
|
212
|
+
remaining: string[];
|
|
213
|
+
/**
|
|
214
|
+
* What the successor must RE-DERIVE from source instead of trusting —
|
|
215
|
+
* the explicit omission list that makes envelope loss visible instead
|
|
216
|
+
* of silent. Required; an empty list is a claim, not a default.
|
|
217
|
+
*/
|
|
218
|
+
not_carried_forward: string[];
|
|
219
|
+
/** Choices already made, each with its one-line rationale in the same line. */
|
|
220
|
+
decisions?: string[];
|
|
221
|
+
/** Binding constraints the successor must not silently re-open. */
|
|
222
|
+
constraints?: string[];
|
|
223
|
+
/** Open worker CHECKPOINT envelopes by path — never inlined bodies. */
|
|
224
|
+
open_worker_envelopes?: string[];
|
|
225
|
+
/** Artifact paths (deliverables, notes, evidence) — refs, never bodies. */
|
|
226
|
+
artifact_paths?: string[];
|
|
227
|
+
/** Stated premises — the shared `{statement, basis, epistemic_state}` shape. */
|
|
228
|
+
assumptions?: CapsuleAssumption[];
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Every key `validateRecycleEnvelope` accepts — anything else is schema-invalid. */
|
|
232
|
+
const RECYCLE_ENVELOPE_KEYS: ReadonlySet<string> = new Set([
|
|
233
|
+
'capsule_version',
|
|
234
|
+
'variant',
|
|
235
|
+
'summary',
|
|
236
|
+
'task',
|
|
237
|
+
'workspace',
|
|
238
|
+
'written_at',
|
|
239
|
+
'acceptance_criteria',
|
|
240
|
+
'remaining',
|
|
241
|
+
'not_carried_forward',
|
|
242
|
+
'decisions',
|
|
243
|
+
'constraints',
|
|
244
|
+
'open_worker_envelopes',
|
|
245
|
+
'artifact_paths',
|
|
246
|
+
'assumptions',
|
|
247
|
+
]);
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Validate a main-session recycle envelope. Returns the list of violations
|
|
251
|
+
* (empty = valid). The consumer never injects an invalid envelope — it is
|
|
252
|
+
* consumed and discarded loudly instead (`handoff_context_hook`).
|
|
253
|
+
*/
|
|
254
|
+
export function validateRecycleEnvelope(input: unknown): string[] {
|
|
255
|
+
const errors: string[] = [];
|
|
256
|
+
if (typeof input !== 'object' || input === null || Array.isArray(input)) {
|
|
257
|
+
return ['not an object'];
|
|
258
|
+
}
|
|
259
|
+
const e = input as Record<string, unknown>;
|
|
260
|
+
|
|
261
|
+
for (const key of Object.keys(e)) {
|
|
262
|
+
if (!RECYCLE_ENVELOPE_KEYS.has(key)) {
|
|
263
|
+
errors.push(
|
|
264
|
+
`unknown field "${key}" — a recycle envelope carries selection and pointers only; ` +
|
|
265
|
+
'free-form additions (prose summaries included) are schema-invalid by construction',
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
if (e['capsule_version'] !== CAPSULE_SCHEMA_VERSION) {
|
|
271
|
+
errors.push(`capsule_version must be ${CAPSULE_SCHEMA_VERSION}`);
|
|
272
|
+
}
|
|
273
|
+
if (e['variant'] !== 'main_session') {
|
|
274
|
+
errors.push("variant must be 'main_session'");
|
|
275
|
+
}
|
|
276
|
+
if (!isShortLine(e['summary'], MAX_LINE_CHARS)) {
|
|
277
|
+
errors.push(`summary must be a single line of 1–${MAX_LINE_CHARS} chars`);
|
|
278
|
+
}
|
|
279
|
+
if (!isShortLine(e['task'], MAX_LINE_CHARS)) {
|
|
280
|
+
errors.push(`task must be a single line of 1–${MAX_LINE_CHARS} chars`);
|
|
281
|
+
}
|
|
282
|
+
if (!isShortLine(e['workspace'], MAX_REF_CHARS)) {
|
|
283
|
+
errors.push(`workspace must be a path ref of ≤ ${MAX_REF_CHARS} chars`);
|
|
284
|
+
}
|
|
285
|
+
const writtenAt = e['written_at'];
|
|
286
|
+
if (typeof writtenAt !== 'string' || Number.isNaN(Date.parse(writtenAt))) {
|
|
287
|
+
errors.push('written_at must be a parseable ISO-8601 timestamp');
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
checkList(errors, 'acceptance_criteria', e['acceptance_criteria'], MAX_LINE_CHARS, true);
|
|
291
|
+
if (Array.isArray(e['acceptance_criteria']) && e['acceptance_criteria'].length === 0) {
|
|
292
|
+
errors.push('acceptance_criteria must carry at least one entry — the successor cannot know "done" without it');
|
|
293
|
+
}
|
|
294
|
+
checkList(errors, 'remaining', e['remaining'], MAX_LINE_CHARS, true);
|
|
295
|
+
checkList(errors, 'not_carried_forward', e['not_carried_forward'], MAX_LINE_CHARS, true);
|
|
296
|
+
checkList(errors, 'decisions', e['decisions'], MAX_LINE_CHARS, false);
|
|
297
|
+
checkList(errors, 'constraints', e['constraints'], MAX_LINE_CHARS, false);
|
|
298
|
+
checkList(errors, 'open_worker_envelopes', e['open_worker_envelopes'], MAX_REF_CHARS, false);
|
|
299
|
+
checkList(errors, 'artifact_paths', e['artifact_paths'], MAX_REF_CHARS, false);
|
|
300
|
+
|
|
301
|
+
// `remaining` and `not_carried_forward` must be PRESENT (checkList already
|
|
302
|
+
// enforces that) — empty arrays are legal, explicit claims.
|
|
303
|
+
const assumptions = e['assumptions'];
|
|
304
|
+
if (assumptions !== undefined) {
|
|
305
|
+
if (!Array.isArray(assumptions)) {
|
|
306
|
+
errors.push('assumptions must be an array');
|
|
307
|
+
} else {
|
|
308
|
+
if (assumptions.length > MAX_ENTRIES) {
|
|
309
|
+
errors.push(`assumptions carries ${assumptions.length} entries (max ${MAX_ENTRIES})`);
|
|
310
|
+
}
|
|
311
|
+
for (const [i, raw] of assumptions.entries()) {
|
|
312
|
+
errors.push(...validateAssumption(raw, `assumptions[${i}]`));
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
return errors;
|
|
318
|
+
}
|
|
319
|
+
|
|
161
320
|
/**
|
|
162
321
|
* Validate one `{statement, basis, epistemic_state}` triple. Exported because
|
|
163
322
|
* the worker RESULT envelope carries the same field (`subagent_response.ts`) —
|
|
@@ -21,12 +21,38 @@ export interface Finding {
|
|
|
21
21
|
mutating?: boolean;
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
// ── Committed envelope size caps (road-to-token-economy-dispatch Phase 6.1) ──
|
|
25
|
+
// The envelope is the ONLY return channel: a worker's full result lands on
|
|
26
|
+
// disk (runtime artifact dir, gitignored) and the envelope carries paths +
|
|
27
|
+
// verdict + a BOUNDED summary. Without caps, transcript-shaped output flows
|
|
28
|
+
// back into the orchestrator context and refunds the isolation win — the
|
|
29
|
+
// measured spawn floor is ~251k tokens; a capped envelope is ≤ ~3k. Caps are
|
|
30
|
+
// validator ERRORS, never silent truncation (never-silent discipline): an
|
|
31
|
+
// oversized envelope is rejected loudly and the author shortens it.
|
|
32
|
+
/** Max chars for the summary — bounded prose, not a transcript. */
|
|
33
|
+
export const MAX_SUMMARY_CHARS = 2000;
|
|
34
|
+
/** Max chars for one line-shaped field (handoff, a risk, a finding title). */
|
|
35
|
+
export const MAX_RESPONSE_LINE_CHARS = 240;
|
|
36
|
+
/** Max entries per array (findings, risks, artifact_paths) — mirrors the capsule. */
|
|
37
|
+
export const MAX_RESPONSE_ENTRIES = 40;
|
|
38
|
+
/** Max chars for one artifact path ref. Mirrors the capsule's ref cap. */
|
|
39
|
+
export const MAX_ARTIFACT_REF_CHARS = 200;
|
|
40
|
+
/** Committed max for the WHOLE serialized envelope (~3k tokens ceiling). */
|
|
41
|
+
export const MAX_ENVELOPE_CHARS = 12000;
|
|
42
|
+
|
|
24
43
|
export interface SubagentResponse {
|
|
25
44
|
summary: string;
|
|
26
45
|
findings: Finding[];
|
|
27
46
|
risks: string[];
|
|
28
47
|
confidence: Confidence;
|
|
29
48
|
handoff: string;
|
|
49
|
+
/**
|
|
50
|
+
* Where the worker's FULL results live on disk (runtime artifact dir,
|
|
51
|
+
* gitignored) — the orchestrator consumes from these paths on demand,
|
|
52
|
+
* never via wholesale transcript ingestion (Phase 6.1/6.2). Ref tokens
|
|
53
|
+
* only, capped like every other array.
|
|
54
|
+
*/
|
|
55
|
+
artifact_paths?: string[];
|
|
30
56
|
/**
|
|
31
57
|
* Premises the worker acted on, `{statement, basis, epistemic_state}` —
|
|
32
58
|
* the same shape and the same validator as the CHECKPOINT capsule
|
|
@@ -74,6 +100,55 @@ export function validateResponse(input: unknown): ValidationResult {
|
|
|
74
100
|
}
|
|
75
101
|
}
|
|
76
102
|
}
|
|
103
|
+
// ── Committed size caps (Phase 6.1) — errors, never silent truncation ──
|
|
104
|
+
if (typeof r.summary === 'string' && r.summary.length > MAX_SUMMARY_CHARS) {
|
|
105
|
+
errors.push(`summary is ${r.summary.length} chars (max ${MAX_SUMMARY_CHARS}) — the envelope carries a bounded summary; the full result belongs on disk (artifact_paths)`);
|
|
106
|
+
}
|
|
107
|
+
if (typeof r.handoff === 'string' && r.handoff.length > MAX_RESPONSE_LINE_CHARS) {
|
|
108
|
+
errors.push(`handoff is ${r.handoff.length} chars (max ${MAX_RESPONSE_LINE_CHARS})`);
|
|
109
|
+
}
|
|
110
|
+
for (const [key, arr] of [
|
|
111
|
+
['findings', r.findings],
|
|
112
|
+
['risks', r.risks],
|
|
113
|
+
['artifact_paths', r.artifact_paths],
|
|
114
|
+
] as const) {
|
|
115
|
+
if (Array.isArray(arr) && arr.length > MAX_RESPONSE_ENTRIES) {
|
|
116
|
+
errors.push(`${key} carries ${arr.length} entries (max ${MAX_RESPONSE_ENTRIES}) — an envelope is a handoff, not a transcript`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
if (Array.isArray(r.risks)) {
|
|
120
|
+
for (const [i, risk] of (r.risks as unknown[]).entries()) {
|
|
121
|
+
if (typeof risk === 'string' && risk.length > MAX_RESPONSE_LINE_CHARS) {
|
|
122
|
+
errors.push(`risks[${i}] is ${risk.length} chars (max ${MAX_RESPONSE_LINE_CHARS})`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
if (Array.isArray(r.findings)) {
|
|
127
|
+
for (const [i, f] of (r.findings as Finding[]).entries()) {
|
|
128
|
+
if (f && typeof f.title === 'string' && f.title.length > MAX_RESPONSE_LINE_CHARS) {
|
|
129
|
+
errors.push(`finding[${i}] title is ${f.title.length} chars (max ${MAX_RESPONSE_LINE_CHARS})`);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
if (r.artifact_paths !== undefined) {
|
|
134
|
+
if (!Array.isArray(r.artifact_paths)) {
|
|
135
|
+
errors.push('artifact_paths must be an array of path ref tokens');
|
|
136
|
+
} else {
|
|
137
|
+
for (const [i, p] of (r.artifact_paths as unknown[]).entries()) {
|
|
138
|
+
if (typeof p !== 'string' || p.includes('\n') || p.length > MAX_ARTIFACT_REF_CHARS) {
|
|
139
|
+
errors.push(`artifact_paths[${i}] must be a single-line path ref ≤ ${MAX_ARTIFACT_REF_CHARS} chars`);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
try {
|
|
145
|
+
const serialized = JSON.stringify(input);
|
|
146
|
+
if (serialized.length > MAX_ENVELOPE_CHARS) {
|
|
147
|
+
errors.push(`envelope serializes to ${serialized.length} chars (max ${MAX_ENVELOPE_CHARS}) — move content to disk and reference it via artifact_paths`);
|
|
148
|
+
}
|
|
149
|
+
} catch {
|
|
150
|
+
errors.push('envelope is not JSON-serializable');
|
|
151
|
+
}
|
|
77
152
|
return { valid: errors.length === 0, errors };
|
|
78
153
|
}
|
|
79
154
|
|
|
@@ -45,6 +45,37 @@ import * as fs from 'node:fs';
|
|
|
45
45
|
import * as path from 'node:path';
|
|
46
46
|
|
|
47
47
|
import { hardenedSpawnEnv } from '../_lib/spawn_env.js';
|
|
48
|
+
import { SESSION_ROLE_ENV } from '../_lib/session_role.js';
|
|
49
|
+
import { load_agent_settings } from '../_lib/agent_settings.js';
|
|
50
|
+
|
|
51
|
+
// Session-wide subagent model ceiling (token-economy-dispatch Phase 5.2):
|
|
52
|
+
// class-C setting, default absent, HUMAN-set only. When present, the CLI
|
|
53
|
+
// spawn below exports CLAUDE_CODE_SUBAGENT_MODEL so the spawned vendor
|
|
54
|
+
// session caps its own subagents' models. Memoised PER CWD, not per
|
|
55
|
+
// process (review finding 2026-08-10): a long-lived server process may
|
|
56
|
+
// serve several projects, and a single-slot memo would apply project A's
|
|
57
|
+
// spend cap to project B's spawns. A human edit still needs a process
|
|
58
|
+
// restart to be seen — acceptable for a per-install cap, stated here.
|
|
59
|
+
const _modelCeilingMemo = new Map<string, string | null>();
|
|
60
|
+
export function _subagentModelCeiling(cwd: string = process.cwd()): string | null {
|
|
61
|
+
const hit = _modelCeilingMemo.get(cwd);
|
|
62
|
+
if (hit !== undefined) return hit;
|
|
63
|
+
let ceiling: string | null;
|
|
64
|
+
try {
|
|
65
|
+
const settings = load_agent_settings({ cwd }) as Record<string, unknown>;
|
|
66
|
+
const sub = (settings['subagents'] ?? {}) as Record<string, unknown>;
|
|
67
|
+
const v = typeof sub['model_ceiling'] === 'string' ? sub['model_ceiling'].trim() : '';
|
|
68
|
+
ceiling = v.length > 0 ? v : null;
|
|
69
|
+
} catch {
|
|
70
|
+
ceiling = null; // unreadable settings → no ceiling (fail-open)
|
|
71
|
+
}
|
|
72
|
+
_modelCeilingMemo.set(cwd, ceiling);
|
|
73
|
+
return ceiling;
|
|
74
|
+
}
|
|
75
|
+
/** Test seam: reset the per-cwd ceiling memo. */
|
|
76
|
+
export function _resetModelCeilingMemo(): void {
|
|
77
|
+
_modelCeilingMemo.clear();
|
|
78
|
+
}
|
|
48
79
|
import * as user_global_paths from '../_lib/user_global_paths.js';
|
|
49
80
|
import { appendEvent } from './events_log.js';
|
|
50
81
|
|
|
@@ -1179,7 +1210,25 @@ export abstract class CliClient extends ExternalAIClient {
|
|
|
1179
1210
|
// (loader preload, git *_COMMAND, NODE_OPTIONS, …) so an
|
|
1180
1211
|
// attacker-influenced parent env cannot RCE via the spawned CLI
|
|
1181
1212
|
// or a `git` it invokes internally.
|
|
1182
|
-
|
|
1213
|
+
// Role marking (token-economy-dispatch Phase 2.2): a council
|
|
1214
|
+
// member CLI session is worker-class — it never talks to OUR
|
|
1215
|
+
// user, never dispatches sub-workers, never runs a council.
|
|
1216
|
+
// The spawned vendor CLI loads this repo's hook chains; the
|
|
1217
|
+
// marker lets the dispatcher run the thinner worker chain
|
|
1218
|
+
// (manifest `roles.worker.drop`; pre_tool_use guards are
|
|
1219
|
+
// structurally exempt). This is the ONE in-tree spawn point
|
|
1220
|
+
// that launches a separate CLI session today — Agent-tool
|
|
1221
|
+
// subagents share the host process env and cannot be marked
|
|
1222
|
+
// (probed live 2026-08-10; see _lib/session_role.ts).
|
|
1223
|
+
// The optional model ceiling (subagents.model_ceiling, class C)
|
|
1224
|
+
// rides the same spawn: a spend cap the human set per install.
|
|
1225
|
+
env: hardenedSpawnEnv({
|
|
1226
|
+
[SESSION_ROLE_ENV]: 'worker',
|
|
1227
|
+
...((): Record<string, string> => {
|
|
1228
|
+
const ceiling = _subagentModelCeiling();
|
|
1229
|
+
return ceiling !== null ? { CLAUDE_CODE_SUBAGENT_MODEL: ceiling } : {};
|
|
1230
|
+
})(),
|
|
1231
|
+
}),
|
|
1183
1232
|
};
|
|
1184
1233
|
if (stdinPayload !== null) {
|
|
1185
1234
|
spawnOpts.input = stdinPayload;
|