@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,168 @@
1
+ /**
2
+ * The Run object: config + adw_id + agent_map + tracer + console, bound once.
3
+ *
4
+ * `run.phase(params, fn)` is the ONE phase primitive — an async scope for all
5
+ * three kinds (engineer, agent, code), replacing Python's `with run.phase(...)
6
+ * as ph:` context manager (JS has no direct equivalent, so the callback form
7
+ * is the idiomatic stand-in). Success must be earned: every phase defaults to
8
+ * fail; only a clean exit flips it (agent phases additionally require a
9
+ * parsed envelope + green gates, enforced inside ph.call).
10
+ */
11
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
12
+ import path from "node:path";
13
+ import * as agents from "./agents.js";
14
+ import { makeGit } from "./git_helper.js";
15
+ import { Console } from "./console.js";
16
+ import { Tracer } from "./tracer.js";
17
+ import { makeEventRecord } from "./data_types.js";
18
+ import { ensureDir, nowIso } from "./utils.js";
19
+ class PhaseHandleImpl {
20
+ run;
21
+ phase;
22
+ constructor(run, phase) {
23
+ this.run = run;
24
+ this.phase = phase;
25
+ }
26
+ log(payload) {
27
+ this.run.tracer.event(makeEventRecord({
28
+ adw_id: this.run.adw_id,
29
+ phase_id: this.phase.phase_id,
30
+ type: "log",
31
+ name: this.phase.params.name,
32
+ payload,
33
+ }));
34
+ this.run.console.note(Object.entries(payload).map(([k, v]) => `${k}: ${v}`).join(", "));
35
+ if (this.phase.params.kind === "engineer" && "input" in payload) {
36
+ this.run.tracer.sessionRequest(this.run.adw_id, String(payload.input));
37
+ }
38
+ }
39
+ async call(call) {
40
+ if (this.phase.params.kind !== "agent") {
41
+ throw new Error("ph.call() is only valid inside an agent phase");
42
+ }
43
+ return (await agents.execute(this.run, this.phase, call));
44
+ }
45
+ }
46
+ export class Run {
47
+ cfg;
48
+ adw_id;
49
+ tracer;
50
+ console;
51
+ engineer;
52
+ phases = [];
53
+ tokens = 0;
54
+ cost = 0;
55
+ repo_root; // where every agent is spawned to work — always absolute
56
+ /** Every git operation for this run, bound to repo_root. Never call git_helper directly. */
57
+ git;
58
+ /** From the same anchor as repo_root — null if no .spf/ dir exists. */
59
+ spf_dir;
60
+ /** Absolute. Sibling of it: flue.db (Flue's own conversation store), sessions/. */
61
+ data_dir;
62
+ session_dir;
63
+ context_handoff_dir;
64
+ agent_map;
65
+ seq; // a joined run continues the sequence
66
+ agentMapPath;
67
+ constructor(init) {
68
+ this.cfg = init.cfg;
69
+ this.adw_id = init.adwId;
70
+ this.tracer = init.tracer;
71
+ this.console = new Console(init.tracer, init.adwId);
72
+ this.engineer = init.engineer;
73
+ this.seq = init.tracer.maxPhaseSeq(init.adwId);
74
+ this.repo_root = init.repoRoot;
75
+ this.spf_dir = init.sfDir;
76
+ this.git = makeGit(init.repoRoot);
77
+ this.data_dir = init.dataDir;
78
+ this.session_dir = ensureDir(path.join(init.dataDir, "sessions", init.adwId));
79
+ this.context_handoff_dir = ensureDir(path.join(this.session_dir, "context_handoff"));
80
+ this.agentMapPath = path.join(this.session_dir, "agent_map.json");
81
+ this.agent_map = existsSync(this.agentMapPath) ? JSON.parse(readFileSync(this.agentMapPath, "utf-8")) : {};
82
+ }
83
+ // ── agent map (adw_id -> per-agent coding-agent session ids) ────────────
84
+ saveAgentMap(agent, entry) {
85
+ this.agent_map[agent] = entry;
86
+ writeFileSync(this.agentMapPath, JSON.stringify(this.agent_map, null, 2));
87
+ }
88
+ // ── usage (run totals mirror what the tracer accumulates in sqlite) ─────
89
+ addUsage(tokens, cost) {
90
+ this.tokens += tokens;
91
+ this.cost += cost;
92
+ this.tracer.sessionAddUsage(this.adw_id, tokens, cost);
93
+ }
94
+ // ── the phase primitive ─────────────────────────────────────────────────
95
+ async phase(params, fn) {
96
+ this.seq += 1;
97
+ const phase = {
98
+ phase_id: `${this.adw_id}_${String(this.seq).padStart(2, "0")}_${params.name}`,
99
+ adw_id: this.adw_id,
100
+ seq: this.seq,
101
+ params,
102
+ status: "running",
103
+ attempt: 0,
104
+ error: null,
105
+ started_at: nowIso(),
106
+ ended_at: null,
107
+ };
108
+ this.phases.push(phase);
109
+ this.tracer.phaseUpsert(phase);
110
+ this.tracer.event(makeEventRecord({
111
+ adw_id: this.adw_id,
112
+ phase_id: phase.phase_id,
113
+ type: "phase_start",
114
+ name: params.name,
115
+ payload: { kind: params.kind, owner: params.owner, description: params.description },
116
+ }));
117
+ this.console.phaseStarted(phase);
118
+ const clock = performance.now();
119
+ try {
120
+ const result = await fn(new PhaseHandleImpl(this, phase));
121
+ phase.status = "success";
122
+ phase.ended_at = nowIso();
123
+ this.tracer.event(makeEventRecord({ adw_id: this.adw_id, phase_id: phase.phase_id, type: "phase_end", name: params.name, payload: { status: "success" } }));
124
+ this.tracer.phaseUpsert(phase);
125
+ this.console.phaseEnded(phase, (performance.now() - clock) / 1000);
126
+ return result;
127
+ }
128
+ catch (error) {
129
+ phase.status = "fail"; // success must be earned
130
+ phase.error = String(error?.message ?? error).slice(0, 1000);
131
+ phase.ended_at = nowIso();
132
+ this.tracer.event(makeEventRecord({ adw_id: this.adw_id, phase_id: phase.phase_id, type: "error", name: params.name, payload: { error: phase.error } }));
133
+ this.tracer.event(makeEventRecord({ adw_id: this.adw_id, phase_id: phase.phase_id, type: "phase_end", name: params.name, payload: { status: "fail" } }));
134
+ this.tracer.phaseUpsert(phase);
135
+ this.tracer.sessionFinish(this.adw_id, false);
136
+ this.console.phaseEnded(phase, (performance.now() - clock) / 1000);
137
+ this.console.sessionFinished(false, this.tokens, this.cost, this.cfg.observability.db);
138
+ throw error;
139
+ }
140
+ }
141
+ /**
142
+ * Finalize the run and return its exit code. Call this exactly once.
143
+ *
144
+ * Two criteria, not one. Every phase must have passed, AND the ADW's own
145
+ * acceptance test must hold. They are different questions on purpose: a
146
+ * test phase that ran a red suite succeeded at its job. Pass `accepted=`
147
+ * so the exit code, the session status, and the banner are decided
148
+ * together and cannot disagree.
149
+ */
150
+ finish(accepted = true, reason = "") {
151
+ const phasesOk = this.phases.length > 0 && this.phases.every((p) => p.status === "success");
152
+ const ok = phasesOk && accepted;
153
+ if (phasesOk && !accepted) {
154
+ const note = reason || "the run's acceptance criterion was not met";
155
+ this.tracer.event(makeEventRecord({
156
+ adw_id: this.adw_id,
157
+ phase_id: this.phases.length > 0 ? this.phases[this.phases.length - 1].phase_id : "",
158
+ type: "error",
159
+ name: "not_accepted",
160
+ payload: { reason: note },
161
+ }));
162
+ this.console.note(`not accepted: ${note}`);
163
+ }
164
+ this.tracer.sessionFinish(this.adw_id, ok);
165
+ this.console.sessionFinished(ok, this.tokens, this.cost, this.cfg.observability.db);
166
+ return ok ? 0 : 1;
167
+ }
168
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Session lifecycle: pin-or-create an adw_id, build the Run object.
3
+ *
4
+ * `ensure(cfg, adwId)` joins the session if it exists or creates it under
5
+ * exactly that id (pinned ids for repeatable runs); omitted, a fresh id is
6
+ * minted and printed so the next ADW can pick it up.
7
+ */
8
+ import { Run } from "./runner.ts";
9
+ import type { SFConfig } from "./data_types.ts";
10
+ /**
11
+ * `cwd` anchors this run's repo_root and data_dir — it is NOT where the
12
+ * process happened to start; it is an explicit decision, threaded down from
13
+ * the CLI/chain context. Defaults to `process.cwd()` only for direct callers
14
+ * (tests, scratch scripts) that have no anchor of their own to pass.
15
+ */
16
+ export declare function ensure(cfg: SFConfig, adwId?: string | null, cwd?: string): Run;
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Session lifecycle: pin-or-create an adw_id, build the Run object.
3
+ *
4
+ * `ensure(cfg, adwId)` joins the session if it exists or creates it under
5
+ * exactly that id (pinned ids for repeatable runs); omitted, a fresh id is
6
+ * minted and printed so the next ADW can pick it up.
7
+ */
8
+ import path from "node:path";
9
+ import * as paths from "./paths.js";
10
+ import { Run } from "./runner.js";
11
+ import { Tracer } from "./tracer.js";
12
+ import { engineerName, newId } from "./utils.js";
13
+ /**
14
+ * A killed run still closes its own trace.
15
+ *
16
+ * Node's default SIGTERM/SIGINT handling exits without unwinding, so `just
17
+ * kill` (or any `kill <pid>`) would leave the session reading `running`
18
+ * forever and its process rows open — the trace would claim work is in
19
+ * flight that is already dead. Handling the signal both finalizes here and
20
+ * lets the phase's try/catch record the phase as failed on the way out
21
+ * (best-effort: a signal can still land mid-write).
22
+ */
23
+ function finalizeWhenKilled(run) {
24
+ const handler = (signal) => {
25
+ run.tracer.sessionFinish(run.adw_id, false); // also closes process rows
26
+ process.exit(128 + (signal === "SIGINT" ? 2 : 15));
27
+ };
28
+ process.on("SIGTERM", handler);
29
+ process.on("SIGINT", handler);
30
+ }
31
+ /**
32
+ * `cwd` anchors this run's repo_root and data_dir — it is NOT where the
33
+ * process happened to start; it is an explicit decision, threaded down from
34
+ * the CLI/chain context. Defaults to `process.cwd()` only for direct callers
35
+ * (tests, scratch scripts) that have no anchor of their own to pass.
36
+ */
37
+ export function ensure(cfg, adwId, cwd) {
38
+ const id = adwId || newId(8);
39
+ const anchor = paths.resolveAnchor(cwd);
40
+ const dataPaths = paths.resolveDataPaths(anchor, cfg.defaults.data_dir, cfg.observability.db);
41
+ const tracer = new Tracer(dataPaths.db_path, path.join(dataPaths.sessions_dir, id, "events.jsonl"));
42
+ const run = new Run({
43
+ cfg,
44
+ adwId: id,
45
+ tracer,
46
+ engineer: engineerName(),
47
+ repoRoot: anchor.repo_root,
48
+ sfDir: anchor.spf_dir,
49
+ dataDir: dataPaths.data_dir,
50
+ });
51
+ const scriptPath = process.argv[1] || "adw";
52
+ const adwName = path.basename(scriptPath, path.extname(scriptPath));
53
+ tracer.sessionStart(id, run.engineer, adwName);
54
+ // This process is the run. Record it before any phase opens, so a run that
55
+ // hangs in its first agent call is still killable by adw_id.
56
+ tracer.processStart(id, "adw", "", process.pid ?? -1, [path.basename(scriptPath), ...process.argv.slice(2)].join(" "));
57
+ finalizeWhenKilled(run);
58
+ run.console.sessionStarted(id, run.engineer);
59
+ return run;
60
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * bun:sqlite's surface, backed by node:sqlite, so tracer.ts and the visualizer's
3
+ * db.ts change only their import line.
4
+ *
5
+ * This is deliberately NOT a full bun:sqlite polyfill — it's the ~10-behavior
6
+ * subset SPF actually calls (query/exec/get/all/run, readonly, PRAGMAs, WAL).
7
+ * Every mapping below was verified live against node:sqlite, not assumed:
8
+ *
9
+ * - bun:sqlite's `{ readonly: true }` maps to node:sqlite's `{ readOnly: true }`
10
+ * (capital O). Passing the lowercase bun spelling to node:sqlite is NOT an
11
+ * error — it is silently ignored and the connection opens read-write. That
12
+ * would quietly void every "this connection never writes" guarantee in the
13
+ * codebase, so the translation below is deliberate, not cosmetic.
14
+ * - PRAGMAs (journal_mode, table_info) return rows through the normal
15
+ * prepare()/get()/all() path on both drivers, and multi-statement `exec()`
16
+ * works on both — so the tracer's schema creation and additive ALTER
17
+ * migrations need no changes at all.
18
+ * - `.query()` on bun:sqlite is a MEMOIZED prepare — the same SQL text
19
+ * returns the same cached statement. node:sqlite's `prepare()` is not
20
+ * memoized, and db.ts rebuilds its SQL per-request through an
21
+ * `optionalColumn()` probe, so this shim caches by exact SQL text. That is
22
+ * also *correct*: when a tracer ALTER lands mid-serve and the probed SQL
23
+ * text changes, a fresh statement is prepared automatically.
24
+ * - node:sqlite's `get()` returns `undefined` for no-row, not `null`; rows
25
+ * come back with a null prototype. Both are normalized below so existing
26
+ * `?? null` / `Object.assign(row, ...)` call sites stay honest.
27
+ * - node:sqlite defaults `enableForeignKeyConstraints: true` — unlike plain
28
+ * SQLite and bun:sqlite, which both default FK enforcement OFF. The
29
+ * tracer's schema uses `REFERENCES` purely as documentation (rows are not
30
+ * always inserted parent-before-child — e.g. the very first `events` row
31
+ * for a session lands before that session's own `sessions` row commits),
32
+ * so this is disabled explicitly to restore the behavior the rest of the
33
+ * codebase was written against. Confirmed by reproduction: enabling it
34
+ * throws `FOREIGN KEY constraint failed` on session start.
35
+ */
36
+ export interface DatabaseOptions {
37
+ readonly?: boolean;
38
+ }
39
+ export interface Statement<Row, Params extends unknown[]> {
40
+ get(...params: Params): Row | null;
41
+ all(...params: Params): Row[];
42
+ run(...params: Params): {
43
+ changes: number;
44
+ lastInsertRowid: number | bigint;
45
+ };
46
+ }
47
+ export declare class Database {
48
+ private readonly db;
49
+ private readonly statementCache;
50
+ constructor(path: string, options?: DatabaseOptions);
51
+ /** Cached prepare, keyed by exact SQL text — see the module doc comment. */
52
+ query<Row = unknown, Params extends unknown[] = unknown[]>(sql: string): Statement<Row, Params>;
53
+ exec(sql: string): void;
54
+ close(): void;
55
+ }
@@ -0,0 +1,106 @@
1
+ /**
2
+ * bun:sqlite's surface, backed by node:sqlite, so tracer.ts and the visualizer's
3
+ * db.ts change only their import line.
4
+ *
5
+ * This is deliberately NOT a full bun:sqlite polyfill — it's the ~10-behavior
6
+ * subset SPF actually calls (query/exec/get/all/run, readonly, PRAGMAs, WAL).
7
+ * Every mapping below was verified live against node:sqlite, not assumed:
8
+ *
9
+ * - bun:sqlite's `{ readonly: true }` maps to node:sqlite's `{ readOnly: true }`
10
+ * (capital O). Passing the lowercase bun spelling to node:sqlite is NOT an
11
+ * error — it is silently ignored and the connection opens read-write. That
12
+ * would quietly void every "this connection never writes" guarantee in the
13
+ * codebase, so the translation below is deliberate, not cosmetic.
14
+ * - PRAGMAs (journal_mode, table_info) return rows through the normal
15
+ * prepare()/get()/all() path on both drivers, and multi-statement `exec()`
16
+ * works on both — so the tracer's schema creation and additive ALTER
17
+ * migrations need no changes at all.
18
+ * - `.query()` on bun:sqlite is a MEMOIZED prepare — the same SQL text
19
+ * returns the same cached statement. node:sqlite's `prepare()` is not
20
+ * memoized, and db.ts rebuilds its SQL per-request through an
21
+ * `optionalColumn()` probe, so this shim caches by exact SQL text. That is
22
+ * also *correct*: when a tracer ALTER lands mid-serve and the probed SQL
23
+ * text changes, a fresh statement is prepared automatically.
24
+ * - node:sqlite's `get()` returns `undefined` for no-row, not `null`; rows
25
+ * come back with a null prototype. Both are normalized below so existing
26
+ * `?? null` / `Object.assign(row, ...)` call sites stay honest.
27
+ * - node:sqlite defaults `enableForeignKeyConstraints: true` — unlike plain
28
+ * SQLite and bun:sqlite, which both default FK enforcement OFF. The
29
+ * tracer's schema uses `REFERENCES` purely as documentation (rows are not
30
+ * always inserted parent-before-child — e.g. the very first `events` row
31
+ * for a session lands before that session's own `sessions` row commits),
32
+ * so this is disabled explicitly to restore the behavior the rest of the
33
+ * codebase was written against. Confirmed by reproduction: enabling it
34
+ * throws `FOREIGN KEY constraint failed` on session start.
35
+ */
36
+ import { DatabaseSync } from "node:sqlite";
37
+ /**
38
+ * bun:sqlite accepts any JS value as a bind param; node:sqlite is typed to
39
+ * null/number/bigint/string/ArrayBufferView. Every current call site only
40
+ * ever passes strings/numbers/null, so this cast documents the narrowing
41
+ * this adapter is responsible for rather than widening node:sqlite's types.
42
+ */
43
+ function asSqlParams(params) {
44
+ return params;
45
+ }
46
+ const STATEMENT_CACHE_LIMIT = 200;
47
+ export class Database {
48
+ db;
49
+ statementCache = new Map();
50
+ constructor(path, options) {
51
+ this.db = new DatabaseSync(path, {
52
+ readOnly: options?.readonly ?? false,
53
+ timeout: 5000,
54
+ enableForeignKeyConstraints: false,
55
+ });
56
+ }
57
+ /** Cached prepare, keyed by exact SQL text — see the module doc comment. */
58
+ query(sql) {
59
+ let stmt = this.statementCache.get(sql);
60
+ if (!stmt) {
61
+ stmt = this.db.prepare(sql);
62
+ if (this.statementCache.size >= STATEMENT_CACHE_LIMIT) {
63
+ const oldest = this.statementCache.keys().next().value;
64
+ if (oldest !== undefined)
65
+ this.statementCache.delete(oldest);
66
+ }
67
+ this.statementCache.set(sql, stmt);
68
+ }
69
+ return {
70
+ get: (...params) => {
71
+ guardNamedParams(params, sql);
72
+ const row = stmt.get(...asSqlParams(params));
73
+ return row === undefined ? null : { ...row };
74
+ },
75
+ all: (...params) => {
76
+ guardNamedParams(params, sql);
77
+ return stmt.all(...asSqlParams(params)).map((row) => ({ ...row }));
78
+ },
79
+ run: (...params) => {
80
+ guardNamedParams(params, sql);
81
+ const result = stmt.run(...asSqlParams(params));
82
+ return { changes: Number(result.changes), lastInsertRowid: result.lastInsertRowid };
83
+ },
84
+ };
85
+ }
86
+ exec(sql) {
87
+ this.db.exec(sql);
88
+ }
89
+ close() {
90
+ this.db.close();
91
+ }
92
+ }
93
+ /**
94
+ * Every current call site passes only strings/numbers, positionally. If a
95
+ * future call ever passes a bare object as the first param against SQL with
96
+ * no named-parameter markers, node:sqlite's `allowBareNamedParameters`
97
+ * default would silently reinterpret it — fail loudly instead.
98
+ */
99
+ function guardNamedParams(params, sql) {
100
+ const first = params[0];
101
+ const looksNamed = first !== null && typeof first === "object";
102
+ const sqlHasNamedMarkers = /[:$@][A-Za-z_]/.test(sql);
103
+ if (looksNamed && !sqlHasNamedMarkers) {
104
+ throw new Error(`sqlite: unexpected object parameter for a positional query: ${sql}`);
105
+ }
106
+ }
@@ -0,0 +1,56 @@
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.ts";
9
+ import type { AgentConfig, EventRecord, GateReport, Phase } from "./data_types.ts";
10
+ export declare class Tracer {
11
+ db: Database;
12
+ dbPath: string;
13
+ eventsJsonl: string;
14
+ constructor(dbPath: string, eventsJsonl: string);
15
+ /** Additive column migrations, so a db from an older SPF still opens. */
16
+ private migrate;
17
+ event(record: EventRecord): string;
18
+ sessionStart(adwId: string, engineer: string, adwName?: string | null): void;
19
+ sessionRequest(adwId: string, request: string): void;
20
+ sessionFinish(adwId: string, ok: boolean): void;
21
+ sessionAddUsage(adwId: string, tokens: number, cost: number): void;
22
+ /**
23
+ * Record a live process for this run.
24
+ *
25
+ * A coding agent that hangs produces no events at all, which is exactly
26
+ * when you need its pid — and `ps` cannot tell you which adw_id it
27
+ * belongs to. Writing it here makes the trace the answer to "what is this
28
+ * run running, and how do I stop it".
29
+ */
30
+ processStart(adwId: string, kind: string, name: string, pid: number, command: string): void;
31
+ /** Mark the newest live row for this pid as finished. */
32
+ processEnd(adwId: string, pid: number): void;
33
+ /** Close out every live row for a run — called when the session ends. */
34
+ processesEndAll(adwId: string): void;
35
+ /**
36
+ * Highest seq already recorded for this session; 0 when it is new.
37
+ *
38
+ * A joined run continues the sequence instead of restarting at 1 — which
39
+ * would collide with the first run's phases on both `seq` (breaking
40
+ * ordering) and `phase_id` (silently overwriting a row through the
41
+ * phase_upsert conflict clause).
42
+ */
43
+ maxPhaseSeq(adwId: string): number;
44
+ phaseUpsert(phase: Phase): void;
45
+ envelopeRow(phase: Phase, agent: string, outputType: string, payloadJson: string, valid: boolean, attempt: number): void;
46
+ /** The report carries both the verdict and the evidence behind it. */
47
+ gateRow(phase: Phase, gate: string, report: GateReport, attempt: number): void;
48
+ /**
49
+ * The agent's config row is the source of truth for its label and color.
50
+ *
51
+ * Context is carried here rather than derived from events because the lane
52
+ * wants one number per agent — the latest — and a session that runs the
53
+ * same agent twice overwrites it, exactly like model and session_id.
54
+ */
55
+ agentSessionRow(adwId: string, agent: AgentConfig, sessionId: string, contextTokens?: number, contextWindow?: number): void;
56
+ }