@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.
- package/CONTEXT.md +61 -0
- package/CONTRIBUTING.md +21 -0
- package/LICENSE +21 -0
- package/README.md +163 -0
- package/SECURITY.md +35 -0
- package/briefs/coder.md +40 -0
- package/briefs/orchestrator.md +49 -0
- package/briefs/planner.md +54 -0
- package/briefs/reviewer.md +40 -0
- package/dist/cli.js +39397 -0
- package/docs/adr/0001-completion-signaling.md +3 -0
- package/docs/adr/0002-orchestrator-authority.md +3 -0
- package/docs/adr/0003-verify-at-gate.md +3 -0
- package/docs/adr/0004-artifact-layout-and-naming.md +3 -0
- package/docs/adr/0005-harness-profiles.md +5 -0
- package/docs/adr/0006-config-trust-model.md +3 -0
- package/docs/adr/0007-jj-first-commits.md +3 -0
- package/docs/adr/0008-effect-v4-internals.md +3 -0
- package/docs/adr/0009-cli-driver-split.md +9 -0
- package/docs/adr/0010-package-split.md +23 -0
- package/docs/protocol/artifacts.md +68 -0
- package/docs/protocol/config.md +186 -0
- package/docs/protocol/manual-gate.md +38 -0
- package/docs/protocol/overview.md +96 -0
- package/extension/adapters/commit.ts +15 -0
- package/extension/adapters/dispatch.ts +15 -0
- package/extension/adapters/setup.ts +34 -0
- package/extension/adapters/start.ts +16 -0
- package/extension/adapters/status.ts +24 -0
- package/extension/adapters/wait.ts +20 -0
- package/extension/api.ts +16 -0
- package/extension/cli/format.ts +44 -0
- package/extension/cli/human-gate.ts +44 -0
- package/extension/cli/main.ts +218 -0
- package/extension/cli/parse.ts +48 -0
- package/extension/domain/artifact-kind.ts +26 -0
- package/extension/domain/frontmatter.ts +69 -0
- package/extension/domain/herdr.ts +109 -0
- package/extension/domain/paths.ts +139 -0
- package/extension/domain/recovery.ts +25 -0
- package/extension/domain/rounds.ts +16 -0
- package/extension/domain/setup.ts +158 -0
- package/extension/domain/slug.ts +9 -0
- package/extension/domain/state-machine.ts +132 -0
- package/extension/domain/timeouts.ts +24 -0
- package/extension/domain/types.ts +145 -0
- package/extension/domain/verify-commands.ts +128 -0
- package/extension/errors.ts +247 -0
- package/extension/host-adapter.ts +8 -0
- package/extension/registry.ts +323 -0
- package/extension/result.ts +55 -0
- package/extension/run-tool.ts +43 -0
- package/extension/schema/config.ts +315 -0
- package/extension/schema/frontmatter.ts +34 -0
- package/extension/schema/state.ts +119 -0
- package/extension/services/app-live.ts +24 -0
- package/extension/services/config.ts +103 -0
- package/extension/services/file-system.ts +178 -0
- package/extension/services/herdr.ts +860 -0
- package/extension/services/run-store.ts +99 -0
- package/extension/services/vcs.ts +246 -0
- package/extension/workflows/commit.ts +148 -0
- package/extension/workflows/dispatch.ts +693 -0
- package/extension/workflows/reset.ts +26 -0
- package/extension/workflows/setup.ts +301 -0
- package/extension/workflows/start.ts +149 -0
- package/extension/workflows/status.ts +45 -0
- package/extension/workflows/wait.ts +793 -0
- package/herdr-plugin/herdr-plugin.toml +15 -0
- package/herdr-plugin/scripts/run-task.sh +8 -0
- package/package.json +75 -0
- package/schemas/artifact-frontmatter.md +38 -0
- package/schemas/config.schema.json +50 -0
- 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
|
+
}
|