@naxodev/apnea 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/CONTEXT.md +61 -0
  2. package/CONTRIBUTING.md +21 -0
  3. package/LICENSE +21 -0
  4. package/README.md +163 -0
  5. package/SECURITY.md +35 -0
  6. package/briefs/coder.md +40 -0
  7. package/briefs/orchestrator.md +49 -0
  8. package/briefs/planner.md +54 -0
  9. package/briefs/reviewer.md +40 -0
  10. package/dist/cli.js +39397 -0
  11. package/docs/adr/0001-completion-signaling.md +3 -0
  12. package/docs/adr/0002-orchestrator-authority.md +3 -0
  13. package/docs/adr/0003-verify-at-gate.md +3 -0
  14. package/docs/adr/0004-artifact-layout-and-naming.md +3 -0
  15. package/docs/adr/0005-harness-profiles.md +5 -0
  16. package/docs/adr/0006-config-trust-model.md +3 -0
  17. package/docs/adr/0007-jj-first-commits.md +3 -0
  18. package/docs/adr/0008-effect-v4-internals.md +3 -0
  19. package/docs/adr/0009-cli-driver-split.md +9 -0
  20. package/docs/adr/0010-package-split.md +23 -0
  21. package/docs/protocol/artifacts.md +68 -0
  22. package/docs/protocol/config.md +186 -0
  23. package/docs/protocol/manual-gate.md +38 -0
  24. package/docs/protocol/overview.md +96 -0
  25. package/extension/adapters/commit.ts +15 -0
  26. package/extension/adapters/dispatch.ts +15 -0
  27. package/extension/adapters/setup.ts +34 -0
  28. package/extension/adapters/start.ts +16 -0
  29. package/extension/adapters/status.ts +24 -0
  30. package/extension/adapters/wait.ts +20 -0
  31. package/extension/api.ts +16 -0
  32. package/extension/cli/format.ts +44 -0
  33. package/extension/cli/human-gate.ts +44 -0
  34. package/extension/cli/main.ts +218 -0
  35. package/extension/cli/parse.ts +48 -0
  36. package/extension/domain/artifact-kind.ts +26 -0
  37. package/extension/domain/frontmatter.ts +69 -0
  38. package/extension/domain/herdr.ts +109 -0
  39. package/extension/domain/paths.ts +139 -0
  40. package/extension/domain/recovery.ts +25 -0
  41. package/extension/domain/rounds.ts +16 -0
  42. package/extension/domain/setup.ts +158 -0
  43. package/extension/domain/slug.ts +9 -0
  44. package/extension/domain/state-machine.ts +132 -0
  45. package/extension/domain/timeouts.ts +24 -0
  46. package/extension/domain/types.ts +145 -0
  47. package/extension/domain/verify-commands.ts +128 -0
  48. package/extension/errors.ts +247 -0
  49. package/extension/host-adapter.ts +8 -0
  50. package/extension/registry.ts +323 -0
  51. package/extension/result.ts +55 -0
  52. package/extension/run-tool.ts +43 -0
  53. package/extension/schema/config.ts +315 -0
  54. package/extension/schema/frontmatter.ts +34 -0
  55. package/extension/schema/state.ts +119 -0
  56. package/extension/services/app-live.ts +24 -0
  57. package/extension/services/config.ts +103 -0
  58. package/extension/services/file-system.ts +178 -0
  59. package/extension/services/herdr.ts +860 -0
  60. package/extension/services/run-store.ts +99 -0
  61. package/extension/services/vcs.ts +246 -0
  62. package/extension/workflows/commit.ts +148 -0
  63. package/extension/workflows/dispatch.ts +693 -0
  64. package/extension/workflows/reset.ts +26 -0
  65. package/extension/workflows/setup.ts +301 -0
  66. package/extension/workflows/start.ts +149 -0
  67. package/extension/workflows/status.ts +45 -0
  68. package/extension/workflows/wait.ts +793 -0
  69. package/herdr-plugin/herdr-plugin.toml +15 -0
  70. package/herdr-plugin/scripts/run-task.sh +8 -0
  71. package/package.json +75 -0
  72. package/schemas/artifact-frontmatter.md +38 -0
  73. package/schemas/config.schema.json +50 -0
  74. package/schemas/state.schema.json +63 -0
