@naxodev/apnea 0.1.0 → 0.2.1

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 (51) hide show
  1. package/README.md +25 -10
  2. package/SECURITY.md +32 -0
  3. package/briefs/orchestrator.md +4 -3
  4. package/dist/cli.js +8571 -15324
  5. package/docs/adr/0005-harness-profiles.md +1 -1
  6. package/docs/adr/0010-package-split.md +1 -1
  7. package/docs/protocol/artifacts.md +18 -2
  8. package/docs/protocol/config.md +22 -25
  9. package/docs/protocol/manual-gate.md +8 -8
  10. package/docs/protocol/overview.md +18 -5
  11. package/extension/adapters/commit.ts +5 -1
  12. package/extension/adapters/dispatch.ts +9 -1
  13. package/extension/adapters/setup.ts +15 -1
  14. package/extension/adapters/start.ts +5 -1
  15. package/extension/adapters/status.ts +17 -2
  16. package/extension/adapters/wait.ts +6 -1
  17. package/extension/api.ts +7 -1
  18. package/extension/cli/main.ts +67 -7
  19. package/extension/cli/parse.ts +172 -5
  20. package/extension/domain/herdr.ts +0 -86
  21. package/extension/domain/paths.ts +3 -13
  22. package/extension/domain/setup.ts +0 -20
  23. package/extension/domain/timeouts.ts +4 -0
  24. package/extension/domain/types.ts +64 -11
  25. package/extension/domain/verify-commands.ts +200 -108
  26. package/extension/errors.ts +51 -16
  27. package/extension/operation-hooks.ts +6 -0
  28. package/extension/registry.ts +29 -15
  29. package/extension/run-tool.ts +19 -2
  30. package/extension/schema/config.ts +86 -30
  31. package/extension/schema/frontmatter.ts +57 -0
  32. package/extension/schema/state.ts +226 -16
  33. package/extension/services/app-live.ts +2 -1
  34. package/extension/services/config.ts +6 -4
  35. package/extension/services/file-system.ts +346 -75
  36. package/extension/services/herdr.ts +393 -402
  37. package/extension/services/operation-lock.ts +418 -0
  38. package/extension/services/process.ts +477 -0
  39. package/extension/services/run-store.ts +38 -16
  40. package/extension/services/vcs.ts +1388 -86
  41. package/extension/workflows/commit.ts +222 -18
  42. package/extension/workflows/dispatch.ts +320 -220
  43. package/extension/workflows/setup.ts +61 -141
  44. package/extension/workflows/start.ts +6 -5
  45. package/extension/workflows/status.ts +2 -2
  46. package/extension/workflows/wait.ts +63 -134
  47. package/package.json +2 -3
  48. package/schemas/config.schema.json +11 -7
  49. package/schemas/state.schema.json +170 -12
  50. package/herdr-plugin/herdr-plugin.toml +0 -15
  51. package/herdr-plugin/scripts/run-task.sh +0 -8
@@ -1,11 +1,7 @@
1
1
  import * as path from "node:path"
2
+ import * as os from "node:os"
2
3
  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"
