@naxodev/apnea 0.1.0 → 0.2.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.
@@ -1,5 +1,5 @@
1
1
  # Harness abstraction: profiles with mode variants
2
2
 
3
- Roles do not carry freeform model strings. Global **profiles** define `cmd_interactive` (required for dispatch) and optional `cmd_oneshot` (unused by regular Apnea dispatch). Every worker role opens the interactive harness TUI in a labeled Herdr pane so humans can watch the session. Binding a role to a profile missing `cmd_interactive` hard-errors at start. Project config may only rebind role→profile names.
3
+ Roles do not carry freeform model strings. Global **profiles** define `cmd_interactive` (required for dispatch) and optional `cmd_oneshot` (unused by Apnea dispatch). Every worker role opens the interactive harness TUI in a labeled Herdr pane so humans can watch the session. Binding a role to a profile missing `cmd_interactive` hard-errors at start. Project config may only rebind role→profile names.
4
4
 
5
5
  Each remembered role pane stores a deterministic fingerprint of the effective profile name and interactive command. Dispatch reuses the pane only when that fingerprint still matches resolved config. Legacy pane records have no fingerprint and are recreated once rather than risking an old harness.
@@ -16,7 +16,7 @@ Both adapters bind the operation registry exported by `@naxodev/apnea`. The Pi a
16
16
 
17
17
  The core exposes only the operation registry, result formatting, command parsing, resource-root lookup, and the `ApneaHostAdapter` launch hooks. Pi supplies those hooks when it creates its registry.
18
18
 
19
- `@naxodev/pi-apnea` depends on `@naxodev/apnea` with `^0.1.0`. Compatible core patch releases flow through that range. An incompatible host interface change requires coordinated minor releases of both packages.
19
+ The current incompatible `@naxodev/pi-apnea` line depends on `@naxodev/apnea` with `^0.2.0`. Compatible core patch releases flow through that range. For an incompatible line, the core manifest is pre-staged at the new minor so the workspace remains installable while Nx coordinates the adapter's minor release.
20
20
 
21
21
  ## Consequences
22
22
 
@@ -52,8 +52,6 @@ Unknown keys and unimplemented values (`isolation: "worktree"`) **hard-error** a
52
52
  "coder": { "profile": "pi-grok" },
53
53
  },
54
54
  "review_round_cap": 3,
