@gr8ful/spf 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (153) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +321 -0
  3. package/assets/defaults/spf.config.yaml +141 -0
  4. package/assets/prompts/builder/system.md +13 -0
  5. package/assets/prompts/builder/user.md +34 -0
  6. package/assets/prompts/documenter/system.md +17 -0
  7. package/assets/prompts/documenter/user.md +48 -0
  8. package/assets/prompts/planner/system.md +21 -0
  9. package/assets/prompts/planner/user.md +45 -0
  10. package/assets/prompts/reviewer/system.md +16 -0
  11. package/assets/prompts/reviewer/user.md +44 -0
  12. package/assets/prompts/scout/system.md +20 -0
  13. package/assets/prompts/scout/user.md +34 -0
  14. package/assets/skill/SKILL.md +80 -0
  15. package/assets/skill/cookbooks/authoring_chains.md +193 -0
  16. package/assets/skill/cookbooks/how_to_prompt_for_the_eng.md +109 -0
  17. package/assets/skill/cookbooks/roster.md +197 -0
  18. package/assets/skill/cookbooks/run_adw.md +92 -0
  19. package/assets/skill/cookbooks/spf_overview.md +111 -0
  20. package/assets/skill/references/config.md +188 -0
  21. package/assets/skill/references/handoff.md +162 -0
  22. package/assets/skill/references/observability.md +184 -0
  23. package/dist/chains/adw_build.d.ts +12 -0
  24. package/dist/chains/adw_build.js +27 -0
  25. package/dist/chains/adw_build_review.d.ts +21 -0
  26. package/dist/chains/adw_build_review.js +55 -0
  27. package/dist/chains/adw_build_test.d.ts +21 -0
  28. package/dist/chains/adw_build_test.js +67 -0
  29. package/dist/chains/adw_document.d.ts +23 -0
  30. package/dist/chains/adw_document.js +59 -0
  31. package/dist/chains/adw_plan.d.ts +12 -0
  32. package/dist/chains/adw_plan.js +27 -0
  33. package/dist/chains/adw_plan_build.d.ts +12 -0
  34. package/dist/chains/adw_plan_build.js +30 -0
  35. package/dist/chains/adw_plan_build_test.d.ts +16 -0
  36. package/dist/chains/adw_plan_build_test.js +65 -0
  37. package/dist/chains/adw_plan_build_test_quality.d.ts +18 -0
  38. package/dist/chains/adw_plan_build_test_quality.js +66 -0
  39. package/dist/chains/adw_prompt.d.ts +12 -0
  40. package/dist/chains/adw_prompt.js +25 -0
  41. package/dist/chains/adw_quality.d.ts +12 -0
  42. package/dist/chains/adw_quality.js +32 -0
  43. package/dist/chains/adw_scout.d.ts +12 -0
  44. package/dist/chains/adw_scout.js +27 -0
  45. package/dist/chains/adw_simple_sdlc.d.ts +43 -0
  46. package/dist/chains/adw_simple_sdlc.js +147 -0
  47. package/dist/chains/context.d.ts +19 -0
  48. package/dist/chains/context.js +1 -0
  49. package/dist/chains/index.d.ts +19 -0
  50. package/dist/chains/index.js +116 -0
  51. package/dist/cli/bin.d.ts +15 -0
  52. package/dist/cli/bin.js +24 -0
  53. package/dist/cli/commands/abort.d.ts +1 -0
  54. package/dist/cli/commands/abort.js +42 -0
  55. package/dist/cli/commands/doctor.d.ts +1 -0
  56. package/dist/cli/commands/doctor.js +165 -0
  57. package/dist/cli/commands/eject.d.ts +1 -0
  58. package/dist/cli/commands/eject.js +55 -0
  59. package/dist/cli/commands/events.d.ts +1 -0
  60. package/dist/cli/commands/events.js +49 -0
  61. package/dist/cli/commands/init.d.ts +1 -0
  62. package/dist/cli/commands/init.js +62 -0
  63. package/dist/cli/commands/install-skill.d.ts +1 -0
  64. package/dist/cli/commands/install-skill.js +122 -0
  65. package/dist/cli/commands/list.d.ts +1 -0
  66. package/dist/cli/commands/list.js +13 -0
  67. package/dist/cli/commands/migrate.d.ts +1 -0
  68. package/dist/cli/commands/migrate.js +167 -0
  69. package/dist/cli/commands/phases.d.ts +1 -0
  70. package/dist/cli/commands/phases.js +25 -0
  71. package/dist/cli/commands/run.d.ts +3 -0
  72. package/dist/cli/commands/run.js +27 -0
  73. package/dist/cli/commands/sessions.d.ts +1 -0
  74. package/dist/cli/commands/sessions.js +20 -0
  75. package/dist/cli/commands/trace.d.ts +8 -0
  76. package/dist/cli/commands/trace.js +10 -0
  77. package/dist/cli/commands/ui.d.ts +1 -0
  78. package/dist/cli/commands/ui.js +35 -0
  79. package/dist/cli/commands/version.d.ts +1 -0
  80. package/dist/cli/commands/version.js +8 -0
  81. package/dist/cli/commands/watch.d.ts +8 -0
  82. package/dist/cli/commands/watch.js +210 -0
  83. package/dist/cli/gitignore.d.ts +1 -0
  84. package/dist/cli/gitignore.js +14 -0
  85. package/dist/cli/index.d.ts +1 -0
  86. package/dist/cli/index.js +156 -0
  87. package/dist/core/agent_cc.d.ts +75 -0
  88. package/dist/core/agent_cc.js +322 -0
  89. package/dist/core/agent_flue.d.ts +66 -0
  90. package/dist/core/agent_flue.js +321 -0
  91. package/dist/core/agents.d.ts +64 -0
  92. package/dist/core/agents.js +456 -0
  93. package/dist/core/changes.d.ts +35 -0
  94. package/dist/core/changes.js +98 -0
  95. package/dist/core/console.d.ts +36 -0
  96. package/dist/core/console.js +156 -0
  97. package/dist/core/data_types.d.ts +562 -0
  98. package/dist/core/data_types.js +382 -0
  99. package/dist/core/gates.d.ts +33 -0
  100. package/dist/core/gates.js +144 -0
  101. package/dist/core/git_helper.d.ts +59 -0
  102. package/dist/core/git_helper.js +115 -0
  103. package/dist/core/issues/github_provider.d.ts +52 -0
  104. package/dist/core/issues/github_provider.js +211 -0
  105. package/dist/core/issues/provider.d.ts +90 -0
  106. package/dist/core/issues/provider.js +14 -0
  107. package/dist/core/paths.d.ts +78 -0
  108. package/dist/core/paths.js +108 -0
  109. package/dist/core/permissions.d.ts +78 -0
  110. package/dist/core/permissions.js +187 -0
  111. package/dist/core/prompts.d.ts +4 -0
  112. package/dist/core/prompts.js +17 -0
  113. package/dist/core/quality.d.ts +65 -0
  114. package/dist/core/quality.js +194 -0
  115. package/dist/core/runner.d.ts +72 -0
  116. package/dist/core/runner.js +168 -0
  117. package/dist/core/session.d.ts +16 -0
  118. package/dist/core/session.js +60 -0
  119. package/dist/core/sqlite.d.ts +55 -0
  120. package/dist/core/sqlite.js +106 -0
  121. package/dist/core/tracer.d.ts +56 -0
  122. package/dist/core/tracer.js +246 -0
  123. package/dist/core/utils.d.ts +40 -0
  124. package/dist/core/utils.js +108 -0
  125. package/dist/core/watch.d.ts +44 -0
  126. package/dist/core/watch.js +194 -0
  127. package/dist/test/agent_cc.test.d.ts +1 -0
  128. package/dist/test/agent_cc.test.js +95 -0
  129. package/dist/test/agent_flue.test.d.ts +1 -0
  130. package/dist/test/agent_flue.test.js +83 -0
  131. package/dist/test/data_types.test.d.ts +10 -0
  132. package/dist/test/data_types.test.js +49 -0
  133. package/dist/test/ui_server.test.d.ts +1 -0
  134. package/dist/test/ui_server.test.js +119 -0
  135. package/dist/test/watch.test.d.ts +1 -0
  136. package/dist/test/watch.test.js +227 -0
  137. package/dist/ui/server/app.d.ts +3 -0
  138. package/dist/ui/server/app.js +98 -0
  139. package/dist/ui/server/db.d.ts +82 -0
  140. package/dist/ui/server/db.js +333 -0
  141. package/dist/ui/server/serve.d.ts +13 -0
  142. package/dist/ui/server/serve.js +80 -0
  143. package/dist/ui/server/static.d.ts +5 -0
  144. package/dist/ui/server/static.js +55 -0
  145. package/dist/ui/shared/types.d.ts +263 -0
  146. package/dist/ui/shared/types.js +8 -0
  147. package/package.json +52 -0
  148. package/web/assets/index-C7nF068F.css +1 -0
  149. package/web/assets/index-mzSArcnQ.js +11 -0
  150. package/web/assets/play-latin-400-normal-GKW-4YV7.woff2 +0 -0
  151. package/web/assets/play-latin-700-normal-DyPlLDbb.woff2 +0 -0
  152. package/web/index.html +14 -0
  153. package/web/logo.svg +6 -0
