@event4u/agent-config 14.2.0 → 14.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +33 -252
  3. package/agents/templates/.ai-council.yml.example +35 -0
  4. package/dist/agent-src/contexts/execution/roadmap-process-loop.md +83 -12
  5. package/dist/agent-src/rules/roadmap-progress-sync.md +48 -4
  6. package/dist/agent-src/scripts/resume_probe.ts +154 -9
  7. package/dist/agent-src/scripts/roadmap_gates.ts +210 -6
  8. package/dist/agent-src/skills/roadmap-management/SKILL.md +9 -4
  9. package/dist/agent-src/templates/agent-settings.md +70 -0
  10. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  11. package/dist/agent-src/templates/scripts/telemetry/remote.ts +571 -0
  12. package/dist/agent-src/templates/scripts/telemetry/settings.ts +222 -44
  13. package/dist/cli/python/workspace_drive.js +8 -3
  14. package/dist/cli/python/workspace_drive.js.map +1 -1
  15. package/dist/cli/python/workspace_hosts.js +16 -12
  16. package/dist/cli/python/workspace_hosts.js.map +1 -1
  17. package/dist/cli/registry.js +2 -0
  18. package/dist/cli/registry.js.map +1 -1
  19. package/dist/cli-delegate/{chunk-F3YUF7SA.js → chunk-3UCJWMMP.js} +1 -1
  20. package/dist/cli-delegate/chunk-F7DQWYR5.js +69 -0
  21. package/dist/cli-delegate/{chunk-7PZ4FSZB.js → chunk-FBKL6JWT.js} +61 -2
  22. package/dist/cli-delegate/{chunk-MNWRDMR5.js → chunk-PKOBQADP.js} +1 -1
  23. package/dist/cli-delegate/cmd_conformance.js +26 -11
  24. package/dist/cli-delegate/cmd_doctor.js +2 -2
  25. package/dist/cli-delegate/cmd_handoff.js +3 -3
  26. package/dist/cli-delegate/cmd_route_audit.js +2 -2
  27. package/dist/cli-delegate/cmd_settings_set.js +17 -3
  28. package/dist/discovery/deprecation-report.md +1 -1
  29. package/dist/discovery/discovery-manifest.json +4 -4
  30. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  31. package/dist/discovery/discovery-manifest.summary.md +1 -1
  32. package/dist/discovery/dormancy-report.md +1 -1
  33. package/dist/discovery/orphan-report.md +1 -1
  34. package/dist/discovery/packs.json +2 -2
  35. package/dist/discovery/trust-report.md +1 -1
  36. package/dist/discovery/workspaces.json +2 -2
  37. package/dist/hooks/dispatch.js +3754 -1721
  38. package/dist/mcp/registry-manifest.json +2 -2
  39. package/dist/mcp/server.json +2 -2
  40. package/dist/mcp/server.mjs +1070 -408
  41. package/dist/scripts/_lib/host_launch.js +66 -0
  42. package/dist/scripts/_lib/host_launch.js.map +1 -0
  43. package/dist/server/io/yamlIO.js +175 -3
  44. package/dist/server/io/yamlIO.js.map +1 -1
  45. package/dist/server/routes/wizard.js +20 -1
  46. package/dist/server/routes/wizard.js.map +1 -1
  47. package/dist/shared/settingsConsent.js +25 -0
  48. package/dist/shared/settingsConsent.js.map +1 -1
  49. package/docs/contracts/settings-classes.md +11 -0
  50. package/docs/guidelines/agent-infra/roadmap-progress-mechanics.md +2 -1
  51. package/package.json +1 -1
  52. package/src/agent-src/scripts/resume_probe.ts +154 -9
  53. package/src/agent-src/scripts/roadmap_gates.ts +210 -6
  54. package/src/agent-src/templates/scripts/telemetry/remote.ts +571 -0
  55. package/src/agent-src/templates/scripts/telemetry/settings.ts +222 -44
  56. package/src/config/estate-count-budget.json +127 -0
  57. package/src/config/evaluator-budgets.json +4 -4
  58. package/src/config/gate-coverage.yml +32 -4
  59. package/src/config/gate-violation-baselines.json +63 -63
  60. package/src/config/gitignore-block.txt +5 -0
  61. package/src/config/hook-latency-budget.json +40 -1
  62. package/src/config/hook-token-budget.json +16 -0
  63. package/src/config/rule-stub-ceilings.json +11 -3
  64. package/src/scripts/_dispatch.bash +42 -1
  65. package/src/scripts/_lib/council_fallback_posture.ts +81 -0
  66. package/src/scripts/_lib/council_fallback_wiring.ts +126 -0
  67. package/src/scripts/_lib/council_settings_block.ts +152 -0
  68. package/src/scripts/_lib/headless_invocation.ts +285 -0
  69. package/src/scripts/_lib/host_launch.ts +72 -0
  70. package/src/scripts/_lib/map_to_object.ts +16 -0
  71. package/src/scripts/_lib/roadmap_checkboxes.ts +88 -0
  72. package/src/scripts/_lib/run_checkpoint.ts +409 -0
  73. package/src/scripts/_lib/session_register.ts +32 -1
  74. package/src/scripts/_lib/stdin.ts +35 -1
  75. package/src/scripts/_lib/turn_end_refusals.ts +12 -4
  76. package/src/scripts/_lib/unattended_guard.ts +371 -0
  77. package/src/scripts/ai_council/config.ts +27 -1
  78. package/src/scripts/ai_council/events_log.ts +12 -1
  79. package/src/scripts/ai_council/fallback_config.ts +114 -0
  80. package/src/scripts/ai_council/mid_flight_fallback.ts +295 -0
  81. package/src/scripts/ai_council/orchestrator.ts +251 -292
  82. package/src/scripts/ai_council/py_parity.ts +139 -0
  83. package/src/scripts/ai_council/response_render.ts +99 -0
  84. package/src/scripts/ai_council/spend_gate.ts +123 -0
  85. package/src/scripts/ai_council/transport_resolver.ts +81 -13
  86. package/src/scripts/bench_hook_injection.ts +118 -7
  87. package/src/scripts/bench_hook_latency.ts +442 -16
  88. package/src/scripts/check_estate_count.ts +924 -0
  89. package/src/scripts/compile_hook_manifest.ts +67 -0
  90. package/src/scripts/council_cli.ts +100 -125
  91. package/src/scripts/decision_memo.ts +325 -0
  92. package/src/scripts/hook_manifest.json +1 -0
  93. package/src/scripts/hook_manifest.yaml +172 -25
  94. package/src/scripts/hooks/concern_registry.ts +6 -0
  95. package/src/scripts/hooks/dispatch_hook.ts +183 -10
  96. package/src/scripts/hooks/dispatch_issues.ts +26 -0
  97. package/src/scripts/hooks/end_review_nudge_hook.ts +97 -24
  98. package/src/scripts/hooks/hook_stdin.ts +111 -5
  99. package/src/scripts/hooks/host_semantics.ts +28 -0
  100. package/src/scripts/hooks/injection_budget.ts +731 -0
  101. package/src/scripts/hooks/payload_stub.ts +446 -0
  102. package/src/scripts/hooks/run_continuation_hook.ts +683 -0
  103. package/src/scripts/hooks/session_eol_hook.ts +52 -0
  104. package/src/scripts/hooks/telemetry_usage_hook.ts +339 -0
  105. package/src/scripts/hooks/tool_result_bytes_hook.ts +27 -0
  106. package/src/scripts/hooks/turn_end_gate_hook.ts +45 -7
  107. package/src/scripts/hooks_doctor.ts +16 -2
  108. package/src/scripts/interruption_report.ts +330 -13
  109. package/src/scripts/lint_hook_manifest.ts +167 -0
  110. package/src/scripts/roadmap_progress_hook.ts +254 -46
  111. package/src/scripts/run_supervise.ts +623 -0
  112. package/src/scripts/session_register_hook.ts +146 -12
  113. package/src/scripts/sessions_cli.ts +18 -6
  114. package/src/scripts/telemetry_disclosure_hook.ts +244 -0
  115. package/src/server/io/yamlIO.ts +167 -3
  116. package/src/shared/settingsConsent.ts +49 -2
  117. 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 };