@gr8ful/spf 0.4.0 → 0.5.1

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 (80) hide show
  1. package/README.md +122 -4
  2. package/assets/defaults/spf.config.yaml +6 -0
  3. package/assets/prompts/reviewer/system.md +1 -1
  4. package/assets/skill/SKILL.md +1 -0
  5. package/assets/skill/cookbooks/authoring_chains.md +90 -7
  6. package/assets/skill/cookbooks/ocr_reviewer.md +196 -0
  7. package/assets/skill/cookbooks/roster.md +15 -4
  8. package/assets/skill/cookbooks/spf_overview.md +1 -0
  9. package/assets/skill/references/config.md +69 -4
  10. package/assets/skill/references/observability.md +11 -2
  11. package/assets/templates/ts-flue-ollama.spf.config.yaml +67 -0
  12. package/assets/templates/ts.spf.config.yaml +5 -0
  13. package/dist/chains/context.d.ts +30 -0
  14. package/dist/chains/index.d.ts +94 -10
  15. package/dist/chains/index.js +70 -5
  16. package/dist/chains/repo_chains.d.ts +139 -0
  17. package/dist/chains/repo_chains.js +428 -0
  18. package/dist/chains/simple_sdlc.d.ts +74 -1
  19. package/dist/chains/simple_sdlc.js +134 -4
  20. package/dist/chains/steps.d.ts +215 -20
  21. package/dist/chains/steps.js +429 -61
  22. package/dist/cli/ask.d.ts +14 -1
  23. package/dist/cli/ask.js +32 -2
  24. package/dist/cli/commands/doctor.d.ts +1 -1
  25. package/dist/cli/commands/doctor.js +319 -11
  26. package/dist/cli/commands/init.d.ts +12 -0
  27. package/dist/cli/commands/init.js +78 -1
  28. package/dist/cli/commands/list.js +42 -5
  29. package/dist/cli/commands/run.js +25 -2
  30. package/dist/cli/commands/watch.d.ts +18 -0
  31. package/dist/cli/commands/watch.js +158 -10
  32. package/dist/cli/index.js +60 -3
  33. package/dist/cli/interview.js +65 -10
  34. package/dist/core/agent_cc.d.ts +40 -1
  35. package/dist/core/agent_cc.js +51 -4
  36. package/dist/core/agent_flue.js +28 -4
  37. package/dist/core/agents.d.ts +8 -0
  38. package/dist/core/agents.js +43 -3
  39. package/dist/core/data_types.d.ts +104 -4
  40. package/dist/core/data_types.js +99 -2
  41. package/dist/core/git_helper.d.ts +29 -0
  42. package/dist/core/git_helper.js +41 -1
  43. package/dist/core/ollama_provider.d.ts +70 -0
  44. package/dist/core/ollama_provider.js +208 -0
  45. package/dist/core/otel.d.ts +352 -0
  46. package/dist/core/otel.js +793 -0
  47. package/dist/core/paths.d.ts +3 -0
  48. package/dist/core/paths.js +48 -1
  49. package/dist/core/providers.js +4 -0
  50. package/dist/core/refine.js +11 -3
  51. package/dist/core/session.js +39 -2
  52. package/dist/core/tracer.d.ts +31 -2
  53. package/dist/core/tracer.js +69 -11
  54. package/dist/core/watch.d.ts +11 -0
  55. package/dist/core/watch.js +17 -2
  56. package/dist/test/chains.test.js +8 -3
  57. package/dist/test/data_types.test.js +140 -2
  58. package/dist/test/git_helper.test.d.ts +1 -0
  59. package/dist/test/git_helper.test.js +59 -0
  60. package/dist/test/hermetic_git.d.ts +1 -0
  61. package/dist/test/hermetic_git.js +22 -0
  62. package/dist/test/init_command.test.d.ts +14 -1
  63. package/dist/test/init_command.test.js +54 -1
  64. package/dist/test/interview.test.d.ts +15 -1
  65. package/dist/test/interview.test.js +127 -0
  66. package/dist/test/ollama_provider.test.d.ts +1 -0
  67. package/dist/test/ollama_provider.test.js +103 -0
  68. package/dist/test/otel.test.d.ts +26 -0
  69. package/dist/test/otel.test.js +512 -0
  70. package/dist/test/paths.test.d.ts +1 -0
  71. package/dist/test/paths.test.js +68 -0
  72. package/dist/test/refine.test.js +64 -1
  73. package/dist/test/repo_chains.test.d.ts +21 -0
  74. package/dist/test/repo_chains.test.js +416 -0
  75. package/dist/test/signoff.test.d.ts +1 -0
  76. package/dist/test/signoff.test.js +329 -0
  77. package/dist/test/ui_server.test.d.ts +7 -1
  78. package/dist/test/ui_server.test.js +1 -0
  79. package/dist/test/watch.test.js +124 -1
  80. package/package.json +5 -5
