@event4u/agent-config 9.31.0 → 9.33.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 +146 -0
- package/dist/agent-src/commands/review/routing.md +3 -4
- package/dist/agent-src/contexts/communication/rules-auto/reply-close-mechanics.md +65 -0
- package/dist/agent-src/contexts/execution/host-capability-manifest.md +32 -1
- package/dist/agent-src/contexts/execution/orchestration-telemetry.md +3 -0
- package/dist/agent-src/rules/delegation-policy.md +17 -1
- package/dist/agent-src/scripts/roadmap_gates.ts +107 -3
- package/dist/agent-src/skills/docker/SKILL.md +11 -0
- package/dist/agent-src/skills/laravel/SKILL.md +11 -0
- package/dist/agent-src/skills/review-routing/SKILL.md +23 -6
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/agent-src/templates/scripts/ownership-map.example.yml +1 -1
- package/dist/agent-src/templates/scripts/work_engine/delivery_state.ts +6 -0
- package/dist/agent-src/templates/scripts/work_engine/directives/backend/_self_fix.ts +332 -0
- package/dist/agent-src/templates/scripts/work_engine/directives/backend/refine.ts +106 -1
- package/dist/agent-src/templates/scripts/work_engine/directives/backend/test.ts +50 -15
- package/dist/agent-src/templates/scripts/work_engine/directives/backend/verify.ts +49 -15
- package/dist/agent-src/templates/scripts/work_engine/state.ts +74 -0
- package/dist/agent-src/templates/scripts/work_engine/state_io.ts +6 -0
- package/dist/cli/registry.js +4 -4
- package/dist/cli/registry.js.map +1 -1
- package/dist/cli-delegate/chunk-2WELHO3P.js +582 -0
- package/dist/cli-delegate/chunk-HTIU3W56.js +66 -0
- package/dist/cli-delegate/{chunk-ITIQSGOQ.js → chunk-MA4GFWDY.js} +7 -4
- package/dist/cli-delegate/{chunk-2WCLVDWC.js → chunk-PMQ6Z3T3.js} +1642 -165
- package/dist/cli-delegate/chunk-PZWRPRWP.js +38 -0
- package/dist/cli-delegate/{chunk-PFBV6XRT.js → chunk-Q26HT4QT.js} +1 -63
- package/dist/cli-delegate/{chunk-LVRYBKTI.js → chunk-YPAWXXXR.js} +158 -0
- package/dist/cli-delegate/cmd_conformance.js +308 -227
- package/dist/cli-delegate/cmd_doctor.js +4 -2
- package/dist/cli-delegate/cmd_export.js +1 -1
- package/dist/cli-delegate/cmd_handoff.js +160 -58
- package/dist/cli-delegate/cmd_prune.js +1 -1
- package/dist/cli-delegate/cmd_session_recycle.js +30 -5
- package/dist/cli-delegate/cmd_settings_get.js +4 -2
- package/dist/cli-delegate/cmd_settings_migrate.js +1 -1
- package/dist/cli-delegate/cmd_settings_set.js +5 -3
- package/dist/cli-delegate/cmd_sync.js +1 -1
- package/dist/cli-delegate/cmd_uninstall.js +1 -1
- package/dist/cli-delegate/cmd_validate.js +1 -1
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +8 -8
- 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 +1136 -690
- package/dist/install/install.mjs +7 -4
- package/dist/mcp/registry-manifest.json +2 -2
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +3688 -1159
- package/package.json +1 -1
- package/src/agent-src/scripts/roadmap_gates.ts +107 -3
- package/src/agent-src/templates/scripts/ownership-map.example.yml +1 -1
- package/src/agent-src/templates/scripts/work_engine/delivery_state.ts +6 -0
- package/src/agent-src/templates/scripts/work_engine/directives/backend/_self_fix.ts +332 -0
- package/src/agent-src/templates/scripts/work_engine/directives/backend/refine.ts +106 -1
- package/src/agent-src/templates/scripts/work_engine/directives/backend/test.ts +50 -15
- package/src/agent-src/templates/scripts/work_engine/directives/backend/verify.ts +49 -15
- package/src/agent-src/templates/scripts/work_engine/state.ts +74 -0
- package/src/agent-src/templates/scripts/work_engine/state_io.ts +6 -0
- package/src/config/cost-parity-budget.json +167 -0
- package/src/config/gate-coverage.yml +44 -5
- package/src/config/quorum-attendance-budget.json +39 -0
- package/src/scripts/_cli/cmd_doctor.ts +69 -1
- package/src/scripts/_cli/cmd_session_recycle.ts +44 -3
- package/src/scripts/_cli/handoff_generate.ts +36 -1
- package/src/scripts/_cli/handoff_sessions.ts +140 -9
- package/src/scripts/_dispatch.bash +5 -3
- package/src/scripts/_lib/carrier_divergence.ts +98 -27
- package/src/scripts/_lib/envelope_grounding.ts +206 -0
- package/src/scripts/_lib/host_capability.ts +79 -0
- package/src/scripts/_lib/orchestration_record.ts +45 -0
- package/src/scripts/_lib/session_eol.ts +85 -0
- package/src/scripts/_lib/subagent_capsule.ts +256 -1
- package/src/scripts/ai_council/clients.ts +31 -0
- package/src/scripts/ai_council/events_log.ts +128 -6
- package/src/scripts/ai_council/orchestrator.ts +12 -3
- package/src/scripts/ai_council/pricing.ts +48 -1
- package/src/scripts/ai_council/quorum.ts +20 -0
- package/src/scripts/ai_council/session.ts +11 -0
- package/src/scripts/ask_transport.ts +20 -1
- package/src/scripts/cache_realization_report.ts +38 -0
- package/src/scripts/check_release_highlights.ts +7 -1
- package/src/scripts/check_secret_leak.test.ts +43 -0
- package/src/scripts/check_secret_leak.ts +12 -0
- package/src/scripts/conformance_scan.ts +105 -0
- package/src/scripts/cost/track.mjs +39 -0
- package/src/scripts/cost_summary.ts +104 -1
- package/src/scripts/council_cli.ts +131 -13
- package/src/scripts/dispatch_economy_report.ts +5 -2
- package/src/scripts/handoff_context_hook.ts +66 -18
- package/src/scripts/hooks/session_eol_hook.ts +12 -12
- package/src/scripts/install.ts +20 -6
- package/src/scripts/lint_budget_ownership.ts +81 -0
- package/src/scripts/lint_skill_frontmatter_safety.ts +55 -1
- package/src/scripts/lint_workflow_paths.ts +384 -0
- package/src/scripts/preamble_byte_census.ts +9 -1
- package/src/scripts/report_carrier_divergence.ts +104 -29
- package/src/scripts/report_conformance_funnel.ts +51 -4
- package/src/scripts/routing_doctor.ts +25 -2
- package/src/scripts/schemas/dod.schema.json +45 -0
- package/src/scripts/schemas/subagent.schema.json +3 -2
- package/dist/cli-delegate/chunk-RVGBH4XK.js +0 -142
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic environment grounding for the handoff envelope
|
|
3
|
+
* (road-to-cost-parity-3 Phase 3.2–3.3).
|
|
4
|
+
*
|
|
5
|
+
* The factual half of an envelope — where the work was, on what branch, at
|
|
6
|
+
* which commit, what was uncommitted, what was last verified — is collected
|
|
7
|
+
* by THIS module, not composed by a model. Scripted facts are free and
|
|
8
|
+
* verifiable; deriving them through a model is the right idea at the wrong
|
|
9
|
+
* price, and a model-composed "branch" is a claim rather than a reading.
|
|
10
|
+
*
|
|
11
|
+
* Every git read goes through `gitEnv()`. An inherited `GIT_DIR` (which any
|
|
12
|
+
* hook exports) silently redirects `git` at the wrong repository, which would
|
|
13
|
+
* make the drift anchor confidently wrong — the one failure mode worse than
|
|
14
|
+
* having no anchor.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { execFileSync } from 'node:child_process';
|
|
18
|
+
import * as fs from 'node:fs';
|
|
19
|
+
import * as path from 'node:path';
|
|
20
|
+
|
|
21
|
+
import { git_common_dir } from './git_common_dir.js';
|
|
22
|
+
import { gitEnv } from './git_env.js';
|
|
23
|
+
|
|
24
|
+
/** Longest ref/line this module will emit — mirrors the envelope's own caps. */
|
|
25
|
+
const MAX_FIELD_CHARS = 200;
|
|
26
|
+
/** Uncommitted paths are a pointer list, not a diff. */
|
|
27
|
+
const MAX_PATHS = 40;
|
|
28
|
+
|
|
29
|
+
function git(root: string, args: string[]): string | null {
|
|
30
|
+
try {
|
|
31
|
+
return execFileSync('git', args, {
|
|
32
|
+
cwd: root,
|
|
33
|
+
env: gitEnv(),
|
|
34
|
+
encoding: 'utf-8',
|
|
35
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
36
|
+
}).trim();
|
|
37
|
+
} catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Canonical form of a repo identity, so the SAME repository never reads as
|
|
44
|
+
* two different ones. Committed rules, in order:
|
|
45
|
+
*
|
|
46
|
+
* 1. scheme stripped (`https://`, `ssh://`, `git://`)
|
|
47
|
+
* 2. credentials stripped (`user:pass@`, `git@`)
|
|
48
|
+
* 3. scp-style `host:org/repo` → `host/org/repo`
|
|
49
|
+
* 4. host segment lower-cased
|
|
50
|
+
* 5. trailing `.git` and trailing `/` removed
|
|
51
|
+
*
|
|
52
|
+
* Without these, one checkout's `git@github.com:Org/Repo.git` and another's
|
|
53
|
+
* `https://github.com/org/repo` compare as drift while being the same repo —
|
|
54
|
+
* a false alarm every time, which trains the reader to ignore the real one.
|
|
55
|
+
*/
|
|
56
|
+
export function canonicalizeRepoIdentity(raw: string): string {
|
|
57
|
+
let s = raw.trim();
|
|
58
|
+
s = s.replace(/^[a-z][a-z0-9+.-]*:\/\//i, ''); // scheme
|
|
59
|
+
s = s.replace(/^[^/@]+@/, ''); // credentials / ssh user
|
|
60
|
+
s = s.replace(/^([^/:]+):(?!\/)/, '$1/'); // scp-style separator
|
|
61
|
+
s = s.replace(/\/+$/, '');
|
|
62
|
+
s = s.replace(/\.git$/i, '');
|
|
63
|
+
const slash = s.indexOf('/');
|
|
64
|
+
if (slash > 0) {
|
|
65
|
+
s = s.slice(0, slash).toLowerCase() + s.slice(slash);
|
|
66
|
+
} else {
|
|
67
|
+
s = s.toLowerCase();
|
|
68
|
+
}
|
|
69
|
+
return s;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The three fields that together identify a working tree at a point in time. */
|
|
73
|
+
export interface RepoAnchor {
|
|
74
|
+
repo_identity: string | null;
|
|
75
|
+
branch: string | null;
|
|
76
|
+
head: string | null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Read the anchor for `root`.
|
|
81
|
+
*
|
|
82
|
+
* Identity is the canonicalized `origin` remote, or — when the repo has no
|
|
83
|
+
* remote — the realpath of the common git dir. Branch and HEAD alone are NOT
|
|
84
|
+
* an anchor: this repo routinely runs many worktrees, a branch name is unique
|
|
85
|
+
* in neither, and two checkouts sitting at the same commit on a same-named
|
|
86
|
+
* branch would compare as "no drift" while being different working trees.
|
|
87
|
+
*/
|
|
88
|
+
export function collectRepoAnchor(root: string): RepoAnchor {
|
|
89
|
+
const remote = git(root, ['config', '--get', 'remote.origin.url']);
|
|
90
|
+
let identity: string | null = null;
|
|
91
|
+
if (remote) {
|
|
92
|
+
identity = canonicalizeRepoIdentity(remote);
|
|
93
|
+
} else {
|
|
94
|
+
const common = git_common_dir(root);
|
|
95
|
+
if (common !== null) {
|
|
96
|
+
try {
|
|
97
|
+
identity = fs.realpathSync(common);
|
|
98
|
+
} catch {
|
|
99
|
+
identity = common;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return {
|
|
104
|
+
repo_identity: identity ? identity.slice(0, MAX_FIELD_CHARS) : null,
|
|
105
|
+
branch: git(root, ['rev-parse', '--abbrev-ref', 'HEAD']),
|
|
106
|
+
head: git(root, ['rev-parse', 'HEAD']),
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** The scripted fields the model never composes. */
|
|
111
|
+
export interface Grounding extends RepoAnchor {
|
|
112
|
+
uncommitted_paths: string[];
|
|
113
|
+
status_summary: string | null;
|
|
114
|
+
last_verify: string | null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const VERIFY_STATE_REL = path.join('agents', 'runtime', 'state', 'verify-before-complete.json');
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The last verification this workspace recorded, as one line.
|
|
121
|
+
*
|
|
122
|
+
* **What this is not:** an exit code. The roadmap step asked for "last verify
|
|
123
|
+
* exit", and nothing in the tree records one — `before_complete_hook` stores
|
|
124
|
+
* `{command, tool, at, …}` and no status. Emitting a fabricated exit would be
|
|
125
|
+
* worse than emitting less, so the field carries the command and its
|
|
126
|
+
* timestamp and says so, rather than implying a pass/fail the source never
|
|
127
|
+
* held.
|
|
128
|
+
*/
|
|
129
|
+
export function readLastVerify(root: string): string | null {
|
|
130
|
+
try {
|
|
131
|
+
const raw = fs.readFileSync(path.join(root, VERIFY_STATE_REL), 'utf-8');
|
|
132
|
+
const state = JSON.parse(raw) as Record<string, unknown>;
|
|
133
|
+
const lv = state['last_verification'];
|
|
134
|
+
if (typeof lv !== 'object' || lv === null || Array.isArray(lv)) return null;
|
|
135
|
+
const rec = lv as Record<string, unknown>;
|
|
136
|
+
const command = typeof rec['command'] === 'string' ? rec['command'] : null;
|
|
137
|
+
if (!command) return null;
|
|
138
|
+
const at = typeof rec['at'] === 'string' ? rec['at'] : 'unknown time';
|
|
139
|
+
return `${command} @ ${at} (no exit status is recorded anywhere)`.slice(0, MAX_FIELD_CHARS);
|
|
140
|
+
} catch {
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Collect every scripted field. Never throws — an unreadable fact is `null`. */
|
|
146
|
+
export function collectGrounding(root: string): Grounding {
|
|
147
|
+
const anchor = collectRepoAnchor(root);
|
|
148
|
+
const porcelain = git(root, ['status', '--porcelain']);
|
|
149
|
+
if (porcelain === null) {
|
|
150
|
+
// A FAILED read is not a clean tree. Collapsing the two would make
|
|
151
|
+
// this the one field in the module that asserts something it never
|
|
152
|
+
// read — and it would assert it exactly when every sibling field is
|
|
153
|
+
// null, so `describeDrift` is silent and nothing challenges it.
|
|
154
|
+
return { ...anchor, uncommitted_paths: [], status_summary: null, last_verify: readLastVerify(root) };
|
|
155
|
+
}
|
|
156
|
+
const lines = porcelain.split('\n').filter((l) => l.trim().length > 0);
|
|
157
|
+
const paths = lines
|
|
158
|
+
.map((l) => l.slice(3).trim())
|
|
159
|
+
.filter((p) => p.length > 0 && p.length <= MAX_FIELD_CHARS)
|
|
160
|
+
.slice(0, MAX_PATHS);
|
|
161
|
+
return {
|
|
162
|
+
...anchor,
|
|
163
|
+
uncommitted_paths: paths,
|
|
164
|
+
status_summary:
|
|
165
|
+
lines.length === 0
|
|
166
|
+
? 'clean working tree'
|
|
167
|
+
: `${lines.length} uncommitted path(s)${lines.length > MAX_PATHS ? `, first ${MAX_PATHS} listed` : ''}`,
|
|
168
|
+
last_verify: readLastVerify(root),
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Compare an envelope's recorded anchor against the tree it is being injected
|
|
174
|
+
* into. Returns one line per mismatch — the consumer LEADS the injected block
|
|
175
|
+
* with them, so a stale resume can never be silent.
|
|
176
|
+
*
|
|
177
|
+
* A field the envelope never recorded produces no line: an absent anchor is
|
|
178
|
+
* an older envelope, not evidence of drift.
|
|
179
|
+
*/
|
|
180
|
+
export function describeDrift(envelope: unknown, actual: RepoAnchor): string[] {
|
|
181
|
+
if (typeof envelope !== 'object' || envelope === null || Array.isArray(envelope)) return [];
|
|
182
|
+
const e = envelope as Record<string, unknown>;
|
|
183
|
+
const drift: string[] = [];
|
|
184
|
+
const recordedIdentity = typeof e['repo_identity'] === 'string' ? e['repo_identity'] : null;
|
|
185
|
+
if (recordedIdentity && actual.repo_identity && recordedIdentity !== actual.repo_identity) {
|
|
186
|
+
drift.push(
|
|
187
|
+
`IDENTITY DRIFT: this envelope was written in "${recordedIdentity}", you are in ` +
|
|
188
|
+
`"${actual.repo_identity}". Re-verify every path before acting — same branch name ` +
|
|
189
|
+
`does not mean same working tree.`,
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
const recordedBranch = typeof e['branch'] === 'string' ? e['branch'] : null;
|
|
193
|
+
if (recordedBranch && actual.branch && recordedBranch !== actual.branch) {
|
|
194
|
+
drift.push(
|
|
195
|
+
`BRANCH DRIFT: envelope written on "${recordedBranch}", checkout is on "${actual.branch}".`,
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
const recordedHead = typeof e['head'] === 'string' ? e['head'] : null;
|
|
199
|
+
if (recordedHead && actual.head && recordedHead !== actual.head) {
|
|
200
|
+
drift.push(
|
|
201
|
+
`COMMIT DRIFT: envelope written at ${recordedHead.slice(0, 12)}, HEAD is now ` +
|
|
202
|
+
`${actual.head.slice(0, 12)} — re-read anything the envelope quotes.`,
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
return drift;
|
|
206
|
+
}
|
|
@@ -31,6 +31,11 @@
|
|
|
31
31
|
* parameter stays for back-compat (tests, and any caller that genuinely has a
|
|
32
32
|
* host-knowledge object of its own) — no production caller passes a
|
|
33
33
|
* settings-derived value into it any more.
|
|
34
|
+
*
|
|
35
|
+
* `describeHostCapabilities` (road-to-capability-answerability Phase 1.2) is
|
|
36
|
+
* the same resolution plus per-field provenance, because five of the six
|
|
37
|
+
* fields come from a committed table rather than from any live check and the
|
|
38
|
+
* `probe*` name says otherwise.
|
|
34
39
|
*/
|
|
35
40
|
import process from 'node:process';
|
|
36
41
|
|
|
@@ -197,3 +202,77 @@ export function probeHostCapabilities(hostId: string | null | undefined): HostCa
|
|
|
197
202
|
const probedAgentTeams = typeof flag === 'string' && flag !== '';
|
|
198
203
|
return { ...base, agent_teams: base.agent_teams || probedAgentTeams };
|
|
199
204
|
}
|
|
205
|
+
|
|
206
|
+
/** Every manifest field that carries a capability answer (i.e. not `schema_version`). */
|
|
207
|
+
export type CapabilityField = Exclude<keyof HostCapabilityManifest, 'schema_version'>;
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Where one field's resolved value came from.
|
|
211
|
+
*
|
|
212
|
+
* `registry` is deliberately NOT called "detected": a registry row is a
|
|
213
|
+
* capability this repo OBSERVED on that host once and then committed to a
|
|
214
|
+
* hardcoded table. It is an assertion about the host, checked at authoring
|
|
215
|
+
* time and never re-checked at run time. `live-probe` is the only value that
|
|
216
|
+
* was established in THIS process, from the environment. `default` means
|
|
217
|
+
* nothing answered and the all-false safe default applied.
|
|
218
|
+
*/
|
|
219
|
+
export type CapabilitySource = 'registry' | 'live-probe' | 'default';
|
|
220
|
+
|
|
221
|
+
/** Per-field provenance for a resolved manifest. */
|
|
222
|
+
export type HostCapabilitySources = Record<CapabilityField, CapabilitySource>;
|
|
223
|
+
|
|
224
|
+
export interface HostCapabilityDescription {
|
|
225
|
+
manifest: HostCapabilityManifest;
|
|
226
|
+
sources: HostCapabilitySources;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const CAPABILITY_FIELDS: readonly CapabilityField[] = [
|
|
230
|
+
'subagent_spawn',
|
|
231
|
+
'parallel_spawn',
|
|
232
|
+
'status_polling',
|
|
233
|
+
'separate_quota_pool',
|
|
234
|
+
'agent_teams',
|
|
235
|
+
'worker_respawn',
|
|
236
|
+
];
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Resolve the manifest AND say, per field, what answered it.
|
|
240
|
+
*
|
|
241
|
+
* `probeHostCapabilities` returns six booleans that look alike and are not:
|
|
242
|
+
* on the one host with a registry row, two of them come from a committed
|
|
243
|
+
* table, one can come from a live environment read, and the rest are the
|
|
244
|
+
* safe default — i.e. "we have no answer", rendered as `false`, which is
|
|
245
|
+
* indistinguishable from "we checked and it is absent". A caller deciding
|
|
246
|
+
* whether to trust `subagent_spawn: false` needs that difference, and the
|
|
247
|
+
* function name `probe*` actively suggests the wrong one.
|
|
248
|
+
*
|
|
249
|
+
* The manifest half delegates to {@link probeHostCapabilities} rather than
|
|
250
|
+
* re-deriving it, so a provenance readout can never disagree with the value
|
|
251
|
+
* the delegation layer actually gated on — the same
|
|
252
|
+
* two-readers-of-one-fact failure `routing_doctor` already records against
|
|
253
|
+
* its own pre-registry bug.
|
|
254
|
+
*/
|
|
255
|
+
export function describeHostCapabilities(
|
|
256
|
+
hostId: string | null | undefined,
|
|
257
|
+
): HostCapabilityDescription {
|
|
258
|
+
const manifest = probeHostCapabilities(hostId);
|
|
259
|
+
const row =
|
|
260
|
+
hostId !== null && hostId !== undefined ? HOST_CAPABILITY_REGISTRY[hostId] : undefined;
|
|
261
|
+
const flag = process.env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS;
|
|
262
|
+
const probedAgentTeams = typeof flag === 'string' && flag !== '';
|
|
263
|
+
|
|
264
|
+
const sources = {} as HostCapabilitySources;
|
|
265
|
+
for (const field of CAPABILITY_FIELDS) {
|
|
266
|
+
// A row entry claims the field whatever its value: an explicit
|
|
267
|
+
// `false` in the table is an observation ("checked, absent"), which is
|
|
268
|
+
// a different fact from the default's "nobody answered".
|
|
269
|
+
if (row !== undefined && typeof row[field] === 'boolean') {
|
|
270
|
+
sources[field] = 'registry';
|
|
271
|
+
} else if (field === 'agent_teams' && probedAgentTeams) {
|
|
272
|
+
sources[field] = 'live-probe';
|
|
273
|
+
} else {
|
|
274
|
+
sources[field] = 'default';
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
return { manifest, sources };
|
|
278
|
+
}
|
|
@@ -64,6 +64,20 @@ export interface RecordInput {
|
|
|
64
64
|
dispatch_tokens?: number | null | undefined;
|
|
65
65
|
/** The orchestrator's own tier — the baseline the downshift cost-% measures against. */
|
|
66
66
|
session_tier?: string | null | undefined;
|
|
67
|
+
/**
|
|
68
|
+
* The model id that was ASKED for. `tier_chosen`, `tier_source`,
|
|
69
|
+
* `session_tier` and the downshift cost-percentage are all read off the
|
|
70
|
+
* requested tier, so on an alias or a provider substitution every one of
|
|
71
|
+
* them attributes the saving to a model that never ran. Recording the
|
|
72
|
+
* requested id beside the served one is what makes that detectable.
|
|
73
|
+
*/
|
|
74
|
+
model_requested?: string | null | undefined;
|
|
75
|
+
/**
|
|
76
|
+
* The model id the provider reported SERVING. `''` / null when the
|
|
77
|
+
* transport reports none (every CLI client) — absent is honest, not a
|
|
78
|
+
* defect, and is NOT a divergence.
|
|
79
|
+
*/
|
|
80
|
+
model_served?: string | null | undefined;
|
|
67
81
|
dispatch_outcome?: DispatchOutcome | undefined;
|
|
68
82
|
verify_mode?: VerifyMode | undefined;
|
|
69
83
|
/** QUALITY: subagent return adopted without parent rework. Optional boolean (null = not measured). */
|
|
@@ -176,6 +190,23 @@ function isInt(n: unknown): n is number {
|
|
|
176
190
|
return typeof n === 'number' && Number.isFinite(n) && Number.isInteger(n);
|
|
177
191
|
}
|
|
178
192
|
|
|
193
|
+
/**
|
|
194
|
+
* Did the provider answer with a different model than the one requested?
|
|
195
|
+
*
|
|
196
|
+
* `null` — not decidable — whenever either id is missing or empty. That is the
|
|
197
|
+
* common case (every CLI transport reports no served id) and it is not the
|
|
198
|
+
* same as "no divergence": a `false` here would claim a comparison that never
|
|
199
|
+
* happened, which is the exact mis-attribution this field exists to expose.
|
|
200
|
+
*/
|
|
201
|
+
function modelDivergent(
|
|
202
|
+
requested: string | null | undefined,
|
|
203
|
+
served: string | null | undefined,
|
|
204
|
+
): boolean | null {
|
|
205
|
+
if (typeof requested !== 'string' || typeof served !== 'string') return null;
|
|
206
|
+
if (!requested || !served) return null;
|
|
207
|
+
return requested !== served;
|
|
208
|
+
}
|
|
209
|
+
|
|
179
210
|
/**
|
|
180
211
|
* Build + validate the audit-log-v1 line. Returns `{line, errors}`; when
|
|
181
212
|
* `errors` is non-empty, `line` is null and the caller must NOT write.
|
|
@@ -290,6 +321,13 @@ export function buildOrchestrationLine(input: RecordInput): BuiltLine {
|
|
|
290
321
|
errors.push(`trigger_arm_earlier must be one of ${TRIGGER_ARMS.join(' | ')} (or omitted / null when neither arm fired)`);
|
|
291
322
|
}
|
|
292
323
|
|
|
324
|
+
for (const [key, v] of [
|
|
325
|
+
['model_requested', input.model_requested],
|
|
326
|
+
['model_served', input.model_served],
|
|
327
|
+
] as const) {
|
|
328
|
+
if (v != null && typeof v !== 'string') errors.push(`${key} must be a string or omitted`);
|
|
329
|
+
}
|
|
330
|
+
|
|
293
331
|
if (!input.ts) errors.push('ts (ISO-8601 UTC) is required');
|
|
294
332
|
if (!input.id) errors.push('id (ULID or content hash) is required');
|
|
295
333
|
|
|
@@ -310,6 +348,13 @@ export function buildOrchestrationLine(input: RecordInput): BuiltLine {
|
|
|
310
348
|
tier_source: input.tier_source ?? null,
|
|
311
349
|
dispatch_tokens: input.dispatch_tokens ?? null,
|
|
312
350
|
session_tier: input.session_tier ?? null,
|
|
351
|
+
// Served-model attribution. `model_divergent` is DERIVED, never
|
|
352
|
+
// supplied: `null` when either id is absent — a transport that reports
|
|
353
|
+
// no served id cannot disagree with anything, and calling that `false`
|
|
354
|
+
// would read as "checked, and they matched".
|
|
355
|
+
model_requested: input.model_requested ?? null,
|
|
356
|
+
model_served: input.model_served ?? null,
|
|
357
|
+
model_divergent: modelDivergent(input.model_requested, input.model_served),
|
|
313
358
|
first_pass_success: input.first_pass_success ?? null,
|
|
314
359
|
escalated: input.escalated ?? null,
|
|
315
360
|
agent_combo: input.agent_combo ?? [],
|
|
@@ -30,7 +30,9 @@
|
|
|
30
30
|
* 50 MB `isSafeTranscriptPath` cap away.
|
|
31
31
|
*/
|
|
32
32
|
|
|
33
|
+
import * as crypto from 'node:crypto';
|
|
33
34
|
import * as fs from 'node:fs';
|
|
35
|
+
import * as path from 'node:path';
|
|
34
36
|
|
|
35
37
|
import { billableInputTokens } from './cc_transcript.js';
|
|
36
38
|
|
|
@@ -57,6 +59,13 @@ export interface EolCounters {
|
|
|
57
59
|
turns: number;
|
|
58
60
|
/** Main-chain assistant records carrying a `usage` block. */
|
|
59
61
|
assistant_records: number;
|
|
62
|
+
/**
|
|
63
|
+
* Main-chain assistant records carrying at least one `tool_use` content
|
|
64
|
+
* block. Added after the initial schema, so a state file written before
|
|
65
|
+
* it is missing the key — readers MUST treat `undefined` as *unknown*,
|
|
66
|
+
* never as zero (`is_substantive` in `_cli/handoff_sessions.ts` does).
|
|
67
|
+
*/
|
|
68
|
+
tool_calls: number;
|
|
60
69
|
/** Billable input tokens of the LAST main-chain assistant record — the fill level. */
|
|
61
70
|
final_context_tokens: number | null;
|
|
62
71
|
/** `timestamp` of that record. */
|
|
@@ -75,6 +84,7 @@ export function emptyCounters(): EolCounters {
|
|
|
75
84
|
scanned_bytes: 0,
|
|
76
85
|
turns: 0,
|
|
77
86
|
assistant_records: 0,
|
|
87
|
+
tool_calls: 0,
|
|
78
88
|
final_context_tokens: null,
|
|
79
89
|
final_context_at: null,
|
|
80
90
|
compactions: [],
|
|
@@ -165,6 +175,26 @@ export function scanEolSlice(text: string, prior: EolCounters): EolCounters {
|
|
|
165
175
|
}
|
|
166
176
|
if (record['type'] === 'assistant') {
|
|
167
177
|
const message = record['message'];
|
|
178
|
+
const content =
|
|
179
|
+
typeof message === 'object' && message !== null
|
|
180
|
+
? (message as Record<string, unknown>)['content']
|
|
181
|
+
: undefined;
|
|
182
|
+
if (
|
|
183
|
+
Array.isArray(content) &&
|
|
184
|
+
content.some(
|
|
185
|
+
(b) =>
|
|
186
|
+
typeof b === 'object' &&
|
|
187
|
+
b !== null &&
|
|
188
|
+
(b as Record<string, unknown>)['type'] === 'tool_use',
|
|
189
|
+
)
|
|
190
|
+
) {
|
|
191
|
+
// `{...prior}` carries `undefined` forward from a state file
|
|
192
|
+
// written before this counter existed, and `undefined + 1` is
|
|
193
|
+
// NaN — which `JSON.stringify` writes as `null`, i.e. as a
|
|
194
|
+
// counted zero rather than as the unknown it is. Coerce here
|
|
195
|
+
// so the writer can never persist that.
|
|
196
|
+
next.tool_calls = (Number.isFinite(next.tool_calls) ? next.tool_calls : 0) + 1;
|
|
197
|
+
}
|
|
168
198
|
const usage =
|
|
169
199
|
typeof message === 'object' && message !== null
|
|
170
200
|
? ((message as Record<string, unknown>)['usage'] as Record<string, unknown> | undefined)
|
|
@@ -184,6 +214,61 @@ export function scanEolSlice(text: string, prior: EolCounters): EolCounters {
|
|
|
184
214
|
return next;
|
|
185
215
|
}
|
|
186
216
|
|
|
217
|
+
// ---------------------------------------------------------------------
|
|
218
|
+
// State location — one derivation, shared by the writer (the Stop-slot
|
|
219
|
+
// concern) and every reader (`agent-config handoff`).
|
|
220
|
+
// ---------------------------------------------------------------------
|
|
221
|
+
|
|
222
|
+
/** Directory holding the per-session counts-only state files. */
|
|
223
|
+
export function eolStateDir(workspaceRoot: string): string {
|
|
224
|
+
return path.join(workspaceRoot, 'agents', 'runtime', 'state', 'session-eol');
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Absolute path of one session's state file. */
|
|
228
|
+
export function eolStateFile(workspaceRoot: string, sessionKey: string): string {
|
|
229
|
+
return path.join(eolStateDir(workspaceRoot), `${sessionKey}.json`);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* The state-file key for a session: sha256 of the raw session id, or of the
|
|
234
|
+
* transcript path when no id is known. Hashed, never raw — the filename must
|
|
235
|
+
* not carry an identifier.
|
|
236
|
+
*/
|
|
237
|
+
export function eolSessionKey(raw: string | null | undefined): string {
|
|
238
|
+
return crypto.createHash('sha256').update(raw || 'unknown-session').digest('hex');
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Counters as they come off DISK. `tool_calls` is optional here and required
|
|
243
|
+
* in `EolCounters` on purpose: a file written before that counter existed
|
|
244
|
+
* carries no key, and "the writer never counted" must stay distinguishable
|
|
245
|
+
* from "the writer counted zero".
|
|
246
|
+
*/
|
|
247
|
+
export type StoredEolCounters = Omit<EolCounters, 'tool_calls'> & { tool_calls?: number };
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Read one session's counters. Returns `null` for absent, unreadable, or
|
|
251
|
+
* mis-shaped state — the caller decides what that means, and every current
|
|
252
|
+
* caller fails OPEN (a wrongly listed session is noise; a wrongly hidden one
|
|
253
|
+
* is data loss).
|
|
254
|
+
*/
|
|
255
|
+
export function readEolCounters(
|
|
256
|
+
workspaceRoot: string,
|
|
257
|
+
sessionKey: string,
|
|
258
|
+
): StoredEolCounters | null {
|
|
259
|
+
try {
|
|
260
|
+
const raw = fs.readFileSync(eolStateFile(workspaceRoot, sessionKey), 'utf-8');
|
|
261
|
+
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
|
262
|
+
const counters = parsed['counters'];
|
|
263
|
+
if (typeof counters !== 'object' || counters === null || Array.isArray(counters)) {
|
|
264
|
+
return null;
|
|
265
|
+
}
|
|
266
|
+
return counters as StoredEolCounters;
|
|
267
|
+
} catch {
|
|
268
|
+
return null;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
187
272
|
export interface NewLinesRead {
|
|
188
273
|
/** The complete lines appended since `fromByte` (possibly empty). */
|
|
189
274
|
text: string;
|