@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,315 @@
1
+ import { Result, Schema } from "effect"
2
+ import { ConfigError } from "../errors.ts"
3
+ import {
4
+ DEFAULT_TIMEOUTS,
5
+ ROLE_MODE,
6
+ type ApneaConfig,
7
+ type PaneStyle,
8
+ type Profile,
9
+ type Role,
10
+ type RoleMode,
11
+ } from "../domain/types.ts"
12
+
13
+ const StringArray = Schema.Array(Schema.String)
14
+
15
+ const ProfileSchema = Schema.Struct({
16
+ cmd_oneshot: Schema.optional(StringArray),
17
+ cmd_interactive: Schema.optional(StringArray),
18
+ })
19
+
20
+ const RoleBindingSchema = Schema.Struct({
21
+ profile: Schema.String.check(Schema.isMinLength(1)),
22
+ })
23
+
24
+ export const PaneStyleSchema = Schema.Literals(["regular", "floating"] as const)
25
+
26
+ /**
27
+ * Mirrors `schemas/config.schema.json` top-level keys.
28
+ *
29
+ * `review_round_cap` / `timeouts_ms` carry no range `check`: an out-of-range
30
+ * number must fall back to the default (see below), not fail the decode. A
31
+ * hard failure here bricks every tool until the file is hand-edited.
32
+ */
33
+ export const GlobalConfigSchema = Schema.Struct({
34
+ profiles: Schema.optional(Schema.Record(Schema.String, ProfileSchema)),
35
+ roles: Schema.optional(Schema.Record(Schema.String, RoleBindingSchema)),
36
+ review_round_cap: Schema.optional(Schema.Number),
37
+ timeouts_ms: Schema.optional(Schema.Record(Schema.String, Schema.Number)),
38
+ pane_style: Schema.optional(PaneStyleSchema),
39
+ })
40
+
41
+ const PROJECT_KNOWN = new Set([
42
+ "roles",
43
+ "review_round_cap",
44
+ "timeouts_ms",
45
+ "isolation",
46
+ "pane_style",
47
+ ])
48
+
49
+ const PROJECT_FORBIDDEN = new Set([
50
+ "cmd",
51
+ "cmd_oneshot",
52
+ "cmd_interactive",
53
+ "bin",
54
+ "profiles",
55
+ ])
56
+
57
+ /**
58
+ * Project overlay — unknown keys and profile-owned keys fail decode.
59
+ * Range-tolerant for the same reason as `GlobalConfigSchema`.
60
+ */
61
+ export const ProjectConfigSchema = Schema.Struct({
62
+ roles: Schema.optional(Schema.Record(Schema.String, RoleBindingSchema)),
63
+ review_round_cap: Schema.optional(Schema.Number),
64
+ timeouts_ms: Schema.optional(Schema.Record(Schema.String, Schema.Number)),
65
+ isolation: Schema.optional(Schema.Literal("shared_cwd")),
66
+ pane_style: Schema.optional(PaneStyleSchema),
67
+ })
68
+
69
+ function configFail(
70
+ message: string,
71
+ path?: string,
72
+ ): Result.Result<never, ConfigError> {
73
+ return Result.fail(
74
+ path !== undefined
75
+ ? new ConfigError({ message, path })
76
+ : new ConfigError({ message }),
77
+ )
78
+ }
79
+
80
+ function asObject(
81
+ v: unknown,
82
+ label: string,
83
+ ): Result.Result<Record<string, unknown>, ConfigError> {
84
+ if (!v || typeof v !== "object" || Array.isArray(v)) {
85
+ return configFail(`${label} must be a JSON object`)
86
+ }
87
+ return Result.succeed(v as Record<string, unknown>)
88
+ }
89
+
90
+ /** Decode + apply parseGlobal defaults into ApneaConfig. */
91
+ export function decodeGlobalConfig(
92
+ raw: unknown,
93
+ ): Result.Result<ApneaConfig, ConfigError> {
94
+ const objR = asObject(raw, "global config")
95
+ if (Result.isFailure(objR)) return configFail(objR.failure.message)
96
+
97
+ const obj = objR.success
98
+
99
+ if (
100
+ "isolation" in obj &&
101
+ obj.isolation !== undefined &&
102
+ obj.isolation !== "shared_cwd"
103
+ ) {
104
+ return configFail(
105
+ `unimplemented config value isolation=${JSON.stringify(obj.isolation)} (v1 only supports shared_cwd or omit)`,
106
+ )
107
+ }
108
+
109
+ // Reject cmd-like keys nested under roles before struct decode.
110
+ if (obj.roles && typeof obj.roles === "object" && !Array.isArray(obj.roles)) {
111
+ for (const [k, v] of Object.entries(obj.roles as Record<string, unknown>)) {
112
+ if (!v || typeof v !== "object" || Array.isArray(v)) continue
113
+ const r = v as Record<string, unknown>
114
+ for (const bad of PROJECT_FORBIDDEN) {
115
+ if (bad !== "profiles" && bad in r) {
116
+ return configFail(`roles.${k} must not include ${bad}; use profiles`)
117
+ }
118
+ }
119
+ }
120
+ }
121
+
122
+ const decoded = Schema.decodeUnknownResult(GlobalConfigSchema)(obj)
123
+ if (Result.isFailure(decoded)) {
124
+ return configFail(decoded.failure.message)
125
+ }
126
+
127
+ const d = decoded.success
128
+ const profiles: Record<string, Profile> = {}
129
+ for (const [name, p] of Object.entries(d.profiles ?? {})) {
130
+ const out: Profile = {}
131
+ if (p.cmd_oneshot) out.cmd_oneshot = [...p.cmd_oneshot]
132
+ if (p.cmd_interactive) out.cmd_interactive = [...p.cmd_interactive]
133
+ if (!out.cmd_oneshot?.length && !out.cmd_interactive?.length) {
134
+ return configFail(
135
+ `profile ${name} needs cmd_oneshot and/or cmd_interactive`,
136
+ )
137
+ }
138
+ profiles[name] = out
139
+ }
140
+
141
+ const roles: Record<string, { profile: string }> = {}
142
+ for (const [k, v] of Object.entries(d.roles ?? {})) {
143
+ roles[k] = { profile: v.profile }
144
+ }
145
+
146
+ const timeouts = { ...DEFAULT_TIMEOUTS }
147
+ if (d.timeouts_ms) {
148
+ for (const [k, v] of Object.entries(d.timeouts_ms)) {
149
+ if (typeof v === "number" && v >= 1000) timeouts[k] = v
150
+ }
151
+ }
152
+
153
+ const pane_style: PaneStyle =
154
+ d.pane_style === "regular" || d.pane_style === "floating"
155
+ ? d.pane_style
156
+ : "regular"
157
+
158
+ return Result.succeed({
159
+ profiles,
160
+ roles,
161
+ review_round_cap:
162
+ typeof d.review_round_cap === "number" && d.review_round_cap >= 1
163
+ ? d.review_round_cap
164
+ : 3,
165
+ timeouts_ms: timeouts,
166
+ pane_style,
167
+ })
168
+ }
169
+
170
+ /** Validate project overlay (unknown keys fail). Does not merge. */
171
+ export function decodeProjectConfig(
172
+ raw: unknown,
173
+ ): Result.Result<typeof ProjectConfigSchema.Type, ConfigError> {
174
+ if (raw == null) {
175
+ return Result.succeed({})
176
+ }
177
+ const objR = asObject(raw, "project config")
178
+ if (Result.isFailure(objR)) return configFail(objR.failure.message)
179
+
180
+ const obj = objR.success
181
+
182
+ for (const key of Object.keys(obj)) {
183
+ if (PROJECT_FORBIDDEN.has(key)) {
184
+ return configFail(
185
+ `project config must not set ${key} (binaries/profiles only allowed in global config)`,
186
+ )
187
+ }
188
+ if (!PROJECT_KNOWN.has(key)) {
189
+ return configFail(`unknown project config key: ${key}`)
190
+ }
191
+ if (
192
+ key === "isolation" &&
193
+ obj.isolation !== "shared_cwd" &&
194
+ obj.isolation !== undefined
195
+ ) {
196
+ return configFail(
197
+ `unimplemented isolation=${JSON.stringify(obj.isolation)}`,
198
+ )
199
+ }
200
+ }
201
+
202
+ // roles must not carry profile-owned keys
203
+ if (obj.roles && typeof obj.roles === "object" && !Array.isArray(obj.roles)) {
204
+ for (const [k, v] of Object.entries(obj.roles as Record<string, unknown>)) {
205
+ if (!v || typeof v !== "object" || Array.isArray(v)) {
206
+ return configFail(`project roles.${k} must be a JSON object`)
207
+ }
208
+ const r = v as Record<string, unknown>
209
+ for (const bad of ["cmd", "cmd_oneshot", "cmd_interactive", "bin"]) {
210
+ if (bad in r) {
211
+ return configFail(`project roles.${k} must not set ${bad}`)
212
+ }
213
+ }
214
+ }
215
+ }
216
+
217
+ const decoded = Schema.decodeUnknownResult(ProjectConfigSchema)(obj, {
218
+ onExcessProperty: "error",
219
+ })
220
+ if (Result.isFailure(decoded)) {
221
+ return configFail(decoded.failure.message)
222
+ }
223
+ return Result.succeed(decoded.success)
224
+ }
225
+
226
+ /**
227
+ * Merge a validated project overlay onto a base config.
228
+ * Profiles always stay from the base (global-only).
229
+ */
230
+ export function applyProjectConfig(
231
+ cfg: ApneaConfig,
232
+ overlay: typeof ProjectConfigSchema.Type,
233
+ ): ApneaConfig {
234
+ const roles = { ...cfg.roles }
235
+ if (overlay.roles) {
236
+ for (const [k, v] of Object.entries(overlay.roles)) {
237
+ roles[k] = { profile: v.profile }
238
+ }
239
+ }
240
+ const timeouts = { ...cfg.timeouts_ms }
241
+ if (overlay.timeouts_ms) {
242
+ for (const [k, v] of Object.entries(overlay.timeouts_ms)) {
243
+ if (typeof v === "number" && v >= 1000) timeouts[k] = v
244
+ }
245
+ }
246
+ return {
247
+ profiles: cfg.profiles,
248
+ roles,
249
+ review_round_cap:
250
+ overlay.review_round_cap !== undefined && overlay.review_round_cap >= 1
251
+ ? overlay.review_round_cap
252
+ : cfg.review_round_cap,
253
+ timeouts_ms: timeouts,
254
+ pane_style:
255
+ overlay.pane_style !== undefined ? overlay.pane_style : cfg.pane_style,
256
+ }
257
+ }
258
+
259
+ /**
260
+ * Resolve role → profile → `cmd_<mode>`. Shared by `ConfigLive` and the test
261
+ * fake so tests exercise the real resolution instead of a copy of it.
262
+ *
263
+ * Deliberately distinct from `validateRoleBindings` below: this is the runtime
264
+ * lookup, that one is the setup-time audit and phrases the same failures as
265
+ * actionable config guidance.
266
+ */
267
+ export function resolveRoleCmdResult(
268
+ cfg: ApneaConfig,
269
+ role: Role,
270
+ mode: RoleMode = ROLE_MODE[role],
271
+ ): Result.Result<string[], ConfigError> {
272
+ const binding = cfg.roles[role]
273
+ if (!binding) {
274
+ return configFail(`no role binding for ${role}`)
275
+ }
276
+ const profile = cfg.profiles[binding.profile]
277
+ if (!profile) {
278
+ return configFail(`unknown profile ${binding.profile}`)
279
+ }
280
+ const cmd = mode === "oneshot" ? profile.cmd_oneshot : profile.cmd_interactive
281
+ if (!cmd?.length) {
282
+ return configFail(`profile ${binding.profile} has no cmd_${mode}`)
283
+ }
284
+ return Result.succeed([...cmd])
285
+ }
286
+
287
+ /**
288
+ * Validate planner/reviewer/coder role→profile bindings and required cmds.
289
+ * Success returns cfg unchanged.
290
+ */
291
+ export function validateRoleBindings(
292
+ cfg: ApneaConfig,
293
+ ): Result.Result<ApneaConfig, ConfigError> {
294
+ for (const role of ["planner", "reviewer", "coder"] as Role[]) {
295
+ const binding = cfg.roles[role]
296
+ if (!binding) {
297
+ return configFail(`config missing roles.${role}`)
298
+ }
299
+ const profile = cfg.profiles[binding.profile]
300
+ if (!profile) {
301
+ return configFail(
302
+ `roles.${role} profile "${binding.profile}" not defined in global profiles`,
303
+ )
304
+ }
305
+ const mode = ROLE_MODE[role]
306
+ const cmd =
307
+ mode === "oneshot" ? profile.cmd_oneshot : profile.cmd_interactive
308
+ if (!cmd?.length) {
309
+ return configFail(
310
+ `profile "${binding.profile}" missing cmd_${mode} required by role ${role}`,
311
+ )
312
+ }
313
+ }
314
+ return Result.succeed(cfg)
315
+ }
@@ -0,0 +1,34 @@
1
+ import { Result, Schema } from "effect"
2
+ import { ArtifactInvalid } from "../errors.ts"
3
+
4
+ const VerdictSchema = Schema.Literals(["APPROVED", "CHANGES_REQUIRED"] as const)
5
+ const ReworkTargetSchema = Schema.Literals(["code", "phase_package"] as const)
6
+
7
+ /**
8
+ * Schema for the parsed front-matter result shape (after the line parser).
9
+ * Consumed by wait in Phase 4.
10
+ */
11
+ export const FrontMatterResultSchema = Schema.Struct({
12
+ status: Schema.String,
13
+ verdict: Schema.optional(VerdictSchema),
14
+ nits: Schema.optional(Schema.String),
15
+ rework: Schema.optional(ReworkTargetSchema),
16
+ })
17
+
18
+ export type FrontMatterResult = typeof FrontMatterResultSchema.Type
19
+
20
+ export function decodeFrontMatterResult(
21
+ raw: unknown,
22
+ artifact = "artifact",
23
+ ): Result.Result<FrontMatterResult, ArtifactInvalid> {
24
+ const decoded = Schema.decodeUnknownResult(FrontMatterResultSchema)(raw)
25
+ if (Result.isFailure(decoded)) {
26
+ return Result.fail(
27
+ new ArtifactInvalid({
28
+ artifact,
29
+ message: decoded.failure.message,
30
+ }),
31
+ )
32
+ }
33
+ return Result.succeed(decoded.success)
34
+ }
@@ -0,0 +1,119 @@
1
+ import { Result, Schema } from "effect"
2
+ import { StateCorrupt } from "../errors.ts"
3
+ import type { RunState, Step } from "../domain/types.ts"
4
+
5
+ export const StepSchema = Schema.Literals([
6
+ "planning",
7
+ "plan_review",
8
+ "phase_packaging",
9
+ "coding",
10
+ "code_review",
11
+ "committing",
12
+ "finishing",
13
+ "done",
14
+ ] as const)
15
+
16
+ export const VcsBackendSchema = Schema.Literals(["jj", "git"] as const)
17
+
18
+ export const RoleSchema = Schema.Literals([
19
+ "orchestrator",
20
+ "planner",
21
+ "reviewer",
22
+ "coder",
23
+ ] as const)
24
+
25
+ const PaneRefSchema = Schema.Struct({
26
+ pane_id: Schema.String,
27
+ label: Schema.String,
28
+ profile_fingerprint: Schema.optionalKey(Schema.NullOr(Schema.String)),
29
+ })
30
+
31
+ /**
32
+ * Runtime codec for `state.json` (version 1).
33
+ * Missing pane-tracking fields are filled in `decodeRunState` (legacy files).
34
+ */
35
+ export const RunStateSchema = Schema.Struct({
36
+ version: Schema.Literal(1),
37
+ slug: Schema.String.check(Schema.isMinLength(1)),
38
+ step: StepSchema,
39
+ phase_index: Schema.Number,
40
+ phase_count_hint: Schema.NullOr(Schema.Number),
41
+ rounds: Schema.Record(Schema.String, Schema.Number),
42
+ vcs: VcsBackendSchema,
43
+ allow_dirty: Schema.Boolean,
44
+ goal: Schema.String,
45
+ last_error: Schema.NullOr(Schema.String),
46
+ pending_artifact: Schema.NullOr(Schema.String),
47
+ pending_role: Schema.NullOr(RoleSchema),
48
+ // optional on Encoded so legacy fixtures without pane fields still decode
49
+ pending_pane_id: Schema.optionalKey(Schema.NullOr(Schema.String)),
50
+ pending_pane_label: Schema.optionalKey(Schema.NullOr(Schema.String)),
51
+ /** Mirrored in `schemas/state.schema.json`; drift is caught by schema.test.ts. */
52
+ pending_floating_exit: Schema.optionalKey(Schema.NullOr(Schema.String)),
53
+ pending_started_at: Schema.optionalKey(Schema.NullOr(Schema.Number)),
54
+ pending_deadline_ms: Schema.optionalKey(Schema.NullOr(Schema.Number)),
55
+ pending_nudged_at: Schema.optionalKey(Schema.NullOr(Schema.Number)),
56
+ pending_final_grace: Schema.optionalKey(Schema.Boolean),
57
+ pending_extended: Schema.optionalKey(Schema.Boolean),
58
+ role_panes: Schema.optionalKey(Schema.Record(Schema.String, PaneRefSchema)),
59
+ package_root: Schema.String,
60
+ reviewer_tree_fingerprint: Schema.NullOr(Schema.String),
61
+ current_phase_package: Schema.NullOr(Schema.String),
62
+ current_code_review: Schema.NullOr(Schema.String),
63
+ phase_package_rework: Schema.optionalKey(Schema.Boolean),
64
+ })
65
+
66
+ export type DecodedRunState = typeof RunStateSchema.Type
67
+
68
+ export function decodeRunState(
69
+ json: unknown,
70
+ path = "state.json",
71
+ ): Result.Result<RunState, StateCorrupt> {
72
+ const decoded = Schema.decodeUnknownResult(RunStateSchema)(json)
73
+ if (Result.isFailure(decoded)) {
74
+ return Result.fail(
75
+ new StateCorrupt({
76
+ path,
77
+ message: decoded.failure.message,
78
+ }),
79
+ )
80
+ }
81
+ const d = decoded.success
82
+ // Backward-compat defaults for state.json files predating pane tracking.
83
+ const state: RunState = {
84
+ version: 1,
85
+ slug: d.slug,
86
+ step: d.step as Step,
87
+ phase_index: d.phase_index,
88
+ phase_count_hint: d.phase_count_hint,
89
+ rounds: { ...d.rounds },
90
+ vcs: d.vcs,
91
+ allow_dirty: d.allow_dirty,
92
+ goal: d.goal,
93
+ last_error: d.last_error,
94
+ pending_artifact: d.pending_artifact,
95
+ pending_role: d.pending_role,
96
+ pending_pane_id: d.pending_pane_id ?? null,
97
+ pending_pane_label: d.pending_pane_label ?? null,
98
+ pending_floating_exit: d.pending_floating_exit ?? null,
99
+ pending_started_at: d.pending_started_at ?? null,
100
+ pending_deadline_ms: d.pending_deadline_ms ?? null,
101
+ pending_nudged_at: d.pending_nudged_at ?? null,
102
+ pending_final_grace: d.pending_final_grace ?? false,
103
+ pending_extended: d.pending_extended ?? false,
104
+ role_panes: Object.fromEntries(
105
+ Object.entries(d.role_panes ?? {}).map(([role, pane]) => [
106
+ role,
107
+ { ...pane, profile_fingerprint: pane.profile_fingerprint ?? null },
108
+ ]),
109
+ ),
110
+ package_root: d.package_root,
111
+ reviewer_tree_fingerprint: d.reviewer_tree_fingerprint,
112
+ current_phase_package: d.current_phase_package,
113
+ current_code_review: d.current_code_review,
114
+ phase_package_rework: d.phase_package_rework ?? false,
115
+ }
116
+ return Result.succeed(state)
117
+ }
118
+
119
+ export type { Step }
@@ -0,0 +1,24 @@
1
+ import { Layer } from "effect"
2
+ import { neutralHostAdapter, type ApneaHostAdapter } from "../host-adapter.ts"
3
+ import { ConfigLive } from "./config.ts"
4
+ import { FileSystemLive } from "./file-system.ts"
5
+ import { makeHerdrLive } from "./herdr.ts"
6
+ import { RunStoreLive } from "./run-store.ts"
7
+ import { VcsLive } from "./vcs.ts"
8
+
9
+ /**
10
+ * Blueprint layer for tool calls. Built freshly on every `Effect.provide`
11
+ * inside runTool — no module-level ManagedRuntime.
12
+ */
13
+ export const makeAppLive = (hostAdapter: ApneaHostAdapter) =>
14
+ Layer.provideMerge(
15
+ Layer.mergeAll(
16
+ RunStoreLive,
17
+ ConfigLive,
18
+ VcsLive,
19
+ makeHerdrLive(hostAdapter),
20
+ ),
21
+ FileSystemLive,
22
+ )
23
+
24
+ export const AppLive = makeAppLive(neutralHostAdapter)
@@ -0,0 +1,103 @@
1
+ import { Context, Effect, Layer, Result } from "effect"
2
+ import { globalConfigPath, projectConfigPath } from "../domain/paths.ts"
3
+ import { ConfigError } from "../errors.ts"
4
+ import {
5
+ ROLE_MODE,
6
+ type ApneaConfig,
7
+ type Role,
8
+ type RoleMode,
9
+ } from "../domain/types.ts"
10
+ import {
11
+ applyProjectConfig,
12
+ decodeGlobalConfig,
13
+ decodeProjectConfig,
14
+ resolveRoleCmdResult,
15
+ validateRoleBindings,
16
+ } from "../schema/config.ts"
17
+ import { FileSystem } from "./file-system.ts"
18
+
19
+ export interface ConfigService {
20
+ readonly load: (root: string) => Effect.Effect<ApneaConfig, ConfigError>
21
+ readonly resolveRoleCmd: (
22
+ cfg: ApneaConfig,
23
+ role: Role,
24
+ mode?: RoleMode,
25
+ ) => Effect.Effect<string[], ConfigError>
26
+ }
27
+
28
+ export class Config extends Context.Service<Config, ConfigService>()(
29
+ "apnea/Config",
30
+ ) {}
31
+
32
+ function parseJson(
33
+ text: string,
34
+ filePath: string,
35
+ ): Effect.Effect<unknown, ConfigError> {
36
+ return Effect.try({
37
+ try: () => JSON.parse(text) as unknown,
38
+ catch: (e) =>
39
+ new ConfigError({
40
+ message: `invalid JSON at ${filePath}: ${e instanceof Error ? e.message : String(e)}`,
41
+ path: filePath,
42
+ }),
43
+ })
44
+ }
45
+
46
+ export const ConfigLive = Layer.effect(
47
+ Config,
48
+ Effect.gen(function* () {
49
+ const fs = yield* FileSystem
50
+
51
+ const load = (root: string): Effect.Effect<ApneaConfig, ConfigError> =>
52
+ Effect.gen(function* () {
53
+ const gPath = globalConfigPath()
54
+ const gPresent = yield* fs.exists(gPath)
55
+ if (!gPresent) {
56
+ return yield* new ConfigError({
57
+ message: `missing global config at ${gPath}. Run apnea-setup / create profiles there.`,
58
+ path: gPath,
59
+ })
60
+ }
61
+ const gText = yield* fs.readFile(gPath)
62
+ const gRaw = yield* parseJson(gText, gPath)
63
+ const gDecoded = decodeGlobalConfig(gRaw)
64
+ if (Result.isFailure(gDecoded)) {
65
+ return yield* gDecoded.failure
66
+ }
67
+ let cfg = gDecoded.success
68
+
69
+ const pPath = projectConfigPath(root)
70
+ const pPresent = yield* fs.exists(pPath)
71
+ if (pPresent) {
72
+ const pText = yield* fs.readFile(pPath)
73
+ const pRaw = yield* parseJson(pText, pPath)
74
+ const pDecoded = decodeProjectConfig(pRaw)
75
+ if (Result.isFailure(pDecoded)) {
76
+ return yield* pDecoded.failure
77
+ }
78
+ cfg = applyProjectConfig(cfg, pDecoded.success)
79
+ }
80
+
81
+ const validated = validateRoleBindings(cfg)
82
+ if (Result.isFailure(validated)) {
83
+ return yield* validated.failure
84
+ }
85
+ return validated.success
86
+ })
87
+
88
+ const resolveRoleCmd = (
89
+ cfg: ApneaConfig,
90
+ role: Role,
91
+ mode: RoleMode = ROLE_MODE[role],
92
+ ): Effect.Effect<string[], ConfigError> =>
93
+ Effect.gen(function* () {
94
+ const resolved = resolveRoleCmdResult(cfg, role, mode)
95
+ if (Result.isFailure(resolved)) {
96
+ return yield* resolved.failure
97
+ }
98
+ return resolved.success
99
+ })
100
+
101
+ return Config.of({ load, resolveRoleCmd })
102
+ }),
103
+ )