@event4u/agent-config 9.30.0 → 9.32.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.
Files changed (94) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +60 -292
  3. package/dist/agent-src/contexts/execution/host-capability-manifest.md +32 -1
  4. package/dist/agent-src/rules/delegation-policy.md +11 -1
  5. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  6. package/dist/agent-src/templates/scripts/work_engine/delivery_state.ts +6 -0
  7. package/dist/agent-src/templates/scripts/work_engine/directives/backend/_self_fix.ts +332 -0
  8. package/dist/agent-src/templates/scripts/work_engine/directives/backend/refine.ts +106 -1
  9. package/dist/agent-src/templates/scripts/work_engine/directives/backend/test.ts +50 -15
  10. package/dist/agent-src/templates/scripts/work_engine/directives/backend/verify.ts +49 -15
  11. package/dist/agent-src/templates/scripts/work_engine/state.ts +74 -0
  12. package/dist/agent-src/templates/scripts/work_engine/state_io.ts +6 -0
  13. package/dist/cli-delegate/chunk-2WELHO3P.js +582 -0
  14. package/dist/cli-delegate/{chunk-IYTGIG2Y.js → chunk-542DZR4P.js} +118 -148
  15. package/dist/cli-delegate/{chunk-66OVWGB5.js → chunk-AG47LAGQ.js} +1 -1
  16. package/dist/cli-delegate/{chunk-MP3JT3IU.js → chunk-DS6BLDMG.js} +1 -1
  17. package/dist/cli-delegate/{chunk-O3CO5BFB.js → chunk-EJITJBJX.js} +12 -1
  18. package/dist/cli-delegate/{chunk-EQY4FLJB.js → chunk-ITIQSGOQ.js} +3 -3
  19. package/dist/cli-delegate/{chunk-LCFJIQ4J.js → chunk-JEJABGKB.js} +110 -6
  20. package/dist/cli-delegate/chunk-PZWRPRWP.js +38 -0
  21. package/dist/cli-delegate/{chunk-BTJNRUX3.js → chunk-QEEB4LHO.js} +1 -1
  22. package/dist/cli-delegate/{chunk-DDUNNFYX.js → chunk-UVKNIJH2.js} +2 -2
  23. package/dist/cli-delegate/cmd_analyze_session.js +1 -1
  24. package/dist/cli-delegate/cmd_brand_status.js +1 -1
  25. package/dist/cli-delegate/cmd_conformance.js +154 -41
  26. package/dist/cli-delegate/cmd_doctor.js +4 -3
  27. package/dist/cli-delegate/cmd_explain.js +2 -2
  28. package/dist/cli-delegate/cmd_export.js +4 -4
  29. package/dist/cli-delegate/cmd_handoff.js +168 -59
  30. package/dist/cli-delegate/cmd_mcp_available.js +1 -1
  31. package/dist/cli-delegate/cmd_migrate.js +2 -2
  32. package/dist/cli-delegate/cmd_packs_active.js +2 -2
  33. package/dist/cli-delegate/cmd_prune.js +4 -4
  34. package/dist/cli-delegate/cmd_route_audit.js +2 -2
  35. package/dist/cli-delegate/cmd_route_explain.js +1 -1
  36. package/dist/cli-delegate/cmd_session_recycle.js +19 -4
  37. package/dist/cli-delegate/cmd_settings_get.js +2 -55
  38. package/dist/cli-delegate/cmd_settings_migrate.js +4 -4
  39. package/dist/cli-delegate/cmd_settings_set.js +1 -1
  40. package/dist/cli-delegate/cmd_sync.js +4 -4
  41. package/dist/cli-delegate/cmd_uninstall.js +4 -4
  42. package/dist/cli-delegate/cmd_update.js +1 -1
  43. package/dist/cli-delegate/cmd_upgrade.js +1 -1
  44. package/dist/cli-delegate/cmd_validate.js +4 -4
  45. package/dist/cli-delegate/cmd_versions.js +1 -1
  46. package/dist/discovery/deprecation-report.md +1 -1
  47. package/dist/discovery/discovery-manifest.json +3 -3
  48. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  49. package/dist/discovery/discovery-manifest.summary.md +1 -1
  50. package/dist/discovery/orphan-report.md +1 -1
  51. package/dist/discovery/packs.json +2 -2
  52. package/dist/discovery/trust-report.md +1 -1
  53. package/dist/discovery/workspaces.json +2 -2
  54. package/dist/hooks/dispatch.js +1411 -810
  55. package/dist/install/install.mjs +121 -16
  56. package/dist/mcp/registry-manifest.json +1 -1
  57. package/dist/mcp/server.json +2 -2
  58. package/dist/mcp/server.mjs +365 -143
  59. package/dist/scripts/_lib/agent_settings.js +111 -2
  60. package/dist/scripts/_lib/agent_settings.js.map +1 -1
  61. package/dist/server/schemas/settings.js +2 -2
  62. package/dist/server/schemas/settings.js.map +1 -1
  63. package/package.json +1 -1
  64. package/src/agent-src/templates/scripts/work_engine/delivery_state.ts +6 -0
  65. package/src/agent-src/templates/scripts/work_engine/directives/backend/_self_fix.ts +332 -0
  66. package/src/agent-src/templates/scripts/work_engine/directives/backend/refine.ts +106 -1
  67. package/src/agent-src/templates/scripts/work_engine/directives/backend/test.ts +50 -15
  68. package/src/agent-src/templates/scripts/work_engine/directives/backend/verify.ts +49 -15
  69. package/src/agent-src/templates/scripts/work_engine/state.ts +74 -0
  70. package/src/agent-src/templates/scripts/work_engine/state_io.ts +6 -0
  71. package/src/config/pack-size-budget.json +3 -2
  72. package/src/scripts/_cli/cmd_session_recycle.ts +25 -0
  73. package/src/scripts/_cli/explain_last/inputs.ts +13 -0
  74. package/src/scripts/_cli/handoff_generate.ts +36 -1
  75. package/src/scripts/_cli/handoff_sessions.ts +140 -9
  76. package/src/scripts/_lib/agent_settings.ts +119 -2
  77. package/src/scripts/_lib/carrier_divergence.ts +120 -2
  78. package/src/scripts/_lib/envelope_grounding.ts +206 -0
  79. package/src/scripts/_lib/host_capability.ts +79 -0
  80. package/src/scripts/_lib/session_eol.ts +85 -0
  81. package/src/scripts/_lib/subagent_capsule.ts +256 -1
  82. package/src/scripts/check_secret_leak.test.ts +43 -0
  83. package/src/scripts/check_secret_leak.ts +12 -0
  84. package/src/scripts/handoff_context_hook.ts +66 -18
  85. package/src/scripts/hooks/session_eol_hook.ts +12 -12
  86. package/src/scripts/hooks/turn_end_gate_hook.ts +296 -88
  87. package/src/scripts/language_mirror_hook.ts +69 -16
  88. package/src/scripts/measure_turn_end_gate.ts +129 -51
  89. package/src/scripts/report_carrier_divergence.ts +143 -30
  90. package/src/scripts/report_conformance_funnel.ts +18 -4
  91. package/src/scripts/routing_doctor.ts +25 -2
  92. package/src/scripts/schemas/dod.schema.json +45 -0
  93. package/src/server/schemas/settings.ts +2 -2
  94. package/dist/cli-delegate/chunk-RVGBH4XK.js +0 -142
