@event4u/agent-config 9.29.0 → 9.31.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 +32 -214
- package/README.md +1 -0
- package/dist/agent-src/commands/analyze/inbox.md +22 -1
- package/dist/agent-src/commands/explain-run.md +31 -1
- package/dist/agent-src/commands/team/delegate.md +1 -0
- package/dist/agent-src/contexts/execution/orchestration-telemetry.md +4 -1
- package/dist/agent-src/contexts/execution/roadmap-process-loop.md +17 -0
- package/dist/agent-src/contexts/execution/subagent-response-contract.md +11 -1
- package/dist/agent-src/rules/preservation-guard.md +9 -0
- package/dist/agent-src/scripts/roadmap_gates.ts +312 -0
- package/dist/agent-src/scripts/update_roadmap_progress.ts +72 -4
- package/dist/agent-src/skills/subagent-orchestration/SKILL.md +10 -0
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/cli/registry.js +2 -0
- package/dist/cli/registry.js.map +1 -1
- package/dist/cli-delegate/{chunk-RCIA67XN.js → chunk-2WCLVDWC.js} +45 -2
- 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-EJITJBJX.js +172 -0
- package/dist/cli-delegate/{chunk-NZ5N2XQL.js → chunk-ITIQSGOQ.js} +3 -3
- package/dist/cli-delegate/{chunk-LCFJIQ4J.js → chunk-JEJABGKB.js} +110 -6
- package/dist/cli-delegate/chunk-MMJL4TIX.js +18 -0
- package/dist/cli-delegate/{chunk-REKCMYOU.js → chunk-PFBV6XRT.js} +36 -20
- package/dist/cli-delegate/{chunk-OJM3MBJM.js → chunk-QEEB4LHO.js} +22 -35
- package/dist/cli-delegate/chunk-RVGBH4XK.js +142 -0
- package/dist/cli-delegate/{chunk-MMXA5GY7.js → chunk-UVKNIJH2.js} +14 -0
- 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 +2861 -96
- package/dist/cli-delegate/cmd_doctor.js +3 -2
- 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 +87 -7
- 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 +3 -2
- package/dist/cli-delegate/cmd_route_explain.js +1 -1
- package/dist/cli-delegate/cmd_session_recycle.js +177 -0
- package/dist/cli-delegate/cmd_settings_get.js +3 -56
- package/dist/cli-delegate/cmd_settings_migrate.js +4 -4
- package/dist/cli-delegate/cmd_settings_set.js +2 -2
- 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 +7 -7
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +1 -1
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +2 -2
- package/dist/discovery/trust-report.md +1 -1
- package/dist/discovery/workspaces.json +2 -2
- package/dist/hooks/dispatch.js +2143 -641
- package/dist/install/install.mjs +133 -14
- package/dist/mcp/registry-manifest.json +2 -2
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +3967 -761
- 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 +6 -0
- package/dist/server/schemas/settings.js.map +1 -1
- package/dist/ui/assets/index-B5qGYOJo.js +26 -0
- package/dist/ui/assets/index-B5qGYOJo.js.map +1 -0
- package/dist/ui/index.html +1 -1
- package/docs/contracts/settings-classes.md +21 -5
- package/package.json +1 -1
- package/src/agent-src/scripts/roadmap_gates.ts +312 -0
- package/src/agent-src/scripts/update_roadmap_progress.ts +72 -4
- package/src/config/agent-settings.template.yml +36 -0
- package/src/config/dispatch-economy-metrics.json +61 -0
- package/src/config/evaluator-budgets.json +4 -4
- package/src/config/hook-token-budget.json +91 -0
- package/src/config/pack-size-budget.json +3 -2
- package/src/config/recycle-threshold-budget.json +12 -0
- package/src/config/rule-enforcement-baseline.json +86 -0
- package/src/scripts/_cli/cmd_conformance.ts +17 -2
- package/src/scripts/_cli/cmd_session_recycle.ts +226 -0
- package/src/scripts/_cli/explain_last/inputs.ts +13 -0
- package/src/scripts/_dispatch.bash +23 -0
- package/src/scripts/_lib/agent_settings.ts +119 -2
- package/src/scripts/_lib/carrier_divergence.ts +47 -0
- package/src/scripts/_lib/judgment_ladder.ts +254 -2
- package/src/scripts/_lib/orchestration_record.ts +41 -10
- package/src/scripts/_lib/recycle_envelope_paths.ts +35 -0
- package/src/scripts/_lib/self_repair.ts +194 -0
- package/src/scripts/_lib/session_eol.ts +230 -0
- package/src/scripts/_lib/session_role.ts +42 -0
- package/src/scripts/_lib/subagent_capsule.ts +159 -0
- package/src/scripts/_lib/subagent_response.ts +75 -0
- package/src/scripts/ai_council/clients.ts +50 -1
- package/src/scripts/ask_transport.ts +221 -0
- package/src/scripts/bench_hook_injection.ts +234 -0
- package/src/scripts/check_adr_frontmatter.ts +84 -40
- package/src/scripts/check_branch_freshness.ts +1 -0
- package/src/scripts/check_corpus_staleness.ts +18 -4
- package/src/scripts/check_detector_corpus.ts +17 -0
- package/src/scripts/check_gitignore_freshness.ts +42 -8
- package/src/scripts/check_hook_bundle_freshness.ts +20 -0
- package/src/scripts/check_kernel_prefix_stability.ts +1 -0
- package/src/scripts/check_standing_rule_delivery.ts +73 -32
- package/src/scripts/check_static_layer_stability.ts +154 -0
- package/src/scripts/check_suppression_hygiene.ts +48 -0
- package/src/scripts/dispatch_economy_report.ts +448 -0
- package/src/scripts/dispatch_r2_reviewer.ts +15 -2
- package/src/scripts/explain_run.ts +206 -4
- package/src/scripts/generate_settings_reference.ts +1 -0
- package/src/scripts/handoff_context_hook.ts +112 -4
- package/src/scripts/hook_manifest.yaml +117 -10
- package/src/scripts/hooks/concern_registry.ts +8 -0
- package/src/scripts/hooks/delegation_nudge_hook.ts +40 -3
- package/src/scripts/hooks/dispatch_hook.ts +39 -2
- package/src/scripts/hooks/edit_shape_hook.ts +303 -0
- package/src/scripts/hooks/orchestration_record_hook.ts +19 -1
- package/src/scripts/hooks/reread_guard_hook.ts +314 -0
- package/src/scripts/hooks/rtk_wrap_hook.ts +133 -14
- package/src/scripts/hooks/session_eol_hook.ts +268 -0
- package/src/scripts/hooks/turn_end_gate_hook.ts +715 -0
- package/src/scripts/install-hooks.sh +1 -1
- package/src/scripts/language_mirror_hook.ts +143 -1
- package/src/scripts/lint_decision_impact.ts +14 -1
- package/src/scripts/lint_hook_manifest.ts +82 -0
- package/src/scripts/lint_readme_serial_comma.ts +1 -0
- package/src/scripts/lint_rule_enforcement_declaration.ts +347 -0
- package/src/scripts/measure_turn_end_gate.ts +272 -0
- package/src/scripts/orchestration_record.ts +4 -1
- package/src/scripts/rebuild_hook_bundle.ts +1 -0
- package/src/scripts/report_carrier_divergence.ts +72 -24
- package/src/scripts/report_conformance_funnel.ts +291 -0
- package/src/scripts/report_imperative_density.ts +1 -0
- package/src/scripts/routing_doctor.ts +97 -0
- package/src/scripts/self_repair_cli.ts +51 -3
- package/src/scripts/session_eol_report.ts +261 -0
- package/src/server/schemas/settings.ts +14 -0
- package/src/templates/compact-instructions.md +40 -0
- package/dist/ui/assets/index-JDAF00bC.js +0 -26
- package/dist/ui/assets/index-JDAF00bC.js.map +0 -1
|
@@ -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) ?? {};
|
|
@@ -81,6 +81,53 @@ export function comparePair(a: Buffer, b: Buffer): PairVerdict {
|
|
|
81
81
|
return 'body-diff';
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
+
/**
|
|
85
|
+
* Drop a leading YAML frontmatter block, returning the prose the host actually
|
|
86
|
+
* delivers.
|
|
87
|
+
*
|
|
88
|
+
* A file with no `---` fence, or an unterminated one, is returned unchanged —
|
|
89
|
+
* the same tolerance `report_carrier_divergence`'s own `type:` reader applies,
|
|
90
|
+
* because a malformed fence must not silently swallow a rule's whole text.
|
|
91
|
+
*/
|
|
92
|
+
export function stripFrontmatter(text: string): string {
|
|
93
|
+
if (!text.startsWith('---')) return text;
|
|
94
|
+
const end = text.indexOf('\n---', 3);
|
|
95
|
+
if (end === -1) return text;
|
|
96
|
+
const afterFence = text.indexOf('\n', end + 1);
|
|
97
|
+
return afterFence === -1 ? '' : text.slice(afterFence + 1);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Do the two copies carry the same governed PROSE, ignoring frontmatter?
|
|
102
|
+
*
|
|
103
|
+
* WHY THIS IS SEPARATE FROM `comparePair`, AND MUST STAY SEPARATE
|
|
104
|
+
* --------------------------------------------------------------
|
|
105
|
+
* `comparePair` answers *can a byte-identity dedup skip this pair* — it is the
|
|
106
|
+
* dedup predicate, and a frontmatter difference is a real byte difference, so
|
|
107
|
+
* folding this in would relax the predicate that
|
|
108
|
+
* `agents/settings/contexts/dedup-reachability-refusal.md` deliberately keeps
|
|
109
|
+
* strict. `measure_scope_dedup` asks exactly that question and its arithmetic
|
|
110
|
+
* stays untouched.
|
|
111
|
+
*
|
|
112
|
+
* This answers the different, reader-facing question `report_carrier_divergence`
|
|
113
|
+
* asks: *must a human act on this pair*. Measured 2026-08-10 over the live
|
|
114
|
+
* carriers at commit `a5b2f4cb7`: 109 shared rules, **0** byte-identical, **109**
|
|
115
|
+
* classified `body-diff` — and after this strip, **0** of the 109 differ in
|
|
116
|
+
* prose. The whole divergence was the metadata block. Reporting that as body
|
|
117
|
+
* divergence manufactures the one class the report tells a reader to act on,
|
|
118
|
+
* which is precisely the failure `compareCarriers` already refuses to commit for
|
|
119
|
+
* an unreadable copy.
|
|
120
|
+
*
|
|
121
|
+
* Edge trimming only, on purpose: the frontmatter fence leaves the two sides one
|
|
122
|
+
* leading newline apart, and that is not a content difference. Internal
|
|
123
|
+
* whitespace and line endings are NOT normalized — a CRLF-vs-LF body stays a
|
|
124
|
+
* prose difference, so this stays no softer than the dedup predicate on the text
|
|
125
|
+
* the host reads.
|
|
126
|
+
*/
|
|
127
|
+
export function proseEqual(a: string, b: string): boolean {
|
|
128
|
+
return stripFrontmatter(a).trim() === stripFrontmatter(b).trim();
|
|
129
|
+
}
|
|
130
|
+
|
|
84
131
|
export interface RuleDirCensus {
|
|
85
132
|
files: number;
|
|
86
133
|
/**
|
|
@@ -37,14 +37,18 @@ import {
|
|
|
37
37
|
} from './auto_dispatch.js';
|
|
38
38
|
|
|
39
39
|
/** The five dispatch rungs, or `null` for the silent ∅ (in-session / ask). */
|
|
40
|
-
export type LadderRung = 0 | 1 | 2 | 3 | 4 | null;
|
|
40
|
+
export type LadderRung = 0 | 0.5 | 1 | 2 | 3 | 4 | null;
|
|
41
41
|
|
|
42
42
|
export type LadderVerdict =
|
|
43
43
|
| 'script' // rung 0 — deterministic transform/primitive, no spawn at all
|
|
44
44
|
| 'subagent' // rung 1/2 — one, or several downshifted, subagent dispatch(es)
|
|
45
45
|
| 'team' // rung 3 — communicating slices, dispatched via the host teams primitive
|
|
46
46
|
| 'council' // rung 4 — contested judgment
|
|
47
|
-
|
|
47
|
+
// 'ask' carries TWO shapes, split by rung (road-to-token-economy-dispatch
|
|
48
|
+
// Phase 4): rung 0.5 = a bounded question routed to ONE completion via
|
|
49
|
+
// `ask_transport` (no session, no tools, hard-capped — never the user);
|
|
50
|
+
// rung null = ambiguity, a VERDICT to the user, never a speculative spawn.
|
|
51
|
+
| 'ask'
|
|
48
52
|
| 'in-session'; // ∅ — halted, no host primitive, trivial, approval-required, or recursive guard
|
|
49
53
|
|
|
50
54
|
export interface LadderResult {
|
|
@@ -279,6 +283,62 @@ export function detectContestedJudgment(text: string): { matched: boolean; reaso
|
|
|
279
283
|
* `point-of-action-carrier` blocker already names as its pre-registered
|
|
280
284
|
* null ("no discriminator" is publishable and does not block this roadmap).
|
|
281
285
|
*/
|
|
286
|
+
/**
|
|
287
|
+
* Rung-0.5 signal — a single bounded question (token-economy-dispatch 4.2).
|
|
288
|
+
*
|
|
289
|
+
* Deliberately conservative, regex-only (the F3-lite discipline): the cost
|
|
290
|
+
* of a false NEGATIVE is one avoidable spawn; the cost of a false POSITIVE
|
|
291
|
+
* is an ask that returns INSUFFICIENT_CONTEXT and gets escalated — which the
|
|
292
|
+
* registered `ask_economy` kill criterion counts against the rung. So every
|
|
293
|
+
* ambiguity falls through (no match), and four hard exclusions apply:
|
|
294
|
+
*
|
|
295
|
+
* 1. Multi-line prompts — a bounded question is one sentence.
|
|
296
|
+
* 2. Length > 240 chars — beyond that it is a briefing, not a question.
|
|
297
|
+
* 3. Imperative work verbs (fix/implement/write/…) — those are tasks.
|
|
298
|
+
* 4. No interrogative shape — must lead with an interrogative (EN/DE) and
|
|
299
|
+
* end with `?`, OR be an explicit classify/categorize instruction
|
|
300
|
+
* (the one imperative the roadmap names as ask-shaped).
|
|
301
|
+
*/
|
|
302
|
+
const _QUESTION_IMPERATIVE_RE =
|
|
303
|
+
/\b(fix|implement|refactor|build|create|write|add|update|delete|remove|rename|migrate|install|deploy|commit|push|merge|run|execute|generate|erstelle|baue|implementiere|schreibe|behebe)\b/i;
|
|
304
|
+
const _QUESTION_LEAD_RE =
|
|
305
|
+
/^(is|are|does|do|did|can|could|should|would|will|which|what|why|how|when|where|who|whose|ist|sind|hat|haben|kann|welche[rs]?|was|warum|wie|wann|wo|wer)\b/i;
|
|
306
|
+
const _CLASSIFY_LEAD_RE = /^(classify|categori[sz]e|klassifiziere)\b/i;
|
|
307
|
+
const _QUESTION_MAX_LEN = 240;
|
|
308
|
+
|
|
309
|
+
/** A question that names a file/repo object or a concrete path needs TOOLS —
|
|
310
|
+
* a blind completion cannot read it, so it is never ask-shaped. */
|
|
311
|
+
const _NEEDS_TOOLS_RE =
|
|
312
|
+
/\b(this|the|that|my|our)\s+(?:[\w-]+\s+){0,2}?(file|files|codebase|repo|repository|transcript|log|logs|diff|document|branch|pr|route|routes|schema|module|class|function|endpoint|test|tests)\b|[\w.-]+\/[\w.-]+|\b[\w-]+\.(ts|js|py|php|md|json|yaml|yml|sh|go|rs)\b/i;
|
|
313
|
+
|
|
314
|
+
export function detectBoundedQuestion(taskText: string): { matched: boolean; reason: string } {
|
|
315
|
+
const text = taskText.trim();
|
|
316
|
+
if (!text || text.length > _QUESTION_MAX_LEN || text.includes('\n')) {
|
|
317
|
+
return { matched: false, reason: 'not-bounded' };
|
|
318
|
+
}
|
|
319
|
+
if (_NEEDS_TOOLS_RE.test(text)) {
|
|
320
|
+
return { matched: false, reason: 'references-tool-requiring-objects' };
|
|
321
|
+
}
|
|
322
|
+
// Classify-lead first: its PAYLOAD may quote work verbs ("classify this
|
|
323
|
+
// error: ETIMEDOUT on push") without being a work instruction itself.
|
|
324
|
+
if (_CLASSIFY_LEAD_RE.test(text)) {
|
|
325
|
+
return {
|
|
326
|
+
matched: true,
|
|
327
|
+
reason: 'bounded-question (classification) — rung-0.5 single completion via ask_transport, no session',
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
if (_QUESTION_IMPERATIVE_RE.test(text)) {
|
|
331
|
+
return { matched: false, reason: 'imperative-work-verb' };
|
|
332
|
+
}
|
|
333
|
+
if (_QUESTION_LEAD_RE.test(text) && text.endsWith('?')) {
|
|
334
|
+
return {
|
|
335
|
+
matched: true,
|
|
336
|
+
reason: 'bounded-question — rung-0.5 single completion via ask_transport, no session',
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
return { matched: false, reason: 'no-interrogative-shape' };
|
|
340
|
+
}
|
|
341
|
+
|
|
282
342
|
export function classifyLadder(inp: LadderInputs): LadderResult {
|
|
283
343
|
if (inp.interactiveApprovalRequired === true) {
|
|
284
344
|
return {
|
|
@@ -362,6 +422,18 @@ export function classifyLadder(inp: LadderInputs): LadderResult {
|
|
|
362
422
|
// regex and dispatched a subagent anyway, silently bypassing the floor
|
|
363
423
|
// `classifyTask` had just enforced one line above.
|
|
364
424
|
if (inp.signals.size_estimate <= SIZE_FLOOR) {
|
|
425
|
+
// Rung 0.5 (token-economy-dispatch 4.2) — a SELF-CONTAINED bounded
|
|
426
|
+
// question below the floor is the one below-spawn shape with its own
|
|
427
|
+
// routing verdict: one completion via ask_transport (no session, no
|
|
428
|
+
// tools, hard-capped). This is an ORCHESTRATOR-facing routing datum —
|
|
429
|
+
// the delegation-nudge carrier deliberately stays silent on it (a
|
|
430
|
+
// user's question is answered in-session at zero marginal cost; see
|
|
431
|
+
// classifyPrompt). Questions referencing files/paths are excluded by
|
|
432
|
+
// the detector: a completion without tools cannot read them.
|
|
433
|
+
const question = detectBoundedQuestion(inp.taskText);
|
|
434
|
+
if (question.matched) {
|
|
435
|
+
return { rung: 0.5, verdict: 'ask', reason: question.reason };
|
|
436
|
+
}
|
|
365
437
|
return {
|
|
366
438
|
rung: null,
|
|
367
439
|
verdict: classification.action === 'ask' ? 'ask' : 'in-session',
|
|
@@ -380,3 +452,183 @@ export function classifyLadder(inp: LadderInputs): LadderResult {
|
|
|
380
452
|
reason: classification.reason,
|
|
381
453
|
};
|
|
382
454
|
}
|
|
455
|
+
|
|
456
|
+
// ── Explanation trail (additive — Phase 4.1, road-to-feedback-9-29) ──────
|
|
457
|
+
//
|
|
458
|
+
// `classifyLadder` short-circuits on the first rung that resolves, so its
|
|
459
|
+
// minimal `{rung, verdict, reason}` return cannot answer "why NOT team /
|
|
460
|
+
// why NOT council / why no spawn". This layer answers exactly that without
|
|
461
|
+
// touching the minimal shape current callers depend on: the result is taken
|
|
462
|
+
// from `classifyLadder` itself (never re-derived, so the two can never
|
|
463
|
+
// disagree on the headline), and the per-rung trail is reconstructed from
|
|
464
|
+
// the SAME exported pure detectors and gate fields the resolver reads, in
|
|
465
|
+
// the same fixed priority order.
|
|
466
|
+
|
|
467
|
+
export type RungStatus = 'taken' | 'rejected' | 'not-reached';
|
|
468
|
+
|
|
469
|
+
export interface RungEvaluation {
|
|
470
|
+
rung: 0 | 0.5 | 1 | 2 | 3 | 4;
|
|
471
|
+
/** The verdict this rung resolves to when taken. */
|
|
472
|
+
resolves_to: 'script' | 'ask' | 'subagent' | 'team' | 'council';
|
|
473
|
+
status: RungStatus;
|
|
474
|
+
reason: string;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
export interface LadderExplanation {
|
|
478
|
+
/** The authoritative resolution — exactly `classifyLadder(inp)`. */
|
|
479
|
+
result: LadderResult;
|
|
480
|
+
/**
|
|
481
|
+
* Per-rung trail in the resolver's fixed priority order: 0, 4, 3, 2, 0.5, 1.
|
|
482
|
+
*
|
|
483
|
+
* 0.5 sits where the resolver checks it — inside the size-floor branch,
|
|
484
|
+
* after rung 2's verdict and before rung 1 — not where its number would
|
|
485
|
+
* sort. The order IS the contract, so the trail follows the walk.
|
|
486
|
+
*/
|
|
487
|
+
trail: RungEvaluation[];
|
|
488
|
+
/** Present only when `result.rung` is null (∅) — the why-no-spawn reason. */
|
|
489
|
+
no_spawn_reason?: string;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* Explain one ladder resolution: the rung taken, every rung genuinely
|
|
494
|
+
* evaluated-and-rejected (with the detector's own reason), and rungs the
|
|
495
|
+
* short-circuit never reached (marked `not-reached`, never given a
|
|
496
|
+
* fabricated rejection reason — a lower rung's detector might well have
|
|
497
|
+
* matched had it been consulted).
|
|
498
|
+
*/
|
|
499
|
+
export function explainLadder(inp: LadderInputs): LadderExplanation {
|
|
500
|
+
const result = classifyLadder(inp);
|
|
501
|
+
const trail: RungEvaluation[] = [];
|
|
502
|
+
const approval = inp.interactiveApprovalRequired === true;
|
|
503
|
+
|
|
504
|
+
// Rung 0 — evaluated first, exempt from the guards below.
|
|
505
|
+
if (result.rung === 0) {
|
|
506
|
+
trail.push({ rung: 0, resolves_to: 'script', status: 'taken', reason: result.reason });
|
|
507
|
+
} else if (approval) {
|
|
508
|
+
trail.push({
|
|
509
|
+
rung: 0,
|
|
510
|
+
resolves_to: 'script',
|
|
511
|
+
status: 'not-reached',
|
|
512
|
+
reason: 'interactive-approval-required resolves before every rung',
|
|
513
|
+
});
|
|
514
|
+
} else {
|
|
515
|
+
const lookup = classifyLookup(inp.taskText);
|
|
516
|
+
const mechanical = detectMechanicalTransform(inp.taskText);
|
|
517
|
+
trail.push({ rung: 0, resolves_to: 'script', status: 'rejected', reason: `${lookup.reason}; ${mechanical.reason}` });
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// A gate that resolved ∅ before rung 4 blocks rungs 4-1 wholesale; a
|
|
521
|
+
// resolution at rung 0 means they were never consulted either.
|
|
522
|
+
const gate: string | null = approval
|
|
523
|
+
? 'interactive-approval-required — caller-stated, resolves ∅'
|
|
524
|
+
: result.rung === 0
|
|
525
|
+
? 'resolved at rung 0'
|
|
526
|
+
: inp.insideSubagentSession === true
|
|
527
|
+
? 'recursive-dispatch guard — running inside a subagent/teammate session'
|
|
528
|
+
: inp.activation.halted
|
|
529
|
+
? 'emergency.orchestration_halt is set'
|
|
530
|
+
: !inp.activation.subagent_spawn
|
|
531
|
+
? 'host has no subagent_spawn primitive'
|
|
532
|
+
: null;
|
|
533
|
+
|
|
534
|
+
const notReached = (why: string): string => `not evaluated — ${why}`;
|
|
535
|
+
|
|
536
|
+
// Rung 4 — council.
|
|
537
|
+
if (gate !== null) {
|
|
538
|
+
trail.push({ rung: 4, resolves_to: 'council', status: 'not-reached', reason: notReached(gate) });
|
|
539
|
+
} else if (result.rung === 4) {
|
|
540
|
+
trail.push({ rung: 4, resolves_to: 'council', status: 'taken', reason: result.reason });
|
|
541
|
+
} else {
|
|
542
|
+
trail.push({ rung: 4, resolves_to: 'council', status: 'rejected', reason: detectContestedJudgment(inp.taskText).reason });
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
// Rung 3 — team.
|
|
546
|
+
if (gate !== null) {
|
|
547
|
+
trail.push({ rung: 3, resolves_to: 'team', status: 'not-reached', reason: notReached(gate) });
|
|
548
|
+
} else if (result.rung === 4) {
|
|
549
|
+
trail.push({ rung: 3, resolves_to: 'team', status: 'not-reached', reason: notReached('resolved at rung 4') });
|
|
550
|
+
} else if (result.rung === 3) {
|
|
551
|
+
trail.push({ rung: 3, resolves_to: 'team', status: 'taken', reason: result.reason });
|
|
552
|
+
} else {
|
|
553
|
+
const communication = detectCommunicationNeed(inp.taskText);
|
|
554
|
+
const reason =
|
|
555
|
+
communication.matched && result.degraded_from === 3
|
|
556
|
+
? `${communication.reason} — but host reports agent_teams: false, degraded (see resolved rung)`
|
|
557
|
+
: communication.reason;
|
|
558
|
+
trail.push({ rung: 3, resolves_to: 'team', status: 'rejected', reason });
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
// Rungs 2 and 1 — classifyTask's verdict, then the single-slice check.
|
|
562
|
+
const resolvedAbove = result.rung === 4 || result.rung === 3;
|
|
563
|
+
if (gate !== null || resolvedAbove) {
|
|
564
|
+
const why = gate ?? `resolved at rung ${result.rung}`;
|
|
565
|
+
trail.push({ rung: 2, resolves_to: 'subagent', status: 'not-reached', reason: notReached(why) });
|
|
566
|
+
trail.push({ rung: 0.5, resolves_to: 'ask', status: 'not-reached', reason: notReached(why) });
|
|
567
|
+
trail.push({ rung: 1, resolves_to: 'subagent', status: 'not-reached', reason: notReached(why) });
|
|
568
|
+
} else {
|
|
569
|
+
const classification = classifyTask(inp.signals, inp.activation);
|
|
570
|
+
if (result.rung === 2) {
|
|
571
|
+
trail.push({ rung: 2, resolves_to: 'subagent', status: 'taken', reason: result.reason });
|
|
572
|
+
trail.push({ rung: 0.5, resolves_to: 'ask', status: 'not-reached', reason: notReached('resolved at rung 2') });
|
|
573
|
+
trail.push({ rung: 1, resolves_to: 'subagent', status: 'not-reached', reason: notReached('resolved at rung 2') });
|
|
574
|
+
} else {
|
|
575
|
+
trail.push({ rung: 2, resolves_to: 'subagent', status: 'rejected', reason: classification.reason });
|
|
576
|
+
if (result.rung === 1) {
|
|
577
|
+
trail.push({
|
|
578
|
+
rung: 0.5,
|
|
579
|
+
resolves_to: 'ask',
|
|
580
|
+
status: 'not-reached',
|
|
581
|
+
reason: notReached(`task above the size floor (${inp.signals.size_estimate} > ${SIZE_FLOOR}) — rung 0.5 is reachable only below it`),
|
|
582
|
+
});
|
|
583
|
+
trail.push({ rung: 1, resolves_to: 'subagent', status: 'taken', reason: result.reason });
|
|
584
|
+
} else if (result.degraded_from === 3) {
|
|
585
|
+
// The communication branch returns from INSIDE rung 3's handling
|
|
586
|
+
// (its `classifyTask` fallback is the rung-2 attempt), so it exits
|
|
587
|
+
// before `detectBoundedReadHeavySlice` is ever called. Reporting
|
|
588
|
+
// rung 1 as `rejected` here quoted that detector's reason — and
|
|
589
|
+
// when the detector MATCHES, that reason describes a match, so
|
|
590
|
+
// the trail read "rejected: one lite-tier subagent instead of
|
|
591
|
+
// session-context burn". A maintainer asking why nothing spawned
|
|
592
|
+
// was told rung 1 declined, hiding the missing agent_teams
|
|
593
|
+
// capability that actually ended the walk.
|
|
594
|
+
const skipped = notReached('the rung-3 communication branch resolved before it was consulted');
|
|
595
|
+
trail.push({ rung: 0.5, resolves_to: 'ask', status: 'not-reached', reason: skipped });
|
|
596
|
+
trail.push({ rung: 1, resolves_to: 'subagent', status: 'not-reached', reason: skipped });
|
|
597
|
+
} else if (inp.signals.size_estimate <= SIZE_FLOOR) {
|
|
598
|
+
// Rung 0.5 lives INSIDE the resolver's floor branch, between rung
|
|
599
|
+
// 2 and rung 1, so it is reachable only here. Emitting it in that
|
|
600
|
+
// position keeps the trail's order the resolver's order; omitting
|
|
601
|
+
// it (as this trail did before the rung landed) would report a
|
|
602
|
+
// walk that skipped a rung the resolver actually consulted.
|
|
603
|
+
if (result.rung === 0.5) {
|
|
604
|
+
trail.push({ rung: 0.5, resolves_to: 'ask', status: 'taken', reason: result.reason });
|
|
605
|
+
} else {
|
|
606
|
+
trail.push({
|
|
607
|
+
rung: 0.5,
|
|
608
|
+
resolves_to: 'ask',
|
|
609
|
+
status: 'rejected',
|
|
610
|
+
reason: detectBoundedQuestion(inp.taskText).reason,
|
|
611
|
+
});
|
|
612
|
+
}
|
|
613
|
+
trail.push({
|
|
614
|
+
rung: 1,
|
|
615
|
+
resolves_to: 'subagent',
|
|
616
|
+
status: 'rejected',
|
|
617
|
+
reason: `task below size floor (${inp.signals.size_estimate} <= ${SIZE_FLOOR}) — rung 1 never bypasses the floor`,
|
|
618
|
+
});
|
|
619
|
+
} else {
|
|
620
|
+
// Above the floor the resolver never enters the branch rung 0.5
|
|
621
|
+
// lives in, so it was not consulted — not declined.
|
|
622
|
+
trail.push({
|
|
623
|
+
rung: 0.5,
|
|
624
|
+
resolves_to: 'ask',
|
|
625
|
+
status: 'not-reached',
|
|
626
|
+
reason: notReached(`task above the size floor (${inp.signals.size_estimate} > ${SIZE_FLOOR}) — rung 0.5 is reachable only below it`),
|
|
627
|
+
});
|
|
628
|
+
trail.push({ rung: 1, resolves_to: 'subagent', status: 'rejected', reason: detectBoundedReadHeavySlice(inp.taskText).reason });
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
return result.rung === null ? { result, trail, no_spawn_reason: result.reason } : { result, trail };
|
|
634
|
+
}
|
|
@@ -19,8 +19,10 @@
|
|
|
19
19
|
import type { LookupClass } from './auto_dispatch.js';
|
|
20
20
|
|
|
21
21
|
export type DispatchOutcome = 'DONE' | 'DONE_WITH_CONCERNS' | 'NEEDS_CONTEXT' | 'BLOCKED' | 'killed';
|
|
22
|
-
/** Which route the
|
|
23
|
-
|
|
22
|
+
/** Which route the rung took: deterministic primitive (lean-init L0),
|
|
23
|
+
* single-completion ask (token-economy-dispatch rung 0.5), or a full
|
|
24
|
+
* subagent spawn. */
|
|
25
|
+
export type RouteTaken = 'primitive' | 'subagent' | 'ask';
|
|
24
26
|
export type VerifyMode = 'deterministic' | 'judge' | 'none';
|
|
25
27
|
export type Provenance = 'measured' | 'estimated';
|
|
26
28
|
export type TierChosen = 'lite' | 'medium' | 'high';
|
|
@@ -98,6 +100,23 @@ export interface RecordInput {
|
|
|
98
100
|
/** Rules the worker actually applied/cited (count of the envelope's
|
|
99
101
|
* `rules_applied`-equivalent on the worker side). Counts only. */
|
|
100
102
|
rules_used?: number | null | undefined;
|
|
103
|
+
// ── dispatch-economy additive fields (road-to-token-economy-dispatch
|
|
104
|
+
// Phase 1.1). `init_tokens` above (lean-init) is the spawn-payload
|
|
105
|
+
// half; `work_tokens` is the delta from first worker turn to envelope
|
|
106
|
+
// close. Both are counts; the pair's provenance rides
|
|
107
|
+
// `floor_provenance` exactly like `token_delta_provenance` tags
|
|
108
|
+
// `token_delta`. ──
|
|
109
|
+
/** Tokens the worker consumed AFTER init (delta to envelope close). */
|
|
110
|
+
work_tokens?: number | null | undefined;
|
|
111
|
+
/** Chars the dispatch RETURNED into the orchestrator context (serialized
|
|
112
|
+
* tool-result length on sync completions) — the Phase 6.3 detector for
|
|
113
|
+
* "isolation win refunded through the return channel". A count, never
|
|
114
|
+
* content. */
|
|
115
|
+
return_channel_chars?: number | null | undefined;
|
|
116
|
+
/** Provenance of the init/work pair: 'measured' (transcript ledger via
|
|
117
|
+
* cc_transcript) or 'estimated'. Defaults to 'estimated' when either
|
|
118
|
+
* field is present without a tag. */
|
|
119
|
+
floor_provenance?: Provenance | null | undefined;
|
|
101
120
|
// ── capsule shadow-measurement fields (road-to-worker-generation-recycling
|
|
102
121
|
// Phase 1.2). SHADOW ONLY: the worker still runs to stop-loss, nothing
|
|
103
122
|
// reads a capsule. All counts / enums — a capsule's CONTENT never
|
|
@@ -138,7 +157,7 @@ const TIER_SOURCES: readonly TierSource[] = ['static', 'inferred', 'inherit'];
|
|
|
138
157
|
const BANDS: readonly Band[] = ['low', 'medium', 'high'];
|
|
139
158
|
const PHASES: readonly LinePhase[] = ['refine', 'memory', 'analyze', 'plan', 'implement', 'test', 'verify', 'report'];
|
|
140
159
|
const LOOKUP_CLASSES: readonly LookupClass[] = ['definition', 'references', 'string-existence', 'report-run'];
|
|
141
|
-
const ROUTES_TAKEN: readonly RouteTaken[] = ['primitive', 'subagent'];
|
|
160
|
+
const ROUTES_TAKEN: readonly RouteTaken[] = ['primitive', 'subagent', 'ask'];
|
|
142
161
|
const TRIGGER_ARMS: readonly TriggerArm[] = ['watermark', 'saturation', 'tie'];
|
|
143
162
|
/** Hex-only payload hash — a hash can never smuggle content (privacy by construction). */
|
|
144
163
|
const PAYLOAD_HASH_RE = /^[a-f0-9]{8,64}$/i;
|
|
@@ -164,13 +183,15 @@ function isInt(n: unknown): n is number {
|
|
|
164
183
|
export function buildOrchestrationLine(input: RecordInput): BuiltLine {
|
|
165
184
|
const errors: string[] = [];
|
|
166
185
|
|
|
167
|
-
// A lookup-class primitive route
|
|
168
|
-
// (
|
|
169
|
-
//
|
|
170
|
-
//
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
186
|
+
// A lookup-class primitive route (lean-init L0) and a rung-0.5 ask route
|
|
187
|
+
// (token-economy-dispatch Phase 4) are the TWO recordable zero-spawn
|
|
188
|
+
// events: the routing decision itself is the datum the cost-reduction
|
|
189
|
+
// claim reads. Everything else with spawn_count 0 stays unrecordable
|
|
190
|
+
// in-session work.
|
|
191
|
+
const zeroSpawnRoute =
|
|
192
|
+
(input.route_taken === 'primitive' || input.route_taken === 'ask') && input.spawn_count === 0;
|
|
193
|
+
if (!zeroSpawnRoute && (!isInt(input.spawn_count) || input.spawn_count < 1)) {
|
|
194
|
+
errors.push('spawn_count must be an integer ≥ 1 (0 = handled in-session, not a dispatch — do not record; exception: route_taken=primitive|ask routes record with spawn_count 0)');
|
|
174
195
|
}
|
|
175
196
|
if (!isInt(input.token_delta)) errors.push('token_delta must be an integer (negative = net saved vs the in-session baseline)');
|
|
176
197
|
if (input.task_size_estimate !== undefined && (!isInt(input.task_size_estimate) || input.task_size_estimate < 0)) {
|
|
@@ -239,9 +260,14 @@ export function buildOrchestrationLine(input: RecordInput): BuiltLine {
|
|
|
239
260
|
for (const [key, v] of [
|
|
240
261
|
['rules_carried', input.rules_carried],
|
|
241
262
|
['rules_used', input.rules_used],
|
|
263
|
+
['work_tokens', input.work_tokens],
|
|
264
|
+
['return_channel_chars', input.return_channel_chars],
|
|
242
265
|
] as const) {
|
|
243
266
|
if (v != null && (!isInt(v) || v < 0)) errors.push(`${key} must be a non-negative integer count`);
|
|
244
267
|
}
|
|
268
|
+
if (input.floor_provenance != null && !PROVENANCES.includes(input.floor_provenance)) {
|
|
269
|
+
errors.push(`floor_provenance must be one of ${PROVENANCES.join(' | ')} (or omitted)`);
|
|
270
|
+
}
|
|
245
271
|
if (
|
|
246
272
|
input.rules_carried != null &&
|
|
247
273
|
input.rules_used != null &&
|
|
@@ -298,6 +324,11 @@ export function buildOrchestrationLine(input: RecordInput): BuiltLine {
|
|
|
298
324
|
origin: input.origin ?? null,
|
|
299
325
|
rules_carried: input.rules_carried ?? null,
|
|
300
326
|
rules_used: input.rules_used ?? null,
|
|
327
|
+
// dispatch-economy additive fields — readers ignore unknowns per audit-log-v1
|
|
328
|
+
work_tokens: input.work_tokens ?? null,
|
|
329
|
+
return_channel_chars: input.return_channel_chars ?? null,
|
|
330
|
+
floor_provenance:
|
|
331
|
+
input.floor_provenance ?? (input.init_tokens != null || input.work_tokens != null ? 'estimated' : null),
|
|
301
332
|
// capsule shadow-measurement — readers ignore unknowns per audit-log-v1
|
|
302
333
|
capsule_emitted: input.capsule_emitted ?? null,
|
|
303
334
|
capsule_entries: input.capsule_entries ?? null,
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Recycle-envelope filesystem contract (road-to-token-economy-recycling
|
|
3
|
+
* Phase 2.2/2.3) — the ONE place the producer (`session:recycle`) and the
|
|
4
|
+
* consumer (`handoff_context_hook`) agree on where the envelope lives and
|
|
5
|
+
* when it is stale. A second copy of any of these values is the drift seam
|
|
6
|
+
* Risk 6 warns about.
|
|
7
|
+
*/
|
|
8
|
+
import * as path from 'node:path';
|
|
9
|
+
|
|
10
|
+
/** Where `session:recycle` writes the pending envelope (gitignored runtime state). */
|
|
11
|
+
export const RECYCLE_ENVELOPE_REL = path.join('agents', 'runtime', 'state', 'recycle-envelope.json');
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Where the consumer MOVES the envelope on consumption — moved, not copied,
|
|
15
|
+
* so a consumed or rejected envelope can never leak into a later session,
|
|
16
|
+
* while the last one stays inspectable for debugging.
|
|
17
|
+
*/
|
|
18
|
+
export const RECYCLE_CONSUMED_REL = path.join(
|
|
19
|
+
'agents',
|
|
20
|
+
'runtime',
|
|
21
|
+
'state',
|
|
22
|
+
'recycle-envelope.consumed.json',
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
/** Staleness guard — mirrors the handoff file's 48 h discipline. */
|
|
26
|
+
export const RECYCLE_MAX_AGE_HOURS = 48;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Producer-side size cap on the serialized envelope. The schema's per-field
|
|
30
|
+
* caps make a transcript-shaped envelope invalid; this cap keeps even a
|
|
31
|
+
* legal envelope inside the `handoff-context` injection budget
|
|
32
|
+
* (`src/config/hook-token-budget.json`: 8192 B for the concern) with room
|
|
33
|
+
* for the wrapper block and a co-pending handoff.
|
|
34
|
+
*/
|
|
35
|
+
export const RECYCLE_ENVELOPE_MAX_BYTES = 6144;
|