@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,98 @@
1
+ /**
2
+ * SPF visualizer app — JSON API over a target repo's spf.db, plus the packaged
3
+ * SPA. Reads are read-only; the single write is POST
4
+ * /api/sessions/:adw_id/archive, which sets one review flag on a row.
5
+ *
6
+ * There is no ingest endpoint and no websocket. The data path is
7
+ * agents -> sqlite -> web ui, and the UI gets there by polling.
8
+ *
9
+ * Ported from a Bun.serve({routes}) app — Hono's :param routes are the same
10
+ * shape, so every route transcribes directly. Two deliberate behavior
11
+ * changes from the Bun version: a GET to the archive route now 404s as JSON
12
+ * (it used to fall through to the SPA, since Bun's routes object only wires
13
+ * POST there); and the manual decodeURIComponent() on route params is
14
+ * dropped — Hono decodes them itself, so keeping it would double-decode.
15
+ */
16
+ import { existsSync, readFileSync } from "node:fs";
17
+ import { resolve, sep } from "node:path";
18
+ import { Hono } from "hono";
19
+ import { SfDb } from "./db.js";
20
+ import { serveStatic } from "./static.js";
21
+ /**
22
+ * adw_ids and agent names are path segments on disk, so anything that isn't a
23
+ * plain identifier is rejected outright rather than sanitized into something
24
+ * that might still escape the sessions directory.
25
+ */
26
+ const SAFE_SEGMENT = /^[A-Za-z0-9._-]+$/;
27
+ function isSafeSegment(value) {
28
+ return SAFE_SEGMENT.test(value) && value !== "." && value !== "..";
29
+ }
30
+ function intQueryParam(value, fallback) {
31
+ if (value === undefined || value.trim() === "")
32
+ return fallback;
33
+ const parsed = Number.parseInt(value, 10);
34
+ return Number.isFinite(parsed) ? parsed : fallback;
35
+ }
36
+ export function createApp(db, webDir) {
37
+ const app = new Hono();
38
+ app.use("/api/*", async (c, next) => {
39
+ await next();
40
+ c.header("cache-control", "no-store");
41
+ });
42
+ app.get("/api/health", (c) => c.json({ ok: true, db: db.path, journal_mode: db.journalMode, sessions: db.sessionCount() }));
43
+ app.get("/api/sessions", (c) => c.json(db.sessions(intQueryParam(c.req.query("limit"), 200))));
44
+ app.get("/api/sessions/:adw_id", (c) => {
45
+ const adwId = c.req.param("adw_id");
46
+ const detail = db.sessionDetail(adwId);
47
+ if (!detail)
48
+ return c.json({ error: `no session ${adwId}` }, 404);
49
+ return c.json(detail);
50
+ });
51
+ // The one write. Archiving is review triage — it belongs to the reader, not
52
+ // to the run — so it never touches anything a tracer wrote.
53
+ app.post("/api/sessions/:adw_id/archive", async (c) => {
54
+ const adwId = c.req.param("adw_id");
55
+ if (!isSafeSegment(adwId))
56
+ return c.json({ error: "invalid adw_id" }, 400);
57
+ const body = (await c.req.json().catch(() => ({})));
58
+ const archived = body.archived === undefined ? true : Boolean(body.archived);
59
+ if (!db.setArchived(adwId, archived))
60
+ return c.json({ error: `no session ${adwId}` }, 404);
61
+ return c.json({ adw_id: adwId, archived });
62
+ });
63
+ app.get("/api/sessions/:adw_id/events", (c) => c.json(db.events(c.req.param("adw_id"), intQueryParam(c.req.query("after"), 0), intQueryParam(c.req.query("limit"), 500))));
64
+ app.get("/api/sessions/:adw_id/envelopes", (c) => c.json(db.envelopes(c.req.param("adw_id"))));
65
+ app.get("/api/sessions/:adw_id/gates", (c) => c.json(db.gates(c.req.param("adw_id"))));
66
+ // The exact prompts an agent was sent, read from the session dir. Files are
67
+ // the raw record; the db has no copy of them.
68
+ app.get("/api/sessions/:adw_id/agents/:agent/prompts", (c) => {
69
+ const adwId = c.req.param("adw_id");
70
+ const agent = c.req.param("agent");
71
+ if (!isSafeSegment(adwId) || !isSafeSegment(agent)) {
72
+ return c.json({ error: "invalid adw_id or agent" }, 400);
73
+ }
74
+ if (!db.session(adwId))
75
+ return c.json({ error: `no session ${adwId}` }, 404);
76
+ const dir = resolve(db.sessionsDir, adwId, agent, "prompts");
77
+ // Defense in depth: the segment check already forbids traversal.
78
+ if (dir !== db.sessionsDir && !dir.startsWith(db.sessionsDir + sep)) {
79
+ return c.json({ error: "invalid path" }, 400);
80
+ }
81
+ // A prompt file is absent whenever the agent never ran in this session —
82
+ // a normal state, so it reads as null rather than an error.
83
+ const read = (name) => {
84
+ const file = resolve(dir, `${name}.md`);
85
+ return existsSync(file) ? readFileSync(file, "utf-8") : null;
86
+ };
87
+ return c.json({ system: read("system"), user: read("user") });
88
+ });
89
+ // Registered after every real /api/* route: a miss under /api/ is a 404,
90
+ // never the SPA fallback.
91
+ app.all("/api/*", (c) => c.json({ error: `no route ${c.req.path}` }, 404));
92
+ app.get("*", serveStatic(webDir));
93
+ app.onError((err, c) => {
94
+ console.error(`[spf] ${c.req.method} ${c.req.path}:`, err);
95
+ return c.json({ error: err.message }, 500);
96
+ });
97
+ return app;
98
+ }
@@ -0,0 +1,82 @@
1
+ import type { AgentSession, Envelope, EventsPage, GateResult, Phase, Session, SessionDetail, SessionSummary, SessionUsage } from "../shared/types.ts";
2
+ export declare class SfDb {
3
+ readonly path: string;
4
+ /**
5
+ * Where the ADW session dirs live: `{data_dir}/sessions/{adw_id}/{agent}/`.
6
+ * The db sits in the same data_dir (config's `observability.db` defaults to
7
+ * `adws/adw_data/spf.db`), so deriving it as a sibling of the db file keeps
8
+ * working when the whole data_dir is relocated.
9
+ */
10
+ readonly sessionsDir: string;
11
+ readonly journalMode: string;
12
+ private readonly db;
13
+ /** Opened on first archive and kept; null until then. */
14
+ private writer;
15
+ /** Cache for optionalColumn(), keyed "table.column". Only ever false → true. */
16
+ private readonly columnCache;
17
+ /** `path` is always absolute — resolved upstream by the ui command via paths.resolveAnchor/resolveDataPaths. */
18
+ constructor(path: string);
19
+ /**
20
+ * A SELECT fragment for a column the tracer adds by migration.
21
+ *
22
+ * We open readonly and cannot run those ALTERs ourselves, so selecting one
23
+ * blindly would throw "no such column" on every request against a db an older
24
+ * tracer wrote. Instead we probe and substitute NULL, which reads downstream
25
+ * as "this db predates the column" — the same thing the UI shows for a row
26
+ * the migration didn't backfill.
27
+ *
28
+ * The probe re-runs while the column is missing, because the tracer's ALTER
29
+ * can land while we're serving: a startup-only check would keep returning
30
+ * NULL for the rest of the process even after the data arrived. Once seen,
31
+ * a column never goes away, so it latches.
32
+ */
33
+ private hasColumn;
34
+ private optionalColumn;
35
+ close(): void;
36
+ /**
37
+ * Archive or restore a session — the only write in this process.
38
+ *
39
+ * busy_timeout matters: a run may be mid-insert on the same WAL db, and a
40
+ * click should wait its turn rather than fail. Returns false when the id
41
+ * does not exist, so the route can 404 instead of silently succeeding.
42
+ */
43
+ setArchived(adwId: string, archived: boolean): boolean;
44
+ /** Sessions, most recent first, each with its phase statuses for the progress dots. */
45
+ sessions(limit?: number): SessionSummary[];
46
+ session(adwId: string): Session | null;
47
+ phases(adwId: string): Phase[];
48
+ agentSessions(adwId: string): AgentSession[];
49
+ /**
50
+ * Agents per session, for a set of ids at once: the agent_sessions rows plus
51
+ * anything that has started but not finished.
52
+ *
53
+ * agents.ts writes the agent_sessions row only after the envelope persists, so
54
+ * a running agent has no row there — precisely the case the live view exists
55
+ * for. Its model, color and session_id are already on the agent_start event,
56
+ * so a lane is labelled and colored from the moment the agent spawns.
57
+ */
58
+ private agentsFor;
59
+ /** Session + phases + agents in one shot — L2 needs all three to draw lanes. */
60
+ sessionDetail(adwId: string): SessionDetail | null;
61
+ /**
62
+ * Raw tokens read and written, beside the billed headline.
63
+ *
64
+ * Derived from the `agent_end` payloads rather than stored, so every run
65
+ * already in the db gets the split without a migration or a re-run.
66
+ *
67
+ * `total_tokens` is a SPEND number: every turn re-sends the whole
68
+ * conversation, so an 86k conversation over 49 turns bills millions. These
69
+ * two say what actually moved — material read for the first time, and
70
+ * material generated. The gap between them and the headline is cached
71
+ * re-reads, which is usually most of it.
72
+ */
73
+ usage(adwId: string): SessionUsage;
74
+ /**
75
+ * The polling query. Rowid cursor, insertion order, bounded page — the same
76
+ * mechanism serves the live tail and lazy-paged history.
77
+ */
78
+ events(adwId: string, after?: number, limit?: number): EventsPage;
79
+ envelopes(adwId: string): Envelope[];
80
+ gates(adwId: string): GateResult[];
81
+ sessionCount(): number;
82
+ }
@@ -0,0 +1,333 @@
1
+ /**
2
+ * SQLite reader over a target repo's spf.db.
3
+ *
4
+ * The read connection is opened readonly and every query on it is a SELECT —
5
+ * the writers are the tracers of running ADW processes, and WAL lets us read
6
+ * straight through their inserts.
7
+ *
8
+ * ONE exception, opened lazily on its own connection: `setArchived`. Archiving
9
+ * is review triage — "I have looked at this run" — which has to outlive a
10
+ * browser, so it lives on the session row rather than in localStorage. It is
11
+ * the only write this process can make, it touches exactly one column, and it
12
+ * never runs unless a human clicks the button.
13
+ */
14
+ import { existsSync } from "node:fs";
15
+ import { dirname, resolve } from "node:path";
16
+ import { Database } from "../../core/sqlite.js";
17
+ const MAX_LIMIT = 1000;
18
+ const DEFAULT_LIMIT = 500;
19
+ export class SfDb {
20
+ path;
21
+ /**
22
+ * Where the ADW session dirs live: `{data_dir}/sessions/{adw_id}/{agent}/`.
23
+ * The db sits in the same data_dir (config's `observability.db` defaults to
24
+ * `adws/adw_data/spf.db`), so deriving it as a sibling of the db file keeps
25
+ * working when the whole data_dir is relocated.
26
+ */
27
+ sessionsDir;
28
+ journalMode;
29
+ db;
30
+ /** Opened on first archive and kept; null until then. */
31
+ writer = null;
32
+ /** Cache for optionalColumn(), keyed "table.column". Only ever false → true. */
33
+ columnCache = new Map();
34
+ /** `path` is always absolute — resolved upstream by the ui command via paths.resolveAnchor/resolveDataPaths. */
35
+ constructor(path) {
36
+ if (!existsSync(path)) {
37
+ throw new Error(`spf.db not found at ${path}\n` +
38
+ `Point spf ui at a target repo: --db <path>, SF_DB=<path>, --cwd <repo>, or run it from inside one.`);
39
+ }
40
+ this.path = path;
41
+ this.sessionsDir = resolve(dirname(path), "sessions");
42
+ this.db = new Database(path, { readonly: true });
43
+ // WAL is set by the tracer when it creates the db; a readonly connection
44
+ // cannot change it, so we assert rather than set, and always take the
45
+ // busy_timeout so a concurrent writer never turns into a failed request.
46
+ this.db.exec("PRAGMA busy_timeout = 5000");
47
+ this.db.exec("PRAGMA synchronous = NORMAL");
48
+ const mode = this.db
49
+ .query("PRAGMA journal_mode")
50
+ .get();
51
+ this.journalMode = mode?.journal_mode ?? "unknown";
52
+ if (this.journalMode.toLowerCase() !== "wal") {
53
+ console.warn(`[db] journal_mode is "${this.journalMode}", expected "wal" — ` +
54
+ `live reads during agent writes may block`);
55
+ }
56
+ }
57
+ /**
58
+ * A SELECT fragment for a column the tracer adds by migration.
59
+ *
60
+ * We open readonly and cannot run those ALTERs ourselves, so selecting one
61
+ * blindly would throw "no such column" on every request against a db an older
62
+ * tracer wrote. Instead we probe and substitute NULL, which reads downstream
63
+ * as "this db predates the column" — the same thing the UI shows for a row
64
+ * the migration didn't backfill.
65
+ *
66
+ * The probe re-runs while the column is missing, because the tracer's ALTER
67
+ * can land while we're serving: a startup-only check would keep returning
68
+ * NULL for the rest of the process even after the data arrived. Once seen,
69
+ * a column never goes away, so it latches.
70
+ */
71
+ hasColumn(table, column) {
72
+ const key = `${table}.${column}`;
73
+ if (!this.columnCache.get(key)) {
74
+ const cols = this.db
75
+ .query(`PRAGMA table_info(${table})`)
76
+ .all();
77
+ this.columnCache.set(key, cols.some((c) => c.name === column));
78
+ }
79
+ return this.columnCache.get(key) ?? false;
80
+ }
81
+ optionalColumn(table, column) {
82
+ return this.hasColumn(table, column) ? column : `NULL AS ${column}`;
83
+ }
84
+ close() {
85
+ this.writer?.close();
86
+ this.db.close();
87
+ }
88
+ /**
89
+ * Archive or restore a session — the only write in this process.
90
+ *
91
+ * busy_timeout matters: a run may be mid-insert on the same WAL db, and a
92
+ * click should wait its turn rather than fail. Returns false when the id
93
+ * does not exist, so the route can 404 instead of silently succeeding.
94
+ */
95
+ setArchived(adwId, archived) {
96
+ if (!this.hasColumn("sessions", "archived")) {
97
+ throw new Error("this db predates the archived column — run any ADW once to migrate it");
98
+ }
99
+ if (!this.writer) {
100
+ this.writer = new Database(this.path);
101
+ this.writer.exec("PRAGMA busy_timeout=5000;");
102
+ }
103
+ this.writer
104
+ .query("UPDATE sessions SET archived = ? WHERE adw_id = ?")
105
+ .run(archived ? 1 : 0, adwId);
106
+ return this.session(adwId) !== null;
107
+ }
108
+ /** Sessions, most recent first, each with its phase statuses for the progress dots. */
109
+ sessions(limit = 200) {
110
+ const rows = this.db
111
+ .query(`SELECT adw_id, ${this.optionalColumn("sessions", "adw_name")}, request,
112
+ status, engineer, started_at, ended_at,
113
+ total_tokens, total_cost,
114
+ ${this.optionalColumn("sessions", "archived")}
115
+ FROM sessions
116
+ WHERE COALESCE(${this.hasColumn("sessions", "archived") ? "archived" : "0"}, 0) = 0
117
+ ORDER BY started_at DESC, rowid DESC
118
+ LIMIT ?`)
119
+ .all(clamp(limit, 1, MAX_LIMIT));
120
+ if (rows.length === 0)
121
+ return [];
122
+ // Embed each session's phases so the L1 progress dots cost no extra request.
123
+ const ids = rows.map((row) => row.adw_id);
124
+ const placeholders = ids.map(() => "?").join(", ");
125
+ const phaseRows = this.db
126
+ .query(`SELECT phase_id, adw_id, seq, name, kind, owner, description, status,
127
+ attempt, retries, error, started_at, ended_at
128
+ FROM phases WHERE adw_id IN (${placeholders}) ORDER BY seq, rowid`)
129
+ .all(...ids);
130
+ const byAdw = new Map();
131
+ for (const phase of phaseRows) {
132
+ const list = byAdw.get(phase.adw_id);
133
+ if (list)
134
+ list.push(phase);
135
+ else
136
+ byAdw.set(phase.adw_id, [phase]);
137
+ }
138
+ // Agents come along too: an L1 card draws a per-agent dot timeline, and its
139
+ // dots are colored per agent — without this it would be one request per card.
140
+ const agentsByAdw = this.agentsFor(ids);
141
+ const summaries = [];
142
+ for (const session of rows) {
143
+ const phases = byAdw.get(session.adw_id) ?? [];
144
+ summaries.push(Object.assign(session, {
145
+ phases,
146
+ phase_count: phases.length,
147
+ agents: agentsByAdw.get(session.adw_id) ?? [],
148
+ }));
149
+ }
150
+ return summaries;
151
+ }
152
+ session(adwId) {
153
+ return (this.db
154
+ .query(`SELECT adw_id, ${this.optionalColumn("sessions", "adw_name")}, request,
155
+ status, engineer, started_at, ended_at,
156
+ total_tokens, total_cost
157
+ FROM sessions WHERE adw_id = ?`)
158
+ .get(adwId) ?? null);
159
+ }
160
+ phases(adwId) {
161
+ return this.db
162
+ .query(`SELECT phase_id, adw_id, seq, name, kind, owner, description, status,
163
+ attempt, retries, error, started_at, ended_at
164
+ FROM phases WHERE adw_id = ? ORDER BY seq, rowid`)
165
+ .all(adwId);
166
+ }
167
+ agentSessions(adwId) {
168
+ return this.agentsFor([adwId]).get(adwId) ?? [];
169
+ }
170
+ /**
171
+ * Agents per session, for a set of ids at once: the agent_sessions rows plus
172
+ * anything that has started but not finished.
173
+ *
174
+ * agents.ts writes the agent_sessions row only after the envelope persists, so
175
+ * a running agent has no row there — precisely the case the live view exists
176
+ * for. Its model, color and session_id are already on the agent_start event,
177
+ * so a lane is labelled and colored from the moment the agent spawns.
178
+ */
179
+ agentsFor(adwIds) {
180
+ const byAdw = new Map();
181
+ if (adwIds.length === 0)
182
+ return byAdw;
183
+ const placeholders = adwIds.map(() => "?").join(", ");
184
+ const append = (adwId, agent) => {
185
+ const list = byAdw.get(adwId);
186
+ if (list)
187
+ list.push(agent);
188
+ else
189
+ byAdw.set(adwId, [agent]);
190
+ };
191
+ const color = this.optionalColumn("agent_sessions", "color");
192
+ const ctxUsed = this.optionalColumn("agent_sessions", "context_tokens");
193
+ const ctxWindow = this.optionalColumn("agent_sessions", "context_window");
194
+ const completed = this.db
195
+ .query(`SELECT adw_id, agent, coding_agent, model, session_id, ${color},
196
+ ${ctxUsed}, ${ctxWindow}, created_at, last_used_at
197
+ FROM agent_sessions WHERE adw_id IN (${placeholders})
198
+ ORDER BY created_at, agent`)
199
+ .all(...adwIds);
200
+ for (const row of completed)
201
+ append(row.adw_id, row);
202
+ const started = this.db
203
+ .query(`SELECT e.adw_id, p.owner AS agent, e.payload_json, e.started_at
204
+ FROM events e JOIN phases p ON p.phase_id = e.phase_id
205
+ WHERE e.adw_id IN (${placeholders}) AND e.type = 'agent_start'
206
+ ORDER BY e.rowid`)
207
+ .all(...adwIds);
208
+ for (const row of started) {
209
+ if (!row.agent)
210
+ continue;
211
+ // A finished row is authoritative; only fill genuine gaps.
212
+ if (byAdw.get(row.adw_id)?.some((a) => a.agent === row.agent))
213
+ continue;
214
+ let payload = {};
215
+ try {
216
+ payload = JSON.parse(row.payload_json ?? "{}");
217
+ }
218
+ catch {
219
+ // A malformed payload just means no label — never a failed request.
220
+ }
221
+ append(row.adw_id, {
222
+ adw_id: row.adw_id,
223
+ agent: row.agent,
224
+ coding_agent: null,
225
+ model: payload.model ?? null,
226
+ session_id: payload.session_id ?? null,
227
+ color: payload.color ?? null,
228
+ // Occupancy is only known once the agent's turn closes.
229
+ context_tokens: null,
230
+ context_window: null,
231
+ created_at: row.started_at,
232
+ last_used_at: row.started_at,
233
+ });
234
+ }
235
+ return byAdw;
236
+ }
237
+ /** Session + phases + agents in one shot — L2 needs all three to draw lanes. */
238
+ sessionDetail(adwId) {
239
+ const session = this.session(adwId);
240
+ if (!session)
241
+ return null;
242
+ return {
243
+ session,
244
+ usage: this.usage(adwId),
245
+ phases: this.phases(adwId),
246
+ agents: this.agentSessions(adwId),
247
+ };
248
+ }
249
+ /**
250
+ * Raw tokens read and written, beside the billed headline.
251
+ *
252
+ * Derived from the `agent_end` payloads rather than stored, so every run
253
+ * already in the db gets the split without a migration or a re-run.
254
+ *
255
+ * `total_tokens` is a SPEND number: every turn re-sends the whole
256
+ * conversation, so an 86k conversation over 49 turns bills millions. These
257
+ * two say what actually moved — material read for the first time, and
258
+ * material generated. The gap between them and the headline is cached
259
+ * re-reads, which is usually most of it.
260
+ */
261
+ usage(adwId) {
262
+ const rows = this.db
263
+ .query("SELECT payload_json FROM events WHERE adw_id = ? AND type = 'agent_end'")
264
+ .all(adwId);
265
+ let read = 0;
266
+ let written = 0;
267
+ for (const row of rows) {
268
+ if (!row.payload_json)
269
+ continue;
270
+ try {
271
+ const u = JSON.parse(row.payload_json).usage;
272
+ if (!u)
273
+ continue;
274
+ // RAW reads only: material entering the context for the first time,
275
+ // billed either as uncached input or as a cache write. Cache reads are
276
+ // the same tokens served again on later turns — counting them here
277
+ // would rebuild the very inflation this split exists to expose.
278
+ read += (u.input_tokens ?? 0) + (u.cache_write_tokens ?? 0);
279
+ written += u.output_tokens ?? 0;
280
+ }
281
+ catch {
282
+ /* a payload written by an older tracer simply contributes nothing */
283
+ }
284
+ }
285
+ return { read, written };
286
+ }
287
+ /**
288
+ * The polling query. Rowid cursor, insertion order, bounded page — the same
289
+ * mechanism serves the live tail and lazy-paged history.
290
+ */
291
+ events(adwId, after = 0, limit = DEFAULT_LIMIT) {
292
+ const cappedLimit = clamp(limit, 1, MAX_LIMIT);
293
+ const events = this.db
294
+ .query(`SELECT rowid, event_id, adw_id, phase_id, parent_id, type, name,
295
+ payload_json, tokens, started_at, ended_at
296
+ FROM events
297
+ WHERE adw_id = ? AND rowid > ?
298
+ ORDER BY rowid
299
+ LIMIT ?`)
300
+ .all(adwId, Math.max(0, after), cappedLimit);
301
+ return {
302
+ events,
303
+ cursor: events.length > 0 ? events[events.length - 1].rowid : Math.max(0, after),
304
+ has_more: events.length === cappedLimit,
305
+ };
306
+ }
307
+ envelopes(adwId) {
308
+ return this.db
309
+ .query(`SELECT envelope_id, adw_id, phase_id, agent, output_type, payload_json,
310
+ valid, attempt, created_at
311
+ FROM envelopes WHERE adw_id = ? ORDER BY created_at, rowid`)
312
+ .all(adwId);
313
+ }
314
+ gates(adwId) {
315
+ const checks = this.optionalColumn("gate_results", "checks_json");
316
+ return this.db
317
+ .query(`SELECT id, adw_id, phase_id, attempt, gate, passed, violations_json,
318
+ ${checks}, created_at
319
+ FROM gate_results WHERE adw_id = ? ORDER BY id`)
320
+ .all(adwId);
321
+ }
322
+ sessionCount() {
323
+ const row = this.db
324
+ .query("SELECT COUNT(*) AS n FROM sessions")
325
+ .get();
326
+ return row?.n ?? 0;
327
+ }
328
+ }
329
+ function clamp(value, min, max) {
330
+ if (!Number.isFinite(value))
331
+ return min;
332
+ return Math.min(max, Math.max(min, Math.trunc(value)));
333
+ }
@@ -0,0 +1,13 @@
1
+ export interface UiOptions {
2
+ dbPath: string;
3
+ webDir: string;
4
+ /** Explicit port. Omit to try the default and probe upward on collision. */
5
+ port?: number;
6
+ open?: boolean;
7
+ }
8
+ export interface UiHandle {
9
+ url: string;
10
+ port: number;
11
+ close(): Promise<void>;
12
+ }
13
+ export declare function runUi(options: UiOptions): Promise<UiHandle>;
@@ -0,0 +1,80 @@
1
+ /**
2
+ * `spf ui` bootstrap: bind the app to a real port, print the URL, optionally
3
+ * open a browser, and shut down cleanly on SIGINT/SIGTERM.
4
+ *
5
+ * Binds loopback-only (127.0.0.1) deliberately — this server can flip a
6
+ * database column and reveal agent prompts, and a companion CLI has no
7
+ * reason to listen on every interface.
8
+ */
9
+ import { spawn } from "node:child_process";
10
+ import { serve } from "@hono/node-server";
11
+ import { createApp } from "./app.js";
12
+ import { SfDb } from "./db.js";
13
+ const DEFAULT_PORT = 4600;
14
+ const PORT_PROBE_ATTEMPTS = 10;
15
+ function isAddrInUse(error) {
16
+ return typeof error === "object" && error !== null && error.code === "EADDRINUSE";
17
+ }
18
+ function listen(app, port) {
19
+ return new Promise((resolvePromise, reject) => {
20
+ const server = serve({ fetch: app.fetch, port, hostname: "127.0.0.1" }, () => resolvePromise(server));
21
+ server.on("error", reject);
22
+ });
23
+ }
24
+ /** Best-effort only — a failure here (no GUI, unknown platform) is never fatal. */
25
+ function openUrl(url) {
26
+ const platform = process.platform;
27
+ const [cmd, args] = platform === "darwin" ? ["open", [url]] : platform === "win32" ? ["cmd", ["/c", "start", "", url]] : ["xdg-open", [url]];
28
+ try {
29
+ const child = spawn(cmd, args, { detached: true, stdio: "ignore" });
30
+ child.on("error", () => { }); // e.g. xdg-open not installed — swallow, url is already printed
31
+ child.unref();
32
+ }
33
+ catch {
34
+ // swallow — the URL is already printed either way
35
+ }
36
+ }
37
+ export async function runUi(options) {
38
+ const db = new SfDb(options.dbPath);
39
+ const app = createApp(db, options.webDir);
40
+ const explicit = options.port !== undefined;
41
+ const startPort = options.port ?? DEFAULT_PORT;
42
+ let server;
43
+ let port = startPort;
44
+ const attempts = explicit ? 1 : PORT_PROBE_ATTEMPTS;
45
+ for (let i = 0; i < attempts; i++) {
46
+ port = startPort + i;
47
+ try {
48
+ server = await listen(app, port);
49
+ break;
50
+ }
51
+ catch (error) {
52
+ if (!isAddrInUse(error))
53
+ throw error;
54
+ if (i === attempts - 1) {
55
+ db.close();
56
+ throw new Error(explicit
57
+ ? `port ${port} is already in use — pick another with --port`
58
+ : `ports ${startPort}-${port} are all in use — pick one explicitly with --port`);
59
+ }
60
+ }
61
+ }
62
+ const info = server.address();
63
+ const boundPort = info?.port ?? port;
64
+ const url = `http://127.0.0.1:${boundPort}`;
65
+ const shouldOpen = options.open !== false && process.stdout.isTTY && !process.env.CI && !process.env.SSH_CONNECTION;
66
+ if (shouldOpen)
67
+ openUrl(url);
68
+ const close = () => new Promise((resolvePromise) => {
69
+ server.close(() => {
70
+ db.close();
71
+ resolvePromise();
72
+ });
73
+ });
74
+ const shutdown = () => {
75
+ void close().then(() => process.exit(0));
76
+ };
77
+ process.on("SIGINT", shutdown);
78
+ process.on("SIGTERM", shutdown);
79
+ return { url, port: boundPort, close };
80
+ }
@@ -0,0 +1,5 @@
1
+ import type { Context } from "hono";
2
+ /** Throws if `webDir` has no index.html — call this at startup, before serving anything. */
3
+ export declare function assertWebDir(webDir: string): void;
4
+ /** `assertWebDir` runs once, here, at app-construction time — not per request. */
5
+ export declare function serveStatic(webDir: string): (c: Context) => Response;
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Serves the packaged SPA (built once, at publish time — see app/vite.config.ts)
3
+ * from web/, a sibling of dist/ in the published package. Hand-rolled rather
4
+ * than @hono/node-server/serve-static, whose `root` option resolves against
5
+ * process.cwd() — wrong here, since `spf ui` runs from an arbitrary target repo,
6
+ * not from inside the package.
7
+ *
8
+ * A missing web/ (no dist/index.html) is a hard startup error, not a
9
+ * plaintext fallback response: the SPA is always prebuilt before publish, so
10
+ * its absence can only mean a corrupt install.
11
+ */
12
+ import { existsSync, readFileSync, statSync } from "node:fs";
13
+ import { extname, join, resolve, sep } from "node:path";
14
+ const MIME = {
15
+ ".html": "text/html; charset=utf-8",
16
+ ".js": "text/javascript; charset=utf-8",
17
+ ".mjs": "text/javascript; charset=utf-8",
18
+ ".css": "text/css; charset=utf-8",
19
+ ".svg": "image/svg+xml",
20
+ ".png": "image/png",
21
+ ".webp": "image/webp",
22
+ ".woff2": "font/woff2",
23
+ ".woff": "font/woff",
24
+ ".json": "application/json; charset=utf-8",
25
+ ".ico": "image/x-icon",
26
+ ".txt": "text/plain; charset=utf-8",
27
+ ".map": "application/json; charset=utf-8",
28
+ };
29
+ /** Throws if `webDir` has no index.html — call this at startup, before serving anything. */
30
+ export function assertWebDir(webDir) {
31
+ if (!existsSync(join(webDir, "index.html"))) {
32
+ throw new Error(`spf ui: no packaged UI found at ${webDir} (missing index.html) — this looks like a corrupt install; try reinstalling.`);
33
+ }
34
+ }
35
+ /** `assertWebDir` runs once, here, at app-construction time — not per request. */
36
+ export function serveStatic(webDir) {
37
+ assertWebDir(webDir);
38
+ return (c) => {
39
+ const pathname = new URL(c.req.url).pathname;
40
+ // Reject traversal before touching the filesystem.
41
+ const candidate = resolve(join(webDir, pathname));
42
+ if (candidate === webDir || candidate.startsWith(webDir + sep)) {
43
+ if (existsSync(candidate) && statSync(candidate).isFile()) {
44
+ const headers = { "content-type": MIME[extname(candidate)] ?? "application/octet-stream" };
45
+ // Vite content-hashes everything under assets/ — safe to cache forever.
46
+ headers["cache-control"] = pathname.startsWith("/assets/") ? "public, max-age=31536000, immutable" : "no-store";
47
+ return new Response(readFileSync(candidate), { headers });
48
+ }
49
+ }
50
+ // SPA fallback: breadcrumb routes are client-side.
51
+ return new Response(readFileSync(join(webDir, "index.html"), "utf-8"), {
52
+ headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" },
53
+ });
54
+ };
55
+ }