@@ -0,0 +1,246 @@
1
+ /**
2
+ * Tracer: every event lands in JSONL and SQLite AS IT HAPPENS.
3
+ *
4
+ * Files are the raw record; spf.db is the queryable mirror the UI polls.
5
+ * No push transport — the flow is always: agents -> sqlite -> web ui.
6
+ * WAL mode so the UI can read while ADW processes write.
7
+ */
8
+ import { Database } from "./sqlite.js";
9
+ import { appendFileSync, mkdirSync } from "node:fs";
10
+ import path from "node:path";
11
+ import { newId, nowIso } from "./utils.js";
12
+ const SCHEMA = `
13
+ CREATE TABLE IF NOT EXISTS sessions (
14
+ adw_id TEXT PRIMARY KEY,
15
+ adw_name TEXT, -- ADW script(s) run, e.g. "adw_plan + adw_build_test"
16
+ request TEXT,
17
+ status TEXT,
18
+ engineer TEXT,
19
+ started_at TEXT, ended_at TEXT,
20
+ total_tokens INTEGER DEFAULT 0, total_cost REAL DEFAULT 0,
21
+ archived INTEGER DEFAULT 0 -- review triage, set by the UI; never by a run
22
+ );
23
+ CREATE TABLE IF NOT EXISTS phases (
24
+ phase_id TEXT PRIMARY KEY,
25
+ adw_id TEXT REFERENCES sessions,
26
+ seq INTEGER,
27
+ name TEXT, kind TEXT, owner TEXT, description TEXT,
28
+ status TEXT DEFAULT 'fail',
29
+ attempt INTEGER DEFAULT 0, retries INTEGER DEFAULT 0,
30
+ error TEXT,
31
+ started_at TEXT, ended_at TEXT
32
+ );
33
+ CREATE TABLE IF NOT EXISTS events (
34
+ event_id TEXT PRIMARY KEY,
35
+ adw_id TEXT REFERENCES sessions,
36
+ phase_id TEXT REFERENCES phases,
37
+ parent_id TEXT,
38
+ type TEXT,
39
+ name TEXT,
40
+ payload_json TEXT,
41
+ tokens INTEGER,
42
+ started_at TEXT, ended_at TEXT
43
+ );
44
+ CREATE TABLE IF NOT EXISTS envelopes (
45
+ envelope_id TEXT PRIMARY KEY,
46
+ adw_id TEXT REFERENCES sessions,
47
+ phase_id TEXT REFERENCES phases,
48
+ agent TEXT,
49
+ output_type TEXT,
50
+ payload_json TEXT,
51
+ valid INTEGER,
52
+ attempt INTEGER,
53
+ created_at TEXT
54
+ );
55
+ CREATE TABLE IF NOT EXISTS gate_results (
56
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
57
+ adw_id TEXT REFERENCES sessions,
58
+ phase_id TEXT REFERENCES phases,
59
+ attempt INTEGER,
60
+ gate TEXT,
61
+ passed INTEGER,
62
+ violations_json TEXT,
63
+ checks_json TEXT, -- [{item, ok, note}] — WHAT the gate verified
64
+ created_at TEXT
65
+ );
66
+ CREATE TABLE IF NOT EXISTS processes (
67
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
68
+ adw_id TEXT REFERENCES sessions,
69
+ kind TEXT, -- 'adw' (the workflow process) | 'agent' (a coding-agent child)
70
+ name TEXT, -- '' for the adw, the agent name for a child
71
+ pid INTEGER,
72
+ command TEXT, -- what the pid was, so a recycled pid is not killed by mistake
73
+ started_at TEXT, ended_at TEXT -- ended_at NULL = believed alive
74
+ );
75
+ CREATE TABLE IF NOT EXISTS agent_sessions (
76
+ adw_id TEXT REFERENCES sessions,
77
+ agent TEXT,
78
+ coding_agent TEXT, model TEXT, color TEXT,
79
+ session_id TEXT,
80
+ context_tokens INTEGER, -- window occupancy after the agent's last turn
81
+ context_window INTEGER, -- the model's ceiling; 0/NULL = unknown
82
+ created_at TEXT, last_used_at TEXT,
83
+ PRIMARY KEY (adw_id, agent)
84
+ );
85
+ `;
86
+ // Columns added after a schema shipped. CREATE TABLE IF NOT EXISTS never
87
+ // revisits an existing table, so additive changes need an explicit ALTER.
88
+ const MIGRATIONS = [
89
+ ["agent_sessions", "color", "TEXT"],
90
+ ["gate_results", "checks_json", "TEXT"],
91
+ ["sessions", "adw_name", "TEXT"],
92
+ ["agent_sessions", "context_tokens", "INTEGER"],
93
+ ["agent_sessions", "context_window", "INTEGER"],
94
+ ["sessions", "archived", "INTEGER DEFAULT 0"],
95
+ ];
96
+ export class Tracer {
97
+ db;
98
+ dbPath;
99
+ eventsJsonl;
100
+ constructor(dbPath, eventsJsonl) {
101
+ mkdirSync(path.dirname(dbPath), { recursive: true });
102
+ this.dbPath = dbPath;
103
+ this.eventsJsonl = eventsJsonl;
104
+ mkdirSync(path.dirname(eventsJsonl), { recursive: true });
105
+ this.db = new Database(this.dbPath);
106
+ this.db.exec("PRAGMA journal_mode=WAL;");
107
+ this.db.exec("PRAGMA synchronous=NORMAL;");
108
+ this.db.exec("PRAGMA busy_timeout=5000;");
109
+ this.db.exec(SCHEMA);
110
+ this.migrate();
111
+ }
112
+ /** Additive column migrations, so a db from an older SPF still opens. */
113
+ migrate() {
114
+ for (const [table, column, decl] of MIGRATIONS) {
115
+ const columns = new Set(this.db.query(`PRAGMA table_info(${table})`).all().map((row) => row.name));
116
+ if (!columns.has(column)) {
117
+ this.db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${decl}`);
118
+ }
119
+ }
120
+ }
121
+ // ── events ──────────────────────────────────────────────────────────────
122
+ event(record) {
123
+ const eventId = `evt_${newId(12)}`;
124
+ const ts = nowIso();
125
+ const line = { event_id: eventId, ts, ...record };
126
+ appendFileSync(this.eventsJsonl, JSON.stringify(line) + "\n");
127
+ this.db
128
+ .query(`INSERT INTO events (event_id, adw_id, phase_id, parent_id, type, name,
129
+ payload_json, tokens, started_at, ended_at) VALUES (?,?,?,?,?,?,?,?,?,?)`)
130
+ .run(eventId, record.adw_id, record.phase_id, record.parent_id, record.type, record.name, JSON.stringify(record.payload), record.tokens ?? null, record.started_at || ts, record.ended_at ?? null);
131
+ return eventId;
132
+ }
133
+ // ── sessions ────────────────────────────────────────────────────────────
134
+ sessionStart(adwId, engineer, adwName) {
135
+ this.db
136
+ .query(`INSERT INTO sessions (adw_id, status, engineer, started_at) VALUES (?,?,?,?)
137
+ ON CONFLICT(adw_id) DO UPDATE SET status='running'`)
138
+ .run(adwId, "running", engineer, nowIso());
139
+ if (!adwName)
140
+ return;
141
+ // A joined session chains ADWs — record each distinct one, in run order.
142
+ const row = this.db.query("SELECT adw_name FROM sessions WHERE adw_id=?").get(adwId);
143
+ const names = row?.adw_name ? row.adw_name.split(" + ") : [];
144
+ if (!names.includes(adwName)) {
145
+ names.push(adwName);
146
+ this.db.query("UPDATE sessions SET adw_name=? WHERE adw_id=?").run(names.join(" + "), adwId);
147
+ }
148
+ }
149
+ sessionRequest(adwId, request) {
150
+ this.db.query("UPDATE sessions SET request=? WHERE adw_id=?").run(request.slice(0, 500), adwId);
151
+ }
152
+ sessionFinish(adwId, ok) {
153
+ this.db
154
+ .query("UPDATE sessions SET status=?, ended_at=? WHERE adw_id=?")
155
+ .run(ok ? "success" : "fail", nowIso(), adwId);
156
+ this.processesEndAll(adwId); // nothing of this run is alive any more
157
+ }
158
+ sessionAddUsage(adwId, tokens, cost) {
159
+ this.db
160
+ .query("UPDATE sessions SET total_tokens=total_tokens+?, total_cost=total_cost+? WHERE adw_id=?")
161
+ .run(tokens, cost, adwId);
162
+ }
163
+ // ── processes (adw_id → pid, so a hung run can be found and killed) ─────
164
+ /**
165
+ * Record a live process for this run.
166
+ *
167
+ * A coding agent that hangs produces no events at all, which is exactly
168
+ * when you need its pid — and `ps` cannot tell you which adw_id it
169
+ * belongs to. Writing it here makes the trace the answer to "what is this
170
+ * run running, and how do I stop it".
171
+ */
172
+ processStart(adwId, kind, name, pid, command) {
173
+ this.db
174
+ .query(`INSERT INTO processes (adw_id, kind, name, pid, command, started_at) VALUES (?,?,?,?,?,?)`)
175
+ .run(adwId, kind, name, pid, command.slice(0, 500), nowIso());
176
+ }
177
+ /** Mark the newest live row for this pid as finished. */
178
+ processEnd(adwId, pid) {
179
+ this.db
180
+ .query(`UPDATE processes SET ended_at=? WHERE id = (
181
+ SELECT id FROM processes WHERE adw_id=? AND pid=? AND ended_at IS NULL
182
+ ORDER BY id DESC LIMIT 1)`)
183
+ .run(nowIso(), adwId, pid);
184
+ }
185
+ /** Close out every live row for a run — called when the session ends. */
186
+ processesEndAll(adwId) {
187
+ this.db.query("UPDATE processes SET ended_at=? WHERE adw_id=? AND ended_at IS NULL").run(nowIso(), adwId);
188
+ }
189
+ // ── phases ──────────────────────────────────────────────────────────────
190
+ /**
191
+ * Highest seq already recorded for this session; 0 when it is new.
192
+ *
193
+ * A joined run continues the sequence instead of restarting at 1 — which
194
+ * would collide with the first run's phases on both `seq` (breaking
195
+ * ordering) and `phase_id` (silently overwriting a row through the
196
+ * phase_upsert conflict clause).
197
+ */
198
+ maxPhaseSeq(adwId) {
199
+ const row = this.db.query("SELECT MAX(seq) as m FROM phases WHERE adw_id = ?").get(adwId);
200
+ return row?.m ?? 0;
201
+ }
202
+ phaseUpsert(phase) {
203
+ const p = phase.params;
204
+ this.db
205
+ .query(`INSERT INTO phases (phase_id, adw_id, seq, name, kind, owner, description,
206
+ status, attempt, retries, error, started_at, ended_at)
207
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)
208
+ ON CONFLICT(phase_id) DO UPDATE SET status=excluded.status,
209
+ attempt=excluded.attempt, error=excluded.error, ended_at=excluded.ended_at`)
210
+ .run(phase.phase_id, phase.adw_id, phase.seq, p.name, p.kind, p.owner, p.description, phase.status, phase.attempt, p.retries, phase.error ?? null, phase.started_at ?? null, phase.ended_at ?? null);
211
+ }
212
+ // ── envelopes / gates / agent sessions ──────────────────────────────────
213
+ envelopeRow(phase, agent, outputType, payloadJson, valid, attempt) {
214
+ this.db
215
+ .query(`INSERT INTO envelopes (envelope_id, adw_id, phase_id, agent, output_type,
216
+ payload_json, valid, attempt, created_at) VALUES (?,?,?,?,?,?,?,?,?)`)
217
+ .run(`env_${newId(12)}`, phase.adw_id, phase.phase_id, agent, outputType, payloadJson, valid ? 1 : 0, attempt, nowIso());
218
+ }
219
+ /** The report carries both the verdict and the evidence behind it. */
220
+ gateRow(phase, gate, report, attempt) {
221
+ this.db
222
+ .query(`INSERT INTO gate_results (adw_id, phase_id, attempt, gate, passed,
223
+ violations_json, checks_json, created_at) VALUES (?,?,?,?,?,?,?,?)`)
224
+ .run(phase.adw_id, phase.phase_id, attempt, gate, report.passed ? 1 : 0, JSON.stringify(report.violations), JSON.stringify(report.checks), nowIso());
225
+ }
226
+ /**
227
+ * The agent's config row is the source of truth for its label and color.
228
+ *
229
+ * Context is carried here rather than derived from events because the lane
230
+ * wants one number per agent — the latest — and a session that runs the
231
+ * same agent twice overwrites it, exactly like model and session_id.
232
+ */
233
+ agentSessionRow(adwId, agent, sessionId, contextTokens = 0, contextWindow = 0) {
234
+ const ts = nowIso();
235
+ this.db
236
+ .query(`INSERT INTO agent_sessions (adw_id, agent, coding_agent, model, color,
237
+ session_id, context_tokens, context_window, created_at, last_used_at)
238
+ VALUES (?,?,?,?,?,?,?,?,?,?)
239
+ ON CONFLICT(adw_id, agent) DO UPDATE SET model=excluded.model,
240
+ color=excluded.color, session_id=excluded.session_id,
241
+ context_tokens=excluded.context_tokens,
242
+ context_window=excluded.context_window,
243
+ last_used_at=excluded.last_used_at`)
244
+ .run(adwId, agent.name, agent.coding_agent, agent.model, agent.color, sessionId, contextTokens, contextWindow, ts, ts);
245
+ }
246
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Small shared helpers. Anything bigger belongs in its own module.
3
+ *
4
+ * Bun loads .env files automatically for anything run with `bun run`/`bun`,
5
+ * so there is no load_dotenv() call to make here — that parity is free.
6
+ */
7
+ import path from "node:path";
8
+ /**
9
+ * The engineer's own environment, as their shell would hand it over.
10
+ *
11
+ * Python's version stripped a `uv run` venv's bin dir off PATH so a
12
+ * subprocess's `python3` resolved the operator's own interpreter, not the
13
+ * ADW's ephemeral one. Bun scripts run directly (no venv layer prepended to
14
+ * PATH), so there is nothing to strip here — this exists for call-site parity
15
+ * and as the one place to patch if a future wrapper starts doing the same
16
+ * thing to PATH.
17
+ */
18
+ export declare function operatorEnv(): Record<string, string>;
19
+ export declare function newId(length?: number): string;
20
+ /** Matches Python's `datetime.now(timezone.utc).isoformat(timespec="milliseconds")`. */
21
+ export declare function nowIso(): string;
22
+ export declare function ensureDir(dirPath: string): string;
23
+ /** CLI prompt arg: a file path resolves to its contents, else inline text. */
24
+ export declare function resolvePrompt(arg: string): string;
25
+ export declare function engineerName(): string;
26
+ /**
27
+ * Minimal `--flag value` / `--bare-flag` CLI parsing — the CLI only ever
28
+ * needs a handful of positionals plus a handful of named options and bare
29
+ * boolean flags, so a full argparse port would be more machinery than the
30
+ * job needs. `flagNames` are boolean — present means true, never consumes
31
+ * the next argv value; anything else in `optionNames` always takes a value.
32
+ */
33
+ export declare function parseCli(argv: string[], optionNames: string[], flagNames?: string[]): {
34
+ positionals: string[];
35
+ options: Record<string, string>;
36
+ flags: Record<string, boolean>;
37
+ };
38
+ /** Run an ADW's async main() and translate its outcome into a process exit code. */
39
+ export declare function runMain(main: () => Promise<number>): void;
40
+ export { path };
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Small shared helpers. Anything bigger belongs in its own module.
3
+ *
4
+ * Bun loads .env files automatically for anything run with `bun run`/`bun`,
5
+ * so there is no load_dotenv() call to make here — that parity is free.
6
+ */
7
+ import { randomBytes } from "node:crypto";
8
+ import { spawnSync } from "node:child_process";
9
+ import { existsSync, mkdirSync, readFileSync, statSync } from "node:fs";
10
+ import path from "node:path";
11
+ /**
12
+ * The engineer's own environment, as their shell would hand it over.
13
+ *
14
+ * Python's version stripped a `uv run` venv's bin dir off PATH so a
15
+ * subprocess's `python3` resolved the operator's own interpreter, not the
16
+ * ADW's ephemeral one. Bun scripts run directly (no venv layer prepended to
17
+ * PATH), so there is nothing to strip here — this exists for call-site parity
18
+ * and as the one place to patch if a future wrapper starts doing the same
19
+ * thing to PATH.
20
+ */
21
+ export function operatorEnv() {
22
+ const env = {};
23
+ for (const [key, value] of Object.entries(process.env)) {
24
+ if (value !== undefined)
25
+ env[key] = value;
26
+ }
27
+ return env;
28
+ }
29
+ export function newId(length = 8) {
30
+ return randomBytes(Math.floor(length / 2)).toString("hex");
31
+ }
32
+ /** Matches Python's `datetime.now(timezone.utc).isoformat(timespec="milliseconds")`. */
33
+ export function nowIso() {
34
+ const iso = new Date().toISOString(); // e.g. 2024-01-01T12:00:00.123Z
35
+ return iso.replace("Z", "+00:00");
36
+ }
37
+ export function ensureDir(dirPath) {
38
+ mkdirSync(dirPath, { recursive: true });
39
+ return dirPath;
40
+ }
41
+ /** CLI prompt arg: a file path resolves to its contents, else inline text. */
42
+ export function resolvePrompt(arg) {
43
+ try {
44
+ if (existsSync(arg) && statSync(arg).isFile()) {
45
+ return readFileSync(arg, "utf-8");
46
+ }
47
+ }
48
+ catch {
49
+ // not a valid path — fall through to inline text
50
+ }
51
+ return arg;
52
+ }
53
+ export function engineerName() {
54
+ const name = (process.env.ENGINEER_NAME || "").trim();
55
+ if (name)
56
+ return name;
57
+ try {
58
+ const out = spawnSync("git", ["config", "user.name"], { encoding: "utf-8", timeout: 5000 });
59
+ if (out.status === 0 && out.stdout.trim())
60
+ return out.stdout.trim();
61
+ }
62
+ catch {
63
+ // git not available or not configured — fall through
64
+ }
65
+ return process.env.USER || process.env.USERNAME || "engineer";
66
+ }
67
+ /**
68
+ * Minimal `--flag value` / `--bare-flag` CLI parsing — the CLI only ever
69
+ * needs a handful of positionals plus a handful of named options and bare
70
+ * boolean flags, so a full argparse port would be more machinery than the
71
+ * job needs. `flagNames` are boolean — present means true, never consumes
72
+ * the next argv value; anything else in `optionNames` always takes a value.
73
+ */
74
+ export function parseCli(argv, optionNames, flagNames = []) {
75
+ const positionals = [];
76
+ const options = {};
77
+ const flags = {};
78
+ for (let i = 0; i < argv.length; i++) {
79
+ const arg = argv[i];
80
+ if (arg.startsWith("--")) {
81
+ const name = arg.slice(2);
82
+ if (flagNames.includes(name)) {
83
+ flags[name] = true;
84
+ continue;
85
+ }
86
+ if (!optionNames.includes(name))
87
+ throw new Error(`unknown option: --${name}`);
88
+ const value = argv[++i];
89
+ if (value === undefined)
90
+ throw new Error(`--${name} requires a value`);
91
+ options[name] = value;
92
+ }
93
+ else {
94
+ positionals.push(arg);
95
+ }
96
+ }
97
+ return { positionals, options, flags };
98
+ }
99
+ /** Run an ADW's async main() and translate its outcome into a process exit code. */
100
+ export function runMain(main) {
101
+ main()
102
+ .then((code) => process.exit(code))
103
+ .catch((error) => {
104
+ console.error(error instanceof Error ? error.message : error);
105
+ process.exit(1);
106
+ });
107
+ }
108
+ export { path };
@@ -0,0 +1,44 @@
1
+ import type { GitHandle } from "./git_helper.ts";
2
+ import type { Issue, IssueProvider } from "./issues/provider.ts";
3
+ export interface ChainRunResult {
4
+ accepted: boolean;
5
+ adwId: string;
6
+ /** Shown to the engineer via a `blocked` comment on a failed/no-op run. */
7
+ detail: string;
8
+ }
9
+ export interface WatchDeps {
10
+ provider: IssueProvider;
11
+ git: GitHandle;
12
+ /** Bound to a specific worktree path (diffFiles/push run there) — inject `git_helper.makeGit` for real use, a fake for tests. */
13
+ worktreeGit: (worktreePath: string) => GitHandle;
14
+ labelPrefix: string;
15
+ chain: string;
16
+ baseBranch: string;
17
+ concurrency: number;
18
+ worktreesDir: string;
19
+ dryRun: boolean;
20
+ runChain: (opts: {
21
+ prompt: string;
22
+ cwd: string;
23
+ adwId: string;
24
+ }) => Promise<ChainRunResult>;
25
+ log: (message: string) => void;
26
+ }
27
+ export interface WatchRunState {
28
+ inflight: Set<number>;
29
+ }
30
+ export declare function createWatchState(): WatchRunState;
31
+ export declare function branchNameFor(issue: Issue): string;
32
+ /**
33
+ * Any issue labeled `working` that THIS process isn't tracking is an
34
+ * orphan — a daemon restart, or another instance's claim this process
35
+ * never saw. Resume if its marker points at a still-open (or already
36
+ * merged) PR; otherwise retry up to `MAX_ORPHAN_ATTEMPTS`, then give up.
37
+ */
38
+ export declare function reconcileOrphans(deps: WatchDeps, state: WatchRunState): Promise<void>;
39
+ /** Poll every `review`-labeled issue's PR for merged (-> done) or closed-without-merging (-> blocked). */
40
+ export declare function finishReviews(deps: WatchDeps): Promise<void>;
41
+ /** Claim as many `ready` issues as the concurrency budget allows, and kick off `runIssue` for each in the background. */
42
+ export declare function claimNewWork(deps: WatchDeps, state: WatchRunState): Promise<void>;
43
+ /** One poll tick: reconcile, finish, claim — each independently caught, so one phase's error never blocks the rest. */
44
+ export declare function tick(deps: WatchDeps, state: WatchRunState): Promise<void>;
@@ -0,0 +1,194 @@
1
+ /**
2
+ * The `spf watch` state machine: poll -> claim -> run a chain -> PR ->
3
+ * done/blocked. Provider-agnostic (drives whatever `IssueProvider` it's
4
+ * given) and chain-agnostic (drives whatever `runChain` callback it's
5
+ * given) — deliberately kept out of `src/chains/`'s dependency direction,
6
+ * so this stays testable against a fake provider and a fake `runChain`
7
+ * with no chain registry involved.
8
+ *
9
+ * Design lifted from the user's own GitHub-poller reference implementation
10
+ * (a label-as-state-machine daemon), leaned down for a v1: no per-issue
11
+ * telemetry, no dashboard, no Projects v2 mirroring, no CI-fix retry loop,
12
+ * no auto-merge. What's kept, because it's cheap and load-bearing:
13
+ *
14
+ * - `transition()` is the ONE state mutator (see `provider.ts`) — every
15
+ * state change traces to one call site.
16
+ * - A marker (a hidden HTML comment on the issue) is the durable scratch
17
+ * state — zero infrastructure, survives a daemon crash.
18
+ * - `listInState("review", {includeAll: true})` — a tracker that
19
+ * auto-closes an issue the instant its linked PR merges (GitHub's
20
+ * `Closes #n`) can make it vanish from an open-only query before the
21
+ * next tick runs.
22
+ * - Orphan reconciliation: any `working` issue this process isn't
23
+ * tracking gets one of three outcomes (resume via its marker's PR,
24
+ * retry up to a cap, or give up and block) every tick — a hung daemon
25
+ * restart never leaves an issue silently stuck.
26
+ * - Worktree-per-issue, isolated outside the repo, made cheap by SPF's
27
+ * own `--cwd` support: no new chain-dispatch plumbing needed, just
28
+ * pointing an existing chain at a different working tree.
29
+ */
30
+ import path from "node:path";
31
+ const MAX_ORPHAN_ATTEMPTS = 2;
32
+ export function createWatchState() {
33
+ return { inflight: new Set() };
34
+ }
35
+ export function branchNameFor(issue) {
36
+ const slug = issue.title
37
+ .toLowerCase()
38
+ .split(/\s+/)
39
+ .filter(Boolean)
40
+ .slice(0, 5)
41
+ .join("-")
42
+ .replace(/[^a-z0-9-]/g, "");
43
+ return `spf-watch/${issue.number}-${slug || "issue"}`.slice(0, 200);
44
+ }
45
+ function worktreePathFor(deps, issue) {
46
+ return path.join(deps.worktreesDir, `issue-${issue.number}`);
47
+ }
48
+ function cleanupWorktree(deps, marker) {
49
+ if (!marker)
50
+ return;
51
+ try {
52
+ if (marker.worktree)
53
+ deps.git.worktreeRemove(marker.worktree);
54
+ if (marker.branch)
55
+ deps.git.deleteLocalBranch(marker.branch);
56
+ }
57
+ catch (error) {
58
+ deps.log(`watch: cleanup warning: ${error.message}`);
59
+ }
60
+ }
61
+ /**
62
+ * Any issue labeled `working` that THIS process isn't tracking is an
63
+ * orphan — a daemon restart, or another instance's claim this process
64
+ * never saw. Resume if its marker points at a still-open (or already
65
+ * merged) PR; otherwise retry up to `MAX_ORPHAN_ATTEMPTS`, then give up.
66
+ */
67
+ export async function reconcileOrphans(deps, state) {
68
+ const working = await deps.provider.listInState("working");
69
+ for (const issue of working) {
70
+ if (state.inflight.has(issue.number))
71
+ continue;
72
+ const marker = await deps.provider.readMarker(issue);
73
+ if (marker?.pr) {
74
+ const status = await deps.provider.prStatus({ number: marker.pr, branch: marker.branch ?? "", url: "" });
75
+ if (status.state === "open" || status.merged) {
76
+ deps.log(`watch: #${issue.number} orphaned with an open/merged PR #${marker.pr} — resuming as review`);
77
+ if (!deps.dryRun)
78
+ await deps.provider.transition(issue, "review");
79
+ continue;
80
+ }
81
+ }
82
+ const attempt = (marker?.attempt ?? 0) + 1;
83
+ if (attempt <= MAX_ORPHAN_ATTEMPTS) {
84
+ deps.log(`watch: #${issue.number} orphaned, retry ${attempt}/${MAX_ORPHAN_ATTEMPTS} — back to ready`);
85
+ if (!deps.dryRun) {
86
+ await deps.provider.writeMarker(issue, { ...marker, attempt });
87
+ await deps.provider.transition(issue, "ready");
88
+ }
89
+ }
90
+ else {
91
+ deps.log(`watch: #${issue.number} orphaned past ${MAX_ORPHAN_ATTEMPTS} attempts — blocked`);
92
+ if (!deps.dryRun) {
93
+ await deps.provider.transition(issue, "blocked", `Gave up after ${MAX_ORPHAN_ATTEMPTS} orphaned attempts.`);
94
+ cleanupWorktree(deps, marker);
95
+ }
96
+ }
97
+ }
98
+ }
99
+ /** Poll every `review`-labeled issue's PR for merged (-> done) or closed-without-merging (-> blocked). */
100
+ export async function finishReviews(deps) {
101
+ const reviewing = await deps.provider.listInState("review", { includeAll: true });
102
+ for (const issue of reviewing) {
103
+ const marker = await deps.provider.readMarker(issue);
104
+ if (!marker?.pr)
105
+ continue;
106
+ const status = await deps.provider.prStatus({ number: marker.pr, branch: marker.branch ?? "", url: "" });
107
+ if (status.merged) {
108
+ deps.log(`watch: #${issue.number}'s PR #${marker.pr} merged — done`);
109
+ if (!deps.dryRun) {
110
+ await deps.provider.transition(issue, "done");
111
+ cleanupWorktree(deps, marker);
112
+ }
113
+ }
114
+ else if (status.state === "closed") {
115
+ deps.log(`watch: #${issue.number}'s PR #${marker.pr} closed without merging — blocked`);
116
+ if (!deps.dryRun) {
117
+ await deps.provider.transition(issue, "blocked", `PR #${marker.pr} was closed without merging.`);
118
+ cleanupWorktree(deps, marker);
119
+ }
120
+ }
121
+ }
122
+ }
123
+ /** One issue's full claim -> chain -> PR path, run in the background — `claimNewWork` doesn't await this. */
124
+ async function runIssue(deps, issue) {
125
+ const branch = branchNameFor(issue);
126
+ const worktreePath = worktreePathFor(deps, issue);
127
+ const adwId = `issue-${issue.number}`;
128
+ try {
129
+ deps.git.fetch("origin", deps.baseBranch);
130
+ deps.git.worktreeAdd(worktreePath, branch, `origin/${deps.baseBranch}`);
131
+ await deps.provider.writeMarker(issue, { worktree: worktreePath, branch, attempt: 0 });
132
+ const prompt = `${issue.title}\n\n${issue.body}`.trim();
133
+ const result = await deps.runChain({ prompt, cwd: worktreePath, adwId });
134
+ if (!result.accepted) {
135
+ deps.log(`watch: #${issue.number}: chain "${deps.chain}" did not succeed — blocked`);
136
+ await deps.provider.transition(issue, "blocked", result.detail || `Chain "${deps.chain}" (adw_id ${adwId}) did not complete successfully. Run \`spf phases ${adwId}\` for detail.`);
137
+ cleanupWorktree(deps, { worktree: worktreePath, branch });
138
+ return;
139
+ }
140
+ const wtGit = deps.worktreeGit(worktreePath);
141
+ if (wtGit.diffFiles(`origin/${deps.baseBranch}`).length === 0) {
142
+ deps.log(`watch: #${issue.number}: chain succeeded but committed nothing — blocked`);
143
+ await deps.provider.transition(issue, "blocked", `Chain "${deps.chain}" (adw_id ${adwId}) completed but left no committed changes.`);
144
+ cleanupWorktree(deps, { worktree: worktreePath, branch });
145
+ return;
146
+ }
147
+ wtGit.push("origin", branch);
148
+ const pr = await deps.provider.openPr(issue, {
149
+ branch,
150
+ title: `${issue.title} (#${issue.number})`,
151
+ body: `Automated by \`spf watch\` — chain \`${deps.chain}\`, adw_id \`${adwId}\`.`,
152
+ base: deps.baseBranch,
153
+ });
154
+ await deps.provider.writeMarker(issue, { worktree: worktreePath, branch, pr: pr.number, attempt: 0 });
155
+ await deps.provider.transition(issue, "review");
156
+ deps.log(`watch: #${issue.number}: opened PR #${pr.number} — review`);
157
+ }
158
+ catch (error) {
159
+ const message = error.message;
160
+ deps.log(`watch: #${issue.number}: error: ${message}`);
161
+ await deps.provider.transition(issue, "blocked", `spf watch error: ${message}`).catch(() => undefined);
162
+ cleanupWorktree(deps, { worktree: worktreePath, branch });
163
+ }
164
+ }
165
+ /** Claim as many `ready` issues as the concurrency budget allows, and kick off `runIssue` for each in the background. */
166
+ export async function claimNewWork(deps, state) {
167
+ if (state.inflight.size >= deps.concurrency)
168
+ return;
169
+ const eligible = await deps.provider.listEligible();
170
+ for (const issue of eligible) {
171
+ if (state.inflight.size >= deps.concurrency)
172
+ break;
173
+ if (state.inflight.has(issue.number))
174
+ continue;
175
+ if (deps.dryRun) {
176
+ deps.log(`watch: [dry-run] would claim #${issue.number} (${issue.title}) and run chain "${deps.chain}"`);
177
+ continue;
178
+ }
179
+ const claimed = await deps.provider.claim(issue);
180
+ if (!claimed) {
181
+ deps.log(`watch: #${issue.number} lost the claim race this tick — skipping`);
182
+ continue;
183
+ }
184
+ deps.log(`watch: claimed #${issue.number}: ${issue.title}`);
185
+ state.inflight.add(issue.number);
186
+ runIssue(deps, issue).finally(() => state.inflight.delete(issue.number));
187
+ }
188
+ }
189
+ /** One poll tick: reconcile, finish, claim — each independently caught, so one phase's error never blocks the rest. */
190
+ export async function tick(deps, state) {
191
+ await reconcileOrphans(deps, state).catch((error) => deps.log(`watch: reconcileOrphans error: ${error.message}`));
192
+ await finishReviews(deps).catch((error) => deps.log(`watch: finishReviews error: ${error.message}`));
193
+ await claimNewWork(deps, state).catch((error) => deps.log(`watch: claimNewWork error: ${error.message}`));
194
+ }
@@ -0,0 +1 @@
1
+ export {};