55
- // "regular" (default) or "floating"; omit the key to keep the default
56
- "pane_style": "regular",
57
55
  "timeouts_ms": {
58
56
  "planning": 1500000,
59
57
  "plan_review": 900000,
@@ -129,29 +127,16 @@ a slow test run can read `idle` for a minute. The nudge is one-shot, so a false
129
127
  105000ms, leaving roughly 15000ms of margin under a 120000ms shell. Raise one and you must raise
130
128
  the other.
131
129
 
132
- ## Pane style
133
-
134
- `pane_style` controls how role dispatches appear in Herdr:
135
-
136
- - Values: `"regular"` | `"floating"`. Default is `"regular"` — **omitting the key changes nothing**.
137
- - Allowed in global config and overridable per project.
138
- - `"floating"` applies to **planner/reviewer oneshot dispatches only**. It requires herdr ≥ 0.7.4 **and** the linked `apnea` plugin (provisioned by `/apnea setup`). The popup is **session-modal**: it takes keyboard input until the worker exits, and dismissing it early kills the dispatch.
139
- - Floating oneshot scripts resolve `cmd_oneshot[0]` to an **absolute path** in the orchestrator environment and pass an augmented `PATH` into the popup. Bare names must be findable via the orchestrator's `PATH` (e.g. `~/.local/bin`); otherwise dispatch fails immediately instead of the popup exiting 127. Prefer absolute `cmd_oneshot` if your orchestrator process has a stripped PATH.
140
- - **Lifecycle (single popup):** herdr allows only one popup. Dispatch refuses while a prior floating oneshot is still live. The worker writes an exit-status file; `workflow_wait` fails closed when the popup dies without a complete artifact (instead of hanging until timeout). A second open while a popup is up surfaces `popup already open` immediately.
141
- - Interactive roles (orchestrator, coder) always use regular panes; the dispatch result reports `pane_style_effective: "regular (interactive role)"`.
142
- - Misconfiguration (old herdr, missing plugin, missing `cmd_oneshot`, unresolved oneshot binary) fails fast at dispatch with an actionable error.
143
- - Non-goal: per-role `pane_style` is future work. Setup never writes or flips this key — it only preserves a valid existing value on re-run.
144
-
145
130
  ## Role modes (fixed)
146
131
 
147
- By default every worker role launches the **interactive** harness TUI so you can watch it in Herdr. With `pane_style: "floating"`, planner and reviewer instead run their profile's **`cmd_oneshot`** inside the popup (those profiles therefore need `cmd_oneshot` for floating dispatch preflight errors otherwise). Interactive roles always stay on `cmd_interactive`.
132
+ Every worker role launches the **interactive** harness TUI in a reusable Herdr pane. Apnea dispatch always resolves `cmd_interactive`; a profile that only has `cmd_oneshot` cannot satisfy any role. `cmd_oneshot` remains part of the profile contract for manual and external workflows.
148
133
 
149
- | Role | Required profile capability |
150
- | ------------ | ------------------------------------------------------------------- |
151
- | orchestrator | `cmd_interactive` |
152
- | planner | `cmd_interactive` (and `cmd_oneshot` when `pane_style: "floating"`) |
153
- | reviewer | `cmd_interactive` (and `cmd_oneshot` when `pane_style: "floating"`) |
154
- | coder | `cmd_interactive` |
134
+ | Role | Required profile capability |
135
+ | ------------ | --------------------------- |
136
+ | orchestrator | `cmd_interactive` |
137
+ | planner | `cmd_interactive` |
138
+ | reviewer | `cmd_interactive` |
139
+ | coder | `cmd_interactive` |
155
140
 
156
141
  Binding a role to a profile missing the required capability → **hard-error**.
157
142
 
@@ -24,7 +24,7 @@ Two phases, e.g. “CLI that greets by name + `--json` flag,” small enough to
24
24
  | 3 | Code CHANGES_REQUIRED | Live follow-up into existing coder pane; new `round-N` code-review; then APPROVED |
25
25
  | 4 | APPROVED + nits | One review with `nits` field; commit still proceeds |
26
26
  | 5 | Kill-and-resume | Kill coder pane mid-phase; human reconciles per protocol (artifact present? ingest : re-dispatch same round); continue to success |
27
- | 6 | Mixed harness | Planner/reviewer Claude oneshot; coder Pi interactive |
27
+ | 6 | Mixed harness | Planner/reviewer Claude interactive; coder Pi interactive |
28
28
 
29
29
  ## Hard rules during the gate
30
30
 
@@ -67,7 +67,7 @@ nits: | # optional; never blocks alone
67
67
  ---
68
68
  ```
69
69
 
70
- Pane markers are human decoration. Herdr `agent_status` is liveness (dead pane / hung oneshot without artifact → escalate).
70
+ Pane markers are human decoration. Herdr `agent_status` is liveness (dead pane without artifact → escalate).
71
71
 
72
72
  ## Rework
73
73
 
@@ -1,52 +1,3 @@
1
- import type { PaneStyle, Role } from "./types.ts"
2
-
3
- /** Parse `herdr X.Y.Z` (or noisy multi-line) into a numeric tuple. */
4
- export function parseHerdrVersion(
5
- raw: string,
6
- ): [number, number, number] | null {
7
- const m = raw.match(/(\d+)\.(\d+)\.(\d+)/)
8
- if (!m) return null
9
- return [Number(m[1]), Number(m[2]), Number(m[3])]
10
- }
11
-
12
- export function versionGte(
13
- a: [number, number, number],
14
- b: [number, number, number],
15
- ): boolean {
16
- const [a0, a1, a2] = a
17
- const [b0, b1, b2] = b
18
- if (a0 !== b0) return a0 > b0
19
- if (a1 !== b1) return a1 > b1
20
- return a2 >= b2
21
- }
22
-
23
- /**
24
- * Floating popups need herdr ≥ 0.7.4. Fail closed on unparseable versions so an
25
- * unattended run never hangs on a CLI that rejects `--placement popup`.
26
- */
27
- export function supportsFloating(
28
- version: [number, number, number] | null,
29
- ): boolean {
30
- return version != null && versionGte(version, [0, 7, 4])
31
- }
32
-
33
- /**
34
- * Configured style vs effective style. Floating is only for planner/reviewer
35
- * (oneshot-eligible artifact producers); interactive roles always stay regular.
36
- */
37
- export function effectivePaneStyle(
38
- configured: PaneStyle,
39
- role: Role,
40
- ): { style: PaneStyle; effective: string } {
41
- if (configured === "regular") {
42
- return { style: "regular", effective: "regular" }
43
- }
44
- if (role === "planner" || role === "reviewer") {
45
- return { style: "floating", effective: "floating" }
46
- }
47
- return { style: "regular", effective: "regular (interactive role)" }
48
- }
49
-
50
1
  export function shellJoin(parts: string[]): string {
51
2
  return parts
52
3
  .map((p) => {
@@ -70,40 +21,3 @@ export function looksLikeShellOnly(names: string[]): boolean {
70
21
  )
71
22
  })
72
23
  }
73
-
74
- /** Parse a floating task's exit-file contents; null if not a finished exit code. */
75
- export function parseFloatingExit(text: string): number | null {
76
- const t = text.trim()
77
- const n = Number.parseInt(t, 10)
78
- return Number.isFinite(n) ? n : null
79
- }
80
-
81
- /**
82
- * Self-contained bash script body that cds to root, runs cmd + prompt as a
83
- * child (not exec — so EXIT trap still fires), and always records the exit
84
- * code for workflow_wait. Popups have no pane id; the exit file is the
85
- * liveness signal.
86
- */
87
- export function floatingTaskScriptBody(opts: {
88
- root: string
89
- resolvedCmd: string[]
90
- prompt: string
91
- exitFileAbs: string
92
- }): string {
93
- return [
94
- "#!/bin/bash",
95
- "set -uo pipefail",
96
- `EXIT_FILE=${shellJoin([opts.exitFileAbs])}`,
97
- "write_exit() {",
98
- " local st=$?",
99
- ` printf '%s\n' "$st" > "$EXIT_FILE" 2>/dev/null || true`,
100
- "}",
101
- "trap write_exit EXIT",
102
- "trap 'exit 129' HUP",
103
- "trap 'exit 130' INT",
104
- "trap 'exit 143' TERM",
105
- shellJoin(["cd", opts.root]),
106
- shellJoin([...opts.resolvedCmd, "--", opts.prompt]),
107
- "",
108
- ].join("\n")
109
- }
@@ -107,8 +107,7 @@ export function findPackageRootFrom(startDir: string): string | null {
107
107
  }
108
108
 
109
109
  /**
110
- * Package root — the directory holding `briefs/`, `herdr-plugin/`, and our
111
- * own `package.json`.
110
+ * Package root — the directory holding `briefs/` and our own `package.json`.
112
111
  *
113
112
  * Found by walking up and reading each `package.json`, NOT by counting
114
113
  * directory levels. The level count differs for every way this code runs, and
@@ -21,19 +21,6 @@ export function deepMergeProfiles(
21
21
  return out
22
22
  }
23
23
 
24
- /**
25
- * Carry a valid user pane_style preference forward. Setup never writes the
26
- * key when absent, and never invents values — only preserves exact "regular"
27
- * or "floating". Invalid prev values are dropped.
28
- */
29
- export function preservePaneStyle(
30
- prev: Record<string, unknown>,
31
- ): "regular" | "floating" | undefined {
32
- const v = prev.pane_style
33
- if (v === "regular" || v === "floating") return v
34
- return undefined
35
- }
36
-
37
24
  export function buildProfiles(has: Detected): Record<string, unknown> {
38
25
  const profiles: Record<string, unknown> = {}
39
26
 
@@ -119,8 +106,6 @@ export function buildGlobalConfig(opts: {
119
106
  )
120
107
  }
121
108
 
122
- const preservedPaneStyle = preservePaneStyle(prev)
123
-
124
109
  const globalConfig: Record<string, unknown> = {
125
110
  profiles: nextProfiles,
126
111
  roles: force || !prev.roles ? roles : prev.roles,
@@ -133,11 +118,6 @@ export function buildGlobalConfig(opts: {
133
118
  // values wait/commit actually use cannot drift apart.
134
119
  { ...DEFAULT_TIMEOUTS },
135
120
  }
136
- // Preserve user opt-in only — never introduce pane_style when absent.
137
- if (preservedPaneStyle !== undefined) {
138
- globalConfig.pane_style = preservedPaneStyle
139
- }
140
-
141
121
  return globalConfig
142
122
  }
143
123
 
@@ -12,8 +12,6 @@ export type Role = "orchestrator" | "planner" | "reviewer" | "coder"
12
12
 
13
13
  export type RoleMode = "oneshot" | "interactive"
14
14
 
15
- export type PaneStyle = "regular" | "floating"
16
-
17
15
  export type Verdict = "APPROVED" | "CHANGES_REQUIRED"
18
16
 
19
17
  export type ReworkTarget = "code" | "phase_package"
@@ -34,7 +32,6 @@ export interface ApneaConfig {
34
32
  roles: Record<string, RoleBinding>
35
33
  review_round_cap: number
36
34
  timeouts_ms: Record<string, number>
37
- pane_style: PaneStyle
38
35
  }
39
36
 
40
37
  export interface RunState {
@@ -57,12 +54,6 @@ export interface RunState {
57
54
  pending_pane_id: string | null
58
55
  /** Label of that pane (apnea:role:unique) */
59
56
  pending_pane_label: string | null
60
- /**
61
- * Relative path to the floating oneshot exit-status file (written when the
62
- * popup worker process ends). Null for regular panes. Herdr popups have no
63
- * pane id — this is how wait detects death without hanging until timeout.
64
- */
65
- pending_floating_exit: string | null
66
57
  /**
67
58
  * Epoch ms when the in-flight dispatch was launched. Null when idle.
68
59
  * Persisted so a chunked `workflow_wait` measures elapsed time from the
@@ -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
  }
@@ -52,7 +52,7 @@ export class VcsError extends Schema.TaggedErrorClass<VcsError>()("VcsError", {
52
52
  command: Schema.optional(Schema.String),
53
53
  }) {}
54
54
 
55
- /** Herdr CLI / pane / floating failure. */
55
+ /** Herdr CLI or pane failure. */
56
56
  export class HerdrError extends Schema.TaggedErrorClass<HerdrError>()(
57
57
  "HerdrError",
58
58
  {