@@ -0,0 +1,25 @@
1
+ import type { RunState } from "./types.ts"
2
+
3
+ /**
4
+ * Clear the recovery ladder's per-dispatch state.
5
+ *
6
+ * These fields are facts about ONE in-flight dispatch: whether it was nudged,
7
+ * whether it consumed its one-time extension, whether it took the final grace.
8
+ * A new dispatch must start from none of them, and an ingested artifact must
9
+ * leave none behind.
10
+ *
11
+ * A shared helper because the block was previously copy-pasted at four sites
12
+ * (`start`, both `dispatch` save paths, and `wait`'s advance). Adding a rung
13
+ * meant editing four places, and missing one leaves a stale flag that makes
14
+ * the new rung fire immediately against a freshly dispatched role — the exact
15
+ * failure class that made this file's history what it is.
16
+ *
17
+ * `pending_started_at` and `pending_deadline_ms` are deliberately NOT reset
18
+ * here: dispatch sets them to real values in the same breath, and clearing
19
+ * them first would let a caller observe a pending role with no deadline.
20
+ */
21
+ export function resetRecoveryLadder(state: RunState): void {
22
+ state.pending_nudged_at = null
23
+ state.pending_final_grace = false
24
+ state.pending_extended = false
25
+ }
@@ -0,0 +1,16 @@
1
+ import type { RunState } from "./types.ts"
2
+
3
+ export function roundKey(phaseIndex: number, gate: string): string {
4
+ if (gate === "plan_review") return "plan_review"
5
+ if (gate === "finishing") return "finishing"
6
+ const n = String(phaseIndex).padStart(2, "0")
7
+ return `phase-${n}/${gate}`
8
+ }
9
+
10
+ export function getRound(state: RunState, key: string): number {
11
+ return state.rounds[key] ?? 1
12
+ }
13
+
14
+ export function setRound(state: RunState, key: string, n: number): void {
15
+ state.rounds[key] = n
16
+ }
@@ -0,0 +1,158 @@
1
+ import { DEFAULT_TIMEOUTS } from "./types.ts"
2
+
3
+ export type Detected = {
4
+ pi: boolean
5
+ claude: boolean
6
+ codex: boolean
7
+ herdr: boolean
8
+ jj: boolean
9
+ git: boolean
10
+ }
11
+
12
+ /** Existing keys win over incoming (never overwrite a user's edited profile). */
13
+ export function deepMergeProfiles(
14
+ existing: Record<string, unknown>,
15
+ incoming: Record<string, unknown>,
16
+ ): Record<string, unknown> {
17
+ const out = { ...existing }
18
+ for (const [k, v] of Object.entries(incoming)) {
19
+ if (!(k in out)) out[k] = v
20
+ }
21
+ return out
22
+ }
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
+ export function buildProfiles(has: Detected): Record<string, unknown> {
38
+ const profiles: Record<string, unknown> = {}
39
+
40
+ if (has.pi) {
41
+ // Interactive launches inject PI_CODING_AGENT_DIR without pi-vimmode
42
+ // through the Pi host adapter, so profiles stay portable.
43
+ profiles["pi-grok"] = {
44
+ cmd_interactive: ["pi", "--provider", "grok-cli", "--model", "grok-4.5"],
45
+ cmd_oneshot: [
46
+ "pi",
47
+ "-p",
48
+ "--provider",
49
+ "grok-cli",
50
+ "--model",
51
+ "grok-4.5",
52
+ ],
53
+ }
54
+ profiles["pi-default"] = {
55
+ cmd_interactive: ["pi"],
56
+ cmd_oneshot: ["pi", "-p"],
57
+ }
58
+ }
59
+
60
+ if (has.claude) {
61
+ // Interactive TUI only for Apnea dispatch (watchable in Herdr).
62
+ // cmd_oneshot kept optional for other tooling; Apnea does not use it.
63
+ profiles["claude-fable"] = {
64
+ cmd_interactive: ["claude", "--model", "claude-fable-5"],
65
+ cmd_oneshot: [
66
+ "claude",
67
+ "-p",
68
+ "--model",
69
+ "claude-fable-5",
70
+ "--allowedTools",
71
+ "Read,Write,Edit,Glob,Grep",
72
+ ],
73
+ }
74
+ }
75
+ if (has.codex) {
76
+ profiles["codex"] = {
77
+ cmd_interactive: ["codex"],
78
+ cmd_oneshot: ["codex", "exec"],
79
+ }
80
+ }
81
+
82
+ return profiles
83
+ }
84
+
85
+ export function pickRoles(has: Detected): Record<string, { profile: string }> {
86
+ const defaultProfile = has.pi
87
+ ? "pi-grok"
88
+ : has.claude
89
+ ? "claude-fable"
90
+ : "codex"
91
+ const reviewProfile = has.claude
92
+ ? "claude-fable"
93
+ : has.codex
94
+ ? "codex"
95
+ : "pi-default"
96
+
97
+ return {
98
+ orchestrator: { profile: defaultProfile },
99
+ planner: { profile: reviewProfile },
100
+ reviewer: { profile: reviewProfile },
101
+ coder: { profile: defaultProfile },
102
+ }
103
+ }
104
+
105
+ export function buildGlobalConfig(opts: {
106
+ has: Detected
107
+ prev: Record<string, unknown>
108
+ force: boolean
109
+ }): Record<string, unknown> {
110
+ const { has, prev, force } = opts
111
+ const profiles = buildProfiles(has)
112
+ const roles = pickRoles(has)
113
+
114
+ let nextProfiles = profiles
115
+ if (!force && prev.profiles && typeof prev.profiles === "object") {
116
+ nextProfiles = deepMergeProfiles(
117
+ prev.profiles as Record<string, unknown>,
118
+ profiles,
119
+ )
120
+ }
121
+
122
+ const preservedPaneStyle = preservePaneStyle(prev)
123
+
124
+ const globalConfig: Record<string, unknown> = {
125
+ profiles: nextProfiles,
126
+ roles: force || !prev.roles ? roles : prev.roles,
127
+ review_round_cap:
128
+ typeof prev.review_round_cap === "number" ? prev.review_round_cap : 3,
129
+ timeouts_ms:
130
+ prev.timeouts_ms && typeof prev.timeouts_ms === "object"
131
+ ? prev.timeouts_ms
132
+ : // Seed from the runtime defaults so the written template and the
133
+ // values wait/commit actually use cannot drift apart.
134
+ { ...DEFAULT_TIMEOUTS },
135
+ }
136
+ // Preserve user opt-in only — never introduce pane_style when absent.
137
+ if (preservedPaneStyle !== undefined) {
138
+ globalConfig.pane_style = preservedPaneStyle
139
+ }
140
+
141
+ return globalConfig
142
+ }
143
+
144
+ export function detectionNotes(has: Detected): string[] {
145
+ const missing: string[] = []
146
+ if (!has.claude && !has.codex) {
147
+ missing.push(
148
+ "no claude/codex — planner/reviewer bound to pi-default (edit global profiles to change)",
149
+ )
150
+ }
151
+ if (!has.herdr) {
152
+ missing.push("herdr not on PATH — pane launch will fail until installed")
153
+ }
154
+ if (!has.jj && !has.git) {
155
+ missing.push("neither jj nor git on PATH — commits will refuse")
156
+ }
157
+ return missing
158
+ }
@@ -0,0 +1,9 @@
1
+ export function slugify(s: string): string {
2
+ return (
3
+ s
4
+ .toLowerCase()
5
+ .replace(/[^a-z0-9]+/g, "-")
6
+ .replace(/^-|-$/g, "")
7
+ .slice(0, 48) || "run"
8
+ )
9
+ }
@@ -0,0 +1,132 @@
1
+ import { Result } from "effect"
2
+ import { IllegalTool } from "../errors.ts"
3
+ import type { ReworkTarget, Role, Step } from "./types.ts"
4
+
5
+ /** Legal next steps after a successful transition from `step`. */
6
+ export const LEGAL_TOOLS: Record<
7
+ Step,
8
+ Array<
9
+ | "workflow_start"
10
+ | "dispatch_role"
11
+ | "workflow_wait"
12
+ | "workflow_commit_phase"
13
+ | "workflow_status"
14
+ >
15
+ > = {
16
+ planning: ["dispatch_role", "workflow_wait", "workflow_status"],
17
+ plan_review: ["dispatch_role", "workflow_wait", "workflow_status"],
18
+ phase_packaging: ["dispatch_role", "workflow_wait", "workflow_status"],
19
+ coding: ["dispatch_role", "workflow_wait", "workflow_status"],
20
+ code_review: ["dispatch_role", "workflow_wait", "workflow_status"],
21
+ committing: ["workflow_commit_phase", "workflow_status"],
22
+ finishing: ["dispatch_role", "workflow_wait", "workflow_status"],
23
+ done: ["workflow_status"],
24
+ }
25
+
26
+ export type ToolName = (typeof LEGAL_TOOLS)[Step][number]
27
+
28
+ /**
29
+ * Actionable next calls for a step: `LEGAL_TOOLS` minus the read-only
30
+ * snapshot. An agent follows this list literally, so it must contain only
31
+ * calls that move the run forward. The human-only cap reset (`reset-rounds`)
32
+ * is never a Pi tool at all — see `registry.ts` — so it never appears here.
33
+ */
34
+ export function nextAfter(step: Step): ToolName[] {
35
+ return LEGAL_TOOLS[step].filter((t) => t !== "workflow_status")
36
+ }
37
+
38
+ /**
39
+ * Single source of truth for dispatch kinds — the Pi tool param schema
40
+ * (`extension/index.ts`), the `state.json` codec (`schema/state.ts`) and the
41
+ * `/apnea dispatch` completion list all derive from this tuple.
42
+ */
43
+ export const DISPATCH_KINDS = [
44
+ "plan",
45
+ "plan_review",
46
+ "phase_package",
47
+ "code",
48
+ "code_review",
49
+ "pr_description",
50
+ ] as const
51
+
52
+ export type DispatchKind = (typeof DISPATCH_KINDS)[number]
53
+
54
+ export function expectedRole(kind: DispatchKind): Role {
55
+ switch (kind) {
56
+ case "plan":
57
+ case "phase_package":
58
+ case "pr_description":
59
+ return "planner"
60
+ case "plan_review":
61
+ case "code_review":
62
+ return "reviewer"
63
+ case "code":
64
+ return "coder"
65
+ }
66
+ }
67
+
68
+ export function allowedKinds(step: Step): DispatchKind[] {
69
+ switch (step) {
70
+ case "planning":
71
+ return ["plan"]
72
+ case "plan_review":
73
+ return ["plan_review", "plan"] // plan = rework after CHANGES_REQUIRED
74
+ case "phase_packaging":
75
+ return ["phase_package"]
76
+ case "coding":
77
+ return ["code"]
78
+ case "code_review":
79
+ return ["code_review", "code"]
80
+ case "finishing":
81
+ return ["pr_description"]
82
+ default:
83
+ return []
84
+ }
85
+ }
86
+
87
+ export function stepAfterArtifact(
88
+ kind: DispatchKind,
89
+ verdict: string | undefined,
90
+ rework?: ReworkTarget,
91
+ ): Step | { error: string } {
92
+ switch (kind) {
93
+ case "plan":
94
+ return "plan_review"
95
+ case "plan_review":
96
+ if (verdict === "APPROVED") return "phase_packaging"
97
+ if (verdict === "CHANGES_REQUIRED") return "planning"
98
+ return { error: "plan_review artifact missing verdict" }
99
+ case "phase_package":
100
+ return "coding"
101
+ case "code":
102
+ return "code_review"
103
+ case "code_review":
104
+ if (verdict === "APPROVED") return "committing"
105
+ if (verdict === "CHANGES_REQUIRED")
106
+ return rework === "phase_package" ? "phase_packaging" : "coding"
107
+ return { error: "code_review artifact missing verdict" }
108
+ case "pr_description":
109
+ return "done"
110
+ }
111
+ }
112
+
113
+ /**
114
+ * Policy check: is `tool` legal at `step`?
115
+ * Returns Result.fail(IllegalTool) instead of throwing (no Object.assign cast).
116
+ */
117
+ export function toolAllowed(
118
+ step: Step,
119
+ tool: ToolName,
120
+ ): Result.Result<void, IllegalTool> {
121
+ const legal = LEGAL_TOOLS[step]
122
+ if (!legal.includes(tool as (typeof legal)[number])) {
123
+ return Result.fail(
124
+ new IllegalTool({
125
+ step,
126
+ tool,
127
+ legal: [...legal],
128
+ }),
129
+ )
130
+ }
131
+ return Result.succeed(undefined)
132
+ }
@@ -0,0 +1,24 @@
1
+ import type { DispatchKind } from "./state-machine.ts"
2
+
3
+ /**
4
+ * Dispatch kind → the `timeouts_ms` key documented in docs/protocol/config.md.
5
+ * Non-nullable on purpose: a kind mapped to `null` silently ignores whatever
6
+ * the user configured, and reports the default back as if it were their value.
7
+ */
8
+ const STEP_KEY: Record<DispatchKind, string> = {
9
+ plan: "planning",
10
+ plan_review: "plan_review",
11
+ phase_package: "phase_packaging",
12
+ code: "coding",
13
+ code_review: "code_review",
14
+ pr_description: "finishing",
15
+ }
16
+
17
+ export const DEFAULT_TIMEOUT_MS = 900_000
18
+
19
+ export function timeoutMsForKind(
20
+ kind: DispatchKind,
21
+ timeouts: Record<string, number>,
22
+ ): number {
23
+ return timeouts[STEP_KEY[kind]] ?? timeouts.default ?? DEFAULT_TIMEOUT_MS
24
+ }
@@ -0,0 +1,145 @@
1
+ export type Step =
2
+ | "planning"
3
+ | "plan_review"
4
+ | "phase_packaging"
5
+ | "coding"
6
+ | "code_review"
7
+ | "committing"
8
+ | "finishing"
9
+ | "done"
10
+
11
+ export type Role = "orchestrator" | "planner" | "reviewer" | "coder"
12
+
13
+ export type RoleMode = "oneshot" | "interactive"
14
+
15
+ export type PaneStyle = "regular" | "floating"
16
+
17
+ export type Verdict = "APPROVED" | "CHANGES_REQUIRED"
18
+
19
+ export type ReworkTarget = "code" | "phase_package"
20
+
21
+ export type VcsBackend = "jj" | "git"
22
+
23
+ export interface Profile {
24
+ cmd_oneshot?: string[]
25
+ cmd_interactive?: string[]
26
+ }
27
+
28
+ export interface RoleBinding {
29
+ profile: string
30
+ }
31
+
32
+ export interface ApneaConfig {
33
+ profiles: Record<string, Profile>
34
+ roles: Record<string, RoleBinding>
35
+ review_round_cap: number
36
+ timeouts_ms: Record<string, number>
37
+ pane_style: PaneStyle
38
+ }
39
+
40
+ export interface RunState {
41
+ version: 1
42
+ slug: string
43
+ step: Step
44
+ phase_index: number
45
+ phase_count_hint: number | null
46
+ /** Keys: plan_review | phase-NN/code_review | phase-NN/coding | finishing */
47
+ rounds: Record<string, number>
48
+ vcs: VcsBackend
49
+ allow_dirty: boolean
50
+ goal: string
51
+ last_error: string | null
52
+ /** Relative artifact path expected for the in-flight dispatch, if any */
53
+ pending_artifact: string | null
54
+ /** Role for pending dispatch */
55
+ pending_role: Role | null
56
+ /** Herdr pane id for the in-flight dispatch */
57
+ pending_pane_id: string | null
58
+ /** Label of that pane (apnea:role:unique) */
59
+ 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
+ /**
67
+ * Epoch ms when the in-flight dispatch was launched. Null when idle.
68
+ * Persisted so a chunked `workflow_wait` measures elapsed time from the
69
+ * dispatch, not from the start of the current process.
70
+ */
71
+ pending_started_at: number | null
72
+ /**
73
+ * Epoch ms after which the in-flight dispatch is considered timed out.
74
+ * Extensions granted by the recovery ladder move this forward and are
75
+ * saved, so the budget cannot be silently reset by re-invoking wait.
76
+ */
77
+ pending_deadline_ms: number | null
78
+ /**
79
+ * Epoch ms of the last idle-nudge sent to the role pane. Persisted so a
80
+ * fresh process does not re-nudge a role it already nudged.
81
+ */
82
+ pending_nudged_at: number | null
83
+ /**
84
+ * True once the final-nudge rung has granted its 180s grace. Persisted for
85
+ * the same reason as `pending_extended`: as a per-call local the grace was
86
+ * re-granted by every new `wait`, so a role behind a pane that cannot be
87
+ * prompted extended its own deadline forever and never timed out.
88
+ */
89
+ pending_final_grace: boolean
90
+ /** True once the one-time deadline extension has been consumed. */
91
+ pending_extended: boolean
92
+ /**
93
+ * Last known live pane per role, keyed by role name.
94
+ * Reuse requires its pane_id and effective profile fingerprint.
95
+ * Labels are never scanned because they are ambiguous.
96
+ */
97
+ role_panes: Partial<
98
+ Record<
99
+ Role,
100
+ { pane_id: string; label: string; profile_fingerprint: string | null }
101
+ >
102
+ >
103
+ /** Absolute path to package root (briefs) */
104
+ package_root: string
105
+ /** Tree snapshot fingerprint before reviewer dispatch */
106
+ reviewer_tree_fingerprint: string | null
107
+ /** Last known phase package path for verify/commit */
108
+ current_phase_package: string | null
109
+ /** Last code-review path for commit gate */
110
+ current_code_review: string | null
111
+ /** The next phase-package dispatch must consume planner-owned rework. */
112
+ phase_package_rework: boolean
113
+ }
114
+
115
+ export interface FrontMatter {
116
+ status?: string
117
+ verdict?: string
118
+ nits?: string
119
+ rework?: string
120
+ raw: string
121
+ body: string
122
+ }
123
+
124
+ /**
125
+ * All worker roles use interactive TUIs so you can watch them live in Herdr.
126
+ * Oneshot (`claude -p`, `pi -p`) is intentionally not used for dispatch:
127
+ * it dumps shell output and is not observable as a harness session.
128
+ */
129
+ export const ROLE_MODE: Record<Role, RoleMode> = {
130
+ orchestrator: "interactive",
131
+ planner: "interactive",
132
+ reviewer: "interactive",
133
+ coder: "interactive",
134
+ }
135
+
136
+ export const DEFAULT_TIMEOUTS: Record<string, number> = {
137
+ planning: 1_500_000,
138
+ plan_review: 900_000,
139
+ phase_packaging: 900_000,
140
+ coding: 2_700_000,
141
+ code_review: 900_000,
142
+ verify: 900_000,
143
+ finishing: 900_000,
144
+ default: 900_000,
145
+ }
@@ -0,0 +1,128 @@
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
5
+ }
6
+
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
60
+ }
61
+
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)
67
+ }
68
+ return cmds
69
+ }
70
+
71
+ function commandsFromFenceBody(body: string): string[] {
72
+ return toCommands(body.split(/\r?\n/))
73
+ }
74
+
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
90
+ }
91
+ }
92
+
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
100
+ }
101
+
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/)
113
+ for (let i = 0; i < rawLines.length; i++) {
114
+ const m = rawLines[i]!.match(
115
+ /^\s*(?:\$\s+)?((?:test |node |npm |bun |bunx |chmod |head ).+)$/,
116
+ )
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]!
121
+ }
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())
126
+ }
127
+ return cmds
128
+ }