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.
- package/CHANGELOG.md +651 -0
- package/LICENSE +21 -0
- package/README.md +187 -1
- package/docs/concurrency.md +18 -0
- package/docs/development.md +37 -0
- package/docs/gates.md +29 -0
- package/docs/guardrails.md +27 -0
- package/docs/headless.md +42 -0
- package/docs/playbook.md +141 -0
- package/extensions/dev-flow.ts +1502 -0
- package/package.json +34 -5
- package/skills/dev-flow/SKILL.md +378 -0
- package/src/core.ts +1320 -0
- package/src/detect.ts +103 -0
- package/src/projectmd.ts +394 -0
- package/src/timeouts.ts +79 -0
package/src/detect.ts
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic project detection for `flow init`.
|
|
3
|
+
*
|
|
4
|
+
* Produces a *partial* config (merged over DEFAULTS by the caller) plus
|
|
5
|
+
* human-readable report lines. No LLM, no guessing beyond strong evidence —
|
|
6
|
+
* anything not confidently detected is left empty and reported as missing.
|
|
7
|
+
*/
|
|
8
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
import { parseVersion } from "./core.ts";
|
|
11
|
+
|
|
12
|
+
export interface Detection {
|
|
13
|
+
partial: { defaultBranch: string; commands: Record<string, string>; versioning?: { file: string }; forge?: string };
|
|
14
|
+
report: string[]; // human lines: what was detected
|
|
15
|
+
missing: string[]; // command keys left empty (excluding setup)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const read = (p: string): string | null => {
|
|
19
|
+
try {
|
|
20
|
+
return readFileSync(p, "utf8");
|
|
21
|
+
} catch {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
function pkgManager(root: string, field?: string): string {
|
|
27
|
+
const m = field?.match(/^(npm|pnpm|yarn|bun)@/);
|
|
28
|
+
if (m) return m[1];
|
|
29
|
+
if (existsSync(path.join(root, "bun.lockb")) || existsSync(path.join(root, "bun.lock"))) return "bun";
|
|
30
|
+
if (existsSync(path.join(root, "pnpm-lock.yaml"))) return "pnpm";
|
|
31
|
+
if (existsSync(path.join(root, "yarn.lock"))) return "yarn";
|
|
32
|
+
return "npm";
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function detectProject(root: string, defaultBranch: string): Detection {
|
|
36
|
+
const commands: Record<string, string> = {};
|
|
37
|
+
const report: string[] = [];
|
|
38
|
+
const guessed = /^(main|master|develop|trunk)$/.test(defaultBranch);
|
|
39
|
+
report.push(guessed ? `default branch: ${defaultBranch} (guessed from git)` : `default branch: "${defaultBranch}" looks like a working branch — confirm or correct it below`);
|
|
40
|
+
const detected = detectCommands(root, commands, report);
|
|
41
|
+
if (!detected) report.push("no supported manifest found — fill in commands yourself (see README)");
|
|
42
|
+
if (Object.keys(commands).length) report.push(`commands: ${Object.entries(commands).map(([k, v]) => `${k}="${v}"`).join(", ")}`);
|
|
43
|
+
const missing = ["test", "build", "run"].filter((k) => !commands[k]);
|
|
44
|
+
const versioning = detectVersioning(root, report);
|
|
45
|
+
return { partial: { defaultBranch, commands, ...(versioning ? { versioning } : {}) }, report, missing };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Version manifest probe: first of VERSION / package.json / Cargo.toml / pyproject.toml holding a version. */
|
|
49
|
+
function detectVersioning(root: string, report: string[]): { file: string } | null {
|
|
50
|
+
const candidates = ["VERSION", "package.json", "Cargo.toml", "pyproject.toml"];
|
|
51
|
+
for (const file of candidates) {
|
|
52
|
+
const raw = read(path.join(root, file));
|
|
53
|
+
if (!raw) continue;
|
|
54
|
+
const v = parseVersion(file, raw);
|
|
55
|
+
if (v) {
|
|
56
|
+
report.push(`versioning: ${file} (current ${v}) — release tags must match this manifest`);
|
|
57
|
+
return { file };
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
report.push("versioning: none found — ask the operator to pick/create a version manifest (VERSION recommended), then set versioning.file");
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function detectCommands(root: string, c: Record<string, string>, report: string[]): boolean {
|
|
65
|
+
// node — first match wins; other ecosystems below are realistically exclusive here
|
|
66
|
+
const pkgRaw = read(path.join(root, "package.json"));
|
|
67
|
+
if (pkgRaw) {
|
|
68
|
+
try {
|
|
69
|
+
const pkg = JSON.parse(pkgRaw) as { packageManager?: string; scripts?: Record<string, string> };
|
|
70
|
+
const pm = pkgManager(root, pkg.packageManager);
|
|
71
|
+
report.push(`node project detected (package manager: ${pm})`);
|
|
72
|
+
c.setup = `${pm} install`;
|
|
73
|
+
if (pkg.scripts?.test) c.test = `${pm} test`;
|
|
74
|
+
if (pkg.scripts?.build) c.build = `${pm} build`;
|
|
75
|
+
if (pkg.scripts?.dev) c.run = `${pm} dev`;
|
|
76
|
+
else if (pkg.scripts?.start) c.run = `${pm} start`;
|
|
77
|
+
return true;
|
|
78
|
+
} catch {
|
|
79
|
+
report.push("package.json exists but is not valid JSON — ignored");
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
if (existsSync(path.join(root, "Cargo.toml"))) {
|
|
83
|
+
report.push("rust project detected (Cargo.toml)");
|
|
84
|
+
Object.assign(c, { test: "cargo test", build: "cargo build --release", run: "cargo run" });
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
87
|
+
if (existsSync(path.join(root, "go.mod"))) {
|
|
88
|
+
report.push("go project detected (go.mod)");
|
|
89
|
+
Object.assign(c, { test: "go test ./...", build: "go build ./..." });
|
|
90
|
+
return true;
|
|
91
|
+
}
|
|
92
|
+
if (existsSync(path.join(root, "pyproject.toml")) || existsSync(path.join(root, "requirements.txt"))) {
|
|
93
|
+
if (existsSync(path.join(root, "uv.lock"))) Object.assign(c, { setup: "uv sync", test: "uv run pytest" });
|
|
94
|
+
else if (existsSync(path.join(root, "poetry.lock"))) Object.assign(c, { setup: "poetry install", test: "poetry run pytest" });
|
|
95
|
+
else {
|
|
96
|
+
c.test = "pytest";
|
|
97
|
+
if (existsSync(path.join(root, "requirements.txt"))) c.setup = "pip install -r requirements.txt";
|
|
98
|
+
}
|
|
99
|
+
report.push("python project detected");
|
|
100
|
+
return true;
|
|
101
|
+
}
|
|
102
|
+
return false;
|
|
103
|
+
}
|
package/src/projectmd.ts
ADDED
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `project.md` — the operator-owned playbook, three tiers by notation:
|
|
3
|
+
* tier 1: ```flow key = value``` config blocks and ```flow-check <name>``` executable blocks
|
|
4
|
+
* tier 2: `- [ ] item` checklist lines (structured confirmation, semantics up to the human)
|
|
5
|
+
* tier 3: every other line — prose injected to the agent when its stage begins
|
|
6
|
+
* Parsing is pure and never fatal: unknown stages/keys and odd syntax become warnings.
|
|
7
|
+
*/
|
|
8
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
import { applyAnswer, type ChecklistItem, type Config, DEFAULTS, healthProbe, parseVersion, toSeconds, type GuardRules, type StageCheck } from "./core.ts";
|
|
11
|
+
|
|
12
|
+
export const MD_STAGES = ["global", "plan", "implement", "pre-release", "release"] as const;
|
|
13
|
+
export type MdStage = (typeof MD_STAGES)[number];
|
|
14
|
+
|
|
15
|
+
export interface ParsedMd {
|
|
16
|
+
hard: string[]; // errors: init --write refuses on these
|
|
17
|
+
warn: string[];
|
|
18
|
+
global: Record<string, string>;
|
|
19
|
+
flow: Record<MdStage, Record<string, string>>;
|
|
20
|
+
checks: StageCheck[];
|
|
21
|
+
guard: GuardRules;
|
|
22
|
+
checklist: Record<string, ChecklistItem[]>;
|
|
23
|
+
prose: Record<string, string>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// stage → flow-key → config dot-path (aliases); unknown keys warn
|
|
27
|
+
const KEYMAP: Record<string, Record<string, string>> = {
|
|
28
|
+
global: {
|
|
29
|
+
defaultBranch: "defaultBranch",
|
|
30
|
+
bashTimeout: "bashTimeout", // require (default) | inject — must a shell call state its own timeout?
|
|
31
|
+
branchPattern: "branchPattern",
|
|
32
|
+
worktreeDir: "worktreeDir",
|
|
33
|
+
mainHook: "mainHook",
|
|
34
|
+
mockupGlobs: "mockupGlobs",
|
|
35
|
+
forge: "forge",
|
|
36
|
+
"versioning.file": "versioning.file",
|
|
37
|
+
"versioning.pattern": "versioning.pattern",
|
|
38
|
+
"release.review": "release.review",
|
|
39
|
+
"release.changelog": "release.changelog",
|
|
40
|
+
"release.github": "release.releases",
|
|
41
|
+
"release.releases": "release.releases",
|
|
42
|
+
"release.artifact": "release.artifact",
|
|
43
|
+
},
|
|
44
|
+
plan: {},
|
|
45
|
+
implement: {
|
|
46
|
+
setup: "commands.setup",
|
|
47
|
+
test: "commands.test",
|
|
48
|
+
build: "commands.build",
|
|
49
|
+
run: "commands.run",
|
|
50
|
+
seedData: "commands.seedData",
|
|
51
|
+
health: "commands.health",
|
|
52
|
+
dev: "commands.dev",
|
|
53
|
+
healthWait: "healthWait", // seconds the post-deploy probe may keep retrying (#42)
|
|
54
|
+
},
|
|
55
|
+
release: {
|
|
56
|
+
versionFile: "versioning.file",
|
|
57
|
+
versionTag: "versioning.pattern",
|
|
58
|
+
review: "release.review",
|
|
59
|
+
changelog: "release.changelog",
|
|
60
|
+
github: "release.releases", // legacy alias for `releases` — parse warns about the rename
|
|
61
|
+
releases: "release.releases",
|
|
62
|
+
artifact: "release.artifact",
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
// pre-release owns release preparation; the release keys are accepted under both sections so
|
|
66
|
+
// existing playbooks (## release with versionFile/…) keep parsing unchanged
|
|
67
|
+
KEYMAP["pre-release"] = { ...KEYMAP.release };
|
|
68
|
+
|
|
69
|
+
const CHECK_ITEM = /^\s*-\s*\[[ xX]\]\s+(.+)/;
|
|
70
|
+
|
|
71
|
+
const GUARD_VERBS = ["deny", "confirm", "protect", "forbid"] as const;
|
|
72
|
+
type GuardVerb = (typeof GUARD_VERBS)[number];
|
|
73
|
+
const isGuardVerb = (w: string): w is GuardVerb => (GUARD_VERBS as readonly string[]).includes(w);
|
|
74
|
+
|
|
75
|
+
/** ` ```flow-guard ` body: `<verb> <pattern> :: <why>` — deny/confirm carry a JS regex source,
|
|
76
|
+
* protect/forbid a repo-relative path. ` :: ` (whitespace before the colons) is the separator so a
|
|
77
|
+
* regex like `pr::list` survives; a bad regex is a hard finding so `flow init --write` refuses it. */
|
|
78
|
+
function parseGuardRules(entries: { line: string; where: string }[], stage: string | null, p: ParsedMd): void {
|
|
79
|
+
if (stage !== "global") {
|
|
80
|
+
p.warn.push(`${entries[0].where}: flow-guard rules belong to ## global — the block under ## ${stage ?? "(no stage)"} is ignored`);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
for (const { line, where } of entries) {
|
|
84
|
+
const m = line.match(/^(\w+)\s+(.*?)\s*::\s*(.*?)\s*$/);
|
|
85
|
+
if (!m) {
|
|
86
|
+
const part = line.match(/^(\w+)\s+/);
|
|
87
|
+
p.hard.push(`${where}: flow-guard rule needs \` :: <why>\`${part ? ` (no separator after \`${part[1]}\`)` : ""}`);
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
const [, verb, pattern, why] = m;
|
|
91
|
+
if (!isGuardVerb(verb)) {
|
|
92
|
+
p.warn.push(`${where}: unknown flow-guard verb "${verb}" — expected deny, confirm, protect or forbid — ignored`);
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
if (!pattern) {
|
|
96
|
+
p.hard.push(`${where}: flow-guard ${verb} rule has no pattern`);
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
if (verb === "deny" || verb === "confirm") {
|
|
100
|
+
try {
|
|
101
|
+
new RegExp(pattern);
|
|
102
|
+
} catch (e) {
|
|
103
|
+
p.hard.push(`${where}: flow-guard ${verb} pattern is not a valid regular expression: ${(e as Error).message}`);
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
if (verb === "protect" || verb === "forbid") {
|
|
108
|
+
// only a basename glob is a sibling set; a glob mid-path names one literal file and guards nothing —
|
|
109
|
+
// the same silent no-op as an invalid regex, so it gets the same loud treatment (#87).
|
|
110
|
+
// The rule is kept: the operator hears it is a no-op and fixes the file, and doctor re-reads it.
|
|
111
|
+
if (pattern.split("/").slice(0, -1).some((s) => /[*?]/.test(s))) {
|
|
112
|
+
p.warn.push(`${where}: flow-guard ${verb} pattern globs outside its last segment — only a basename glob (docker/.env*) matches; as written this names one literal path and guards nothing`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
p.guard[verb].push({ pattern, why });
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function parseProjectMd(text: string): ParsedMd {
|
|
120
|
+
const p: ParsedMd = { hard: [], warn: [], global: {}, flow: Object.fromEntries(MD_STAGES.map((s) => [s, {}])) as Record<MdStage, Record<string, string>>, checks: [], guard: structuredClone(DEFAULTS.guard), checklist: {}, prose: {} };
|
|
121
|
+
let stage: string | null = null;
|
|
122
|
+
const prose: Record<string, string[]> = {};
|
|
123
|
+
let inFence: string | null = null; // "flow" | "flow-check" | other lang
|
|
124
|
+
let checkName = "";
|
|
125
|
+
let checkBody: string[] = [];
|
|
126
|
+
let guardBody: { line: string; where: string }[] = [];
|
|
127
|
+
let lastLine = ""; // for fence-start line numbers (1-based = index+1)
|
|
128
|
+
text.split("\n").forEach((line, i) => {
|
|
129
|
+
const where = `line ${i + 1}`;
|
|
130
|
+
if (inFence) {
|
|
131
|
+
if (line.trim() === "```") {
|
|
132
|
+
if (inFence === "flow-check") {
|
|
133
|
+
const cmd = checkBody.join("\n").trim();
|
|
134
|
+
if (!checkName) p.hard.push(`${where}: flow-check block missing a name`);
|
|
135
|
+
else if (!cmd) p.hard.push(`${lastLine}: flow-check "${checkName}" is empty`);
|
|
136
|
+
else if (stage !== "implement") p.warn.push(`${where}: flow-check "${checkName}" ignored (tier-1 checks belong to ## implement)`);
|
|
137
|
+
else p.checks.push({ name: checkName, cmd });
|
|
138
|
+
}
|
|
139
|
+
if (inFence === "flow-guard") {
|
|
140
|
+
if (guardBody.length) parseGuardRules(guardBody, stage, p);
|
|
141
|
+
guardBody = [];
|
|
142
|
+
}
|
|
143
|
+
inFence = null;
|
|
144
|
+
checkName = "";
|
|
145
|
+
checkBody = [];
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
if (inFence === "flow") {
|
|
149
|
+
const m = line.match(/^\s*([A-Za-z][\w.]*)\s*=\s*(.+?)\s*$/);
|
|
150
|
+
if (!m || !stage) {
|
|
151
|
+
if (line.trim() && !line.trim().startsWith("#")) p.hard.push(`${where}: expected \`key = value\` inside flow block`);
|
|
152
|
+
} else if (!MD_STAGES.includes(stage as MdStage)) {
|
|
153
|
+
/* unknown stage already warned */
|
|
154
|
+
} else {
|
|
155
|
+
const mapped = KEYMAP[stage][m[1]];
|
|
156
|
+
if (!mapped) p.warn.push(`${where}: unknown key "${m[1]}" in ## ${stage} — ignored`);
|
|
157
|
+
else {
|
|
158
|
+
if (mapped === "release.releases" && m[1].endsWith("github")) p.warn.push(`${where}: key "${m[1]}" is renamed "releases" — rename it`);
|
|
159
|
+
// the only enum-valued key: a junk mode must not silently mean "block every shell call"
|
|
160
|
+
if (m[1] === "bashTimeout" && !/^(require|inject)$/i.test(m[2].trim())) {
|
|
161
|
+
p.warn.push(`${where}: bashTimeout = "${m[2]}" — expected require or inject, keeping require`);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
p.flow[stage as MdStage][mapped] = m[2];
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
} else if (inFence === "flow-check") checkBody.push(line);
|
|
168
|
+
else if (inFence === "flow-guard") guardBody.push({ line, where });
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
lastLine = where;
|
|
172
|
+
const fence = line.match(/^```\s*(.*)$/);
|
|
173
|
+
if (fence) {
|
|
174
|
+
const lang = fence[1].trim();
|
|
175
|
+
if (lang === "flow") inFence = "flow";
|
|
176
|
+
else if (lang.startsWith("flow-check")) {
|
|
177
|
+
inFence = "flow-check";
|
|
178
|
+
checkName = lang.slice("flow-check".length).trim();
|
|
179
|
+
} else if (lang === "flow-guard") inFence = "flow-guard";
|
|
180
|
+
else inFence = lang || "code";
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
const head = line.match(/^##\s+(\S+)\s*$/);
|
|
184
|
+
if (head) {
|
|
185
|
+
stage = head[1].toLowerCase();
|
|
186
|
+
if (!MD_STAGES.includes(stage as MdStage)) p.warn.push(`${where}: unknown stage "${stage}" — its content is kept as inert prose`);
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
if (!stage || stage === "global" || !MD_STAGES.includes(stage as MdStage)) return; // preamble/unknown → not injected
|
|
190
|
+
const item = line.match(CHECK_ITEM);
|
|
191
|
+
if (item) {
|
|
192
|
+
const rawItem = item[1].trim();
|
|
193
|
+
const uiOnly = rawItem.startsWith("(ui) ");
|
|
194
|
+
(p.checklist[stage] ??= []).push({ text: uiOnly ? rawItem.slice(5).trim() : rawItem, uiOnly });
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
if (/^(#|```|<!--|\s*$)/.test(line) && !line.startsWith("### ")) return; // h1/html comments/blank are not prose
|
|
198
|
+
(prose[stage] ??= []).push(line.trim());
|
|
199
|
+
});
|
|
200
|
+
if (inFence) p.hard.push(`unterminated \`\`\`${inFence === "code" ? "" : inFence} block`);
|
|
201
|
+
for (const [s, lines] of Object.entries(prose)) p.prose[s] = lines.join(" ").trim();
|
|
202
|
+
for (const s of MD_STAGES) if (p.prose[s] === undefined) p.prose[s] = "";
|
|
203
|
+
return p;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Fold parsed project.md onto DEFAULTS — project.md is the only persisted config source. */
|
|
207
|
+
export function mdToConfig(p: ParsedMd): Config {
|
|
208
|
+
const c: Config = structuredClone(DEFAULTS);
|
|
209
|
+
const raw: Record<string, unknown> = {};
|
|
210
|
+
for (const stage of MD_STAGES) for (const [key, val] of Object.entries(p.flow[stage])) applyAnswer(raw, key, val);
|
|
211
|
+
// boolean/key normalization identical to project.json semantics
|
|
212
|
+
if (typeof raw.mainHook === "string") raw.mainHook = /^(yes|true)$/i.test(raw.mainHook);
|
|
213
|
+
if (typeof raw.mockupGlobs === "string") raw.mockupGlobs = String(raw.mockupGlobs).split(",").map((s) => s.trim()).filter(Boolean);
|
|
214
|
+
// flow keys were mapped to dot paths (commands.x, versioning.y, release.z) — rebuild nested
|
|
215
|
+
const nested: Record<string, unknown> = {};
|
|
216
|
+
for (const [k, v] of Object.entries(raw)) applyAnswer(nested, k, v as string);
|
|
217
|
+
const rel = (nested as { release?: { releases?: unknown } }).release;
|
|
218
|
+
if (rel && typeof rel.releases === "string") rel.releases = !/^(no|false)$/i.test(rel.releases);
|
|
219
|
+
mergeInto(c, nested);
|
|
220
|
+
// a flow-guard block is the whole rule set; with none in project.md the DEFAULTS (empty) stand
|
|
221
|
+
if (GUARD_VERBS.some((v) => p.guard[v].length)) c.guard = structuredClone(p.guard);
|
|
222
|
+
c.checkList = [
|
|
223
|
+
...(c.commands.test ? [{ name: "test", cmd: c.commands.test }] : []),
|
|
224
|
+
...(c.commands.health ? [{ name: "health", cmd: c.commands.health }] : []),
|
|
225
|
+
...p.checks.map((x) => ({ name: x.name, cmd: x.cmd })),
|
|
226
|
+
];
|
|
227
|
+
c.checklist = p.checklist;
|
|
228
|
+
c.prose = p.prose;
|
|
229
|
+
return c;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function mergeInto(c: Config, r: Record<string, unknown>): void {
|
|
233
|
+
if (typeof r.defaultBranch === "string") c.defaultBranch = r.defaultBranch;
|
|
234
|
+
if (typeof r.branchPattern === "string") c.branchPattern = r.branchPattern;
|
|
235
|
+
if (typeof r.worktreeDir === "string") c.worktreeDir = r.worktreeDir;
|
|
236
|
+
if (typeof r.mainHook === "boolean") c.mainHook = r.mainHook;
|
|
237
|
+
if (Array.isArray(r.mockupGlobs)) c.mockupGlobs = r.mockupGlobs.map(String);
|
|
238
|
+
if (typeof r.forge === "string") c.forge = r.forge;
|
|
239
|
+
if (r.bashTimeout === "require" || r.bashTimeout === "inject") c.bashTimeout = r.bashTimeout;
|
|
240
|
+
if (typeof r.commands === "object" && r.commands) Object.assign(c.commands, r.commands);
|
|
241
|
+
if (typeof r.versioning === "object" && r.versioning) Object.assign(c.versioning, r.versioning);
|
|
242
|
+
if (typeof r.release === "object" && r.release) Object.assign(c.release, r.release);
|
|
243
|
+
// playbook values arrive as strings; a junk healthWait must not make the gate wait forever
|
|
244
|
+
const wait = toSeconds(r.healthWait);
|
|
245
|
+
if (wait !== undefined) c.healthWait = wait;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Load the project config: project.md alone — the legacy project.json is no longer read. */
|
|
249
|
+
export function loadProject(dfDir: string): { cfg: Config; md: ParsedMd | null } {
|
|
250
|
+
const mdFile = path.join(dfDir, "project.md");
|
|
251
|
+
let mdText: string | null = null;
|
|
252
|
+
try {
|
|
253
|
+
if (existsSync(mdFile)) mdText = readFileSync(mdFile, "utf8");
|
|
254
|
+
} catch {
|
|
255
|
+
mdText = null;
|
|
256
|
+
}
|
|
257
|
+
if (mdText === null) return { cfg: structuredClone(DEFAULTS), md: null };
|
|
258
|
+
const md = parseProjectMd(mdText);
|
|
259
|
+
return { cfg: mdToConfig(md), md };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const safeJson = (s: string): unknown => {
|
|
263
|
+
try {
|
|
264
|
+
return JSON.parse(s);
|
|
265
|
+
} catch {
|
|
266
|
+
return null;
|
|
267
|
+
}
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
/** `.dev-flow/<slug>/` directories holding a plan/ with no state.json — a closed or aborted feature
|
|
271
|
+
* whose directory outlived its flow state (a hand-close from before auto-close, an interrupted
|
|
272
|
+
* start). Nothing reads them: `flow status`, the guards and auto-bind all key off state.json.
|
|
273
|
+
* Root-relative paths, for the doctor report and the operator-confirmed remover. */
|
|
274
|
+
export function ghostFeatureDirs(root: string): string[] {
|
|
275
|
+
const df = path.join(root, ".dev-flow");
|
|
276
|
+
if (!existsSync(df)) return [];
|
|
277
|
+
return readdirSync(df, { withFileTypes: true })
|
|
278
|
+
.filter((d) => d.isDirectory() && existsSync(path.join(df, d.name, "plan")) && !existsSync(path.join(df, d.name, "state.json")))
|
|
279
|
+
.map((d) => `.dev-flow/${d.name}`);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Doctor: cheap file-level probes only — NEVER runs user commands.
|
|
284
|
+
* Findings are advisory strings for `flow init` (fixer) and the status flag.
|
|
285
|
+
*/
|
|
286
|
+
export function doctorFindings(root: string, cfg: Config, md: ParsedMd | null): string[] {
|
|
287
|
+
const f: string[] = [];
|
|
288
|
+
if (existsSync(path.join(root, ".dev-flow", "project.json"))) f.push(".dev-flow/project.json is no longer read — fold its values into project.md and delete it");
|
|
289
|
+
const gi = path.join(root, ".dev-flow", ".gitignore");
|
|
290
|
+
if (existsSync(gi) && !readFileSync(gi, "utf8").includes("!project.md")) f.push(".dev-flow/.gitignore still hides project.md from git — re-run flow init to heal it");
|
|
291
|
+
if (cfg.forge && !["github", "gitlab", "self"].includes(cfg.forge)) f.push(`forge "${cfg.forge}" is invalid — use github, gitlab or self`);
|
|
292
|
+
if (cfg.versioning.file) {
|
|
293
|
+
const mf = path.join(root, cfg.versioning.file);
|
|
294
|
+
if (!existsSync(mf)) f.push(`versioning.file "${cfg.versioning.file}" does not exist`);
|
|
295
|
+
else if (!parseVersion(cfg.versioning.file, readFileSync(mf, "utf8"))) f.push(`versioning.file "${cfg.versioning.file}": no parsable version found`);
|
|
296
|
+
}
|
|
297
|
+
// drift: `npm|pnpm|yarn|bun run <script>` where the script no longer exists
|
|
298
|
+
let pkgScripts: Record<string, string> | null = null;
|
|
299
|
+
const pkgRaw = existsSync(path.join(root, "package.json")) ? safeJson(readFileSync(path.join(root, "package.json"), "utf8")) : null;
|
|
300
|
+
if (pkgRaw && typeof pkgRaw === "object" && typeof (pkgRaw as { scripts?: unknown }).scripts === "object") pkgScripts = (pkgRaw as { scripts: Record<string, string> }).scripts;
|
|
301
|
+
const cmdList = [...Object.entries(cfg.commands).map(([k, v]) => [`commands.${k}`, v!] as [string, string]), ...cfg.checkList.map((c) => [`check ${c.name}`, c.cmd] as [string, string])];
|
|
302
|
+
for (const [label, cmd] of cmdList) {
|
|
303
|
+
const run = cmd.match(/\b(?:npm|pnpm|yarn|bun)\s+run\s+([\w:.-]+)/);
|
|
304
|
+
if (run && pkgScripts && !(run[1] in pkgScripts)) f.push(`${label} runs "run ${run[1]}" but package.json has no such script`);
|
|
305
|
+
for (const tok of cmd.split(/\s+/)) {
|
|
306
|
+
// only root-relative paths are probeable — checks run in the worktree, where `../../docker/.env` is correct
|
|
307
|
+
if (/^[\w][\w-]*\/[\w./-]+$/.test(tok) && /\.\w+$/.test(tok) && !existsSync(path.join(root, tok))) {
|
|
308
|
+
f.push(`${label} references missing file ${tok}`);
|
|
309
|
+
break;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
if (md) f.push(...md.hard.map((h) => `project.md: ${h}`), ...md.warn.map((w) => `project.md: ${w}`));
|
|
314
|
+
if (cfg.commands.run && !cfg.checkList.some((c) => c.name === "health")) f.push('run command configured but no tier-1 health check — add commands.health or a flow-check so "running and healthy" is enforced');
|
|
315
|
+
if (cfg.commands.dev && !healthProbe(cfg)) f.push("dev deploy configured but no health check — flow verify cannot prove the deploy came back serving this build: add commands.health or a flow-check health block");
|
|
316
|
+
if (!cfg.checkList.length) f.push("implement tier-1 gates nothing — no test, no health, no flow-check block: 'flow check' would pass vacuously");
|
|
317
|
+
for (const g of ghostFeatureDirs(root)) f.push(`${g} has a plan/ but no state.json — ghost of a closed or aborted feature (flow init offers to remove it)`);
|
|
318
|
+
// dev-flow must load its own working code in its own checkout: the committed .pi shim is the only thing that guarantees it
|
|
319
|
+
if (pkgRaw && (pkgRaw as { name?: unknown }).name === "pi-dev-flow") {
|
|
320
|
+
const shim = path.join(root, ".pi", "extensions", "dev-flow.ts");
|
|
321
|
+
if (!existsSync(shim) || !readFileSync(shim, "utf8").includes("../../extensions/dev-flow.ts"))
|
|
322
|
+
f.push("dogfood shim .pi/extensions/dev-flow.ts is missing or does not re-export ../../extensions/dev-flow.ts — sessions in this checkout will not load the current code");
|
|
323
|
+
}
|
|
324
|
+
return f;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** Compact per-stage digest of project.md content for dialog display (raw md walls are unreadable). */
|
|
328
|
+
const DIGEST_STAGES: Record<string, string> = { global: "General", plan: "Planning", implement: "Implement", "pre-release": "Pre-release", release: "Release" };
|
|
329
|
+
const DIGEST_CMDS: Record<string, string> = { setup: "setup", test: "test", build: "build", run: "run app", seedData: "seed data", dev: "dev deploy" };
|
|
330
|
+
const DIGEST_WORDS: Record<string, string> = { defaultBranch: "default branch", branchPattern: "branch pattern", worktreeDir: "worktree dir", mainHook: "main-branch hook", mockupGlobs: "mockup files", forge: "forge", bashTimeout: "shell-call timeout mode" };
|
|
331
|
+
|
|
332
|
+
/** Human summary of a playbook for the init confirm: one line per stage, real values and full item/guidance text, nothing truncated. */
|
|
333
|
+
export function mdDigest(text: string): string {
|
|
334
|
+
const p = parseProjectMd(text);
|
|
335
|
+
const rows: string[] = [];
|
|
336
|
+
for (const s of MD_STAGES) {
|
|
337
|
+
const label = DIGEST_STAGES[s];
|
|
338
|
+
if (!label) continue;
|
|
339
|
+
// pre-release owns release preparation and renders exactly like release — buildDraft writes the
|
|
340
|
+
// version keys there, so without this the init dialog never showed the version rules at all.
|
|
341
|
+
const relStage = s === "release" || s === "pre-release";
|
|
342
|
+
const bits: string[] = [];
|
|
343
|
+
const keys: Record<string, string> = {};
|
|
344
|
+
for (const [k, v] of Object.entries(p.flow[s] as Record<string, string>)) keys[k.split(".").pop()!] = v; // config dot-paths → display keys
|
|
345
|
+
for (const [k, v] of Object.entries(keys)) {
|
|
346
|
+
if (relStage && ["file", "pattern", "github", "artifact"].includes(k)) continue; // rendered specially below
|
|
347
|
+
bits.push(DIGEST_CMDS[k] ? `${DIGEST_CMDS[k]}: ${JSON.stringify(v)}` : `${DIGEST_WORDS[k] ?? k}: ${v}`);
|
|
348
|
+
}
|
|
349
|
+
if (s === "implement" && p.checks.length) bits.push(`named checks: ${p.checks.map((c) => c.name).join(", ")}`);
|
|
350
|
+
if (relStage && keys.file) {
|
|
351
|
+
bits.push(`version in ${keys.file} → tag ${keys.pattern ?? "v{version}"}`);
|
|
352
|
+
bits.push(keys.releases === "false" || keys.releases === "no" ? "forge release: off" : "forge release: created automatically (gh/glab)");
|
|
353
|
+
bits.push(`artifact check: ${keys.artifact ?? "none set"}`);
|
|
354
|
+
}
|
|
355
|
+
const items = p.checklist[s] ?? [];
|
|
356
|
+
if (items.length) bits.push(`manual check: ${items.map((i) => `${i.uiOnly ? "(UI only) " : ""}\"${i.text}\"`).join(" · ")}`);
|
|
357
|
+
if (p.prose[s]) bits.push(`guidance: "${p.prose[s].replace(/\s+/g, " ").trim()}"`);
|
|
358
|
+
if (bits.length) rows.push(`${label} — ${bits.join(" · ")}`);
|
|
359
|
+
}
|
|
360
|
+
return `${rows.join("\n") || "(empty)"}\n\nnothing is executed or uploaded now — gates only run later inside the flow`;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** Draft project.md: fresh = pure detection (first init, --force); otherwise a refresh keeping configured values. */
|
|
364
|
+
export function buildDraft(det: { report: string[]; partial: { defaultBranch: string; commands: Record<string, string>; versioning?: { file: string }; forge?: string } }, cfg?: Config): string {
|
|
365
|
+
const fresh = cfg === undefined;
|
|
366
|
+
const c = cfg ?? structuredClone(DEFAULTS);
|
|
367
|
+
const cmd = (k: string) => (fresh ? undefined : (c.commands as Record<string, string>)[k]) || det.partial.commands[k];
|
|
368
|
+
const branch = fresh ? det.partial.defaultBranch : c.defaultBranch;
|
|
369
|
+
const verFile = (fresh ? undefined : c.versioning.file) || det.partial.versioning?.file || "package.json";
|
|
370
|
+
const L: string[] = [];
|
|
371
|
+
L.push("# dev-flow", "", "<!-- Operator-owned playbook. Regenerate with: flow init --draft -->", "");
|
|
372
|
+
L.push("## global", "", "```flow", `defaultBranch = ${branch}`);
|
|
373
|
+
if (det.partial.forge) L.push(`forge = ${det.partial.forge}`); // from the origin URL — confirm or correct
|
|
374
|
+
L.push("```", "");
|
|
375
|
+
L.push("## plan", "", "- [ ] rendered mockups attached for every changed screen", "", "<!-- what the plan must contain, UI expectations -->", "");
|
|
376
|
+
L.push("## implement", "", "```flow");
|
|
377
|
+
for (const k of ["setup", "test", "build", "run", "seedData", "dev"]) if (cmd(k)) L.push(`${k} = ${cmd(k)}`);
|
|
378
|
+
// a hand-set wait budget is a real setting: a refresh that dropped it would silently make the probe single-shot again
|
|
379
|
+
if (!fresh && c.healthWait !== DEFAULTS.healthWait) L.push(`healthWait = ${c.healthWait}`);
|
|
380
|
+
L.push("```", "");
|
|
381
|
+
const healthCmd = cmd("health") || (fresh ? undefined : c.checkList.find((x) => x.name === "health")?.cmd);
|
|
382
|
+
// never invent a health check: a placeholder that can't pass blocks verify forever (dogfooded: curl localhost:3000 in a library repo)
|
|
383
|
+
if (healthCmd) L.push("```flow-check health", healthCmd, "```", "");
|
|
384
|
+
else L.push("<!-- optional tier-1: add a flow-check 'health' block once the app exposes a health endpoint -->", "");
|
|
385
|
+
// carry every other explicit check through a refresh, or regeneration would silently drop teeth
|
|
386
|
+
for (const x of fresh ? [] : c.checkList) {
|
|
387
|
+
if (x.name === "test" || x.name === "health") continue; // implicit ones ride the flow key block / healthCmd above
|
|
388
|
+
L.push("```flow-check " + x.name, x.cmd, "```", "");
|
|
389
|
+
}
|
|
390
|
+
L.push("- [ ] app runs locally with real data; visual pass: no overflows or stray scrollbars", "", "<!-- tier-3: standing guidance for this stage -->", "");
|
|
391
|
+
L.push("## pre-release", "", "<!-- e.g. review policy, docs expectations, who picks the version -->", "", "```flow", `versionFile = ${verFile}`, "versionTag = v{version}", "```", "");
|
|
392
|
+
L.push("## release", "", "<!-- e.g. PR/merge/tag policy, who publishes artifacts -->", "");
|
|
393
|
+
return L.join("\n") + "\n";
|
|
394
|
+
}
|
package/src/timeouts.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic default timeouts for shell tool calls (bash-tool-timeouts).
|
|
3
|
+
*
|
|
4
|
+
* pi arms its kill timer only when a timeout is passed, and the model omits it
|
|
5
|
+
* in ~75% of calls — observed hangs ran for hours (interactive prompts, hung
|
|
6
|
+
* network). The class defaults below come from this host's session logs:
|
|
7
|
+
* 99% of no-timeout calls finish under ~70s (p99=66s), real builds under ~400s,
|
|
8
|
+
* so these ceilings sit at a comfortable multiple and a premature kill is
|
|
9
|
+
* self-correcting — the model sees `timeout:` and reruns with an explicit value.
|
|
10
|
+
*
|
|
11
|
+
* Pure module — no pi imports so it is unit-testable with `node --test`.
|
|
12
|
+
*/
|
|
13
|
+
import { bashSegments } from "./core.ts";
|
|
14
|
+
|
|
15
|
+
export const QUICK_S = 30;
|
|
16
|
+
export const LOCAL_GIT_S = 120;
|
|
17
|
+
export const NETWORK_S = 180;
|
|
18
|
+
export const SERVICE_S = 300;
|
|
19
|
+
export const BUILD_S = 900;
|
|
20
|
+
export const WATCH_S = 1200;
|
|
21
|
+
export const DEFAULT_S = 120;
|
|
22
|
+
export const CLAMP_S = 1800;
|
|
23
|
+
|
|
24
|
+
// heads that also appear as subcommand-sensitive tools are handled by name below
|
|
25
|
+
const QUICK = new Set(["ls", "cat", "head", "tail", "wc", "pwd", "which", "whereis", "echo", "printf", "true", "find", "rg", "grep", "fgrep", "egrep", "sort", "uniq", "cut", "awk", "sed", "jq", "stat", "file", "du", "df", "date", "uname", "id", "tree", "diff", "comm"]);
|
|
26
|
+
const NETWORK = new Set(["curl", "wget", "ssh", "scp", "rsync", "gh", "nc"]);
|
|
27
|
+
const SERVICE = new Set(["docker", "podman", "kubectl", "systemctl", "service", "journalctl", "helm"]);
|
|
28
|
+
const BUILD = new Set(["make", "cargo", "go", "tsc", "pytest", "gradle", "mvn", "cmake", "npm", "pnpm", "yarn", "bun", "pip", "pip3", "uv", "node", "flow"]);
|
|
29
|
+
const GIT_QUICK = new Set(["status", "log", "diff", "show", "branch", "rev-parse", "ls-files", "ls-tree", "remote", "describe", "blame", "shortlog", "config", "help", "version"]);
|
|
30
|
+
const GIT_NETWORK = new Set(["push", "pull", "fetch", "clone", "ls-remote", "submodule", "request-pull"]);
|
|
31
|
+
// wrappers/prefixes that never decide the class of a segment
|
|
32
|
+
const SKIP_HEADS = new Set(["cd", "export", "source", ".", "set", "unset", "if", "then", "else", "fi", "do", "done", "while", "for", "case", "esac", "{", "}", "(", "!", "test", "[[", "time", "nohup", "env", "command", "sudo"]);
|
|
33
|
+
|
|
34
|
+
/** Timeout class of one command segment; 0 = contributes nothing (wrapper like `cd x`). */
|
|
35
|
+
function segTimeout(seg: string): number {
|
|
36
|
+
// watchers run by design until done — they get their own ceiling no matter what head they carry
|
|
37
|
+
if (/\bgh\s+run\s+watch\b/.test(seg)) return WATCH_S;
|
|
38
|
+
if (/(logs?|tail|journalctl)/.test(seg) && /(^|\s)(-f|--follow)(\s|$)/.test(seg)) return WATCH_S;
|
|
39
|
+
if (/\bcompose\b.*\bup\b/.test(seg) && !/(^|\s)(-d|--detach)(\s|$)/.test(seg)) return WATCH_S;
|
|
40
|
+
const tok = seg.trim().split(/\s+/);
|
|
41
|
+
let i = 0;
|
|
42
|
+
while (i < tok.length && (/^[\w.]+=/.test(tok[i]) || SKIP_HEADS.has(tok[i]))) i++;
|
|
43
|
+
const base = (tok[i] ?? "").split("/").pop() ?? "";
|
|
44
|
+
if (SKIP_HEADS.has(base) || !base) return 0;
|
|
45
|
+
if (base === "git") {
|
|
46
|
+
const sub = tok[i + 1] ?? "";
|
|
47
|
+
if (GIT_QUICK.has(sub)) return QUICK_S;
|
|
48
|
+
if (GIT_NETWORK.has(sub)) return NETWORK_S;
|
|
49
|
+
return LOCAL_GIT_S;
|
|
50
|
+
}
|
|
51
|
+
if (NETWORK.has(base)) return NETWORK_S;
|
|
52
|
+
if (SERVICE.has(base)) return /\bbuild\b/.test(seg) ? BUILD_S : SERVICE_S; // docker/compose build is an image build, not a service poke
|
|
53
|
+
if (BUILD.has(base)) return BUILD_S;
|
|
54
|
+
if (QUICK.has(base)) return QUICK_S;
|
|
55
|
+
return DEFAULT_S;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Effective timeout (seconds) for a shell command: an explicit value is kept but
|
|
60
|
+
* clamped to CLAMP_S; otherwise the slowest class among the command's segments wins.
|
|
61
|
+
*/
|
|
62
|
+
export function bashTimeout(command: string, explicit?: number): number {
|
|
63
|
+
if (typeof explicit === "number" && Number.isFinite(explicit) && explicit > 0) return Math.min(explicit, CLAMP_S);
|
|
64
|
+
let t = 0;
|
|
65
|
+
for (const seg of bashSegments(command)) t = Math.max(t, segTimeout(seg));
|
|
66
|
+
return t || DEFAULT_S;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* `bashTimeout = require` (the default): the model must state the timeout itself, because the
|
|
71
|
+
* injected value never reaches the transcript — the tool call entry records the arguments the model
|
|
72
|
+
* emitted, and the handler mutates them afterwards. Returns the refusal, or undefined when the call
|
|
73
|
+
* already carries an explicit timeout. The refused call is not lost: the reason names the exact
|
|
74
|
+
* value the class table would have armed, so the retry is a one-token fix.
|
|
75
|
+
*/
|
|
76
|
+
export function requireTimeout(command: string, explicit?: number): string | undefined {
|
|
77
|
+
if (typeof explicit === "number" && Number.isFinite(explicit) && explicit > 0) return undefined;
|
|
78
|
+
return `pass an explicit timeout on shell calls — the class default here is ${bashTimeout(command)}s (it is armed silently either way; saying it makes it visible in the transcript)`;
|
|
79
|
+
}
|