@@ -22,8 +22,16 @@
22
22
  * provenance-only difference survives even a perfect version alignment. Keeping
23
23
  * it apart from class 3 is what turns a bare "0 identical twins" into an answer:
24
24
  * `provenanceOnly == shared` means aligning versions would buy nothing, and
25
- * `bodyDiff > 0` means the two carriers are delivering different text — the only
26
- * class where a reader has to act.
25
+ * `bodyDiff > 0` means the two carriers are delivering different bytes.
26
+ *
27
+ * `bodyDiff > 0` is NOT the same as "a reader has to act", and an earlier version
28
+ * of this paragraph said it was. Measured 2026-08-10: all 109 shared rules
29
+ * classified `bodyDiff` and NONE of them differed in prose. The reader-facing
30
+ * split lives in `report_carrier_divergence`, which subdivides this class into
31
+ * prose divergence (act), a `paths:`-scope disagreement (act — it changes WHEN a
32
+ * rule loads), and a metadata-only difference (do not act). The three-way split
33
+ * below stays three-way on purpose: it is the DEDUP predicate, and every class
34
+ * here is a real byte difference.
27
35
  *
28
36
  * NOT A LICENCE TO DEDUP. Classifying a pair `provenance-only` says the bodies
29
37
  * match; it does NOT say the rule is safe to drop from one carrier. Byte-identity
@@ -81,6 +89,116 @@ export function comparePair(a: Buffer, b: Buffer): PairVerdict {
81
89
  return 'body-diff';
82
90
  }
83
91
 
