pi-dev-flow 0.0.1-stage → 0.52.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.
@@ -0,0 +1,1502 @@
1
+ /**
2
+ * dev-flow: enforced feature-flow state machine for Pi (pi package extension).
3
+ *
4
+ * Stages: plan → implement → pre-release → release → done, per feature,
5
+ * with state in <repo>/.dev-flow/<slug>/state.json (gitignored, flow-owned).
6
+ *
7
+ * Enforcement has three teeth:
8
+ * 1. Gate commands (/flow approve|verify|release|release-done|finish) refuse
9
+ * to advance unless exit criteria hold (plan file, mockups, green tests,
10
+ * operator confirmations, recorded release).
11
+ * 2. tool_call guards: no commits/pushes on the main branch outside the
12
+ * release stage; edits confined to the plan dir or the worktree; state.json
13
+ * is untouchable and the project.md playbook is operator-owned.
14
+ * 3. before_agent_start injects the active stage so the model always knows
15
+ * where it is and which gate comes next.
16
+ *
17
+ * The stage logic lives in src/core.ts (unit-tested); this file is pi wiring.
18
+ * Inactive (fully pass-through) in repos without a .dev-flow/ directory.
19
+ */
20
+ import { exec } from "node:child_process";
21
+ import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
22
+ import path from "node:path";
23
+ import { promisify } from "node:util";
24
+ import { Type } from "@earendil-works/pi-ai";
25
+ import { compact, defineTool, isToolCallEventType, SettingsManager, type ExtensionContext } from "@earendil-works/pi-coding-agent";
26
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
27
+ import {
28
+ ALLOW_REWRITE,
29
+ approveGate,
30
+ applicableItems,
31
+ appendReflection,
32
+ appendRoadmapItem,
33
+ bashFlowGuard,
34
+ changelogEntry,
35
+ checklistHash,
36
+ checklistPending,
37
+ checkoutTarget,
38
+ claimBlocks,
39
+ cmpVersions,
40
+ cleanupAssess,
41
+ compactionReflectionLine,
42
+ detectForge,
43
+ featureBranchPrefix,
44
+ FLOW_SHELL_HINT,
45
+ flowShellInvocation,
46
+ forcePushInvocation,
47
+ gateHelpJson,
48
+ gitInvocation,
49
+ githubSlug,
50
+ guardBash,
51
+ guardPath,
52
+ healthProbe,
53
+ healthRetry,
54
+ investigationBrief,
55
+ parseLsRemoteTags,
56
+ parsePushRefs,
57
+ playbookTamper,
58
+ playbookOnlyChange,
59
+ pluginVersion,
60
+ preReleaseGate,
61
+ reflectProtocol,
62
+ reflectSections,
63
+ reflectionLine,
64
+ reflectionRoutes,
65
+ sessionRunsOlderBuild,
66
+
67
+ listFiles,
68
+ looksLikeShellError,
69
+ needsOperator,
70
+ newestTaggedVersion,
71
+ newState,
72
+ nextHint,
73
+ openChecklistItems,
74
+ openReflections,
75
+ releaseGate,
76
+ parseVersion,
77
+ roadmapDone,
78
+ roadmapDoneCount,
79
+ roadmapDoneForSlug,
80
+ roadmapOpenCount,
81
+ roadmapPick,
82
+ roadmapTidy,
83
+ shouldSelfCompact,
84
+ shouldTakeOverCompaction,
85
+ stateTamper,
86
+ statusCompact,
87
+ statusJson,
88
+ subst,
89
+ sq,
90
+ tagNotesCommand,
91
+ validSlug,
92
+ verifyGate,
93
+ versionTagGate,
94
+ writeGuard,
95
+ type BranchState,
96
+ type Config,
97
+ type FeatureRef,
98
+ type FlowState,
99
+ type StageCheck,
100
+ type PushRef,
101
+ type SessionClaim,
102
+ } from "../src/core.ts";
103
+ import { buildDraft, doctorFindings, ghostFeatureDirs, loadProject, mdDigest, parseProjectMd } from "../src/projectmd.ts";
104
+ import { bashTimeout, requireTimeout } from "../src/timeouts.ts";
105
+ import { detectProject } from "../src/detect.ts";
106
+
107
+ const execAsync = promisify(exec);
108
+
109
+ /** The dev-flow build this session actually loaded — see sessionRunsOlderBuild. */
110
+ const LOADED_VERSION = pluginVersion(import.meta.dirname).version;
111
+
112
+ interface Ui {
113
+ hasUI: boolean;
114
+ confirm: (title: string, message: string) => Promise<boolean>;
115
+ /** Multi-line review/edit dialog where the client has one (TUI); undefined elsewhere. */
116
+ editor?: (title: string, prefill?: string) => Promise<string | undefined>;
117
+ }
118
+
119
+ const now = () => new Date().toISOString();
120
+
121
+ const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
122
+
123
+ async function sh(command: string, cwd: string): Promise<{ ok: boolean; out: string }> {
124
+ try {
125
+ const { stdout, stderr } = await execAsync(command, { cwd, timeout: 15 * 60_000, maxBuffer: 16 * 1024 * 1024 });
126
+ return { ok: true, out: `${stdout}\n${stderr}`.trim() };
127
+ } catch (e) {
128
+ const err = e as { stdout?: string; stderr?: string; message?: string };
129
+ return { ok: false, out: `${err.stdout ?? ""}\n${err.stderr ?? ""}`.trim() || String(err.message) };
130
+ }
131
+ }
132
+
133
+ const dfDir = (root: string) => path.join(root, ".dev-flow");
134
+
135
+ /**
136
+ * Run the health probe with the playbook's wait budget (#42). A service whose readiness lags its
137
+ * container start can never pass a single shot, and hand-writing a poll loop into every playbook was
138
+ * the workaround; the gate waits and says how long it tried. Attempts/schedule: core.healthRetry.
139
+ */
140
+ async function runHealthProbe(cmd: string, cwd: string, waitSec: number): Promise<{ ok: boolean; out: string; attempts: number }> {
141
+ const { attempts, delayMs } = healthRetry(waitSec);
142
+ const deadline = Date.now() + waitSec * 1000; // the budget is wall time: a slow probe must not multiply it by its own duration
143
+ let res = { ok: false, out: "" };
144
+ for (let i = 1; ; i++) {
145
+ res = await sh(cmd, cwd);
146
+ if (res.ok || i >= attempts || Date.now() >= deadline) return { ...res, attempts: i };
147
+ await sleep(delayMs);
148
+ }
149
+ }
150
+
151
+ /** Tags to judge a release against: what origin holds — a clone that has not fetched must not get to
152
+ * decide what is already tagged (#52). Local tags only when there is no remote to ask; null when an
153
+ * origin exists but could not be asked (offline, no ssh agent) — then local tags must not stand in. */
154
+ async function releaseTags(root: string): Promise<string[] | null> {
155
+ if (!(await sh("git remote get-url origin", root)).ok) return (await sh("git tag --sort=-creatordate", root)).out.split("\n").filter(Boolean);
156
+ const remote = await sh("git ls-remote --tags origin", root);
157
+ if (!remote.ok) return null;
158
+ return parseLsRemoteTags(remote.out);
159
+ }
160
+
161
+ const GATE_HELP =
162
+ "help [--json] | init [--force|--draft|--write <text>] | reflect [<note>] | roadmap [add <text>|pick <n> <slug>|done <n>|tidy] | doctor [<issue note>] | status [slug] [--json] | start <slug> [--ui] [--force] [--patch|--docs --confirmed] | use <slug> | test | check | health | approve [--yolo] | verify [--yolo] | release [--yolo|--no-version] | release-done [--no-artifact-check|--no-release-create] | finish [--yolo] [slug] | abort [slug] | version — reflect <note> queues a suggestion in reflections.md (gate flags like --confirmed are ignored there); bare reflect prints the full session-reflection protocol (review the session log, re-check open items against shipped fixes and newer dev-flow versions, dedupe and report new findings, put the fix decision to the operator); roadmap manages .dev-flow/roadmap.md, the gitignored backlog of future features (bare lists open items; add appends one; pick <n> <slug> stamps open item <n> with the slug a later flow start uses — flow finish checks it off when that feature ships; done <n> checks off by hand; tidy drops done items); bare doctor reviews structure, git consistency and open reflections (routes each fix to a flow; it never writes); doctor <note> prints an investigation brief for a fresh observation; state gates take [slug] and --confirmed (the operator already answered in chat); --patch/--docs likewise need --confirmed (the operator granted the mode); --yolo on any state gate (approve/verify/release/finish) = the operator said \"yolo\" at that checkpoint: it answers that question and pre-confirms every later one (needs --confirmed; checks and guardrails stay); release --no-version = this flow ships merge + changelog only — no bump, tag or forge release; the entry rides under Unreleased until the next versioned release folds it (default: versioned)";
163
+ const featDir = (root: string, slug: string) => path.join(dfDir(root), slug);
164
+ const stateFile = (root: string, slug: string) => path.join(featDir(root, slug), "state.json");
165
+ const claimFile = (root: string, slug: string) => path.join(featDir(root, slug), "claim.json");
166
+
167
+ function readState(root: string, slug: string): FlowState | null {
168
+ try {
169
+ return JSON.parse(readFileSync(stateFile(root, slug), "utf8")) as FlowState;
170
+ } catch {
171
+ return null;
172
+ }
173
+ }
174
+
175
+ function writeState(root: string, state: FlowState): void {
176
+ atomicWrite(stateFile(root, state.slug), `${JSON.stringify(state, null, "\t")}\n`);
177
+ }
178
+
179
+ /** Replace a file in one move. Gates write state after every check, and a kill in the middle left a
180
+ * truncated state.json — which reads as "no such feature", silently orphaning the worktree, the plan
181
+ * and every recorded check, with the folder gitignored so nothing else could restore it. */
182
+ function atomicWrite(file: string, text: string): void {
183
+ const tmp = `${file}.tmp`;
184
+ writeFileSync(tmp, text);
185
+ renameSync(tmp, file);
186
+ }
187
+
188
+ /** Session bindings: one flow per pi session; persisted per session branch via appendEntry. */
189
+ const bindings = new Map<string, string | null>();
190
+ const sessionKey = (ctx: { cwd: string; sessionManager?: { getSessionId(): string } }) => ctx.sessionManager?.getSessionId() ?? ctx.cwd;
191
+ /** Sessions with a dev-flow-initiated compaction in flight (prevents stacked compactions). */
192
+ const compacting = new Set<string>();
193
+
194
+ /** Seam for the takeover's summarize call (pi's own compact()); tests replace `.run` — mutable object stands in for ESM mocking. */
195
+ export const compactionTakeover: { run: typeof compact } = { run: compact };
196
+
197
+ function unfinishedStates(root: string): FlowState[] {
198
+ if (!existsSync(dfDir(root))) return [];
199
+ return readdirSync(dfDir(root), { withFileTypes: true })
200
+ .filter((d) => d.isDirectory())
201
+ .map((d) => readState(root, d.name))
202
+ .filter((s): s is FlowState => !!s && s.stage !== "done");
203
+ }
204
+
205
+ /** Open items of the agent's suggestion queue (.dev-flow/reflections.md). */
206
+ const readReflections = (root: string): string[] => {
207
+ const f = path.join(dfDir(root), "reflections.md");
208
+ return existsSync(f) ? openReflections(readFileSync(f, "utf8")) : [];
209
+ };
210
+
211
+ const roadmapFile = (root: string) => path.join(dfDir(root), "roadmap.md");
212
+ const readRoadmap = (root: string): string => {
213
+ const f = roadmapFile(root);
214
+ return existsSync(f) ? readFileSync(f, "utf8") : "";
215
+ };
216
+
217
+ /** The session's feature: explicit binding, else the single unfinished feature — auto-bind unless another live session claims it. */
218
+ function boundFeature(root: string, key: string): FlowState | null {
219
+ const bound = bindings.get(key);
220
+ if (bound) {
221
+ const s = readState(root, bound);
222
+ if (s && s.stage !== "done") return s;
223
+ }
224
+ const un = unfinishedStates(root);
225
+ if (un.length !== 1) return null;
226
+ const claim = readClaim(root, un[0].slug);
227
+ // shortcut: liveness is pid-only — pid reuse can read "live" and force an explicit flow use; friction only, never a wrong commit.
228
+ if (claimBlocks(claim, key)) return null;
229
+ if (claim?.key !== key) writeClaim(root, un[0].slug, key);
230
+ return un[0];
231
+ }
232
+
233
+ function readClaim(root: string, slug: string): SessionClaim | null {
234
+ try {
235
+ return JSON.parse(readFileSync(claimFile(root, slug), "utf8")) as SessionClaim;
236
+ } catch {
237
+ return null;
238
+ }
239
+ }
240
+
241
+ const writeClaim = (root: string, slug: string, key: string) =>
242
+ atomicWrite(claimFile(root, slug), `${JSON.stringify({ key, pid: process.pid, at: now() })}\n`);
243
+
244
+ /** The auto-bind this session was denied by another live session's claim — lets the unbound-context message name the feature. */
245
+ function claimBlocker(root: string, key: string): { state: FlowState; claim: SessionClaim } | null {
246
+ const un = unfinishedStates(root);
247
+ if (un.length !== 1) return null;
248
+ const claim = readClaim(root, un[0].slug);
249
+ return claim && claimBlocks(claim, key) ? { state: un[0], claim } : null;
250
+ }
251
+
252
+ /** Flow-aware focus for any compaction dev-flow initiates — state lives on disk, the summary must not invent it. */
253
+ const flowCompactInstructions = (st: FlowState) =>
254
+ `dev-flow feature "${st.slug}", stage "${st.stage}" — flow state is persisted on disk (state.json, plan/). Preserve verbatim: operator answers not yet recorded by a gate, reasons for uncommitted changes, and the exact next action from the last exchange.`;
255
+
256
+ /**
257
+ * Flow-aware self-compaction at safe points: called after a successful gate (state just hit disk),
258
+ * at every turn_end and when a run settles. Only fires within COMPACT_MARGIN of pi's own threshold
259
+ * (computed with the *effective* reserveTokens — settings + model overrides, like pi does) and
260
+ * never while chat-only state is pending (open dialog, undigested plan) — see shouldSelfCompact.
261
+ */
262
+ function maybeCompactNow(ctx: ExtensionContext): void {
263
+ if (!existsSync(dfDir(ctx.cwd))) return;
264
+ const u = ctx.getContextUsage?.(); // minimal/fake contexts may lack usage info — treat as unknown
265
+ if (!u || u.tokens === null) return;
266
+ const key = sessionKey(ctx);
267
+ const st = boundFeature(ctx.cwd, key);
268
+ if (!st) return;
269
+ let reserve: number;
270
+ try {
271
+ const settings = SettingsManager.create(ctx.cwd); // throws on a malformed compaction setting — pi surfaces that itself; we just stay out
272
+ if (!settings.getCompactionEnabled()) return; // operator turned auto-compaction off — don't do it behind their back
273
+ reserve = settings.getCompactionReserveTokens(ctx.model);
274
+ } catch {
275
+ return;
276
+ }
277
+ const cfg = loadProject(dfDir(ctx.cwd)).cfg;
278
+ const decision = {
279
+ tokens: u.tokens,
280
+ contextWindow: u.contextWindow,
281
+ stage: st.stage,
282
+ planFiles: listFiles(path.join(featDir(ctx.cwd, st.slug), "plan")).length,
283
+ checklistPending: checklistPending(cfg, st, st.stage),
284
+ compacting: compacting.has(key),
285
+ };
286
+ if (!shouldSelfCompact(decision, reserve)) return;
287
+ compacting.add(key);
288
+ const done = () => compacting.delete(key);
289
+ try {
290
+ ctx.compact({
291
+ customInstructions: flowCompactInstructions(st),
292
+ onComplete: done,
293
+ onError: done,
294
+ });
295
+ } catch {
296
+ done(); // a throw before the run started must not leave the flag set: that switched off self-compaction and the takeover for the rest of the session
297
+ }
298
+ }
299
+
300
+ function featureRefs(root: string): FeatureRef[] {
301
+ return unfinishedStates(root).map((s) => ({ state: s, worktreeAbs: s.worktree ? path.resolve(root, s.worktree) : null }));
302
+ }
303
+
304
+ function worktreeAbs(root: string, state: FlowState): string | null {
305
+ return state.worktree ? path.resolve(root, state.worktree) : null;
306
+ }
307
+
308
+ /** Git-level consistency probes for the doctor: orphan worktrees/branches, features missing their worktree. */
309
+ async function gitConsistency(root: string, cfg: Config): Promise<string[]> {
310
+ const feats = unfinishedStates(root);
311
+ const out: string[] = [];
312
+ const wtRoot = path.resolve(root, cfg.worktreeDir.split("{")[0]);
313
+ const owned = new Set(feats.map((f) => (f.worktree ? path.resolve(root, f.worktree) : "")));
314
+ const wtRaw = await sh("git worktree list --porcelain", root);
315
+ if (wtRaw.ok)
316
+ for (const line of wtRaw.out.split("\n")) {
317
+ if (!line.startsWith("worktree ")) continue;
318
+ const abs = path.resolve(line.slice(9).trim());
319
+ if (abs !== path.resolve(root) && abs.startsWith(wtRoot) && !owned.has(abs)) out.push(`orphan worktree ${path.relative(root, abs)} — no feature owns it (git worktree remove)`);
320
+ }
321
+ const ownedBr = new Set(feats.map((f) => f.branch));
322
+ const brRaw = await sh(`git branch --list ${sq(`${featureBranchPrefix(cfg)}*`)}`, root);
323
+ if (brRaw.ok)
324
+ // git marks the current branch with "* " and a worktree-checked-out one with "+ " — strip both, or an active feature's own branch never matches ownedBr
325
+ for (const b of brRaw.out.split("\n").map((x) => x.replace(/^[*+]?\s+/, "").trim()).filter(Boolean)) {
326
+ if (!ownedBr.has(b)) out.push(`orphan branch ${b} — no unfinished feature owns it (git branch -D ${b})`);
327
+ }
328
+ for (const f of feats) if (f.worktree && !existsSync(path.resolve(root, f.worktree))) out.push(`feature ${f.slug} (${f.stage}) has no worktree at ${f.worktree}`);
329
+ // the playbook belongs in git — an untracked project.md is invisible to teammates and lost on machine changes
330
+ if (existsSync(path.join(root, ".dev-flow", "project.md")) && !(await sh("git ls-files --error-unmatch -- .dev-flow/project.md", root)).ok)
331
+ out.push('.dev-flow/project.md is not tracked in git — track it: git add -f .dev-flow/project.md && DEV_FLOW_ALLOW_MAIN=1 git commit -m "track project.md" (force: a root .gitignore may exclude .dev-flow/ wholesale; the env prefix is for the installed hook — dev-flow allows a main commit whose only change is the playbook)');
332
+ return out;
333
+ }
334
+
335
+ /** Where feature commands run: the feature worktree once it exists (that is where the
336
+ * code under test lives), else the main checkout. Keeps gates off the main branch. */
337
+
338
+ /** Lines the current project.md has that a regenerated draft cannot reproduce. */
339
+ function droppableLines(current: string, draft: string): string[] {
340
+ const keep = new Set(draft.split("\n").map((l) => l.trim()).filter(Boolean));
341
+ return current.split("\n").map((l) => l.trim()).filter((l) => l && !keep.has(l));
342
+ }
343
+
344
+ function gateCwd(root: string, state: FlowState): string {
345
+ const wt = worktreeAbs(root, state);
346
+ return wt && existsSync(wt) ? wt : root;
347
+ }
348
+
349
+ /** Tier-1 checks the gate still has to see green. Entering a stage — and every edit of the worktree —
350
+ * clears the records, so "run flow check first" cost three round trips to say what one could (#84).
351
+ * `health` is left to the post-deploy probe when there is a deploy to prove: probing twice only waits twice. */
352
+ function pendingChecks(state: FlowState, cfg: Config): StageCheck[] {
353
+ if (state.docs) return [];
354
+ return cfg.checkList.filter((c) => !state.checks[c.name]?.ok && !(c.name === "health" && cfg.commands.dev && healthProbe(cfg)));
355
+ }
356
+
357
+ /** Run tier-1 checks in the feature cwd and record each result (health seeds first, as `flow health` does). */
358
+ async function runChecks(root: string, state: FlowState, cfg: Config, checks: StageCheck[]): Promise<{ lines: string[]; failed: boolean }> {
359
+ const lines: string[] = [];
360
+ let failed = false;
361
+ for (const c of checks) {
362
+ if (c.name === "health" && cfg.commands.seedData) {
363
+ const seed = await sh(cfg.commands.seedData, gateCwd(root, state));
364
+ if (!seed.ok) {
365
+ lines.push(`seedData FAILED:\n${tail(seed.out)}`);
366
+ failed = true;
367
+ break;
368
+ }
369
+ }
370
+ const res = await sh(c.cmd, gateCwd(root, state));
371
+ state.checks[c.name] = { ok: res.ok, at: now() };
372
+ lines.push(`${c.name}: ${res.ok ? "PASS" : "FAIL"} (${c.cmd})${res.ok ? "" : `\n${tail(res.out)}`}`);
373
+ if (!res.ok) failed = true;
374
+ }
375
+ writeState(root, state);
376
+ return { lines, failed };
377
+ }
378
+
379
+ /** Commits the feature branch holds over its merge base with the default branch, plus how many
380
+ * paths sit uncommitted: verify's proof that there is a committed state behind the operator's
381
+ * question. Local refs only — same offline-safe stance as the changelog proof — and an
382
+ * unanswerable merge base reports null instead of refusing on a measurement failure. */
383
+ async function branchState(cwd: string, defaultBranch: string): Promise<BranchState> {
384
+ const mb = await sh(`git merge-base origin/${sq(defaultBranch)} HEAD 2>/dev/null || git merge-base ${sq(defaultBranch)} HEAD 2>/dev/null || true`, cwd);
385
+ const base = mb.out.trim();
386
+ if (!mb.ok || !base) return { commitsAhead: null, uncommitted: 0 };
387
+ const ahead = await sh(`git rev-list --count ${sq(base)}..HEAD`, cwd);
388
+ const n = Number.parseInt(ahead.out.trim(), 10);
389
+ const dirty = await sh("git status --porcelain", cwd);
390
+ return {
391
+ commitsAhead: Number.isFinite(n) ? n : null,
392
+ uncommitted: dirty.ok ? dirty.out.split("\n").filter((l) => l.trim()).length : 0,
393
+ };
394
+ }
395
+
396
+ function describeBranchState(b: BranchState, defaultBranch: string): string {
397
+ return b.commitsAhead === null
398
+ ? `commits: unknown (no merge base with ${defaultBranch} — the commit proof is skipped)`
399
+ : `commits: ${b.commitsAhead} ahead of ${defaultBranch}, ${b.uncommitted} uncommitted path(s)`;
400
+ }
401
+
402
+ // ---------------------------------------------------------------- gate runner
403
+
404
+ interface RunResult {
405
+ text: string;
406
+ isError?: boolean;
407
+ }
408
+
409
+ interface Session {
410
+ slug: string | null;
411
+ key: string;
412
+ bind: (slug: string | null) => void;
413
+ }
414
+
415
+ /** Is this checkout the dev-flow repo itself? Plugin reflections are fixable in-repo only here. */
416
+ function isSelfRepo(root: string): boolean {
417
+ try {
418
+ return (JSON.parse(readFileSync(path.join(root, "package.json"), "utf8")) as { name?: unknown }).name === "pi-dev-flow";
419
+ } catch {
420
+ return false;
421
+ }
422
+ }
423
+
424
+ /** The questions `flow init` asks before writing a playbook — detection fills what it can, the operator answers the rest (the agent relays them). */
425
+ function setupQuestions(det: { partial: { defaultBranch: string; versioning?: { file: string }; forge?: string }; missing: string[] }): string[] {
426
+ const q = [det.partial.forge ? `git forge: detected "${det.partial.forge}" from the origin URL — github, gitlab or self-hosted? (global key forge)` : "git forge: no origin remote to detect from — github, gitlab or self-hosted? (global key forge)"];
427
+ q.push(det.partial.versioning ? `version manifest: detected ${det.partial.versioning.file} — keep it? tag pattern v{version}?` : "versioning: no manifest found — which file holds the version (VERSION recommended), and the tag pattern?");
428
+ q.push("releases: should release-done create the forge release automatically via gh/glab (releases = yes), or is publishing manual (no)?");
429
+ q.push("artifacts: do you publish anything to a registry (e.g. a container image to Docker Hub)? then release.artifact needs a verify command (e.g. docker buildx imagetools inspect <image>:{version}); the push itself belongs to ## release prose.");
430
+ q.push(`tier-1 checks: ${det.missing.length ? `not detected: ${det.missing.join(", ")} —` : "detected test/build/run —"} which commands run tests, the build? any lint/typecheck? each extra becomes a flow-check block under ## implement.`);
431
+ q.push("runtime: does the app expose a health endpoint (commands.health) and is there a dev deploy (commands.dev) the operator reviews before verify?");
432
+ q.push("manual checks: any per-stage items the operator must tick off (tier-2 checklist) or standing guidance (prose)?");
433
+ return q;
434
+ }
435
+
436
+ const GIT_TRACK_NOTE = 'the playbook belongs in git: git add -f .dev-flow/project.md && DEV_FLOW_ALLOW_MAIN=1 git commit -m "track project.md" (force-add: a root .gitignore may exclude .dev-flow/ entirely — tracked once, the ignore stops mattering; the env prefix is for the pre-commit hook, dev-flow itself lets a main commit whose only change is the playbook)';
437
+
438
+ /** `flow reflect <note>` — advisory queue, operator decides. The note lands in the
439
+ * playbook and/or plugin section of reflections.md, chosen from its wording. */
440
+ function reflectNote(root: string, sess: Session, note: string[]): RunResult {
441
+ const file = path.join(dfDir(root), "reflections.md");
442
+ const text = existsSync(file) ? readFileSync(file, "utf8") : "";
443
+ const sections = reflectSections(note.join(" "));
444
+ const line = reflectionLine(note.join(" "), sess.slug, now().slice(0, 10));
445
+ const next = appendReflection(text, sections, line);
446
+ if (next === text) return { text: `already reflected (duplicate): ${line}` };
447
+ writeFileSync(file, next);
448
+ return { text: `reflected (${sections.join(" + ")}): ${line}` };
449
+ }
450
+
451
+ /** Single runner for both the /flow command and the flow tool. */
452
+ async function runFlow(args: string[], root: string, ui: Ui, sess: Session): Promise<RunResult> {
453
+ const [sub = "status", ...rest] = args;
454
+ // help and version are questions about the plugin, not the repo: answerable with no .dev-flow/ at all
455
+ if (sub === "help" || sub === "--help" || sub === "-h") return { text: rest.includes("--json") ? gateHelpJson(GATE_HELP, LOADED_VERSION) : `usage: flow <sub> — ${GATE_HELP}` };
456
+ if (!existsSync(dfDir(root)) && sub !== "init" && sub !== "version") return { text: "no .dev-flow/ in this repo — run flow init first", isError: true };
457
+ const cfg = loadProject(dfDir(root)).cfg;
458
+ const flag = (name: string) => rest.includes(`--${name}`);
459
+ const slugArg = rest.find((a) => !a.startsWith("--"));
460
+
461
+ switch (sub) {
462
+ case "init": {
463
+ // a recursive mkdir is silent about whether it created anything, and `flow init --write` is the
464
+ // only sanctioned agent path to the playbook: claiming "created" when the folder was already
465
+ // there sends the agent diffing its own config for damage that never happened (#39).
466
+ const dfExisted = existsSync(dfDir(root));
467
+ mkdirSync(dfDir(root), { recursive: true });
468
+ const notes: string[] = dfExisted ? [] : [`.dev-flow/ created in ${root}`];
469
+ // self-ignoring folder with one exception: the playbook is the only file under .dev-flow/ that lives in git
470
+ writeFileSync(path.join(dfDir(root), ".gitignore"), "*\n!project.md\n");
471
+ rmSync(path.join(dfDir(root), ".current"), { force: true }); // migrate away the legacy repo-global pointer
472
+ const mdFile = path.join(dfDir(root), "project.md");
473
+ const head = await sh("git symbolic-ref refs/remotes/origin/HEAD", root);
474
+ const curBr = await currentBranch(root);
475
+ // never propose a feature branch (contains "/") as the default branch
476
+ const branch = (head.ok ? head.out.match(/refs\/remotes\/origin\/(.+)/)?.[1] : undefined) || (curBr.includes("/") ? undefined : curBr) || "main";
477
+ const det = detectProject(root, branch);
478
+ const origin = await sh("git remote get-url origin", root);
479
+ det.partial.forge = origin.ok && origin.out.trim() ? detectForge(origin.out) : undefined; // confirmed or corrected by the operator during setup; absent = no origin to detect from
480
+ const loaded = loadProject(dfDir(root));
481
+ const draftFresh = buildDraft(det);
482
+ const draftRefresh = buildDraft(det, loaded.cfg);
483
+ const editHint = "to change anything: edit .dev-flow/project.md directly (flow init = doctor revalidates), or from an agent session: flow init --draft → discuss → --write";
484
+ if (flag("draft")) return { text: existsSync(mdFile) ? readFileSync(mdFile, "utf8") : draftFresh }; // read path: prints content, writes nothing (and offers no deletions)
485
+ // ghosts: feature dirs whose state.json is gone — inert, invisible to status and guards, and only findable by eye
486
+ const ghosts = ghostFeatureDirs(root);
487
+ if (ghosts.length) {
488
+ if (!ui.hasUI) notes.push(`${ghosts.length} ghost feature dir(s) — a plan/ with no state.json, no flow owns them (${ghosts.join(", ")}); delete them or run flow init interactively`);
489
+ else if (await ui.confirm("Remove ghost feature dirs?", `${ghosts.map((g) => `${g} — plan/ with no state.json`).join("\n")}\n\nNo flow owns these; their plan/*.md is not recoverable afterwards.`)) {
490
+ for (const g of ghosts) rmSync(path.join(root, g), { recursive: true, force: true });
491
+ notes.push(`removed ${ghosts.length} ghost feature dir(s): ${ghosts.map((g) => g.slice(".dev-flow/".length)).join(", ")}`);
492
+ } else notes.push(`kept ${ghosts.length} ghost feature dir(s) — inert, remove them whenever you like`);
493
+ }
494
+ const writeMd = (text: string): RunResult | null => {
495
+ const parsed = parseProjectMd(text);
496
+ if (parsed.hard.length) return { text: `project.md REJECTED:\n- ${parsed.hard.join("\n- ")}`, isError: true };
497
+ // the operator's rules are not regenerable — keep what was there before any overwrite
498
+ if (existsSync(mdFile)) copyFileSync(mdFile, `${mdFile}.bak`);
499
+ writeFileSync(mdFile, text.endsWith("\n") ? text : `${text}\n`);
500
+ return null;
501
+ };
502
+ if (flag("write")) {
503
+ const content = rest[rest.indexOf("--write") + 1];
504
+ if (!content || content.startsWith("--")) return { text: 'usage: flow init --write "<full project.md content>"', isError: true };
505
+ const parsed = parseProjectMd(content);
506
+ const rej = writeMd(content);
507
+ if (rej) return rej;
508
+ notes.push("project.md written (operator-owned playbook)", GIT_TRACK_NOTE);
509
+ if (parsed.warn.length) notes.push("warnings:", ...parsed.warn.map((w) => ` ${w}`));
510
+ } else if (!existsSync(mdFile)) {
511
+ // first init: never write silently. The setup brief lists what detection could not decide;
512
+ // the agent relays the questions to the operator and applies the answers via flow init --write
513
+ // (validated) after presenting the digest; an interactive init may confirm the draft right here.
514
+ const brief = [
515
+ "setup — project.md not written yet; nothing is drafted to disk until the operator confirms.",
516
+ ...det.report.map((r) => ` ${r}`),
517
+ "questions for the operator (one per unclear knob, with the detected default):",
518
+ ...setupQuestions(det).map((line, i) => ` ${i + 1}. ${line}`),
519
+ "agent: ask these in chat, fill the draft (flow init --draft), then apply with flow init --write \"<content>\" (hard-validated) — present the digest and get the operator's confirmation before writing.",
520
+ GIT_TRACK_NOTE,
521
+ ];
522
+ if (ui.hasUI) {
523
+ const ok = await ui.confirm("Write project.md draft?", `${mdDigest(draftFresh)}\n\nWrite this detection draft now? Declining leaves the setup questions open for chat clarification.`);
524
+ if (ok) {
525
+ const rej = writeMd(draftFresh);
526
+ if (rej) return rej;
527
+ notes.push("wrote .dev-flow/project.md from detection — review it and adjust as needed", GIT_TRACK_NOTE);
528
+ } else notes.push("draft not written — answer the setup questions, then flow init --write");
529
+ }
530
+ notes.push(...brief);
531
+ } else if (flag("force")) {
532
+ // a destructive reset of the operator's own file needs their word: --confirmed for an agent, one dialog for a human
533
+ const ok = flag("confirmed") || (ui.hasUI && (await ui.confirm("Overwrite project.md?", `flow init --force replaces the operator playbook with a fresh detection draft. Hand-written rules, checklist items and guidance are lost (a copy is kept as project.md.bak).`)));
534
+ if (!ok) return { text: "flow init --force overwrites the operator playbook — that needs the operator's word: re-run with --confirmed (the current file is kept as project.md.bak)", isError: true };
535
+ copyFileSync(mdFile, `${mdFile}.bak`);
536
+ writeFileSync(mdFile, draftFresh);
537
+ notes.push("project.md OVERWRITTEN (--force; previous copy kept as project.md.bak):", ...det.report.map((r) => ` ${r}`), GIT_TRACK_NOTE);
538
+ } else if (!ui.hasUI) {
539
+ // the CI/agent path: the doctor's findings have to be printed, not just promised
540
+ const findings = doctorFindings(root, loaded.cfg, loaded.md);
541
+ notes.push("project.md already exists — kept (flow init --force to overwrite, --write to edit)", findings.length ? `doctor: ${findings.length} finding(s):` : "doctor: no findings", ...findings.map((x) => ` ${x}`));
542
+ } else if (existsSync(mdFile)) {
543
+ // doctor mode: cheap consistency probes only — never runs project commands
544
+ const findings = doctorFindings(root, loaded.cfg, loaded.md);
545
+ findings.push(...(await gitConsistency(root, loaded.cfg)));
546
+ for (const l of readReflections(root)) findings.push(`open reflection: ${l.slice(6)}`);
547
+ if (!findings.length) notes.push("doctor: project.md OK — no drift, no inconsistencies");
548
+ else {
549
+ // a refresh is regenerated from detection + config: lines it cannot reproduce are
550
+ // hand-written (prose, checklist items, hand-set keys) and would vanish silently.
551
+ const dropped = droppableLines(readFileSync(mdFile, "utf8"), draftRefresh);
552
+ let edited: string | undefined;
553
+ if (dropped.length)
554
+ notes.push(`doctor: refresh refused — a regenerated draft would drop ${dropped.length} hand-written line(s):`, ...dropped.slice(0, 8).map((l) => ` ${l}`), ...(dropped.length > 8 ? [` …(${dropped.length - 8} more)`] : []));
555
+ else
556
+ edited = ui.editor
557
+ ? await ui.editor(`flow init — doctor: ${findings.length} finding(s) — edit & save to apply, Esc skips`, draftRefresh)
558
+ : (await ui.confirm(`flow init — doctor: ${findings.length} finding(s)`, `${findings.join("\n")}
559
+
560
+ Write refreshed project.md? Your file; cancel writes nothing.
561
+ ${mdDigest(draftRefresh)}`))
562
+ ? draftRefresh
563
+ : undefined;
564
+ if (edited === undefined) notes.push(dropped.length ? "findings — fix them by editing the file:" : "declined — project.md unchanged", ...findings.map((f) => ` ${f}`));
565
+ else {
566
+ const rej = writeMd(edited);
567
+ if (rej) return rej;
568
+ notes.push("project.md refreshed — findings addressed:", ...findings.map((f) => ` ${f}`), editHint);
569
+ }
570
+ }
571
+ }
572
+ const initCfg = loadProject(dfDir(root)).cfg;
573
+ if (initCfg.mainHook) {
574
+ const hookDir = path.join(root, ".git", "hooks");
575
+ const hook = path.join(hookDir, "pre-commit");
576
+ if (existsSync(hookDir) && !existsSync(hook)) {
577
+ writeFileSync(
578
+ hook,
579
+ `#!/bin/sh\n# installed by dev-flow: direct commits to ${initCfg.defaultBranch} are forbidden (override: DEV_FLOW_ALLOW_MAIN=1)\nbranch=$(git rev-parse --abbrev-ref HEAD)\nif [ "$branch" = "${initCfg.defaultBranch}" ] && [ -z "$DEV_FLOW_ALLOW_MAIN" ]; then\n echo "dev-flow: direct commits to ${initCfg.defaultBranch} are forbidden — use a feature worktree and a PR" >&2\n exit 1\nfi\n`,
580
+ { mode: 0o755 },
581
+ );
582
+ notes.push("pre-commit hook installed (blocks commits on main)");
583
+ } else if (existsSync(hook) && !readFileSync(hook, "utf8").includes("installed by dev-flow")) {
584
+ notes.push("existing pre-commit hook found — dev-flow hook NOT installed; main commits outside Pi are unguarded");
585
+ }
586
+ }
587
+ return { text: notes.join("\n") };
588
+ }
589
+
590
+ case "start": {
591
+ if (!slugArg || !validSlug(slugArg)) return { text: "usage: flow start <slug> [--ui] [--patch|--docs] (slug: lowercase, dashes); --patch/--docs are operator-granted modes — ask the operator in chat, then add --confirmed", isError: true };
592
+ if (existsSync(featDir(root, slugArg))) return { text: `feature ${slugArg} already exists`, isError: true };
593
+ // relaxed modes are operator-granted: --confirmed anchors a literal chat grant, same convention as the gate verbs
594
+ if (flag("patch") && flag("docs")) return { text: "--patch and --docs are exclusive — ask the operator which mode they grant", isError: true };
595
+ const mode = flag("patch") ? "patch" : flag("docs") ? "docs" : null;
596
+ if (mode && !flag("confirmed")) return { text: `${mode} mode is operator-granted — the operator must grant it in chat, then re-run with --confirmed`, isError: true };
597
+ // branch discipline: features branch off the default branch — starting from a stale checkout poisons the base
598
+ const curBr = await currentBranch(root);
599
+ if (curBr !== cfg.defaultBranch && !flag("force")) return { text: `main checkout is on ${curBr}, not ${cfg.defaultBranch} — check out ${cfg.defaultBranch} first (git checkout ${cfg.defaultBranch} && git pull) or flow use an unfinished feature; --force to override`, isError: true };
600
+ // stamp the dev-flow build that started this feature — a stuck flow can later be read against its tooling
601
+ const self = pluginVersion(import.meta.dirname);
602
+ const state = newState(slugArg, flag("ui"), flag("patch"), flag("docs"), self.version ?? undefined);
603
+ mkdirSync(path.join(featDir(root, slugArg), "plan"), { recursive: true });
604
+ writeState(root, state);
605
+ sess.bind(slugArg);
606
+ return { text: `feature ${slugArg} started at stage plan (ui=${state.ui}${state.patch ? ", patch" : state.docs ? ", docs" : ""}${self.version ? `, dev-flow ${self.version}` : ""}).\n${nextHint(state, cfg)}` };
607
+ }
608
+
609
+ case "status": {
610
+ const df = dfDir(root);
611
+ const { md } = loadProject(df);
612
+ // cheap fs probes only (no git subprocesses) — status is a glance, `flow doctor` is the full list
613
+ const findings = md ? doctorFindings(root, cfg, md) : [];
614
+ const warn = findings.length ? [`⚠ doctor: ${findings.length} finding(s) — run flow doctor`] : [];
615
+ // the backlog nudge belongs to the no-features answer too — that is when a pick is most useful
616
+ const rmap = roadmapOpenCount(readRoadmap(root));
617
+ const rmapLine = rmap ? [`◇ ${rmap} roadmap item(s) open — flow roadmap`] : [];
618
+ const slugs = slugArg ? [slugArg] : existsSync(df) ? (await listDirs(df)).filter((s) => readState(root, s)) : [];
619
+ const un0 = unfinishedStates(root);
620
+ const cur = sess.slug ?? (un0.length === 1 ? un0[0].slug : null);
621
+ if (flag("json")) {
622
+ const sts = slugs.map((s) => readState(root, s)).filter((s): s is FlowState => s !== null);
623
+ return { text: statusJson(sts, cur, cfg) }; // before the prose "no features" answer — an orchestrator must be able to parse the empty case too
624
+ }
625
+ if (!slugs.length) return { text: ["no features — start one: flow start <slug> [--ui] [--patch|--docs --confirmed]", ...warn, ...rmapLine].join("\n") };
626
+ const lines = slugs.flatMap((s) => {
627
+ const st = readState(root, s);
628
+ if (!st) return [];
629
+ return [
630
+ `${s === cur ? "→" : " "} ${s}: stage=${st.stage}${st.ui ? " [ui]" : ""}${st.yolo ? " [yolo]" : ""}${st.noVersion ? " [no-version]" : ""} branch=${st.branch || "-"} worktree=${st.worktree || "-"}${st.flowVersion ? ` flow=${st.flowVersion}` : ""}`,
631
+ ` approvals: ${Object.keys(st.approvals).join(", ") || "none"} | checks: ${Object.entries(st.checks).map(([k, v]) => `${k}:${v.ok ? "ok" : "FAILED"}`).join(", ") || "none"}${st.confirmations?.implement ? " | checklist: confirmed" : ""}`,
632
+ ` next: ${nextHint(st, cfg)}`,
633
+ ];
634
+ });
635
+ if (warn.length) lines.push(...warn);
636
+ const refl = readReflections(root);
637
+ if (refl.length) lines.push(`↻ ${refl.length} reflection(s) open — run flow doctor (it reviews and routes them)`);
638
+ lines.push(...rmapLine);
639
+ return { text: lines.join("\n") || "no features" };
640
+ }
641
+
642
+ case "reflect": {
643
+ // gate flags belong to the state verbs — pasted into the note they read as part of the operator's
644
+ // own sentence in reflections.md (#43)
645
+ const words = rest.filter((a) => !a.startsWith("--"));
646
+ if (words.length) return reflectNote(root, sess, words);
647
+ // upstream for the update probe: the manifest's repository field; the dev-flow checkout itself falls back to its origin
648
+ const self = pluginVersion(import.meta.dirname);
649
+ const upstream = self.repo || (isSelfRepo(root) ? githubSlug((await sh("git remote get-url origin", root)).out) : "");
650
+ return { text: reflectProtocol(readReflections(root), isSelfRepo(root), { version: self.version, upstream }).join("\n") };
651
+ }
652
+
653
+ case "roadmap": {
654
+ // the backlog is advisory — no state, no gating; direct file edits stay allowed like reflections.md
655
+ const words = rest.filter((a) => !a.startsWith("--")); // gate flags are not backlog prose (#43 applies here too)
656
+ const [verb = "list", ...args] = words;
657
+ const text = readRoadmap(root);
658
+ const open = roadmapOpenCount(text);
659
+ const done = roadmapDoneCount(text);
660
+ const save = (next: string) => writeFileSync(roadmapFile(root), next); // .dev-flow/ existence is runFlow's precondition
661
+ const idx = Number(args[0]);
662
+ const validIdx = Number.isInteger(idx) && idx >= 1 && idx <= open;
663
+ switch (verb) {
664
+ case "list": {
665
+ if (!open) return { text: done ? `roadmap: nothing open (${done} done — flow roadmap tidy drops them)` : "roadmap: empty — flow roadmap add \"<future feature>\"" };
666
+ return { text: [`roadmap — ${open} open (${done} done):`, ...openChecklistItems(text).map((l, i) => ` ${i + 1}. ${l.slice(6)}`)].join("\n") };
667
+ }
668
+ case "add": {
669
+ if (!args.length) return { text: 'usage: flow roadmap add "<future feature>"', isError: true };
670
+ save(appendRoadmapItem(text, args.join(" ")));
671
+ return { text: `roadmap: added — ${open + 1} open` };
672
+ }
673
+ case "pick": {
674
+ const slug = args[1] ?? "";
675
+ if (!validIdx) return { text: `pick: open item 1–${open} required — flow roadmap lists them`, isError: true };
676
+ if (!slug || !validSlug(slug)) return { text: `usage: flow roadmap pick <n> <slug> — the slug flow start will use (lowercase, dashes)`, isError: true };
677
+ save(roadmapPick(text, idx, slug)!);
678
+ return { text: `roadmap: item ${idx} picked for ${slug} — now: flow start ${slug} (flow finish checks the item off when it ships)` };
679
+ }
680
+ case "done": {
681
+ if (!validIdx) return { text: `done: open item 1–${open} required — flow roadmap lists them`, isError: true };
682
+ // stamped with date + bound slug, so history says what shipped it
683
+ save(roadmapDone(text, idx, [new Date().toISOString().slice(0, 10), sess.slug].filter(Boolean).join(" "))!);
684
+ return { text: `roadmap: item ${idx} marked done (${open - 1} open)` };
685
+ }
686
+ case "tidy": {
687
+ if (!done) return { text: `roadmap: nothing to tidy (${open} open, 0 done)` };
688
+ save(roadmapTidy(text));
689
+ return { text: `roadmap: dropped ${done} done item(s) — ${open} open` };
690
+ }
691
+ default:
692
+ return { text: "usage: flow roadmap [list|add <text>|pick <n> <slug>|done <n>|tidy]", isError: true };
693
+ }
694
+ }
695
+
696
+ case "doctor": {
697
+ // read-only findings on demand — never writes, never asks, safe for agents and CI at any stage
698
+ const df = dfDir(root);
699
+ const loaded = loadProject(df);
700
+ const findings = doctorFindings(root, loaded.cfg, loaded.md);
701
+ findings.push(...(await gitConsistency(root, loaded.cfg)));
702
+ const refl = readReflections(root);
703
+ if (refl.length) {
704
+ findings.push(`reflection review — ${refl.length} open item(s); doctor never implements: investigate each, present the fix, the operator confirms, then the routed flow implements it:`);
705
+ for (const r of reflectionRoutes(refl, isSelfRepo(root))) findings.push(` [${r.sections.join("+")}] ${r.text}\n → ${r.route}`);
706
+ }
707
+ if (!rest.length) return { text: findings.length ? `doctor: ${findings.length} finding(s)\n${findings.map((f) => ` ${f}`).join("\n")}` : "doctor: no findings — playbook, flow state and git layout are consistent" };
708
+ // flow doctor <note>: a fresh observation gets an investigation brief and a fix route
709
+ return { text: investigationBrief(rest.filter((a) => !a.startsWith("--")).join(" "), isSelfRepo(root)).join("\n") };
710
+ }
711
+
712
+ case "version": {
713
+ // the plugin's own version — separate from the project's versioning.file version the release gates use
714
+ const self = pluginVersion(import.meta.dirname);
715
+ const behind = sessionRunsOlderBuild(LOADED_VERSION, self.version);
716
+ const head = self.version ? `dev-flow ${self.version} (pi-dev-flow at ${self.dir})` : `dev-flow version unknown — no pi-dev-flow package.json above ${self.dir}`;
717
+ return { text: behind ? `${head} — but this session runs ${behind}; /reload to run ${self.version}` : head };
718
+ }
719
+
720
+ case "approve": {
721
+ const { state, err } = needState(root, sess, slugArg);
722
+ if (err) return err;
723
+ const errs = approveGate(state!, cfg, listFiles(path.join(featDir(root, state!.slug), "plan")));
724
+ if (errs.length) return { text: `plan gate FAILED:\n- ${errs.join("\n- ")}`, isError: true };
725
+ const planItems = applicableItems(cfg, state!, "plan");
726
+ if (flag("yolo")) state!.yolo = true; // operator's word at Approve? — every later gate question is pre-confirmed; checks and guardrails stay
727
+ const audit = flag("confirmed") ? " (confirmed by operator instruction)" : "";
728
+ if (!flag("confirmed")) {
729
+ if (!ui.hasUI) return { text: needsOperator("plan approval needs the operator: approve interactively, or re-run with --confirmed once the operator answered in chat", "Approve?"), isError: true };
730
+ const ok = await ui.confirm("Approve plan", `Approve ${state!.slug}${state!.ui ? " (UI)" : ""}${state!.patch ? " (patch)" : state!.docs ? " (docs)" : ""}? Creates the worktree.${flag("yolo") ? " YOLO: all later gate questions (Verified?/Release?/Finish?) are pre-confirmed — checks and guardrails stay in place." : ""}`);
731
+ if (!ok) return { text: "approval declined — keep iterating on the plan" };
732
+ }
733
+ if (planItems.length) state!.confirmations = { ...state!.confirmations, plan: { hash: checklistHash(planItems), at: now() } };
734
+ const branch = subst(cfg.branchPattern, state!.slug);
735
+ const wtRel = subst(cfg.worktreeDir, state!.slug);
736
+ const wt = path.resolve(root, wtRel);
737
+ let setupNote = "";
738
+ if (!existsSync(wt)) {
739
+ // branch discipline: features branch off the default branch — never from a stale checkout HEAD
740
+ const base = (await sh(`git rev-parse --verify -q origin/${cfg.defaultBranch}`, root)).ok ? `origin/${cfg.defaultBranch}` : cfg.defaultBranch;
741
+ const res = await sh(`git worktree add ${sq(wt)} -b ${branch} ${base}`, root);
742
+ if (!res.ok) return { text: `worktree creation failed:\n${res.out}`, isError: true };
743
+ }
744
+ // setup is worktree work of a normal feature: a docs flow runs no project command at all (#49),
745
+ // and a worktree left behind by a setup that never succeeded must be retried — "fix it and run
746
+ // approve again" was a silent no-op on the reused directory (#49). setup is expected to be idempotent.
747
+ const setupCmd = state!.docs ? undefined : cfg.commands.setup;
748
+ if (setupCmd && !state!.setupAt) {
749
+ const s = await sh(setupCmd, wt);
750
+ if (!s.ok) return { text: `setup failed in ${wtRel} — fix commands.setup, then run flow approve again (the worktree is kept and setup retried):\n${tail(s.out)}`, isError: true };
751
+ state!.setupAt = now();
752
+ setupNote = `\nsetup ran in the worktree (${setupCmd}).`;
753
+ } else if (state!.docs && cfg.commands.setup) {
754
+ setupNote = "\nsetup skipped (docs mode — no project commands run).";
755
+ }
756
+ state!.branch = branch;
757
+ state!.worktree = wtRel;
758
+ state!.stage = "implement";
759
+ state!.approvals.plan = now();
760
+ writeState(root, state!);
761
+ return { text: `plan approved${audit}${state!.yolo ? " — yolo: later gate questions pre-confirmed" : ""}; worktree ${wtRel} on branch ${branch}.${setupNote}\n${nextHint(state!, cfg)}` };
762
+ }
763
+
764
+ case "test": {
765
+ const { state, err } = needState(root, sess, slugArg);
766
+ if (err) return err;
767
+ if (!cfg.commands.test) return { text: "no commands.test configured — add `test = <command>` under ## implement in .dev-flow/project.md", isError: true };
768
+ const res = await sh(cfg.commands.test, gateCwd(root, state!));
769
+ state!.checks.test = { ok: res.ok, at: now() };
770
+ writeState(root, state!);
771
+ return { text: `test: ${res.ok ? "PASS" : "FAIL"} (${cfg.commands.test})\n${tail(res.out)}` };
772
+ }
773
+
774
+ case "verify": {
775
+ const { state, err } = needState(root, sess, slugArg);
776
+ if (err) return err;
777
+ const items = applicableItems(cfg, state!, "implement");
778
+ const branch = await branchState(gateCwd(root, state!), cfg.defaultBranch);
779
+ const branchLine = describeBranchState(branch, cfg.defaultBranch);
780
+ // a check with no record is run here, not asked for: entering the stage — and every edit since —
781
+ // cleared it, and telling the agent to run flow check cost three round trips (#84)
782
+ const pending = pendingChecks(state!, cfg);
783
+ const gateRun = pending.length ? await runChecks(root, state!, cfg, pending) : null;
784
+ if (gateRun?.failed) return { text: `verify gate FAILED — a check run at the gate failed:\n${gateRun.lines.join("\n")}\n${branchLine}`, isError: true };
785
+ // tier-1 gate first (tier-2 confirmation is recorded by this very command, so it is excluded here)
786
+ const preGate = verifyGate({ ...state!, confirmations: { ...state!.confirmations, implement: { hash: checklistHash(items), at: now() } } }, cfg, branch);
787
+ if (preGate.length) return { text: `verify gate FAILED:\n- ${preGate.join("\n- ")}\n${branchLine}`, isError: true };
788
+ // commands that take minutes and touch live services must say so (#38): a terse gate let the
789
+ // agent tell the operator the dev deploy had never run, right after it recreated a container.
790
+ const did: string[] = [];
791
+ if (gateRun) did.push(`checks ran at the gate (${pending.map((c) => c.name).join(", ")})`);
792
+ if (state!.docs) {
793
+ // docs mode's contract: no tier-1 work happens, so a docs flow neither builds, deploys nor probes (#43 #49)
794
+ did.push("docs mode: project commands skipped (build/dev/health)");
795
+ } else {
796
+ if (cfg.commands.build) {
797
+ const res = await sh(cfg.commands.build, gateCwd(root, state!));
798
+ state!.checks.build = { ok: res.ok, at: now() };
799
+ writeState(root, state!);
800
+ if (!res.ok) return { text: `build FAILED (${cfg.commands.build}):\n${tail(res.out)}`, isError: true };
801
+ did.push(`build ran (${cfg.commands.build})`);
802
+ }
803
+ // deploy to dev before asking — the operator reviews the running dev app, not a promise
804
+ if (cfg.commands.dev) {
805
+ const res = await sh(cfg.commands.dev, gateCwd(root, state!));
806
+ state!.checks.dev = { ok: res.ok, at: now() };
807
+ writeState(root, state!);
808
+ if (!res.ok) return { text: `dev deploy FAILED (${cfg.commands.dev}):\n${tail(res.out)}`, isError: true };
809
+ did.push(`dev ran (${cfg.commands.dev})`);
810
+ // the deploy is what must be proven, not just run: a container that came back unhealthy, or
811
+ // still serves the previous build, must not pass on a health PASS recorded before it was
812
+ // recreated. What "the expected version" means is the project's business — the probe runs in
813
+ // the worktree cwd, so it can compare the served build stamp with `git rev-parse HEAD`.
814
+ const probe = healthProbe(cfg);
815
+ if (!probe) did.push("health: none configured");
816
+ else {
817
+ if (cfg.commands.seedData) {
818
+ const seed = await sh(cfg.commands.seedData, gateCwd(root, state!));
819
+ state!.checks.seedData = { ok: seed.ok, at: now() };
820
+ writeState(root, state!);
821
+ if (!seed.ok) return { text: `seedData FAILED (${cfg.commands.seedData}):\n${tail(seed.out)}`, isError: true };
822
+ }
823
+ const probed = await runHealthProbe(probe, gateCwd(root, state!), cfg.healthWait);
824
+ state!.checks.health = { ok: probed.ok, at: now() };
825
+ writeState(root, state!);
826
+ // a probe that needed the wait budget says so — "it passed on the 9th try" is the operator's business (#42)
827
+ const tried = probed.attempts > 1 ? `, ${probed.attempts} attempts` : "";
828
+ if (!probed.ok) return { text: `post-deploy probe FAILED (${probe}) — the deploy is not healthy / not serving this build (gave up after ${probed.attempts} attempt(s), healthWait ${cfg.healthWait}s):\n${tail(probed.out)}`, isError: true };
829
+ did.push(`health: PASS (${probe}${tried})`);
830
+ }
831
+ } else did.push("dev: none configured");
832
+ }
833
+ const ran = [branchLine, ...did].join("; ");
834
+ // yolo is the operator's word at Verified? — answers this question and pre-confirms the later ones; --confirmed anchors it, checks and guardrails stay
835
+ if (flag("yolo")) {
836
+ if (!state!.yolo && !flag("confirmed")) return { text: "yolo is the operator's word — the operator must have said it in chat, then re-run with --yolo --confirmed", isError: true };
837
+ state!.yolo = true;
838
+ }
839
+ const audit = flag("confirmed") ? " (confirmed by operator instruction)" : state!.yolo ? " (yolo: pre-confirmed)" : "";
840
+ if (!flag("confirmed") && !state!.yolo) {
841
+ // these commands already ran — say so even on the way out
842
+ if (!ui.hasUI) return { text: `${needsOperator("verification needs the operator: confirm interactively, or re-run with --confirmed once the operator answered in chat.", "Verified?")}\n${ran}`, isError: true };
843
+ const ok = await ui.confirm("Operator verification", `Verified? All checks green${cfg.commands.dev && !state!.docs ? `; dev deploy is up for review (${cfg.commands.dev})` : ""} \u2014 does it work?`);
844
+ if (!ok) return { text: "verification declined — stay in implement and fix what failed" };
845
+ }
846
+ state!.confirmations = { ...state!.confirmations, implement: { hash: checklistHash(items), at: now() } };
847
+ const final = verifyGate(state!, cfg, branch);
848
+ if (final.length) return { text: `verify gate FAILED:\n- ${final.join("\n- ")}`, isError: true };
849
+ state!.approvals.verify = now();
850
+ state!.stage = "pre-release";
851
+ writeState(root, state!);
852
+ return { text: `verified${audit} — entering pre-release stage.\n${ran}\n${nextHint(state!, cfg)}` };
853
+ }
854
+
855
+ case "check": {
856
+ const { state, err } = needState(root, sess, slugArg);
857
+ if (err) return err;
858
+ if (!cfg.checkList.length) return { text: 'no tier-1 checks configured — add ```flow blocks / flow-check blocks to ## implement in project.md', isError: true };
859
+ const ran = await runChecks(root, state!, cfg, cfg.checkList);
860
+ return { text: `tier-1 checks:\n${ran.lines.join("\n")}`, isError: ran.failed };
861
+ }
862
+
863
+ case "health": {
864
+ const { state, err } = needState(root, sess, slugArg);
865
+ if (err) return err;
866
+ if (!cfg.commands.health) return { text: "no commands.health configured — add `health = <command>` (or a flow-check health block) under ## implement in .dev-flow/project.md", isError: true };
867
+ if (cfg.commands.seedData) {
868
+ const seed = await sh(cfg.commands.seedData, gateCwd(root, state!));
869
+ state!.checks.seedData = { ok: seed.ok, at: now() };
870
+ writeState(root, state!);
871
+ if (!seed.ok) return { text: `seedData FAILED:\n${tail(seed.out)}`, isError: true };
872
+ }
873
+ const res = await runHealthProbe(cfg.commands.health, gateCwd(root, state!), cfg.healthWait);
874
+ state!.checks.health = { ok: res.ok, at: now() };
875
+ writeState(root, state!);
876
+ const tried = res.attempts > 1 ? `, ${res.attempts} attempts` : "";
877
+ return { text: `health: ${res.ok ? "PASS" : "FAIL"} (${cfg.commands.health}${tried})\n${tail(res.out)}` };
878
+ }
879
+
880
+ case "release": {
881
+ const { state, err } = needState(root, sess, slugArg);
882
+ if (err) return err;
883
+ const items = applicableItems(cfg, state!, "pre-release");
884
+ if (flag("no-version")) state!.noVersion = true; // operator's word at Release?: this flow ships without a version bump or tag
885
+ // yolo is the operator's word at Release? — answers this question and pre-confirms Finish?; publishing guardrails stay enforced
886
+ if (flag("yolo")) {
887
+ if (!state!.yolo && !flag("confirmed")) return { text: "yolo is the operator's word — the operator must have said it in chat, then re-run with --yolo --confirmed", isError: true };
888
+ state!.yolo = true;
889
+ }
890
+ // prep evidence is read from the worktree: the bump and changelog entry exist on the branch before the PR merges
891
+ const wtCwd = gateCwd(root, state!);
892
+ const manifestVer = cfg.versioning.file && existsSync(path.join(wtCwd, cfg.versioning.file))
893
+ ? parseVersion(cfg.versioning.file, readFileSync(path.join(wtCwd, cfg.versioning.file), "utf8"))
894
+ : null;
895
+ // tags are only needed on the versioned path; a project without versioning must not be held up by an offline origin
896
+ const remoteTags = cfg.versioning.file && !state!.noVersion ? await releaseTags(root) : [];
897
+ if (remoteTags === null) return { text: "release gate FAILED:\n- cannot list tags on origin (offline?) — retry; local tags do not decide what is already released", isError: true };
898
+ const changelog =
899
+ cfg.release.changelog && existsSync(path.join(wtCwd, cfg.release.changelog))
900
+ ? { file: cfg.release.changelog, content: readFileSync(path.join(wtCwd, cfg.release.changelog), "utf8") }
901
+ : null;
902
+ // an unversioned release earns its keep by changing the changelog: proof = the file differs
903
+ // from the branch's merge base with the default branch, or is brand new (untracked) — local
904
+ // refs only, so the check stays offline-safe
905
+ let changelogChanged = false;
906
+ if (changelog) {
907
+ const mb = await sh(`git merge-base origin/${sq(cfg.defaultBranch)} HEAD 2>/dev/null || git merge-base ${sq(cfg.defaultBranch)} HEAD 2>/dev/null || true`, wtCwd);
908
+ if (mb.ok && mb.out.trim()) {
909
+ const p = sq(cfg.release.changelog!);
910
+ const changes = await sh(`git diff --name-only ${sq(mb.out.trim())} -- ${p} 2>/dev/null; git ls-files --others --exclude-standard -- ${p}`, wtCwd);
911
+ changelogChanged = changes.out.split("\n").some((l) => l.trim());
912
+ }
913
+ }
914
+ const prep = { manifestVersion: manifestVer, remoteTags, changelog, changelogChanged };
915
+ // tier-2 pre-release confirmation is recorded by this very command, so it is excluded here
916
+ const preGate = preReleaseGate({ ...state!, confirmations: { ...state!.confirmations, "pre-release": { hash: checklistHash(items), at: now() } } }, cfg, prep);
917
+ if (preGate.length) return { text: `release gate FAILED:\n- ${preGate.join("\n- ")}`, isError: true };
918
+ const audit = flag("confirmed") ? " (confirmed by operator instruction)" : state!.yolo ? " (yolo: pre-confirmed)" : "";
919
+ if (!flag("confirmed") && !state!.yolo) {
920
+ if (!ui.hasUI) return { text: needsOperator("release confirmation needs the operator: confirm interactively, or re-run with --confirmed once the operator answered 'Release?' in chat", "Release?"), isError: true };
921
+ const ok = await ui.confirm("Enter release", `Release ${state!.slug}${manifestVer ? ` v${manifestVer}` : ""}${state!.noVersion ? " (unversioned — merge + changelog entry only: no bump, no tag for this flow)" : ""}? Publishing starts: PR, merge${state!.noVersion ? "" : ", tag"} — release-done afterwards needs no further answer.`);
922
+ if (!ok) return { text: "release declined — keep preparing in pre-release" };
923
+ }
924
+ state!.confirmations = { ...state!.confirmations, "pre-release": { hash: checklistHash(items), at: now() } };
925
+ state!.approvals["pre-release"] = now();
926
+ state!.stage = "release";
927
+ // the version of record is fixed here, from the manifest this gate validated: release-done must not
928
+ // re-read a checkout that may still be behind (#52)
929
+ if (!state!.noVersion && manifestVer) state!.releaseVersion = manifestVer;
930
+ writeState(root, state!);
931
+ return { text: `release approved${audit}${state!.noVersion ? " — unversioned: no bump or tag; your changelog entry rides under Unreleased until the next versioned release folds it" : ""} — entering release stage.${manifestVer ? ` Version ${manifestVer}.` : ""}\n${nextHint(state!, cfg)}` };
932
+ }
933
+
934
+ case "release-done": {
935
+ const { state, err } = needState(root, sess, slugArg);
936
+ if (err) return err;
937
+ const errs = releaseGate(state!);
938
+ let manifestVer: string | null = state!.releaseVersion ?? null;
939
+ if (cfg.versioning.file && !state!.noVersion && !manifestVer) {
940
+ // a flow approved before releaseVersion existed: the worktree copy is what the release gate
941
+ // validated, the main checkout is the last resort (#52)
942
+ for (const dir of [gateCwd(root, state!), root]) {
943
+ const p = path.join(dir, cfg.versioning.file);
944
+ if (!existsSync(p)) continue;
945
+ manifestVer = parseVersion(cfg.versioning.file, readFileSync(p, "utf8"));
946
+ if (manifestVer) break;
947
+ }
948
+ if (!manifestVer) errs.push(`versioning.file "${cfg.versioning.file}" has no parsable version — fix the playbook`);
949
+ }
950
+ const expectedTag = manifestVer ? cfg.versioning.pattern.replace("{version}", manifestVer) : null;
951
+ const hasOrigin = (await sh("git remote get-url origin", root)).ok;
952
+ // one round-trip answers both questions about the tag: is it there, and did it leave this laptop (#52)
953
+ const tags = expectedTag ? await releaseTags(root) : [];
954
+ if (tags === null) errs.push("cannot list tags on origin (offline?) — retry: local tags must not decide what is already released");
955
+ else if (expectedTag && manifestVer) {
956
+ const verrs = versionTagGate(manifestVer, cfg.versioning.pattern, tags);
957
+ if (verrs.length) {
958
+ // the actionable version of "not on the forge": the tag exists but never got pushed
959
+ const local = hasOrigin ? (await sh(`git tag --list ${sq(expectedTag)}`, root)).out.trim() : "";
960
+ if (local) errs.push(`tag ${expectedTag} is local-only — git push origin ${expectedTag} (git push --follow-tags silently skips lightweight tags; tag with git tag -a and push the ref explicitly)`);
961
+ else errs.push(...verrs);
962
+ }
963
+ // never publish behind what the forge already has — that is how a stale manifest republished
964
+ // the previous version and its artifact probe passed (#52)
965
+ const newest = newestTaggedVersion(cfg.versioning.pattern, tags);
966
+ if (newest && cmpVersions(newest, manifestVer) > 0) errs.push(`${cfg.versioning.pattern.replace("{version}", newest)} is already tagged — refusing to release ${expectedTag} behind it`);
967
+ if (!verrs.length) {
968
+ const reach = await sh(`git merge-base --is-ancestor ${sq(expectedTag)} origin/${cfg.defaultBranch} 2>/dev/null || git merge-base --is-ancestor ${sq(expectedTag)} ${cfg.defaultBranch}`, root);
969
+ if (!reach.ok) errs.push(`tag ${expectedTag} is not reachable from ${cfg.defaultBranch} — tag what actually shipped on ${cfg.defaultBranch}`);
970
+ }
971
+ }
972
+ let artNote = "";
973
+ if (expectedTag && cfg.release.artifact) {
974
+ if (flag("no-artifact-check")) artNote = "Artifact check skipped (--no-artifact-check).";
975
+ else {
976
+ const cmd = cfg.release.artifact.replaceAll("{version}", manifestVer!).replaceAll("{tag}", expectedTag);
977
+ const art = await sh(cmd, root);
978
+ if (art.ok) artNote = `Artifact verified for ${manifestVer}.`;
979
+ else errs.push(`artifact not published — release.artifact check failed (${cmd}):\n${tail(art.out)}`);
980
+ }
981
+ }
982
+ if (errs.length) return { text: `release gate FAILED:\n- ${errs.join("\n- ")}`, isError: true };
983
+ // no checkpoint here — the operator's Release? answer covered the whole publication; everything left is objective checks
984
+ const releasesWanted = !!manifestVer && !flag("no-release-create") && cfg.release.releases !== false;
985
+ let ghNote = "";
986
+ const forgeName = cfg.forge || detectForge((await sh("git remote get-url origin", root)).out);
987
+ if (manifestVer && flag("no-release-create")) ghNote = "Forge release skipped (--no-release-create).";
988
+ else if (manifestVer && !releasesWanted) ghNote = "no forge release created (release.releases = false — the playbook says publication is manual; a stale key is how a repo ends up with tags and an empty Releases page #85).";
989
+ else if (manifestVer && releasesWanted) {
990
+ // forge release for the verified tag — gh for GitHub, glab for GitLab. A failed create is no
991
+ // longer the end of the story: the proof below asks the forge whether anything is there (#63, #85)
992
+ const tag = expectedTag!;
993
+ const forge = forgeName;
994
+ if (forge === "self") ghNote = "Forge release skipped (forge=self — publication stays manual).";
995
+ else {
996
+ // notes: the tag's annotation, else the changelog entry for this version, else nothing —
997
+ // and never the output of a failed command, which is how a shell error became a release body (#51)
998
+ const notesRes = await sh(tagNotesCommand(tag), root);
999
+ let notes = notesRes.ok ? notesRes.out.trim() : "";
1000
+ let notesNote = "";
1001
+ if (!notesRes.ok) notesNote = `no release notes (the tag command failed: ${tail(notesRes.out)}). `;
1002
+ else if (looksLikeShellError(notes)) {
1003
+ notesNote = "no release notes (the tag command returned a shell error, not a body). ";
1004
+ notes = "";
1005
+ }
1006
+ if (!notes && !notesNote && cfg.release.changelog) {
1007
+ const cl = path.join(gateCwd(root, state!), cfg.release.changelog);
1008
+ if (existsSync(cl)) notes = changelogEntry(readFileSync(cl, "utf8"), manifestVer!);
1009
+ if (notes) notesNote = `notes from ${cfg.release.changelog}. `;
1010
+ }
1011
+ if (!notes && !notesNote) notesNote = `no release notes (tag ${tag} carries no annotation${cfg.release.changelog ? ` and ${cfg.release.changelog} has no ${manifestVer} entry` : ""} — tag with git tag -a). `;
1012
+ const cmd = forge === "gitlab"
1013
+ ? `glab release create ${sq(tag)} --name ${sq(tag)}${notes ? ` --notes ${sq(notes)}` : ""}`
1014
+ : `gh release create ${sq(tag)} --title ${sq(tag)}${notes ? ` --notes ${sq(notes)}` : ""}`;
1015
+ const gr = await sh(cmd, root);
1016
+ // a failed create is the operator's call to decline, never a choice we make for them — stamp it so finish names it (#63)
1017
+ if (gr.ok) delete state!.forgeReleaseSkipped; // a successful retry lifts the stamp
1018
+ else state!.forgeReleaseSkipped = tail(gr.out);
1019
+ ghNote = gr.ok ? `${forge} release ${tag} created. ${notesNote}`.trim() : `${forge} release skipped: ${tail(gr.out)}`;
1020
+ }
1021
+ }
1022
+ const behind = sessionRunsOlderBuild(LOADED_VERSION, pluginVersion(import.meta.dirname).version);
1023
+ const openRelease = checklistPending(cfg, state!, "release");
1024
+ // release-stage items have no gate of their own: the operator's Finish? answer is what closes them, so
1025
+ // name them at the last objective gate — on the way out too, or the refusal hides half the story
1026
+ const openNote = openRelease ? `release-stage checklist still open (${openRelease}): ${applicableItems(cfg, state!, "release").map((i) => i.text).join(" | ")} — close these with the operator before finishing.` : "";
1027
+ // #85: ask the forge whether the release exists instead of trusting the attempt. #51, #60 and #63
1028
+ // were three ways of recording a release nothing published, and the artifact probe saw none of them
1029
+ if (releasesWanted && expectedTag && forgeName !== "self") {
1030
+ const view = forgeName === "gitlab" ? `glab release view ${sq(expectedTag)}` : `gh release view ${sq(expectedTag)}`;
1031
+ if (!(await sh(view, root)).ok) {
1032
+ return { text: `release gate FAILED:\n- no ${forgeName} release exists for ${expectedTag} — create it (${view.split(" ")[0]} release create ${expectedTag}) or, if publication is deliberately manual, say so with release.releases = false in project.md${ghNote ? `. ${ghNote}` : ""}${openNote ? ` ${openNote}` : ""}`, isError: true };
1033
+ }
1034
+ delete state!.forgeReleaseSkipped; // the forge has it, whatever the create call reported
1035
+ ghNote += `${ghNote ? " " : ""}Release ${expectedTag} verified on ${forgeName}.`;
1036
+ }
1037
+ state!.approvals.release = now();
1038
+ const tailNote = [
1039
+ // a session that shipped from behind: the code it runs is not the code the checkout just released
1040
+ behind ? `warning: this session runs dev-flow ${behind}, not the build in the checkout — /reload before trusting what it printed.` : "",
1041
+ openNote,
1042
+ ghNote,
1043
+ artNote,
1044
+ ].filter(Boolean).join(" ");
1045
+ writeState(root, state!);
1046
+ return { text: `release recorded as complete${state!.noVersion ? " (unversioned — the changelog entry ships under Unreleased; the next versioned release folds it)" : ""}.${tailNote ? ` ${tailNote}` : ""}\n${nextHint(state!, cfg)}` };
1047
+ }
1048
+
1049
+ case "finish": {
1050
+ const { state, err } = needState(root, sess, slugArg);
1051
+ if (err) return err;
1052
+ if (!state!.approvals.release)
1053
+ return { text: `finish closes a released feature — stage is "${state!.stage}" and no release is recorded; run flow release-done first (to abandon an unfinished feature, run flow abort)`, isError: true };
1054
+ // after this the flow state is gone: nothing may sit uncommitted or unpushed
1055
+ const dirty = await sh("git status --porcelain", root);
1056
+ if (dirty.ok && dirty.out.trim()) return { text: `main checkout has uncommitted changes — commit them first (finish deletes .dev-flow/${state!.slug}/):\n${tail(dirty.out)}`, isError: true };
1057
+ if ((await sh("git remote get-url origin", root)).ok) {
1058
+ const pushed = await sh(`git merge-base --is-ancestor HEAD ${sq(`origin/${cfg.defaultBranch}`)}`, root);
1059
+ if (!pushed.ok) return { text: `HEAD is not on origin/${cfg.defaultBranch} — push the release (git push origin ${cfg.defaultBranch}) before finish`, isError: true };
1060
+ }
1061
+ // the cleanup part of closing out: drop the worktree and branch the release consumed
1062
+ const wt = worktreeAbs(root, state!);
1063
+ let closeNote: string;
1064
+ let unmergedNote = "";
1065
+ if (wt && existsSync(wt)) {
1066
+ const status = await sh(`git -C ${sq(wt)} status --porcelain`, root);
1067
+ const unmerged = await sh(`git -C ${sq(wt)} log origin/${cfg.defaultBranch}..HEAD --oneline 2>/dev/null || git -C ${sq(wt)} log ${cfg.defaultBranch}..HEAD --oneline`, root);
1068
+ const assess = cleanupAssess(status.out, unmerged.out.split("\n"));
1069
+ if (assess.errors.length) return { text: `finish blocked:\n- ${assess.errors.join("\n- ")}`, isError: true };
1070
+ if (assess.unmerged.length) unmergedNote = ` ${assess.unmerged.length} commit(s) in ${state!.worktree} are not on ${cfg.defaultBranch} (squash merge?).`;
1071
+ }
1072
+ // a forge release that failed at release-done is still the operator's call — but they must see it before the state vanishes (#63)
1073
+ const forgeSkipNote = state!.forgeReleaseSkipped ? ` WARNING: the forge release was not created (${state!.forgeReleaseSkipped}) — recreate it manually or re-run release-done before finishing.` : "";
1074
+ // the one finish question — asked after the safety checks so it can never be a wasted question
1075
+ // yolo works uniformly at every gate: at Finish? nothing later remains to pre-confirm, the flag just answers this one
1076
+ if (flag("yolo")) {
1077
+ if (!state!.yolo && !flag("confirmed")) return { text: "yolo is the operator's word — the operator must have said it in chat, then re-run with --yolo --confirmed", isError: true };
1078
+ state!.yolo = true;
1079
+ }
1080
+ if (!flag("confirmed") && !state!.yolo) {
1081
+ if (!ui.hasUI) return { text: needsOperator(`finish needs the operator: ask them Finish? in chat, then re-run with --confirmed (or run /flow finish interactively)${forgeSkipNote}`, "Finish?"), isError: true };
1082
+ const ok = await ui.confirm("Finish feature", `Finish ${state!.slug}?${unmergedNote}${forgeSkipNote} Removes worktree, branch ${state!.branch || "-"} and .dev-flow/${state!.slug}/.`);
1083
+ if (!ok) return { text: "finish declined" };
1084
+ }
1085
+ if (wt && existsSync(wt)) {
1086
+ const rm = await sh(`git worktree remove ${sq(wt)} && git branch -D ${sq(state!.branch)}`, root);
1087
+ if (!rm.ok) return { text: `worktree removal failed:\n${rm.out}\nif files are owned by another user (e.g. root-owned docker build output), remove ${state!.worktree} manually and rerun flow finish`, isError: true };
1088
+ closeNote = `worktree and branch ${state!.branch} removed`;
1089
+ } else {
1090
+ await sh("git worktree prune", root);
1091
+ closeNote = "worktree already gone";
1092
+ if (state!.branch && (await sh(`git rev-parse --verify --quiet refs/heads/${state!.branch}`, root)).ok) {
1093
+ const del = await sh(`git branch -d ${sq(state!.branch)}`, root);
1094
+ closeNote = del.ok ? `branch ${state!.branch} deleted` : `branch ${state!.branch} kept (not fully merged — git branch -D if sure)`;
1095
+ }
1096
+ }
1097
+ // remote cleanup: origin/<branch> is deleted only on proof it was released — being an ancestor of
1098
+ // origin/<default> (merge-commit merges) or a merged PR via gh (squash merges); else it stays.
1099
+ let remoteNote = "";
1100
+ if (state!.branch && (await sh("git remote get-url origin", root)).ok) {
1101
+ const br = state!.branch;
1102
+ const onRemote = await sh("git ls-remote --heads origin", root);
1103
+ if (onRemote.out.split("\n").some((l) => l.endsWith(`\trefs/heads/${br}`))) {
1104
+ await sh(`git fetch -q origin ${sq(br)}`, root);
1105
+ let merged = (await sh(`git merge-base --is-ancestor origin/${sq(br)} origin/${sq(cfg.defaultBranch)}`, root)).ok;
1106
+ if (!merged) {
1107
+ // squash merges leave no ancestry — ask the forge: gh pr list, or the GitLab API via glab; self-hosted stays on ancestry proof
1108
+ const originUrl = await sh("git remote get-url origin", root);
1109
+ const forge = cfg.forge || detectForge(originUrl.out);
1110
+ if (forge === "gitlab") {
1111
+ const mr = await sh(`glab api ${sq(`projects/:id/merge_requests?source_branch=${br}&state=merged&per_page=1`)}`, root);
1112
+ merged = mr.ok && mr.out.trim() !== "[]";
1113
+ } else if (forge === "github") {
1114
+ const pr = await sh(`gh pr list --state merged --head ${sq(br)} --json number --jq length`, root);
1115
+ merged = pr.ok && pr.out.trim() !== "0";
1116
+ }
1117
+ }
1118
+ if (merged) remoteNote = (await sh(`git push origin --delete ${sq(`refs/heads/${br}`)}`, root)).ok
1119
+ ? ` origin/${br} deleted (verified merged)`
1120
+ : ` origin/${br} delete failed — git push origin --delete ${br}`;
1121
+ else remoteNote = ` origin/${br} kept — merged state not verifiable from here (git push origin --delete ${br} when sure)`;
1122
+ }
1123
+ }
1124
+ rmSync(featDir(root, state!.slug), { recursive: true, force: true });
1125
+ if (sess.slug === state!.slug) sess.bind(null);
1126
+ // a feature picked from the backlog closes its own item: check off what `roadmap pick` stamped (slug)
1127
+ let roadmapNote = "";
1128
+ if (existsSync(roadmapFile(root))) {
1129
+ const r = roadmapDoneForSlug(readRoadmap(root), state!.slug, new Date().toISOString().slice(0, 10));
1130
+ if (r.count) {
1131
+ writeFileSync(roadmapFile(root), r.text);
1132
+ roadmapNote = ` roadmap: ${r.count} item(s) marked done.`;
1133
+ }
1134
+ }
1135
+ return { text: `feature ${state!.slug} done — ${closeNote}${remoteNote}; .dev-flow/${state!.slug}/ removed.${roadmapNote}${forgeSkipNote}` };
1136
+ }
1137
+
1138
+ case "use": {
1139
+ if (!slugArg) return { text: "usage: flow use <slug>", isError: true };
1140
+ const st = readState(root, slugArg);
1141
+ if (!st || st.stage === "done") return { text: `no unfinished feature ${slugArg}`, isError: true };
1142
+ const prior = readClaim(root, slugArg);
1143
+ const takeover = claimBlocks(prior, sess.key) ? ` — taken over from live session (pid ${prior!.pid})` : "";
1144
+ sess.bind(slugArg);
1145
+ return { text: `this session is now bound to ${slugArg} (stage ${st.stage})${st.worktree ? ` — work in its worktree: ${st.worktree} (branch ${st.branch})` : ""}${takeover}` };
1146
+ }
1147
+
1148
+ case "abort": {
1149
+ // Unbinds the session only; state and worktree stay for manual recovery.
1150
+ const target = slugArg ?? sess.slug;
1151
+ if (!target) return { text: "nothing bound — pass a slug", isError: true };
1152
+ const st = readState(root, target);
1153
+ if (sess.slug === target) sess.bind(null);
1154
+ if (st && !st.worktree) {
1155
+ // plan-stage abort = drop: no worktree/branch exists, so the state dir is a ghost that
1156
+ // the one-unfinished-feature auto-bind would resurrect into every unbound session
1157
+ rmSync(featDir(root, target), { recursive: true, force: true });
1158
+ return { text: `aborted ${target} — feature removed (nothing approved, nothing to recover)` };
1159
+ }
1160
+ return { text: `session unbound from ${target}; state stays at .dev-flow/${target}/` + (st?.worktree ? `\nto remove the worktree and branch:\n git worktree remove ${st.worktree}\n git branch -D ${st.branch}` : "") };
1161
+ }
1162
+
1163
+ default:
1164
+ return { text: `unknown subcommand "${sub}" — ${GATE_HELP}`, isError: true };
1165
+ }
1166
+ }
1167
+
1168
+ async function currentBranch(root: string): Promise<string> {
1169
+ const r = await sh("git branch --show-current", root);
1170
+ return r.ok ? r.out.split("\n")[0] : "";
1171
+ }
1172
+
1173
+ function needState(root: string, sess: Session, slugArg?: string): { state: FlowState | null; err?: RunResult } {
1174
+ let slug = slugArg ?? sess.slug;
1175
+ if (!slug) {
1176
+ const un = unfinishedStates(root);
1177
+ if (un.length === 1) slug = un[0].slug;
1178
+ else if (!un.length) return { state: null, err: { text: "no unfinished features — flow start <slug> [--ui]", isError: true } };
1179
+ else return { state: null, err: { text: `multiple unfinished features (${un.map((s) => s.slug).join(", ")}) — flow use <slug> or pass an explicit slug`, isError: true } };
1180
+ }
1181
+ const state = readState(root, slug);
1182
+ if (!state) return { state: null, err: { text: `no state for feature ${slug}`, isError: true } };
1183
+ // passing a slug is not a takeover: a live session that holds the claim drives its gates, or the two
1184
+ // sessions overwrite one state file (last write wins) while both believe they own the stage.
1185
+ const claim = readClaim(root, slug);
1186
+ if (sess.slug !== slug && claimBlocks(claim, sess.key))
1187
+ return { state: null, err: { text: `${slug} is claimed by another live session (pid ${claim!.pid}) — flow use ${slug} takes it over explicitly`, isError: true } };
1188
+ return { state };
1189
+ }
1190
+
1191
+ async function listDirs(dir: string): Promise<string[]> {
1192
+ const { readdirSync } = await import("node:fs");
1193
+ return readdirSync(dir, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name);
1194
+ }
1195
+
1196
+ function tail(out: string, n = 15): string {
1197
+ const lines = out.split("\n").filter(Boolean);
1198
+ return lines.length > n ? `…(${lines.length - n} lines)…\n${lines.slice(-n).join("\n")}` : out;
1199
+ }
1200
+
1201
+ // ------------------------------------------------------------------ extension
1202
+
1203
+ /** Compact footer status: stage glyph, slug, mode markers, ⚠️ on failing checks; cleared when no active feature. */
1204
+ function refreshStatus(ctx: ExtensionContext): void {
1205
+ if (!ctx.hasUI) return;
1206
+ const st = existsSync(dfDir(ctx.cwd)) ? boundFeature(ctx.cwd, sessionKey(ctx)) : null;
1207
+ if (!st || st.stage === "done") {
1208
+ ctx.ui.setStatus("dev-flow", undefined);
1209
+ return;
1210
+ }
1211
+ const { text, failed } = statusCompact(st);
1212
+ ctx.ui.setStatus("dev-flow", failed ? `${text} ⚠️` : text);
1213
+ }
1214
+
1215
+
1216
+ /** A project confirm rule with no operator present is a block — a hidden confirmation is not a confirmation. */
1217
+ async function askOperator(ctx: ExtensionContext, what: string, why: string): Promise<string | null> {
1218
+ if (!ctx.hasUI) return needsOperator(`${why}: this needs an operator confirmation and no UI is available — ask them, or run it yourself outside Pi`);
1219
+ const ok = await ctx.ui.confirm("dev-flow project rule", `${what}\n\n${why}\n\nProceed?`);
1220
+ return ok ? null : `operator declined — ${why}`;
1221
+ }
1222
+
1223
+ export default function devFlow(pi: ExtensionAPI) {
1224
+ /** Parse "flow <sub> [slug] [--flags]" args shared by command and tool. */
1225
+ const parse = (input: unknown): string[] => {
1226
+ if (Array.isArray(input)) return input.map(String);
1227
+ return String(input ?? "").trim().split(/\s+/).filter(Boolean);
1228
+ };
1229
+
1230
+ const uiFrom = (ctx: ExtensionContext): Ui => ({
1231
+ hasUI: ctx.hasUI,
1232
+ confirm: async (title, message) => (ctx.hasUI ? ctx.ui.confirm(title, message) : Promise.resolve(false)),
1233
+ editor: ctx.mode === "tui" ? (title: string, prefill?: string) => ctx.ui.editor(title, prefill) : undefined,
1234
+ });
1235
+
1236
+ const sessFor = (ctx: ExtensionContext): Session => {
1237
+ const key = sessionKey(ctx);
1238
+ return {
1239
+ key,
1240
+ slug: bindings.get(key) ?? null,
1241
+ bind: (slug) => {
1242
+ const prev = bindings.get(key);
1243
+ bindings.set(key, slug);
1244
+ pi.appendEntry("dev-flow-bind", { slug });
1245
+ // claims track the binding: claim what you bind, release what you drop.
1246
+ if (prev && prev !== slug && readClaim(ctx.cwd, prev)?.key === key) rmSync(claimFile(ctx.cwd, prev), { force: true });
1247
+ if (slug) writeClaim(ctx.cwd, slug, key);
1248
+ },
1249
+ };
1250
+ };
1251
+
1252
+ /** Restore this session's binding from its branch history (survives resume/fork). */
1253
+ const rebuildSession = (ctx: ExtensionContext) => {
1254
+ let slug: string | null = null;
1255
+ for (const e of ctx.sessionManager?.getBranch() ?? []) {
1256
+ // SAFETY: narrow structural view of the SessionEntry union; we only read entries whose type/customType match our own appendEntry writes.
1257
+ const ce = e as unknown as { type: string; customType?: string; data?: { slug?: string | null } };
1258
+ if (ce.type === "custom" && ce.customType === "dev-flow-bind") slug = ce.data?.slug ?? null;
1259
+ }
1260
+ bindings.set(sessionKey(ctx), slug);
1261
+ };
1262
+
1263
+ pi.registerCommand("flow", {
1264
+ description: `dev-flow gates: ${GATE_HELP}`,
1265
+ handler: async (args, ctx) => {
1266
+ const cmd = parse(args);
1267
+ const res = await runFlow(cmd, ctx.cwd, uiFrom(ctx), sessFor(ctx));
1268
+ // gate output belongs to the conversation, not a toast: display:true renders a [flow] box (TUI + web), no triggerTurn so no model turn fires.
1269
+ // bare reflect is the exception: its whole output is instructions for the agent — displayed without a turn, they would never execute.
1270
+ const wake = cmd.length === 1 && cmd[0] === "reflect";
1271
+ pi.sendMessage({ customType: "flow", content: res.text, display: true }, wake ? { triggerTurn: true } : undefined);
1272
+ refreshStatus(ctx);
1273
+ if (!res.isError) maybeCompactNow(ctx);
1274
+ },
1275
+ });
1276
+
1277
+ const flowTool = defineTool({
1278
+ name: "flow",
1279
+ label: "Flow",
1280
+ description:
1281
+ "Drive the dev-flow stage gates for the active feature (plan → implement → pre-release → release → done). Never edit .dev-flow state directly; gates enforce plan/mockup/test/verification criteria and confirm with the operator.",
1282
+ parameters: Type.Object({
1283
+ args: Type.Array(Type.String(), { description: 'Gate invocation, e.g. ["status"] or ["start", "my-slug", "--ui"]' }),
1284
+ }),
1285
+ execute: async (_id, params, _signal, _onUpdate, ctx) => {
1286
+ const res = await runFlow(params.args, ctx.cwd, uiFrom(ctx), sessFor(ctx));
1287
+ if (!res.isError) maybeCompactNow(ctx);
1288
+ return { content: [{ type: "text", text: res.text }], details: undefined, isError: res.isError };
1289
+ },
1290
+ });
1291
+ pi.registerTool(flowTool);
1292
+
1293
+ // Agent-run gates refresh at turn_end (tool ctx lacks setStatus; turn_end catches them).
1294
+ pi.on("session_start", async (_event, ctx) => {
1295
+ rebuildSession(ctx);
1296
+ refreshStatus(ctx);
1297
+ });
1298
+ pi.on("session_tree", async (_event, ctx) => rebuildSession(ctx));
1299
+ pi.on("turn_end", async (_event, ctx) => {
1300
+ refreshStatus(ctx);
1301
+ // pi checks its compaction threshold between every turn; our safe points must be as frequent.
1302
+ maybeCompactNow(ctx);
1303
+ });
1304
+ // Idle backstop: long between-gate runs compact here instead of letting pi hit the threshold mid-work.
1305
+ pi.on("agent_settled", async (_event, ctx) => maybeCompactNow(ctx));
1306
+ // pi forcing a threshold compaction while a flow is bound means our safe-point pacing missed — rebuild the
1307
+ // summary here with flow-aware instructions and hand it back through the event's custom-compaction channel.
1308
+ // Never ctx.compact() from this hook: AgentSession.compact() opens with abort() + waitForIdle(), and the run
1309
+ // awaiting this handler can never go idle — the takeover would deadlock the session (no summarize request ever
1310
+ // reaches the provider). If our summary fails we return undefined and pi's own compaction proceeds.
1311
+ // overflow is never taken over (cancelling it would drop pi's compact-and-retry); manual is the operator's or ours.
1312
+ pi.on("session_before_compact", async (event, ctx) => {
1313
+ if (!existsSync(dfDir(ctx.cwd))) return undefined;
1314
+ const key = sessionKey(ctx);
1315
+ const st = boundFeature(ctx.cwd, key);
1316
+ if (!shouldTakeOverCompaction({ reason: event.reason, bound: !!st, inFlight: compacting.has(key) })) return undefined;
1317
+ const model = ctx.model;
1318
+ if (!model) return undefined;
1319
+ compacting.add(key);
1320
+ try {
1321
+ const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
1322
+ if (!auth.ok) return undefined;
1323
+ // ProviderHeaders allows null values; compact() takes plain string headers
1324
+ const headers = auth.headers ? (Object.fromEntries(Object.entries(auth.headers).filter(([, v]) => v !== null)) as Record<string, string>) : undefined;
1325
+ // same function pi itself calls — same split-turn merge, file lists and prompts, just flow-aware instructions
1326
+ const result = await compactionTakeover.run(event.preparation, model, auth.apiKey, headers, flowCompactInstructions(st!), event.signal, undefined, undefined, auth.env);
1327
+ return { compaction: result };
1328
+ } catch {
1329
+ return undefined; // pi's default compaction proceeds untouched
1330
+ } finally {
1331
+ compacting.delete(key);
1332
+ }
1333
+ });
1334
+ // pi forcing its own compaction while a flow is bound means the pacing was wrong — park a note for the operator.
1335
+ pi.on("session_compact", async (event, ctx) => {
1336
+ if (!existsSync(dfDir(ctx.cwd)) || event.reason === "manual") return;
1337
+ const st = boundFeature(ctx.cwd, sessionKey(ctx));
1338
+ if (!st) return;
1339
+ const f = path.join(dfDir(ctx.cwd), "reflections.md");
1340
+ const text = existsSync(f) ? readFileSync(f, "utf8") : "";
1341
+ const line = compactionReflectionLine(st.slug, st.stage, event.reason, new Date().toISOString().slice(0, 10));
1342
+ writeFileSync(f, appendReflection(text, ["plugin"], line));
1343
+ });
1344
+
1345
+ pi.on("tool_call", async (event, ctx) => {
1346
+ // pi kills a shell call only when a timeout was passed, and the model omits it in ~75% of
1347
+ // calls (observed hangs: hours). Deterministic class defaults, clamped ceilings — runs for
1348
+ // every session that loads this package, flow-bound or not.
1349
+ const shellInput = isToolCallEventType("bash", event) || isToolCallEventType("powershell", event) ? (event.input as { command?: unknown; timeout?: unknown }) : undefined;
1350
+ const shellCmd = shellInput ? String(shellInput.command ?? "") : "";
1351
+ const explicitTimeout = typeof shellInput?.timeout === "number" ? shellInput.timeout : undefined;
1352
+ // the silent default stays armed either way: it is the safety net for an `inject` project and the number the refusal quotes
1353
+ if (shellCmd && shellInput) shellInput.timeout = bashTimeout(shellCmd, explicitTimeout);
1354
+ const noDf = !existsSync(dfDir(ctx.cwd));
1355
+ // with no playbook to opt out, the hard default rules: the model must say its own timeout
1356
+ if (shellCmd && noDf) {
1357
+ const why = requireTimeout(shellCmd, explicitTimeout);
1358
+ if (why) return { block: true, reason: why };
1359
+ }
1360
+ if (noDf) return undefined;
1361
+ const cfg = loadProject(dfDir(ctx.cwd)).cfg;
1362
+ const mustStateTimeout = shellCmd !== "" && explicitTimeout === undefined && cfg.bashTimeout === "require";
1363
+ // for bash this verdict waits behind the operator's own deny/confirm rules further down;
1364
+ // powershell meets no project rules at all, so it is answered here
1365
+ if (mustStateTimeout && !isToolCallEventType("bash", event)) return { block: true, reason: requireTimeout(shellCmd)! };
1366
+ const key = sessionKey(ctx);
1367
+
1368
+ if (isToolCallEventType("edit", event) || isToolCallEventType("write", event)) {
1369
+ const abs = path.resolve(ctx.cwd, String(event.input.path ?? ""));
1370
+ const feats = featureRefs(ctx.cwd);
1371
+ const bound = boundFeature(ctx.cwd, key);
1372
+ // operator safety rules from project.md run first — as in the bash guard, the operator's deny outranks flow discipline
1373
+ const pv = guardPath(abs, ctx.cwd, cfg.guard);
1374
+ if (pv.block) return { block: true, reason: pv.block };
1375
+ if (pv.confirm) {
1376
+ const why = await askOperator(ctx, `Editing ${path.relative(ctx.cwd, abs) || abs}`, pv.confirm);
1377
+ if (why) return { block: true, reason: why };
1378
+ }
1379
+ const reason = writeGuard({ abs, dfDir: dfDir(ctx.cwd), features: feats, boundSlug: bound?.slug ?? null });
1380
+ if (reason) return { block: true, reason };
1381
+ // Editing a worktree invalidates that feature's tier-1 checks and tier-2 confirmation until they rerun.
1382
+ const owner = feats.find((f) => f.worktreeAbs && abs.startsWith(f.worktreeAbs + path.sep));
1383
+ if (owner && (owner.state.stage === "implement" || owner.state.stage === "pre-release") && (Object.keys(owner.state.checks).length || owner.state.confirmations?.implement)) {
1384
+ owner.state.checks = {};
1385
+ delete owner.state.confirmations?.implement;
1386
+ writeState(ctx.cwd, owner.state);
1387
+ }
1388
+ return undefined;
1389
+ }
1390
+
1391
+ if (isToolCallEventType("bash", event)) {
1392
+ const command = String(event.input.command ?? "");
1393
+ if (stateTamper(command)) return { block: true, reason: "flow state under .dev-flow/ is dev-flow-owned — advance stages with the flow tool only" };
1394
+ if (playbookTamper(command)) return { block: true, reason: "project.md is the operator playbook — change it with flow init --write or inside a docs/patch flow's worktree" };
1395
+ // project safety rules first: they cover docker/gh/npm and everything else, not only git,
1396
+ // and the operator's deny outranks dev-flow's own stage verdicts
1397
+ const gv = guardBash(command, cfg.guard);
1398
+ if (gv.block) return { block: true, reason: gv.block };
1399
+ for (const r of gv.confirms) {
1400
+ const why = await askOperator(ctx, command, r.why || `project rule ${r.pattern}`);
1401
+ if (why) return { block: true, reason: why };
1402
+ }
1403
+ // `flow` in a shell is a 127 with no pointer, so the mistake repeated — name the call that works
1404
+ if (flowShellInvocation(command)) return { block: true, reason: FLOW_SHELL_HINT };
1405
+ // the operator's rules had their say — now the timeout must be stated
1406
+ if (mustStateTimeout) return { block: true, reason: requireTimeout(command)! };
1407
+ // a forced push rewrites history CI and other sessions already have. dev-flow asks the operator
1408
+ // itself so a project never needs a blunt `--force` deny of its own (#82, #83)
1409
+ if (forcePushInvocation(command)) {
1410
+ const why = await askOperator(ctx, command, `a forced push rewrites a pushed branch — CI and other sessions lose these commits (with the operator's word, prefix the push with ${ALLOW_REWRITE})`);
1411
+ if (why) return { block: true, reason: why };
1412
+ }
1413
+ const inv = gitInvocation(command);
1414
+ if (!inv.subs.length) return undefined; // git is not invoked as a command — nothing to guard
1415
+ const bound = boundFeature(ctx.cwd, key);
1416
+ // judge EACH git segment by the branch of the directory it runs in — a chain may cd across
1417
+ // directories; one last-cd-wins verdict misattributed mixed-dir chains and blocked legit worktree commits
1418
+ const branchCache = new Map<string, string>();
1419
+ const branchOf = async (dir: string | null): Promise<string> => {
1420
+ const at = dir ? path.resolve(ctx.cwd, dir) : ctx.cwd;
1421
+ if (!branchCache.has(at)) {
1422
+ // no session-cwd fallback: when the probe could not answer (a path that resolved nowhere, a
1423
+ // detached HEAD) it reported the session's branch, and a worktree commit got the main rule
1424
+ const br = await sh(`git -C ${sq(at)} branch --show-current`, ctx.cwd);
1425
+ branchCache.set(at, (br.ok ? br.out : "").trim().split("\n")[0]);
1426
+ }
1427
+ return branchCache.get(at)!;
1428
+ };
1429
+ const worktrees = featureRefs(ctx.cwd).map((f) => f.worktreeAbs).filter((w): w is string => !!w);
1430
+ for (const g of inv.gitSegs) {
1431
+ const gitDir = g.dir ? path.resolve(ctx.cwd, g.dir) : ctx.cwd;
1432
+ const seg = gitInvocation(g.seg); // parsed once per segment — this runs on every bash call of every session
1433
+ const branch = await branchOf(g.dir);
1434
+ let pushRefs: PushRef[] | undefined;
1435
+ const gp = seg.pushes;
1436
+ if (gp.length) {
1437
+ // push targets are judged by ref; bare names disambiguated against that repo's own tags (read-only probe)
1438
+ pushRefs = await Promise.all(
1439
+ parsePushRefs(gp).map(async (r) => {
1440
+ if (r.kind !== "branch" || !r.name || r.name === "*") return r;
1441
+ const tag = await sh(`git rev-parse --verify -q refs/tags/${sq(r.name)}`, gitDir);
1442
+ return tag.ok ? { kind: "tag" as const, name: r.name } : r;
1443
+ }),
1444
+ );
1445
+ }
1446
+ // the playbook tracking chore: when the playbook is this checkout's only change, a commit here
1447
+ // can only contain it — setup, not feature work, so it needs no flow (#53)
1448
+ let playbookChore = false;
1449
+ if (seg.subs.includes("commit") && branch === cfg.defaultBranch && bound?.stage !== "release") {
1450
+ const dirty = await sh(`git -C ${sq(gitDir)} status --porcelain`, ctx.cwd);
1451
+ playbookChore = dirty.ok && playbookOnlyChange(dirty.out);
1452
+ }
1453
+ const reason = bashFlowGuard({ command: g.seg, branch, stage: bound?.stage ?? null, defaultBranch: cfg.defaultBranch, pushRefs, playbookChore, dir: gitDir });
1454
+ if (reason) return { block: true, reason };
1455
+ // Branch discipline: feature branches live in their worktree — never checked out or created (-b) in the main checkout
1456
+ const target = checkoutTarget(g.seg);
1457
+ if (target && target.startsWith(featureBranchPrefix(cfg))) {
1458
+ const inWorktree = worktrees.some((w) => gitDir === w || gitDir.startsWith(w + path.sep));
1459
+ if (!inWorktree) return { block: true, reason: `branch ${target} belongs to a feature worktree, not the main checkout — flow use <slug> and run git inside ${featureBranchPrefix(cfg)}…'s worktree` };
1460
+ }
1461
+ }
1462
+ }
1463
+ return undefined;
1464
+ });
1465
+
1466
+ pi.on("before_agent_start", async (_event, ctx) => {
1467
+ if (!existsSync(dfDir(ctx.cwd))) return undefined;
1468
+ const cfg = loadProject(dfDir(ctx.cwd)).cfg;
1469
+ const state = boundFeature(ctx.cwd, sessionKey(ctx));
1470
+ if (!state || state.stage === "done") {
1471
+ // Default posture for an unbound session in a dev-flow repo (guards make it stick; this makes it understood).
1472
+ let content = "[dev-flow] no flow bound. Every request goes through flow. New work: flow start <slug>; unfinished feature: flow use <slug>.";
1473
+ const cb = claimBlocker(ctx.cwd, sessionKey(ctx));
1474
+ if (cb) content += ` Also: ${cb.state.slug} is claimed by another live session (pid ${cb.claim.pid}) — flow use ${cb.state.slug} takes it over explicitly.`;
1475
+ const curBr = await currentBranch(ctx.cwd);
1476
+ if (curBr && curBr !== cfg.defaultBranch) content += ` Also: main checkout is on ${curBr}, not ${cfg.defaultBranch} — surface this to the operator before starting work.`;
1477
+ const refl0 = readReflections(ctx.cwd);
1478
+ if (refl0.length) content += ` Also: ${refl0.length} reflection(s) open — run flow doctor.`;
1479
+ const rmap0 = roadmapOpenCount(readRoadmap(ctx.cwd));
1480
+ if (rmap0) content += ` Also: ${rmap0} roadmap item(s) open — flow roadmap lists them, flow roadmap pick <n> <slug> hands one to flow start.`;
1481
+ return { message: { customType: "dev-flow-context", display: false, content } };
1482
+ }
1483
+ const guidance: string[] = [];
1484
+ if (state.yolo) guidance.push("YOLO mode: the operator pre-confirmed every gate question from here on (Verified?/Release?/Finish?) — run the remaining gates (verify → release → release-done → finish) back to back without stopping to ask; guidance the operator attached to the yolo word (e.g. patch bump, no tag) is a binding directive for the chain; checks and guardrails stay fully enforced.");
1485
+ if (cfg.prose[state.stage]) guidance.push(`Stage guidance (project.md): ${cfg.prose[state.stage]}`);
1486
+ const pend = checklistPending(cfg, state, state.stage);
1487
+ if (pend) guidance.push(`Tier-2 checklist NOT yet confirmed (${pend} item(s)): ${applicableItems(cfg, state, state.stage).map((i) => i.text).join(" | ")}${state.stage === "release" ? " — release-stage items close with the operator's Finish? answer; no earlier gate records them" : ""}`);
1488
+ const refl = readReflections(ctx.cwd);
1489
+ if (refl.length) guidance.push(`↻ ${refl.length} reflection(s) open — mention to the operator; run flow doctor to review and route them (fold in only on request).`);
1490
+ const rmap = roadmapOpenCount(readRoadmap(ctx.cwd));
1491
+ if (rmap) guidance.push(`◇ ${rmap} roadmap item(s) open — flow roadmap lists them (pick only on the operator's word).`);
1492
+ return {
1493
+ message: {
1494
+ customType: "dev-flow-context",
1495
+ display: false,
1496
+ content: `[dev-flow] this session is bound to: ${state.slug}${state.patch ? " (patch)" : state.docs ? " (docs)" : ""} — stage: ${state.stage}${state.ui ? " (UI)" : ""}${state.worktree ? `, worktree: ${state.worktree} (branch ${state.branch})` : ""}.
1497
+ Next: ${nextHint(state, cfg)}.
1498
+ ${guidance.length ? guidance.join("\n") + "\n" : ""}Rules: follow the dev-flow skill playbook; gates run through the flow tool only — \`flow\` is not a shell command, and the operator answers words in chat, never commands: you run the gate. \`/flow\` is the operator's manual override, not yours. Stage guards block out-of-stage edits and main-branch git ops — do not work around them.`,
1499
+ },
1500
+ };
1501
+ });
1502
+ }