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