@naxodev/apnea 0.1.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 (74) hide show
  1. package/CONTEXT.md +61 -0
  2. package/CONTRIBUTING.md +21 -0
  3. package/LICENSE +21 -0
  4. package/README.md +163 -0
  5. package/SECURITY.md +35 -0
  6. package/briefs/coder.md +40 -0
  7. package/briefs/orchestrator.md +49 -0
  8. package/briefs/planner.md +54 -0
  9. package/briefs/reviewer.md +40 -0
  10. package/dist/cli.js +39397 -0
  11. package/docs/adr/0001-completion-signaling.md +3 -0
  12. package/docs/adr/0002-orchestrator-authority.md +3 -0
  13. package/docs/adr/0003-verify-at-gate.md +3 -0
  14. package/docs/adr/0004-artifact-layout-and-naming.md +3 -0
  15. package/docs/adr/0005-harness-profiles.md +5 -0
  16. package/docs/adr/0006-config-trust-model.md +3 -0
  17. package/docs/adr/0007-jj-first-commits.md +3 -0
  18. package/docs/adr/0008-effect-v4-internals.md +3 -0
  19. package/docs/adr/0009-cli-driver-split.md +9 -0
  20. package/docs/adr/0010-package-split.md +23 -0
  21. package/docs/protocol/artifacts.md +68 -0
  22. package/docs/protocol/config.md +186 -0
  23. package/docs/protocol/manual-gate.md +38 -0
  24. package/docs/protocol/overview.md +96 -0
  25. package/extension/adapters/commit.ts +15 -0
  26. package/extension/adapters/dispatch.ts +15 -0
  27. package/extension/adapters/setup.ts +34 -0
  28. package/extension/adapters/start.ts +16 -0
  29. package/extension/adapters/status.ts +24 -0
  30. package/extension/adapters/wait.ts +20 -0
  31. package/extension/api.ts +16 -0
  32. package/extension/cli/format.ts +44 -0
  33. package/extension/cli/human-gate.ts +44 -0
  34. package/extension/cli/main.ts +218 -0
  35. package/extension/cli/parse.ts +48 -0
  36. package/extension/domain/artifact-kind.ts +26 -0
  37. package/extension/domain/frontmatter.ts +69 -0
  38. package/extension/domain/herdr.ts +109 -0
  39. package/extension/domain/paths.ts +139 -0
  40. package/extension/domain/recovery.ts +25 -0
  41. package/extension/domain/rounds.ts +16 -0
  42. package/extension/domain/setup.ts +158 -0
  43. package/extension/domain/slug.ts +9 -0
  44. package/extension/domain/state-machine.ts +132 -0
  45. package/extension/domain/timeouts.ts +24 -0
  46. package/extension/domain/types.ts +145 -0
  47. package/extension/domain/verify-commands.ts +128 -0
  48. package/extension/errors.ts +247 -0
  49. package/extension/host-adapter.ts +8 -0
  50. package/extension/registry.ts +323 -0
  51. package/extension/result.ts +55 -0
  52. package/extension/run-tool.ts +43 -0
  53. package/extension/schema/config.ts +315 -0
  54. package/extension/schema/frontmatter.ts +34 -0
  55. package/extension/schema/state.ts +119 -0
  56. package/extension/services/app-live.ts +24 -0
  57. package/extension/services/config.ts +103 -0
  58. package/extension/services/file-system.ts +178 -0
  59. package/extension/services/herdr.ts +860 -0
  60. package/extension/services/run-store.ts +99 -0
  61. package/extension/services/vcs.ts +246 -0
  62. package/extension/workflows/commit.ts +148 -0
  63. package/extension/workflows/dispatch.ts +693 -0
  64. package/extension/workflows/reset.ts +26 -0
  65. package/extension/workflows/setup.ts +301 -0
  66. package/extension/workflows/start.ts +149 -0
  67. package/extension/workflows/status.ts +45 -0
  68. package/extension/workflows/wait.ts +793 -0
  69. package/herdr-plugin/herdr-plugin.toml +15 -0
  70. package/herdr-plugin/scripts/run-task.sh +8 -0
  71. package/package.json +75 -0
  72. package/schemas/artifact-frontmatter.md +38 -0
  73. package/schemas/config.schema.json +50 -0
  74. package/schemas/state.schema.json +63 -0
