@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,44 @@
|
|
|
1
|
+
import { toolToVerb } from "../registry.ts"
|
|
2
|
+
import type { ToolResult } from "../result.ts"
|
|
3
|
+
|
|
4
|
+
/** 0 ready · 1 refusal/error · 2 usage · 3 wait budget spent, call again. */
|
|
5
|
+
export const EXIT_OK = 0
|
|
6
|
+
export const EXIT_ERROR = 1
|
|
7
|
+
export const EXIT_USAGE = 2
|
|
8
|
+
export const EXIT_PENDING = 3
|
|
9
|
+
|
|
10
|
+
export function exitCodeFor(r: ToolResult): number {
|
|
11
|
+
if (!r.ok) return EXIT_ERROR
|
|
12
|
+
return r.data?.pending === true ? EXIT_PENDING : EXIT_OK
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Canonical tool name → the command a shell caller actually runs, or `null`
|
|
17
|
+
* when `tool` isn't a runnable command: either it's already a human-readable
|
|
18
|
+
* hint (contains whitespace, e.g. "dispatch_role with allowed kind") that
|
|
19
|
+
* should render unchanged, or it's a bug — a tool-name-shaped string with no
|
|
20
|
+
* CLI verb behind it (e.g. a tool that was deleted from `LEGAL_TOOLS` but
|
|
21
|
+
* left in some error's `legal_next`). Returning `null` for the latter instead
|
|
22
|
+
* of the raw string keeps a dangling tool name from silently reaching
|
|
23
|
+
* rendered output.
|
|
24
|
+
*/
|
|
25
|
+
function asCommand(tool: string): string | null {
|
|
26
|
+
const verb = toolToVerb(tool)
|
|
27
|
+
if (verb) return `apnea ${verb}`
|
|
28
|
+
return /\s/.test(tool) ? tool : null
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function renderHuman(r: ToolResult): string {
|
|
32
|
+
const mapped = (r.legal_next ?? [])
|
|
33
|
+
.map(asCommand)
|
|
34
|
+
.filter((c): c is string => c !== null)
|
|
35
|
+
const next = mapped.length ? `\nnext: ${mapped.join(" | ")}` : ""
|
|
36
|
+
const extra = r.data ? `\n${JSON.stringify(r.data, null, 2)}` : ""
|
|
37
|
+
return r.ok
|
|
38
|
+
? `OK: ${r.message}${next}${extra}`
|
|
39
|
+
: `ERROR: ${r.error}${next}${extra}`
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function renderJson(r: ToolResult): string {
|
|
43
|
+
return JSON.stringify(r)
|
|
44
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
export type HumanGateDeps = {
|
|
2
|
+
isTty: () => boolean
|
|
3
|
+
prompt: (question: string) => Promise<string>
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export const prodHumanGateDeps: HumanGateDeps = {
|
|
7
|
+
isTty: () => Boolean(process.stdin.isTTY && process.stdout.isTTY),
|
|
8
|
+
prompt: async (question) => {
|
|
9
|
+
process.stdout.write(question)
|
|
10
|
+
for await (const line of console) return line.trim()
|
|
11
|
+
return ""
|
|
12
|
+
},
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Human-only confirmation for cap reset. An agent shelling out has captured
|
|
17
|
+
* pipes, so the TTY check fails closed. `--i-am-human` remains available for
|
|
18
|
+
* scripts and remote shells: the property this buys is auditability — the
|
|
19
|
+
* bypass is named in the transcript — not prevention.
|
|
20
|
+
*/
|
|
21
|
+
export async function confirmHuman(
|
|
22
|
+
gate: string,
|
|
23
|
+
deps: HumanGateDeps,
|
|
24
|
+
override: boolean,
|
|
25
|
+
): Promise<{ ok: true } | { ok: false; reason: string }> {
|
|
26
|
+
if (override) return { ok: true }
|
|
27
|
+
if (!deps.isTty()) {
|
|
28
|
+
return {
|
|
29
|
+
ok: false,
|
|
30
|
+
reason:
|
|
31
|
+
"reset-rounds is human-only and stdin/stdout are not a terminal. " +
|
|
32
|
+
"Run it yourself in a shell, or pass --i-am-human to override.",
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
const answer = await deps.prompt(
|
|
36
|
+
`Type the gate key to confirm reset (${gate}): `,
|
|
37
|
+
)
|
|
38
|
+
return answer === gate
|
|
39
|
+
? { ok: true }
|
|
40
|
+
: {
|
|
41
|
+
ok: false,
|
|
42
|
+
reason: `confirmation did not match "${gate}"; nothing reset`,
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { parseFlags, parseNumFlag } from "./parse.ts"
|
|
3
|
+
import {
|
|
4
|
+
EXIT_ERROR,
|
|
5
|
+
EXIT_USAGE,
|
|
6
|
+
exitCodeFor,
|
|
7
|
+
renderHuman,
|
|
8
|
+
renderJson,
|
|
9
|
+
} from "./format.ts"
|
|
10
|
+
import { confirmHuman, prodHumanGateDeps } from "./human-gate.ts"
|
|
11
|
+
import { OPERATIONS, executeOperation, findByVerb } from "../registry.ts"
|
|
12
|
+
import { DISPATCH_KINDS } from "../domain/state-machine.ts"
|
|
13
|
+
import type { ToolResult } from "../result.ts"
|
|
14
|
+
|
|
15
|
+
function usage(): string {
|
|
16
|
+
return [
|
|
17
|
+
"apnea — multi-role workflow driver",
|
|
18
|
+
"",
|
|
19
|
+
"Usage: apnea <command> [args] [--json]",
|
|
20
|
+
"",
|
|
21
|
+
...OPERATIONS.map(
|
|
22
|
+
(o) => ` ${`${o.verb} ${o.usage ?? ""}`.trim().padEnd(38)} ${o.summary}`,
|
|
23
|
+
),
|
|
24
|
+
" resume | abandon actions on an existing run",
|
|
25
|
+
"",
|
|
26
|
+
"apnea reset-rounds also accepts [--i-am-human] (CLI only — skips the TTY",
|
|
27
|
+
"confirmation prompt that only this surface has; see README.md).",
|
|
28
|
+
"",
|
|
29
|
+
`dispatch kinds: ${DISPATCH_KINDS.join(" | ")}`,
|
|
30
|
+
"",
|
|
31
|
+
"Exit codes: 0 ok · 1 refused/error · 2 usage · 3 still waiting (call again)",
|
|
32
|
+
].join("\n")
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Renders a usage failure through the same `renderJson`/`renderHuman` split
|
|
37
|
+
* as every other exit path, so a `--json` caller gets parseable output on
|
|
38
|
+
* exit 2 too instead of a plain-text message that breaks its parser. The
|
|
39
|
+
* full command listing is only useful to a human reading a terminal, so it's
|
|
40
|
+
* appended after the rendered error and only outside `--json` mode.
|
|
41
|
+
*/
|
|
42
|
+
function printUsageError(message: string, json: boolean): void {
|
|
43
|
+
const result: ToolResult = { ok: false, error: message }
|
|
44
|
+
console.error(
|
|
45
|
+
json ? renderJson(result) : `${renderHuman(result)}\n\n${usage()}`,
|
|
46
|
+
)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function main(argv: string[]): Promise<number> {
|
|
50
|
+
const [verbRaw, ...rest] = argv
|
|
51
|
+
const { flags, values, rest: positional } = parseFlags(rest)
|
|
52
|
+
const json = flags.has("json")
|
|
53
|
+
|
|
54
|
+
if (!verbRaw) {
|
|
55
|
+
printUsageError("usage: apnea <command> [args] [--json]", json)
|
|
56
|
+
return EXIT_USAGE
|
|
57
|
+
}
|
|
58
|
+
if (verbRaw === "help" || verbRaw === "--help") {
|
|
59
|
+
console.log(usage())
|
|
60
|
+
return 0
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// `resume` and `abandon` are actions on the start operation.
|
|
64
|
+
const isAction = verbRaw === "resume" || verbRaw === "abandon"
|
|
65
|
+
const op = findByVerb(isAction ? "start" : verbRaw)
|
|
66
|
+
if (!op) {
|
|
67
|
+
printUsageError(`unknown command: ${verbRaw}`, json)
|
|
68
|
+
return EXIT_USAGE
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const built = buildParams(
|
|
72
|
+
op.verb,
|
|
73
|
+
isAction ? verbRaw : null,
|
|
74
|
+
flags,
|
|
75
|
+
values,
|
|
76
|
+
positional,
|
|
77
|
+
)
|
|
78
|
+
if (!built.ok) {
|
|
79
|
+
printUsageError(
|
|
80
|
+
built.message ?? `usage: apnea ${op.verb} ${op.usage}`.trim(),
|
|
81
|
+
json,
|
|
82
|
+
)
|
|
83
|
+
return EXIT_USAGE
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (op.humanOnly) {
|
|
87
|
+
const gate = String(built.params.gate ?? "")
|
|
88
|
+
const confirmed = await confirmHuman(
|
|
89
|
+
gate,
|
|
90
|
+
prodHumanGateDeps,
|
|
91
|
+
flags.has("i-am-human"),
|
|
92
|
+
)
|
|
93
|
+
if (!confirmed.ok) {
|
|
94
|
+
const result: ToolResult = { ok: false, error: confirmed.reason }
|
|
95
|
+
console.error(json ? renderJson(result) : renderHuman(result))
|
|
96
|
+
return EXIT_ERROR
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const result = await executeOperation(op.verb, built.params)
|
|
101
|
+
const text = json ? renderJson(result) : renderHuman(result)
|
|
102
|
+
if (result.ok) console.log(text)
|
|
103
|
+
else console.error(text)
|
|
104
|
+
return exitCodeFor(result)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export type BuildParamsResult =
|
|
108
|
+
| { ok: true; params: Record<string, unknown> }
|
|
109
|
+
| { ok: false; message?: string }
|
|
110
|
+
|
|
111
|
+
export function buildParams(
|
|
112
|
+
verb: string,
|
|
113
|
+
action: string | null,
|
|
114
|
+
flags: Set<string>,
|
|
115
|
+
values: Map<string, string>,
|
|
116
|
+
positional: string[],
|
|
117
|
+
): BuildParamsResult {
|
|
118
|
+
switch (verb) {
|
|
119
|
+
case "start": {
|
|
120
|
+
if (action) return { ok: true, params: { action } }
|
|
121
|
+
const goal = positional.join(" ").trim()
|
|
122
|
+
if (!goal) {
|
|
123
|
+
return {
|
|
124
|
+
ok: false,
|
|
125
|
+
message: "usage: apnea start <goal> [--allow-dirty] [--slug=name]",
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return {
|
|
129
|
+
ok: true,
|
|
130
|
+
params: {
|
|
131
|
+
action: "start",
|
|
132
|
+
goal,
|
|
133
|
+
slug: values.get("slug"),
|
|
134
|
+
allow_dirty: flags.has("allow-dirty") || undefined,
|
|
135
|
+
},
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
case "dispatch": {
|
|
139
|
+
const kind = positional[0]
|
|
140
|
+
if (!kind || !DISPATCH_KINDS.includes(kind as never)) {
|
|
141
|
+
return {
|
|
142
|
+
ok: false,
|
|
143
|
+
message: `usage: apnea dispatch <${DISPATCH_KINDS.join("|")}> [--rework]`,
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return {
|
|
147
|
+
ok: true,
|
|
148
|
+
params: { kind, rework: flags.has("rework") || undefined },
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
case "wait": {
|
|
152
|
+
// `--timeout` and `--budget` are the same knob: how long THIS call
|
|
153
|
+
// blocks. The role's deadline comes from config, stamped at dispatch.
|
|
154
|
+
const poll = parseNumFlag(values, "poll")
|
|
155
|
+
if (!poll.ok) {
|
|
156
|
+
return {
|
|
157
|
+
ok: false,
|
|
158
|
+
message: `usage: apnea wait [--poll=<ms>] (got --poll=${poll.raw})`,
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
const budget = parseNumFlag(values, "budget")
|
|
162
|
+
if (!budget.ok) {
|
|
163
|
+
return {
|
|
164
|
+
ok: false,
|
|
165
|
+
message: `usage: apnea wait [--budget=<ms>] (got --budget=${budget.raw})`,
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
const timeout = parseNumFlag(values, "timeout")
|
|
169
|
+
if (!timeout.ok) {
|
|
170
|
+
return {
|
|
171
|
+
ok: false,
|
|
172
|
+
message: `usage: apnea wait [--timeout=<ms>] (got --timeout=${timeout.raw})`,
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return {
|
|
176
|
+
ok: true,
|
|
177
|
+
params: {
|
|
178
|
+
poll_ms: poll.value,
|
|
179
|
+
budget_ms: budget.value ?? timeout.value,
|
|
180
|
+
},
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
case "commit": {
|
|
184
|
+
const message = positional.join(" ").trim()
|
|
185
|
+
return {
|
|
186
|
+
ok: true,
|
|
187
|
+
params: {
|
|
188
|
+
message: message || undefined,
|
|
189
|
+
no_remaining_phases: flags.has("done") || undefined,
|
|
190
|
+
},
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
case "status":
|
|
194
|
+
return { ok: true, params: {} }
|
|
195
|
+
case "reset-rounds": {
|
|
196
|
+
const gate = positional[0]
|
|
197
|
+
if (!gate) {
|
|
198
|
+
return { ok: false, message: "usage: apnea reset-rounds <gate>" }
|
|
199
|
+
}
|
|
200
|
+
return { ok: true, params: { gate } }
|
|
201
|
+
}
|
|
202
|
+
case "setup":
|
|
203
|
+
return {
|
|
204
|
+
ok: true,
|
|
205
|
+
params: {
|
|
206
|
+
project: flags.has("project") || undefined,
|
|
207
|
+
force: flags.has("force") || undefined,
|
|
208
|
+
agents_md: flags.has("agents-md") || undefined,
|
|
209
|
+
},
|
|
210
|
+
}
|
|
211
|
+
default:
|
|
212
|
+
return { ok: false }
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (import.meta.main) {
|
|
217
|
+
process.exit(await main(process.argv.slice(2)))
|
|
218
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Split `--bare` switches from `--key=value` options; everything else is
|
|
3
|
+
* positional. `values` matters: `rest` never sees a `--`-prefixed token, so
|
|
4
|
+
* `--key=value` options are only reachable through the map.
|
|
5
|
+
*/
|
|
6
|
+
export function parseFlags(tokens: string[]): {
|
|
7
|
+
flags: Set<string>
|
|
8
|
+
values: Map<string, string>
|
|
9
|
+
rest: string[]
|
|
10
|
+
} {
|
|
11
|
+
const flags = new Set<string>()
|
|
12
|
+
const values = new Map<string, string>()
|
|
13
|
+
const rest: string[] = []
|
|
14
|
+
for (const t of tokens) {
|
|
15
|
+
if (!t.startsWith("--")) {
|
|
16
|
+
rest.push(t)
|
|
17
|
+
continue
|
|
18
|
+
}
|
|
19
|
+
const body = t.slice(2)
|
|
20
|
+
const eq = body.indexOf("=")
|
|
21
|
+
if (eq > 0) values.set(body.slice(0, eq), body.slice(eq + 1))
|
|
22
|
+
else flags.add(body)
|
|
23
|
+
}
|
|
24
|
+
return { flags, values, rest }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Reading a `--key=value` numeric flag either yields the parsed number (or
|
|
28
|
+
* `undefined` when the caller didn't pass it) or the raw token that failed
|
|
29
|
+
* to parse, so the caller can name exactly what it received. */
|
|
30
|
+
export type NumFlag =
|
|
31
|
+
{ ok: true; value: number | undefined } | { ok: false; raw: string }
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Shared by `/apnea` and the CLI so a mistyped `--budget=abc` is refused the
|
|
35
|
+
* same way on both surfaces instead of silently falling back to a default —
|
|
36
|
+
* a scripting agent needs a signal, not a quietly-wrong value. `--key=`
|
|
37
|
+
* (empty string) counts as "not provided": a caller who writes it almost
|
|
38
|
+
* certainly forgot the value, not asked for zero.
|
|
39
|
+
*/
|
|
40
|
+
export function parseNumFlag(
|
|
41
|
+
values: Map<string, string>,
|
|
42
|
+
key: string,
|
|
43
|
+
): NumFlag {
|
|
44
|
+
const raw = values.get(key)
|
|
45
|
+
if (raw === undefined || raw === "") return { ok: true, value: undefined }
|
|
46
|
+
const n = Number(raw)
|
|
47
|
+
return Number.isFinite(n) ? { ok: true, value: n } : { ok: false, raw }
|
|
48
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { Result } from "effect"
|
|
2
|
+
import { ArtifactInvalid } from "../errors.ts"
|
|
3
|
+
import type { DispatchKind } from "./state-machine.ts"
|
|
4
|
+
|
|
5
|
+
/** Infer the dispatch kind an artifact path corresponds to. Ordering matters:
|
|
6
|
+
* plan-review must be checked before the bare plan.md suffix check. */
|
|
7
|
+
export function inferKind(
|
|
8
|
+
artifactRel: string,
|
|
9
|
+
): Result.Result<DispatchKind, ArtifactInvalid> {
|
|
10
|
+
if (artifactRel.endsWith("plan.md") && !artifactRel.includes("plan-review"))
|
|
11
|
+
return Result.succeed("plan")
|
|
12
|
+
if (artifactRel.includes("plan-review")) return Result.succeed("plan_review")
|
|
13
|
+
if (artifactRel.endsWith("phase-package.md"))
|
|
14
|
+
return Result.succeed("phase_package")
|
|
15
|
+
if (artifactRel.endsWith("coder-result.md")) return Result.succeed("code")
|
|
16
|
+
if (artifactRel.endsWith("code-review.md"))
|
|
17
|
+
return Result.succeed("code_review")
|
|
18
|
+
if (artifactRel.endsWith("pr-description.md"))
|
|
19
|
+
return Result.succeed("pr_description")
|
|
20
|
+
return Result.fail(
|
|
21
|
+
new ArtifactInvalid({
|
|
22
|
+
artifact: artifactRel,
|
|
23
|
+
message: `cannot infer dispatch kind from ${artifactRel}`,
|
|
24
|
+
}),
|
|
25
|
+
)
|
|
26
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { FrontMatter, Verdict } from "./types.ts"
|
|
2
|
+
|
|
3
|
+
const FM_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/
|
|
4
|
+
|
|
5
|
+
export function parseFrontMatter(text: string): FrontMatter | null {
|
|
6
|
+
const m = text.match(FM_RE)
|
|
7
|
+
if (!m) return null
|
|
8
|
+
const raw = m[1] ?? ""
|
|
9
|
+
const body = m[2] ?? ""
|
|
10
|
+
const fields: Record<string, string> = {}
|
|
11
|
+
let currentKey: string | null = null
|
|
12
|
+
let currentVal: string[] = []
|
|
13
|
+
|
|
14
|
+
const flush = () => {
|
|
15
|
+
if (currentKey) {
|
|
16
|
+
fields[currentKey] = currentVal
|
|
17
|
+
.join("\n")
|
|
18
|
+
.replace(/^\s*\|\s*\n?/, "")
|
|
19
|
+
.trimEnd()
|
|
20
|
+
}
|
|
21
|
+
currentKey = null
|
|
22
|
+
currentVal = []
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
26
|
+
const kv = line.match(/^([A-Za-z0-9_]+):\s*(.*)$/)
|
|
27
|
+
if (kv) {
|
|
28
|
+
flush()
|
|
29
|
+
currentKey = kv[1]!
|
|
30
|
+
const rest = kv[2] ?? ""
|
|
31
|
+
if (rest === "|" || rest === ">") {
|
|
32
|
+
currentVal = []
|
|
33
|
+
} else {
|
|
34
|
+
currentVal = [rest]
|
|
35
|
+
}
|
|
36
|
+
} else if (
|
|
37
|
+
currentKey &&
|
|
38
|
+
(line.startsWith(" ") || line.startsWith("\t") || line === "")
|
|
39
|
+
) {
|
|
40
|
+
currentVal.push(line.replace(/^\s{2}/, ""))
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
flush()
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
status: fields.status,
|
|
47
|
+
verdict: fields.verdict,
|
|
48
|
+
nits: fields.nits,
|
|
49
|
+
rework: fields.rework,
|
|
50
|
+
raw,
|
|
51
|
+
body,
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function isCompleteArtifact(
|
|
56
|
+
fm: FrontMatter | null,
|
|
57
|
+
opts: { requireVerdict?: boolean } = {},
|
|
58
|
+
): boolean {
|
|
59
|
+
if (!fm || fm.status !== "done") return false
|
|
60
|
+
if (opts.requireVerdict) {
|
|
61
|
+
return fm.verdict === "APPROVED" || fm.verdict === "CHANGES_REQUIRED"
|
|
62
|
+
}
|
|
63
|
+
return true
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function asVerdict(v: string | undefined): Verdict | null {
|
|
67
|
+
if (v === "APPROVED" || v === "CHANGES_REQUIRED") return v
|
|
68
|
+
return null
|
|
69
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
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
|
+
export function shellJoin(parts: string[]): string {
|
|
51
|
+
return parts
|
|
52
|
+
.map((p) => {
|
|
53
|
+
if (p === "&&" || p === "|" || p === "exec" || p === "env") return p
|
|
54
|
+
if (/^[A-Za-z0-9_./:=,@+-]+$/.test(p)) return p
|
|
55
|
+
return `'${p.replace(/'/g, `'\\''`)}'`
|
|
56
|
+
})
|
|
57
|
+
.join(" ")
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** True when every foreground process name looks like a bare shell prompt. */
|
|
61
|
+
export function looksLikeShellOnly(names: string[]): boolean {
|
|
62
|
+
if (names.length === 0) return false
|
|
63
|
+
return names.every((n) => {
|
|
64
|
+
const t = n.trim()
|
|
65
|
+
return (
|
|
66
|
+
/^(zsh|bash|sh|fish)$/i.test(t) ||
|
|
67
|
+
t === "-zsh" ||
|
|
68
|
+
t === "-bash" ||
|
|
69
|
+
t === "-sh"
|
|
70
|
+
)
|
|
71
|
+
})
|
|
72
|
+
}
|
|
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
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import * as fs from "node:fs"
|
|
2
|
+
import * as os from "node:os"
|
|
3
|
+
import * as path from "node:path"
|
|
4
|
+
import { fileURLToPath } from "node:url"
|
|
5
|
+
|
|
6
|
+
export const APNEA_DIR = ".apnea"
|
|
7
|
+
|
|
8
|
+
export function cwd(): string {
|
|
9
|
+
return process.cwd()
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function apneaRoot(root = cwd()): string {
|
|
13
|
+
return path.join(root, APNEA_DIR)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function statePath(root = cwd()): string {
|
|
17
|
+
return path.join(apneaRoot(root), "state.json")
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function projectConfigPath(root = cwd()): string {
|
|
21
|
+
return path.join(apneaRoot(root), "config.json")
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function homedir(): string {
|
|
25
|
+
// Env first so an overridden HOME still wins (Bun's os.homedir() reads the
|
|
26
|
+
// passwd entry and ignores $HOME), then the passwd entry as the floor.
|
|
27
|
+
// Never "": an empty home makes globalConfigPath() cwd-relative, i.e. the
|
|
28
|
+
// *project* repo would be read as the trusted global config — the one place
|
|
29
|
+
// profiles/cmd_interactive are honoured. node:os is a pure read here.
|
|
30
|
+
return process.env.HOME || process.env.USERPROFILE || os.homedir()
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function globalConfigPath(): string {
|
|
34
|
+
return path.join(homedir(), ".config", "apnea", "config.json")
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function artifactsDir(root = cwd()): string {
|
|
38
|
+
return path.join(apneaRoot(root), "artifacts")
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function tasksDir(root = cwd()): string {
|
|
42
|
+
return path.join(apneaRoot(root), "tasks")
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function phaseDir(
|
|
46
|
+
phaseIndex: number,
|
|
47
|
+
round: number,
|
|
48
|
+
root = cwd(),
|
|
49
|
+
): string {
|
|
50
|
+
const n = String(phaseIndex).padStart(2, "0")
|
|
51
|
+
return path.join(artifactsDir(root), `phase-${n}`, `round-${round}`)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function planPath(root = cwd()): string {
|
|
55
|
+
return path.join(artifactsDir(root), "plan.md")
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function planReviewPath(round: number, root = cwd()): string {
|
|
59
|
+
return path.join(artifactsDir(root), "plan-review", `round-${round}.md`)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function prDescriptionPath(root = cwd()): string {
|
|
63
|
+
return path.join(artifactsDir(root), "pr-description.md")
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function rel(p: string, root = cwd()): string {
|
|
67
|
+
return path.relative(root, p) || p
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function abs(p: string, root = cwd()): string {
|
|
71
|
+
return path.isAbsolute(p) ? p : path.join(root, p)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export const PACKAGE_NAME = "@naxodev/apnea"
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The package-root search, with the starting directory as a parameter.
|
|
78
|
+
*
|
|
79
|
+
* Returns `null` when it cannot find us. An earlier version guessed two levels
|
|
80
|
+
* up instead — the exact wrong answer for the bundled and installed layouts
|
|
81
|
+
* this function exists to handle, handed back as if it were a real result.
|
|
82
|
+
* Callers can now tell "not found" from "found", and `dispatch` turns it into
|
|
83
|
+
* a refusal naming the paths it tried.
|
|
84
|
+
*
|
|
85
|
+
* Split out from `packageRoot()` so a test can supply the layout rather than
|
|
86
|
+
* inheriting whichever one the test runner happens to use. Testing
|
|
87
|
+
* `packageRoot()` directly only ever exercises the source tree, where the old
|
|
88
|
+
* two-levels-up rule was also correct — a test written that way passes against
|
|
89
|
+
* the bug.
|
|
90
|
+
*/
|
|
91
|
+
export function findPackageRootFrom(startDir: string): string | null {
|
|
92
|
+
let dir = startDir
|
|
93
|
+
for (let up = 0; up < 10; up++) {
|
|
94
|
+
try {
|
|
95
|
+
const manifest = fs.readFileSync(path.join(dir, "package.json"), "utf8")
|
|
96
|
+
if ((JSON.parse(manifest) as { name?: string }).name === PACKAGE_NAME) {
|
|
97
|
+
return dir
|
|
98
|
+
}
|
|
99
|
+
} catch {
|
|
100
|
+
// No package.json here, or it is not readable JSON. Keep walking.
|
|
101
|
+
}
|
|
102
|
+
const parent = path.dirname(dir)
|
|
103
|
+
if (parent === dir) break
|
|
104
|
+
dir = parent
|
|
105
|
+
}
|
|
106
|
+
return null
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Package root — the directory holding `briefs/`, `herdr-plugin/`, and our
|
|
111
|
+
* own `package.json`.
|
|
112
|
+
*
|
|
113
|
+
* Found by walking up and reading each `package.json`, NOT by counting
|
|
114
|
+
* directory levels. The level count differs for every way this code runs, and
|
|
115
|
+
* a wrong root is silent: `dispatch` still launches a pane, and the role sits
|
|
116
|
+
* there having been told to read a brief that does not exist.
|
|
117
|
+
*
|
|
118
|
+
* from source extension/domain/paths.ts -> 2 up
|
|
119
|
+
* from bundle dist/cli.js -> 1 up
|
|
120
|
+
* from npm node_modules/@naxodev/apnea/dist/cli.js -> 1 up
|
|
121
|
+
*
|
|
122
|
+
* The old two-up rule was right only for source. `bun build` emits one file at
|
|
123
|
+
* `dist/cli.js`, so the CLI resolved the repo's PARENT and every brief path it
|
|
124
|
+
* printed was wrong — found by running the CLI against this repo, not by any
|
|
125
|
+
* test, because the tests fake this function and the Pi extension happens to
|
|
126
|
+
* load from source.
|
|
127
|
+
*
|
|
128
|
+
* Matching on the package NAME rather than on the presence of `package.json`
|
|
129
|
+
* matters once this is installed as a dependency: the first `package.json`
|
|
130
|
+
* above `node_modules/...` belongs to the consumer, not to us.
|
|
131
|
+
*
|
|
132
|
+
* The source-layout guess remains only as a last resort for a fork or rename,
|
|
133
|
+
* where the name will never match. `dispatch` checks the brief exists before
|
|
134
|
+
* launching, so a wrong guess surfaces as a refusal rather than a stalled role.
|
|
135
|
+
*/
|
|
136
|
+
export function packageRoot(): string {
|
|
137
|
+
const here = path.dirname(fileURLToPath(import.meta.url))
|
|
138
|
+
return findPackageRootFrom(here) ?? path.resolve(here, "..", "..")
|
|
139
|
+
}
|