@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.
- package/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +60 -292
- package/dist/agent-src/contexts/execution/host-capability-manifest.md +32 -1
- package/dist/agent-src/rules/delegation-policy.md +11 -1
- package/dist/agent-src/templates/agents/agent-project-settings.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-delegate/chunk-2WELHO3P.js +582 -0
- package/dist/cli-delegate/{chunk-IYTGIG2Y.js → chunk-542DZR4P.js} +118 -148
- package/dist/cli-delegate/{chunk-66OVWGB5.js → chunk-AG47LAGQ.js} +1 -1
- package/dist/cli-delegate/{chunk-MP3JT3IU.js → chunk-DS6BLDMG.js} +1 -1
- package/dist/cli-delegate/{chunk-O3CO5BFB.js → chunk-EJITJBJX.js} +12 -1
- package/dist/cli-delegate/{chunk-EQY4FLJB.js → chunk-ITIQSGOQ.js} +3 -3
- package/dist/cli-delegate/{chunk-LCFJIQ4J.js → chunk-JEJABGKB.js} +110 -6
- package/dist/cli-delegate/chunk-PZWRPRWP.js +38 -0
- package/dist/cli-delegate/{chunk-BTJNRUX3.js → chunk-QEEB4LHO.js} +1 -1
- package/dist/cli-delegate/{chunk-DDUNNFYX.js → chunk-UVKNIJH2.js} +2 -2
- package/dist/cli-delegate/cmd_analyze_session.js +1 -1
- package/dist/cli-delegate/cmd_brand_status.js +1 -1
- package/dist/cli-delegate/cmd_conformance.js +154 -41
- package/dist/cli-delegate/cmd_doctor.js +4 -3
- package/dist/cli-delegate/cmd_explain.js +2 -2
- package/dist/cli-delegate/cmd_export.js +4 -4
- package/dist/cli-delegate/cmd_handoff.js +168 -59
- package/dist/cli-delegate/cmd_mcp_available.js +1 -1
- package/dist/cli-delegate/cmd_migrate.js +2 -2
- package/dist/cli-delegate/cmd_packs_active.js +2 -2
- package/dist/cli-delegate/cmd_prune.js +4 -4
- package/dist/cli-delegate/cmd_route_audit.js +2 -2
- package/dist/cli-delegate/cmd_route_explain.js +1 -1
- package/dist/cli-delegate/cmd_session_recycle.js +19 -4
- package/dist/cli-delegate/cmd_settings_get.js +2 -55
- package/dist/cli-delegate/cmd_settings_migrate.js +4 -4
- package/dist/cli-delegate/cmd_settings_set.js +1 -1
- package/dist/cli-delegate/cmd_sync.js +4 -4
- package/dist/cli-delegate/cmd_uninstall.js +4 -4
- package/dist/cli-delegate/cmd_update.js +1 -1
- package/dist/cli-delegate/cmd_upgrade.js +1 -1
- package/dist/cli-delegate/cmd_validate.js +4 -4
- package/dist/cli-delegate/cmd_versions.js +1 -1
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +3 -3
- 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 +1411 -810
- package/dist/install/install.mjs +121 -16
- package/dist/mcp/registry-manifest.json +1 -1
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +365 -143
- package/dist/scripts/_lib/agent_settings.js +111 -2
- package/dist/scripts/_lib/agent_settings.js.map +1 -1
- package/dist/server/schemas/settings.js +2 -2
- package/dist/server/schemas/settings.js.map +1 -1
- package/package.json +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/pack-size-budget.json +3 -2
- package/src/scripts/_cli/cmd_session_recycle.ts +25 -0
- package/src/scripts/_cli/explain_last/inputs.ts +13 -0
- package/src/scripts/_cli/handoff_generate.ts +36 -1
- package/src/scripts/_cli/handoff_sessions.ts +140 -9
- package/src/scripts/_lib/agent_settings.ts +119 -2
- package/src/scripts/_lib/carrier_divergence.ts +120 -2
- package/src/scripts/_lib/envelope_grounding.ts +206 -0
- package/src/scripts/_lib/host_capability.ts +79 -0
- package/src/scripts/_lib/session_eol.ts +85 -0
- package/src/scripts/_lib/subagent_capsule.ts +256 -1
- package/src/scripts/check_secret_leak.test.ts +43 -0
- package/src/scripts/check_secret_leak.ts +12 -0
- package/src/scripts/handoff_context_hook.ts +66 -18
- package/src/scripts/hooks/session_eol_hook.ts +12 -12
- package/src/scripts/hooks/turn_end_gate_hook.ts +296 -88
- package/src/scripts/language_mirror_hook.ts +69 -16
- package/src/scripts/measure_turn_end_gate.ts +129 -51
- package/src/scripts/report_carrier_divergence.ts +143 -30
- package/src/scripts/report_conformance_funnel.ts +18 -4
- package/src/scripts/routing_doctor.ts +25 -2
- package/src/scripts/schemas/dod.schema.json +45 -0
- package/src/server/schemas/settings.ts +2 -2
- package/dist/cli-delegate/chunk-RVGBH4XK.js +0 -142
|
@@ -220,6 +220,7 @@ export class WorkState {
|
|
|
220
220
|
ui_polish: Dict | null;
|
|
221
221
|
contract: Dict | null;
|
|
222
222
|
stitch: Dict | null;
|
|
223
|
+
self_fix: Dict | null;
|
|
223
224
|
halts: Dict[];
|
|
224
225
|
version: number;
|
|
225
226
|
persona: string;
|
|
@@ -245,6 +246,7 @@ export class WorkState {
|
|
|
245
246
|
ui_polish?: Dict | null;
|
|
246
247
|
contract?: Dict | null;
|
|
247
248
|
stitch?: Dict | null;
|
|
249
|
+
self_fix?: Dict | null;
|
|
248
250
|
halts?: Dict[];
|
|
249
251
|
version?: number;
|
|
250
252
|
persona?: string;
|
|
@@ -269,6 +271,7 @@ export class WorkState {
|
|
|
269
271
|
this.ui_polish = opts.ui_polish ?? null;
|
|
270
272
|
this.contract = opts.contract ?? null;
|
|
271
273
|
this.stitch = opts.stitch ?? null;
|
|
274
|
+
this.self_fix = opts.self_fix ?? null;
|
|
272
275
|
this.halts = opts.halts ?? [];
|
|
273
276
|
this.version = opts.version ?? SCHEMA_VERSION;
|
|
274
277
|
this.persona = opts.persona ?? 'senior-engineer';
|
|
@@ -309,6 +312,7 @@ export function to_dict(state: WorkState): Dict {
|
|
|
309
312
|
_validate_ui_polish(state.ui_polish);
|
|
310
313
|
_validate_contract(state.contract);
|
|
311
314
|
_validate_stitch(state.stitch);
|
|
315
|
+
_validate_self_fix(state.self_fix);
|
|
312
316
|
_validate_halts(state.halts);
|
|
313
317
|
return {
|
|
314
318
|
version: state.version,
|
|
@@ -324,6 +328,7 @@ export function to_dict(state: WorkState): Dict {
|
|
|
324
328
|
ui_polish: state.ui_polish,
|
|
325
329
|
contract: state.contract,
|
|
326
330
|
stitch: state.stitch,
|
|
331
|
+
self_fix: state.self_fix,
|
|
327
332
|
halts: [...state.halts],
|
|
328
333
|
persona: state.persona,
|
|
329
334
|
memory: state.memory,
|
|
@@ -406,6 +411,9 @@ export function from_dict(payload: JsonValue): WorkState {
|
|
|
406
411
|
const stitch = payload['stitch'] ?? null;
|
|
407
412
|
_validate_stitch(stitch);
|
|
408
413
|
|
|
414
|
+
const self_fix = payload['self_fix'] ?? null;
|
|
415
|
+
_validate_self_fix(self_fix);
|
|
416
|
+
|
|
409
417
|
const halts = 'halts' in payload ? payload['halts'] : [];
|
|
410
418
|
_validate_halts(halts);
|
|
411
419
|
|
|
@@ -422,6 +430,7 @@ export function from_dict(payload: JsonValue): WorkState {
|
|
|
422
430
|
ui_polish: _isDict(ui_polish) ? { ...ui_polish } : null,
|
|
423
431
|
contract: _isDict(contract) ? { ...contract } : null,
|
|
424
432
|
stitch: _isDict(stitch) ? { ...stitch } : null,
|
|
433
|
+
self_fix: _isDict(self_fix) ? { ...self_fix } : null,
|
|
425
434
|
halts: Array.isArray(halts) ? ([...halts] as Dict[]) : [],
|
|
426
435
|
version: version as number,
|
|
427
436
|
persona: _get(payload, 'persona', 'senior-engineer') as string,
|
|
@@ -866,6 +875,71 @@ function _validate_ui_review(ui_review: JsonValue): void {
|
|
|
866
875
|
}
|
|
867
876
|
}
|
|
868
877
|
|
|
878
|
+
/**
|
|
879
|
+
* Reject malformed `self_fix` envelopes; tolerate `null` and `{}`.
|
|
880
|
+
*
|
|
881
|
+
* `null` means no red check has been seen. Once populated, each key is a lane
|
|
882
|
+
* name (`test`, `verify`) whose record may carry `attempts` (a non-negative
|
|
883
|
+
* int) and `signatures` (a list of strings). As with `ui_polish`, the schema
|
|
884
|
+
* enforces only shape: the ceiling and the no-progress floor are the handler's
|
|
885
|
+
* business (`directives/backend/_self_fix.ts`), so a state file cannot decide
|
|
886
|
+
* how many attempts are allowed.
|
|
887
|
+
*
|
|
888
|
+
* `attempts` is validated but NOT bounded here on purpose. A file carrying
|
|
889
|
+
* `attempts: 99` is well-formed and simply exhausted — rejecting it would turn
|
|
890
|
+
* a spent budget into a crash, and the loop's whole point is a clean exit.
|
|
891
|
+
*/
|
|
892
|
+
function _validate_self_fix(self_fix: JsonValue): void {
|
|
893
|
+
if (self_fix === null) {
|
|
894
|
+
return;
|
|
895
|
+
}
|
|
896
|
+
if (!_isDict(self_fix)) {
|
|
897
|
+
throw new SchemaError(
|
|
898
|
+
`state.self_fix must be a JSON object or null; ` +
|
|
899
|
+
`got ${pyTypeName(self_fix)}`,
|
|
900
|
+
);
|
|
901
|
+
}
|
|
902
|
+
for (const [lane, record] of Object.entries(self_fix)) {
|
|
903
|
+
if (!_isDict(record)) {
|
|
904
|
+
throw new SchemaError(
|
|
905
|
+
`state.self_fix.${lane} must be a JSON object; ` +
|
|
906
|
+
`got ${pyTypeName(record)}`,
|
|
907
|
+
);
|
|
908
|
+
}
|
|
909
|
+
if ('attempts' in record) {
|
|
910
|
+
const attempts = record['attempts'];
|
|
911
|
+
if (
|
|
912
|
+
typeof attempts !== 'number' ||
|
|
913
|
+
typeof attempts === 'boolean' ||
|
|
914
|
+
!Number.isInteger(attempts) ||
|
|
915
|
+
attempts < 0
|
|
916
|
+
) {
|
|
917
|
+
throw new SchemaError(
|
|
918
|
+
`state.self_fix.${lane}.attempts must be a non-negative ` +
|
|
919
|
+
`integer when present; got ${pyRepr(attempts)}`,
|
|
920
|
+
);
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
if ('signatures' in record) {
|
|
924
|
+
const signatures = record['signatures'];
|
|
925
|
+
if (!Array.isArray(signatures)) {
|
|
926
|
+
throw new SchemaError(
|
|
927
|
+
`state.self_fix.${lane}.signatures must be a list when ` +
|
|
928
|
+
`present; got ${pyTypeName(signatures)}`,
|
|
929
|
+
);
|
|
930
|
+
}
|
|
931
|
+
for (const entry of signatures) {
|
|
932
|
+
if (typeof entry !== 'string') {
|
|
933
|
+
throw new SchemaError(
|
|
934
|
+
`state.self_fix.${lane}.signatures entries must be ` +
|
|
935
|
+
`strings; got ${pyTypeName(entry)}`,
|
|
936
|
+
);
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
|
|
869
943
|
/**
|
|
870
944
|
* Reject malformed `ui_polish` envelopes; tolerate `null` and `{}`.
|
|
871
945
|
*
|
|
@@ -150,6 +150,7 @@ export function _to_delivery(work: WorkState): DeliveryState {
|
|
|
150
150
|
contract: work.contract as Record<string, DeliveryAny> | null,
|
|
151
151
|
stitch: work.stitch as Record<string, DeliveryAny> | null,
|
|
152
152
|
stack: work.stack as Record<string, DeliveryAny> | null,
|
|
153
|
+
self_fix: work.self_fix as Record<string, DeliveryAny> | null,
|
|
153
154
|
});
|
|
154
155
|
}
|
|
155
156
|
|
|
@@ -182,6 +183,11 @@ export function _sync_back(work: WorkState, delivery: DeliveryState): void {
|
|
|
182
183
|
work.contract = delivery.contract as Dict | null;
|
|
183
184
|
work.stitch = delivery.stitch as Dict | null;
|
|
184
185
|
work.stack = delivery.stack as Dict | null;
|
|
186
|
+
// Reassigned (not mutated) by `record_attempt`, so the defensive mirror
|
|
187
|
+
// above is load-bearing here rather than a no-op: without this line the
|
|
188
|
+
// attempt counter would reset on every dispatch and the loop would never
|
|
189
|
+
// reach either floor.
|
|
190
|
+
work.self_fix = delivery.self_fix as Dict | null;
|
|
185
191
|
}
|
|
186
192
|
|
|
187
193
|
/**
|
|
@@ -7,9 +7,10 @@
|
|
|
7
7
|
"measurement_conditions": "npm pack --dry-run --json --ignore-scripts, run at the repo root on a clean checkout. `--ignore-scripts` means NO build runs, so dist/cli, dist/ui, dist/mcp and dist/hooks are absent and the figure covers only the TRACKED payload (src/scripts/, dist/agent-src/, dist/install/, docs/guidelines/, templates). That is deliberate: this gate is always-on for every PR, and the tracked content tree is exactly the surface that grows by accretion. The built artifact is ~5.4 MB larger and is gated separately, at release time, by evaluator-budgets.unpacked_size_mb.",
|
|
8
8
|
"budgets": {
|
|
9
9
|
"packed_size_mb": {
|
|
10
|
-
"max":
|
|
11
|
-
"last_measured":
|
|
10
|
+
"max": 7.8,
|
|
11
|
+
"last_measured": 7.238,
|
|
12
12
|
"method": "npm pack --dry-run --json --ignore-scripts → dist.size / 1e6 (the compressed tarball a consumer actually downloads)",
|
|
13
|
+
"baseline_note_2026_08_10": "6.9 → 7.8 re-baselined 2026-08-10. WHICH OF THE TWO THIS IS, stated because the guard asks: a RATCHET RESET, not the config bent around a failing check — and the discriminator is that the payload is real and needed rather than that the check was inconvenient. The 44 KB is two source files a shipped instruction depends on, the cheaper alternative was examined and rejected on that ground (below), the new cap carries ~8% headroom over the higher of two disagreeing measurements rather than clearing by a hair, and the raise was put to the maintainer as a decision instead of applied to make a red go away. A reader who disagrees has everything needed to reverse it: the measurement, its method, the trip cause, and the rejected option. Maintainer-decided in the PR that tripped it, surfaced rather than smuggled — the same standing this file's `owner: maintainer` requires. MEASURED TWICE, and the two do not agree: CI reported 6.907 on this exact commit, a local run under the documented conditions reports 7.238. The 331 KB gap is UNEXPLAINED — the tree matches the pushed commit and only this file differs — so the cap is set over the HIGHER figure rather than the convenient one, and the discrepancy is recorded here as an open question instead of resolved by assumption. A third party reproducing this should expect either number and should not treat the lower one as authoritative. Separately, and this part IS understood: `npm pack --dry-run --ignore-scripts` in a worktree that has been BUILT reads 8.45, because the flag assumes dist/cli, dist/ui, dist/mcp and dist/hooks are absent and a built tree has them. That figure is not comparable to this budget and must never be written here — recorded because it is a trap the documented method invites. Trip cause: road-to-conformance-round5 Phase 3 added src/scripts/hooks/turn_end_gate_hook.ts (32 KB) and src/scripts/measure_turn_end_gate.ts (12 KB) in packed paths plus schema/template/contract growth, landing 7 KB over a 6.9 cap. Max set to 7.8 (~8% headroom over 7.238, and ~13% over CI's 6.907) for the reason the 2026-08-05 note already gives: a cap pinned just above the measurement reds on the next commit and teaches the reader to raise it again. ALTERNATIVE EXAMINED AND REJECTED, so it is not re-proposed: dropping measure_turn_end_gate.ts from the pack would have cleared the cap with room, but the settings description added in the same change instructs CONSUMERS to run that script against their own transcript store before arming the gate — excluding it would break a shipped instruction.",
|
|
13
14
|
"baseline_note": "5.903 → 6.398 re-baselined 2026-08-05. This is a RE-BASELINE, not a carve-out for the change that tripped it: measured on the tracked tree, `origin/main` alone was already at 6.372 of a 6.4 cap, so 28 KB of headroom remained after months of accretion and the next legitimate addition of any size was going to cross it. The addition here is 26 KB — docs/contracts/settings-classes.md (the A/B/C fence, read at RUNTIME by the settings writer, so it must ship) plus src/server/io/yamlIO.ts (imported by that writer to preserve the user's comments). CI measures ~4 KB above the local figure because it regenerates dist/agent-src first. Max set to 6.9 rather than to a number that merely clears: a cap pinned 2 KB above the measurement reds on the next commit and teaches the reader to raise it again, which is how a budget becomes a formality. That restores the ~8% headroom the 5.903/6.4 pair originally carried. The `owner: maintainer` on this file stands — this raise is surfaced in the PR for that decision, not smuggled."
|
|
14
15
|
}
|
|
15
16
|
},
|
|
@@ -38,6 +38,7 @@ import {
|
|
|
38
38
|
CAPSULE_SCHEMA_VERSION,
|
|
39
39
|
validateRecycleEnvelope,
|
|
40
40
|
} from '../_lib/subagent_capsule.js';
|
|
41
|
+
import { collectGrounding } from '../_lib/envelope_grounding.js';
|
|
41
42
|
import { atomic_write_json } from '../hooks/state_io.js';
|
|
42
43
|
|
|
43
44
|
export interface RecycleResult {
|
|
@@ -61,6 +62,9 @@ export function templateEnvelope(): Record<string, unknown> {
|
|
|
61
62
|
open_worker_envelopes: [],
|
|
62
63
|
artifact_paths: ['<path to a deliverable / note / evidence file>'],
|
|
63
64
|
assumptions: [],
|
|
65
|
+
next_task: '<the ONE task this envelope is written for — select content for it>',
|
|
66
|
+
suggested_skills: ['<skill the successor should invoke>'],
|
|
67
|
+
failed_approaches: ['<tried X, failed because Y — or the single entry "none">'],
|
|
64
68
|
};
|
|
65
69
|
}
|
|
66
70
|
|
|
@@ -123,6 +127,27 @@ export function runSessionRecycle(
|
|
|
123
127
|
}
|
|
124
128
|
if (envelope['workspace'] === undefined) envelope['workspace'] = projectRoot;
|
|
125
129
|
|
|
130
|
+
// Scripted grounding (Phase 3.3): the factual fields are READ, never
|
|
131
|
+
// composed. A model-written branch is a claim; this is a reading. The
|
|
132
|
+
// composer cannot override them — a "next_task" it got wrong is a
|
|
133
|
+
// proposal, a "head" it got wrong is a silent stale resume.
|
|
134
|
+
const grounding = collectGrounding(projectRoot);
|
|
135
|
+
// Drop the composer's factual keys UNCONDITIONALLY first. Guarding each
|
|
136
|
+
// assignment on `!== null` would leave a model-composed branch or head
|
|
137
|
+
// standing whenever the git read fails — the consumer would then compare
|
|
138
|
+
// a fabricated anchor against the real tree and produce either false
|
|
139
|
+
// drift or, worse, false silence. An unreadable fact is absent, never
|
|
140
|
+
// inherited from the composer.
|
|
141
|
+
for (const key of ['repo_identity', 'branch', 'head', 'status_summary', 'last_verify', 'uncommitted_paths']) {
|
|
142
|
+
delete envelope[key];
|
|
143
|
+
}
|
|
144
|
+
if (grounding.repo_identity !== null) envelope['repo_identity'] = grounding.repo_identity;
|
|
145
|
+
if (grounding.branch !== null) envelope['branch'] = grounding.branch;
|
|
146
|
+
if (grounding.head !== null) envelope['head'] = grounding.head;
|
|
147
|
+
if (grounding.status_summary !== null) envelope['status_summary'] = grounding.status_summary;
|
|
148
|
+
if (grounding.last_verify !== null) envelope['last_verify'] = grounding.last_verify;
|
|
149
|
+
envelope['uncommitted_paths'] = grounding.uncommitted_paths;
|
|
150
|
+
|
|
126
151
|
const violations = validateRecycleEnvelope(envelope);
|
|
127
152
|
if (violations.length > 0) {
|
|
128
153
|
return {
|
|
@@ -132,6 +132,19 @@ export function build(project_root: string): Record<string, unknown> | null {
|
|
|
132
132
|
rule_loading_tier = null;
|
|
133
133
|
}
|
|
134
134
|
let rule_loading_tier_source = _pyTruthy(rule_loading_tier) ? 'user' : 'default';
|
|
135
|
+
// The one read-site divergence from the template that is NOT a
|
|
136
|
+
// `settingsCarveOut` row, so it is explained here instead
|
|
137
|
+
// (`road-to-scripts-settings-defaults` Phase 1, § 2 of the Evidence Report).
|
|
138
|
+
// The template ships `rule_loading_tier: __RULE_LOADING_TIER__` — an
|
|
139
|
+
// installer placeholder, not a value — so the template-defaults layer in
|
|
140
|
+
// `load_agent_settings` deliberately does not inject it and an absent key
|
|
141
|
+
// arrives here as `undefined`. `balanced` is therefore this reader's own
|
|
142
|
+
// default, matching what the installer substitutes.
|
|
143
|
+
// The second arm is the deliberate part: a settings file that still
|
|
144
|
+
// literally contains the placeholder is a BROKEN install, and this reports
|
|
145
|
+
// it as `source: default` — i.e. the trace looks healthy. Surfacing it
|
|
146
|
+
// instead would be a reporting change beyond this roadmap's scope; it is
|
|
147
|
+
// recorded rather than silently kept.
|
|
135
148
|
if (!_pyTruthy(rule_loading_tier) || rule_loading_tier === '__RULE_LOADING_TIER__') {
|
|
136
149
|
rule_loading_tier = _DEFAULT_RULE_LOADING_TIER;
|
|
137
150
|
rule_loading_tier_source = 'default';
|
|
@@ -35,6 +35,19 @@ export const WORD_CAP = 1200;
|
|
|
35
35
|
const SNIPPET_CHARS = 200;
|
|
36
36
|
const DONE_SNIPPET_CHARS = 400;
|
|
37
37
|
const MAX_ERRORS = 5;
|
|
38
|
+
/**
|
|
39
|
+
* Per-error cap. Above `SNIPPET_CHARS` because an error string is kept
|
|
40
|
+
* verbatim so it stays greppable — but deliberately not far above.
|
|
41
|
+
*
|
|
42
|
+
* The section competes for a fixed word cap whose trim pops the NEWEST entry
|
|
43
|
+
* first, and the newest failure is the one a successor needs. Enlarging each
|
|
44
|
+
* entry therefore makes that pre-existing trim order bite more often. 400 is
|
|
45
|
+
* twice the old truncation — enough to carry a real error line whole — while
|
|
46
|
+
* keeping the five-entry section within roughly a sixth of the cap. The
|
|
47
|
+
* pop()-newest ordering is pre-existing and is not this change's to alter;
|
|
48
|
+
* this bound is what keeps the change from amplifying it.
|
|
49
|
+
*/
|
|
50
|
+
const MAX_ERROR_CHARS = 400;
|
|
38
51
|
const MAX_DECISIONS = 5;
|
|
39
52
|
const MAX_FILES = 15;
|
|
40
53
|
|
|
@@ -325,11 +338,23 @@ export function build_handoff(
|
|
|
325
338
|
);
|
|
326
339
|
|
|
327
340
|
// Errors + fixes — failure-pattern turns, oldest→newest, last 5.
|
|
341
|
+
//
|
|
342
|
+
// Precision rule (Phase 3.1): the error string is carried VERBATIM. It
|
|
343
|
+
// used to be `_snippet`-ed like every other section, which truncates
|
|
344
|
+
// mid-string — and a truncated error cannot be grepped, matched against a
|
|
345
|
+
// tracker, or recognised when it recurs, which is the only reason to
|
|
346
|
+
// carry it at all. So the FAILURE-matching line is taken whole (capped
|
|
347
|
+
// only against an absurd single line) instead of the turn being cut at an
|
|
348
|
+
// arbitrary offset.
|
|
328
349
|
const errors = _redact_items(
|
|
329
350
|
toolish
|
|
330
351
|
.filter((t) => FAILURE_RE.test(t.text))
|
|
331
352
|
.slice(-MAX_ERRORS)
|
|
332
|
-
.map((t) =>
|
|
353
|
+
.map((t) => {
|
|
354
|
+
const line = t.text.split('\n').find((l) => FAILURE_RE.test(l))?.trim() ?? t.text.trim();
|
|
355
|
+
const prefix = t.tool ? `${t.tool}: ` : '';
|
|
356
|
+
return `${prefix}${line}`.slice(0, MAX_ERROR_CHARS);
|
|
357
|
+
}),
|
|
333
358
|
);
|
|
334
359
|
|
|
335
360
|
// Key decisions — decision-pattern assistant turns, last 5.
|
|
@@ -396,6 +421,16 @@ export function build_handoff(
|
|
|
396
421
|
}
|
|
397
422
|
for (const s of sections) {
|
|
398
423
|
lines.push('', `## ${s.title}`, '');
|
|
424
|
+
if (s.title === 'Resume pointer') {
|
|
425
|
+
// Precision rules (Phase 3.1), rendered where the successor
|
|
426
|
+
// reads them rather than filed in a doc nobody opens.
|
|
427
|
+
lines.push(
|
|
428
|
+
'> Precision contract: identify code by signature or `path:line`, never by',
|
|
429
|
+
'> description; quote error strings verbatim; give every resume step its',
|
|
430
|
+
'> expected outcome, so "done" is checkable rather than felt.',
|
|
431
|
+
'',
|
|
432
|
+
);
|
|
433
|
+
}
|
|
399
434
|
if (s.items.length === 0) {
|
|
400
435
|
if (s.verbatim && verbatim.withheldLines > 0) {
|
|
401
436
|
lines.push(`- [${verbatim.withheldLines} line(s) withheld by privacy floor]`);
|
|
@@ -19,6 +19,11 @@
|
|
|
19
19
|
* De-dup: native-store sessions whose platform id maps (via
|
|
20
20
|
* `derive_session_tag`) to a chat-history bucket are dropped — the
|
|
21
21
|
* chat-history entry wins.
|
|
22
|
+
*
|
|
23
|
+
* Two exclusions then apply to the merged list (road-to-cost-parity-3
|
|
24
|
+
* Phase 1): the ISSUING session, by exact id and never heuristically, and
|
|
25
|
+
* every candidate holding nothing worth resuming (`is_substantive`). Both
|
|
26
|
+
* fail OPEN — an unknown lists.
|
|
22
27
|
*/
|
|
23
28
|
|
|
24
29
|
import * as fs from 'node:fs';
|
|
@@ -26,10 +31,35 @@ import * as os from 'node:os';
|
|
|
26
31
|
import * as path from 'node:path';
|
|
27
32
|
|
|
28
33
|
import { derive_session_tag, list_sessions, read_entries } from '../chat_history.js';
|
|
34
|
+
import { eolSessionKey, readEolCounters, type StoredEolCounters } from '../_lib/session_eol.js';
|
|
29
35
|
|
|
30
36
|
export const DEFAULT_CAP = 15;
|
|
37
|
+
/**
|
|
38
|
+
* Per-source over-fetch factor. The exclusions run after the per-source cap,
|
|
39
|
+
* so fetching exactly `cap` lets a filtered candidate consume a slot no
|
|
40
|
+
* eligible session can reclaim. 3× is a backfill margin, not a measurement:
|
|
41
|
+
* on the observed store 10 of 217 sessions are filtered (~5%), so 3× covers
|
|
42
|
+
* that comfortably while keeping the per-file scan bounded.
|
|
43
|
+
*/
|
|
44
|
+
const FETCH_OVERSHOOT = 3;
|
|
31
45
|
const SUMMARY_SNIPPET_CHARS = 120;
|
|
32
46
|
|
|
47
|
+
/**
|
|
48
|
+
* Committed substantive-content floor, in parsed transcript tokens (billable
|
|
49
|
+
* input of the last main-chain assistant record, per `cc_transcript.ts`) — the
|
|
50
|
+
* OR-arm for a session that produced real discussion without ever calling a
|
|
51
|
+
* tool.
|
|
52
|
+
*
|
|
53
|
+
* Derivation (`agents/evidence/analysis/handoff-substantive-threshold.md`,
|
|
54
|
+
* 217 sessions of the local Claude store, 2026-08-10): 206 of the 207 sessions
|
|
55
|
+
* carrying an assistant record also carry a `tool_use` block, so this arm
|
|
56
|
+
* serves the ~0.5 % tail and the hosts whose transcripts log no tool blocks at
|
|
57
|
+
* all. 10 000 sits **25× below** the p10 of real working sessions (254 939),
|
|
58
|
+
* so it cannot hide one, and above a single trivial exchange. Changing it is a
|
|
59
|
+
* PR citing evidence, never a drive-by edit.
|
|
60
|
+
*/
|
|
61
|
+
export const SUBSTANTIVE_TOKEN_FLOOR = 10_000;
|
|
62
|
+
|
|
33
63
|
export type HandoffSource = 'chat-history' | 'claude-transcript' | 'codex-session';
|
|
34
64
|
|
|
35
65
|
export interface HandoffSession {
|
|
@@ -55,6 +85,21 @@ export interface ListOptions {
|
|
|
55
85
|
codexSessionsRoot?: string;
|
|
56
86
|
/** Max sessions per source AND in the merged result. Default 15. */
|
|
57
87
|
cap?: number;
|
|
88
|
+
/**
|
|
89
|
+
* The ISSUING session's id — excluded from the result unconditionally.
|
|
90
|
+
*
|
|
91
|
+
* Default: `AGENT_SESSION_ID` (this package's own envelope convention),
|
|
92
|
+
* else `CLAUDE_CODE_SESSION_ID`. The second name is **measured, not
|
|
93
|
+
* guessed**: a live Claude Code session (host 2026-08-10) exports
|
|
94
|
+
* `CLAUDE_CODE_SESSION_ID` into every Bash tool call and its value is the
|
|
95
|
+
* session uuid that also names the transcript file — a plausible-looking
|
|
96
|
+
* `CLAUDE_SESSION_ID` is not exported and would leave this filter inert.
|
|
97
|
+
*
|
|
98
|
+
* Pass `null` to disable (a caller that genuinely is not a session).
|
|
99
|
+
*/
|
|
100
|
+
selfSessionId?: string | null;
|
|
101
|
+
/** Workspace root holding `agents/runtime/state/session-eol/`. Default: `cwd`. */
|
|
102
|
+
workspaceRoot?: string;
|
|
58
103
|
}
|
|
59
104
|
|
|
60
105
|
/** Mirrors the Claude Code project-dir naming: `/` and `.` → `-`. */
|
|
@@ -408,28 +453,114 @@ function _sort_key(s: HandoffSession): string {
|
|
|
408
453
|
return s.endedAt ?? s.startedAt ?? '';
|
|
409
454
|
}
|
|
410
455
|
|
|
456
|
+
/**
|
|
457
|
+
* Does this session hold anything worth resuming?
|
|
458
|
+
*
|
|
459
|
+
* `≥ 1 assistant turn AND (≥ 1 tool call OR parsed tokens ≥ the committed
|
|
460
|
+
* floor)`, read from the counts-only session-eol state.
|
|
461
|
+
*
|
|
462
|
+
* **Fail-open, deliberately** (Phase 1.2): absent, unreadable or mis-shaped
|
|
463
|
+
* state LISTS the candidate rather than filtering it, and a `tool_calls` key
|
|
464
|
+
* missing from a state file written before that counter existed reads as
|
|
465
|
+
* *unknown*, never as zero. A wrongly listed candidate is noise the user
|
|
466
|
+
* scrolls past; a wrongly hidden one is data loss they cannot even see.
|
|
467
|
+
*/
|
|
468
|
+
export function is_substantive(counters: StoredEolCounters | null): boolean {
|
|
469
|
+
if (counters === null) return true;
|
|
470
|
+
// Every "unknown" below is a NUMBER test, not an `=== undefined` test.
|
|
471
|
+
// Absent, null and NaN are all unknown, and each one reaches this code by
|
|
472
|
+
// a real path: a state file written before the counter existed is absent,
|
|
473
|
+
// `JSON.stringify` turns NaN into null on the way to disk, and a
|
|
474
|
+
// half-written file yields a parseable object with missing keys. Reading
|
|
475
|
+
// any of them as "counted zero" hides a session that did real work.
|
|
476
|
+
if (!Number.isFinite(counters.assistant_records)) return true;
|
|
477
|
+
if ((counters.assistant_records as number) < 1) return false;
|
|
478
|
+
if (!Number.isFinite(counters.tool_calls)) return true;
|
|
479
|
+
if ((counters.tool_calls as number) >= 1) return true;
|
|
480
|
+
if (!Number.isFinite(counters.final_context_tokens)) return true;
|
|
481
|
+
return (counters.final_context_tokens as number) >= SUBSTANTIVE_TOKEN_FLOOR;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function _derive_tag(id: string): string | null {
|
|
485
|
+
try {
|
|
486
|
+
return derive_session_tag(id);
|
|
487
|
+
} catch {
|
|
488
|
+
return null;
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* The state file is keyed on the RAW session id (or transcript path) the hook
|
|
494
|
+
* saw. A chat-history bucket carries only the derived tag, so the native scan
|
|
495
|
+
* — which holds both — supplies the raw identity for those buckets.
|
|
496
|
+
*/
|
|
497
|
+
function _counters_for(
|
|
498
|
+
session: HandoffSession,
|
|
499
|
+
byTag: Map<string, HandoffSession>,
|
|
500
|
+
workspaceRoot: string,
|
|
501
|
+
): StoredEolCounters | null {
|
|
502
|
+
const native = session.source === 'chat-history' ? byTag.get(session.id) : session;
|
|
503
|
+
const candidates = [
|
|
504
|
+
...(session.source === 'chat-history' ? [] : [session.id]),
|
|
505
|
+
native?.id,
|
|
506
|
+
native?.transcriptPath,
|
|
507
|
+
];
|
|
508
|
+
for (const candidate of candidates) {
|
|
509
|
+
if (!candidate) continue;
|
|
510
|
+
const counters = readEolCounters(workspaceRoot, eolSessionKey(candidate));
|
|
511
|
+
if (counters !== null) return counters;
|
|
512
|
+
}
|
|
513
|
+
return null;
|
|
514
|
+
}
|
|
515
|
+
|
|
411
516
|
/**
|
|
412
517
|
* Unified newest-first session list. Native-store sessions whose platform
|
|
413
518
|
* id maps to an existing chat-history bucket are dropped (chat-history is
|
|
414
519
|
* the primary source).
|
|
520
|
+
*
|
|
521
|
+
* Two exclusions apply to the merged list: the ISSUING session, by exact id
|
|
522
|
+
* (never heuristically — a heuristic here hides a session the user wanted),
|
|
523
|
+
* and every candidate `is_substantive` rejects.
|
|
415
524
|
*/
|
|
416
525
|
export function list_handoff_sessions(opts: ListOptions = {}): HandoffSession[] {
|
|
417
526
|
const cap = opts.cap ?? DEFAULT_CAP;
|
|
527
|
+
const workspaceRoot = opts.workspaceRoot ?? opts.cwd ?? process.cwd();
|
|
528
|
+
const selfId =
|
|
529
|
+
opts.selfSessionId === null
|
|
530
|
+
? null
|
|
531
|
+
: (opts.selfSessionId ??
|
|
532
|
+
process.env.AGENT_SESSION_ID ??
|
|
533
|
+
process.env.CLAUDE_CODE_SESSION_ID ??
|
|
534
|
+
null);
|
|
535
|
+
const selfTag = selfId ? _derive_tag(selfId) : null;
|
|
536
|
+
|
|
418
537
|
const primary = _chat_history_sessions(opts);
|
|
419
538
|
const knownTags = new Set(primary.map((s) => s.id));
|
|
420
539
|
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
540
|
+
// Over-fetch per source, because the two filters below run AFTER it and
|
|
541
|
+
// the issuing session is by construction among the newest — with a
|
|
542
|
+
// per-source cap of exactly `cap`, the caller always burns a slot and
|
|
543
|
+
// every filtered empty session burns another, so a substantive session
|
|
544
|
+
// just outside the window is silently dropped instead of backfilled.
|
|
545
|
+
// That is the hidden-session direction Phase 1.2 calls data loss.
|
|
546
|
+
const fetchCap = cap * FETCH_OVERSHOOT;
|
|
547
|
+
const nativeAll = [
|
|
548
|
+
..._claude_transcript_sessions(opts, fetchCap),
|
|
549
|
+
..._codex_sessions(opts, fetchCap),
|
|
550
|
+
];
|
|
551
|
+
const byTag = new Map<string, HandoffSession>();
|
|
552
|
+
for (const s of nativeAll) {
|
|
553
|
+
const tag = _derive_tag(s.id);
|
|
554
|
+
if (tag) byTag.set(tag, s);
|
|
555
|
+
}
|
|
556
|
+
const native = nativeAll.filter((s) => {
|
|
557
|
+
const tag = _derive_tag(s.id);
|
|
558
|
+
return tag === null || !knownTags.has(tag);
|
|
430
559
|
});
|
|
431
560
|
|
|
432
561
|
return [...primary, ...native]
|
|
562
|
+
.filter((s) => s.id !== selfId && (selfTag === null || s.id !== selfTag))
|
|
563
|
+
.filter((s) => is_substantive(_counters_for(s, byTag, workspaceRoot)))
|
|
433
564
|
.sort((a, b) => (_sort_key(a) < _sort_key(b) ? 1 : _sort_key(a) > _sort_key(b) ? -1 : 0))
|
|
434
565
|
.slice(0, cap);
|
|
435
566
|
}
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
*
|
|
10
10
|
* Resolution order (deepest wins; user-global is whitelist-filtered only):
|
|
11
11
|
*
|
|
12
|
+
* N+1. `src/config/agent-settings.template.yml` (shipped defaults; base)
|
|
12
13
|
* N. `~/.event4u/agent-config/agent-settings.yml` (user-global; whitelist only)
|
|
13
14
|
* N-1. `<repo-root>/.agent-settings.yml` (project-wide; all keys)
|
|
14
15
|
* N-2. `<intermediate-dir>/.agent-settings.yml` (subsystem-scoped; all keys)
|
|
@@ -36,13 +37,22 @@
|
|
|
36
37
|
* - YAML parse failure / unreadable file → defaults, logged at WARNING.
|
|
37
38
|
* - Missing files → defaults.
|
|
38
39
|
* - No file is ever created or written by this module.
|
|
40
|
+
*
|
|
41
|
+
* The template-defaults base ({@link template_defaults}) gives this path the
|
|
42
|
+
* guarantee the server read path already had: a key absent from every layer
|
|
43
|
+
* resolves to its shipped default rather than `undefined`. Two sets are
|
|
44
|
+
* excluded from it — the `settingsCarveOut` keys, whose absence deliberately
|
|
45
|
+
* means something else, and placeholder-valued leaves the installer fills.
|
|
46
|
+
* An unreadable template degrades to the pre-existing empty-defaults behaviour.
|
|
39
47
|
*/
|
|
40
48
|
import { createRequire } from 'node:module';
|
|
41
49
|
import * as fs from 'node:fs';
|
|
42
50
|
import * as os from 'node:os';
|
|
43
51
|
import * as path from 'node:path';
|
|
52
|
+
import { fileURLToPath } from 'node:url';
|
|
44
53
|
|
|
45
54
|
import * as user_global_paths from './user_global_paths.js';
|
|
55
|
+
import { carveOutKeys } from '../../shared/settingsCarveOut.js';
|
|
46
56
|
import type * as YamlModule from 'yaml';
|
|
47
57
|
|
|
48
58
|
// ESM has no `require`; `createRequire(import.meta.url)` restores it so the
|
|
@@ -286,7 +296,106 @@ export const MERGEABLE_KEYS: readonly string[] = [
|
|
|
286
296
|
'knowledge.global_sharing.freshness.stale_after_days',
|
|
287
297
|
];
|
|
288
298
|
|
|
289
|
-
|
|
299
|
+
/** The shipped template, relative to the package root. */
|
|
300
|
+
const TEMPLATE_RELATIVE = path.join('src', 'config', 'agent-settings.template.yml');
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Package root: `src/scripts/_lib/agent_settings.ts` → three levels up.
|
|
304
|
+
* Same idiom as `cli_wrapper.ts:28`, and it holds in an installed consumer
|
|
305
|
+
* too because `src/config/` ships (`package.json` `files[]`).
|
|
306
|
+
*/
|
|
307
|
+
const _PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..');
|
|
308
|
+
|
|
309
|
+
/** Default template path — overridable per call for tests. */
|
|
310
|
+
export function default_template_path(): string {
|
|
311
|
+
return path.join(_PACKAGE_ROOT, TEMPLATE_RELATIVE);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* A leaf whose template value is still an installer placeholder
|
|
316
|
+
* (`__RULE_LOADING_TIER__`, `__CHAT_HISTORY_FREQUENCY__`, …). The installer
|
|
317
|
+
* substitutes these from the chosen profile preset; the template alone does
|
|
318
|
+
* not carry a usable default, so the defaults layer must not inject one.
|
|
319
|
+
*
|
|
320
|
+
* Matched by shape rather than against `TEMPLATE_PLACEHOLDER_DEFAULTS`
|
|
321
|
+
* (`src/server/io/yamlIO.ts:139`) on purpose: the scripts family does not
|
|
322
|
+
* import from the server family, and a duplicated map would be one more thing
|
|
323
|
+
* that can drift. A new placeholder is covered the day it is added.
|
|
324
|
+
*/
|
|
325
|
+
function _is_placeholder(value: SettingsValue): boolean {
|
|
326
|
+
return typeof value === 'string' && /^__[A-Z0-9_]+__$/.test(value);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Prune a parsed template into the tree the defaults layer may contribute:
|
|
331
|
+
* every placeholder-valued leaf and every carve-out key removed, and any
|
|
332
|
+
* branch that empties out as a result dropped with them.
|
|
333
|
+
*/
|
|
334
|
+
function _prune_template(tree: SettingsDict, excluded: ReadonlySet<string>, prefix = ''): SettingsDict {
|
|
335
|
+
const out: SettingsDict = {};
|
|
336
|
+
for (const [key, value] of Object.entries(tree)) {
|
|
337
|
+
const dotted = prefix === '' ? key : `${prefix}.${key}`;
|
|
338
|
+
if (excluded.has(dotted) || _is_placeholder(value)) {
|
|
339
|
+
continue;
|
|
340
|
+
}
|
|
341
|
+
if (_is_plain_dict(value)) {
|
|
342
|
+
const child = _prune_template(value as SettingsDict, excluded, dotted);
|
|
343
|
+
if (Object.keys(child).length > 0) {
|
|
344
|
+
out[key] = child;
|
|
345
|
+
}
|
|
346
|
+
continue;
|
|
347
|
+
}
|
|
348
|
+
out[key] = value;
|
|
349
|
+
}
|
|
350
|
+
return out;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** Parsed-and-pruned template, memoised for the default path only. */
|
|
354
|
+
let _template_defaults_cache: SettingsDict | null = null;
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* The template-defaults layer — the base every real settings layer sits on.
|
|
358
|
+
*
|
|
359
|
+
* Gives the scripts read path the guarantee the server read path already has:
|
|
360
|
+
* a key absent from every layer resolves to its shipped default instead of
|
|
361
|
+
* `undefined`. Two exclusions, both load-bearing:
|
|
362
|
+
*
|
|
363
|
+
* - **Carve-out keys** (`src/shared/settingsCarveOut.ts`). For these an absent
|
|
364
|
+
* key deliberately resolves to something other than the template default,
|
|
365
|
+
* and injecting the default here would flip existing installs — precisely
|
|
366
|
+
* the silent narrowing that module exists to prevent (`settingsCarveOut.ts:18-21`).
|
|
367
|
+
* - **Placeholder-valued leaves**, which carry no default until the installer
|
|
368
|
+
* substitutes them.
|
|
369
|
+
*
|
|
370
|
+
* Tolerant like the rest of this module: an unreadable or malformed template
|
|
371
|
+
* yields `{}`, i.e. exactly the pre-existing no-defaults behaviour.
|
|
372
|
+
*
|
|
373
|
+
* The result is a full `_deepcopy`, never `_deep_copy_defaults`. The latter is
|
|
374
|
+
* built on `_deep_merge`, which assigns a nested value by REFERENCE whenever
|
|
375
|
+
* the destination has no dict at that key — so its "copy" of a fresh `{}`
|
|
376
|
+
* shares every sub-tree with the source. The caller then merges the real
|
|
377
|
+
* layers into that shared sub-tree and silently rewrites the cache: the first
|
|
378
|
+
* settings file read in a process would become the defaults every later read
|
|
379
|
+
* sees. The predecessor `_DEFAULTS` was `{}`, so the hazard existed but had
|
|
380
|
+
* nothing to poison; a populated base makes it live.
|
|
381
|
+
*/
|
|
382
|
+
export function template_defaults(template_path?: string): SettingsDict {
|
|
383
|
+
const use_cache = template_path === undefined;
|
|
384
|
+
if (use_cache && _template_defaults_cache !== null) {
|
|
385
|
+
return _deepcopy(_template_defaults_cache);
|
|
386
|
+
}
|
|
387
|
+
const parsed = _read_yaml(template_path ?? default_template_path()) ?? {};
|
|
388
|
+
const pruned = _prune_template(parsed, new Set(carveOutKeys()));
|
|
389
|
+
if (use_cache) {
|
|
390
|
+
_template_defaults_cache = pruned;
|
|
391
|
+
}
|
|
392
|
+
return _deepcopy(pruned);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/** Drop the memoised template — tests only; production reads it once per process. */
|
|
396
|
+
export function _reset_template_defaults_cache(): void {
|
|
397
|
+
_template_defaults_cache = null;
|
|
398
|
+
}
|
|
290
399
|
|
|
291
400
|
/**
|
|
292
401
|
* Defaults applied by `get_modules_config` when a key is absent from both
|
|
@@ -730,12 +839,17 @@ export function load_agent_settings(
|
|
|
730
839
|
user_global_path?: string | null;
|
|
731
840
|
verbose?: boolean;
|
|
732
841
|
cwd?: string | null;
|
|
842
|
+
template_path?: string | null;
|
|
733
843
|
} = {},
|
|
734
844
|
): SettingsDict {
|
|
735
845
|
const project_path = options.project_path ?? null;
|
|
736
846
|
const user_global_path = options.user_global_path ?? null;
|
|
737
847
|
const verbose = options.verbose ?? false;
|
|
738
848
|
const cwd = options.cwd ?? null;
|
|
849
|
+
// Same caller-override contract as `project_path` / `user_global_path`:
|
|
850
|
+
// a test or tool that pins every other input can pin the defaults base
|
|
851
|
+
// too, and point it at a nonexistent file to isolate the cascade.
|
|
852
|
+
const template_path = options.template_path ?? null;
|
|
739
853
|
|
|
740
854
|
// An explicit path is a caller override (tests, tooling) and stays single;
|
|
741
855
|
// otherwise every user-global file merges in precedence order.
|
|
@@ -754,7 +868,10 @@ export function load_agent_settings(
|
|
|
754
868
|
|
|
755
869
|
const cascade = _resolve_cascade_paths(cwd, project_path);
|
|
756
870
|
|
|
757
|
-
|
|
871
|
+
// Template defaults sit BELOW every real layer — never above one. The
|
|
872
|
+
// precedence here is the inverse of the server family's, so the order of
|
|
873
|
+
// these three statements is the whole contract.
|
|
874
|
+
const merged: SettingsDict = template_defaults(template_path ?? undefined);
|
|
758
875
|
_deep_merge(merged, user_global_filtered);
|
|
759
876
|
for (const p of cascade) {
|
|
760
877
|
const layer = _read_yaml(p) ?? {};
|