@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,247 @@
1
+ import { Schema } from "effect"
2
+ import { err, type ToolErr } from "./result.ts"
3
+
4
+ /** No `.apnea/state.json` for the current project root. */
5
+ export class NoRunState extends Schema.TaggedErrorClass<NoRunState>()(
6
+ "NoRunState",
7
+ {},
8
+ ) {}
9
+
10
+ /** Tool call refused by the step → legal-tools table. */
11
+ export class IllegalTool extends Schema.TaggedErrorClass<IllegalTool>()(
12
+ "IllegalTool",
13
+ {
14
+ step: Schema.String,
15
+ tool: Schema.String,
16
+ legal: Schema.Array(Schema.String),
17
+ },
18
+ ) {}
19
+
20
+ /** Dispatch kind refused at the current step. */
21
+ export class IllegalKind extends Schema.TaggedErrorClass<IllegalKind>()(
22
+ "IllegalKind",
23
+ {
24
+ step: Schema.String,
25
+ kind: Schema.String,
26
+ allowed: Schema.Array(Schema.String),
27
+ },
28
+ ) {}
29
+
30
+ /** Global or project config missing, invalid, or untrusted. */
31
+ export class ConfigError extends Schema.TaggedErrorClass<ConfigError>()(
32
+ "ConfigError",
33
+ {
34
+ message: Schema.String,
35
+ path: Schema.optional(Schema.String),
36
+ details: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
37
+ },
38
+ ) {}
39
+
40
+ /** `state.json` present but not decodable / inconsistent. */
41
+ export class StateCorrupt extends Schema.TaggedErrorClass<StateCorrupt>()(
42
+ "StateCorrupt",
43
+ {
44
+ path: Schema.String,
45
+ message: Schema.String,
46
+ },
47
+ ) {}
48
+
49
+ /** VCS detect / dirty / commit / bookmark failure. */
50
+ export class VcsError extends Schema.TaggedErrorClass<VcsError>()("VcsError", {
51
+ message: Schema.String,
52
+ command: Schema.optional(Schema.String),
53
+ }) {}
54
+
55
+ /** Herdr CLI / pane / floating failure. */
56
+ export class HerdrError extends Schema.TaggedErrorClass<HerdrError>()(
57
+ "HerdrError",
58
+ {
59
+ message: Schema.String,
60
+ command: Schema.optional(Schema.String),
61
+ details: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
62
+ },
63
+ ) {}
64
+
65
+ /** Commit/review gate refused (e.g. verdict not APPROVED). */
66
+ export class GateRefused extends Schema.TaggedErrorClass<GateRefused>()(
67
+ "GateRefused",
68
+ {
69
+ gate: Schema.String,
70
+ message: Schema.String,
71
+ details: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
72
+ },
73
+ ) {}
74
+
75
+ /** `workflow_wait` hit its timeout without a complete artifact. */
76
+ export class WaitTimeout extends Schema.TaggedErrorClass<WaitTimeout>()(
77
+ "WaitTimeout",
78
+ {
79
+ artifact: Schema.String,
80
+ timeoutMs: Schema.Number,
81
+ details: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
82
+ },
83
+ ) {}
84
+
85
+ /** `workflow_wait` aborted (Esc / cancel signal). */
86
+ export class WaitAborted extends Schema.TaggedErrorClass<WaitAborted>()(
87
+ "WaitAborted",
88
+ {
89
+ artifact: Schema.String,
90
+ details: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
91
+ },
92
+ ) {}
93
+
94
+ /** Artifact exists but front-matter / shape is invalid. */
95
+ export class ArtifactInvalid extends Schema.TaggedErrorClass<ArtifactInvalid>()(
96
+ "ArtifactInvalid",
97
+ {
98
+ artifact: Schema.String,
99
+ message: Schema.String,
100
+ },
101
+ ) {}
102
+
103
+ /** Phase-package verify commands failed — commit refused. */
104
+ export class VerifyFailed extends Schema.TaggedErrorClass<VerifyFailed>()(
105
+ "VerifyFailed",
106
+ {
107
+ commands: Schema.Array(Schema.String),
108
+ /** Tail of the verify log — truncated; `verify_log` has the full text. */
109
+ outputs: Schema.Array(Schema.String),
110
+ /** Repo-relative path of the full verify log. */
111
+ verify_log: Schema.String,
112
+ },
113
+ ) {}
114
+
115
+ export type AppError =
116
+ | NoRunState
117
+ | IllegalTool
118
+ | IllegalKind
119
+ | ConfigError
120
+ | StateCorrupt
121
+ | VcsError
122
+ | HerdrError
123
+ | GateRefused
124
+ | WaitTimeout
125
+ | WaitAborted
126
+ | ArtifactInvalid
127
+ | VerifyFailed
128
+
129
+ const APP_ERROR_TAG_LIST = [
130
+ "NoRunState",
131
+ "IllegalTool",
132
+ "IllegalKind",
133
+ "ConfigError",
134
+ "StateCorrupt",
135
+ "VcsError",
136
+ "HerdrError",
137
+ "GateRefused",
138
+ "WaitTimeout",
139
+ "WaitAborted",
140
+ "ArtifactInvalid",
141
+ "VerifyFailed",
142
+ ] as const satisfies readonly AppError["_tag"][]
143
+
144
+ /**
145
+ * Compile-time guard: an `AppError` member missing from `APP_ERROR_TAG_LIST`
146
+ * would make `isAppError` return false for it, silently degrading a designed
147
+ * refusal into `bug: …`. Unlike the `toToolResult` switch, a `Set<string>`
148
+ * cannot be checked by exhaustiveness alone — so assert it here.
149
+ */
150
+ type AssertNever<T extends never> = T
151
+ type _AllAppErrorTagsCovered = AssertNever<
152
+ Exclude<AppError["_tag"], (typeof APP_ERROR_TAG_LIST)[number]>
153
+ >
154
+
155
+ const APP_ERROR_TAGS: ReadonlySet<string> = new Set(APP_ERROR_TAG_LIST)
156
+
157
+ export function isAppError(u: unknown): u is AppError {
158
+ return (
159
+ typeof u === "object" &&
160
+ u !== null &&
161
+ "_tag" in u &&
162
+ typeof (u as { _tag: unknown })._tag === "string" &&
163
+ APP_ERROR_TAGS.has((u as { _tag: string })._tag)
164
+ )
165
+ }
166
+
167
+ /** Map a tagged app error to the stable ToolErr boundary shape. */
168
+ export function toToolResult(e: AppError): ToolErr {
169
+ switch (e._tag) {
170
+ case "NoRunState":
171
+ return err("no run state; call workflow_start first", {
172
+ legal_next: ["workflow_start"],
173
+ })
174
+ case "IllegalTool":
175
+ return err(
176
+ `illegal tool ${e.tool} at step=${e.step}. legal: ${e.legal.join(", ") || "(none)"}`,
177
+ {
178
+ legal_next: [...e.legal],
179
+ data: { step: e.step, tool: e.tool },
180
+ },
181
+ )
182
+ case "IllegalKind":
183
+ return err(
184
+ `kind=${e.kind} not allowed at step=${e.step}. allowed: ${e.allowed.join(", ")}`,
185
+ {
186
+ legal_next: ["dispatch_role with allowed kind", "workflow_status"],
187
+ data: { step: e.step, kind: e.kind, allowed: e.allowed },
188
+ },
189
+ )
190
+ case "ConfigError": {
191
+ const data = {
192
+ ...(e.path !== undefined ? { path: e.path } : {}),
193
+ ...(e.details ?? {}),
194
+ }
195
+ return err(e.message, {
196
+ data: Object.keys(data).length > 0 ? data : undefined,
197
+ })
198
+ }
199
+ case "StateCorrupt":
200
+ return err(`corrupt state at ${e.path}: ${e.message}`, {
201
+ data: { path: e.path },
202
+ })
203
+ case "VcsError":
204
+ return err(e.message, {
205
+ data: e.command !== undefined ? { command: e.command } : undefined,
206
+ })
207
+ case "HerdrError":
208
+ return err(e.message, {
209
+ data:
210
+ e.command !== undefined || e.details !== undefined
211
+ ? {
212
+ ...(e.command !== undefined ? { command: e.command } : {}),
213
+ ...(e.details ?? {}),
214
+ }
215
+ : undefined,
216
+ })
217
+ case "GateRefused":
218
+ return err(e.message, {
219
+ data: {
220
+ gate: e.gate,
221
+ ...(e.details ?? {}),
222
+ },
223
+ })
224
+ case "WaitTimeout":
225
+ return err(`timeout after ${e.timeoutMs}ms waiting for ${e.artifact}`, {
226
+ data: {
227
+ artifact: e.artifact,
228
+ timeout_ms: e.timeoutMs,
229
+ ...(e.details ?? {}),
230
+ },
231
+ })
232
+ case "WaitAborted":
233
+ return err("workflow_wait aborted (Esc / cancel)", {
234
+ data: { artifact: e.artifact, ...(e.details ?? {}) },
235
+ })
236
+ case "ArtifactInvalid":
237
+ return err(e.message, { data: { artifact: e.artifact } })
238
+ case "VerifyFailed":
239
+ return err("verify commands failed — commit refused", {
240
+ data: {
241
+ commands: e.commands,
242
+ outputs: e.outputs,
243
+ verify_log: e.verify_log,
244
+ },
245
+ })
246
+ }
247
+ }
@@ -0,0 +1,8 @@
1
+ /** Host-specific behavior used while launching interactive role harnesses. */
2
+ export type ApneaHostAdapter = {
3
+ readonly materializeRoleAgentDir?: () => string
4
+ readonly prepareInteractiveCommand?: (command: string[]) => string[]
5
+ readonly beforeInteractivePrompt?: (command: string[]) => string | null
6
+ }
7
+
8
+ export const neutralHostAdapter: ApneaHostAdapter = {}
@@ -0,0 +1,323 @@
1
+ import { Type, type TSchema } from "typebox"
2
+ import { Check, Errors } from "typebox/value"
3
+ import { workflowCommitPhase } from "./adapters/commit.ts"
4
+ import { workflowDispatch } from "./adapters/dispatch.ts"
5
+ import { apneaSetup } from "./adapters/setup.ts"
6
+ import { workflowStart } from "./adapters/start.ts"
7
+ import { workflowResetRounds, workflowStatus } from "./adapters/status.ts"
8
+ import { workflowWait } from "./adapters/wait.ts"
9
+ import { DISPATCH_KINDS } from "./domain/state-machine.ts"
10
+ import type { ToolResult } from "./result.ts"
11
+ import { neutralHostAdapter, type ApneaHostAdapter } from "./host-adapter.ts"
12
+ import {
13
+ DEAD_POLLS_NEEDED,
14
+ DEFAULT_BUDGET_MS,
15
+ GRACE_MS,
16
+ HOST_SHELL_TIMEOUT_MS,
17
+ IDLE_NUDGE_AFTER_MS,
18
+ MAX_AUTO_POLL_MS,
19
+ MIN_POLL_MS,
20
+ type WaitParams,
21
+ type WaitHooks,
22
+ } from "./workflows/wait.ts"
23
+
24
+ export type Operation = {
25
+ /** Pi tool name, or null when the operation is not model-facing. */
26
+ readonly tool: string | null
27
+ /** CLI verb and `/apnea` subcommand. */
28
+ readonly verb: string
29
+ /**
30
+ * Argument syntax shown next to the verb in `/apnea help`, e.g.
31
+ * `"<goal> [--allow-dirty] [--slug=name]"`. Empty string for verbs that
32
+ * take no arguments (e.g. `status`). Required so a new operation can't
33
+ * silently omit the human-facing usage the old hand-written helpText()
34
+ * used to carry.
35
+ */
36
+ readonly usage: string
37
+ /** One line, shared by the tool description and `--help`. */
38
+ readonly summary: string
39
+ /** Extra prose for the model only; omitted from `--help`. */
40
+ readonly guidance?: string
41
+ readonly params: TSchema
42
+ /** Gated behind the TTY check in the CLI; never registered as a tool. */
43
+ readonly humanOnly?: true
44
+ }
45
+
46
+ type RegisteredOperation = Operation & {
47
+ readonly run: (
48
+ params: Record<string, unknown>,
49
+ hooks?: WaitHooks,
50
+ ) => Promise<ToolResult>
51
+ }
52
+
53
+ export type ExecuteOperation = (
54
+ verb: string,
55
+ params: Record<string, unknown>,
56
+ hooks?: WaitHooks,
57
+ ) => Promise<ToolResult>
58
+
59
+ // Sourced from domain/state-machine.ts (not hardcoded here) so a new kind
60
+ // added there can't silently drift out of sync with the registry — the same
61
+ // pattern extension/index.ts already uses.
62
+ const DispatchKind = Type.Union(
63
+ DISPATCH_KINDS.map((kind) => Type.Literal(kind)),
64
+ )
65
+
66
+ function operationParams(properties: Record<string, TSchema>): TSchema {
67
+ return Type.Object(properties, { additionalProperties: false })
68
+ }
69
+
70
+ // Workflow order, not alphabetical or tool-name order: setup is the natural
71
+ // first step for a new checkout, then the start → dispatch → wait → commit
72
+ // loop, then the always-available status, then the human-only escape hatch.
73
+ // `/apnea help` and autocomplete (SUBS) both derive their order from this
74
+ // array, so ordering it once here keeps every rendering in sync for free.
75
+ function createRegisteredOperations(
76
+ hostAdapter: ApneaHostAdapter = neutralHostAdapter,
77
+ ): readonly RegisteredOperation[] {
78
+ return [
79
+ {
80
+ tool: null,
81
+ verb: "setup",
82
+ usage: "[--project] [--force] [--agents-md]",
83
+ summary: "Write global profiles and optional project role bindings.",
84
+ params: operationParams({
85
+ project: Type.Optional(Type.Boolean()),
86
+ force: Type.Optional(Type.Boolean()),
87
+ agents_md: Type.Optional(Type.Boolean()),
88
+ }),
89
+ run: (p) =>
90
+ apneaSetup(p as Parameters<typeof apneaSetup>[0], hostAdapter),
91
+ },
92
+ {
93
+ tool: "workflow_start",
94
+ verb: "start",
95
+ usage: "<goal> [--allow-dirty] [--slug=name]",
96
+ summary: "Start, resume, or abandon an Apnea run.",
97
+ guidance:
98
+ "Start only writes state (step=planning) — it does NOT launch roles. After start succeeds you MUST immediately call dispatch_role kind=plan then workflow_wait. Resume never auto-dispatches. Refuses if state exists or tree dirty (unless allow_dirty).",
99
+ params: operationParams({
100
+ goal: Type.Optional(
101
+ Type.String({ description: "Run goal (required for action=start)" }),
102
+ ),
103
+ slug: Type.Optional(
104
+ Type.String({ description: "Run slug for branch/bookmark" }),
105
+ ),
106
+ allow_dirty: Type.Optional(Type.Boolean()),
107
+ action: Type.Optional(
108
+ Type.Union([
109
+ Type.Literal("start"),
110
+ Type.Literal("resume"),
111
+ Type.Literal("abandon"),
112
+ ]),
113
+ ),
114
+ }),
115
+ // Mirrors the guard in index.ts's execute(): without it, action=start
116
+ // with no goal reaches slugify(undefined) in workflows/start.ts and
117
+ // throws instead of returning a clean refusal.
118
+ run: (p) => {
119
+ const params = p as Parameters<typeof workflowStart>[0]
120
+ const action = params.action ?? "start"
121
+ if (action === "start" && !params.goal?.trim()) {
122
+ return Promise.resolve({
123
+ ok: false,
124
+ error: "goal is required when action=start",
125
+ })
126
+ }
127
+ return workflowStart(
128
+ {
129
+ goal: params.goal ?? "",
130
+ slug: params.slug,
131
+ allow_dirty: params.allow_dirty,
132
+ action,
133
+ },
134
+ hostAdapter,
135
+ )
136
+ },
137
+ },
138
+ {
139
+ tool: "dispatch_role",
140
+ verb: "dispatch",
141
+ usage: "<kind> [--rework]",
142
+ summary: "Write the task file and launch a role in a Herdr pane.",
143
+ guidance:
144
+ "One outstanding dispatch at a time. Pass rework=true for plan/code after CHANGES_REQUIRED. Phase-package rework advances its round automatically from review frontmatter.",
145
+ params: operationParams({
146
+ kind: DispatchKind,
147
+ task_markdown: Type.Optional(
148
+ Type.String({ description: "Extra task body details" }),
149
+ ),
150
+ rework: Type.Optional(
151
+ Type.Boolean({
152
+ description: "Increment round after CHANGES_REQUIRED",
153
+ }),
154
+ ),
155
+ }),
156
+ run: (p) =>
157
+ workflowDispatch(
158
+ p as Parameters<typeof workflowDispatch>[0],
159
+ hostAdapter,
160
+ ),
161
+ },
162
+ {
163
+ tool: "workflow_wait",
164
+ verb: "wait",
165
+ usage: "[--poll=<ms>] [--budget=<ms>|--timeout=<ms>]",
166
+ summary: "Wait for the pending artifact's front-matter to be complete.",
167
+ guidance:
168
+ "Blocks until the artifact is ready or the role times out. Exit is non-fatal when the call's budget is spent but the role still has time — call again. Omit both parameters unless you have a reason.",
169
+ // `timeout_ms` was dropped in Task 3: dispatch always stamps the deadline,
170
+ // so it no-opped for every real run. The role timeout lives in config.
171
+ //
172
+ // Only `minimum` is a schema-level bound, because only it is
173
+ // unconditional. The poll ceiling applies solely when budget_ms is
174
+ // absent, and a `maximum` here would have declared a limit the runtime
175
+ // does not enforce — rejecting the legal large-poll-with-explicit-budget
176
+ // call at the boundary, or lying to a model that never hits it.
177
+ //
178
+ // The floor is interpolated from the constants, not spelled out. A
179
+ // hardcoded formula here goes stale the moment IDLE_NUDGE_AFTER_MS
180
+ // moves, and then the schema promises a budget the runtime refuses.
181
+ params: operationParams({
182
+ poll_ms: Type.Optional(
183
+ Type.Number({
184
+ minimum: MIN_POLL_MS,
185
+ description:
186
+ `Milliseconds between polls. At least ${MIN_POLL_MS} — each poll spawns two herdr subprocesses. ` +
187
+ `Keep it at or under ${MAX_AUTO_POLL_MS} unless you also pass budget_ms: above that, the floor ` +
188
+ `below forces a budget past the ${HOST_SHELL_TIMEOUT_MS}ms an agent shell commonly allows, and the call is refused.`,
189
+ }),
190
+ ),
191
+ budget_ms: Type.Optional(
192
+ Type.Number({
193
+ description:
194
+ `How long THIS call may block — not the role's deadline, which comes from config. ` +
195
+ `Must be at least ${GRACE_MS} + max(${IDLE_NUDGE_AFTER_MS}, ${DEAD_POLLS_NEEDED} x poll_ms), so the call ` +
196
+ `can contain a whole recovery rung. Omitting it is usually right: this tool then blocks until the ` +
197
+ `role finishes, streaming progress and interruptible, because it has no host shell timeout to fit inside. ` +
198
+ `The apnea CLI, which does, defaults to ${DEFAULT_BUDGET_MS}ms instead and returns exit 3 to be called again.`,
199
+ }),
200
+ ),
201
+ }),
202
+ // The Pi driver bypasses this handler entirely — Task 5 special-cases
203
+ // workflow_wait and calls workflowWait directly with its own streaming
204
+ // hooks, supplying its own budget (see extension/index.ts). This run is
205
+ // reached only by the CLI, which must not block forever, so params pass
206
+ // through unchanged and DEFAULT_BUDGET_MS applies when budget_ms
207
+ // is absent. No hooks parameter here — nothing calls op.run with one.
208
+ run: (p, hooks) => workflowWait(p as WaitParams, hostAdapter, hooks),
209
+ },
210
+ {
211
+ tool: "workflow_commit_phase",
212
+ verb: "commit",
213
+ usage: "[--done] [message]",
214
+ summary: "Verify and commit the current phase, then advance.",
215
+ guidance:
216
+ "Requires an APPROVED code review. Runs the phase package's verify commands and refuses on non-zero exit. Pass no_remaining_phases=true to move to the PR description instead of the next phase.",
217
+ params: operationParams({
218
+ message: Type.Optional(Type.String()),
219
+ no_remaining_phases: Type.Optional(
220
+ Type.Boolean({
221
+ description:
222
+ "If true, go to finishing (PR description) after commit",
223
+ }),
224
+ ),
225
+ }),
226
+ run: (p) =>
227
+ workflowCommitPhase(
228
+ p as Parameters<typeof workflowCommitPhase>[0],
229
+ hostAdapter,
230
+ ),
231
+ },
232
+ {
233
+ tool: "workflow_status",
234
+ verb: "status",
235
+ usage: "",
236
+ summary: "Read-only snapshot of run state and legal next calls.",
237
+ guidance: "Never mutates. Safe to call at any point.",
238
+ params: operationParams({}),
239
+ run: () => workflowStatus(hostAdapter),
240
+ },
241
+ {
242
+ tool: null,
243
+ verb: "reset-rounds",
244
+ // `--i-am-human` is deliberately not listed here: it's a CLI-only TTY
245
+ // bypass (the slash handler doesn't accept it — see main.ts's own usage()
246
+ // and README.md), and this string feeds both `/apnea help` and the CLI's
247
+ // per-verb line, so listing it here would advertise it on the slash
248
+ // command too.
249
+ usage: "<gate>",
250
+ summary: "Reset the rework counter for a gate. Human only.",
251
+ humanOnly: true,
252
+ params: operationParams({
253
+ gate: Type.String({
254
+ description: "Round key, e.g. plan_review or phase-01/code_review",
255
+ }),
256
+ }),
257
+ run: (p) =>
258
+ workflowResetRounds(
259
+ p as Parameters<typeof workflowResetRounds>[0],
260
+ hostAdapter,
261
+ ),
262
+ },
263
+ ]
264
+ }
265
+
266
+ function publicOperation(operation: RegisteredOperation): Operation {
267
+ const { run: _run, ...metadata } = operation
268
+ return metadata
269
+ }
270
+
271
+ function executorFor(
272
+ operations: readonly RegisteredOperation[],
273
+ ): ExecuteOperation {
274
+ return async (verb, params, hooks) => {
275
+ const operation = operations.find((candidate) => candidate.verb === verb)
276
+ if (!operation) {
277
+ return { ok: false, error: `unknown operation: ${verb}` }
278
+ }
279
+ if (!Check(operation.params, params)) {
280
+ return {
281
+ ok: false,
282
+ error: `invalid parameters for ${verb}`,
283
+ data: {
284
+ verb,
285
+ issues: [...Errors(operation.params, params)].map((issue) => ({
286
+ path: issue.instancePath,
287
+ message: issue.message,
288
+ })),
289
+ },
290
+ }
291
+ }
292
+ return operation.run(params, hooks)
293
+ }
294
+ }
295
+
296
+ export function createOperations(
297
+ hostAdapter: ApneaHostAdapter = neutralHostAdapter,
298
+ ): readonly Operation[] {
299
+ return createRegisteredOperations(hostAdapter).map(publicOperation)
300
+ }
301
+
302
+ export function createExecutor(
303
+ hostAdapter: ApneaHostAdapter = neutralHostAdapter,
304
+ ): ExecuteOperation {
305
+ return executorFor(createRegisteredOperations(hostAdapter))
306
+ }
307
+
308
+ const REGISTERED_OPERATIONS = createRegisteredOperations()
309
+ export const OPERATIONS = REGISTERED_OPERATIONS.map(publicOperation)
310
+ export const executeOperation = executorFor(REGISTERED_OPERATIONS)
311
+
312
+ export function findByVerb(verb: string): Operation | undefined {
313
+ return OPERATIONS.find((o) => o.verb === verb)
314
+ }
315
+
316
+ export function findByTool(tool: string): Operation | undefined {
317
+ return OPERATIONS.find((o) => o.tool === tool)
318
+ }
319
+
320
+ /** Canonical tool name → CLI verb, or null when not model-facing. */
321
+ export function toolToVerb(tool: string): string | null {
322
+ return findByTool(tool)?.verb ?? null
323
+ }
@@ -0,0 +1,55 @@
1
+ export interface ToolOk {
2
+ ok: true
3
+ message: string
4
+ data?: Record<string, unknown>
5
+ legal_next?: string[]
6
+ }
7
+
8
+ export interface ToolErr {
9
+ ok: false
10
+ error: string
11
+ legal_next?: string[]
12
+ data?: Record<string, unknown>
13
+ }
14
+
15
+ export type ToolResult = ToolOk | ToolErr
16
+
17
+ export function ok(
18
+ message: string,
19
+ data?: Record<string, unknown>,
20
+ legal_next?: string[],
21
+ ): ToolResult {
22
+ const r: ToolOk = { ok: true, message }
23
+ if (data) r.data = data
24
+ if (legal_next?.length) r.legal_next = legal_next
25
+ return r
26
+ }
27
+
28
+ export function err(
29
+ error: string,
30
+ opts?: { legal_next?: string[]; data?: Record<string, unknown> },
31
+ ): ToolErr {
32
+ return {
33
+ ok: false,
34
+ error,
35
+ legal_next: opts?.legal_next,
36
+ data: opts?.data,
37
+ }
38
+ }
39
+
40
+ export function formatResult(r: ToolResult): string {
41
+ const legal = r.legal_next?.length
42
+ ? `\nlegal_next: ${r.legal_next.join(", ")}`
43
+ : ""
44
+ const extra = r.data ? `\n${JSON.stringify(r.data, null, 2)}` : ""
45
+ return r.ok
46
+ ? `OK: ${r.message}${legal}${extra}`
47
+ : `ERROR: ${r.error}${legal}${extra}`
48
+ }
49
+
50
+ export function toolContent(r: ToolResult) {
51
+ return {
52
+ content: [{ type: "text" as const, text: formatResult(r) }],
53
+ details: r,
54
+ }
55
+ }
@@ -0,0 +1,43 @@
1
+ import { Cause, Effect, Exit, Layer, Option, Result } from "effect"
2
+ import { isAppError, toToolResult } from "./errors.ts"
3
+ import type { ToolResult } from "./result.ts"
4
+
5
+ /**
6
+ * Per-call runner (no toolContent wrap): provide `layer`, map AppError →
7
+ * ToolResult, defects → bug:. No module-level ManagedRuntime.
8
+ */
9
+ export async function runToolResult<E, R>(
10
+ effect: Effect.Effect<ToolResult, E, R>,
11
+ // Required: a defaulted `Layer.empty as Layer.Layer<R>` erases R, so calling
12
+ // without the layer would type-check and then die as a service-not-found
13
+ // defect on every invocation. Pass `Layer.empty` explicitly when R is never.
14
+ layer: Layer.Layer<R, never, never>,
15
+ ): Promise<ToolResult> {
16
+ const provided = Effect.provide(effect, layer) as Effect.Effect<ToolResult, E>
17
+ const exit = await Effect.runPromiseExit(provided)
18
+
19
+ if (Exit.isSuccess(exit)) {
20
+ return exit.value
21
+ }
22
+
23
+ const error = Exit.findErrorOption(exit)
24
+ if (Option.isSome(error) && isAppError(error.value)) {
25
+ return toToolResult(error.value)
26
+ }
27
+
28
+ const defect = Cause.findDefect(exit.cause)
29
+ const msg = Result.isSuccess(defect)
30
+ ? defectMessage(defect.success)
31
+ : defectMessage(Cause.squash(exit.cause))
32
+ return { ok: false, error: `bug: ${msg}` }
33
+ }
34
+
35
+ function defectMessage(defect: unknown): string {
36
+ if (defect instanceof Error) return defect.message || defect.name
37
+ if (typeof defect === "string") return defect
38
+ try {
39
+ return JSON.stringify(defect)
40
+ } catch {
41
+ return String(defect)
42
+ }
43
+ }