@@ -67,6 +67,9 @@ export interface DataPaths {
67
67
  * alwaysWritable()` matches `cfg.defaults.data_dir` against repo-relative git
68
68
  * output, so that field stays exactly as configured. This produces a
69
69
  * parallel, absolute set of paths for filesystem use instead of mutating it.
70
+ *
71
+ * Not otherwise side-effect-free: see `healBrokenDataDir` above, which every
72
+ * caller needs run before its own first `mkdirSync` against `data_dir`.
70
73
  */
71
74
  export declare function resolveDataPaths(anchor: RepoAnchor, rawDataDir: string, rawDbPath: string): DataPaths;
72
75
  /**
@@ -16,7 +16,7 @@
16
16
  * levels below the package root, so the same relative path resolves either
17
17
  * way), with `assets/` a direct child of it.
18
18
  */
19
- import { existsSync, statSync } from "node:fs";
19
+ import { existsSync, lstatSync, realpathSync, rmSync, statSync } from "node:fs";
20
20
  import path from "node:path";
21
21
  import { findRepoRoot } from "./git_helper.js";
22
22
  export const PACKAGE_ROOT = path.resolve(import.meta.dirname, "..", "..");
@@ -67,6 +67,49 @@ export function resolveConfigPaths(anchor, explicit) {
67
67
  }
68
68
  return { paths: [BUILTIN_CONFIG_PATH], source: "built-in-only" };
69
69
  }
70
+ /**
71
+ * Self-heals one specific, previously-observed failure mode: `data_dir`
72
+ * existing as a broken symlink — self-referential (points at itself) or
73
+ * dangling (points at something that no longer exists) — instead of a real
74
+ * directory. `spf watch`'s `linkDataDir` guards against ever CREATING one of
75
+ * these now (see its comment in `cli/commands/watch.ts`), but a prior,
76
+ * unguarded build already left one behind on at least one real checkout, and
77
+ * that state persists across every later `spf` invocation until something
78
+ * removes it: `mkdirSync(data_dir, {recursive: true})` — the very first
79
+ * filesystem touch nearly every command makes, whether via `ensureDir`'s
80
+ * session dirs or `acquireLock`'s lockfile dir — throws `ELOOP` before
81
+ * anything else runs, with no recovery path of its own.
82
+ *
83
+ * This has to run HERE, in the one function every one of those call sites
84
+ * resolves `data_dir` through, rather than as a guard inside each of them
85
+ * individually — a fix duplicated at every call site is a fix that's one new
86
+ * call site away from being missed again.
87
+ *
88
+ * `lstatSync` (never follows the link itself) distinguishes "nothing here
89
+ * yet" (the ordinary first-run case — silently returns, nothing to heal)
90
+ * from "a symlink sits here." Only for the symlink case does `realpathSync`
91
+ * (which fully resolves it) get a chance to throw — `ELOOP` for a circular
92
+ * link, `ENOENT` for a dangling target — and only then is anything removed.
93
+ * A real directory never reaches the `realpathSync` call at all.
94
+ */
95
+ function healBrokenDataDir(data_dir) {
96
+ let entry;
97
+ try {
98
+ entry = lstatSync(data_dir);
99
+ }
100
+ catch {
101
+ return; // nothing at this path at all — ordinary first run
102
+ }
103
+ if (!entry.isSymbolicLink())
104
+ return; // a real directory (or file) — not this bug
105
+ try {
106
+ realpathSync(data_dir);
107
+ }
108
+ catch {
109
+ rmSync(data_dir);
110
+ console.error(`spf: removed a broken data_dir symlink at ${data_dir} (self-referential or dangling) — a fresh directory will be created in its place`);
111
+ }
112
+ }
70
113
  /**
71
114
  * Anchor a config's own `data_dir`/`observability.db` (as loaded — relative
72
115
  * or absolute, whichever the YAML says) to `repo_root`.
@@ -75,9 +118,13 @@ export function resolveConfigPaths(anchor, explicit) {
75
118
  * alwaysWritable()` matches `cfg.defaults.data_dir` against repo-relative git
76
119
  * output, so that field stays exactly as configured. This produces a
77
120
  * parallel, absolute set of paths for filesystem use instead of mutating it.
121
+ *
122
+ * Not otherwise side-effect-free: see `healBrokenDataDir` above, which every
123
+ * caller needs run before its own first `mkdirSync` against `data_dir`.
78
124
  */
79
125
  export function resolveDataPaths(anchor, rawDataDir, rawDbPath) {
80
126
  const data_dir = path.resolve(anchor.repo_root, rawDataDir);
127
+ healBrokenDataDir(data_dir);
81
128
  const data_dir_rel = path.relative(anchor.repo_root, data_dir).split(path.sep).join("/");
82
129
  const db_path = path.resolve(anchor.repo_root, rawDbPath);
83
130
  const sessions_dir = path.resolve(path.dirname(db_path), "sessions");
@@ -21,4 +21,8 @@ export const PROVIDER_ENV_KEYS = {
21
21
  deepseek: ["DEEPSEEK_API_KEY"],
22
22
  together: ["TOGETHER_API_KEY"],
23
23
  cerebras: ["CEREBRAS_API_KEY"],
24
+ // Keyless: a local server, not a hosted API — nothing to check for or
25
+ // prompt for. An empty array here means "known provider, needs no key",
26
+ // never "unknown provider" (that's a missing table entry, not `[]`).
27
+ ollama: [],
24
28
  };
@@ -27,14 +27,22 @@ export function resolveAuthoringProvider(cfg) {
27
27
  throw new Error(`watch.issue_provider ${JSON.stringify(cfg.watch.issue_provider)} does not support issue authoring — ` +
28
28
  `the refine lane needs "github" (see jira_provider.ts's module comment on why Jira isn't wired up yet)`);
29
29
  }
30
- if (!cfg.watch.repo.trim()) {
31
- throw new Error(`watch.repo is not configured add it to spf.config.yaml's watch: section, e.g. "owner/name"`);
30
+ // Issue authoring always targets the ISSUE tracker's repo — `issue_repo`
31
+ // if set, falling back to plain `repo` (the common case: issue_provider
32
+ // and code_host are both github, so they're the same repo). Reading
33
+ // `repo` alone would be wrong for a github-issues + bitbucket-code setup,
34
+ // where `repo` names the BITBUCKET repo (see WatchConfigSchema's doc
35
+ // comment) — this would try to create GitHub issues against a Bitbucket
36
+ // identifier.
37
+ const repo = cfg.watch.issue_repo.trim() || cfg.watch.repo.trim();
38
+ if (!repo) {
39
+ throw new Error(`watch.repo (or watch.issue_repo, if code_host names a different repo) is not configured — add it to spf.config.yaml's watch: section, e.g. "owner/name"`);
32
40
  }
33
41
  const token = process.env["GITHUB_TOKEN"];
34
42
  if (!token) {
35
43
  throw new Error('GITHUB_TOKEN is not set — the refine lane needs a classic PAT with "repo" scope (or "public_repo" for a public-only repo)');
36
44
  }
37
- return new GitHubProvider(cfg.watch.repo, cfg.watch.label_prefix, token);
45
+ return new GitHubProvider(repo, cfg.watch.label_prefix, token);
38
46
  }
39
47
  function typeLabel(labelPrefix, kind) {
40
48
  return `${labelPrefix}:type:${kind}`;
@@ -11,6 +11,15 @@ import { Run } from "./runner.js";
11
11
  import { Tracer } from "./tracer.js";
12
12
  import { engineerName, newId } from "./utils.js";
13
13
  import { resolveNotifier } from "./notify/notifier.js";
14
+ import * as otel from "./otel.js";
15
+ /**
16
+ * How long a signalled run may spend pushing spans before it exits anyway.
17
+ * Short on purpose: someone who just pressed ^C is waiting, and an
18
+ * observability projection is never worth making a kill feel broken. The
19
+ * budget is enforced inside `otel.flushAll()` (a raced, unref'd deadline), so
20
+ * an unreachable collector costs exactly this and not one tick more.
21
+ */
22
+ const SIGNAL_DRAIN_MS = 750;
14
23
  /**
15
24
  * A killed run still closes its own trace.
16
25
  *
@@ -20,11 +29,31 @@ import { resolveNotifier } from "./notify/notifier.js";
20
29
  * flight that is already dead. Handling the signal both finalizes here and
21
30
  * lets the phase's try/catch record the phase as failed on the way out
22
31
  * (best-effort: a signal can still land mid-write).
32
+ *
33
+ * SQLite is written FIRST and synchronously, exactly as before — the otel
34
+ * drain is appended after it and can only ever cost time, never correctness.
35
+ * (`notify` has no equivalent drain on this path: its in-flight webhooks are
36
+ * dropped on a signal today. Fixing that means touching the notifier's
37
+ * lifecycle, which is outside this change; only the otel path is drained here.)
38
+ * A second signal during the drain exits immediately — someone pressing ^C
39
+ * twice means "now", and a shutdown path that ignores that is a hang.
23
40
  */
24
41
  function finalizeWhenKilled(run) {
42
+ let draining = false;
25
43
  const handler = (signal) => {
44
+ const code = 128 + (signal === "SIGINT" ? 2 : 15);
45
+ if (draining)
46
+ process.exit(code);
47
+ draining = true;
26
48
  run.tracer.sessionFinish(run.adw_id, false); // also closes process rows
27
- process.exit(128 + (signal === "SIGINT" ? 2 : 15));
49
+ // Unconfigured (the default) exits SYNCHRONOUSLY, exactly as it did before
50
+ // otel existed — no extra tick between the signal and the exit for the
51
+ // repos that never opted in.
52
+ if (!run.tracer.otel)
53
+ process.exit(code);
54
+ // Bounded and never-throwing: flushAll() swallows its own failures and
55
+ // resolves on its own deadline, so this always reaches process.exit().
56
+ void otel.flushAll(SIGNAL_DRAIN_MS).then(() => process.exit(code), () => process.exit(code));
28
57
  };
29
58
  process.on("SIGTERM", handler);
30
59
  process.on("SIGINT", handler);
@@ -44,7 +73,15 @@ export function ensure(cfg, adwId, cwd, chainName) {
44
73
  const id = adwId || newId(8);
45
74
  const anchor = paths.resolveAnchor(cwd);
46
75
  const dataPaths = paths.resolveDataPaths(anchor, cfg.defaults.data_dir, cfg.observability.db);
47
- const tracer = new Tracer(dataPaths.db_path, path.join(dataPaths.sessions_dir, id, "events.jsonl"));
76
+ // `null` unless `observability.otel` is configured — no environment variable
77
+ // can turn this on (see core/otel.ts's EXPLICIT CONFIG ONLY). Constructed
78
+ // BEFORE the Tracer because the Tracer's write methods are the fan-out
79
+ // seams: SQLite stays the source of truth, otel is a projection off it, and
80
+ // registering here (module-level LIVE, exactly like resolveNotifier) is what
81
+ // lets the CLI's finally block and the signal handler above drain it without
82
+ // threading a handle through every call site.
83
+ const otelExporter = otel.resolveOtelExporter(cfg, { adwId: id, chainName: chainName || "adw" });
84
+ const tracer = new Tracer(dataPaths.db_path, path.join(dataPaths.sessions_dir, id, "events.jsonl"), otelExporter);
48
85
  const run = new Run({
49
86
  cfg,
50
87
  adwId: id,
@@ -2,18 +2,47 @@
2
2
  * Tracer: every event lands in JSONL and SQLite AS IT HAPPENS.
3
3
  *
4
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
5
  * WAL mode so the UI can read while ADW processes write.
6
+ *
7
+ * No push transport in the CONTROL flow — that is always, still, and only:
8
+ * agents -> sqlite -> web ui. SQLite is the source of truth; nothing
9
+ * downstream of it can affect a phase, a gate, or a run outcome.
10
+ *
11
+ * The one amendment: when (and only when) `observability.otel` is configured,
12
+ * each write method below ends with a single fan-out line to an optional
13
+ * OtelExporter — a lossy, allowlisted PROJECTION of what was just written,
14
+ * pushed to an OTLP endpoint fire-and-forget. It is deliberately NOT a second
15
+ * record: it never blocks, never throws into a caller (see `fanOut`), drops
16
+ * spans under backpressure, and carries only the allowlisted subset of fields
17
+ * (never `EventRecord.payload`, never the request text, never envelope
18
+ * contents — `core/otel.ts`'s header has the full list and the reasons).
19
+ * Methods whose data is entirely outside that allowlist —
20
+ * `sessionRequest` (the operator's prompt), `envelopeRow` (agent output),
21
+ * `processStart`/`processEnd` (pids) — have NO fan-out line on purpose. Do not
22
+ * add one.
7
23
  */
8
24
  import { Database } from "./sqlite.ts";
9
25
  import type { AgentConfig, EventRecord, GateReport, Phase } from "./data_types.ts";
26
+ import type { OtelExporter } from "./otel.ts";
10
27
  export declare class Tracer {
11
28
  db: Database;
12
29
  dbPath: string;
13
30
  eventsJsonl: string;
14
- constructor(dbPath: string, eventsJsonl: string);
31
+ /** `null` unless `observability.otel` is configured — see the header. */
32
+ otel: OtelExporter | null;
33
+ constructor(dbPath: string, eventsJsonl: string, otel?: OtelExporter | null);
15
34
  /** Additive column migrations, so a db from an older SPF still opens. */
16
35
  private migrate;
36
+ /**
37
+ * The ONE door to the optional otel projection, and the only reason a fan-out
38
+ * line is safe to put at the end of a synchronous write method: it is a
39
+ * no-op when unconfigured, and it swallows everything. An exporter bug, a
40
+ * malformed span, an exhausted queue — none of it may ever surface as a
41
+ * failed phase, because export is not allowed to dispose of anything. The
42
+ * exporter's own methods are synchronous enqueues; the network happens later,
43
+ * on an unref'd timer.
44
+ */
45
+ private fanOut;
17
46
  event(record: EventRecord): string;
18
47
  sessionStart(adwId: string, engineer: string, adwName?: string | null): void;
19
48
  sessionRequest(adwId: string, request: string): void;
@@ -2,8 +2,24 @@
2
2
  * Tracer: every event lands in JSONL and SQLite AS IT HAPPENS.
3
3
  *
4
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
5
  * WAL mode so the UI can read while ADW processes write.
6
+ *
7
+ * No push transport in the CONTROL flow — that is always, still, and only:
8
+ * agents -> sqlite -> web ui. SQLite is the source of truth; nothing
9
+ * downstream of it can affect a phase, a gate, or a run outcome.
10
+ *
11
+ * The one amendment: when (and only when) `observability.otel` is configured,
12
+ * each write method below ends with a single fan-out line to an optional
13
+ * OtelExporter — a lossy, allowlisted PROJECTION of what was just written,
14
+ * pushed to an OTLP endpoint fire-and-forget. It is deliberately NOT a second
15
+ * record: it never blocks, never throws into a caller (see `fanOut`), drops
16
+ * spans under backpressure, and carries only the allowlisted subset of fields
17
+ * (never `EventRecord.payload`, never the request text, never envelope
18
+ * contents — `core/otel.ts`'s header has the full list and the reasons).
19
+ * Methods whose data is entirely outside that allowlist —
20
+ * `sessionRequest` (the operator's prompt), `envelopeRow` (agent output),
21
+ * `processStart`/`processEnd` (pids) — have NO fan-out line on purpose. Do not
22
+ * add one.
7
23
  */
8
24
  import { Database } from "./sqlite.js";
9
25
  import { appendFileSync, mkdirSync } from "node:fs";
@@ -97,7 +113,10 @@ export class Tracer {
97
113
  db;
98
114
  dbPath;
99
115
  eventsJsonl;
100
- constructor(dbPath, eventsJsonl) {
116
+ /** `null` unless `observability.otel` is configured — see the header. */
117
+ otel;
118
+ constructor(dbPath, eventsJsonl, otel) {
119
+ this.otel = otel ?? null;
101
120
  mkdirSync(path.dirname(dbPath), { recursive: true });
102
121
  this.dbPath = dbPath;
103
122
  this.eventsJsonl = eventsJsonl;
@@ -118,6 +137,27 @@ export class Tracer {
118
137
  }
119
138
  }
120
139
  }
140
+ /**
141
+ * The ONE door to the optional otel projection, and the only reason a fan-out
142
+ * line is safe to put at the end of a synchronous write method: it is a
143
+ * no-op when unconfigured, and it swallows everything. An exporter bug, a
144
+ * malformed span, an exhausted queue — none of it may ever surface as a
145
+ * failed phase, because export is not allowed to dispose of anything. The
146
+ * exporter's own methods are synchronous enqueues; the network happens later,
147
+ * on an unref'd timer.
148
+ */
149
+ fanOut(action) {
150
+ if (!this.otel)
151
+ return;
152
+ try {
153
+ action(this.otel);
154
+ }
155
+ catch {
156
+ // Deliberately silent: a logged line per event on a hot path would be
157
+ // its own failure mode, and otel.ts already logs its own send failures
158
+ // exactly once.
159
+ }
160
+ }
121
161
  // ── events ──────────────────────────────────────────────────────────────
122
162
  event(record) {
123
163
  const eventId = `evt_${newId(12)}`;
@@ -128,23 +168,28 @@ export class Tracer {
128
168
  .query(`INSERT INTO events (event_id, adw_id, phase_id, parent_id, type, name,
129
169
  payload_json, tokens, started_at, ended_at) VALUES (?,?,?,?,?,?,?,?,?,?)`)
130
170
  .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);
171
+ this.fanOut((otel) => otel.recordEvent(record, eventId, ts)); // otel projection — see fanOut
131
172
  return eventId;
132
173
  }
133
174
  // ── sessions ────────────────────────────────────────────────────────────
134
175
  sessionStart(adwId, engineer, adwName) {
176
+ const startedAt = nowIso();
135
177
  this.db
136
178
  .query(`INSERT INTO sessions (adw_id, status, engineer, started_at) VALUES (?,?,?,?)
137
179
  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);
180
+ .run(adwId, "running", engineer, startedAt);
181
+ if (adwName) {
182
+ // A joined session chains ADWs — record each distinct one, in run order.
183
+ const row = this.db.query("SELECT adw_name FROM sessions WHERE adw_id=?").get(adwId);
184
+ const names = row?.adw_name ? row.adw_name.split(" + ") : [];
185
+ if (!names.includes(adwName)) {
186
+ names.push(adwName);
187
+ this.db.query("UPDATE sessions SET adw_name=? WHERE adw_id=?").run(names.join(" + "), adwId);
188
+ }
147
189
  }
190
+ // otel projection: the run's clock only. `engineer` is a person's name —
191
+ // outside the allowlist, and not a measure of anything.
192
+ this.fanOut((otel) => otel.recordSessionStart(startedAt));
148
193
  }
149
194
  sessionRequest(adwId, request) {
150
195
  this.db.query("UPDATE sessions SET request=? WHERE adw_id=?").run(request.slice(0, 500), adwId);
@@ -154,6 +199,7 @@ export class Tracer {
154
199
  .query("UPDATE sessions SET status=?, ended_at=? WHERE adw_id=?")
155
200
  .run(ok ? "success" : "fail", nowIso(), adwId);
156
201
  this.processesEndAll(adwId); // nothing of this run is alive any more
202
+ this.fanOut((otel) => otel.recordSessionFinish(ok)); // otel projection: emits the root run span, once
157
203
  }
158
204
  sessionAddUsage(adwId, tokens, cost) {
159
205
  this.db
@@ -208,6 +254,9 @@ export class Tracer {
208
254
  ON CONFLICT(phase_id) DO UPDATE SET status=excluded.status,
209
255
  attempt=excluded.attempt, error=excluded.error, ended_at=excluded.ended_at`)
210
256
  .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);
257
+ // otel projection: a no-op on the start-of-phase upsert (no ended_at yet) —
258
+ // phase spans are emitted at phase END only. `phase.error` never crosses.
259
+ this.fanOut((otel) => otel.recordPhase(phase));
211
260
  }
212
261
  // ── envelopes / gates / agent sessions ──────────────────────────────────
213
262
  envelopeRow(phase, agent, outputType, payloadJson, valid, attempt) {
@@ -222,6 +271,10 @@ export class Tracer {
222
271
  .query(`INSERT INTO gate_results (adw_id, phase_id, attempt, gate, passed,
223
272
  violations_json, checks_json, created_at) VALUES (?,?,?,?,?,?,?,?)`)
224
273
  .run(phase.adw_id, phase.phase_id, attempt, gate, report.passed ? 1 : 0, JSON.stringify(report.violations), JSON.stringify(report.checks), nowIso());
274
+ // otel projection: gate name + verdict + violation COUNT as a span event on
275
+ // the phase span. The violation and check TEXT stays here in SQLite — it
276
+ // quotes the agent's claim and the repo's files.
277
+ this.fanOut((otel) => otel.recordGate(phase, gate, report, attempt));
225
278
  }
226
279
  /**
227
280
  * The agent's config row is the source of truth for its label and color.
@@ -242,5 +295,10 @@ export class Tracer {
242
295
  context_window=excluded.context_window,
243
296
  last_used_at=excluded.last_used_at`)
244
297
  .run(adwId, agent.name, agent.coding_agent, agent.model, agent.color, sessionId, contextTokens, contextWindow, ts, ts);
298
+ // otel projection: the TYPED source of an agent's model + backend for its
299
+ // span (agents.ts writes this row before the agent_end event, which is what
300
+ // lets otel.ts avoid reading the agent_start payload at all). `sessionId`
301
+ // is not exported — it is a coding-agent handle, not a measure.
302
+ this.fanOut((otel) => otel.recordAgentSession(agent));
245
303
  }
246
304
  }
@@ -6,6 +6,17 @@ export interface ChainRunResult {
6
6
  adwId: string;
7
7
  /** Shown to the engineer via a `blocked` comment on a failed/no-op run. */
8
8
  detail: string;
9
+ /**
10
+ * A short, already-sanitized/truncated digest of the reviewer's verdict
11
+ * (approved/blocking/findings), read back best-effort from the sessions DB
12
+ * — see `cli/commands/watch.ts`'s `runChain`. `undefined` when the chain
13
+ * that ran has no reviewer step, or the DB read/parse failed; `runIssue`
14
+ * below falls back to `reviewRequired` to tell those two apart in the PR
15
+ * body and `pr_opened` notification.
16
+ */
17
+ reviewSummary?: string;
18
+ /** Whether the chain that ran declares a "reviewer" in its `requiredAgents` — distinguishes "reviewer approved" from "nothing reviewed this change" when `reviewSummary` is absent. */
19
+ reviewRequired?: boolean;
9
20
  }
10
21
  /** One issue the refine lane created — enough for `finishSpec`'s summary comment and the marker's idempotency record. */
11
22
  export interface RefinedIssueRef {
@@ -284,6 +284,15 @@ async function runIssue(deps, issue) {
284
284
  return;
285
285
  }
286
286
  wtGit.push("origin", branch);
287
+ // The human merging this PR sees whatever the reviewer found — or, if
288
+ // nothing reviewed this change at all, is told that plainly rather than
289
+ // left to assume a silent approval. `reviewSummary` is already
290
+ // sanitized/truncated by the caller (see runChain's own doc comment).
291
+ const reviewLine = result.reviewSummary
292
+ ? result.reviewSummary
293
+ : result.reviewRequired
294
+ ? "Reviewer ran, but no verdict could be read back from the session data."
295
+ : `Nothing reviewed this change — chain \`${deps.chain}\` has no reviewer step.`;
287
296
  // No cross-linking magic keyword here on purpose (a code host paired
288
297
  // with a different tracker has no "Closes #n" convention to hook into
289
298
  // — see provider.ts) — the issue id in the title/body is plain text
@@ -292,7 +301,7 @@ async function runIssue(deps, issue) {
292
301
  const pr = await deps.codeHost.openPr({
293
302
  branch,
294
303
  title: `${issue.title} (${issue.id})`,
295
- body: `Automated by \`spf watch\` — chain \`${deps.chain}\`, adw_id \`${adwId}\`, issue ${issue.id}.`,
304
+ body: `Automated by \`spf watch\` — chain \`${deps.chain}\`, adw_id \`${adwId}\`, issue ${issue.id}.\n\n${reviewLine}`,
296
305
  base: deps.baseBranch,
297
306
  });
298
307
  await deps.provider.writeMarker(issue, { worktree: worktreePath, branch, pr: pr.number, attempt: 0 });
@@ -302,7 +311,13 @@ async function runIssue(deps, issue) {
302
311
  kind: "pr_opened",
303
312
  level: "info",
304
313
  title: `PR #${pr.number} opened`,
305
- fields: [["issue", issue.id], ["title", issue.title], ["chain", deps.chain]],
314
+ detail: reviewLine,
315
+ fields: [
316
+ ["issue", issue.id],
317
+ ["title", issue.title],
318
+ ["chain", deps.chain],
319
+ ["review", result.reviewSummary ? "reviewed" : result.reviewRequired ? "reviewer ran, no verdict" : "not reviewed"],
320
+ ],
306
321
  url: pr.url || undefined,
307
322
  });
308
323
  }
@@ -11,7 +11,7 @@
11
11
  */
12
12
  import { test } from "node:test";
13
13
  import assert from "node:assert/strict";
14
- import { CHAINS, findChain, resolveRequiredAgents } from "../chains/index.js";
14
+ import { CHAINS, findChain, resolveRequiredAgents, resolveRequiredSuites } from "../chains/index.js";
15
15
  // name -> [phases, requiredAgents (with no options), requiredSuites]
16
16
  const EXPECTED = {
17
17
  prompt: { phases: "engineer(request) -> <agent>", agents: ["builder"], suites: [] },
@@ -44,7 +44,7 @@ const EXPECTED = {
44
44
  refine: { phases: "engineer(request) -> refiner -> code(publish)", agents: ["refiner"], suites: [] },
45
45
  "simple-sdlc": {
46
46
  phases: "engineer(request) -> planner -> git(commit_plan) -> builder -> code(test) [-> builder(fix) -> code(test) ...] " +
47
- "-> reviewer [-> builder(revise) -> reviewer ...] -> code(retest, if revised) -> git(commit_build) " +
47
+ "-> reviewer [-> builder(revise) -> reviewer ...] -> code(retest, if revised) -> engineer(signoff) -> git(commit_build) " +
48
48
  "-> code(changes) -> documenter -> git(commit_docs)",
49
49
  agents: ["planner", "builder", "reviewer", "documenter"],
50
50
  suites: ["test"],
@@ -59,7 +59,7 @@ for (const chain of CHAINS) {
59
59
  test(`${chain.name}: derived phases/requiredAgents/requiredSuites match what was hand-verified against \`spf list\``, () => {
60
60
  assert.equal(chain.phases, expected.phases);
61
61
  assert.deepEqual(resolveRequiredAgents(chain, {}), expected.agents);
62
- assert.deepEqual(chain.requiredSuites, expected.suites);
62
+ assert.deepEqual(resolveRequiredSuites(chain, {}), expected.suites);
63
63
  });
64
64
  }
65
65
  test("prompt: requiredAgents depends on --agent, not a fixed list — the one dynamic case", () => {
@@ -67,6 +67,11 @@ test("prompt: requiredAgents depends on --agent, not a fixed list — the one dy
67
67
  assert.deepEqual(resolveRequiredAgents(chain, {}), ["builder"], "no --agent -> falls back to builder");
68
68
  assert.deepEqual(resolveRequiredAgents(chain, { agent: "planner" }), ["planner"], "--agent overrides the default");
69
69
  });
70
+ test("plan-build-test: requiredSuites depends on --suite, the same way prompt's agent does", () => {
71
+ const chain = findChain("plan-build-test");
72
+ assert.deepEqual(resolveRequiredSuites(chain, {}), ["test"], "no --suite -> falls back to the compiled-in default");
73
+ assert.deepEqual(resolveRequiredSuites(chain, { suite: "custom" }), ["custom"], "--suite overrides the default");
74
+ });
70
75
  test("every chain but simple-sdlc is a steps list; simple-sdlc alone uses the imperative run() escape hatch", () => {
71
76
  for (const chain of CHAINS) {
72
77
  if (chain.name === "simple-sdlc") {
@@ -14,8 +14,8 @@ import { tmpdir } from "node:os";
14
14
  import { join } from "node:path";
15
15
  import * as v from "valibot";
16
16
  import { toJsonSchema } from "@valibot/to-json-schema";
17
- import { AgentConfigSchema, BuildOutput, ChangesOutput, DocumentOutput, GenericOutput, NotificationsConfigSchema, PhaseParamsSchema, PlanOutput, ReviewOutput, ScoutOutput, VerifyOutput, makePhaseParams, } from "../core/data_types.js";
18
- import { loadConfig } from "../core/agents.js";
17
+ import { AgentConfigSchema, BuildOutput, ChangesOutput, DocumentOutput, EventRecordTypeSchema, GenericOutput, NotificationsConfigSchema, PhaseParamsSchema, PlanOutput, ReviewConfigSchema, ReviewOutput, ScoutOutput, VerifyOutput, makePhaseParams, } from "../core/data_types.js";
18
+ import { agentEnv, loadConfig } from "../core/agents.js";
19
19
  test("writes: three-state semantics — absent, null, and [] all mean something different", () => {
20
20
  const base = { name: "builder", prompt_engineering: { system: "s.md", user: "u.md" } };
21
21
  const unrestricted = v.parse(AgentConfigSchema, base);
@@ -80,3 +80,141 @@ test("notifications survives loadConfig's merge — key-by-key like observabilit
80
80
  rmSync(dir, { recursive: true, force: true });
81
81
  }
82
82
  });
83
+ test("ReviewConfigSchema: defaults to require_human_signoff=false, signoff_timeout_seconds=300", () => {
84
+ const parsed = v.parse(ReviewConfigSchema, {});
85
+ assert.equal(parsed.require_human_signoff, false, "this release fails OPEN by default — see the schema's own doc comment");
86
+ assert.equal(parsed.signoff_timeout_seconds, 300);
87
+ });
88
+ // The silent-drop trap: mergeRawConfig (core/agents.ts) is a FIXED-SHAPE
89
+ // object literal, so a `review:` key in a real config file that isn't named
90
+ // on both sides of that literal is dropped before SFConfigSchema ever sees
91
+ // it — parsing would then succeed anyway, quietly, on the schema default.
92
+ // This is adversarial history on this branch, not a hypothetical: it is
93
+ // exactly the bug `observability`/`notifications` already guard against, and
94
+ // `review` gets the same guard the day it is added.
95
+ test("review survives loadConfig's merge — the silent-drop trap mergeRawConfig's fixed-shape literal sets, for a single config file", () => {
96
+ const dir = mkdtempSync(join(tmpdir(), "spf-review-merge-test-"));
97
+ try {
98
+ const configPath = join(dir, "spf.config.yaml");
99
+ writeFileSync(configPath, "review:\n require_human_signoff: true\n signoff_timeout_seconds: 45\n");
100
+ const cfg = loadConfig([configPath]);
101
+ assert.equal(cfg.review.require_human_signoff, true, "a review: value from a real config file must reach SFConfig, not be dropped by mergeRawConfig's object literal");
102
+ assert.equal(cfg.review.signoff_timeout_seconds, 45);
103
+ }
104
+ finally {
105
+ rmSync(dir, { recursive: true, force: true });
106
+ }
107
+ });
108
+ test("review merges key-by-key across two layered config files, like observability/notifications", () => {
109
+ const dir = mkdtempSync(join(tmpdir(), "spf-review-merge-layered-test-"));
110
+ try {
111
+ const base = join(dir, "base.yaml");
112
+ const override = join(dir, "override.yaml");
113
+ writeFileSync(base, "review:\n require_human_signoff: false\n signoff_timeout_seconds: 120\n");
114
+ writeFileSync(override, "review:\n require_human_signoff: true\n");
115
+ const cfg = loadConfig([base, override]);
116
+ assert.equal(cfg.review.require_human_signoff, true, "override wins for the key it names");
117
+ assert.equal(cfg.review.signoff_timeout_seconds, 120, "unset in the override -> the base's value survives, key-by-key, not a whole-block replace");
118
+ }
119
+ finally {
120
+ rmSync(dir, { recursive: true, force: true });
121
+ }
122
+ });
123
+ // observability.otel is the one nested OBJECT under a top-level key that
124
+ // mergeRawConfig spreads field-by-field, so its merge semantics differ from its
125
+ // siblings' and are worth pinning: `otel:` is replaced as a WHOLE OBJECT by an
126
+ // override that names it (you never want a half-merged endpoint/headers pair —
127
+ // that is how an auth token gets POSTed to the wrong collector), while
128
+ // `observability`'s other keys still merge key-by-key around it. Also the
129
+ // activation contract: absent by default, so no repo starts exporting because
130
+ // it upgraded.
131
+ test("observability.otel survives loadConfig's merge — absent by default, whole-object replace on override", () => {
132
+ const dir = mkdtempSync(join(tmpdir(), "spf-otel-merge-test-"));
133
+ try {
134
+ const bare = join(dir, "bare.yaml");
135
+ writeFileSync(bare, "observability:\n poll_ms: 250\n");
136
+ assert.equal(loadConfig([bare]).observability.otel, undefined, "no otel: block -> export stays off, the default for every repo");
137
+ const base = join(dir, "base.yaml");
138
+ const override = join(dir, "override.yaml");
139
+ writeFileSync(base, "observability:\n poll_ms: 250\n otel:\n endpoint: http://base-collector:4318/v1/traces\n headers: {authorization: base-token}\n service_name: base\n");
140
+ writeFileSync(override, "observability:\n otel:\n endpoint: http://override-collector:4318/v1/traces\n");
141
+ const cfg = loadConfig([base, override]);
142
+ assert.equal(cfg.observability.poll_ms, 250, "observability's other keys still merge key-by-key around otel");
143
+ assert.equal(cfg.observability.otel?.endpoint, "http://override-collector:4318/v1/traces");
144
+ assert.equal(cfg.observability.otel?.headers, undefined, "whole-object replace: the base's auth header does NOT follow the override's endpoint");
145
+ assert.equal(cfg.observability.otel?.service_name, "spf", "and the base's service_name doesn't either — the schema default applies");
146
+ // A single config file must reach SFConfig at all — the silent-drop trap
147
+ // mergeRawConfig's fixed-shape object literal sets for any new key.
148
+ const single = join(dir, "single.yaml");
149
+ writeFileSync(single, "observability:\n otel:\n endpoint: https://collector.example.com/v1/traces\n");
150
+ assert.equal(loadConfig([single]).observability.otel?.endpoint, "https://collector.example.com/v1/traces");
151
+ // A typo fails at config load, not as a silent per-run export failure.
152
+ const bad = join(dir, "bad.yaml");
153
+ writeFileSync(bad, "observability:\n otel:\n endpoint: not-a-url\n");
154
+ assert.throws(() => loadConfig([bad]), /invalid config/, "endpoint is URL-validated");
155
+ }
156
+ finally {
157
+ rmSync(dir, { recursive: true, force: true });
158
+ }
159
+ });
160
+ test("EventRecordTypeSchema accepts every observed event type and rejects an unknown one", () => {
161
+ for (const type of ["phase_start", "agent_start", "tool_call", "handoff", "gate_pass", "gate_fail", "log", "agent_end", "phase_end", "error"]) {
162
+ assert.equal(v.parse(EventRecordTypeSchema, type), type);
163
+ }
164
+ assert.throws(() => v.parse(EventRecordTypeSchema, "bogus_type"), "an event type outside the observed set must be rejected, not silently stored");
165
+ });
166
+ test("AgentConfigSchema: env_allowlist is optional and defaults to undefined (byte-identical to before this field existed)", () => {
167
+ const base = { name: "builder", prompt_engineering: { system: "s.md", user: "u.md" } };
168
+ const unset = v.parse(AgentConfigSchema, base);
169
+ assert.equal(unset.env_allowlist, undefined);
170
+ const allowlisted = v.parse(AgentConfigSchema, { ...base, env_allowlist: ["MY_API_KEY"] });
171
+ assert.deepEqual(allowlisted.env_allowlist, ["MY_API_KEY"]);
172
+ const nulled = v.parse(AgentConfigSchema, { ...base, env_allowlist: null });
173
+ assert.equal(nulled.env_allowlist, null, "null must parse (the 'unrestricted' spelling config.md teaches for writes)");
174
+ });
175
+ // This is the load-bearing half of the field: the schema merely declares
176
+ // the shape, agentEnv() is what actually filters the operator's own
177
+ // environment down to the allowlist. A test that only exercised the schema
178
+ // (as the previous version of this test did) would never notice agentEnv
179
+ // silently failing to filter anything.
180
+ test("agentEnv: filters the operator environment down to the allowlist plus the baseline keys", () => {
181
+ const base = { name: "builder", prompt_engineering: { system: "s.md", user: "u.md" } };
182
+ const savedApiKey = process.env["MY_API_KEY"];
183
+ const savedSecret = process.env["SECRET_TOKEN"];
184
+ try {
185
+ process.env["MY_API_KEY"] = "abc123";
186
+ process.env["SECRET_TOKEN"] = "should-never-appear";
187
+ // unset -> undefined: both backends fall back to `request.env ?? operatorEnv()`,
188
+ // so this is byte-identical to the unfiltered behavior that predates the field.
189
+ const unset = v.parse(AgentConfigSchema, base);
190
+ assert.equal(agentEnv(unset), undefined);
191
+ // [] -> baseline-only: every ENV_BASELINE_KEYS entry actually present in
192
+ // process.env, and nothing else — SECRET_TOKEN must not leak through.
193
+ const empty = v.parse(AgentConfigSchema, { ...base, env_allowlist: [] });
194
+ const baselineOnly = agentEnv(empty);
195
+ const baselineKeys = ["PATH", "HOME", "USER", "LANG", "TERM", "TMPDIR"];
196
+ for (const key of Object.keys(baselineOnly)) {
197
+ assert.ok(baselineKeys.includes(key), `${key} is not a baseline key — [] must yield baseline-only`);
198
+ }
199
+ assert.equal(baselineOnly["SECRET_TOKEN"], undefined);
200
+ assert.equal(baselineOnly["MY_API_KEY"], undefined);
201
+ // ["MY_API_KEY"] -> baseline + MY_API_KEY, with SECRET_TOKEN provably absent.
202
+ const allowlisted = v.parse(AgentConfigSchema, { ...base, env_allowlist: ["MY_API_KEY"] });
203
+ const filtered = agentEnv(allowlisted);
204
+ assert.equal(filtered["MY_API_KEY"], "abc123");
205
+ assert.equal(filtered["SECRET_TOKEN"], undefined, "an unrelated secret must not survive the filter");
206
+ for (const key of Object.keys(filtered)) {
207
+ assert.ok(baselineKeys.includes(key) || key === "MY_API_KEY", `${key} leaked through the allowlist unexpectedly`);
208
+ }
209
+ }
210
+ finally {
211
+ if (savedApiKey === undefined)
212
+ delete process.env["MY_API_KEY"];
213
+ else
214
+ process.env["MY_API_KEY"] = savedApiKey;
215
+ if (savedSecret === undefined)
216
+ delete process.env["SECRET_TOKEN"];
217
+ else
218
+ process.env["SECRET_TOKEN"] = savedSecret;
219
+ }
220
+ });
@@ -0,0 +1 @@
1
+ import "./hermetic_git.ts";