@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,128 +1,220 @@
1
- /** Does this line end in an ODD run of backslashes — a shell continuation? */
2
- function endsInContinuation(line: string): boolean {
3
- const run = /(\\+)$/.exec(line)
4
- return run !== null && run[1]!.length % 2 === 1
1
+ export type VerifyBlock = {
2
+ readonly interpreter: "bash" | "sh"
3
+ readonly source: string
5
4
  }
6
5
 
7
- /**
8
- * Split fence lines into logical shell commands: continuations joined,
9
- * comments dropped — in ONE pass, because the two interact and both split
10
- * orderings shipped bugs.
11
- *
12
- * Join-then-strip (version 1): a comment ending in `\` swallowed the command
13
- * below it, then the merged line was dropped as a comment — `bun test
14
- * extension` silently never ran and the phase committed green.
15
- *
16
- * Strip-then-join (version 2): removing a comment line that sat BETWEEN a
17
- * continued line and the next command spliced the two commands together —
18
- * `test -f README.md \` + `# note` + `bun test extension` became one command.
19
- *
20
- * bash resolves this by interleaving, and each command later runs through
21
- * `bash -lc`, so bash's rules are the spec:
22
- *
23
- * - Joining appends the next line with NOTHING between; the final backslash of
24
- * an odd run is dropped, the rest stay (they are escaped literals). A
25
- * backslash that is not the line's last character continues nothing —
26
- * `echo hi \ ` escapes the space and ends the command.
27
- * - A `#` at the start of a logical command is a comment; its own trailing
28
- * backslash is inside the comment and continues nothing.
29
- * - A comment line reached MID-continuation ends the logical command, when the
30
- * `#` would start a word in the joined text (whitespace before it). Without
31
- * whitespace on either side of the join, `over\` + `#note` is the single
32
- * word `over#note`, not a comment — so it is appended, not terminated on.
33
- */
34
- function logicalCommands(lines: string[]): string[] {
35
- const out: string[] = []
36
- let acc = ""
37
- let pending = false
38
- for (const line of lines) {
39
- const isCommentish = line.trim().startsWith("#")
40
- if (!pending && isCommentish) continue
41
- if (pending && isCommentish && (/\s$/.test(acc) || /^\s/.test(line))) {
42
- out.push(acc)
43
- acc = ""
44
- pending = false
45
- continue
46
- }
47
- const continues = endsInContinuation(line)
48
- const text = continues ? line.slice(0, -1) : line
49
- acc = pending ? acc + text : text
50
- pending = continues
51
- if (!pending) {
52
- out.push(acc)
53
- acc = ""
54
- }
55
- }
56
- // A dangling continuation on the last line: keep what we have rather than
57
- // dropping the command on the floor.
58
- if (pending) out.push(acc)
59
- return out
6
+ type FenceRegion = {
7
+ readonly start: number
8
+ readonly bodyStart: number
9
+ readonly bodyEnd: number
10
+ readonly info: string
11
+ readonly indent: number
12
+ }
13
+
14
+ type Heading = {
15
+ readonly start: number
16
+ readonly end: number
17
+ readonly rank: number | null
18
+ readonly text: string
19
+ }
20
+
21
+ export function normalizeVerifySource(source: string): string {
22
+ return source.replace(/\r\n?/g, "\n")
60
23
  }
61
24
 
62
- function toCommands(lines: string[]): string[] {
63
- const cmds: string[] = []
64
- for (const joined of logicalCommands(lines)) {
65
- const t = joined.trim()
66
- if (t) cmds.push(t)
25
+ function atxHeading(line: string): { rank: number; text: string } | null {
26
+ const match = /^ {0,3}(#{1,6})(?:[\t ]+(.*?)|[\t ]*)$/.exec(line)
27
+ if (!match) return null
28
+ return {
29
+ rank: match[1]!.length,
30
+ text: (match[2] ?? "").replace(/[\t ]+#+[\t ]*$/, "").trim(),
67
31
  }
68
- return cmds
69
32
  }
70
33
 
71
- function commandsFromFenceBody(body: string): string[] {
72
- return toCommands(body.split(/\r?\n/))
34
+ function boldHeading(line: string): string | null {
35
+ return /^ {0,3}\*\*(.+?)\*\*[\t ]*$/.exec(line)?.[1]?.trim() ?? null
73
36
  }
74
37
 
75
- /**
76
- * Extract shell commands from a phase package.
77
- * Prefer the fence under a "Verify commands" heading — packages often embed
78
- * earlier ```bash sketches that must not be executed at the commit gate.
79
- */
80
- export function extractVerifyCommands(phasePackageText: string): string[] {
81
- // Heading-scoped fence first (## / ### / **Verify commands**)
82
- const section = phasePackageText.match(
83
- /(?:^|\n)(?:#{1,6}\s*|\*\*)Verify commands(?:\*\*)?\s*\r?\n([\s\S]*?)(?=\n#{1,6}\s|\n\*\*[A-Z]|$)/i,
84
- )
85
- if (section) {
86
- const fence = section[1]!.match(/```(?:sh|bash|shell)\r?\n([\s\S]*?)```/i)
87
- if (fence) {
88
- const cmds = commandsFromFenceBody(fence[1]!)
89
- if (cmds.length) return cmds
38
+ function scanMarkdown(text: string): {
39
+ fences: FenceRegion[]
40
+ headings: Heading[]
41
+ unclosedFence: boolean
42
+ } {
43
+ const fences: FenceRegion[] = []
44
+ const headings: Heading[] = []
45
+ let open:
46
+ | {
47
+ marker: "`" | "~"
48
+ length: number
49
+ start: number
50
+ bodyStart: number
51
+ info: string
52
+ indent: number
53
+ }
54
+ | undefined
55
+
56
+ let offset = 0
57
+ while (offset < text.length) {
58
+ const newline = text.indexOf("\n", offset)
59
+ const lineEnd = newline === -1 ? text.length : newline
60
+ const next = newline === -1 ? text.length : newline + 1
61
+ const line = text.slice(offset, lineEnd)
62
+
63
+ if (open) {
64
+ const closing = /^ {0,3}(`{3,}|~{3,})[\t ]*$/.exec(line)?.[1]
65
+ if (closing?.[0] === open.marker && closing.length >= open.length) {
66
+ fences.push({
67
+ start: open.start,
68
+ bodyStart: open.bodyStart,
69
+ bodyEnd: offset,
70
+ info: open.info,
71
+ indent: open.indent,
72
+ })
73
+ open = undefined
74
+ }
75
+ } else {
76
+ const opening = /^( {0,3})(`{3,}|~{3,})(.*)$/.exec(line)
77
+ const marker = opening?.[2]
78
+ const info = opening?.[3] ?? ""
79
+ if (marker && !(marker[0] === "`" && info.includes("`"))) {
80
+ open = {
81
+ marker: marker[0] as "`" | "~",
82
+ length: marker.length,
83
+ start: offset,
84
+ bodyStart: next,
85
+ info: info.trim(),
86
+ indent: opening[1]!.length,
87
+ }
88
+ } else {
89
+ const atx = atxHeading(line)
90
+ const bold = boldHeading(line)
91
+ if (atx) {
92
+ headings.push({
93
+ start: offset,
94
+ end: next,
95
+ rank: atx.rank,
96
+ text: atx.text,
97
+ })
98
+ } else if (bold !== null) {
99
+ headings.push({ start: offset, end: next, rank: null, text: bold })
100
+ }
101
+ }
90
102
  }
103
+
104
+ offset = next
91
105
  }
92
106
 
93
- // Fallback: last sh/bash fence in the doc (verify blocks are usually last)
94
- const all = [
95
- ...phasePackageText.matchAll(/```(?:sh|bash|shell)\r?\n([\s\S]*?)```/gi),
96
- ]
97
- for (let i = all.length - 1; i >= 0; i--) {
98
- const cmds = commandsFromFenceBody(all[i]![1]!)
99
- if (cmds.length) return cmds
107
+ return { fences, headings, unclosedFence: open !== undefined }
108
+ }
109
+
110
+ function toVerifyBlock(text: string, fence: FenceRegion): VerifyBlock | null {
111
+ const language = fence.info.toLowerCase()
112
+ if (language !== "bash" && language !== "sh" && language !== "shell") {
113
+ return null
114
+ }
115
+
116
+ const body = text.slice(fence.bodyStart, fence.bodyEnd)
117
+ const source =
118
+ fence.indent === 0
119
+ ? body
120
+ : body.replace(new RegExp(`^ {0,${fence.indent}}`, "gm"), "")
121
+ const hasExecutableLine = source
122
+ .split("\n")
123
+ .some((line) => line.trim() !== "" && !line.trimStart().startsWith("#"))
124
+ if (!hasExecutableLine) return null
125
+
126
+ return {
127
+ interpreter: language === "sh" ? "sh" : "bash",
128
+ source,
100
129
  }
130
+ }
101
131
 
102
- // Last resort: $ / test / bun lines scattered through prose.
103
- //
104
- // Continuations join FORWARD FROM A MATCHED COMMAND LINE only, never
105
- // document-wide. This text is markdown, not shell, and a trailing backslash
106
- // on a prose line is a markdown hard break: joining globally glued prose
107
- // onto the command below it, the merged line no longer matched the command
108
- // pattern, and the check was silently dropped — the gate ran a subset of
109
- // the verify commands and committed the phase green. Comments need no
110
- // handling here: a line starting with `#` cannot match the pattern.
111
- const cmds: string[] = []
112
- const rawLines = phasePackageText.split(/\r?\n/)
132
+ function isShellFence(fence: FenceRegion): boolean {
133
+ return /^(?:bash|sh|shell)$/i.test(fence.info)
134
+ }
135
+
136
+ /** Does this line end in an odd run of backslashes? */
137
+ function endsInContinuation(line: string): boolean {
138
+ const run = /(\\+)$/.exec(line)
139
+ return run !== null && run[1]!.length % 2 === 1
140
+ }
141
+
142
+ function extractLegacyBlocks(text: string): VerifyBlock[] {
143
+ const blocks: VerifyBlock[] = []
144
+ const rawLines = text.split("\n")
113
145
  for (let i = 0; i < rawLines.length; i++) {
114
- const m = rawLines[i]!.match(
146
+ const match = rawLines[i]!.match(
115
147
  /^\s*(?:\$\s+)?((?:test |node |npm |bun |bunx |chmod |head ).+)$/,
116
148
  )
117
- if (!m) continue
118
- let cmd = m[1]!
119
- while (endsInContinuation(cmd) && i + 1 < rawLines.length) {
120
- cmd = cmd.slice(0, -1) + rawLines[++i]!
149
+ if (!match) continue
150
+ let source = match[1]!
151
+ while (endsInContinuation(source) && i + 1 < rawLines.length) {
152
+ source = source.slice(0, -1) + rawLines[++i]!
121
153
  }
122
- // Dangling continuation on the final line: drop the backslash rather
123
- // than handing the shell a command that continues into nothing.
124
- if (endsInContinuation(cmd)) cmd = cmd.slice(0, -1)
125
- cmds.push(cmd.trim())
154
+ if (endsInContinuation(source)) source = source.slice(0, -1)
155
+ blocks.push({ interpreter: "bash", source: source.trim() })
126
156
  }
127
- return cmds
157
+ return blocks
158
+ }
159
+
160
+ /**
161
+ * Extract interpreter-aware verification scripts from a phase package.
162
+ * A Verify commands section owns every shell fence below it. Without that
163
+ * section, only the last shell fence is verification, matching legacy package
164
+ * selection without splitting a script into unrelated command lines.
165
+ */
166
+ export function extractVerifyBlocks(phasePackageText: string): VerifyBlock[] {
167
+ const text = normalizeVerifySource(phasePackageText)
168
+ const { fences, headings, unclosedFence } = scanMarkdown(text)
169
+ if (unclosedFence) return []
170
+ const verifyHeading = headings.find(
171
+ (heading) => heading.text.toLowerCase() === "verify commands",
172
+ )
173
+
174
+ if (verifyHeading) {
175
+ const nextHeading = headings.find(
176
+ (heading) =>
177
+ heading.start >= verifyHeading.end &&
178
+ (heading.rank === null ||
179
+ verifyHeading.rank === null ||
180
+ (heading.rank !== null && heading.rank <= verifyHeading.rank)),
181
+ )
182
+ const sectionEnd = nextHeading?.start ?? text.length
183
+ const sectionFences = fences.filter(
184
+ (fence) => fence.start >= verifyHeading.end && fence.start < sectionEnd,
185
+ )
186
+ const blocks = sectionFences
187
+ .filter(isShellFence)
188
+ .map((fence) => toVerifyBlock(text, fence))
189
+ .filter((block): block is VerifyBlock => block !== null)
190
+ return sectionFences.length > 0
191
+ ? blocks
192
+ : extractLegacyBlocks(text.slice(verifyHeading.end, sectionEnd))
193
+ }
194
+
195
+ const shellFences = fences.filter(isShellFence)
196
+ if (shellFences.length > 0) {
197
+ const block = toVerifyBlock(text, shellFences[shellFences.length - 1]!)
198
+ return block ? [block] : []
199
+ }
200
+
201
+ return fences.length === 0 ? extractLegacyBlocks(text) : []
202
+ }
203
+
204
+ /** Render a block for readable logs without leaking its temp path. */
205
+ export function formatVerifyBlock(block: VerifyBlock): string {
206
+ const source = normalizeVerifySource(block.source)
207
+ const body = source.endsWith("\n") ? source.slice(0, -1) : source
208
+ const displayedSource = body
209
+ .split("\n")
210
+ .map((line) => `| ${line}`)
211
+ .join("\n")
212
+ return `${block.interpreter} -e [verification block]\n${displayedSource}`
213
+ }
214
+
215
+ /** Render a verification block as a runnable command without its temp path. */
216
+ export function formatVerifyCommand(block: VerifyBlock): string {
217
+ const source = normalizeVerifySource(block.source)
218
+ const quotedSource = `'${source.replaceAll("'", `'"'"'`)}'`
219
+ return `${block.interpreter} -e -c ${quotedSource}`
128
220
  }
@@ -2,13 +2,13 @@ import { Schema } from "effect"
2
2
  import { err, type ToolErr } from "./result.ts"
3
3
 
4
4
  /** No `.apnea/state.json` for the current project root. */
5
- export class NoRunState extends Schema.TaggedErrorClass<NoRunState>()(
5
+ export class NoRunState extends Schema.TaggedError<NoRunState>()(
6
6
  "NoRunState",
7
7
  {},
8
8
  ) {}
9
9
 
10
10
  /** Tool call refused by the step → legal-tools table. */
11
- export class IllegalTool extends Schema.TaggedErrorClass<IllegalTool>()(
11
+ export class IllegalTool extends Schema.TaggedError<IllegalTool>()(
12
12
  "IllegalTool",
13
13
  {
14
14
  step: Schema.String,
@@ -18,7 +18,7 @@ export class IllegalTool extends Schema.TaggedErrorClass<IllegalTool>()(
18
18
  ) {}
19
19
 
20
20
  /** Dispatch kind refused at the current step. */
21
- export class IllegalKind extends Schema.TaggedErrorClass<IllegalKind>()(
21
+ export class IllegalKind extends Schema.TaggedError<IllegalKind>()(
22
22
  "IllegalKind",
23
23
  {
24
24
  step: Schema.String,
@@ -28,7 +28,7 @@ export class IllegalKind extends Schema.TaggedErrorClass<IllegalKind>()(
28
28
  ) {}
29
29
 
30
30
  /** Global or project config missing, invalid, or untrusted. */
31
- export class ConfigError extends Schema.TaggedErrorClass<ConfigError>()(
31
+ export class ConfigError extends Schema.TaggedError<ConfigError>()(
32
32
  "ConfigError",
33
33
  {
34
34
  message: Schema.String,
@@ -38,7 +38,7 @@ export class ConfigError extends Schema.TaggedErrorClass<ConfigError>()(
38
38
  ) {}
39
39
 
40
40
  /** `state.json` present but not decodable / inconsistent. */
41
- export class StateCorrupt extends Schema.TaggedErrorClass<StateCorrupt>()(
41
+ export class StateCorrupt extends Schema.TaggedError<StateCorrupt>()(
42
42
  "StateCorrupt",
43
43
  {
44
44
  path: Schema.String,
@@ -47,23 +47,32 @@ export class StateCorrupt extends Schema.TaggedErrorClass<StateCorrupt>()(
47
47
  ) {}
48
48
 
49
49
  /** VCS detect / dirty / commit / bookmark failure. */
50
- export class VcsError extends Schema.TaggedErrorClass<VcsError>()("VcsError", {
50
+ export class VcsError extends Schema.TaggedError<VcsError>()("VcsError", {
51
51
  message: Schema.String,
52
52
  command: Schema.optional(Schema.String),
53
53
  }) {}
54
54
 
55
- /** Herdr CLI / pane / floating failure. */
56
- export class HerdrError extends Schema.TaggedErrorClass<HerdrError>()(
57
- "HerdrError",
55
+ /** Another live Apnea process owns the repository mutation lock. */
56
+ export class OperationLocked extends Schema.TaggedError<OperationLocked>()(
57
+ "OperationLocked",
58
58
  {
59
59
  message: Schema.String,
60
- command: Schema.optional(Schema.String),
61
- details: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
60
+ repository: Schema.String,
61
+ lock_path: Schema.String,
62
+ reason: Schema.String,
63
+ pid: Schema.Number,
62
64
  },
63
65
  ) {}
64
66
 
67
+ /** Herdr CLI or pane failure. */
68
+ export class HerdrError extends Schema.TaggedError<HerdrError>()("HerdrError", {
69
+ message: Schema.String,
70
+ command: Schema.optional(Schema.String),
71
+ details: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
72
+ }) {}
73
+
65
74
  /** Commit/review gate refused (e.g. verdict not APPROVED). */
66
- export class GateRefused extends Schema.TaggedErrorClass<GateRefused>()(
75
+ export class GateRefused extends Schema.TaggedError<GateRefused>()(
67
76
  "GateRefused",
68
77
  {
69
78
  gate: Schema.String,
@@ -73,7 +82,7 @@ export class GateRefused extends Schema.TaggedErrorClass<GateRefused>()(
73
82
  ) {}
74
83
 
75
84
  /** `workflow_wait` hit its timeout without a complete artifact. */
76
- export class WaitTimeout extends Schema.TaggedErrorClass<WaitTimeout>()(
85
+ export class WaitTimeout extends Schema.TaggedError<WaitTimeout>()(
77
86
  "WaitTimeout",
78
87
  {
79
88
  artifact: Schema.String,
@@ -83,7 +92,7 @@ export class WaitTimeout extends Schema.TaggedErrorClass<WaitTimeout>()(
83
92
  ) {}
84
93
 
85
94
  /** `workflow_wait` aborted (Esc / cancel signal). */
86
- export class WaitAborted extends Schema.TaggedErrorClass<WaitAborted>()(
95
+ export class WaitAborted extends Schema.TaggedError<WaitAborted>()(
87
96
  "WaitAborted",
88
97
  {
89
98
  artifact: Schema.String,
@@ -91,8 +100,17 @@ export class WaitAborted extends Schema.TaggedErrorClass<WaitAborted>()(
91
100
  },
92
101
  ) {}
93
102
 
103
+ /** A non-wait operation was interrupted by its host cancellation signal. */
104
+ export class OperationAborted extends Schema.TaggedError<OperationAborted>()(
105
+ "OperationAborted",
106
+ {
107
+ operation: Schema.String,
108
+ details: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
109
+ },
110
+ ) {}
111
+
94
112
  /** Artifact exists but front-matter / shape is invalid. */
95
- export class ArtifactInvalid extends Schema.TaggedErrorClass<ArtifactInvalid>()(
113
+ export class ArtifactInvalid extends Schema.TaggedError<ArtifactInvalid>()(
96
114
  "ArtifactInvalid",
97
115
  {
98
116
  artifact: Schema.String,
@@ -101,7 +119,7 @@ export class ArtifactInvalid extends Schema.TaggedErrorClass<ArtifactInvalid>()(
101
119
  ) {}
102
120
 
103
121
  /** Phase-package verify commands failed — commit refused. */
104
- export class VerifyFailed extends Schema.TaggedErrorClass<VerifyFailed>()(
122
+ export class VerifyFailed extends Schema.TaggedError<VerifyFailed>()(
105
123
  "VerifyFailed",
106
124
  {
107
125
  commands: Schema.Array(Schema.String),
@@ -119,10 +137,12 @@ export type AppError =
119
137
  | ConfigError
120
138
  | StateCorrupt
121
139
  | VcsError
140
+ | OperationLocked
122
141
  | HerdrError
123
142
  | GateRefused
124
143
  | WaitTimeout
125
144
  | WaitAborted
145
+ | OperationAborted
126
146
  | ArtifactInvalid
127
147
  | VerifyFailed
128
148
 
@@ -133,10 +153,12 @@ const APP_ERROR_TAG_LIST = [
133
153
  "ConfigError",
134
154
  "StateCorrupt",
135
155
  "VcsError",
156
+ "OperationLocked",
136
157
  "HerdrError",
137
158
  "GateRefused",
138
159
  "WaitTimeout",
139
160
  "WaitAborted",
161
+ "OperationAborted",
140
162
  "ArtifactInvalid",
141
163
  "VerifyFailed",
142
164
  ] as const satisfies readonly AppError["_tag"][]
@@ -204,6 +226,15 @@ export function toToolResult(e: AppError): ToolErr {
204
226
  return err(e.message, {
205
227
  data: e.command !== undefined ? { command: e.command } : undefined,
206
228
  })
229
+ case "OperationLocked":
230
+ return err(e.message, {
231
+ data: {
232
+ repository: e.repository,
233
+ lock_path: e.lock_path,
234
+ reason: e.reason,
235
+ pid: e.pid,
236
+ },
237
+ })
207
238
  case "HerdrError":
208
239
  return err(e.message, {
209
240
  data:
@@ -233,6 +264,10 @@ export function toToolResult(e: AppError): ToolErr {
233
264
  return err("workflow_wait aborted (Esc / cancel)", {
234
265
  data: { artifact: e.artifact, ...(e.details ?? {}) },
235
266
  })
267
+ case "OperationAborted":
268
+ return err(`${e.operation} aborted (signal / cancel)`, {
269
+ data: { operation: e.operation, ...(e.details ?? {}) },
270
+ })
236
271
  case "ArtifactInvalid":
237
272
  return err(e.message, { data: { artifact: e.artifact } })
238
273
  case "VerifyFailed":
@@ -0,0 +1,6 @@
1
+ export type OperationHooks = {
2
+ readonly signal?: AbortSignal
3
+ readonly onUpdate?: (partial: {
4
+ content: Array<{ type: "text"; text: string }>
5
+ }) => void
6
+ }
@@ -18,8 +18,8 @@ import {
18
18
  MAX_AUTO_POLL_MS,
19
19
  MIN_POLL_MS,
20
20
  type WaitParams,
21
- type WaitHooks,
22
21
  } from "./workflows/wait.ts"
22
+ import type { OperationHooks } from "./operation-hooks.ts"
23
23
 
24
24
  export type Operation = {
25
25
  /** Pi tool name, or null when the operation is not model-facing. */
@@ -46,14 +46,14 @@ export type Operation = {
46
46
  type RegisteredOperation = Operation & {
47
47
  readonly run: (
48
48
  params: Record<string, unknown>,
49
- hooks?: WaitHooks,
49
+ hooks?: OperationHooks,
50
50
  ) => Promise<ToolResult>
51
51
  }
52
52
 
53
53
  export type ExecuteOperation = (
54
54
  verb: string,
55
55
  params: Record<string, unknown>,
56
- hooks?: WaitHooks,
56
+ hooks?: OperationHooks,
57
57
  ) => Promise<ToolResult>
58
58
 
59
59
  // Sourced from domain/state-machine.ts (not hardcoded here) so a new kind
@@ -86,8 +86,8 @@ function createRegisteredOperations(
86
86
  force: Type.Optional(Type.Boolean()),
87
87
  agents_md: Type.Optional(Type.Boolean()),
88
88
  }),
89
- run: (p) =>
90
- apneaSetup(p as Parameters<typeof apneaSetup>[0], hostAdapter),
89
+ run: (p, hooks) =>
90
+ apneaSetup(p as Parameters<typeof apneaSetup>[0], hostAdapter, hooks),
91
91
  },
92
92
  {
93
93
  tool: "workflow_start",
@@ -115,7 +115,7 @@ function createRegisteredOperations(
115
115
  // Mirrors the guard in index.ts's execute(): without it, action=start
116
116
  // with no goal reaches slugify(undefined) in workflows/start.ts and
117
117
  // throws instead of returning a clean refusal.
118
- run: (p) => {
118
+ run: (p, hooks) => {
119
119
  const params = p as Parameters<typeof workflowStart>[0]
120
120
  const action = params.action ?? "start"
121
121
  if (action === "start" && !params.goal?.trim()) {
@@ -132,16 +132,17 @@ function createRegisteredOperations(
132
132
  action,
133
133
  },
134
134
  hostAdapter,
135
+ hooks,
135
136
  )
136
137
  },
137
138
  },
138
139
  {
139
140
  tool: "dispatch_role",
140
141
  verb: "dispatch",
141
- usage: "<kind> [--rework]",
142
+ usage: "<kind> [--rework] [--redeliver]",
142
143
  summary: "Write the task file and launch a role in a Herdr pane.",
143
144
  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
+ "One outstanding dispatch at a time. Persisted review state selects rework and advances its round. rework=true is a deprecated assertion through 0.2.x and grants authority only for ambiguous version-1 plan or code migration. Use redeliver=true only to reuse matching pending ownership after proving the prior delivery is dead. A complete pending artifact refuses redelivery; call workflow_wait to ingest it.",
145
146
  params: operationParams({
146
147
  kind: DispatchKind,
147
148
  task_markdown: Type.Optional(
@@ -149,14 +150,22 @@ function createRegisteredOperations(
149
150
  ),
150
151
  rework: Type.Optional(
151
152
  Type.Boolean({
152
- description: "Increment round after CHANGES_REQUIRED",
153
+ description:
154
+ "Deprecated assertion through 0.2.x; persisted state owns rework",
155
+ }),
156
+ ),
157
+ redeliver: Type.Optional(
158
+ Type.Boolean({
159
+ description:
160
+ "Reuse matching pending ownership after the prior delivery is demonstrably dead",
153
161
  }),
154
162
  ),
155
163
  }),
156
- run: (p) =>
164
+ run: (p, hooks) =>
157
165
  workflowDispatch(
158
166
  p as Parameters<typeof workflowDispatch>[0],
159
167
  hostAdapter,
168
+ hooks,
160
169
  ),
161
170
  },
162
171
  {
@@ -180,8 +189,9 @@ function createRegisteredOperations(
180
189
  // moves, and then the schema promises a budget the runtime refuses.
181
190
  params: operationParams({
182
191
  poll_ms: Type.Optional(
183
- Type.Number({
192
+ Type.Integer({
184
193
  minimum: MIN_POLL_MS,
194
+ maximum: Number.MAX_SAFE_INTEGER,
185
195
  description:
186
196
  `Milliseconds between polls. At least ${MIN_POLL_MS} — each poll spawns two herdr subprocesses. ` +
187
197
  `Keep it at or under ${MAX_AUTO_POLL_MS} unless you also pass budget_ms: above that, the floor ` +
@@ -189,7 +199,9 @@ function createRegisteredOperations(
189
199
  }),
190
200
  ),
191
201
  budget_ms: Type.Optional(
192
- Type.Number({
202
+ Type.Integer({
203
+ minimum: 1,
204
+ maximum: Number.MAX_SAFE_INTEGER,
193
205
  description:
194
206
  `How long THIS call may block — not the role's deadline, which comes from config. ` +
195
207
  `Must be at least ${GRACE_MS} + max(${IDLE_NUDGE_AFTER_MS}, ${DEAD_POLLS_NEEDED} x poll_ms), so the call ` +
@@ -223,10 +235,11 @@ function createRegisteredOperations(
223
235
  }),
224
236
  ),
225
237
  }),
226
- run: (p) =>
238
+ run: (p, hooks) =>
227
239
  workflowCommitPhase(
228
240
  p as Parameters<typeof workflowCommitPhase>[0],
229
241
  hostAdapter,
242
+ hooks,
230
243
  ),
231
244
  },
232
245
  {
@@ -236,7 +249,7 @@ function createRegisteredOperations(
236
249
  summary: "Read-only snapshot of run state and legal next calls.",
237
250
  guidance: "Never mutates. Safe to call at any point.",
238
251
  params: operationParams({}),
239
- run: () => workflowStatus(hostAdapter),
252
+ run: (_p, hooks) => workflowStatus(hostAdapter, hooks),
240
253
  },
241
254
  {
242
255
  tool: null,
@@ -254,10 +267,11 @@ function createRegisteredOperations(
254
267
  description: "Round key, e.g. plan_review or phase-01/code_review",
255
268
  }),
256
269
  }),
257
- run: (p) =>
270
+ run: (p, hooks) =>
258
271
  workflowResetRounds(
259
272
  p as Parameters<typeof workflowResetRounds>[0],
260
273
  hostAdapter,
274
+ hooks,
261
275
  ),
262
276
  },
263
277
  ]