@@ -0,0 +1,26 @@
1
+ import { Effect } from "effect"
2
+ import { getRound, setRound } from "../domain/rounds.ts"
3
+ import type { AppError } from "../errors.ts"
4
+ import { ok, type ToolResult } from "../result.ts"
5
+ import { RunStore } from "../services/run-store.ts"
6
+
7
+ /**
8
+ * Human-only: reset rework counter for a gate key.
9
+ * Missing/corrupt state → NoRunState / StateCorrupt.
10
+ */
11
+ export const resetRoundsWorkflow = (
12
+ params: { gate: string },
13
+ root: string,
14
+ ): Effect.Effect<ToolResult, AppError, RunStore> =>
15
+ Effect.gen(function* () {
16
+ const store = yield* RunStore
17
+ const state = yield* store.require(root)
18
+ const key = params.gate
19
+ const prev = getRound(state, key)
20
+ setRound(state, key, 1)
21
+ state.last_error = null
22
+ yield* store.save(state, root)
23
+ return ok(`reset rounds for ${key}: ${prev} → 1`, {
24
+ note: "human-only tool; orchestrator must not call this",
25
+ })
26
+ })
@@ -0,0 +1,301 @@
1
+ import * as path from "node:path"
2
+ import { Effect, Result } from "effect"
3
+ import { supportsFloating } from "../domain/herdr.ts"
4
+ import {
5
+ globalConfigPath,
6
+ packageRoot,
7
+ projectConfigPath,
8
+ } from "../domain/paths.ts"
9
+ import {
10
+ buildGlobalConfig,
11
+ detectionNotes,
12
+ pickRoles,
13
+ type Detected,
14
+ } from "../domain/setup.ts"
15
+ import { ConfigError, type AppError } from "../errors.ts"
16
+ import { ok, type ToolResult } from "../result.ts"
17
+ import { decodeGlobalConfig } from "../schema/config.ts"
18
+ import { FileSystem } from "../services/file-system.ts"
19
+ import { Herdr } from "../services/herdr.ts"
20
+
21
+ export type SetupParams = {
22
+ /** Write .apnea/config.json role bindings in cwd */
23
+ project?: boolean
24
+ /** Overwrite existing global profiles (default: merge, keep existing profile keys) */
25
+ force?: boolean
26
+ /**
27
+ * Write (or refresh) a loop primer at `<root>/AGENTS.md` for harnesses
28
+ * that read that file but have no Apnea Pi plugin. Merge is marker-guarded
29
+ * — see `AGENTS_SECTION` — so a re-run never clobbers the rest of the file.
30
+ */
31
+ agents_md?: boolean
32
+ }
33
+
34
+ const AGENTS_MD_BEGIN = "<!-- apnea:begin -->"
35
+ const AGENTS_MD_END = "<!-- apnea:end -->"
36
+
37
+ /**
38
+ * Names real `apnea` CLI verbs, never Pi tool names — a harness with no
39
+ * Apnea plugin can only run shell commands, so a primer naming `dispatch_role`
40
+ * would be useless to it.
41
+ */
42
+ const AGENTS_SECTION = `${AGENTS_MD_BEGIN}
43
+ ## Apnea runs
44
+
45
+ Drive a run with the \`apnea\` CLI. Each result prints \`next:\` — follow it.
46
+
47
+ \`\`\`
48
+ apnea start "<goal>" # writes state only; does NOT launch a role
49
+ apnea dispatch plan # then follow next: on every result
50
+ apnea wait # exit 3 = still waiting, call again; exit 0 = ready
51
+ apnea commit --done # after an APPROVED code review
52
+ \`\`\`
53
+
54
+ Never edit \`.apnea/state.json\` by hand. \`apnea reset-rounds\` is human-only.
55
+ ${AGENTS_MD_END}
56
+ `
57
+
58
+ const AGENTS_BLOCK_RE = /<!-- apnea:begin -->[\s\S]*?<!-- apnea:end -->\n?/
59
+
60
+ /**
61
+ * Replace the marker-bounded block in place when present, otherwise append.
62
+ * Internal to `setupWorkflow`; `setup.test.ts` exercises merge behavior
63
+ * black-box, through the written `AGENTS.md` file, not by importing this.
64
+ */
65
+ function mergeAgentsMd(existing: string | null): string {
66
+ if (existing == null || existing.length === 0) return AGENTS_SECTION
67
+ if (AGENTS_BLOCK_RE.test(existing)) {
68
+ return existing.replace(AGENTS_BLOCK_RE, AGENTS_SECTION)
69
+ }
70
+ const sep = existing.endsWith("\n") ? "\n" : "\n\n"
71
+ return `${existing}${sep}${AGENTS_SECTION}`
72
+ }
73
+
74
+ export type SetupDeps = {
75
+ /** Detect an executable on PATH (production: `which`). */
76
+ onPath: (bin: string) => boolean
77
+ /** Prepare host-specific role resources, or return null when none are needed. */
78
+ materializeRoleAgentDir: () => string | null
79
+ }
80
+
81
+ export type ProvisionResult = {
82
+ copied: string | null // dest dir, or null when skipped
83
+ linked: boolean // true only when we ran `herdr plugin link` OK
84
+ already_linked: boolean
85
+ notes: string[]
86
+ }
87
+
88
+ /**
89
+ * Copy the package's herdr-plugin into a stable config-local path and, when
90
+ * herdr is new enough and the plugin isn't already linked, run
91
+ * `herdr plugin link`. Never fails — every branch is a note.
92
+ */
93
+ export const provisionHerdrPlugin = (opts: {
94
+ srcDir: string // packageRoot()/herdr-plugin
95
+ destDir: string // dirname(globalConfigPath())/herdr-plugin
96
+ version: [number, number, number] | null // herdr.version
97
+ }): Effect.Effect<ProvisionResult, never, FileSystem | Herdr> =>
98
+ Effect.gen(function* () {
99
+ const fs = yield* FileSystem
100
+ const herdr = yield* Herdr
101
+ const notes: string[] = []
102
+
103
+ const srcExists = yield* fs.exists(opts.srcDir)
104
+ if (!srcExists) {
105
+ notes.push("herdr-plugin missing from package — reinstall @naxodev/apnea")
106
+ return { copied: null, linked: false, already_linked: false, notes }
107
+ }
108
+
109
+ yield* fs.copyDir(opts.srcDir, opts.destDir)
110
+ const runTask = path.join(opts.destDir, "scripts", "run-task.sh")
111
+ if (yield* fs.exists(runTask)) {
112
+ yield* fs.chmod(runTask, 0o755)
113
+ }
114
+
115
+ if (!supportsFloating(opts.version)) {
116
+ const ver =
117
+ opts.version == null
118
+ ? "unknown"
119
+ : `${opts.version[0]}.${opts.version[1]}.${opts.version[2]}`
120
+ notes.push(
121
+ `herdr ${ver} < 0.7.4 — floating panes unavailable; run \`herdr update\`, then re-run /apnea setup`,
122
+ )
123
+ return {
124
+ copied: opts.destDir,
125
+ linked: false,
126
+ already_linked: false,
127
+ notes,
128
+ }
129
+ }
130
+
131
+ if (yield* herdr.hasApneaPlugin) {
132
+ return {
133
+ copied: opts.destDir,
134
+ linked: false,
135
+ already_linked: true,
136
+ notes,
137
+ }
138
+ }
139
+
140
+ const linkResult = yield* herdr.linkPlugin(opts.destDir)
141
+ if (!linkResult.ok) {
142
+ notes.push(
143
+ `herdr plugin link failed: ${linkResult.raw.trim() || "(no output)"}`,
144
+ )
145
+ return {
146
+ copied: opts.destDir,
147
+ linked: false,
148
+ already_linked: false,
149
+ notes,
150
+ }
151
+ }
152
+
153
+ return { copied: opts.destDir, linked: true, already_linked: false, notes }
154
+ })
155
+
156
+ /**
157
+ * Deterministic Apnea setup: detect binaries, write global profiles
158
+ * (and optional project role bindings). Never writes cmd into project config.
159
+ */
160
+ export const setupWorkflow = (
161
+ params: SetupParams,
162
+ root: string,
163
+ deps: SetupDeps,
164
+ ): Effect.Effect<ToolResult, AppError, FileSystem | Herdr> =>
165
+ Effect.gen(function* () {
166
+ const fs = yield* FileSystem
167
+ const herdr = yield* Herdr
168
+
169
+ const readJsonSafe = (
170
+ filePath: string,
171
+ ): Effect.Effect<Record<string, unknown>> =>
172
+ Effect.gen(function* () {
173
+ const present = yield* fs.exists(filePath)
174
+ if (!present) return {}
175
+ const text = yield* fs.readFile(filePath)
176
+ try {
177
+ const v = JSON.parse(text)
178
+ if (v && typeof v === "object" && !Array.isArray(v)) {
179
+ return v as Record<string, unknown>
180
+ }
181
+ return {}
182
+ } catch {
183
+ return {}
184
+ }
185
+ })
186
+
187
+ const has: Detected = {
188
+ pi: deps.onPath("pi"),
189
+ claude: deps.onPath("claude"),
190
+ codex: deps.onPath("codex"),
191
+ herdr: deps.onPath("herdr"),
192
+ jj: deps.onPath("jj"),
193
+ git: deps.onPath("git"),
194
+ }
195
+
196
+ if (!has.pi && !has.claude && !has.codex) {
197
+ return yield* new ConfigError({
198
+ message:
199
+ "no supported agent CLI on PATH — install pi, claude, or codex before apnea setup",
200
+ })
201
+ }
202
+
203
+ const gPath = globalConfigPath()
204
+ yield* fs.mkdir(path.dirname(gPath), { recursive: true })
205
+
206
+ const prev = yield* readJsonSafe(gPath)
207
+ const force = params.force === true
208
+ const globalConfig = buildGlobalConfig({ has, prev, force })
209
+
210
+ const serialized = `${JSON.stringify(globalConfig, null, 2)}\n`
211
+ yield* fs.writeFile(gPath, serialized)
212
+
213
+ let projectPath: string | null = null
214
+ if (params.project) {
215
+ const pPath = projectConfigPath(root)
216
+ // project: roles only — never cmd
217
+ const projectCfg = { roles: pickRoles(has) }
218
+ yield* fs.writeProjectFile(
219
+ root,
220
+ pPath,
221
+ `${JSON.stringify(projectCfg, null, 2)}\n`,
222
+ )
223
+ projectPath = pPath
224
+ }
225
+
226
+ const missing = detectionNotes(has)
227
+
228
+ // Host adapters may pre-build launch resources so first dispatch is fast.
229
+ let roleAgentDir: string | null = null
230
+ const materialized = yield* Effect.result(
231
+ Effect.try({ try: deps.materializeRoleAgentDir, catch: (e) => e }),
232
+ )
233
+ if (Result.isSuccess(materialized)) {
234
+ if (materialized.success !== null) {
235
+ roleAgentDir = materialized.success
236
+ }
237
+ } else {
238
+ const e = materialized.failure
239
+ missing.push(
240
+ `role agent dir failed: ${e instanceof Error ? e.message : String(e)}`,
241
+ )
242
+ }
243
+
244
+ let herdrPlugin: ProvisionResult | null = null
245
+ let herdrVer: string | null = null
246
+ if (has.herdr) {
247
+ const version = yield* herdr.version
248
+ herdrVer =
249
+ version == null ? null : `${version[0]}.${version[1]}.${version[2]}`
250
+ herdrPlugin = yield* provisionHerdrPlugin({
251
+ srcDir: path.join(packageRoot(), "herdr-plugin"),
252
+ destDir: path.join(path.dirname(globalConfigPath()), "herdr-plugin"),
253
+ version,
254
+ })
255
+ missing.push(...herdrPlugin.notes)
256
+ }
257
+
258
+ let agentsMdPath: string | null = null
259
+ if (params.agents_md) {
260
+ const target = path.join(root, "AGENTS.md")
261
+ const present = yield* fs.exists(target)
262
+ const existing = present ? yield* fs.readFile(target) : null
263
+ yield* fs.writeFile(target, mergeAgentsMd(existing))
264
+ agentsMdPath = target
265
+ }
266
+
267
+ // A failed decode after writing is a note, not a failure — a user with a
268
+ // malformed pre-existing config must still get a written config and an
269
+ // actionable note, never a hard refusal where today there is none.
270
+ const decoded = decodeGlobalConfig(globalConfig)
271
+ if (Result.isFailure(decoded)) {
272
+ missing.push(
273
+ `global config written but does not validate: ${decoded.failure.message}`,
274
+ )
275
+ }
276
+
277
+ const data: Record<string, unknown> = {
278
+ global: gPath,
279
+ project: projectPath,
280
+ detected: has,
281
+ roles: globalConfig.roles,
282
+ notes: missing,
283
+ role_agent_dir: roleAgentDir,
284
+ next: "edit ~/.config/apnea/config.json if model ids differ, then /apnea start <goal> inside Herdr",
285
+ }
286
+ if (params.agents_md) {
287
+ data.agents_md = agentsMdPath
288
+ }
289
+ if (has.herdr) {
290
+ data.herdr_version = herdrVer
291
+ data.herdr_plugin = herdrPlugin
292
+ ? {
293
+ copied: herdrPlugin.copied,
294
+ linked: herdrPlugin.linked,
295
+ already_linked: herdrPlugin.already_linked,
296
+ }
297
+ : null
298
+ }
299
+
300
+ return ok(`wrote global config ${gPath}`, data)
301
+ })
@@ -0,0 +1,149 @@
1
+ import { Effect } from "effect"
2
+ import { packageRoot } from "../domain/paths.ts"
3
+ import { resetRecoveryLadder } from "../domain/recovery.ts"
4
+ import { slugify } from "../domain/slug.ts"
5
+ import { nextAfter } from "../domain/state-machine.ts"
6
+ import { GateRefused, NoRunState, VcsError, type AppError } from "../errors.ts"
7
+ import type { RunState } from "../domain/types.ts"
8
+ import { ok, type ToolResult } from "../result.ts"
9
+ import { Config } from "../services/config.ts"
10
+ import { FileSystem } from "../services/file-system.ts"
11
+ import { RunStore } from "../services/run-store.ts"
12
+ import { Vcs } from "../services/vcs.ts"
13
+
14
+ export type StartParams = {
15
+ goal: string
16
+ slug?: string
17
+ allow_dirty?: boolean
18
+ action?: "start" | "resume" | "abandon"
19
+ }
20
+
21
+ /**
22
+ * Start / resume / abandon an Apnea run.
23
+ * Refusals are tagged failures only — never ok:false ToolResults.
24
+ */
25
+ export const startWorkflow = (
26
+ params: StartParams,
27
+ root: string,
28
+ ): Effect.Effect<ToolResult, AppError, FileSystem | RunStore | Config | Vcs> =>
29
+ Effect.gen(function* () {
30
+ const store = yield* RunStore
31
+ const fs = yield* FileSystem
32
+ const config = yield* Config
33
+ const vcsSvc = yield* Vcs
34
+ const action = params.action ?? "start"
35
+
36
+ if (action === "abandon") {
37
+ const bak = yield* store.abandon(root)
38
+ return ok(`abandoned run; state moved to ${bak}`, { backup: bak })
39
+ }
40
+
41
+ const existing = yield* store.load(root)
42
+
43
+ if (action === "resume") {
44
+ if (!existing) return yield* new NoRunState({})
45
+ // Never auto-dispatch; report reconcile info
46
+ const pending = existing.pending_artifact
47
+ let pendingStatus: string = "none"
48
+ if (pending) {
49
+ const absPath = pending.startsWith("/") ? pending : `${root}/${pending}`
50
+ const present = yield* fs.exists(absPath)
51
+ pendingStatus = present ? "artifact_exists" : "artifact_missing"
52
+ }
53
+ return ok(
54
+ "resume: re-resolve panes by label; do not auto-dispatch",
55
+ {
56
+ state: existing,
57
+ pending_status: pendingStatus,
58
+ hint:
59
+ pendingStatus === "artifact_exists"
60
+ ? "call workflow_wait to ingest pending artifact"
61
+ : pendingStatus === "artifact_missing"
62
+ ? "offer re-dispatch same round via dispatch_role"
63
+ : "inspect workflow_status and continue legal next step",
64
+ },
65
+ nextAfter(existing.step),
66
+ )
67
+ }
68
+
69
+ // start
70
+ if (existing) {
71
+ return yield* new GateRefused({
72
+ gate: "start",
73
+ message: `state.json already exists (step=${existing.step}). Use action=resume or action=abandon.`,
74
+ details: { step: existing.step, slug: existing.slug },
75
+ })
76
+ }
77
+
78
+ const cfg = yield* config.load(root)
79
+
80
+ const vcs = yield* vcsSvc.detect(root)
81
+ if (!vcs) {
82
+ return yield* new VcsError({
83
+ message: "no .jj or .git — refuse auto-commit setup (init vcs first)",
84
+ })
85
+ }
86
+
87
+ const allowDirty = params.allow_dirty === true
88
+ if (!allowDirty && (yield* vcsSvc.isDirty(root, vcs))) {
89
+ return yield* new GateRefused({
90
+ gate: "clean_tree",
91
+ message:
92
+ "working tree is dirty (file content). Commit/clean first, or pass allow_dirty=true",
93
+ })
94
+ }
95
+
96
+ const slug = params.slug?.trim() || slugify(params.goal)
97
+
98
+ if (vcs === "git") {
99
+ yield* vcsSvc.ensureGitBranch(root, slug)
100
+ }
101
+
102
+ const state: RunState = {
103
+ version: 1,
104
+ slug,
105
+ step: "planning",
106
+ phase_index: 1,
107
+ phase_count_hint: null,
108
+ rounds: {},
109
+ vcs,
110
+ allow_dirty: allowDirty,
111
+ goal: params.goal,
112
+ last_error: null,
113
+ pending_artifact: null,
114
+ pending_role: null,
115
+ pending_pane_id: null,
116
+ pending_pane_label: null,
117
+ pending_floating_exit: null,
118
+ pending_started_at: null,
119
+ pending_deadline_ms: null,
120
+ pending_nudged_at: null,
121
+ pending_final_grace: false,
122
+ pending_extended: false,
123
+ role_panes: {},
124
+ package_root: packageRoot(),
125
+ reviewer_tree_fingerprint: null,
126
+ current_phase_package: null,
127
+ current_code_review: null,
128
+ phase_package_rework: false,
129
+ }
130
+ // The literal above assigns the ladder's fields; this re-asserts them
131
+ // through the shared helper so a rung added there cannot be missed here.
132
+ // The type checker only catches a missing REQUIRED field — a flag added
133
+ // with a schema default would leave a fresh run carrying a stale rung.
134
+ resetRecoveryLadder(state)
135
+ yield* store.save(state, root)
136
+
137
+ return ok(
138
+ `started run slug=${slug} vcs=${vcs} step=planning. NEXT: dispatch_role kind=plan, then workflow_wait.`,
139
+ {
140
+ state,
141
+ profiles: Object.keys(cfg.profiles),
142
+ roles: cfg.roles,
143
+ next: "dispatch_role",
144
+ next_args: { kind: "plan" },
145
+ note: "start only writes state — it does not launch roles. Orchestrator must dispatch plan immediately.",
146
+ },
147
+ nextAfter(state.step),
148
+ )
149
+ })
@@ -0,0 +1,45 @@
1
+ import { Effect, Result } from "effect"
2
+ import { LEGAL_TOOLS, nextAfter } from "../domain/state-machine.ts"
3
+ import type { StateCorrupt } from "../errors.ts"
4
+ import { ok, type ToolResult } from "../result.ts"
5
+ import { Config } from "../services/config.ts"
6
+ import { RunStore } from "../services/run-store.ts"
7
+ import { Vcs } from "../services/vcs.ts"
8
+
9
+ /**
10
+ * Read-only snapshot. Missing state is a success (`has_state: false`),
11
+ * not NoRunState. Corrupt state may fail as StateCorrupt.
12
+ */
13
+ export const statusWorkflow = (
14
+ root: string,
15
+ ): Effect.Effect<ToolResult, StateCorrupt, RunStore | Config | Vcs> =>
16
+ Effect.gen(function* () {
17
+ const store = yield* RunStore
18
+ const config = yield* Config
19
+ const vcsSvc = yield* Vcs
20
+ const state = yield* store.load(root)
21
+ if (!state) {
22
+ return ok("no active run", { has_state: false }, ["workflow_start"])
23
+ }
24
+
25
+ // Config summary never fails the tool
26
+ const cfgR = yield* Effect.result(config.load(root))
27
+ const cfgSummary: Record<string, unknown> = Result.isSuccess(cfgR)
28
+ ? {
29
+ roles: cfgR.success.roles,
30
+ review_round_cap: cfgR.success.review_round_cap,
31
+ }
32
+ : { config_error: cfgR.failure.message }
33
+
34
+ const vcs = yield* vcsSvc.detect(root)
35
+ return ok(
36
+ `step=${state.step} phase=${state.phase_index}`,
37
+ {
38
+ state,
39
+ legal_tools: LEGAL_TOOLS[state.step],
40
+ config: cfgSummary,
41
+ dirty: vcs ? yield* vcsSvc.isDirty(root, vcs) : null,
42
+ },
43
+ nextAfter(state.step),
44
+ )
45
+ })