@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.
- package/README.md +8 -10
- package/dist/cli.js +552 -530
- package/docs/adr/0005-harness-profiles.md +1 -1
- package/docs/adr/0010-package-split.md +1 -1
- package/docs/protocol/config.md +7 -22
- package/docs/protocol/manual-gate.md +1 -1
- package/docs/protocol/overview.md +1 -1
- package/extension/domain/herdr.ts +0 -86
- package/extension/domain/paths.ts +1 -2
- package/extension/domain/setup.ts +0 -20
- package/extension/domain/types.ts +0 -9
- package/extension/domain/verify-commands.ts +200 -108
- package/extension/errors.ts +1 -1
- package/extension/schema/config.ts +31 -17
- package/extension/schema/state.ts +16 -3
- package/extension/services/herdr.ts +5 -161
- package/extension/services/vcs.ts +393 -21
- package/extension/workflows/commit.ts +8 -5
- package/extension/workflows/dispatch.ts +60 -177
- package/extension/workflows/setup.ts +2 -109
- package/extension/workflows/start.ts +0 -1
- package/extension/workflows/wait.ts +1 -57
- package/package.json +1 -2
- package/schemas/config.schema.json +6 -6
- package/schemas/state.schema.json +5 -1
- package/herdr-plugin/herdr-plugin.toml +0 -15
- package/herdr-plugin/scripts/run-task.sh +0 -8
|
@@ -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
|
|
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.
|
|
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
|
|
package/docs/protocol/config.md
CHANGED
|
@@ -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
|
-
|
|
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`
|
|
153
|
-
| reviewer | `cmd_interactive`
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
2
|
-
|
|
3
|
-
|
|
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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
|
72
|
-
return
|
|
34
|
+
function boldHeading(line: string): string | null {
|
|
35
|
+
return /^ {0,3}\*\*(.+?)\*\*[\t ]*$/.exec(line)?.[1]?.trim() ?? null
|
|
73
36
|
}
|
|
74
37
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
|
146
|
+
const match = rawLines[i]!.match(
|
|
115
147
|
/^\s*(?:\$\s+)?((?:test |node |npm |bun |bunx |chmod |head ).+)$/,
|
|
116
148
|
)
|
|
117
|
-
if (!
|
|
118
|
-
let
|
|
119
|
-
while (endsInContinuation(
|
|
120
|
-
|
|
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
|
-
|
|
123
|
-
|
|
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
|
|
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
|
}
|
package/extension/errors.ts
CHANGED
|
@@ -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
|
|
55
|
+
/** Herdr CLI or pane failure. */
|
|
56
56
|
export class HerdrError extends Schema.TaggedErrorClass<HerdrError>()(
|
|
57
57
|
"HerdrError",
|
|
58
58
|
{
|