@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,99 @@
1
+ import * as path from "node:path"
2
+ import { Clock, Context, Effect, Layer, Result } from "effect"
3
+ import {
4
+ apneaRoot,
5
+ artifactsDir,
6
+ statePath,
7
+ tasksDir,
8
+ } from "../domain/paths.ts"
9
+ import { NoRunState, StateCorrupt } from "../errors.ts"
10
+ import type { RunState } from "../domain/types.ts"
11
+ import { decodeRunState } from "../schema/state.ts"
12
+ import { FileSystem, type FileSystemService } from "./file-system.ts"
13
+
14
+ export interface RunStoreService {
15
+ readonly load: (root: string) => Effect.Effect<RunState | null, StateCorrupt>
16
+ readonly save: (state: RunState, root: string) => Effect.Effect<void>
17
+ readonly require: (
18
+ root: string,
19
+ ) => Effect.Effect<RunState, NoRunState | StateCorrupt>
20
+ readonly abandon: (root: string) => Effect.Effect<string, NoRunState>
21
+ }
22
+
23
+ export class RunStore extends Context.Service<RunStore, RunStoreService>()(
24
+ "apnea/RunStore",
25
+ ) {}
26
+
27
+ function ensureApneaDirs(
28
+ fs: FileSystemService,
29
+ root: string,
30
+ ): Effect.Effect<void> {
31
+ const dirs = [
32
+ apneaRoot(root),
33
+ artifactsDir(root),
34
+ tasksDir(root),
35
+ path.join(artifactsDir(root), "plan-review"),
36
+ ]
37
+ return Effect.forEach(dirs, (d) => fs.mkdir(d, { recursive: true }), {
38
+ discard: true,
39
+ })
40
+ }
41
+
42
+ export const RunStoreLive = Layer.effect(
43
+ RunStore,
44
+ Effect.gen(function* () {
45
+ const fs = yield* FileSystem
46
+
47
+ const load = (root: string): Effect.Effect<RunState | null, StateCorrupt> =>
48
+ Effect.gen(function* () {
49
+ const p = statePath(root)
50
+ const present = yield* fs.exists(p)
51
+ if (!present) return null
52
+ const text = yield* fs.readFile(p)
53
+ let json: unknown
54
+ try {
55
+ json = JSON.parse(text)
56
+ } catch (e) {
57
+ return yield* new StateCorrupt({
58
+ path: p,
59
+ message: e instanceof Error ? e.message : String(e),
60
+ })
61
+ }
62
+ const decoded = decodeRunState(json, p)
63
+ if (Result.isFailure(decoded)) {
64
+ return yield* decoded.failure
65
+ }
66
+ return decoded.success
67
+ })
68
+
69
+ const save = (state: RunState, root: string): Effect.Effect<void> =>
70
+ Effect.gen(function* () {
71
+ yield* ensureApneaDirs(fs, root)
72
+ const p = statePath(root)
73
+ const body = `${JSON.stringify(state, null, 2)}\n`
74
+ yield* fs.writeFile(p, body)
75
+ })
76
+
77
+ const require = (
78
+ root: string,
79
+ ): Effect.Effect<RunState, NoRunState | StateCorrupt> =>
80
+ Effect.gen(function* () {
81
+ const s = yield* load(root)
82
+ if (!s) return yield* new NoRunState({})
83
+ return s
84
+ })
85
+
86
+ const abandon = (root: string): Effect.Effect<string, NoRunState> =>
87
+ Effect.gen(function* () {
88
+ const p = statePath(root)
89
+ const present = yield* fs.exists(p)
90
+ if (!present) return yield* new NoRunState({})
91
+ const millis = yield* Clock.currentTimeMillis
92
+ const bak = `${p}.abandoned.${millis}`
93
+ yield* fs.rename(p, bak)
94
+ return bak
95
+ })
96
+
97
+ return RunStore.of({ load, save, require, abandon })
98
+ }),
99
+ )
@@ -0,0 +1,246 @@
1
+ import { spawnSync } from "node:child_process"
2
+ import * as path from "node:path"
3
+ import { Context, Effect, Layer } from "effect"
4
+ import { VcsError } from "../errors.ts"
5
+ import type { VcsBackend } from "../domain/types.ts"
6
+ import { FileSystem } from "./file-system.ts"
7
+
8
+ export interface VcsService {
9
+ readonly detect: (root: string) => Effect.Effect<VcsBackend | null>
10
+ readonly isDirty: (root: string, vcs: VcsBackend) => Effect.Effect<boolean>
11
+ readonly treeFingerprint: (
12
+ root: string,
13
+ vcs: VcsBackend,
14
+ ) => Effect.Effect<string>
15
+ readonly ensureGitBranch: (
16
+ root: string,
17
+ slug: string,
18
+ ) => Effect.Effect<string, VcsError>
19
+ readonly commitPhase: (
20
+ root: string,
21
+ vcs: VcsBackend,
22
+ message: string,
23
+ ) => Effect.Effect<string, VcsError>
24
+ readonly setBookmarkAtTerminus: (
25
+ root: string,
26
+ slug: string,
27
+ ) => Effect.Effect<void>
28
+ readonly runVerify: (
29
+ root: string,
30
+ commands: readonly string[],
31
+ timeoutMs: number,
32
+ ) => Effect.Effect<{ ok: boolean; log: string }>
33
+ }
34
+
35
+ export class Vcs extends Context.Service<Vcs, VcsService>()("apnea/Vcs") {}
36
+
37
+ function run(
38
+ cmd: string,
39
+ args: string[],
40
+ cwd: string,
41
+ ): { ok: boolean; stdout: string; stderr: string; code: number } {
42
+ const r = spawnSync(cmd, args, {
43
+ cwd,
44
+ encoding: "utf8",
45
+ maxBuffer: 10 * 1024 * 1024,
46
+ })
47
+ return {
48
+ ok: r.status === 0,
49
+ stdout: (r.stdout ?? "").toString(),
50
+ stderr: (r.stderr ?? "").toString(),
51
+ code: r.status ?? 1,
52
+ }
53
+ }
54
+
55
+ /** Drop .apnea/ runtime paths from VCS summaries (artifacts are allowed). */
56
+ export function filterAppPaths(summary: string): string {
57
+ return summary
58
+ .split(/\r?\n/)
59
+ .filter((line) => {
60
+ const t = line.trim()
61
+ if (!t) return false
62
+ // git porcelain: XY path
63
+ if (/^.. /.test(line)) {
64
+ const p = line.slice(3).replace(/^"|"$/g, "")
65
+ return !p.startsWith(".apnea/") && !p.includes("/.apnea/")
66
+ }
67
+ // jj summary often: M path / A path
68
+ const m = t.match(/^[A-Z]+\s+(.+)$/)
69
+ if (m) {
70
+ const p = m[1]!
71
+ return !p.startsWith(".apnea/") && !p.includes("/.apnea/")
72
+ }
73
+ return !t.includes(".apnea/")
74
+ })
75
+ .join("\n")
76
+ }
77
+
78
+ export const VcsLive = Layer.effect(
79
+ Vcs,
80
+ Effect.gen(function* () {
81
+ const fs = yield* FileSystem
82
+
83
+ const detect = (root: string): Effect.Effect<VcsBackend | null> =>
84
+ Effect.gen(function* () {
85
+ if (yield* fs.exists(path.join(root, ".jj"))) return "jj"
86
+ if (yield* fs.exists(path.join(root, ".git"))) return "git"
87
+ return null
88
+ })
89
+
90
+ const treeFingerprint = (
91
+ root: string,
92
+ vcs: VcsBackend,
93
+ ): Effect.Effect<string> =>
94
+ Effect.sync(() => {
95
+ if (vcs === "jj") {
96
+ const r = run("jj", ["diff", "--summary"], root)
97
+ return filterAppPaths(r.stdout)
98
+ }
99
+ const r = run("git", ["status", "--porcelain"], root)
100
+ return filterAppPaths(r.stdout)
101
+ })
102
+
103
+ const isDirty = (root: string, vcs: VcsBackend): Effect.Effect<boolean> =>
104
+ Effect.gen(function* () {
105
+ const fp = yield* treeFingerprint(root, vcs)
106
+ return fp.trim().length > 0
107
+ })
108
+
109
+ const ensureGitBranch = (
110
+ root: string,
111
+ slug: string,
112
+ ): Effect.Effect<string, VcsError> =>
113
+ Effect.gen(function* () {
114
+ const branch = `apnea/${slug}`
115
+ const cur = yield* Effect.sync(() =>
116
+ run("git", ["rev-parse", "--abbrev-ref", "HEAD"], root),
117
+ )
118
+ if (cur.stdout.trim() === branch) return branch
119
+ const exists = yield* Effect.sync(() =>
120
+ run(
121
+ "git",
122
+ ["show-ref", "--verify", "--quiet", `refs/heads/${branch}`],
123
+ root,
124
+ ),
125
+ )
126
+ if (exists.ok) {
127
+ const co = yield* Effect.sync(() =>
128
+ run("git", ["checkout", branch], root),
129
+ )
130
+ if (!co.ok) {
131
+ return yield* new VcsError({
132
+ message: `git checkout ${branch}: ${co.stderr}`,
133
+ command: `git checkout ${branch}`,
134
+ })
135
+ }
136
+ return branch
137
+ }
138
+ const cr = yield* Effect.sync(() =>
139
+ run("git", ["checkout", "-b", branch], root),
140
+ )
141
+ if (!cr.ok) {
142
+ return yield* new VcsError({
143
+ message: `git checkout -b ${branch}: ${cr.stderr}`,
144
+ command: `git checkout -b ${branch}`,
145
+ })
146
+ }
147
+ return branch
148
+ })
149
+
150
+ const commitPhase = (
151
+ root: string,
152
+ vcs: VcsBackend,
153
+ message: string,
154
+ ): Effect.Effect<string, VcsError> =>
155
+ Effect.gen(function* () {
156
+ if (vcs === "jj") {
157
+ const d = yield* Effect.sync(() =>
158
+ run("jj", ["describe", "-m", message], root),
159
+ )
160
+ if (!d.ok) {
161
+ return yield* new VcsError({
162
+ message: d.stderr || d.stdout,
163
+ command: "jj describe",
164
+ })
165
+ }
166
+ const n = yield* Effect.sync(() => run("jj", ["new"], root))
167
+ if (!n.ok) {
168
+ return yield* new VcsError({
169
+ message: n.stderr || n.stdout,
170
+ command: "jj new",
171
+ })
172
+ }
173
+ return "jj describe + new"
174
+ }
175
+ const add = yield* Effect.sync(() => run("git", ["add", "-A"], root))
176
+ if (!add.ok) {
177
+ return yield* new VcsError({
178
+ message: add.stderr,
179
+ command: "git add -A",
180
+ })
181
+ }
182
+ const c = yield* Effect.sync(() =>
183
+ run("git", ["commit", "-m", message], root),
184
+ )
185
+ if (!c.ok) {
186
+ return yield* new VcsError({
187
+ message: c.stderr || c.stdout,
188
+ command: "git commit",
189
+ })
190
+ }
191
+ return "git commit"
192
+ })
193
+
194
+ const setBookmarkAtTerminus = (
195
+ root: string,
196
+ slug: string,
197
+ ): Effect.Effect<void> =>
198
+ Effect.sync(() => {
199
+ const name = `apnea/${slug}`
200
+ const r = run("jj", ["bookmark", "set", name, "-r", "@-"], root)
201
+ if (!r.ok) {
202
+ run("jj", ["bookmark", "create", name, "-r", "@-"], root)
203
+ }
204
+ })
205
+
206
+ const runVerify = (
207
+ root: string,
208
+ commands: readonly string[],
209
+ timeoutMs: number,
210
+ ): Effect.Effect<{ ok: boolean; log: string }> =>
211
+ Effect.sync(() => {
212
+ const lines: string[] = []
213
+ for (const cmd of commands) {
214
+ lines.push(`$ ${cmd}`)
215
+ const r = spawnSync("bash", ["-lc", cmd], {
216
+ cwd: root,
217
+ encoding: "utf8",
218
+ timeout: timeoutMs,
219
+ maxBuffer: 10 * 1024 * 1024,
220
+ })
221
+ const out = `${r.stdout ?? ""}${r.stderr ?? ""}`.trimEnd()
222
+ if (out) lines.push(out)
223
+ lines.push(`exit=${r.status ?? 1}`)
224
+ if (r.error) {
225
+ lines.push(String(r.error))
226
+ return { ok: false, log: lines.join("\n") }
227
+ }
228
+ if (r.status !== 0) {
229
+ return { ok: false, log: lines.join("\n") }
230
+ }
231
+ lines.push("")
232
+ }
233
+ return { ok: true, log: lines.join("\n") }
234
+ })
235
+
236
+ return Vcs.of({
237
+ detect,
238
+ isDirty,
239
+ treeFingerprint,
240
+ ensureGitBranch,
241
+ commitPhase,
242
+ setBookmarkAtTerminus,
243
+ runVerify,
244
+ })
245
+ }),
246
+ )
@@ -0,0 +1,148 @@
1
+ import * as path from "node:path"
2
+ import { Effect, Result } from "effect"
3
+ import { asVerdict, parseFrontMatter } from "../domain/frontmatter.ts"
4
+ import { abs, phaseDir, rel } from "../domain/paths.ts"
5
+ import { nextAfter, toolAllowed } from "../domain/state-machine.ts"
6
+ import { extractVerifyCommands } from "../domain/verify-commands.ts"
7
+ import {
8
+ ArtifactInvalid,
9
+ GateRefused,
10
+ VerifyFailed,
11
+ type AppError,
12
+ } from "../errors.ts"
13
+ import { ok, type ToolResult } from "../result.ts"
14
+ import { Config } from "../services/config.ts"
15
+ import { FileSystem } from "../services/file-system.ts"
16
+ import { RunStore } from "../services/run-store.ts"
17
+ import { Vcs } from "../services/vcs.ts"
18
+
19
+ export type CommitParams = {
20
+ message?: string
21
+ /** When true and no more phases, advance to finishing instead of phase_packaging */
22
+ no_remaining_phases?: boolean
23
+ }
24
+
25
+ /**
26
+ * Require APPROVED code review, run phase package verify commands,
27
+ * jj/git commit, advance phase. Refusals are tagged failures only.
28
+ */
29
+ export const commitWorkflow = (
30
+ params: CommitParams,
31
+ root: string,
32
+ ): Effect.Effect<ToolResult, AppError, FileSystem | RunStore | Config | Vcs> =>
33
+ Effect.gen(function* () {
34
+ const store = yield* RunStore
35
+ const fs = yield* FileSystem
36
+ const config = yield* Config
37
+ const vcs = yield* Vcs
38
+
39
+ const state = yield* store.require(root)
40
+
41
+ const allowed = toolAllowed(state.step, "workflow_commit_phase")
42
+ if (Result.isFailure(allowed)) {
43
+ return yield* allowed.failure
44
+ }
45
+
46
+ const reviewRel = state.current_code_review
47
+ if (!reviewRel) {
48
+ return yield* new GateRefused({
49
+ gate: "commit",
50
+ message: "current_code_review not set — complete code_review first",
51
+ })
52
+ }
53
+
54
+ const reviewAbs = abs(reviewRel, root)
55
+ const reviewPresent = yield* fs.exists(reviewAbs)
56
+ let fm: ReturnType<typeof parseFrontMatter> = null
57
+ if (reviewPresent) {
58
+ const reviewText = yield* fs.readFile(reviewAbs)
59
+ fm = parseFrontMatter(reviewText)
60
+ }
61
+ const verdict = asVerdict(fm?.verdict)
62
+ if (verdict !== "APPROVED") {
63
+ return yield* new GateRefused({
64
+ gate: "commit",
65
+ message: `commit refused: code review verdict is ${fm?.verdict ?? "missing"} (need APPROVED)`,
66
+ details: {
67
+ review: reviewRel,
68
+ verdict: fm?.verdict ?? "missing",
69
+ },
70
+ })
71
+ }
72
+
73
+ const pkgRel =
74
+ state.current_phase_package ??
75
+ rel(
76
+ path.join(phaseDir(state.phase_index, 1, root), "phase-package.md"),
77
+ root,
78
+ )
79
+ const pkgAbs = abs(pkgRel, root)
80
+ const pkgPresent = yield* fs.exists(pkgAbs)
81
+ if (!pkgPresent) {
82
+ return yield* new GateRefused({
83
+ gate: "commit",
84
+ message: `phase package missing: ${pkgRel}`,
85
+ details: { package: pkgRel },
86
+ })
87
+ }
88
+ const pkgText = yield* fs.readFile(pkgAbs)
89
+ const cmds = extractVerifyCommands(pkgText)
90
+ if (!cmds.length) {
91
+ return yield* new ArtifactInvalid({
92
+ artifact: pkgRel,
93
+ message: "no verify commands found in phase package (need ```sh block)",
94
+ })
95
+ }
96
+
97
+ const cfg = yield* config.load(root)
98
+ const verifyTimeout = cfg.timeouts_ms.verify ?? 900_000
99
+ const verify = yield* vcs.runVerify(root, cmds, verifyTimeout)
100
+
101
+ const vlog = path.join(path.dirname(reviewAbs), "verify.log")
102
+ yield* fs.mkdir(path.dirname(vlog), { recursive: true })
103
+ yield* fs.writeFile(vlog, `${verify.log}\n`)
104
+
105
+ if (!verify.ok) {
106
+ return yield* new VerifyFailed({
107
+ commands: cmds,
108
+ outputs: [verify.log.slice(-2000)],
109
+ // `outputs` is a tail — point the caller at the full log on disk.
110
+ verify_log: rel(vlog, root),
111
+ })
112
+ }
113
+
114
+ const message =
115
+ params.message?.trim() ||
116
+ `feat: apnea phase ${state.phase_index} (${state.slug})`
117
+
118
+ const detail = yield* vcs.commitPhase(root, state.vcs, message)
119
+
120
+ if (params.no_remaining_phases) {
121
+ state.step = "finishing"
122
+ if (state.vcs === "jj") {
123
+ yield* vcs.setBookmarkAtTerminus(root, state.slug)
124
+ }
125
+ } else {
126
+ state.phase_index += 1
127
+ state.step = "phase_packaging"
128
+ state.current_phase_package = null
129
+ state.current_code_review = null
130
+ }
131
+ state.last_error = null
132
+ yield* store.save(state, root)
133
+
134
+ return ok(
135
+ `committed phase; step → ${state.step}`,
136
+ {
137
+ vcs_detail: detail,
138
+ verify_log: rel(vlog, root),
139
+ step: state.step,
140
+ phase_index: state.phase_index,
141
+ next:
142
+ state.step === "finishing"
143
+ ? ["dispatch_role kind=pr_description"]
144
+ : ["dispatch_role kind=phase_package"],
145
+ },
146
+ nextAfter(state.step),
147
+ )
148
+ })