@event4u/agent-config 14.2.0 → 14.4.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 +286 -218
- package/agents/templates/.ai-council.yml.example +35 -0
- package/dist/agent-src/contexts/execution/roadmap-process-loop.md +83 -12
- package/dist/agent-src/rules/roadmap-progress-sync.md +48 -4
- package/dist/agent-src/scripts/resume_probe.ts +154 -9
- package/dist/agent-src/scripts/roadmap_gates.ts +210 -6
- package/dist/agent-src/skills/roadmap-management/SKILL.md +9 -4
- package/dist/agent-src/templates/agent-settings.md +70 -0
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/agent-src/templates/scripts/telemetry/remote.ts +571 -0
- package/dist/agent-src/templates/scripts/telemetry/settings.ts +222 -44
- package/dist/cli/python/workspace_drive.js +8 -3
- package/dist/cli/python/workspace_drive.js.map +1 -1
- package/dist/cli/python/workspace_hosts.js +16 -12
- package/dist/cli/python/workspace_hosts.js.map +1 -1
- package/dist/cli/registry.js +2 -0
- package/dist/cli/registry.js.map +1 -1
- package/dist/cli-delegate/{chunk-F3YUF7SA.js → chunk-3UCJWMMP.js} +1 -1
- package/dist/cli-delegate/chunk-F7DQWYR5.js +69 -0
- package/dist/cli-delegate/{chunk-7PZ4FSZB.js → chunk-FBKL6JWT.js} +61 -2
- package/dist/cli-delegate/{chunk-MNWRDMR5.js → chunk-PKOBQADP.js} +1 -1
- package/dist/cli-delegate/cmd_conformance.js +26 -11
- package/dist/cli-delegate/cmd_doctor.js +2 -2
- package/dist/cli-delegate/cmd_handoff.js +3 -3
- package/dist/cli-delegate/cmd_route_audit.js +2 -2
- package/dist/cli-delegate/cmd_settings_set.js +17 -3
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +4 -4
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +1 -1
- package/dist/discovery/dormancy-report.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 +3684 -1719
- package/dist/mcp/registry-manifest.json +2 -2
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +1070 -408
- package/dist/scripts/_lib/host_launch.js +66 -0
- package/dist/scripts/_lib/host_launch.js.map +1 -0
- package/dist/server/io/yamlIO.js +175 -3
- package/dist/server/io/yamlIO.js.map +1 -1
- package/dist/server/routes/wizard.js +20 -1
- package/dist/server/routes/wizard.js.map +1 -1
- package/dist/shared/settingsConsent.js +25 -0
- package/dist/shared/settingsConsent.js.map +1 -1
- package/docs/contracts/settings-classes.md +11 -0
- package/docs/guidelines/agent-infra/roadmap-progress-mechanics.md +2 -1
- package/package.json +1 -1
- package/src/agent-src/scripts/resume_probe.ts +154 -9
- package/src/agent-src/scripts/roadmap_gates.ts +210 -6
- package/src/agent-src/templates/scripts/telemetry/remote.ts +571 -0
- package/src/agent-src/templates/scripts/telemetry/settings.ts +222 -44
- package/src/config/estate-count-budget.json +120 -0
- package/src/config/evaluator-budgets.json +4 -4
- package/src/config/gate-coverage.yml +32 -4
- package/src/config/gate-violation-baselines.json +63 -63
- package/src/config/gitignore-block.txt +5 -0
- package/src/config/hook-latency-budget.json +40 -1
- package/src/config/hook-token-budget.json +16 -0
- package/src/config/rule-stub-ceilings.json +11 -3
- package/src/scripts/_dispatch.bash +42 -1
- package/src/scripts/_lib/council_fallback_posture.ts +81 -0
- package/src/scripts/_lib/council_fallback_wiring.ts +126 -0
- package/src/scripts/_lib/council_settings_block.ts +152 -0
- package/src/scripts/_lib/headless_invocation.ts +285 -0
- package/src/scripts/_lib/host_launch.ts +72 -0
- package/src/scripts/_lib/map_to_object.ts +16 -0
- package/src/scripts/_lib/roadmap_checkboxes.ts +88 -0
- package/src/scripts/_lib/run_checkpoint.ts +409 -0
- package/src/scripts/_lib/session_register.ts +32 -1
- package/src/scripts/_lib/stdin.ts +35 -1
- package/src/scripts/_lib/turn_end_refusals.ts +12 -4
- package/src/scripts/_lib/unattended_guard.ts +371 -0
- package/src/scripts/ai_council/config.ts +27 -1
- package/src/scripts/ai_council/events_log.ts +12 -1
- package/src/scripts/ai_council/fallback_config.ts +114 -0
- package/src/scripts/ai_council/mid_flight_fallback.ts +295 -0
- package/src/scripts/ai_council/orchestrator.ts +251 -292
- package/src/scripts/ai_council/py_parity.ts +139 -0
- package/src/scripts/ai_council/response_render.ts +99 -0
- package/src/scripts/ai_council/spend_gate.ts +123 -0
- package/src/scripts/ai_council/transport_resolver.ts +81 -13
- package/src/scripts/bench_hook_injection.ts +100 -6
- package/src/scripts/bench_hook_latency.ts +442 -16
- package/src/scripts/check_estate_count.ts +924 -0
- package/src/scripts/compile_hook_manifest.ts +67 -0
- package/src/scripts/council_cli.ts +100 -125
- package/src/scripts/decision_memo.ts +325 -0
- package/src/scripts/hook_manifest.json +1 -0
- package/src/scripts/hook_manifest.yaml +172 -25
- package/src/scripts/hooks/concern_registry.ts +6 -0
- package/src/scripts/hooks/dispatch_hook.ts +183 -10
- package/src/scripts/hooks/dispatch_issues.ts +26 -0
- package/src/scripts/hooks/end_review_nudge_hook.ts +97 -24
- package/src/scripts/hooks/hook_stdin.ts +111 -5
- package/src/scripts/hooks/injection_budget.ts +595 -0
- package/src/scripts/hooks/payload_stub.ts +446 -0
- package/src/scripts/hooks/run_continuation_hook.ts +627 -0
- package/src/scripts/hooks/session_eol_hook.ts +52 -0
- package/src/scripts/hooks/telemetry_usage_hook.ts +339 -0
- package/src/scripts/hooks/tool_result_bytes_hook.ts +27 -0
- package/src/scripts/hooks/turn_end_gate_hook.ts +45 -7
- package/src/scripts/hooks_doctor.ts +16 -2
- package/src/scripts/interruption_report.ts +330 -13
- package/src/scripts/lint_hook_manifest.ts +167 -0
- package/src/scripts/roadmap_progress_hook.ts +254 -46
- package/src/scripts/run_supervise.ts +623 -0
- package/src/scripts/session_register_hook.ts +108 -8
- package/src/scripts/sessions_cli.ts +18 -6
- package/src/scripts/telemetry_disclosure_hook.ts +244 -0
- package/src/server/io/yamlIO.ts +167 -3
- package/src/shared/settingsConsent.ts +49 -2
- package/dist/cli-delegate/chunk-MMJL4TIX.js +0 -18
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* council_fallback_wiring — the api-twin factory and the event sink that
|
|
3
|
+
* `build_members` hands to the orchestrator.
|
|
4
|
+
*
|
|
5
|
+
* Lives here rather than in `council_cli.ts` because that file is 2,700 lines
|
|
6
|
+
* over the source ceiling and the documented fix is extraction
|
|
7
|
+
* (`gate-violation-baselines.json` § check_source_size_budget).
|
|
8
|
+
*
|
|
9
|
+
* Deps are injected rather than imported: `council_cli.ts` owns the provider
|
|
10
|
+
* table, the api constructor and the error classes, and importing them back
|
|
11
|
+
* here would make this module a second place that decides which providers
|
|
12
|
+
* exist. It decides nothing — it wires.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { CliFallbackOptions, FallbackEvent } from '../ai_council/mid_flight_fallback.js';
|
|
16
|
+
import { CouncilConfigError } from '../ai_council/config.js';
|
|
17
|
+
|
|
18
|
+
type Dict = Record<string, unknown>;
|
|
19
|
+
|
|
20
|
+
/** Anything the twin factory needs that `council_cli.ts` owns. */
|
|
21
|
+
export interface FallbackWiringDeps {
|
|
22
|
+
readonly apiOnQuota: boolean;
|
|
23
|
+
/** Does this provider have an api constructor at all? */
|
|
24
|
+
readonly hasApiRung: (provider: string) => boolean;
|
|
25
|
+
/** This provider's `members.<name>` block, or `{}`. */
|
|
26
|
+
readonly memberConfig: (provider: string) => Dict;
|
|
27
|
+
/** A `--model` override for this provider, or `null`. */
|
|
28
|
+
readonly modelOverride: (provider: string) => string | null;
|
|
29
|
+
/** The strict api constructor. Throws to refuse; see the catch below. */
|
|
30
|
+
readonly constructApi: (
|
|
31
|
+
name: string,
|
|
32
|
+
model: string | null,
|
|
33
|
+
opts: {
|
|
34
|
+
api_key_ref?: string | null;
|
|
35
|
+
enable_prompt_cache?: boolean;
|
|
36
|
+
prompt_cache_ttl?: '5m' | '1h' | undefined;
|
|
37
|
+
},
|
|
38
|
+
) => unknown;
|
|
39
|
+
/** The events-log appender. */
|
|
40
|
+
readonly emit: (record: Dict) => unknown;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Errors that mean "no api rung for this provider" rather than "something
|
|
45
|
+
* broke".
|
|
46
|
+
*
|
|
47
|
+
* `CouncilConfigError` is in this set because it is what key resolution throws
|
|
48
|
+
* on a rotated or removed key — the ORDINARY case. It was missing once, and
|
|
49
|
+
* the escalation escaped the factory, left `consult`, and took down the pass
|
|
50
|
+
* the fallback exists to rescue. A mechanism that turns a recoverable seat
|
|
51
|
+
* loss into a total loss is worse than no mechanism.
|
|
52
|
+
*
|
|
53
|
+
* The other two are matched by NAME rather than by identity: importing
|
|
54
|
+
* `CouncilDisabledError` and `CliClientError` back from `council_cli.ts` would
|
|
55
|
+
* be a cycle, and this module deliberately owns no provider knowledge.
|
|
56
|
+
*/
|
|
57
|
+
const REFUSAL_NAMES: ReadonlySet<string> = new Set([
|
|
58
|
+
'CouncilDisabledError',
|
|
59
|
+
'CliClientError',
|
|
60
|
+
'CouncilConfigError',
|
|
61
|
+
// R2 round 6, critical finding 1 — and the docblock above already told the
|
|
62
|
+
// story one name short. `KeyGateError` is what an api-twin construction
|
|
63
|
+
// raises when the provider key is absent or violates the 0600 contract,
|
|
64
|
+
// which is the ORDINARY shape of the very configuration this fallback
|
|
65
|
+
// targets: a cli member under subscription auth, no `api_key_ref`, no
|
|
66
|
+
// local key. It escaped `construct()`, escaped `consult()`, and is not in
|
|
67
|
+
// `main`'s catch list — so instead of losing one seat, the whole pass died
|
|
68
|
+
// with an unhandled exception. A mechanism that turns a recoverable seat
|
|
69
|
+
// loss into a total loss is worse than no mechanism, which is the sentence
|
|
70
|
+
// directly above this list.
|
|
71
|
+
'KeyGateError',
|
|
72
|
+
]);
|
|
73
|
+
|
|
74
|
+
function isRefusal(exc: unknown): boolean {
|
|
75
|
+
if (exc instanceof CouncilConfigError) return true;
|
|
76
|
+
return exc instanceof Error && REFUSAL_NAMES.has(exc.constructor.name);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function buildFallbackOptions(deps: FallbackWiringDeps): CliFallbackOptions {
|
|
80
|
+
return {
|
|
81
|
+
api_on_quota: deps.apiOnQuota,
|
|
82
|
+
// The orchestrator is a pure library and holds no sink; this is it.
|
|
83
|
+
// One line per establishing escalation, so attendance analysis can
|
|
84
|
+
// tell a seat SAVED by the fallback from one that was natively api.
|
|
85
|
+
on_event: (e: FallbackEvent): void => {
|
|
86
|
+
// Wrapped, for the reason `appendQuorumEvent` states for the
|
|
87
|
+
// identical call: an events-log write is best-effort telemetry, and
|
|
88
|
+
// a failed one (read-only mount, full disk, a permission change on
|
|
89
|
+
// agents/runtime) must not take the council pass down with it.
|
|
90
|
+
//
|
|
91
|
+
// R2 round 2, finding 7. Unwrapped, this throws out of `on_event`,
|
|
92
|
+
// out of `establishTwin`, and out of `consult` — so a disk problem
|
|
93
|
+
// during a fallback kills the whole round, and the mechanism whose
|
|
94
|
+
// entire purpose is to SAVE a seat becomes the thing that loses
|
|
95
|
+
// every seat. Losing the log line is the strictly smaller failure.
|
|
96
|
+
try {
|
|
97
|
+
deps.emit({
|
|
98
|
+
action: 'transport_fallback',
|
|
99
|
+
provider: e.provider,
|
|
100
|
+
failure_class: e.failure,
|
|
101
|
+
outcome: e.outcome,
|
|
102
|
+
api_on_quota: e.api_on_quota,
|
|
103
|
+
});
|
|
104
|
+
} catch {
|
|
105
|
+
/* best-effort telemetry — never fail the pass */
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
construct: (provider: string) => {
|
|
109
|
+
if (!deps.hasApiRung(provider)) return null;
|
|
110
|
+
const cfg = deps.memberConfig(provider);
|
|
111
|
+
// Same model resolution the cli member got: override, then
|
|
112
|
+
// config, then the api constructor's own default.
|
|
113
|
+
const model = deps.modelOverride(provider) ?? (cfg['model'] as string | undefined) ?? null;
|
|
114
|
+
try {
|
|
115
|
+
return deps.constructApi(provider, model, {
|
|
116
|
+
api_key_ref: (cfg['api_key_ref'] as string | null) ?? null,
|
|
117
|
+
enable_prompt_cache: cfg['prompt_cache'] !== false,
|
|
118
|
+
prompt_cache_ttl: cfg['prompt_cache_ttl'] as '5m' | '1h' | undefined,
|
|
119
|
+
}) as ReturnType<CliFallbackOptions['construct']>;
|
|
120
|
+
} catch (exc) {
|
|
121
|
+
if (isRefusal(exc)) return null;
|
|
122
|
+
throw exc;
|
|
123
|
+
}
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* council_settings_block — the `CouncilConfig` → settings-dict projection.
|
|
3
|
+
*
|
|
4
|
+
* A pure mapping with no I/O and one responsibility, relocated out of
|
|
5
|
+
* `council_cli.ts` UNCHANGED. That file sits ~2,600 lines over the source
|
|
6
|
+
* ceiling, and this repository answers growth with extraction rather than a
|
|
7
|
+
* raised baseline (`gate-violation-baselines.json`
|
|
8
|
+
* § check_source_size_budget: "extract, then measure").
|
|
9
|
+
*
|
|
10
|
+
* Nothing is redesigned, renamed or re-ordered: the body below is the one that
|
|
11
|
+
* lived in `council_cli.ts`, so a reviewer diffs a MOVE rather than a rewrite,
|
|
12
|
+
* and the existing describe block over it keeps passing untouched.
|
|
13
|
+
*
|
|
14
|
+
* ## The defect this projection has shipped three times
|
|
15
|
+
*
|
|
16
|
+
* `load_settings` does not hand `build_members` the config FILE — it hands it
|
|
17
|
+
* this block. So a key the loader validates and this projection drops is
|
|
18
|
+
* enforced at load and invisible at runtime. That has now happened to
|
|
19
|
+
* `quorum`, to `quorum_min_present`, and to `fallback.api_on_quota` in turn;
|
|
20
|
+
* each carries its own comment below. A new key belongs in BOTH places or in
|
|
21
|
+
* neither.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import type { CouncilConfig } from '../ai_council/config.js';
|
|
25
|
+
import { _mapToObject } from './map_to_object.js';
|
|
26
|
+
|
|
27
|
+
type Dict = Record<string, unknown>;
|
|
28
|
+
|
|
29
|
+
export function synthesizeAiCouncilBlock(cfg: CouncilConfig): Dict {
|
|
30
|
+
const members: Dict = {};
|
|
31
|
+
for (const [name, m] of cfg.members) {
|
|
32
|
+
const entry: Dict = { enabled: m.enabled, model: m.model };
|
|
33
|
+
if (m.api_key_ref !== null) {
|
|
34
|
+
entry['api_key_ref'] = m.api_key_ref;
|
|
35
|
+
}
|
|
36
|
+
if (m.mode !== null) {
|
|
37
|
+
entry['mode'] = m.mode;
|
|
38
|
+
}
|
|
39
|
+
if (m.binary !== null) {
|
|
40
|
+
entry['binary'] = m.binary;
|
|
41
|
+
}
|
|
42
|
+
if (m.model_ladder && m.model_ladder.length > 0) {
|
|
43
|
+
entry['model_ladder'] = [...m.model_ladder];
|
|
44
|
+
}
|
|
45
|
+
// road-to-cache-economy Phase 4: forward only an explicit override —
|
|
46
|
+
// the '5m' default needs no key, keeping the synthesized block
|
|
47
|
+
// byte-identical to pre-Phase-4 output when nobody opted in.
|
|
48
|
+
if (m.prompt_cache_ttl !== '5m') {
|
|
49
|
+
entry['prompt_cache_ttl'] = m.prompt_cache_ttl;
|
|
50
|
+
}
|
|
51
|
+
members[name] = entry;
|
|
52
|
+
}
|
|
53
|
+
const advisors: Dict = {};
|
|
54
|
+
for (const [name, a] of cfg.advisors) {
|
|
55
|
+
const entry: Dict = {
|
|
56
|
+
enabled: a.enabled,
|
|
57
|
+
member: a.member,
|
|
58
|
+
persona: a.persona,
|
|
59
|
+
};
|
|
60
|
+
if (a.model !== null) {
|
|
61
|
+
entry['model'] = a.model;
|
|
62
|
+
}
|
|
63
|
+
advisors[name] = entry;
|
|
64
|
+
}
|
|
65
|
+
const lensCostDisclosure: Dict = {};
|
|
66
|
+
for (const [lens, cd] of cfg.lens_overrides.cost_disclosure) {
|
|
67
|
+
lensCostDisclosure[lens] = {
|
|
68
|
+
mode: cd.mode,
|
|
69
|
+
threshold_usd: cd.threshold_usd,
|
|
70
|
+
show_per_member: cd.show_per_member,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
const lensModelDowngrade: Dict = {};
|
|
74
|
+
for (const [lens, md] of cfg.lens_overrides.model_downgrade) {
|
|
75
|
+
lensModelDowngrade[lens] = { enabled: md.enabled, auto_apply: md.auto_apply };
|
|
76
|
+
}
|
|
77
|
+
return {
|
|
78
|
+
enabled: cfg.enabled,
|
|
79
|
+
mode: cfg.defaults.mode,
|
|
80
|
+
min_rounds: cfg.defaults.min_rounds,
|
|
81
|
+
deep_min_rounds: cfg.defaults.deep_min_rounds,
|
|
82
|
+
max_output_tokens: cfg.defaults.max_output_tokens,
|
|
83
|
+
session_retention_days: cfg.defaults.session_retention_days,
|
|
84
|
+
debate_max_rounds: cfg.defaults.debate_max_rounds,
|
|
85
|
+
cost_budget: {
|
|
86
|
+
max_input_tokens: cfg.cost_budget.max_input_tokens,
|
|
87
|
+
max_output_tokens: cfg.cost_budget.max_output_tokens,
|
|
88
|
+
max_calls: cfg.cost_budget.max_calls,
|
|
89
|
+
max_total_usd: cfg.cost_budget.max_total_usd,
|
|
90
|
+
daily_limit_usd: cfg.cost_budget.daily_limit_usd,
|
|
91
|
+
},
|
|
92
|
+
consensus_scoring: {
|
|
93
|
+
enabled: cfg.consensus_scoring.enabled,
|
|
94
|
+
strong_threshold: cfg.consensus_scoring.strong_threshold,
|
|
95
|
+
minority_threshold: cfg.consensus_scoring.minority_threshold,
|
|
96
|
+
lenses: [...cfg.consensus_scoring.lenses],
|
|
97
|
+
},
|
|
98
|
+
cli_call_budget: {
|
|
99
|
+
max_calls_per_day: _mapToObject(cfg.cli_call_budget.max_calls_per_day),
|
|
100
|
+
warn_at: cfg.cli_call_budget.warn_at,
|
|
101
|
+
},
|
|
102
|
+
// Phase 3.3 (road-to-always-on-orchestration): was validated by
|
|
103
|
+
// `_build_config`/`_build_quorum` but never forwarded into the
|
|
104
|
+
// synthesized block, so `build_members` always saw `undefined` and
|
|
105
|
+
// silently fell back to `'majority'` regardless of the user's
|
|
106
|
+
// configured `quorum: <k>`. Additive key — callers that never read
|
|
107
|
+
// it observe no change.
|
|
108
|
+
quorum: cfg.quorum,
|
|
109
|
+
// Forwarded for the same reason `quorum` above had to be: a key that
|
|
110
|
+
// `_build_config` validates and this block drops is a key the loader
|
|
111
|
+
// enforces and the runtime never sees. That defect shipped once here
|
|
112
|
+
// already; repeating it for the shadow floor would silently pin every
|
|
113
|
+
// `floor_would_hold` to the default no matter what the operator set.
|
|
114
|
+
quorum_min_present: cfg.quorum_min_present,
|
|
115
|
+
// Third instance of the same defect the two comments above describe,
|
|
116
|
+
// and the most expensive of the three: `build_members` reads
|
|
117
|
+
// `ai_council.fallback.api_on_quota` off THIS block, so before the key
|
|
118
|
+
// was forwarded no config file could turn quota fall-through on. The
|
|
119
|
+
// documentation, the contract section and the tests all described a
|
|
120
|
+
// switch that production could not flip.
|
|
121
|
+
fallback: { api_on_quota: cfg.fallback.api_on_quota },
|
|
122
|
+
necessity_classifier: {
|
|
123
|
+
enabled: cfg.necessity_classifier.enabled,
|
|
124
|
+
mode: cfg.necessity_classifier.mode,
|
|
125
|
+
user_explicit_mode: cfg.necessity_classifier.user_explicit_mode,
|
|
126
|
+
},
|
|
127
|
+
model_downgrade: {
|
|
128
|
+
enabled: cfg.model_downgrade.enabled,
|
|
129
|
+
auto_apply: cfg.model_downgrade.auto_apply,
|
|
130
|
+
},
|
|
131
|
+
debate: {
|
|
132
|
+
max_cost_usd: cfg.debate.max_cost_usd,
|
|
133
|
+
cost_disclosure: {
|
|
134
|
+
mode: cfg.debate.cost_disclosure.mode,
|
|
135
|
+
threshold_usd: cfg.debate.cost_disclosure.threshold_usd,
|
|
136
|
+
show_per_member: cfg.debate.cost_disclosure.show_per_member,
|
|
137
|
+
},
|
|
138
|
+
},
|
|
139
|
+
lens_overrides: {
|
|
140
|
+
necessity_classifier_mode: _mapToObject(
|
|
141
|
+
cfg.lens_overrides.necessity_classifier_mode,
|
|
142
|
+
),
|
|
143
|
+
necessity_classifier_user_explicit_mode: _mapToObject(
|
|
144
|
+
cfg.lens_overrides.necessity_classifier_user_explicit_mode,
|
|
145
|
+
),
|
|
146
|
+
model_downgrade: lensModelDowngrade,
|
|
147
|
+
cost_disclosure: lensCostDisclosure,
|
|
148
|
+
},
|
|
149
|
+
members,
|
|
150
|
+
advisors,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* headless_invocation — the resume command a dead run would be restarted with,
|
|
3
|
+
* constructed and PRINTED, never executed.
|
|
4
|
+
*
|
|
5
|
+
* road-to-long-horizon-execution Phase 4.0, second half. The first half is
|
|
6
|
+
* `unattended_guard.ts` (the preconditions). This is the seam between the
|
|
7
|
+
* watcher and a process that does not exist.
|
|
8
|
+
*
|
|
9
|
+
* ## The live spawn is a published refusal, not a pending item
|
|
10
|
+
*
|
|
11
|
+
* Phase 4.0 shipped the guard and deferred the spawn, and a deferral with no
|
|
12
|
+
* end condition is the exact defect the roadmap opens with (D-5: "pending,
|
|
13
|
+
* indefinitely"). The AI council (2026-08-19, anthropic/claude-sonnet-4-5 +
|
|
14
|
+
* openai/codex-default, blind peer review) split on what to do — build a
|
|
15
|
+
* dry-run seam, or cancel the capability — and named the SAME defect from both
|
|
16
|
+
* sides: `run:supervise --relaunch` advertised a capability that did not
|
|
17
|
+
* exist. The resolution taken, recorded HERE and in the roadmap step rather
|
|
18
|
+
* than behind a pointer — a decision memo is gitignored, run-scoped and
|
|
19
|
+
* auto-pruned, so a tracked file resting its rationale on one leaves a reader
|
|
20
|
+
* with an unreachable citation (R2 round 2, finding 8):
|
|
21
|
+
*
|
|
22
|
+
* The invocation seam SHIPS, print-only. Executing it is refused, and the
|
|
23
|
+
* refusal has a falsifiable reopen condition rather than a date.
|
|
24
|
+
*
|
|
25
|
+
* ## Why print-only is not a half-measure
|
|
26
|
+
*
|
|
27
|
+
* A printed command is the human-in-the-loop shape this suite already commits
|
|
28
|
+
* to everywhere else — merge stays human, the supervisor may open a PR and
|
|
29
|
+
* never merges one. An operator reading a digest can paste the line and watch
|
|
30
|
+
* the run; nothing about that needs a daemon. What a daemon would add is
|
|
31
|
+
* exactly the part nobody has validated: an auth path, a sandbox posture, and
|
|
32
|
+
* a process that spends money with nobody watching.
|
|
33
|
+
*
|
|
34
|
+
* **There is no `spawn`, `exec` or `fork` in this file, and that is asserted
|
|
35
|
+
* by a test rather than promised by this comment.**
|
|
36
|
+
*
|
|
37
|
+
* Scoped precisely, because the wider reading is false (R2 round 2, finding
|
|
38
|
+
* 5): what cannot start from here is an AGENT. A plan DOES cause a subprocess
|
|
39
|
+
* — `planResume` calls `preflight`, which runs `git remote -v` in the target
|
|
40
|
+
* worktree to answer the production-remote precondition, one per plan. Reading
|
|
41
|
+
* a git remote is not the capability under refusal, and a claim that "nothing
|
|
42
|
+
* here runs a process" would be the kind of over-read this module's own
|
|
43
|
+
* reopen-condition paragraph warns about in the other direction.
|
|
44
|
+
*
|
|
45
|
+
* ## Reopen condition — falsifiable, not a date
|
|
46
|
+
*
|
|
47
|
+
* The live arm is reconsidered when the watcher has actually had an input:
|
|
48
|
+
* **the first time `agents/runtime/state/checkpoints/` holds a checkpoint
|
|
49
|
+
* written by a real dying run.** Measured 2026-08-19 on the main checkout,
|
|
50
|
+
* that directory does not exist — no session has ever both crossed the
|
|
51
|
+
* recycle threshold and held a roadmap claim, so the resume path has never
|
|
52
|
+
* had a single input to resume from. Building an unattended spawn on top of
|
|
53
|
+
* that is the fourth floor of an untenanted building.
|
|
54
|
+
*
|
|
55
|
+
* Stated the other way round so it cannot be over-read: the empty directory
|
|
56
|
+
* is a CONJUNCTION of two rare conditions, not evidence that sessions never
|
|
57
|
+
* die with work left. It licenses "do not build the spawn yet". It does not
|
|
58
|
+
* license "the need does not exist".
|
|
59
|
+
*/
|
|
60
|
+
|
|
61
|
+
// Both imports resolve inside the shipped tree. They used to reach into
|
|
62
|
+
// `src/cli/python/`, which is NOT in the package.json `files` whitelist, so a
|
|
63
|
+
// global install of this module would have died on ERR_MODULE_NOT_FOUND —
|
|
64
|
+
// caught by `prepack-check`, not by anything local. See `host_launch.ts`.
|
|
65
|
+
import { HOST_INVENTORY, LAUNCH_ARGV } from './host_launch.js';
|
|
66
|
+
import { CHECKPOINT_DIR_REL } from './run_checkpoint.js';
|
|
67
|
+
import { preflight, type PreflightVerdict } from './unattended_guard.js';
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Session-register `platform` → `HOST_INVENTORY` key.
|
|
71
|
+
*
|
|
72
|
+
* The two vocabularies genuinely differ — a live record carries
|
|
73
|
+
* `"platform": "claude"` while the inventory keys the same host as
|
|
74
|
+
* `"claude-code"` — and an unlisted platform resolves to `undefined` rather
|
|
75
|
+
* than to a guessed binary. Fail-closed, matching `unattended_guard`'s posture
|
|
76
|
+
* on an unreadable git config: "I do not recognise this host" and "this host
|
|
77
|
+
* can be driven" must never resolve the same way.
|
|
78
|
+
*/
|
|
79
|
+
export const PLATFORM_TO_HOST: Readonly<Record<string, string>> = Object.freeze({
|
|
80
|
+
claude: 'claude-code',
|
|
81
|
+
codex: 'codex',
|
|
82
|
+
gemini: 'gemini',
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
/** The prompt a resumed run is started with. */
|
|
86
|
+
export function resumePrompt(roadmapSlug: string): string {
|
|
87
|
+
return (
|
|
88
|
+
`Resume the claimed roadmap \`${roadmapSlug}\`. ` +
|
|
89
|
+
`First act: look the checkpoint up by slug (latestCheckpointFor) and verify it ` +
|
|
90
|
+
`against the worktree before acting on any of it — resume by evidence, never by ` +
|
|
91
|
+
`bookkeeping. Then run /roadmap:process-full ${roadmapSlug}.`
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The argv a resume would be launched with, or `null` when the host cannot be
|
|
97
|
+
* driven headlessly.
|
|
98
|
+
*
|
|
99
|
+
* Only Tier-1 hosts from `HOST_INVENTORY` have a documented non-interactive
|
|
100
|
+
* surface (`docs/contracts/host-agent-protocol.md`); every Tier-3 host is
|
|
101
|
+
* observe-only and there is nothing to construct. The shape is read off that
|
|
102
|
+
* contract rather than invented here.
|
|
103
|
+
*/
|
|
104
|
+
export function buildResumeArgv(
|
|
105
|
+
platform: string,
|
|
106
|
+
roadmapSlug: string,
|
|
107
|
+
inventory: Readonly<Record<string, { tier: number; cli: string | null }>> = HOST_INVENTORY,
|
|
108
|
+
launch: Readonly<Record<string, (prompt: string) => string[]>> = LAUNCH_ARGV,
|
|
109
|
+
): readonly string[] | null {
|
|
110
|
+
const hostId = PLATFORM_TO_HOST[platform];
|
|
111
|
+
if (hostId === undefined) return null;
|
|
112
|
+
|
|
113
|
+
// The tier check is NOT dead code, and the reason is worth stating because
|
|
114
|
+
// it looks dead: `PLATFORM_TO_HOST` maps only Tier-1 hosts today, so every
|
|
115
|
+
// unmapped platform returns above and this guard is currently unreachable.
|
|
116
|
+
// It is the guard that has to hold the day a host is DEMOTED — an entry
|
|
117
|
+
// going `tier: 1 -> 3` in `HOST_INVENTORY` while its row here stays — and
|
|
118
|
+
// that is a one-line edit in a different file with no reason to look here.
|
|
119
|
+
//
|
|
120
|
+
// R2 round 2, finding 6 caught the test certifying this guard while
|
|
121
|
+
// short-circuiting at the mapping and never reaching it. The fix is the
|
|
122
|
+
// test, not the deletion — hence the injectable `inventory`, which lets a
|
|
123
|
+
// test demote a mapped host without waiting for a real demotion.
|
|
124
|
+
const entry = inventory[hostId];
|
|
125
|
+
if (entry === undefined || entry.tier !== 1 || entry.cli === null) return null;
|
|
126
|
+
|
|
127
|
+
// The argv comes from `HOST_CONFIGS`, the config the drive loop actually
|
|
128
|
+
// executes — never re-derived here.
|
|
129
|
+
//
|
|
130
|
+
// R2 round 2, finding 12 flagged that `host-agent-protocol.md`
|
|
131
|
+
// contradicts itself about gemini (its inventory row reads
|
|
132
|
+
// `gemini --output-format json` over stdin; a later row reads
|
|
133
|
+
// `gemini -p … --output-format json`), and it was right that picking a
|
|
134
|
+
// side silently leaves an auditor at a coin flip. It turned out to be
|
|
135
|
+
// sharper than a documentation nit: the first version of this function
|
|
136
|
+
// hand-built BOTH codex and gemini as stdin consumers, and the tree's own
|
|
137
|
+
// executable configs pass the prompt as an argv member for both. A pasted
|
|
138
|
+
// command would have piped a prompt into a CLI that was not reading one.
|
|
139
|
+
//
|
|
140
|
+
// So the fix is not to choose a row. Prose can contradict itself; the
|
|
141
|
+
// table the drive loop runs on cannot, and there is no reason for a second
|
|
142
|
+
// derivation of the same argv to exist. `promptOnStdin` is gone with it —
|
|
143
|
+
// no supported host takes the prompt on stdin.
|
|
144
|
+
const build = launch[hostId];
|
|
145
|
+
if (build === undefined) return null;
|
|
146
|
+
return build(resumePrompt(roadmapSlug));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* The smallest REAL unit of each budget axis, used to probe whether the
|
|
151
|
+
* unattended lane is open at all.
|
|
152
|
+
*
|
|
153
|
+
* One cent and one token — the smallest amounts either axis can actually be
|
|
154
|
+
* charged. Not a forecast of what a resume costs, which is unknowable before
|
|
155
|
+
* it runs.
|
|
156
|
+
*
|
|
157
|
+
* The first version used `Number.EPSILON`, and a test caught it: EPSILON is
|
|
158
|
+
* the representable gap at 1.0, so `10 + Number.EPSILON === 10` and an
|
|
159
|
+
* EXHAUSTED $10/$10 budget read as open. A "smallest positive number" that
|
|
160
|
+
* disappears under addition at the magnitudes budgets actually use is not a
|
|
161
|
+
* probe, it is a zero wearing a careful name. A real minimum unit is exact at
|
|
162
|
+
* those magnitudes and is the smaller claim besides.
|
|
163
|
+
*/
|
|
164
|
+
export const SPEND_PROBE_USD = 0.01;
|
|
165
|
+
export const SPEND_PROBE_TOKENS = 1;
|
|
166
|
+
|
|
167
|
+
export interface ResumeTarget {
|
|
168
|
+
readonly roadmapSlug: string;
|
|
169
|
+
readonly worktree: string;
|
|
170
|
+
readonly platform: string;
|
|
171
|
+
/** Commit the resume would start from — the dedup key's second half. */
|
|
172
|
+
readonly head: string;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export interface ResumePlan {
|
|
176
|
+
readonly target: ResumeTarget;
|
|
177
|
+
/** The command a HUMAN can paste, or `null` when the host has no headless surface. */
|
|
178
|
+
readonly command: string | null;
|
|
179
|
+
/** Why no command could be built. Empty when `command` is non-null. */
|
|
180
|
+
readonly hostRefusal: string | null;
|
|
181
|
+
/**
|
|
182
|
+
* What an UNATTENDED lane would decide about this same run.
|
|
183
|
+
*
|
|
184
|
+
* Reported next to the command and never conflated with it: a human
|
|
185
|
+
* pasting the command is not the unattended lane, so a budget refusal here
|
|
186
|
+
* does not mean the operator may not run it. It means nothing may run it
|
|
187
|
+
* *without* the operator.
|
|
188
|
+
*/
|
|
189
|
+
readonly unattended: PreflightVerdict;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Quote one argv member for a copy-pasteable POSIX shell line. */
|
|
193
|
+
function shQuote(arg: string): string {
|
|
194
|
+
return /^[A-Za-z0-9_@%+=:,./-]+$/.test(arg) ? arg : `'${arg.replaceAll("'", `'\\''`)}'`;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export function planResume(repoRoot: string, target: ResumeTarget, now?: Date): ResumePlan {
|
|
198
|
+
const argv = buildResumeArgv(target.platform, target.roadmapSlug);
|
|
199
|
+
const unattended = preflight({
|
|
200
|
+
repoRoot,
|
|
201
|
+
worktree: target.worktree,
|
|
202
|
+
roadmapSlug: target.roadmapSlug,
|
|
203
|
+
head: target.head,
|
|
204
|
+
// A resume's cost is NOT knowable before it runs, so this projection
|
|
205
|
+
// is a probe, not an estimate, and the question it asks is "is the
|
|
206
|
+
// lane open at all" rather than "would it afford this run".
|
|
207
|
+
//
|
|
208
|
+
// The probe is each axis's smallest REAL unit: zero would let a
|
|
209
|
+
// disabled-by-zero ceiling read as allowed (`checkBudget` refuses a 0
|
|
210
|
+
// ceiling only against a positive projection), while a
|
|
211
|
+
// plausible-looking `1` USD would refuse a budget with $0.50 of
|
|
212
|
+
// headroom and report a closed lane that is open. A made-up cost is
|
|
213
|
+
// worse than an admittedly minimal one, because only the second is
|
|
214
|
+
// obviously not a forecast.
|
|
215
|
+
projectedUsd: SPEND_PROBE_USD,
|
|
216
|
+
projectedTokens: SPEND_PROBE_TOKENS,
|
|
217
|
+
...(now === undefined ? {} : { now }),
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
if (argv === null) {
|
|
221
|
+
return {
|
|
222
|
+
target,
|
|
223
|
+
command: null,
|
|
224
|
+
hostRefusal:
|
|
225
|
+
`host: platform '${target.platform}' has no documented headless surface ` +
|
|
226
|
+
`(docs/contracts/host-agent-protocol.md) — a resume must be started by hand`,
|
|
227
|
+
unattended,
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Every supported host takes the prompt in argv — the stdin branch this
|
|
232
|
+
// line used to carry was built on a prose row that the tree's own drive
|
|
233
|
+
// configs contradict, and it would have piped a prompt into a CLI that was
|
|
234
|
+
// not reading one (R2 round 2, finding 12).
|
|
235
|
+
const command = `cd ${shQuote(target.worktree)} && ${argv.map(shQuote).join(' ')}`;
|
|
236
|
+
|
|
237
|
+
return { target, command, hostRefusal: null, unattended };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* The refusal `run:supervise --relaunch` emits.
|
|
242
|
+
*
|
|
243
|
+
* Names a DECISION, never a pending implementation. The wording matters: the
|
|
244
|
+
* flag used to say "not implemented yet", which is the sentence that makes an
|
|
245
|
+
* operator wait for a release that is not coming.
|
|
246
|
+
*/
|
|
247
|
+
export const LIVE_SPAWN_REFUSAL =
|
|
248
|
+
'run:supervise: starting a session unattended is a published refusal, not an unbuilt ' +
|
|
249
|
+
'feature — road-to-long-horizon-execution 4.0, resolved by AI council 2026-08-19. ' +
|
|
250
|
+
'Use --print-relaunch for the exact command to run by hand. The refusal is reopened ' +
|
|
251
|
+
'when a real dying run first writes agents/runtime/state/checkpoints/ — until then the ' +
|
|
252
|
+
'resume path has never had an input.';
|
|
253
|
+
|
|
254
|
+
export function renderResumePlans(plans: readonly ResumePlan[]): string {
|
|
255
|
+
if (plans.length === 0) {
|
|
256
|
+
return 'run:supervise --print-relaunch: nothing to resume.\n';
|
|
257
|
+
}
|
|
258
|
+
const out: string[] = ['run:supervise --print-relaunch · run these by hand:', ''];
|
|
259
|
+
for (const p of plans) {
|
|
260
|
+
out.push(` ${p.target.roadmapSlug} (${p.target.platform}, head ${p.target.head})`);
|
|
261
|
+
out.push(p.command === null ? ` REFUSED ${p.hostRefusal ?? 'no command'}` : ` ${p.command}`);
|
|
262
|
+
out.push(
|
|
263
|
+
p.unattended.ok
|
|
264
|
+
? ' unattended: an unattended lane would also be permitted to run this'
|
|
265
|
+
: ` unattended: would be REFUSED — ${p.unattended.refusals.join('; ')}`,
|
|
266
|
+
);
|
|
267
|
+
out.push('');
|
|
268
|
+
}
|
|
269
|
+
out.push(
|
|
270
|
+
'The command above is for YOU. The unattended line describes what an automated',
|
|
271
|
+
'lane would decide about the same run; it never gates what you may run by hand.',
|
|
272
|
+
'',
|
|
273
|
+
);
|
|
274
|
+
return out.join('\n');
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Where the reopen condition is measured — re-exported, never re-declared.
|
|
279
|
+
*
|
|
280
|
+
* The path already has one owner (`run_checkpoint.ts`). A second literal here
|
|
281
|
+
* would be a second source of truth for the one directory this module's reopen
|
|
282
|
+
* condition is written against, which is the way a reopen condition quietly
|
|
283
|
+
* starts watching a directory nothing writes.
|
|
284
|
+
*/
|
|
285
|
+
export { CHECKPOINT_DIR_REL };
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* host_launch — which host agents exist, which can be driven headlessly, and
|
|
3
|
+
* the exact argv each one is launched with.
|
|
4
|
+
*
|
|
5
|
+
* ## Why this file exists, and why HERE
|
|
6
|
+
*
|
|
7
|
+
* These two facts used to live under `src/cli/python/` — the inventory in
|
|
8
|
+
* `workspace_hosts.ts`, the argv in `workspace_drive.ts`'s `HOST_CONFIGS`.
|
|
9
|
+
* That directory is **not in the package.json `files` whitelist**, so anything
|
|
10
|
+
* under `src/scripts/` (which IS shipped) that imports from it resolves fine in
|
|
11
|
+
* this checkout and crashes a global install with `ERR_MODULE_NOT_FOUND`.
|
|
12
|
+
*
|
|
13
|
+
* `prepack-check` caught exactly that when `_lib/headless_invocation.ts`
|
|
14
|
+
* reached across for both constants. The two available answers were to ship
|
|
15
|
+
* `src/cli/python/` (396 KB, 14 modules including `workspace_secrets` and
|
|
16
|
+
* `workspace_crypto` — a consumer-facing surface nobody asked to widen) or to
|
|
17
|
+
* move the ~40 lines the shipped side actually needs. This is the second.
|
|
18
|
+
*
|
|
19
|
+
* The dependency is therefore INVERTED rather than duplicated: the shipped
|
|
20
|
+
* module owns the facts, and the unshipped drive layer imports them back.
|
|
21
|
+
* Re-deriving them on the shipped side would be a second source of truth for
|
|
22
|
+
* one host's argv — the defect an R2 round found in `headless_invocation`
|
|
23
|
+
* itself, where a hand-built copy modelled codex and gemini as stdin consumers
|
|
24
|
+
* while the real configs pass the prompt in argv.
|
|
25
|
+
*
|
|
26
|
+
* ## What stays behind
|
|
27
|
+
*
|
|
28
|
+
* Everything with a runtime: the spawn, the timeout, the per-host stdout
|
|
29
|
+
* parsers, resume-session handling. Those belong to the driver. What moved is
|
|
30
|
+
* only the declarative part — a table a reader could check against
|
|
31
|
+
* `docs/contracts/host-agent-protocol.md` by eye.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
export interface InventoryEntry {
|
|
35
|
+
tier: number;
|
|
36
|
+
cli: string | null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Mirrors `docs/contracts/host-agent-protocol.md` § Today's inventory.
|
|
41
|
+
*
|
|
42
|
+
* `cli` is the PATH binary that proves the Tier-1 surface is reachable;
|
|
43
|
+
* Tier-3 hosts have no drivable CLI (`null`). Insertion order matches the
|
|
44
|
+
* table in the contract, and `tests/test_workspace_hosts.ts` asserts the two
|
|
45
|
+
* agree — the assertion imports through `workspace_hosts.ts`, which re-exports
|
|
46
|
+
* this, so the move is invisible to it.
|
|
47
|
+
*/
|
|
48
|
+
export const HOST_INVENTORY: Record<string, InventoryEntry> = {
|
|
49
|
+
'claude-code': { tier: 1, cli: 'claude' },
|
|
50
|
+
codex: { tier: 1, cli: 'codex' },
|
|
51
|
+
gemini: { tier: 1, cli: 'gemini' },
|
|
52
|
+
augment: { tier: 3, cli: null },
|
|
53
|
+
cursor: { tier: 3, cli: null },
|
|
54
|
+
cline: { tier: 3, cli: null },
|
|
55
|
+
windsurf: { tier: 3, cli: null },
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The launch argv per Tier-1 host — one entry, one source of truth.
|
|
60
|
+
*
|
|
61
|
+
* **Every supported host takes the prompt in ARGV.** None reads it from
|
|
62
|
+
* stdin, and that is worth stating because the contract prose said otherwise
|
|
63
|
+
* for two of the three until 2026-08-19: a consumer that built its own argv
|
|
64
|
+
* off the prose modelled codex and gemini as stdin consumers and would have
|
|
65
|
+
* piped a prompt into a CLI that was not reading one. The contract now
|
|
66
|
+
* describes this table rather than the other way round.
|
|
67
|
+
*/
|
|
68
|
+
export const LAUNCH_ARGV: Readonly<Record<string, (prompt: string) => string[]>> = Object.freeze({
|
|
69
|
+
'claude-code': (prompt: string) => ['claude', '-p', prompt, '--output-format', 'json'],
|
|
70
|
+
codex: (prompt: string) => ['codex', 'exec', '--json', prompt],
|
|
71
|
+
gemini: (prompt: string) => ['gemini', '-p', prompt, '--output-format', 'json'],
|
|
72
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* map_to_object — a `ReadonlyMap` as a plain object.
|
|
3
|
+
*
|
|
4
|
+
* Relocated out of `council_cli.ts` unchanged so the settings-block
|
|
5
|
+
* projection could move with it without importing back into that file (a
|
|
6
|
+
* cycle). Both call sites now import it from here.
|
|
7
|
+
*/
|
|
8
|
+
export function _mapToObject<V>(m: ReadonlyMap<string, V>): Record<string, V> {
|
|
9
|
+
const out: Record<string, V> = {};
|
|
10
|
+
for (const [k, v] of m) {
|
|
11
|
+
out[k] = v;
|
|
12
|
+
}
|
|
13
|
+
return out;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export { _mapToObject as mapToObject };
|