92
+ /** One rule file, split at its frontmatter fence. */
93
+ export interface FrontmatterSplit {
94
+ /** `true` only when a terminated `---` fence was actually found. */
95
+ hadFrontmatter: boolean;
96
+ /** The fence block WITHOUT its delimiters, or `''` when there is none. */
97
+ frontmatter: string;
98
+ /** Everything after the fence — the prose the host delivers. */
99
+ body: string;
100
+ }
101
+
102
+ /**
103
+ * THE fence parser. One definition, for the reason the header gives: a second
104
+ * copy is how two surfaces drift into disagreeing about what frontmatter is.
105
+ *
106
+ * An absent or UNTERMINATED fence yields `hadFrontmatter: false` and the whole
107
+ * text as body. That is deliberate and it is the safe direction: a malformed
108
+ * fence must not silently swallow a rule's governed text. Note this is the
109
+ * OPPOSITE of what a naive `type:` reader does — treating the whole file as
110
+ * frontmatter — and that asymmetry was a live inconsistency between this
111
+ * function and `report_carrier_divergence._ruleType` until the latter was moved
112
+ * onto this parser.
113
+ */
114
+ export function splitFrontmatter(text: string): FrontmatterSplit {
115
+ if (!text.startsWith('---')) return { hadFrontmatter: false, frontmatter: '', body: text };
116
+ const end = text.indexOf('\n---', 3);
117
+ if (end === -1) return { hadFrontmatter: false, frontmatter: '', body: text };
118
+ const afterFence = text.indexOf('\n', end + 1);
119
+ return {
120
+ hadFrontmatter: true,
121
+ frontmatter: text.slice(3, end),
122
+ body: afterFence === -1 ? '' : text.slice(afterFence + 1),
123
+ };
124
+ }
125
+
126
+ /** The prose a rule file delivers, with any frontmatter block removed. */
127
+ export function stripFrontmatter(text: string): string {
128
+ return splitFrontmatter(text).body;
129
+ }
130
+
131
+ /**
132
+ * Is the ONLY difference between these two copies inside the frontmatter block?
133
+ *
134
+ * WHY THIS IS SEPARATE FROM `comparePair`, AND MUST STAY SEPARATE
135
+ * --------------------------------------------------------------
136
+ * `comparePair` answers *can a byte-identity dedup skip this pair* — it is the
137
+ * dedup predicate, and a frontmatter difference is a real byte difference, so
138
+ * folding this in would relax the predicate that
139
+ * `agents/settings/contexts/dedup-reachability-refusal.md` deliberately keeps
140
+ * strict. `measure_scope_dedup` asks exactly that question and its arithmetic
141
+ * stays untouched.
142
+ *
143
+ * WHAT IT PROVES, EXACTLY — AND WHY NOT `trim()`
144
+ * ---------------------------------------------
145
+ * Two conditions, both required:
146
+ *
147
+ * 1. at least one side actually HAD a frontmatter block. Without this the
148
+ * predicate would answer "the prose matches" for a pair with no frontmatter
149
+ * anywhere, and the caller would print "differs only in frontmatter" about
150
+ * a difference that is not in any frontmatter.
151
+ * 2. the bodies match after removing LEADING newlines only. The fence
152
+ * necessarily leaves the stripped side one newline ahead, so that one
153
+ * character is a parsing artefact. A `trim()` would additionally absorb a
154
+ * TRAILING difference, which is a real byte difference no fence explains —
155
+ * and it was measured to be unnecessary: all 109 live pairs on 2026-08-10
156
+ * matched on leading-only, 0 needed a trailing trim.
157
+ *
158
+ * Nothing else is normalized. Internal whitespace and line endings stay
159
+ * significant, so a CRLF-vs-LF body is still a prose difference and this stays
160
+ * no softer than the dedup predicate on the text the host reads.
161
+ */
162
+ export function proseEqual(a: string, b: string): boolean {
163
+ const sa = splitFrontmatter(a);
164
+ const sb = splitFrontmatter(b);
165
+ if (!sa.hadFrontmatter && !sb.hadFrontmatter) return false;
166
+ const leading = /^\n+/;
167
+ return sa.body.replace(leading, '') === sb.body.replace(leading, '');
168
+ }
169
+
170
+ /**
171
+ * The `paths:` block of a rule's frontmatter, normalized, or `null` when absent.
172
+ *
173
+ * `paths` is the ONE frontmatter key this host reads
174
+ * (`agents/evidence/analysis/claude-code-rules-dir-contract.md`), and it decides
175
+ * WHEN a rule loads: with it, the rule fires when a matching file is read and is
176
+ * not re-injected after `/compact`; without it, the rule loads unconditionally
177
+ * at launch. So two carriers that disagree here deliver the same text on
178
+ * different schedules, which a reader must act on even though the prose is
179
+ * identical. Measured 2026-08-10: 24 of the 109 shared rules carry `paths:` in
180
+ * the project copy and NONE in the global copy, so on a machine with both the
181
+ * always-on global copy defeats the project copy's scoping for all 24.
182
+ *
183
+ * Normalization is deliberately shallow — the key line plus its indented
184
+ * continuation, whitespace-collapsed. It answers "do these two agree" and is not
185
+ * a YAML parser; a reordered list reads as a difference, which is the
186
+ * conservative direction for a report that asks a human to look.
187
+ */
188
+ export function frontmatterPaths(text: string): string | null {
189
+ const { hadFrontmatter, frontmatter } = splitFrontmatter(text);
190
+ if (!hadFrontmatter) return null;
191
+ const lines = frontmatter.split('\n');
192
+ const start = lines.findIndex((l) => /^paths:/.test(l));
193
+ if (start === -1) return null;
194
+ const block = [lines[start] as string];
195
+ for (const line of lines.slice(start + 1)) {
196
+ if (/^\s/.test(line) && line.trim() !== '') block.push(line);
197
+ else break;
198
+ }
199
+ return block.map((l) => l.trim()).join(' ');
200
+ }
201
+
84
202
  export interface RuleDirCensus {
85
203
  files: number;
86
204
  /**
@@ -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
+ }
@@ -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;