4
+ import { globalConfigPath, projectConfigPath } from "../domain/paths.ts"
9
5
  import {
10
6
  buildGlobalConfig,
11
7
  detectionNotes,
@@ -14,9 +10,8 @@ import {
14
10
  } from "../domain/setup.ts"
15
11
  import { ConfigError, type AppError } from "../errors.ts"
16
12
  import { ok, type ToolResult } from "../result.ts"
17
- import { decodeGlobalConfig } from "../schema/config.ts"
13
+ import { decodeGlobalConfig, decodeProjectConfig } from "../schema/config.ts"
18
14
  import { FileSystem } from "../services/file-system.ts"
19
- import { Herdr } from "../services/herdr.ts"
20
15
 
21
16
  export type SetupParams = {
22
17
  /** Write .apnea/config.json role bindings in cwd */
@@ -76,83 +71,10 @@ export type SetupDeps = {
76
71
  onPath: (bin: string) => boolean
77
72
  /** Prepare host-specific role resources, or return null when none are needed. */
78
73
  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[]
74
+ /** Trusted account home. Production uses node:os.homedir(). */
75
+ trustedHome?: () => string
86
76
  }
87
77
 
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
78
  /**
157
79
  * Deterministic Apnea setup: detect binaries, write global profiles
158
80
  * (and optional project role bindings). Never writes cmd into project config.
@@ -161,28 +83,9 @@ export const setupWorkflow = (
161
83
  params: SetupParams,
162
84
  root: string,
163
85
  deps: SetupDeps,
164
- ): Effect.Effect<ToolResult, AppError, FileSystem | Herdr> =>
86
+ ): Effect.Effect<ToolResult, AppError, FileSystem> =>
165
87
  Effect.gen(function* () {
166
88
  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
89
 
187
90
  const has: Detected = {
188
91
  pi: deps.onPath("pi"),
@@ -200,27 +103,64 @@ export const setupWorkflow = (
200
103
  })
201
104
  }
202
105
 
203
- const gPath = globalConfigPath()
204
- yield* fs.mkdir(path.dirname(gPath), { recursive: true })
106
+ const trustedHome = deps.trustedHome?.() ?? os.homedir()
107
+ const gPath = globalConfigPath(trustedHome)
205
108
 
206
- const prev = yield* readJsonSafe(gPath)
207
109
  const force = params.force === true
110
+ let replacedMalformedGlobal = false
111
+ let prev: Record<string, unknown> = {}
112
+ if (yield* fs.exists(gPath)) {
113
+ const text = yield* fs.readTrustedGlobalFile(trustedHome, gPath)
114
+ try {
115
+ const parsed: unknown = JSON.parse(text)
116
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
117
+ throw new Error("global config must be a JSON object")
118
+ }
119
+ prev = parsed as Record<string, unknown>
120
+ } catch (error) {
121
+ if (!force) {
122
+ return yield* new ConfigError({
123
+ message: `existing global config is malformed; refusing to replace it without --force: ${error instanceof Error ? error.message : String(error)}`,
124
+ path: gPath,
125
+ })
126
+ }
127
+ replacedMalformedGlobal = true
128
+ }
129
+ }
208
130
  const globalConfig = buildGlobalConfig({ has, prev, force })
209
131
 
210
132
  const serialized = `${JSON.stringify(globalConfig, null, 2)}\n`
211
- yield* fs.writeFile(gPath, serialized)
212
133
 
213
134
  let projectPath: string | null = null
214
135
  if (params.project) {
215
136
  const pPath = projectConfigPath(root)
137
+ if (yield* fs.projectPathExists(root, pPath)) {
138
+ const text = yield* fs.readProjectFile(root, pPath)
139
+ let parsed: unknown
140
+ try {
141
+ parsed = JSON.parse(text)
142
+ } catch (error) {
143
+ return yield* new ConfigError({
144
+ message: `invalid JSON at ${pPath}: ${error instanceof Error ? error.message : String(error)}`,
145
+ path: pPath,
146
+ })
147
+ }
148
+ const decoded = decodeProjectConfig(parsed)
149
+ if (Result.isFailure(decoded)) return yield* decoded.failure
150
+ }
151
+ projectPath = pPath
152
+ }
153
+
154
+ yield* fs.writeTrustedGlobalFile(trustedHome, gPath, serialized)
155
+
156
+ if (projectPath) {
216
157
  // project: roles only — never cmd
217
158
  const projectCfg = { roles: pickRoles(has) }
218
159
  yield* fs.writeProjectFile(
219
160
  root,
220
- pPath,
161
+ projectPath,
221
162
  `${JSON.stringify(projectCfg, null, 2)}\n`,
222
163
  )
223
- projectPath = pPath
224
164
  }
225
165
 
226
166
  const missing = detectionNotes(has)
@@ -241,32 +181,17 @@ export const setupWorkflow = (
241
181
  )
242
182
  }
243
183
 
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
184
  let agentsMdPath: string | null = null
259
185
  if (params.agents_md) {
260
186
  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))
187
+ const present = yield* fs.projectPathExists(root, target)
188
+ const existing = present ? yield* fs.readProjectFile(root, target) : null
189
+ yield* fs.writeProjectFile(root, target, mergeAgentsMd(existing))
264
190
  agentsMdPath = target
265
191
  }
266
192
 
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.
193
+ // Generated defaults can still inherit malformed profile or role shapes
194
+ // from valid JSON. Report those separately from JSON parse refusals.
270
195
  const decoded = decodeGlobalConfig(globalConfig)
271
196
  if (Result.isFailure(decoded)) {
272
197
  missing.push(
@@ -286,16 +211,11 @@ export const setupWorkflow = (
286
211
  if (params.agents_md) {
287
212
  data.agents_md = agentsMdPath
288
213
  }
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)
214
+ if (replacedMalformedGlobal) data.replaced_malformed_global = true
215
+ return ok(
216
+ replacedMalformedGlobal
217
+ ? `replaced malformed global config ${gPath}`
218
+ : `wrote global config ${gPath}`,
219
+ data,
220
+ )
301
221
  })
@@ -47,7 +47,7 @@ export const startWorkflow = (
47
47
  let pendingStatus: string = "none"
48
48
  if (pending) {
49
49
  const absPath = pending.startsWith("/") ? pending : `${root}/${pending}`
50
- const present = yield* fs.exists(absPath)
50
+ const present = yield* fs.projectPathExists(root, absPath)
51
51
  pendingStatus = present ? "artifact_exists" : "artifact_missing"
52
52
  }
53
53
  return ok(
@@ -59,7 +59,7 @@ export const startWorkflow = (
59
59
  pendingStatus === "artifact_exists"
60
60
  ? "call workflow_wait to ingest pending artifact"
61
61
  : pendingStatus === "artifact_missing"
62
- ? "offer re-dispatch same round via dispatch_role"
62
+ ? "offer same-round dispatch_role redeliver=true after proving the prior delivery is dead"
63
63
  : "inspect workflow_status and continue legal next step",
64
64
  },
65
65
  nextAfter(existing.step),
@@ -100,7 +100,7 @@ export const startWorkflow = (
100
100
  }
101
101
 
102
102
  const state: RunState = {
103
- version: 1,
103
+ version: 2,
104
104
  slug,
105
105
  step: "planning",
106
106
  phase_index: 1,
@@ -112,9 +112,9 @@ export const startWorkflow = (
112
112
  last_error: null,
113
113
  pending_artifact: null,
114
114
  pending_role: null,
115
+ pending_delivery: null,
115
116
  pending_pane_id: null,
116
117
  pending_pane_label: null,
117
- pending_floating_exit: null,
118
118
  pending_started_at: null,
119
119
  pending_deadline_ms: null,
120
120
  pending_nudged_at: null,
@@ -125,7 +125,8 @@ export const startWorkflow = (
125
125
  reviewer_tree_fingerprint: null,
126
126
  current_phase_package: null,
127
127
  current_code_review: null,
128
- phase_package_rework: false,
128
+ required_rework: null,
129
+ pending_commit: null,
129
130
  }
130
131
  // The literal above assigns the ladder's fields; this re-asserts them
131
132
  // through the shared helper so a rung added there cannot be missed here.
@@ -1,6 +1,6 @@
1
1
  import { Effect, Result } from "effect"
2
2
  import { LEGAL_TOOLS, nextAfter } from "../domain/state-machine.ts"
3
- import type { StateCorrupt } from "../errors.ts"
3
+ import type { AppError } from "../errors.ts"
4
4
  import { ok, type ToolResult } from "../result.ts"
5
5
  import { Config } from "../services/config.ts"
6
6
  import { RunStore } from "../services/run-store.ts"
@@ -12,7 +12,7 @@ import { Vcs } from "../services/vcs.ts"
12
12
  */
13
13
  export const statusWorkflow = (
14
14
  root: string,
15
- ): Effect.Effect<ToolResult, StateCorrupt, RunStore | Config | Vcs> =>
15
+ ): Effect.Effect<ToolResult, AppError, RunStore | Config | Vcs> =>
16
16
  Effect.gen(function* () {
17
17
  const store = yield* RunStore
18
18
  const config = yield* Config