@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,26 @@
|
|
|
1
|
+
import { Effect } from "effect"
|
|
2
|
+
import { getRound, setRound } from "../domain/rounds.ts"
|
|
3
|
+
import type { AppError } from "../errors.ts"
|
|
4
|
+
import { ok, type ToolResult } from "../result.ts"
|
|
5
|
+
import { RunStore } from "../services/run-store.ts"
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Human-only: reset rework counter for a gate key.
|
|
9
|
+
* Missing/corrupt state → NoRunState / StateCorrupt.
|
|
10
|
+
*/
|
|
11
|
+
export const resetRoundsWorkflow = (
|
|
12
|
+
params: { gate: string },
|
|
13
|
+
root: string,
|
|
14
|
+
): Effect.Effect<ToolResult, AppError, RunStore> =>
|
|
15
|
+
Effect.gen(function* () {
|
|
16
|
+
const store = yield* RunStore
|
|
17
|
+
const state = yield* store.require(root)
|
|
18
|
+
const key = params.gate
|
|
19
|
+
const prev = getRound(state, key)
|
|
20
|
+
setRound(state, key, 1)
|
|
21
|
+
state.last_error = null
|
|
22
|
+
yield* store.save(state, root)
|
|
23
|
+
return ok(`reset rounds for ${key}: ${prev} → 1`, {
|
|
24
|
+
note: "human-only tool; orchestrator must not call this",
|
|
25
|
+
})
|
|
26
|
+
})
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
import * as path from "node:path"
|
|
2
|
+
import { Effect, Result } from "effect"
|
|
3
|
+
import { supportsFloating } from "../domain/herdr.ts"
|
|
4
|
+
import {
|
|
5
|
+
globalConfigPath,
|
|
6
|
+
packageRoot,
|
|
7
|
+
projectConfigPath,
|
|
8
|
+
} from "../domain/paths.ts"
|
|
9
|
+
import {
|
|
10
|
+
buildGlobalConfig,
|
|
11
|
+
detectionNotes,
|
|
12
|
+
pickRoles,
|
|
13
|
+
type Detected,
|
|
14
|
+
} from "../domain/setup.ts"
|
|
15
|
+
import { ConfigError, type AppError } from "../errors.ts"
|
|
16
|
+
import { ok, type ToolResult } from "../result.ts"
|
|
17
|
+
import { decodeGlobalConfig } from "../schema/config.ts"
|
|
18
|
+
import { FileSystem } from "../services/file-system.ts"
|
|
19
|
+
import { Herdr } from "../services/herdr.ts"
|
|
20
|
+
|
|
21
|
+
export type SetupParams = {
|
|
22
|
+
/** Write .apnea/config.json role bindings in cwd */
|
|
23
|
+
project?: boolean
|
|
24
|
+
/** Overwrite existing global profiles (default: merge, keep existing profile keys) */
|
|
25
|
+
force?: boolean
|
|
26
|
+
/**
|
|
27
|
+
* Write (or refresh) a loop primer at `<root>/AGENTS.md` for harnesses
|
|
28
|
+
* that read that file but have no Apnea Pi plugin. Merge is marker-guarded
|
|
29
|
+
* — see `AGENTS_SECTION` — so a re-run never clobbers the rest of the file.
|
|
30
|
+
*/
|
|
31
|
+
agents_md?: boolean
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const AGENTS_MD_BEGIN = "<!-- apnea:begin -->"
|
|
35
|
+
const AGENTS_MD_END = "<!-- apnea:end -->"
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Names real `apnea` CLI verbs, never Pi tool names — a harness with no
|
|
39
|
+
* Apnea plugin can only run shell commands, so a primer naming `dispatch_role`
|
|
40
|
+
* would be useless to it.
|
|
41
|
+
*/
|
|
42
|
+
const AGENTS_SECTION = `${AGENTS_MD_BEGIN}
|
|
43
|
+
## Apnea runs
|
|
44
|
+
|
|
45
|
+
Drive a run with the \`apnea\` CLI. Each result prints \`next:\` — follow it.
|
|
46
|
+
|
|
47
|
+
\`\`\`
|
|
48
|
+
apnea start "<goal>" # writes state only; does NOT launch a role
|
|
49
|
+
apnea dispatch plan # then follow next: on every result
|
|
50
|
+
apnea wait # exit 3 = still waiting, call again; exit 0 = ready
|
|
51
|
+
apnea commit --done # after an APPROVED code review
|
|
52
|
+
\`\`\`
|
|
53
|
+
|
|
54
|
+
Never edit \`.apnea/state.json\` by hand. \`apnea reset-rounds\` is human-only.
|
|
55
|
+
${AGENTS_MD_END}
|
|
56
|
+
`
|
|
57
|
+
|
|
58
|
+
const AGENTS_BLOCK_RE = /<!-- apnea:begin -->[\s\S]*?<!-- apnea:end -->\n?/
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Replace the marker-bounded block in place when present, otherwise append.
|
|
62
|
+
* Internal to `setupWorkflow`; `setup.test.ts` exercises merge behavior
|
|
63
|
+
* black-box, through the written `AGENTS.md` file, not by importing this.
|
|
64
|
+
*/
|
|
65
|
+
function mergeAgentsMd(existing: string | null): string {
|
|
66
|
+
if (existing == null || existing.length === 0) return AGENTS_SECTION
|
|
67
|
+
if (AGENTS_BLOCK_RE.test(existing)) {
|
|
68
|
+
return existing.replace(AGENTS_BLOCK_RE, AGENTS_SECTION)
|
|
69
|
+
}
|
|
70
|
+
const sep = existing.endsWith("\n") ? "\n" : "\n\n"
|
|
71
|
+
return `${existing}${sep}${AGENTS_SECTION}`
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export type SetupDeps = {
|
|
75
|
+
/** Detect an executable on PATH (production: `which`). */
|
|
76
|
+
onPath: (bin: string) => boolean
|
|
77
|
+
/** Prepare host-specific role resources, or return null when none are needed. */
|
|
78
|
+
materializeRoleAgentDir: () => string | null
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export type ProvisionResult = {
|
|
82
|
+
copied: string | null // dest dir, or null when skipped
|
|
83
|
+
linked: boolean // true only when we ran `herdr plugin link` OK
|
|
84
|
+
already_linked: boolean
|
|
85
|
+
notes: string[]
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Copy the package's herdr-plugin into a stable config-local path and, when
|
|
90
|
+
* herdr is new enough and the plugin isn't already linked, run
|
|
91
|
+
* `herdr plugin link`. Never fails — every branch is a note.
|
|
92
|
+
*/
|
|
93
|
+
export const provisionHerdrPlugin = (opts: {
|
|
94
|
+
srcDir: string // packageRoot()/herdr-plugin
|
|
95
|
+
destDir: string // dirname(globalConfigPath())/herdr-plugin
|
|
96
|
+
version: [number, number, number] | null // herdr.version
|
|
97
|
+
}): Effect.Effect<ProvisionResult, never, FileSystem | Herdr> =>
|
|
98
|
+
Effect.gen(function* () {
|
|
99
|
+
const fs = yield* FileSystem
|
|
100
|
+
const herdr = yield* Herdr
|
|
101
|
+
const notes: string[] = []
|
|
102
|
+
|
|
103
|
+
const srcExists = yield* fs.exists(opts.srcDir)
|
|
104
|
+
if (!srcExists) {
|
|
105
|
+
notes.push("herdr-plugin missing from package — reinstall @naxodev/apnea")
|
|
106
|
+
return { copied: null, linked: false, already_linked: false, notes }
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
yield* fs.copyDir(opts.srcDir, opts.destDir)
|
|
110
|
+
const runTask = path.join(opts.destDir, "scripts", "run-task.sh")
|
|
111
|
+
if (yield* fs.exists(runTask)) {
|
|
112
|
+
yield* fs.chmod(runTask, 0o755)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (!supportsFloating(opts.version)) {
|
|
116
|
+
const ver =
|
|
117
|
+
opts.version == null
|
|
118
|
+
? "unknown"
|
|
119
|
+
: `${opts.version[0]}.${opts.version[1]}.${opts.version[2]}`
|
|
120
|
+
notes.push(
|
|
121
|
+
`herdr ${ver} < 0.7.4 — floating panes unavailable; run \`herdr update\`, then re-run /apnea setup`,
|
|
122
|
+
)
|
|
123
|
+
return {
|
|
124
|
+
copied: opts.destDir,
|
|
125
|
+
linked: false,
|
|
126
|
+
already_linked: false,
|
|
127
|
+
notes,
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (yield* herdr.hasApneaPlugin) {
|
|
132
|
+
return {
|
|
133
|
+
copied: opts.destDir,
|
|
134
|
+
linked: false,
|
|
135
|
+
already_linked: true,
|
|
136
|
+
notes,
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const linkResult = yield* herdr.linkPlugin(opts.destDir)
|
|
141
|
+
if (!linkResult.ok) {
|
|
142
|
+
notes.push(
|
|
143
|
+
`herdr plugin link failed: ${linkResult.raw.trim() || "(no output)"}`,
|
|
144
|
+
)
|
|
145
|
+
return {
|
|
146
|
+
copied: opts.destDir,
|
|
147
|
+
linked: false,
|
|
148
|
+
already_linked: false,
|
|
149
|
+
notes,
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return { copied: opts.destDir, linked: true, already_linked: false, notes }
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Deterministic Apnea setup: detect binaries, write global profiles
|
|
158
|
+
* (and optional project role bindings). Never writes cmd into project config.
|
|
159
|
+
*/
|
|
160
|
+
export const setupWorkflow = (
|
|
161
|
+
params: SetupParams,
|
|
162
|
+
root: string,
|
|
163
|
+
deps: SetupDeps,
|
|
164
|
+
): Effect.Effect<ToolResult, AppError, FileSystem | Herdr> =>
|
|
165
|
+
Effect.gen(function* () {
|
|
166
|
+
const fs = yield* FileSystem
|
|
167
|
+
const herdr = yield* Herdr
|
|
168
|
+
|
|
169
|
+
const readJsonSafe = (
|
|
170
|
+
filePath: string,
|
|
171
|
+
): Effect.Effect<Record<string, unknown>> =>
|
|
172
|
+
Effect.gen(function* () {
|
|
173
|
+
const present = yield* fs.exists(filePath)
|
|
174
|
+
if (!present) return {}
|
|
175
|
+
const text = yield* fs.readFile(filePath)
|
|
176
|
+
try {
|
|
177
|
+
const v = JSON.parse(text)
|
|
178
|
+
if (v && typeof v === "object" && !Array.isArray(v)) {
|
|
179
|
+
return v as Record<string, unknown>
|
|
180
|
+
}
|
|
181
|
+
return {}
|
|
182
|
+
} catch {
|
|
183
|
+
return {}
|
|
184
|
+
}
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
const has: Detected = {
|
|
188
|
+
pi: deps.onPath("pi"),
|
|
189
|
+
claude: deps.onPath("claude"),
|
|
190
|
+
codex: deps.onPath("codex"),
|
|
191
|
+
herdr: deps.onPath("herdr"),
|
|
192
|
+
jj: deps.onPath("jj"),
|
|
193
|
+
git: deps.onPath("git"),
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (!has.pi && !has.claude && !has.codex) {
|
|
197
|
+
return yield* new ConfigError({
|
|
198
|
+
message:
|
|
199
|
+
"no supported agent CLI on PATH — install pi, claude, or codex before apnea setup",
|
|
200
|
+
})
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const gPath = globalConfigPath()
|
|
204
|
+
yield* fs.mkdir(path.dirname(gPath), { recursive: true })
|
|
205
|
+
|
|
206
|
+
const prev = yield* readJsonSafe(gPath)
|
|
207
|
+
const force = params.force === true
|
|
208
|
+
const globalConfig = buildGlobalConfig({ has, prev, force })
|
|
209
|
+
|
|
210
|
+
const serialized = `${JSON.stringify(globalConfig, null, 2)}\n`
|
|
211
|
+
yield* fs.writeFile(gPath, serialized)
|
|
212
|
+
|
|
213
|
+
let projectPath: string | null = null
|
|
214
|
+
if (params.project) {
|
|
215
|
+
const pPath = projectConfigPath(root)
|
|
216
|
+
// project: roles only — never cmd
|
|
217
|
+
const projectCfg = { roles: pickRoles(has) }
|
|
218
|
+
yield* fs.writeProjectFile(
|
|
219
|
+
root,
|
|
220
|
+
pPath,
|
|
221
|
+
`${JSON.stringify(projectCfg, null, 2)}\n`,
|
|
222
|
+
)
|
|
223
|
+
projectPath = pPath
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const missing = detectionNotes(has)
|
|
227
|
+
|
|
228
|
+
// Host adapters may pre-build launch resources so first dispatch is fast.
|
|
229
|
+
let roleAgentDir: string | null = null
|
|
230
|
+
const materialized = yield* Effect.result(
|
|
231
|
+
Effect.try({ try: deps.materializeRoleAgentDir, catch: (e) => e }),
|
|
232
|
+
)
|
|
233
|
+
if (Result.isSuccess(materialized)) {
|
|
234
|
+
if (materialized.success !== null) {
|
|
235
|
+
roleAgentDir = materialized.success
|
|
236
|
+
}
|
|
237
|
+
} else {
|
|
238
|
+
const e = materialized.failure
|
|
239
|
+
missing.push(
|
|
240
|
+
`role agent dir failed: ${e instanceof Error ? e.message : String(e)}`,
|
|
241
|
+
)
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
let herdrPlugin: ProvisionResult | null = null
|
|
245
|
+
let herdrVer: string | null = null
|
|
246
|
+
if (has.herdr) {
|
|
247
|
+
const version = yield* herdr.version
|
|
248
|
+
herdrVer =
|
|
249
|
+
version == null ? null : `${version[0]}.${version[1]}.${version[2]}`
|
|
250
|
+
herdrPlugin = yield* provisionHerdrPlugin({
|
|
251
|
+
srcDir: path.join(packageRoot(), "herdr-plugin"),
|
|
252
|
+
destDir: path.join(path.dirname(globalConfigPath()), "herdr-plugin"),
|
|
253
|
+
version,
|
|
254
|
+
})
|
|
255
|
+
missing.push(...herdrPlugin.notes)
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
let agentsMdPath: string | null = null
|
|
259
|
+
if (params.agents_md) {
|
|
260
|
+
const target = path.join(root, "AGENTS.md")
|
|
261
|
+
const present = yield* fs.exists(target)
|
|
262
|
+
const existing = present ? yield* fs.readFile(target) : null
|
|
263
|
+
yield* fs.writeFile(target, mergeAgentsMd(existing))
|
|
264
|
+
agentsMdPath = target
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// A failed decode after writing is a note, not a failure — a user with a
|
|
268
|
+
// malformed pre-existing config must still get a written config and an
|
|
269
|
+
// actionable note, never a hard refusal where today there is none.
|
|
270
|
+
const decoded = decodeGlobalConfig(globalConfig)
|
|
271
|
+
if (Result.isFailure(decoded)) {
|
|
272
|
+
missing.push(
|
|
273
|
+
`global config written but does not validate: ${decoded.failure.message}`,
|
|
274
|
+
)
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const data: Record<string, unknown> = {
|
|
278
|
+
global: gPath,
|
|
279
|
+
project: projectPath,
|
|
280
|
+
detected: has,
|
|
281
|
+
roles: globalConfig.roles,
|
|
282
|
+
notes: missing,
|
|
283
|
+
role_agent_dir: roleAgentDir,
|
|
284
|
+
next: "edit ~/.config/apnea/config.json if model ids differ, then /apnea start <goal> inside Herdr",
|
|
285
|
+
}
|
|
286
|
+
if (params.agents_md) {
|
|
287
|
+
data.agents_md = agentsMdPath
|
|
288
|
+
}
|
|
289
|
+
if (has.herdr) {
|
|
290
|
+
data.herdr_version = herdrVer
|
|
291
|
+
data.herdr_plugin = herdrPlugin
|
|
292
|
+
? {
|
|
293
|
+
copied: herdrPlugin.copied,
|
|
294
|
+
linked: herdrPlugin.linked,
|
|
295
|
+
already_linked: herdrPlugin.already_linked,
|
|
296
|
+
}
|
|
297
|
+
: null
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
return ok(`wrote global config ${gPath}`, data)
|
|
301
|
+
})
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { Effect } from "effect"
|
|
2
|
+
import { packageRoot } from "../domain/paths.ts"
|
|
3
|
+
import { resetRecoveryLadder } from "../domain/recovery.ts"
|
|
4
|
+
import { slugify } from "../domain/slug.ts"
|
|
5
|
+
import { nextAfter } from "../domain/state-machine.ts"
|
|
6
|
+
import { GateRefused, NoRunState, VcsError, type AppError } from "../errors.ts"
|
|
7
|
+
import type { RunState } from "../domain/types.ts"
|
|
8
|
+
import { ok, type ToolResult } from "../result.ts"
|
|
9
|
+
import { Config } from "../services/config.ts"
|
|
10
|
+
import { FileSystem } from "../services/file-system.ts"
|
|
11
|
+
import { RunStore } from "../services/run-store.ts"
|
|
12
|
+
import { Vcs } from "../services/vcs.ts"
|
|
13
|
+
|
|
14
|
+
export type StartParams = {
|
|
15
|
+
goal: string
|
|
16
|
+
slug?: string
|
|
17
|
+
allow_dirty?: boolean
|
|
18
|
+
action?: "start" | "resume" | "abandon"
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Start / resume / abandon an Apnea run.
|
|
23
|
+
* Refusals are tagged failures only — never ok:false ToolResults.
|
|
24
|
+
*/
|
|
25
|
+
export const startWorkflow = (
|
|
26
|
+
params: StartParams,
|
|
27
|
+
root: string,
|
|
28
|
+
): Effect.Effect<ToolResult, AppError, FileSystem | RunStore | Config | Vcs> =>
|
|
29
|
+
Effect.gen(function* () {
|
|
30
|
+
const store = yield* RunStore
|
|
31
|
+
const fs = yield* FileSystem
|
|
32
|
+
const config = yield* Config
|
|
33
|
+
const vcsSvc = yield* Vcs
|
|
34
|
+
const action = params.action ?? "start"
|
|
35
|
+
|
|
36
|
+
if (action === "abandon") {
|
|
37
|
+
const bak = yield* store.abandon(root)
|
|
38
|
+
return ok(`abandoned run; state moved to ${bak}`, { backup: bak })
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const existing = yield* store.load(root)
|
|
42
|
+
|
|
43
|
+
if (action === "resume") {
|
|
44
|
+
if (!existing) return yield* new NoRunState({})
|
|
45
|
+
// Never auto-dispatch; report reconcile info
|
|
46
|
+
const pending = existing.pending_artifact
|
|
47
|
+
let pendingStatus: string = "none"
|
|
48
|
+
if (pending) {
|
|
49
|
+
const absPath = pending.startsWith("/") ? pending : `${root}/${pending}`
|
|
50
|
+
const present = yield* fs.exists(absPath)
|
|
51
|
+
pendingStatus = present ? "artifact_exists" : "artifact_missing"
|
|
52
|
+
}
|
|
53
|
+
return ok(
|
|
54
|
+
"resume: re-resolve panes by label; do not auto-dispatch",
|
|
55
|
+
{
|
|
56
|
+
state: existing,
|
|
57
|
+
pending_status: pendingStatus,
|
|
58
|
+
hint:
|
|
59
|
+
pendingStatus === "artifact_exists"
|
|
60
|
+
? "call workflow_wait to ingest pending artifact"
|
|
61
|
+
: pendingStatus === "artifact_missing"
|
|
62
|
+
? "offer re-dispatch same round via dispatch_role"
|
|
63
|
+
: "inspect workflow_status and continue legal next step",
|
|
64
|
+
},
|
|
65
|
+
nextAfter(existing.step),
|
|
66
|
+
)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// start
|
|
70
|
+
if (existing) {
|
|
71
|
+
return yield* new GateRefused({
|
|
72
|
+
gate: "start",
|
|
73
|
+
message: `state.json already exists (step=${existing.step}). Use action=resume or action=abandon.`,
|
|
74
|
+
details: { step: existing.step, slug: existing.slug },
|
|
75
|
+
})
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const cfg = yield* config.load(root)
|
|
79
|
+
|
|
80
|
+
const vcs = yield* vcsSvc.detect(root)
|
|
81
|
+
if (!vcs) {
|
|
82
|
+
return yield* new VcsError({
|
|
83
|
+
message: "no .jj or .git — refuse auto-commit setup (init vcs first)",
|
|
84
|
+
})
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const allowDirty = params.allow_dirty === true
|
|
88
|
+
if (!allowDirty && (yield* vcsSvc.isDirty(root, vcs))) {
|
|
89
|
+
return yield* new GateRefused({
|
|
90
|
+
gate: "clean_tree",
|
|
91
|
+
message:
|
|
92
|
+
"working tree is dirty (file content). Commit/clean first, or pass allow_dirty=true",
|
|
93
|
+
})
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const slug = params.slug?.trim() || slugify(params.goal)
|
|
97
|
+
|
|
98
|
+
if (vcs === "git") {
|
|
99
|
+
yield* vcsSvc.ensureGitBranch(root, slug)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const state: RunState = {
|
|
103
|
+
version: 1,
|
|
104
|
+
slug,
|
|
105
|
+
step: "planning",
|
|
106
|
+
phase_index: 1,
|
|
107
|
+
phase_count_hint: null,
|
|
108
|
+
rounds: {},
|
|
109
|
+
vcs,
|
|
110
|
+
allow_dirty: allowDirty,
|
|
111
|
+
goal: params.goal,
|
|
112
|
+
last_error: null,
|
|
113
|
+
pending_artifact: null,
|
|
114
|
+
pending_role: null,
|
|
115
|
+
pending_pane_id: null,
|
|
116
|
+
pending_pane_label: null,
|
|
117
|
+
pending_floating_exit: null,
|
|
118
|
+
pending_started_at: null,
|
|
119
|
+
pending_deadline_ms: null,
|
|
120
|
+
pending_nudged_at: null,
|
|
121
|
+
pending_final_grace: false,
|
|
122
|
+
pending_extended: false,
|
|
123
|
+
role_panes: {},
|
|
124
|
+
package_root: packageRoot(),
|
|
125
|
+
reviewer_tree_fingerprint: null,
|
|
126
|
+
current_phase_package: null,
|
|
127
|
+
current_code_review: null,
|
|
128
|
+
phase_package_rework: false,
|
|
129
|
+
}
|
|
130
|
+
// The literal above assigns the ladder's fields; this re-asserts them
|
|
131
|
+
// through the shared helper so a rung added there cannot be missed here.
|
|
132
|
+
// The type checker only catches a missing REQUIRED field — a flag added
|
|
133
|
+
// with a schema default would leave a fresh run carrying a stale rung.
|
|
134
|
+
resetRecoveryLadder(state)
|
|
135
|
+
yield* store.save(state, root)
|
|
136
|
+
|
|
137
|
+
return ok(
|
|
138
|
+
`started run slug=${slug} vcs=${vcs} step=planning. NEXT: dispatch_role kind=plan, then workflow_wait.`,
|
|
139
|
+
{
|
|
140
|
+
state,
|
|
141
|
+
profiles: Object.keys(cfg.profiles),
|
|
142
|
+
roles: cfg.roles,
|
|
143
|
+
next: "dispatch_role",
|
|
144
|
+
next_args: { kind: "plan" },
|
|
145
|
+
note: "start only writes state — it does not launch roles. Orchestrator must dispatch plan immediately.",
|
|
146
|
+
},
|
|
147
|
+
nextAfter(state.step),
|
|
148
|
+
)
|
|
149
|
+
})
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { Effect, Result } from "effect"
|
|
2
|
+
import { LEGAL_TOOLS, nextAfter } from "../domain/state-machine.ts"
|
|
3
|
+
import type { StateCorrupt } from "../errors.ts"
|
|
4
|
+
import { ok, type ToolResult } from "../result.ts"
|
|
5
|
+
import { Config } from "../services/config.ts"
|
|
6
|
+
import { RunStore } from "../services/run-store.ts"
|
|
7
|
+
import { Vcs } from "../services/vcs.ts"
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Read-only snapshot. Missing state is a success (`has_state: false`),
|
|
11
|
+
* not NoRunState. Corrupt state may fail as StateCorrupt.
|
|
12
|
+
*/
|
|
13
|
+
export const statusWorkflow = (
|
|
14
|
+
root: string,
|
|
15
|
+
): Effect.Effect<ToolResult, StateCorrupt, RunStore | Config | Vcs> =>
|
|
16
|
+
Effect.gen(function* () {
|
|
17
|
+
const store = yield* RunStore
|
|
18
|
+
const config = yield* Config
|
|
19
|
+
const vcsSvc = yield* Vcs
|
|
20
|
+
const state = yield* store.load(root)
|
|
21
|
+
if (!state) {
|
|
22
|
+
return ok("no active run", { has_state: false }, ["workflow_start"])
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Config summary never fails the tool
|
|
26
|
+
const cfgR = yield* Effect.result(config.load(root))
|
|
27
|
+
const cfgSummary: Record<string, unknown> = Result.isSuccess(cfgR)
|
|
28
|
+
? {
|
|
29
|
+
roles: cfgR.success.roles,
|
|
30
|
+
review_round_cap: cfgR.success.review_round_cap,
|
|
31
|
+
}
|
|
32
|
+
: { config_error: cfgR.failure.message }
|
|
33
|
+
|
|
34
|
+
const vcs = yield* vcsSvc.detect(root)
|
|
35
|
+
return ok(
|
|
36
|
+
`step=${state.step} phase=${state.phase_index}`,
|
|
37
|
+
{
|
|
38
|
+
state,
|
|
39
|
+
legal_tools: LEGAL_TOOLS[state.step],
|
|
40
|
+
config: cfgSummary,
|
|
41
|
+
dirty: vcs ? yield* vcsSvc.isDirty(root, vcs) : null,
|
|
42
|
+
},
|
|
43
|
+
nextAfter(state.step),
|
|
44
|
+
)
|
|
45
|
+
})
|