pi-dev-flow 0.0.0-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.
package/src/core.ts ADDED
@@ -0,0 +1,1320 @@
1
+ /**
2
+ * dev-flow core: config, flow state, stage gates and guard decisions.
3
+ *
4
+ * Pure(ish) logic only — no pi imports here so it is unit-testable with
5
+ * `node --test`. The extension in extensions/dev-flow.ts is thin wiring.
6
+ */
7
+ import { existsSync, readdirSync, readFileSync, realpathSync } from "node:fs";
8
+ import { createHash } from "node:crypto";
9
+ import path from "node:path";
10
+
11
+ export type Stage = "plan" | "implement" | "pre-release" | "release" | "done";
12
+ export const STAGES: Stage[] = ["plan", "implement", "pre-release", "release", "done"];
13
+
14
+ export interface Check {
15
+ ok: boolean;
16
+ at: string;
17
+ note?: string;
18
+ }
19
+
20
+ export interface StageCheck {
21
+ name: string;
22
+ cmd: string;
23
+ }
24
+
25
+ export interface FlowState {
26
+ slug: string;
27
+ stage: Stage;
28
+ ui: boolean;
29
+ branch: string;
30
+ worktree: string; // relative to repo root, filled at approve
31
+ createdAt: string;
32
+ approvals: Record<string, string>;
33
+ checks: Record<string, Check>;
34
+ confirmations?: Record<string, { hash: string; at: string }>;
35
+ patch?: boolean; // chore release: no plan document, all gates intact
36
+ docs?: boolean; // docs-only release: no plan document, no tier-1 checks; worktree discipline and the full release stay
37
+ flowVersion?: string; // dev-flow build that started the feature (stamped at start, shown by status)
38
+ noVersion?: boolean; // operator chose: this flow ships merge + changelog only — no bump, no tag, no forge release; the entry rides under Unreleased until the next versioned release folds it
39
+ yolo?: boolean; // operator said "yolo" at any gate question: that one answered, every later one pre-confirmed; checks and guardrails stay
40
+ setupAt?: string; // commands.setup exited 0 in this worktree — a reused worktree reruns it only when unset (#49)
41
+ releaseVersion?: string; // the manifest version the release gate approved — release-done publishes this, not whatever a checkout says later (#52)
42
+ forgeReleaseSkipped?: string; // a gh/glab release create that failed: release-done says so, finish names it so the missing forge release cannot slip past (#63)
43
+ }
44
+
45
+ /** Tier-2 item: operator prose; uiOnly items ("(ui)" in project.md) apply only to --ui features. */
46
+ export interface ChecklistItem {
47
+ text: string;
48
+ uiOnly: boolean;
49
+ }
50
+
51
+ export interface Commands {
52
+ setup?: string;
53
+ test?: string;
54
+ build?: string;
55
+ seedData?: string;
56
+ /** Must exit 0 only when the app is up locally AND its data looks real. */
57
+ health?: string;
58
+ run?: string;
59
+ /** Deploy to a dev/staging environment before verification — operator reviews the running dev app. */
60
+ dev?: string;
61
+ }
62
+
63
+ export interface Versioning {
64
+ /** Repo-relative manifest holding the version: VERSION | package.json | Cargo.toml | pyproject.toml. */
65
+ file?: string;
66
+ /** Tag template; {version} substituted from the manifest. */
67
+ pattern: string;
68
+ }
69
+
70
+ export interface Config {
71
+ defaultBranch: string;
72
+ branchPattern: string; // {slug} substituted
73
+ worktreeDir: string; // {slug} substituted, relative to repo root
74
+ mockupGlobs: string[]; // suffix globs required in plan/ when ui (default: rendered screenshots)
75
+ /** Forge the release machinery targets: github (gh), gitlab (glab), self (forge-neutral).
76
+ * "" = unset → detected from the origin URL at the moment of use. */
77
+ forge: string;
78
+ commands: Commands;
79
+ checkList: StageCheck[]; // tier-1 executable checks (project.md flow blocks)
80
+ checklist: Record<string, ChecklistItem[]>; // tier-2 operator items per stage
81
+ prose: Record<string, string>; // tier-3 guidance per stage
82
+ versioning: Versioning;
83
+ /** Seconds the post-deploy health probe may keep retrying before the gate gives up (0 = single shot). */
84
+ healthWait: number;
85
+ release: { review: string; changelog?: string; tagPattern?: string; releases?: boolean; artifact?: string };
86
+ mainHook: boolean; // flow init installs a pre-commit hook blocking main commits
87
+ /** `require` (default): a shell call must carry its own visible timeout — the injected class
88
+ * default is armed silently and never shows in the transcript. `inject`: arm it silently. */
89
+ bashTimeout: "require" | "inject";
90
+ /** Operator safety rules from the project.md flow-guard block — stage-independent. */
91
+ guard: GuardRules;
92
+ }
93
+
94
+ export const DEFAULTS: Config = {
95
+ defaultBranch: "main",
96
+ branchPattern: "feat/{slug}",
97
+ worktreeDir: ".dev-flow/{slug}/worktree",
98
+ mockupGlobs: ["*.png"],
99
+ forge: "",
100
+ commands: {},
101
+ checkList: [],
102
+ checklist: {},
103
+ prose: {},
104
+ versioning: { pattern: "v{version}" },
105
+ healthWait: 30,
106
+ release: { review: "/ponytail-review", changelog: "CHANGELOG.md", releases: true },
107
+ mainHook: true,
108
+ bashTimeout: "require",
109
+ guard: { deny: [], confirm: [], protect: [], forbid: [] },
110
+ };
111
+
112
+ /** Build a Config from DEFAULTS + a partial overrides object (tests, drafts — the persisted
113
+ * source is project.md alone; the legacy project.json loader is gone). */
114
+ export function buildConfig(raw: unknown): Config {
115
+ const c: Config = structuredClone(DEFAULTS);
116
+ if (!raw || typeof raw !== "object") return c;
117
+ const r = raw as Record<string, unknown>;
118
+ for (const key of ["defaultBranch", "branchPattern", "worktreeDir", "mainHook", "forge"] as const) {
119
+ if (typeof r[key] === "string" || typeof r[key] === "boolean") (c[key] as never) = r[key] as never;
120
+ }
121
+ if (Array.isArray(r.mockupGlobs)) c.mockupGlobs = r.mockupGlobs.map(String);
122
+ if (r.commands && typeof r.commands === "object")
123
+ for (const [k, v] of Object.entries(r.commands as Record<string, unknown>)) if (typeof v === "string") (c.commands as Record<string, string>)[k] = v;
124
+ if (r.release && typeof r.release === "object") Object.assign(c.release, r.release);
125
+ if (r.healthWait !== undefined) c.healthWait = toSeconds(r.healthWait) ?? c.healthWait;
126
+ if (r.versioning && typeof r.versioning === "object") {
127
+ const v = r.versioning as Record<string, unknown>;
128
+ if (typeof v.file === "string") c.versioning.file = v.file;
129
+ if (typeof v.pattern === "string") c.versioning.pattern = v.pattern;
130
+ }
131
+ if (r.guard && typeof r.guard === "object") {
132
+ const g = r.guard as Record<string, unknown>;
133
+ for (const verb of ["deny", "confirm", "protect", "forbid"] as const)
134
+ if (Array.isArray(g[verb])) c.guard[verb] = normalizeGuardRules(g[verb]);
135
+ }
136
+ // tier-1 checks from plain commands so legacy project.json keeps the same teeth as project.md
137
+ c.checkList = [
138
+ ...(c.commands.test ? [{ name: "test", cmd: c.commands.test }] : []),
139
+ ...(c.commands.health ? [{ name: "health", cmd: c.commands.health }] : []),
140
+ ];
141
+ return c;
142
+ }
143
+
144
+ /** The project's own "running and healthy" probe — `commands.health` or a flow-check block named
145
+ * health. The plugin runs it, the project says what healthy means (readiness, served build stamp). */
146
+ export function healthProbe(c: Config): string | undefined {
147
+ return c.checkList.find((x) => x.name === "health")?.cmd;
148
+ }
149
+
150
+ /** A playbook number of seconds, or undefined when it is not a usable non-negative number. */
151
+ export function toSeconds(raw: unknown): number | undefined {
152
+ const n = typeof raw === "number" ? raw : typeof raw === "string" && raw.trim() !== "" ? Number(raw) : NaN;
153
+ return Number.isFinite(n) && n >= 0 ? Math.floor(n) : undefined;
154
+ }
155
+
156
+ /**
157
+ * Retry schedule for the health probe (#42): a service whose readiness lags its container start can
158
+ * never pass a single shot, so the gate waits instead of failing the deploy. One immediate attempt
159
+ * plus one retry per interval, spanning `waitSeconds`; 0 keeps the old single-shot behaviour.
160
+ */
161
+ export function healthRetry(waitSeconds: number, intervalSeconds = 2): { attempts: number; delayMs: number } {
162
+ const wait = toSeconds(waitSeconds) ?? 0;
163
+ const interval = toSeconds(intervalSeconds) ?? 2;
164
+ return { attempts: 1 + Math.ceil(wait / interval), delayMs: interval * 1000 };
165
+ }
166
+
167
+ export function subst(pattern: string, slug: string): string {
168
+ return pattern.replaceAll("{slug}", slug);
169
+ }
170
+
171
+ export function validSlug(slug: string): boolean {
172
+ return /^[a-z0-9][a-z0-9-]*$/.test(slug);
173
+ }
174
+
175
+ export function newState(slug: string, ui: boolean, patch = false, docs = false, flowVersion?: string): FlowState {
176
+ return { slug, stage: "plan", ui, branch: "", worktree: "", createdAt: new Date().toISOString(), approvals: {}, checks: {}, ...(patch ? { patch: true } : {}), ...(docs ? { docs: true } : {}), ...(flowVersion ? { flowVersion } : {}) };
177
+ }
178
+
179
+ /** Recursive relative file listing of a directory (empty when missing). */
180
+ export function listFiles(dir: string): string[] {
181
+ if (!existsSync(dir)) return [];
182
+ const out: string[] = [];
183
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
184
+ if (e.isDirectory()) out.push(...listFiles(path.join(dir, e.name)).map((f) => `${e.name}/${f}`));
185
+ else out.push(e.name);
186
+ }
187
+ return out;
188
+ }
189
+
190
+ function matchesGlob(file: string, globs: string[]): boolean {
191
+ return globs.some((g) => (g.startsWith("*.") ? file.endsWith(g.slice(1)) : file.includes(g)));
192
+ }
193
+
194
+ /** Gate: plan → implement. Returns reasons it cannot advance (empty = pass). */
195
+ export function approveGate(state: FlowState, cfg: Config, planFiles: string[]): string[] {
196
+ const errs: string[] = [];
197
+ if (state.stage !== "plan") errs.push(`stage is "${state.stage}", approval only applies to "plan"`);
198
+ if (!state.patch && !state.docs && !planFiles.some((f) => f.endsWith(".md"))) errs.push("plan/ must contain the written plan (*.md)");
199
+ if (state.ui && !planFiles.some((f) => matchesGlob(f, cfg.mockupGlobs)))
200
+ errs.push(`UI feature: plan/mockups/ must contain rendered screenshots matching ${cfg.mockupGlobs.join(", ")} (headless chromium, embedded in plan.md)`);
201
+ return errs;
202
+ }
203
+
204
+ /** What the feature branch has proved when verify is asked: commits it holds over its merge
205
+ * base with the default branch, and paths sitting uncommitted. commitsAhead is null when git
206
+ * could not answer (no merge base — shallow clone, unfetched origin): a gate must not refuse
207
+ * on its own measurement failure. */
208
+ export interface BranchState {
209
+ commitsAhead: number | null;
210
+ uncommitted: number;
211
+ }
212
+
213
+ /** Gate: implement → release. Tier 1: every parsed check green; tier 2: checklist confirmed;
214
+ * the branch must hold at least one commit — checks describe the working tree, so with nothing
215
+ * committed the operator is asked to approve state no gate has ever seen. */
216
+ export function verifyGate(state: FlowState, cfg: Config, branch?: BranchState | null): string[] {
217
+ const errs: string[] = [];
218
+ if (state.stage !== "implement") errs.push(`stage is "${state.stage}", verify only applies to "implement"`);
219
+ if (branch && branch.commitsAhead === 0) errs.push("nothing committed on the feature branch yet — commit as you go, verify proves a committed state");
220
+ if (!state.docs) for (const c of cfg.checkList) if (!state.checks[c.name]?.ok) errs.push(`check "${c.name}" has not passed — run flow check`);
221
+ const pend = checklistPending(cfg, state, "implement");
222
+ if (pend) errs.push(`tier-2 checklist not confirmed (${pend} item(s)) — run flow verify after ticking them off`);
223
+ return errs;
224
+ }
225
+
226
+ export interface ReleasePrep {
227
+ /** Version parsed from the manifest (versioning.file), null when unset/unparsable. */
228
+ manifestVersion: string | null;
229
+ /** Tag names as the forge sees them (origin when the repo has one) — not what one clone happens to have fetched (#52). */
230
+ remoteTags: string[];
231
+ /** Changelog file + content when release.changelog names a file that exists, else null. */
232
+ changelog: { file: string; content: string } | null;
233
+ /** True when the branch changed the changelog vs its merge base with the default branch —
234
+ * the proof an unversioned release ships a changelog entry although it cuts no version. */
235
+ changelogChanged: boolean;
236
+ }
237
+
238
+ /**
239
+ * Gate: pre-release → release ("Release?"). Machine-proves the parts of release prep that can be
240
+ * proven — checks re-greened after the prep edits, manifest bumped past the last tag, changelog
241
+ * mentions the version and holds no pending Unreleased entries. An unversioned (--no-version)
242
+ * flow skips the version rules but still owes a changelog line under Unreleased, proving the
243
+ * branch changed the changelog. Review/docs quality stays tier-2 (operator confirms the checklist).
244
+ */
245
+ export function preReleaseGate(state: FlowState, cfg: Config, prep: ReleasePrep): string[] {
246
+ const errs: string[] = [];
247
+ if (state.stage !== "pre-release") errs.push(`stage is "${state.stage}", the release gate only applies to "pre-release"`);
248
+ if (!state.docs) for (const c of cfg.checkList) if (!state.checks[c.name]?.ok) errs.push(`check "${c.name}" has not passed — run flow check`);
249
+ const pend = checklistPending(cfg, state, "pre-release");
250
+ if (pend) errs.push(`tier-2 checklist not confirmed (${pend} item(s)) — run flow release after ticking them off`);
251
+ if (state.noVersion) {
252
+ // unversioned flow (operator granted --no-version): no bump, no tag, no forge release —
253
+ // but the merge still owes a changelog line under Unreleased, folded by the next versioned release
254
+ if (cfg.release.changelog) {
255
+ if (!prep.changelog) errs.push(`release.changelog "${cfg.release.changelog}" does not exist — an unversioned release must record its entry there (under Unreleased)`);
256
+ else if (!prep.changelogChanged) errs.push(`${prep.changelog.file} is unchanged on this branch — an unversioned release must add its entry (under Unreleased)`);
257
+ }
258
+ } else {
259
+ if (cfg.versioning.file) {
260
+ if (!prep.manifestVersion) errs.push(`versioning.file "${cfg.versioning.file}" has no parsable version — bump it to the version you will release`);
261
+ else if (prep.remoteTags.includes(cfg.versioning.pattern.replace("{version}", prep.manifestVersion)))
262
+ errs.push(`version ${prep.manifestVersion} is already tagged — bump the manifest to the next version`);
263
+ }
264
+ if (cfg.release.changelog) {
265
+ if (!prep.changelog) errs.push(`release.changelog "${cfg.release.changelog}" does not exist — prepare the changelog`);
266
+ else {
267
+ if (prep.manifestVersion && !prep.changelog.content.includes(prep.manifestVersion))
268
+ errs.push(`${prep.changelog.file} does not mention version ${prep.manifestVersion} — add its entry before releasing`);
269
+ if (unreleasedPending(prep.changelog.content))
270
+ errs.push(`${prep.changelog.file} still has entries under Unreleased — fold them under the v${prep.manifestVersion ?? "new"} heading before releasing (they were left by --no-version flows)`);
271
+ }
272
+ }
273
+ }
274
+ return errs;
275
+ }
276
+
277
+ /** True when the changelog's Unreleased section still holds entries — --no-version flows park
278
+ * their lines there and a versioned release must fold them under its own heading. Matches
279
+ * `## Unreleased` / `## [Unreleased]`; deeper `###` sub-headings stay inside the section, the
280
+ * next h1/h2 ends it; no section = nothing pending (the convention is opt-in). */
281
+ export function unreleasedPending(content: string): boolean {
282
+ let inside = false;
283
+ for (const line of content.split("\n")) {
284
+ if (/^#{1,2}\s/.test(line)) {
285
+ if (inside) return false; // section ended without entries
286
+ inside = /^#{2,3}\s*\[?Unreleased\]?\s*$/.test(line);
287
+ continue;
288
+ }
289
+ if (inside && line.trim()) return true;
290
+ }
291
+ return false;
292
+ }
293
+
294
+ export function checklistHash(items: ChecklistItem[]): string {
295
+ return createHash("sha1")
296
+ .update(items.map((i) => `${i.uiOnly ? "(ui) " : ""}${i.text}`).join("\n"))
297
+ .digest("hex")
298
+ .slice(0, 12);
299
+ }
300
+
301
+ /** Items that apply to this feature — "(ui)" items only when the UI flag is set. */
302
+ export function applicableItems(cfg: Config, state: FlowState, stage: string): ChecklistItem[] {
303
+ return (cfg.checklist[stage] ?? []).filter((i) => !i.uiOnly || state.ui);
304
+ }
305
+
306
+ /** Items exist for the stage and current confirmation does not match their hash; returns pending count or 0. */
307
+ export function checklistPending(cfg: Config, state: FlowState, stage: string): number {
308
+ const items = applicableItems(cfg, state, stage);
309
+ if (!items.length) return 0;
310
+ return state.confirmations?.[stage]?.hash === checklistHash(items) ? 0 : items.length;
311
+ }
312
+
313
+ /** Read the version out of a manifest file's content; null when unparseable. */
314
+ export function parseVersion(file: string, content: string): string | null {
315
+ if (file.endsWith("VERSION")) return content.trim() || null;
316
+ if (file.endsWith("package.json")) {
317
+ try {
318
+ const v = (JSON.parse(content) as { version?: string }).version;
319
+ return typeof v === "string" && v ? v : null;
320
+ } catch {
321
+ return null;
322
+ }
323
+ }
324
+ // Cargo.toml / pyproject.toml style: version = "x.y.z"
325
+ return content.match(/^version\s*=\s*"([^"]+)"/m)?.[1] ?? null;
326
+ }
327
+
328
+ /**
329
+ * dev-flow's own version, for `flow version`: the nearest package.json above `fromDir` (the
330
+ * extension's module directory) whose name is `pkgName`. Walking up instead of a fixed `../`
331
+ * keeps it right for this checkout, an installed pi package and a copy loaded from elsewhere.
332
+ * `dir` is the manifest's directory when found, else `fromDir` — the place to name in the error.
333
+ */
334
+ export function pluginVersion(fromDir: string, pkgName = "pi-dev-flow"): { version: string | null; dir: string; repo: string } {
335
+ for (let dir = path.resolve(fromDir); ; ) {
336
+ try {
337
+ const pkg = JSON.parse(readFileSync(path.join(dir, "package.json"), "utf8")) as { name?: unknown; version?: unknown; repository?: unknown };
338
+ if (pkg.name === pkgName) {
339
+ const repository = typeof pkg.repository === "string" ? pkg.repository : typeof (pkg.repository as { url?: unknown })?.url === "string" ? (pkg.repository as { url: string }).url : "";
340
+ return { version: typeof pkg.version === "string" && pkg.version ? pkg.version : null, dir, repo: githubSlug(repository) };
341
+ }
342
+ } catch {
343
+ /* missing, unreadable or not JSON: keep walking up */
344
+ }
345
+ const parent = path.dirname(dir);
346
+ if (parent === dir) return { version: null, dir: fromDir, repo: "" };
347
+ dir = parent;
348
+ }
349
+ }
350
+
351
+ /** A session keeps the code it loaded at start, while the manifest on disk moves past it after a release.
352
+ * `flow version` read the manifest, so a stale session reported the new number while running the old gate code —
353
+ * and shipped the previous session's broken release-notes path twice. Returns the loaded version when the session
354
+ * runs behind the checkout, "" when it is current or either side is unknown. */
355
+ export function sessionRunsOlderBuild(loaded: string | null, disk: string | null): string {
356
+ return loaded && disk && loaded !== disk ? loaded : "";
357
+ }
358
+
359
+ /** Owner/slug from any GitHub repo reference — "owner/repo", "github.com/owner/repo", git+https or scp-style git@github.com:owner/repo.git; "" when it is not GitHub. */
360
+ export function githubSlug(ref: string): string {
361
+ const m = ref.match(/github\.com[/:]([\w.-]+\/[\w.-]+?)(?:\.git)?(?:\/|$)/) ?? /^[\w.-]+\/[\w.-]+$/.exec(ref);
362
+ return m ? m[1].replace(/\.git$/, "") : "";
363
+ }
364
+
365
+ /** Gate: the tag derived from the manifest version must exist among local tags. */
366
+ export function versionTagGate(manifestVersion: string | null, tagPattern: string, tags: string[]): string[] {
367
+ if (!manifestVersion) return ["versioning.file is set but its version could not be parsed — fix versioning config"];
368
+ const expected = tagPattern.replace("{version}", manifestVersion);
369
+ if (tags.includes(expected)) return [];
370
+ return [`expected release tag "${expected}" (from manifest version ${manifestVersion}) not found; recent tags: ${tags.slice(0, 5).join(", ") || "none"}`];
371
+ }
372
+
373
+ /** Gate: release-done records the release; `flow finish` then closes the feature. */
374
+ export function releaseGate(state: FlowState): string[] {
375
+ const errs: string[] = [];
376
+ if (state.stage !== "release") errs.push(`stage is "${state.stage}", release-done only applies to "release"`);
377
+ if (!state.approvals.verify) errs.push("operator verify approval is missing");
378
+ return errs;
379
+ }
380
+
381
+ /**
382
+ * Version comparison for release guards: dot-separated numeric segments, a missing or non-numeric
383
+ * segment counts as 0 (`1.2` == `1.2.0`, `1.2.3-rc1` compares as `1.2.0`). Negative when `a` is older.
384
+ */
385
+ export function cmpVersions(a: string, b: string): number {
386
+ const seg = (v: string, i: number) => Number(v.split(".")[i]) || 0;
387
+ const parts = Math.max(a.split(".").length, b.split(".").length);
388
+ for (let i = 0; i < parts; i++) {
389
+ if (seg(a, i) !== seg(b, i)) return seg(a, i) - seg(b, i);
390
+ }
391
+ return 0;
392
+ }
393
+
394
+ /** Tag names from `git ls-remote --tags` output: refs peeled with `^{}` collapse into their tag. */
395
+ export function parseLsRemoteTags(out: string): string[] {
396
+ return [...new Set(out.split("\n").map((l) => (l.split("\t")[1] ?? "").trim().replace(/^refs\/tags\//, "").replace(/\^\{\}$/, "")).filter(Boolean))];
397
+ }
398
+
399
+ /** Newest version among tags matching the release pattern; null when none does (#52). */
400
+ export function newestTaggedVersion(pattern: string, tags: string[]): string | null {
401
+ const parts = pattern.split("{version}");
402
+ if (parts.length !== 2) return null;
403
+ const re = new RegExp("^" + parts.map((p) => p.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("(.+)") + "$");
404
+ let best: string | null = null;
405
+ for (const t of tags) {
406
+ const m = t.match(re);
407
+ if (m && (best === null || cmpVersions(m[1], best) > 0)) best = m[1];
408
+ }
409
+ return best;
410
+ }
411
+
412
+ /** The command that prints a tag's annotation. The format must be quoted for /bin/sh: an unquoted
413
+ * `%(contents)` is a syntax error, and `sh()` folds stderr into its output, so the error text
414
+ * landed in release bodies as if it were the notes (#51). */
415
+ /** POSIX shell literal: embeds arbitrary text as one argument — the JSON-style quoting a template string suggests is not shell quoting (backticks and $() inside it still execute, #51 fallout). */
416
+ export function sq(s: string): string {
417
+ return `'${s.replace(/'/g, `'\\''`)}'`;
418
+ }
419
+
420
+ export function tagNotesCommand(tag: string): string {
421
+ return `git tag -l --format='%(contents)' ${sq(tag)}`;
422
+ }
423
+
424
+ /** Is this "release body" actually a shell complaint? The #51 shape is
425
+ * `/bin/sh: 1: Syntax error: "(" unexpected`. Deliberately narrow: a changelog line that merely
426
+ * mentions a syntax error is a real body and must not be dropped. */
427
+ export function looksLikeShellError(text: string): boolean {
428
+ return /Syntax error:|\bsh: ?\d+:/.test(text);
429
+ }
430
+
431
+ /** A changelog entry by version: the section whose heading names the version, up to the next heading
432
+ * of the same or a higher level. "" when no heading mentions it — used as a deterministic release
433
+ * body when the tag carries no annotation (#51). */
434
+ export function changelogEntry(content: string, version: string): string {
435
+ const lines = content.split("\n");
436
+ const level = (l: string) => (l.match(/^#+/)?.[0].length ?? 99);
437
+ const i = lines.findIndex((l) => level(l) <= 3 && l.includes(version));
438
+ if (i === -1) return "";
439
+ const base = level(lines[i]);
440
+ const out: string[] = [];
441
+ for (const l of lines.slice(i + 1)) {
442
+ if (level(l) <= base) break;
443
+ out.push(l);
444
+ }
445
+ return out.join("\n").trim();
446
+ }
447
+
448
+ /**
449
+ * Cleanup safety for `flow finish`. Returns { errors, unmerged }: errors hard-block;
450
+ * unmerged is a warning list the operator can confirm through (e.g. squash merges).
451
+ */
452
+ export function cleanupAssess(porcelain: string, unmerged: string[]): { errors: string[]; unmerged: string[] } {
453
+ const errors: string[] = [];
454
+ if (porcelain.trim()) errors.push("worktree has uncommitted changes — commit or discard them first");
455
+ return { errors, unmerged: unmerged.filter(Boolean) };
456
+ }
457
+
458
+ export interface GuardCtx {
459
+ command: string;
460
+ branch: string; // branch of the directory the git commands run in (feature worktree or main checkout)
461
+ stage: Stage | null; // active feature stage, null when no active feature
462
+ defaultBranch: string;
463
+ pushRefs?: PushRef[]; // caller-resolved push targets (bare names disambiguated tag vs branch); omit = guard parses raw command
464
+ playbookChore?: boolean; // the only change in this checkout is the playbook — tracking it is setup, not feature work (#53)
465
+ dir?: string; // absolute directory this segment runs in; named in the refusal so a wrong verdict is diagnosable
466
+ }
467
+
468
+ const unquote = (s: string): string => s.replace(/^(['"])(.*)\1$/, "$2");
469
+
470
+ /** The shell expands a leading `~` or `$HOME` before git sees the path and path.resolve does not: an
471
+ * unexpanded `~/wt` resolved against the session cwd, so a worktree commit was judged from the main
472
+ * checkout. shortcut: any other $var stays literal — resolving it needs a shell, and a wrong guess is
473
+ * what produced the bogus verdict in the first place. */
474
+ export function expandHome(p: string): string {
475
+ const home = process.env.HOME;
476
+ if (!home) return p;
477
+ return p.replace(/^\$(?:HOME|\{HOME\})(?=$|\/)/, home).replace(/^~(?=$|\/)/, home);
478
+ }
479
+
480
+ /** git usage in a bash command, parsed per shell segment (split on ; | & newline):
481
+ * the git subcommands invoked — empty when git is not a command, so paths like
482
+ * .git/hooks or words like pre-commit never trigger the guard — and the directory
483
+ * git runs in (last preceding `cd` target or `git -C` target; null = session cwd).
484
+ * shortcut: naive segment split, subshells/backticks not tracked; the pre-commit
485
+ * hook still covers what slips past this advisory guard. */
486
+ export function gitInvocation(command: string): { subs: string[]; dir: string | null; pushes: string[][]; gitSegs: { seg: string; dir: string | null }[] } {
487
+ const subs: string[] = [];
488
+ const pushes: string[][] = [];
489
+ const gitSegs: { seg: string; dir: string | null }[] = [];
490
+ let dir: string | null = null;
491
+ // global git options that consume a following value
492
+ const OPT_VAL = new Set(["-c", "-C", "--git-dir", "--work-tree", "--exec-path", "--namespace", "--super-prefix"]);
493
+ for (const seg of bashSegments(command)) {
494
+ const tok = seg.trim().split(/\s+/).filter(Boolean);
495
+ let i = 0;
496
+ while (i < tok.length && /^[\w.]+=/.test(tok[i])) i++; // leading ENV=val prefixes
497
+ if (tok[i] === "sudo") i++;
498
+ if (tok[i] === "cd" && tok[i + 1]) {
499
+ dir = expandHome(unquote(tok[i + 1]));
500
+ continue;
501
+ }
502
+ if (REEXEC_HEADS.has(tok[i] ?? "")) {
503
+ // a wrapper that execs its argument: every git verb in the payload is still that verb (sh -c "git add . && git commit -m x")
504
+ let wrapped = false;
505
+ for (const m of seg.matchAll(/\bgit\s+([\w-]+)/g)) {
506
+ subs.push(m[1]);
507
+ if (m[1] === "push") pushes.push(seg.slice((m.index ?? 0) + m[0].length).replace(/["']/g, "").split(/[\s&]+/).filter((x) => x && !x.startsWith("-")));
508
+ wrapped = true;
509
+ }
510
+ if (wrapped) gitSegs.push({ seg: seg.trim(), dir });
511
+ continue;
512
+ }
513
+ if (!/(^|\/)git$/.test(tok[i] ?? "")) continue; // git must be the command (git, /usr/bin/git)
514
+ for (i++; i < tok.length; i++) {
515
+ const t = tok[i];
516
+ if (!t.startsWith("-")) {
517
+ subs.push(t);
518
+ if (t === "push") pushes.push(tok.slice(i + 1).filter((x) => x !== "&&"));
519
+ break;
520
+ }
521
+ const eq = t.indexOf("=");
522
+ const name = eq === -1 ? t : t.slice(0, eq);
523
+ if (name === "-C") {
524
+ dir = expandHome(eq === -1 ? unquote(tok[++i] ?? "") : unquote(t.slice(eq + 1)));
525
+ continue;
526
+ }
527
+ if (OPT_VAL.has(name) && eq === -1) i++;
528
+ }
529
+ gitSegs.push({ seg: seg.trim(), dir }); // after -C parsing so the entry carries its own effective dir
530
+ }
531
+ return { subs, dir, pushes, gitSegs };
532
+ }
533
+
534
+ export type PushRef = { kind: "tag" | "branch" | "current"; name: string };
535
+
536
+ /** Refspecs of each `git push` (from gitInvocation.pushes): what ref does each push actually write?
537
+ * bare names come back "branch" (conservative) — the caller resolves tags via git. */
538
+ export function parsePushRefs(pushes: string[][]): PushRef[] {
539
+ const out: PushRef[] = [];
540
+ for (const toks of pushes) {
541
+ if (toks.includes("--mirror")) {
542
+ out.push({ kind: "branch", name: "*" }); // mirror rewrites every branch, assume the worst
543
+ continue;
544
+ }
545
+ if (toks.includes("--tags")) {
546
+ out.push({ kind: "tag", name: "*" });
547
+ continue;
548
+ }
549
+ const bare = toks.filter((t) => !t.startsWith("-")).map(unquote);
550
+ const specs = bare.slice(1); // first bare token is the remote
551
+ if (!specs.length) {
552
+ out.push({ kind: "current", name: "" });
553
+ continue;
554
+ }
555
+ for (const s of specs) {
556
+ const [src, dest] = s.split(":");
557
+ // 'src:' deletes remote src → judge src; ':dest' pushes local dest; otherwise dest is what lands remotely
558
+ const target = dest === undefined ? src : dest || src;
559
+ if (target === "HEAD") {
560
+ out.push({ kind: "current", name: "" });
561
+ continue;
562
+ }
563
+ const kind = target.startsWith("refs/tags/") ? "tag" : "branch";
564
+ out.push({ kind, name: target.replace(/^refs\/(heads|tags)\//, "") });
565
+ }
566
+ }
567
+ return out;
568
+ }
569
+
570
+ /** Flow state by full path, or a bare `state.json` when the command also touches `.dev-flow`
571
+ * (that is how `cd .dev-flow/<slug> && mv state.json …` reaches it). */
572
+ const STATE_PATH = /(^|[\s'"\/])state\.json$/;
573
+ const STATE_PATH_ABS = /\.dev-flow\/[\w./-]*state\.json$/;
574
+ const PLAYBOOK_PATH = /\.dev-flow\/[\w./-]*project\.md/;
575
+ const DF_ANYWHERE = /\.dev-flow\b/;
576
+ /** The `.dev-flow/` folder itself or one feature directory under it — deleting either destroys the flow
577
+ * state and its plan for good. The advisory files the agent owns, and anything deeper (a worktree, a
578
+ * plan/mockups path), are not "the folder". */
579
+ const DF_FOLDER = /(?:^|[\s'"])\.dev-flow(?:\/(?!project\.md$|roadmap\.md$|reflections\.md$|\.gitignore$)[\w.-]+)?(?=$|[\s'"])/;
580
+ // write tools that can overwrite a file. A bare `>` is deliberately absent: the redirect target is
581
+ // checked on its own, and testing for any `>` here refused every segment that merely mentioned an
582
+ // owned path while redirecting elsewhere (`gh pr create … 2>&1`).
583
+ const WRITE_TOOLS = /\btee\b|\brm\b|\bmv\b|\bcp\b|\bsed\b|\btruncate\b|\bdd\b|python|node\s/;
584
+ /** copy/replace verbs: only the destination (last positional) is written to. */
585
+ const COPY_HEADS = new Set(["cp", "install", "tee", "dd"]);
586
+ /** verbs that destroy the file they name — copying a file somewhere safe is not one of them. */
587
+ const DESTROY_HEADS = new Set(["rm", "mv", "truncate"]);
588
+ const DESTROY_ANY = /\b(rm|mv|truncate)\b/;
589
+
590
+ /** The file a `>`/`>>` writes (`2>` allowed), or null. */
591
+ function redirectTarget(seg: string): string | null {
592
+ const m = seg.match(/(?:^|\s)\d?>+ ?(\S+)/);
593
+ return m ? unquote(m[1]) : null;
594
+ }
595
+
596
+ /** One shell segment: does it write, move or delete a path dev-flow owns? Decided per segment and by
597
+ * target, never by "owned path anywhere + write verb anywhere": reading flow state or backing the
598
+ * playbook up is safety, prose about `cp` is not a write (#53), and the loose match both refused
599
+ * `jq -e .stage .dev-flow/x/state.json` and waved through `rm -rf .dev-flow/x`. Each caller asks about
600
+ * its own subject so the block message names what was actually touched. */
601
+ function ownedWrite(seg: string, what: "state" | "playbook", flowState: boolean): boolean {
602
+ const stateNamed = (q: string): boolean => (flowState ? STATE_PATH.test(q) : STATE_PATH_ABS.test(q));
603
+ const owned = (p: string): boolean => {
604
+ const q = unquote(p);
605
+ return what === "playbook" ? PLAYBOOK_PATH.test(q) : stateNamed(q) || DF_FOLDER.test(q);
606
+ };
607
+ const tok = seg.split(/\s+/).filter(Boolean);
608
+ if (!tok.some((t) => owned(t))) return false;
609
+ const redir = redirectTarget(seg);
610
+ if (redir && owned(redir)) return true; // a redirect into an owned path is a write even behind `echo`
611
+ const head = segHead(seg);
612
+ if (READ_ONLY_HEADS.has(head)) return false; // cat/grep/ls/jq read it
613
+ if (head === "sed" && !/\s-i\b/.test(seg)) return false; // sed as a printer is a read
614
+ if (DESTROY_HEADS.has(head)) return tok.slice(1).some((t) => !t.startsWith("-") && owned(t));
615
+ if (COPY_HEADS.has(head)) return owned(tok[tok.length - 1] ?? "");
616
+ // what is left is the loose fallback — a write tool named in the clear. A wrapper's payload counts; everyone
617
+ // else's quoted text is prose, and prose about `cp`/`sed` is not a write (it refused a PR body describing #53).
618
+ const payload = REEXEC_HEADS.has(head) ? seg : maskQuoted(seg);
619
+ if (!WRITE_TOOLS.test(payload)) return false;
620
+ // a generic writer (interpreter, wrapper): state and playbook always count; the folder only when the
621
+ // payload really destroys it — `python tools/x.py --dir .dev-flow/<slug>` points at a directory, it does not delete it.
622
+ if (what === "playbook") return true;
623
+ return tok.some((t) => stateNamed(unquote(t))) || (tok.some((t) => DF_FOLDER.test(unquote(t))) && DESTROY_ANY.test(payload));
624
+ }
625
+
626
+ /** True when a bash command writes, moves or deletes flow state — or the `.dev-flow` folder/feature
627
+ * directory that holds it. */
628
+ export function stateTamper(command: string): boolean {
629
+ const flowState = DF_ANYWHERE.test(command); // a bare state.json counts only next to .dev-flow
630
+ return bashSegments(command).some((seg) => ownedWrite(seg, "state", flowState));
631
+ }
632
+
633
+ /** True when a bash segment runs a command named `flow`. There is no flow CLI — gates live inside the
634
+ * session as the flow tool — and a bare 127 taught agents nothing, so the habit repeated.
635
+ * Head token of a masked segment only: `grep flow status`, `skills/dev-flow/SKILL.md` and a commit
636
+ * message quoting a gate are prose/arguments, not invocations. */
637
+ export function flowShellInvocation(command: string): boolean {
638
+ return bashSegments(command).some((seg) => segHead(maskQuoted(seg).replace(/^(?:\w+=\S*\s+)+/, "")) === "flow");
639
+ }
640
+
641
+ /** Refusal for a bash call named `flow` — it names the call that works, like bashTimeout's hint does. */
642
+ export const FLOW_SHELL_HINT = 'there is no "flow" shell command — gates live in the session: call the flow tool with an args array, e.g. flow {args:["status"]}';
643
+
644
+ /** True when a bash command writes the playbook (project.md) — the sanctioned channels are
645
+ * `flow init --write` (runs in-plugin, never matches) and edits inside a docs/patch flow's worktree.
646
+ * Decided per segment and by target, not by any verb anywhere: reading the playbook or copying it to
647
+ * a backup is safety, and blocking it cost the operator their only backup before a write (#53). */
648
+ export function playbookTamper(command: string): boolean {
649
+ return bashSegments(command).some((seg) => ownedWrite(seg, "playbook", false));
650
+ }
651
+
652
+ /** The playbook-tracking chore (#53): the playbook is the only change in the checkout, so whatever
653
+ * the commit says it can only contain that file — setup, not feature work, legal on the default
654
+ * branch with no flow bound. Any other dirty path refuses the lane, so there is nothing to widen
655
+ * and no command parsing to fool. shortcut: a guard rail against accidents, not a sandbox. */
656
+ export function playbookOnlyChange(porcelain: string): boolean {
657
+ const paths = porcelain.split("\n").map((l) => l.slice(3).trim()).filter(Boolean);
658
+ return paths.length === 1 && PLAYBOOK_PATH.test(paths[0]);
659
+ }
660
+
661
+ /** The operator's authorisation for rewriting a branch that is already pushed — same contract as
662
+ * DEV_FLOW_ALLOW_MAIN: the guard prints it, the operator grants it, the agent types it. (#83) */
663
+ export const ALLOW_REWRITE = "DEV_FLOW_ALLOW_REWRITE=1";
664
+
665
+ /** True when a segment force-pushes: `git push` carrying a force flag (`--force`, `--force-with-lease…`,
666
+ * `-f`, `-uf`). Decided on the verb per segment, not on a flag anywhere after `git` — `filter-branch
667
+ * --force` rewrites a local copy and `git show -f` lists files, and a blunt flag-only deny blocks both,
668
+ * which agents learn to route around. (#82, #83) */
669
+ export function forcePushInvocation(command: string): boolean {
670
+ return bashSegments(command).some((seg) => {
671
+ const tok = maskQuoted(seg).trim().split(/\s+/);
672
+ let i = 0;
673
+ while (i < tok.length && /^[\w.]+=/.test(tok[i])) {
674
+ if (tok[i] === ALLOW_REWRITE) return false; // the operator's word, spelled on the command itself
675
+ i++;
676
+ }
677
+ if (tok[i] === "sudo") i++; // a wrapper is not an authorisation and not an escape either
678
+ if (!/(^|\/)git$/.test(tok[i] ?? "") || !tok.includes("push")) return false;
679
+ return tok.some((t) => /^--force\S*$/.test(t) || /^-[a-z]*f[a-z]*$/.test(t));
680
+ });
681
+ }
682
+
683
+ /** Forge from an origin URL: github.com / github enterprise hosts → github, any gitlab host → gitlab. */
684
+ export function detectForge(remoteUrl: string): string {
685
+ return /github/i.test(remoteUrl) ? "github" : /gitlab/i.test(remoteUrl) ? "gitlab" : "self";
686
+ }
687
+
688
+ /** Guard: decide whether a git bash command violates the flow. Returns a reason or null.
689
+ * Call per git segment (gitInvocation().gitSegs) with that segment's own branch — a chain may span directories. */
690
+ export function bashFlowGuard(o: GuardCtx): string | null {
691
+ const c = o.command;
692
+ const { subs } = gitInvocation(c);
693
+ if (!subs.length) return null;
694
+ const onMain = o.branch === o.defaultBranch;
695
+ const git = (word: string) => subs.includes(word);
696
+
697
+ // release chores commit in the main checkout — same stage exception as push/merge below; the playbook
698
+ // tracking chore is the other legal main commit: its only content is the operator's own playbook (#53).
699
+ // --amend is not a chore: it rewrites the default branch's history.
700
+ const playbookCommit = o.playbookChore && !c.includes("--amend");
701
+ // a cd earlier in the command picks the directory this verdict is about — name it, or a misjudged segment
702
+ // reads as "the rule is broken" instead of "the guard looked in the wrong place".
703
+ const judgedIn = o.dir ? ` — judged in ${o.dir}; a cd or git -C earlier in the command picks the directory` : "";
704
+ if (git("commit") && onMain && o.stage !== "release" && !playbookCommit) return `direct commits to ${o.defaultBranch} are forbidden — work in the feature worktree (tracking the playbook is legal: git add -f .dev-flow/project.md && DEV_FLOW_ALLOW_MAIN=1 git commit)${judgedIn}`;
705
+ if (git("push")) {
706
+ // ref-aware: only pushes that WRITE the default branch need stage release — tags and other branches pass.
707
+ // unresolved bare names arrive pre-resolved by the caller (tag vs branch); no parse info → conservative onMain.
708
+ const refs = o.pushRefs ?? parsePushRefs(gitInvocation(c).pushes);
709
+ const writesDefault = refs.some((r) => (r.kind === "branch" && (r.name === o.defaultBranch || r.name === "*")) || (r.kind === "current" && onMain));
710
+ if (writesDefault && o.stage !== "release") return `pushing to ${o.defaultBranch} requires stage "release" (verified feature)`;
711
+ }
712
+ if (git("merge") && onMain && o.stage !== "release") return `merging into ${o.defaultBranch} requires stage "release"`;
713
+ return null;
714
+ }
715
+
716
+ // ------------------------------------------------------- project safety rules
717
+
718
+ /** One flow-guard rule: `pattern` is a JS regex source (deny/confirm) or a repo-relative path (protect/forbid). */
719
+ export interface GuardRule {
720
+ pattern: string;
721
+ why: string;
722
+ }
723
+
724
+ export interface GuardRules {
725
+ deny: GuardRule[];
726
+ confirm: GuardRule[];
727
+ protect: GuardRule[];
728
+ forbid: GuardRule[];
729
+ }
730
+
731
+ /** project.json may spell a rule as a bare pattern string. */
732
+ function normalizeGuardRules(raw: unknown[]): GuardRule[] {
733
+ const out: GuardRule[] = [];
734
+ for (const e of raw) {
735
+ if (typeof e === "string" && e.trim()) out.push({ pattern: e, why: "" });
736
+ else if (e && typeof e === "object") {
737
+ const o = e as Record<string, unknown>;
738
+ if (typeof o.pattern === "string" && o.pattern.trim()) out.push({ pattern: o.pattern, why: typeof o.why === "string" ? o.why : "" });
739
+ }
740
+ }
741
+ return out;
742
+ }
743
+
744
+ /** Heads that execute their argument: inside these, quoted text is code, not prose. */
745
+ export const REEXEC_HEADS = new Set(["sh", "bash", "zsh", "dash", "ksh", "node", "nodejs", "python", "python2", "python3", "perl", "ruby", "php", "env", "xargs", "sudo", "time", "nice", "nohup", "watch"]);
746
+
747
+ /** The first command word of a segment (a leading ./ ignored). */
748
+ export const segHead = (seg: string): string => (seg.trim().split(/\s+/)[0] ?? "").replace(/^\.\//, "");
749
+
750
+ /** Quoted argument contents are what a command says, not what it does — collapse them to empty. */
751
+ export function maskQuoted(seg: string): string {
752
+ return seg.replace(/"(?:[^"\\]|\\.)*"|'[^']*'/g, '""');
753
+ }
754
+
755
+ function quotesBalanced(command: string): boolean {
756
+ let q: string | null = null;
757
+ for (let i = 0; i < command.length; i++) {
758
+ const c = command[i];
759
+ if (c === "\\" && q !== "'") { i++; continue; }
760
+ if (q === null) { if (c === '"' || c === "'") q = c; }
761
+ else if (c === q) q = null;
762
+ }
763
+ return q === null;
764
+ }
765
+
766
+ /** Split on shell separators — but only outside quotes. Text inside quotes is an argument's prose, not a
767
+ * command: a gh --body that discusses a commit is one segment, not five. Unbalanced quoting keeps the plain
768
+ * split, so a stray quote cannot be used to hide a real command. The same split the git guard uses, so
769
+ * `cd … &&`, `;`, pipes and `&` each judge on their own. */
770
+ export function bashSegments(command: string): string[] {
771
+ if (!quotesBalanced(command)) return command.split(/[\n;|&]+/).map((s) => s.trim()).filter(Boolean);
772
+ const out: string[] = [];
773
+ let cur = "";
774
+ let q: string | null = null;
775
+ for (let i = 0; i < command.length; i++) {
776
+ const c = command[i];
777
+ const esc = i > 0 && command[i - 1] === "\\" && q !== "'";
778
+ if (!esc && (c === '"' || c === "'")) q = q === null ? c : q === c ? null : q;
779
+ if (q === null && /[\n;|&]/.test(c)) {
780
+ if (cur.trim()) out.push(cur.trim());
781
+ cur = "";
782
+ continue;
783
+ }
784
+ cur += c;
785
+ }
786
+ if (cur.trim()) out.push(cur.trim());
787
+ return out;
788
+ }
789
+
790
+ /** Heads whose arguments are data, not commands: a `grep "git push origin main"` in a docs search must not trip a deny rule. */
791
+ const READ_ONLY_HEADS = new Set(["grep", "rg", "cat", "bat", "head", "tail", "less", "awk", "jq", "find", "fd", "ls", "tree", "wc", "sort", "uniq", "column", "cut", "diff", "cmp", "stat", "file", "du", "echo", "printf", "which", "man"]);
792
+ /** `sed` is deliberately absent — `sed -i` writes. */
793
+ /** Write-capable flags that make a READ_ONLY head a writer after all: `find -delete`, `find -exec`,
794
+ * `sort -o FILE`, `awk 'BEGIN{system(...)}'`. Over-flagging only means a rule gets evaluated. */
795
+ const WRITE_FLAG = /(?:^|\s)(?:-delete|-exec(dir)?\b|-o\s)|\bsystem\(/;
796
+ const READ_ONLY_GIT = new Set(["log", "show", "diff", "status", "blame", "cat-file", "ls-files", "rev-parse"]);
797
+ const GIT_INVOKED = /(^|[\s/;|&(])git\s+(\S+)/;
798
+
799
+ // project.md is re-parsed on every tool call; compiled rules are cached by source.
800
+ const regexCache = new Map<string, RegExp | null>();
801
+ function compile(source: string): RegExp | null {
802
+ if (!regexCache.has(source)) {
803
+ try {
804
+ regexCache.set(source, new RegExp(source));
805
+ } catch {
806
+ regexCache.set(source, null); // unreachable through project.md (parse validates); project.json is not
807
+ }
808
+ }
809
+ return regexCache.get(source)!;
810
+ }
811
+
812
+ /** Why this bash segment must never run (deny), or needs an explicit operator OK (confirm).
813
+ * Segments are judged one at a time — rule regexes are written with a no-newline character class,
814
+ * so an `&&` chain cannot let one segment's text drag a rule across from the next. */
815
+ export function guardBash(command: string, g: GuardRules): { block: string | null; confirms: GuardRule[] } {
816
+ const segs = bashSegments(command);
817
+ const confirms: GuardRule[] = [];
818
+ const skip = (seg: string): boolean => {
819
+ if (/[<>]/.test(seg)) return false; // a redirect makes even a read-only head a write
820
+ if (WRITE_FLAG.test(seg)) return false; // `find -delete`/`-exec`, `sort -o`, awk system() write behind a read-only head
821
+ const head = seg.split(/\s+/)[0].replace(/^\.\//, "");
822
+ if (READ_ONLY_HEADS.has(head)) return true;
823
+ const gm = seg.match(GIT_INVOKED);
824
+ return !!gm && READ_ONLY_GIT.has(gm[2]);
825
+ };
826
+ for (const seg of segs) {
827
+ if (skip(seg)) continue;
828
+ for (const r of g.deny) if (compile(r.pattern)?.test(seg)) return { block: `project rule: ${r.why || `denied pattern ${r.pattern}`}`, confirms };
829
+ for (const r of g.confirm) if (compile(r.pattern)?.test(seg) && !confirms.includes(r)) confirms.push(r);
830
+ }
831
+ return { block: null, confirms };
832
+ }
833
+
834
+ /** realpath the deepest existing ancestor and walk back down, so a rule on a directory covers
835
+ * `docker/secrets/new-key` on a host where that directory does not exist yet. */
836
+ function realAbs(abs: string): string {
837
+ const missing: string[] = [];
838
+ let cur = abs;
839
+ while (!existsSync(cur)) {
840
+ missing.unshift(path.basename(cur));
841
+ const parent = path.dirname(cur);
842
+ if (parent === cur) break;
843
+ cur = parent;
844
+ }
845
+ let real = realpathSync(cur);
846
+ for (const seg of missing) real = path.join(real, seg);
847
+ return real;
848
+ }
849
+
850
+ /** A protect/forbid pattern whose last segment globs (`docker/.env*`) is a sibling set: the files in that
851
+ * directory whose basename matches. A secret's backup beside it is the same secret, and a prefix rule
852
+ * cannot say that — `docker/.env*` as a path names one file literally called `.env*` and guards nothing.
853
+ * Anywhere else in the pattern a glob is not supported (see the parse warning in projectmd.ts). */
854
+ function globSegment(pattern: string): { dir: string; re: RegExp } | null {
855
+ const base = path.basename(pattern);
856
+ if (!/[*?]/.test(base)) return null;
857
+ const re = base
858
+ .replace(/[.+^${}()|[\]\\]/g, "\\$&")
859
+ .replace(/\*/g, ".*")
860
+ .replace(/\?/g, ".");
861
+ return { dir: path.dirname(pattern), re: new RegExp(`^${re}$`) };
862
+ }
863
+
864
+ /** Protect/forbid verdict for an edit/write target: a rule on a directory covers its subtree, and
865
+ * comparing realpaths means `../` cannot reach out of the root. */
866
+ export function guardPath(abs: string, root: string, g: GuardRules): { block: string | null; confirm: string | null } {
867
+ const real = realAbs(path.resolve(abs)); // resolve collapses lexical `..` so an escape cannot dodge the rule
868
+ const hit = (rule: GuardRule): boolean => {
869
+ const glob = globSegment(rule.pattern);
870
+ if (glob) {
871
+ // the directory is realpath'd the same way, so a symlinked or `..`-reached dir cannot dodge the rule
872
+ return path.dirname(real) === realAbs(path.resolve(root, glob.dir)) && glob.re.test(path.basename(real));
873
+ }
874
+ const target = realAbs(path.resolve(root, rule.pattern));
875
+ return real === target || real.startsWith(target + path.sep);
876
+ };
877
+ const forbid = g.forbid.find(hit);
878
+ if (forbid) return { block: `project rule: ${forbid.why || `forbidden path ${forbid.pattern}`}`, confirm: null };
879
+ const protect = g.protect.find(hit);
880
+ return { block: null, confirm: protect ? protect.why || `protected path ${protect.pattern}` : null };
881
+ }
882
+
883
+ export interface FeatureRef {
884
+ state: FlowState;
885
+ worktreeAbs: string | null;
886
+ }
887
+
888
+ const within = (abs: string, dir: string | null) => !!dir && (abs === dir || abs.startsWith(dir + path.sep));
889
+
890
+ /**
891
+ * Write guard with per-path attribution: writes inside a worktree are judged by that
892
+ * worktree's own feature; main-checkout writes are judged by the session's bound feature
893
+ * (release+ may touch it; plan/implement may not); with no binding and unfinished features
894
+ * the session must bind first (`flow use`). Flow-by-default: a session with no flow may not
895
+ * touch the checkout at all — a bound feature is the only way in.
896
+ * Returns a reason or null (allowed).
897
+ */
898
+ export function writeGuard(o: { abs: string; dfDir: string; features: FeatureRef[]; boundSlug: string | null }): string | null {
899
+ if (o.abs === path.join(o.dfDir, "project.json")) return ".dev-flow/project.json is legacy — it is no longer read; fold its values into project.md and delete it";
900
+ if (o.abs === path.join(o.dfDir, "project.md")) return "project.md is the operator playbook — change it with flow init --write (setup) or inside a docs/patch flow's worktree";
901
+ if (o.abs.endsWith("state.json") && o.abs.includes(o.dfDir + path.sep)) return "state.json is dev-flow-owned — advance stages with the flow tool only";
902
+ // Owner attribution BEFORE the .dev-flow allow: with the default worktreeDir inside .dev-flow,
903
+ // a worktree must still be judged by its owning feature's stage, not treated as free plan space.
904
+ const owner = o.features.find((f) => within(o.abs, f.worktreeAbs));
905
+ if (owner) {
906
+ if (owner.state.stage === "plan") return `feature ${owner.state.slug} is in plan — code is read-only until its plan is approved`;
907
+ if (owner.state.stage === "done") return `feature ${owner.state.slug} is done`;
908
+ // the playbook copy inside a worktree: only operator-granted lightweight modes may change it
909
+ if (path.basename(o.abs) === "project.md" && o.abs.includes(path.join(".dev-flow", "project.md")) && !owner.state.docs && !owner.state.patch)
910
+ return `project.md changes belong to a docs or patch flow — ${owner.state.slug} is a normal feature (flow start <slug> --docs --confirmed)`;
911
+ return null; // implement/pre-release/release: own worktree is the working area
912
+ }
913
+ const bound = o.boundSlug ? o.features.find((f) => f.state.slug === o.boundSlug) : undefined;
914
+ // A plan-stage session may only write its own feature directory: otherwise one session edits another
915
+ // feature's plan and that feature's approval gate then rubber-stamps text it never wrote.
916
+ if (bound?.state.stage === "plan" && !within(o.abs, path.join(o.dfDir, bound.state.slug)))
917
+ return `bound feature ${bound.state.slug} is in plan — write only inside its own .dev-flow/${bound.state.slug}/`;
918
+ if (within(o.abs, o.dfDir)) return null;
919
+ if (bound) {
920
+ if (bound.state.stage === "implement" || bound.state.stage === "pre-release")
921
+ return `bound feature ${o.boundSlug} is at ${bound.state.stage} — edit its worktree (${bound.state.worktree}), not the main checkout`;
922
+ return null; // release: release chores may touch the main checkout
923
+ }
924
+ if (!o.features.length) return "no active flow — every request goes through flow: flow start <slug> for new work, flow use <slug> for an unfinished feature";
925
+ return `unfinished features: ${o.features.map((f) => f.state.slug).join(", ")} — bind this session with flow use <slug> (or pass an explicit slug to gates)`;
926
+ }
927
+
928
+ /** Branch targeted by a `git checkout`/`git switch` in the command, or null. */
929
+ export function checkoutTarget(command: string): string | null {
930
+ const m = command.match(/\bgit\s+(?:-C\s+\S+\s+)?(?:checkout|switch)\s+(?:-[bBc]\s+)?([^\s;|&]+)/);
931
+ return m ? m[1] : null;
932
+ }
933
+
934
+ /** Prefix feature branches match: branchPattern up to `{slug}` — used to keep feature branches out of the main checkout. */
935
+ export function featureBranchPrefix(cfg: Config): string {
936
+ return cfg.branchPattern.split("{")[0];
937
+ }
938
+
939
+ /** Content of .dev-flow/<slug>/claim.json: which session is working the feature. */
940
+ export interface SessionClaim {
941
+ key: string;
942
+ pid: number;
943
+ at: string;
944
+ }
945
+
946
+ /** Whether a process is running (kill 0 only probes existence; EPERM means it exists, foreign-owned). */
947
+ export function pidAlive(pid: number): boolean {
948
+ try {
949
+ process.kill(pid, 0);
950
+ return true;
951
+ } catch (e) {
952
+ return (e as NodeJS.ErrnoException).code === "EPERM";
953
+ }
954
+ }
955
+
956
+ /** True only when another live session holds the claim — that blocks silently auto-binding onto its feature. */
957
+ export function claimBlocks(claim: SessionClaim | null, myKey: string, alive: (pid: number) => boolean = pidAlive): boolean {
958
+ return !!claim && claim.key !== myKey && alive(claim.pid);
959
+ }
960
+
961
+ /** Footer glyph per stage: the footer has no room for stage words and no legend of its own (README:Status bar is the legend). */
962
+ export const STAGE_EMOJI: Record<Stage, string> = { plan: "📝", implement: "🔨", "pre-release": "🔍", release: "🚀", done: "🏁" };
963
+
964
+ export function statusCompact(state: FlowState): { text: string; failed: boolean } {
965
+ const failed = Object.values(state.checks).some((c) => !c.ok);
966
+ return { text: `${STAGE_EMOJI[state.stage]} ${state.slug}${state.patch ? " ⚡" : state.docs ? " 📚" : ""}${state.yolo ? " 🎱" : ""}`, failed };
967
+ }
968
+
969
+ /** `- [ ]` checklist items of a markdown document (reflections.md and roadmap.md share the format). */
970
+ export function openChecklistItems(text: string): string[] {
971
+ return text.split("\n").map((l) => l.trim()).filter((l) => l.startsWith("- [ ] "));
972
+ }
973
+
974
+ /** Open (unchecked) reflection items in reflections.md content — the agent's suggestion queue. */
975
+ export function openReflections(text: string): string[] {
976
+ return openChecklistItems(text);
977
+ }
978
+
979
+ /** The note text of an item, without the checkbox and without the trailing ` — <attribution>`: what
980
+ * two reflects are compared on, so the same note at another date is still the same note.
981
+ * shortcut: split on the last em dash — matches what the tool writes; a hand-written item ending in
982
+ * an em-dash clause compares wider than intended. */
983
+ const reflectionBody = (l: string) => l.trim().replace(/^-\s*\[[ xX]\]\s*/, "").replace(/\s*—.*$/, "").trim();
984
+
985
+ /** One reflections.md item line — with no flow bound the slug is left out; `— - <date>` read as a broken slug. */
986
+ export function reflectionLine(note: string, slug: string | null, date: string): string {
987
+ return `- [ ] ${note.trim()} — ${slug ? `${slug} ` : ""}${date}`;
988
+ }
989
+
990
+ export const REFLECT_HEADER =
991
+ "# dev-flow reflections\n\n<!-- Suggestions queued by the agent; the operator decides. Fold playbook items in via flow init --write, file plugin items as issues; check off when handled. -->\n";
992
+
993
+ /** The two reflections.md sections, in document order. */
994
+ export const REFLECT_SECTIONS = ["playbook", "plugin"] as const;
995
+ export type ReflectSection = (typeof REFLECT_SECTIONS)[number];
996
+
997
+ // keyword sets decide the section: plugin = dev-flow's own behaviour, playbook = this project's
998
+ const PLUGIN_HINT = /\b(plugin|extension|dev-flow|gate|guard|tool|session|compact\w*|hook|status bar)\b/i;
999
+ const PLAYBOOK_HINT = /\b(project\.md|playbook|checklist|versioning|defaultBranch|prose|setup|health|release rules)\b/i;
1000
+
1001
+ /** Which reflections.md section(s) a note belongs to. Both keyword sets → both sections;
1002
+ * neither → playbook, where the operator will notice and move it. */
1003
+ export function reflectSections(note: string): ReflectSection[] {
1004
+ const plugin = PLUGIN_HINT.test(note);
1005
+ return plugin && PLAYBOOK_HINT.test(note) ? ["playbook", "plugin"] : plugin ? ["plugin"] : ["playbook"];
1006
+ }
1007
+
1008
+ /** Where a fix must be implemented: every fix runs through a flow whose gates confirm it —
1009
+ * playbook-only change → docs flow; code in the dev-flow repo → patch/normal flow;
1010
+ * plugin feedback in someone else's repo → upstream issue (dev-flow files nothing itself). */
1011
+ export interface ReflectionRoute {
1012
+ text: string;
1013
+ sections: ReflectSection[];
1014
+ mode: "docs" | "code" | "upstream";
1015
+ route: string;
1016
+ }
1017
+
1018
+ export function reflectionRoutes(items: string[], selfRepo: boolean): ReflectionRoute[] {
1019
+ return items.map((l) => {
1020
+ const text = l.replace(/^-\s*\[[ xX]\]\s*/, "").trim();
1021
+ const sections = reflectSections(text);
1022
+ if (sections.includes("plugin"))
1023
+ return selfRepo
1024
+ ? { text, sections, mode: "code" as const, route: "patch flow for a small fix (flow start fix-<topic> --patch --confirmed), normal flow when bigger" }
1025
+ : { text, sections, mode: "upstream" as const, route: "file an issue against pi-dev-flow upstream (manual — dev-flow files nothing itself); copy the item text there" };
1026
+ return { text, sections, mode: "docs", route: "docs flow (flow start fold-<topic> --docs --confirmed) — project.md edits only pass its worktree" };
1027
+ });
1028
+ }
1029
+
1030
+ /** What `flow doctor <note>` prints: the note is an investigation order, not an answer —
1031
+ * the plugin has no LLM; the agent investigates, the gates confirm, the flow implements. */
1032
+ export function investigationBrief(note: string, selfRepo: boolean): string[] {
1033
+ const [route] = reflectionRoutes([`- [ ] ${note}`], selfRepo);
1034
+ return [
1035
+ `investigate: "${route.text}"`,
1036
+ "read first: .dev-flow/project.md, flow doctor findings, and whatever files the note names.",
1037
+ `fix routing: ${route.route}`,
1038
+ "the note is not implemented by doctor — propose the fix to the operator first; only after they confirm, open the routed flow; its gates carry the confirmation.",
1039
+ ];
1040
+ }
1041
+
1042
+ /** Bare `flow reflect`: the full session-reflection protocol for the agent (the plugin has no LLM —
1043
+ * the agent reviews the session log, re-checks open items against shipped fixes, dedupes new
1044
+ * findings and puts the fix decision to the operator). Writes nothing, runs no network calls. */
1045
+ export function reflectProtocol(items: string[], selfRepo: boolean, facts: { version: string | null; upstream: string }): string[] {
1046
+ const ver = facts.version ? `dev-flow ${facts.version}` : "dev-flow version unknown";
1047
+ const up = facts.upstream || "<upstream — ask the operator for the dev-flow repo>";
1048
+ const url = facts.upstream ? `https://github.com/${facts.upstream}` : "<upstream-url>";
1049
+ const lines = [
1050
+ `flow reflect — session reflection (${ver}, upstream: ${up})`,
1051
+ "this output is your work instruction: execute these steps now — do not echo the protocol and do not ask to begin.",
1052
+ "queue: .dev-flow/reflections.md — gitignored and yours to edit; check items off as `- [x] … — fixed: <what or where>`.",
1053
+ "1. review the current session log in full — every message, tool call and result: friction, wrong turns, repeated failures, guards or gates that misfired, playbook drift, optimization candidates. Decide for each: playbook issue or plugin issue.",
1054
+ `2. re-check every open item below: already handled by the current playbook (.dev-flow/project.md) or obsolete → check it off with a fixed: note. Possibly fixed in a newer dev-flow: npm view pi-dev-flow version — a newer published version covering the item → recommend the operator update the plugin (pi install npm:pi-dev-flow, then /reload; a git-installed copy updates against its own tags: git ls-remote --tags --sort=-v:refname ${url} | head -1), never update it yourself.`,
1055
+ '3. new findings: dedupe against the open items first — never reflect the same issue twice; queue each real one with flow reflect "<one-line note>", then report to the operator structurally: playbook items vs plugin items, each with its route from the list below.',
1056
+ "4. ask the operator whether to fix the open issues now. If yes:",
1057
+ ` a. plugin items: search upstream first — gh issue list --repo ${up} --state all --search "<keywords>" — an author-addressed match → report the verdict back to the operator (fixed in v… / closed won't-fix + reason), do not re-file; no match → gh issue create --repo ${up} with a detailed description, session evidence and a suggested fix.`,
1058
+ " b. playbook items: run flow doctor — it reviews each open item and routes it to the docs flow that implements the fix.",
1059
+ ];
1060
+ if (!items.length) lines.push("open items: none — steps 1 and 3 still apply.");
1061
+ else {
1062
+ lines.push(`open items (${items.length}):`);
1063
+ for (const r of reflectionRoutes(items, selfRepo)) lines.push(` [${r.sections.join("+")}] ${r.text}`, ` → ${r.route}`);
1064
+ }
1065
+ return lines;
1066
+ }
1067
+
1068
+ /**
1069
+ * Rewrite reflections.md as a preamble + one section per non-empty bucket, with `line` appended to
1070
+ * the end of each named section. Everything above the first item (title, comment, operator notes) is
1071
+ * kept verbatim. Below the first item a `## section` heading owns the lines under it, so an existing
1072
+ * line never changes section. Legacy flat lines carry their target inline (`- [ ] (plugin) …`,
1073
+ * `— to playbook`); that marker buckets them and is then dropped, so an old file migrates on next
1074
+ * write. A note already in a section is not written there again — an all-duplicate call returns the
1075
+ * content untouched so the caller can say so.
1076
+ */
1077
+ export function appendReflection(text: string, sections: ReflectSection[], line: string): string {
1078
+ const lines = text.split("\n");
1079
+ const sectionHeader = new RegExp(`^## (${REFLECT_SECTIONS.join("|")})\\s*$`, "i");
1080
+ // shortcut: only the preamble above the first header or item survives verbatim; hand-written prose below the items is dropped.
1081
+ // a header is part of the body, not of the preamble: it owns the items under it, and one with no items left simply disappears from the rewrite.
1082
+ const firstBody = lines.findIndex((l) => l.trim().startsWith("- [") || sectionHeader.test(l.trim()));
1083
+ const preamble = (firstBody < 0 ? lines : lines.slice(0, firstBody))
1084
+ .join("\n")
1085
+ .replace(/\n{3,}/g, "\n\n")
1086
+ .trim();
1087
+ const buckets = Object.fromEntries(REFLECT_SECTIONS.map((s) => [s, [] as string[]])) as Record<ReflectSection, string[]>;
1088
+ let section: ReflectSection | null = null;
1089
+ for (const raw of firstBody < 0 ? [] : lines.slice(firstBody)) {
1090
+ const l = raw.trim();
1091
+ const header = sectionHeader.exec(l);
1092
+ if (header) {
1093
+ // a heading is authoritative for the items under it: deriving the bucket from the inline
1094
+ // marker instead moved every marker-less `## plugin` line into `## playbook` on the next write.
1095
+ section = header[1].toLowerCase() as ReflectSection;
1096
+ continue;
1097
+ }
1098
+ if (!l.startsWith("- [")) continue;
1099
+ // items above every heading come from a legacy flat file, where the inline marker is the only clue.
1100
+ const key: ReflectSection = section ?? (/\((plugin|doctor)\)/.test(l) || /\bto plugin\b/.test(l) ? "plugin" : "playbook");
1101
+ buckets[key].push(l.replace(/\((plugin|playbook|doctor)\)/, "").replace(/\s*[—-]\s*to\s+(playbook|plugin|doctor)/, "").replace(/\s{2,}/g, " ").trim());
1102
+ }
1103
+ const note = line.trim();
1104
+ const body = reflectionBody(note);
1105
+ // only open items block a duplicate: a checked-off note may be reported again when it comes back.
1106
+ for (const s of sections) if (!buckets[s].some((l) => l.trim().startsWith("- [ ]") && reflectionBody(l) === body)) buckets[s].push(note);
1107
+ return `${preamble || REFLECT_HEADER.trimEnd()}\n\n` + REFLECT_SECTIONS.filter((s) => buckets[s].length).map((s) => `## ${s}\n\n${buckets[s].join("\n")}\n`).join("\n");
1108
+ }
1109
+
1110
+ /** Header of roadmap.md — the backlog of future features (gitignored, agent-editable like reflections.md). */
1111
+ export const ROADMAP_HEADER =
1112
+ "# dev-flow roadmap\n\n<!-- Backlog of features to build later. flow roadmap add/pick/done/tidy; a pick stamps its item (slug) and flow finish checks it off when that feature ships. -->\n";
1113
+
1114
+ // one strict format rules them all: the tool writes "- [ ] "/"- [x] " and counts exactly that —
1115
+ // a looser matcher made `list` numbering disagree with what pick/done edit.
1116
+ const isOpenItem = (line: string) => line.trim().startsWith("- [ ] ");
1117
+ const isDoneItem = (line: string) => /^\s*-\s*\[[xX]\]\s/.test(line);
1118
+ const stampItem = (line: string, stamp: string) => `- [x] ${line.trim().slice(6)} — done: ${stamp}`;
1119
+
1120
+ /** Append an open item to roadmap.md content; a header is minted from empty content. */
1121
+ export function appendRoadmapItem(text: string, item: string): string {
1122
+ return `${text.trimEnd() || ROADMAP_HEADER.trimEnd()}\n- [ ] ${item.trim()}\n`;
1123
+ }
1124
+
1125
+ /** Number of open roadmap items. */
1126
+ export const roadmapOpenCount = (text: string) => openChecklistItems(text).length;
1127
+ /** Number of done roadmap items (tidy removes them). */
1128
+ export const roadmapDoneCount = (text: string) => text.split("\n").filter(isDoneItem).length;
1129
+
1130
+ /** Stamp the nth open item (1-based, open items only) with `(slug)` — the pick→finish handshake.
1131
+ * Returns null when n is out of range. Re-picking the same slug is a no-op stamp. */
1132
+ export function roadmapPick(text: string, n: number, slug: string): string | null {
1133
+ let seen = 0;
1134
+ let hit = false;
1135
+ const out = text.split("\n").map((l) => {
1136
+ if (!isOpenItem(l)) return l;
1137
+ ++seen;
1138
+ if (seen !== n) return l;
1139
+ hit = true;
1140
+ return l.trim().includes(`(${slug})`) ? l.trim() : `${l.trim()} (${slug})`;
1141
+ });
1142
+ return hit ? out.join("\n") : null;
1143
+ }
1144
+
1145
+ /** Check the nth open item (1-based, open items only) with a done stamp. Returns null when n is out of range. */
1146
+ export function roadmapDone(text: string, n: number, stamp: string): string | null {
1147
+ let seen = 0;
1148
+ let hit = false;
1149
+ const out = text.split("\n").map((l) => {
1150
+ if (!isOpenItem(l)) return l;
1151
+ ++seen;
1152
+ if (seen !== n) return l;
1153
+ hit = true;
1154
+ return stampItem(l, stamp);
1155
+ });
1156
+ return hit ? out.join("\n") : null;
1157
+ }
1158
+
1159
+ /** Check every open item stamped `(slug)` by a pick — flow finish closes the backlog item a picked feature ships.
1160
+ * shortcut: "(slug)" matches as substring — item prose literally containing "(fin)" closes when fin ships; only pick-minted stamps are meant to match. */
1161
+ export function roadmapDoneForSlug(text: string, slug: string, stamp: string): { text: string; count: number } {
1162
+ let count = 0;
1163
+ const out = text.split("\n").map((l) => {
1164
+ if (!isOpenItem(l) || !l.includes(`(${slug})`)) return l;
1165
+ count++;
1166
+ return stampItem(l, stamp);
1167
+ });
1168
+ return { text: out.join("\n"), count };
1169
+ }
1170
+
1171
+ /** Drop the done (`- [x]`) items; preamble and open items stay verbatim. */
1172
+ export function roadmapTidy(text: string): string {
1173
+ return text.split("\n").filter((l) => !isDoneItem(l)).join("\n").replace(/\n{3,}/g, "\n\n");
1174
+ }
1175
+
1176
+ /** How far before pi's own compaction threshold dev-flow compacts (one fat tool turn). */
1177
+ export const COMPACT_MARGIN = 16384;
1178
+ /** pi's default compaction reserveTokens; pass the real value if known. */
1179
+ export const PI_RESERVE_DEFAULT = 16384;
1180
+
1181
+ /**
1182
+ * Safe-point decision for flow-aware self-compaction: only when the context is
1183
+ * within COMPACT_MARGIN of pi's threshold AND nothing important lives only in chat —
1184
+ * an open operator dialog (pending checklist) or an undigested plan discussion both mean the transcript IS the state.
1185
+ */
1186
+ export function shouldSelfCompact(
1187
+ d: { tokens: number | null; contextWindow: number; stage: Stage | null; planFiles: number; checklistPending: number; compacting: boolean },
1188
+ reserve = PI_RESERVE_DEFAULT,
1189
+ ): boolean {
1190
+ if (d.compacting || d.tokens === null) return false;
1191
+ if (!d.stage || d.stage === "done") return false;
1192
+ if (d.checklistPending > 0) return false;
1193
+ if (d.stage === "plan" && d.planFiles === 0) return false;
1194
+ return d.tokens > d.contextWindow - reserve - COMPACT_MARGIN;
1195
+ }
1196
+
1197
+ /**
1198
+ * Forced pi compaction that dev-flow should take over and redo with flow-aware instructions:
1199
+ * only the threshold path — overflow must keep pi's compact-and-retry, manual is the operator's or ours.
1200
+ */
1201
+ export function shouldTakeOverCompaction(d: { reason: "manual" | "threshold" | "overflow"; bound: boolean; inFlight: boolean }): boolean {
1202
+ return d.reason === "threshold" && d.bound && !d.inFlight;
1203
+ }
1204
+
1205
+ /** reflections.md line for a forced (non-manual) pi compaction while a flow is bound — advisory, never gates. */
1206
+ export function compactionReflectionLine(slug: string, stage: Stage, reason: string, today: string): string {
1207
+ return `- [ ] pi auto-compacted (${reason}) at stage ${stage} — pace gates so work lands on disk before compaction; draft plan discussion into plan/ early — ${slug} ${today}`;
1208
+ }
1209
+
1210
+ /** What the agent/user should do next, per stage. */
1211
+ export function nextHint(state: FlowState, cfg: Config): string {
1212
+ switch (state.stage) {
1213
+ case "plan":
1214
+ if (state.patch) return `patch: describe the change to the operator in one line, then ask them Approve? in chat and run flow approve --confirmed when they answer — no plan file needed (all gates still apply)`;
1215
+ if (state.docs) return `docs: describe the change to the operator in one line, then ask them Approve? in chat and run flow approve --confirmed when they answer — no plan file and no tier-1 checks (worktree and release stay intact)`;
1216
+ return `investigate, present your direction and proposed mode to the operator and clarify in chat, then write the plan into .dev-flow/${state.slug}/plan/${state.ui ? ` — render mockups to plan/mockups/ (${cfg.mockupGlobs.join(", ")}) with headless chromium and embed them in plan.md` : ""}, then ask the operator Approve? in chat and run flow approve --confirmed when they answer`;
1217
+ case "implement":
1218
+ if (state.docs) return `implement in the worktree ${state.worktree} — docs mode runs no project command (no build, no dev deploy, no health probe); tick the tier-2 checklist with the operator, then ask them Verified? in chat and run flow verify --confirmed when they answer`;
1219
+ return `implement in the worktree ${state.worktree}, add tests, run flow check (tier-1 checks, incl. health with seeded data); tick the tier-2 checklist with the operator, then ask them Verified? in chat and run flow verify --confirmed when they answer`;
1220
+ case "pre-release":
1221
+ return `${cfg.release.review} → fix must-have/should-have → update docs${cfg.release.changelog ? ` → prepare ${cfg.release.changelog}` : ""}${cfg.versioning.file ? ` → bump version in ${cfg.versioning.file}` : ""}, re-run flow check, then show the operator the PR summary, version and changelog, ask Release? and run flow release`;
1222
+ case "release":
1223
+ return state.approvals.release
1224
+ ? "release recorded — commit and push the release chores in the main checkout (prefix the commit with DEV_FLOW_ALLOW_MAIN=1 — the installed pre-commit hook otherwise blocks main commits), ask the operator Finish? and run flow finish (removes worktree, branch and .dev-flow/" + state.slug + "/)"
1225
+ : state.noVersion
1226
+ ? "publish: commit and push the branch, open and merge the PR — unversioned release: no manifest bump, no tag, your changelog entry rides under Unreleased; then run flow release-done (no further question)"
1227
+ : "publish: commit and push the branch, open and merge the PR, create the annotated tag from the manifest version and push it, then run flow release-done (no further question)";
1228
+ default:
1229
+ return "feature finished";
1230
+ }
1231
+ }
1232
+
1233
+ /** Stable token prefixing every no-UI operator refusal, so a headless orchestrator can route refusals without grepping prose. */
1234
+ export const NEEDS_OPERATOR = "NEEDS-OPERATOR";
1235
+
1236
+ /** Refusal for "the gate needs the operator's word and no UI is present": `<TOKEN> [Question?] — <what to do>`. */
1237
+ export function needsOperator(detail: string, question?: string): string {
1238
+ return `${NEEDS_OPERATOR}${question ? ` ${question}` : ""} — ${detail}`;
1239
+ }
1240
+
1241
+ /** Machine-readable `flow status --json`: the prose view's facts as stable JSON (no git probes, same as prose). */
1242
+ export function statusJson(states: FlowState[], bound: string | null, cfg: Config): string {
1243
+ return JSON.stringify(
1244
+ {
1245
+ bound,
1246
+ features: states.map((st) => ({
1247
+ slug: st.slug,
1248
+ stage: st.stage,
1249
+ current: st.slug === bound,
1250
+ ui: st.ui,
1251
+ patch: !!st.patch,
1252
+ docs: !!st.docs,
1253
+ yolo: !!st.yolo,
1254
+ noVersion: !!st.noVersion,
1255
+ branch: st.branch || null,
1256
+ worktree: st.worktree || null,
1257
+ flowVersion: st.flowVersion ?? null,
1258
+ releaseVersion: st.releaseVersion ?? null,
1259
+ approvals: Object.keys(st.approvals),
1260
+ checks: Object.fromEntries(Object.entries(st.checks).map(([k, v]) => [k, v.ok])),
1261
+ checklistConfirmed: !!st.confirmations?.implement,
1262
+ next: nextHint(st, cfg),
1263
+ })),
1264
+ },
1265
+ null,
1266
+ "\t",
1267
+ );
1268
+ }
1269
+
1270
+ /** Machine-readable `flow help --json` from the one GATE_HELP source: the command list splits on " | " (bracketed alternations carry no spaces around the pipe), the shared note starts at the first " — ". No parallel help text to drift. */
1271
+ export function gateHelpJson(gateHelp: string, version: string | null): string {
1272
+ const i = gateHelp.indexOf(" — ");
1273
+ return JSON.stringify(
1274
+ {
1275
+ version,
1276
+ gates: (i < 0 ? gateHelp : gateHelp.slice(0, i)).split(" | "),
1277
+ notes: i < 0 ? "" : gateHelp.slice(i + 3),
1278
+ protocol: {
1279
+ checkpointQuestions: ["Approve?", "Verified?", "Release?", "Finish?"],
1280
+ refusalMarker: NEEDS_OPERATOR,
1281
+ confirmFlag: "--confirmed",
1282
+ yoloFlag: "--yolo",
1283
+ },
1284
+ },
1285
+ null,
1286
+ "\t",
1287
+ );
1288
+ }
1289
+
1290
+ export function getPath(obj: Record<string, unknown>, key: string): unknown {
1291
+ let cur: unknown = obj;
1292
+ for (const seg of key.split(".")) {
1293
+ if (typeof cur !== "object" || cur === null) return undefined;
1294
+ cur = (cur as Record<string, unknown>)[seg];
1295
+ }
1296
+ return cur;
1297
+ }
1298
+
1299
+ /** Write (or delete, on null) a dot-path value in a partial project.json object. */
1300
+ export function applyAnswer(partial: Record<string, unknown>, key: string, value: string | boolean | null): void {
1301
+ const segs = key.split(".");
1302
+ const leaf = segs.pop()!;
1303
+ const chain: Record<string, unknown>[] = [partial];
1304
+ for (const s of segs) {
1305
+ const next = chain[chain.length - 1][s];
1306
+ if (typeof next !== "object" || next === null) {
1307
+ const created: Record<string, unknown> = {};
1308
+ chain[chain.length - 1][s] = created;
1309
+ chain.push(created);
1310
+ } else chain.push(next as Record<string, unknown>);
1311
+ }
1312
+ const holder = chain[chain.length - 1];
1313
+ if (value === null || value === "" || value === "(none)") delete holder[leaf];
1314
+ else holder[leaf] = value;
1315
+ for (let i = chain.length - 2; i >= 0; i--) {
1316
+ const child = chain[i][segs[i]] as Record<string, unknown>;
1317
+ if (child && Object.keys(child).length === 0) delete chain[i][segs[i]];
1318
+ }
1319
+ }
1320
+