@gr8ful/spf 0.14.0 → 0.16.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 (77) hide show
  1. package/README.md +51 -9
  2. package/assets/skill/references/config.md +12 -5
  3. package/assets/skill/references/observability.md +57 -12
  4. package/assets/templates/ts-opencode.spf.config.yaml +54 -0
  5. package/dist/chains/index.js +1 -1
  6. package/dist/chains/simple_sdlc.d.ts +2 -2
  7. package/dist/chains/simple_sdlc.js +13 -13
  8. package/dist/chains/steps.d.ts +2 -2
  9. package/dist/chains/steps.js +35 -19
  10. package/dist/cli/commands/abort.d.ts +1 -1
  11. package/dist/cli/commands/abort.js +30 -3
  12. package/dist/cli/commands/doctor.js +121 -8
  13. package/dist/cli/commands/estimate.js +3 -3
  14. package/dist/cli/commands/events.js +4 -4
  15. package/dist/cli/commands/fanout.js +93 -21
  16. package/dist/cli/commands/loop.js +31 -32
  17. package/dist/cli/commands/migrate.js +8 -1
  18. package/dist/cli/commands/phases.js +2 -2
  19. package/dist/cli/commands/sessions.js +2 -2
  20. package/dist/cli/commands/trace.d.ts +28 -8
  21. package/dist/cli/commands/trace.js +28 -15
  22. package/dist/cli/commands/ui.js +15 -5
  23. package/dist/cli/commands/watch.js +91 -30
  24. package/dist/cli/index.js +3 -1
  25. package/dist/cli/interview.d.ts +1 -0
  26. package/dist/cli/interview.js +95 -5
  27. package/dist/core/agent_opencode.d.ts +247 -0
  28. package/dist/core/agent_opencode.js +590 -0
  29. package/dist/core/agents.d.ts +12 -12
  30. package/dist/core/agents.js +113 -46
  31. package/dist/core/console.d.ts +12 -12
  32. package/dist/core/console.js +25 -25
  33. package/dist/core/data_types.d.ts +356 -15
  34. package/dist/core/data_types.js +180 -7
  35. package/dist/core/fanout.d.ts +1 -1
  36. package/dist/core/fanout.js +1 -1
  37. package/dist/core/gates.js +14 -1
  38. package/dist/core/issues/github_provider.d.ts +66 -2
  39. package/dist/core/issues/github_provider.js +161 -2
  40. package/dist/core/issues/jira_provider.d.ts +50 -9
  41. package/dist/core/issues/jira_provider.js +62 -2
  42. package/dist/core/paths.d.ts +41 -4
  43. package/dist/core/paths.js +32 -3
  44. package/dist/core/quality.d.ts +7 -7
  45. package/dist/core/quality.js +16 -10
  46. package/dist/core/refine.js +2 -2
  47. package/dist/core/runner.d.ts +9 -3
  48. package/dist/core/runner.js +39 -27
  49. package/dist/core/session.d.ts +2 -2
  50. package/dist/core/session.js +39 -18
  51. package/dist/core/sqlite.d.ts +14 -7
  52. package/dist/core/sqlite.js +14 -7
  53. package/dist/core/trace_db.d.ts +118 -0
  54. package/dist/core/trace_db.js +278 -0
  55. package/dist/core/tracer.d.ts +64 -34
  56. package/dist/core/tracer.js +141 -69
  57. package/dist/core/watch.d.ts +4 -4
  58. package/dist/core/watch.js +2 -2
  59. package/dist/ui/server/app.js +10 -10
  60. package/dist/ui/server/db.d.ts +89 -21
  61. package/dist/ui/server/db.js +235 -99
  62. package/dist/ui/server/serve.d.ts +5 -1
  63. package/dist/ui/server/serve.js +4 -5
  64. package/package.json +1 -1
  65. package/web/assets/index-CQ3k1Y1-.css +1 -0
  66. package/web/assets/index-CU8tom6S.js +21 -0
  67. package/web/assets/overpass-latin-400-normal-BpeLJ0bs.woff2 +0 -0
  68. package/web/assets/overpass-latin-600-normal-25RhTNCi.woff2 +0 -0
  69. package/web/assets/overpass-latin-700-normal-CQX2QTgM.woff2 +0 -0
  70. package/web/assets/overpass-mono-latin-400-normal-VINZG6Js.woff2 +0 -0
  71. package/web/assets/overpass-mono-latin-700-normal-D6nRBrbd.woff2 +0 -0
  72. package/web/index.html +33 -2
  73. package/web/logo.svg +4 -4
  74. package/web/assets/index-C7nF068F.css +0 -1
  75. package/web/assets/index-mzSArcnQ.js +0 -11
  76. package/web/assets/play-latin-400-normal-GKW-4YV7.woff2 +0 -0
  77. package/web/assets/play-latin-700-normal-DyPlLDbb.woff2 +0 -0
@@ -18,13 +18,22 @@
18
18
  * one paragraph of plain text, nothing richer.
19
19
  *
20
20
  * State is modeled as Jira labels (`<prefix>:ready`, etc.), mirroring
21
- * `github_provider.ts` exactly, rather than native workflow status
22
- * transitions the latter would need per-project transition-id mapping
23
- * (workflows vary by project/scheme in Jira), while labels work
24
- * identically everywhere with zero per-project setup. One caveat, verified
25
- * against Atlassian's own docs: colons ARE a legal label character and JQL
26
- * matches on them fine, they just don't show up in Jira's label
27
- * autocomplete UI cosmetic only, not a functional issue.
21
+ * `github_provider.ts` exactly labels are spf's ACTUAL state machine and
22
+ * always get written, unconditionally. One caveat, verified against
23
+ * Atlassian's own docs: colons ARE a legal label character and JQL matches
24
+ * on them fine, they just don't show up in Jira's label autocomplete UI —
25
+ * cosmetic only, not a functional issue.
26
+ *
27
+ * Native workflow status is a separate, OPTIONAL, best-effort layer on top
28
+ * (`syncStatus()`), driven entirely by the configured `statusMap` — empty
29
+ * by default, so an existing config's behavior is unchanged. It's optional
30
+ * rather than baked into every `transition()` call unconditionally because
31
+ * Jira workflows vary by project/scheme (status names, which transitions
32
+ * are reachable from where) in a way labels never do; a project that wants
33
+ * its board's Status column to move when spf changes a label opts in with
34
+ * `watch.jira.status_map`, and a misconfigured or unreachable entry there
35
+ * degrades to a logged warning, never a thrown error — a status-sync miss
36
+ * must never block the label update `spf watch` actually depends on.
28
37
  *
29
38
  * `ensureLabels()` is a no-op that reports the labels this run will use:
30
39
  * Jira labels are freeform strings with no color/description registry to
@@ -58,7 +67,7 @@
58
67
  * Jira API error at publish time — a genuine platform difference, not
59
68
  * something this file tries to paper over.
60
69
  */
61
- import type { JiraIssueTypeMap } from "../data_types.ts";
70
+ import type { JiraIssueTypeMap, JiraStatusMap } from "../data_types.ts";
62
71
  import type { EnsureLabelsResult, Issue, IssueAuthoringKind, IssueAuthoringProvider, IssueComment, IssueProvider, WatchMarker, WatchState } from "./provider.ts";
63
72
  export declare class JiraProvider implements IssueProvider, IssueAuthoringProvider {
64
73
  private readonly baseUrl;
@@ -67,8 +76,9 @@ export declare class JiraProvider implements IssueProvider, IssueAuthoringProvid
67
76
  private readonly email;
68
77
  private readonly apiToken;
69
78
  private readonly issueTypes;
79
+ private readonly statusMap;
70
80
  constructor(baseUrl: string, // e.g. "https://your-domain.atlassian.net", no trailing slash
71
- projectKey: string, labelPrefix: string, email: string, apiToken: string, issueTypes: JiraIssueTypeMap);
81
+ projectKey: string, labelPrefix: string, email: string, apiToken: string, issueTypes: JiraIssueTypeMap, statusMap?: JiraStatusMap);
72
82
  private authHeader;
73
83
  private jira;
74
84
  private label;
@@ -145,6 +155,37 @@ export declare class JiraProvider implements IssueProvider, IssueAuthoringProvid
145
155
  to?: WatchState;
146
156
  }): Promise<boolean>;
147
157
  transition(issue: Issue, to: WatchState, detail?: string): Promise<void>;
158
+ /**
159
+ * Best-effort native workflow-status sync — a no-op unless `statusMap`
160
+ * configures a name for `to`. Looked up per call, not cached: the
161
+ * available transitions are FROM-status-dependent, so the same target
162
+ * status can need a different transition id depending where the issue
163
+ * currently sits, and this same issue's status keeps moving across calls
164
+ * as it advances through the build lane. Every failure mode here
165
+ * (unconfigured, unreachable, or a rejected transition) is logged and
166
+ * swallowed, never thrown — see this file's module comment on why a
167
+ * status-sync miss must never break the label update callers depend on.
168
+ */
169
+ private syncStatus;
170
+ /**
171
+ * Read-only validation of the configured `status_map` against this
172
+ * project's real statuses — what `spf watch init` and `spf watch`'s own
173
+ * startup check should call to catch a misnamed status before an
174
+ * unattended run silently no-ops its status sync every time, the same
175
+ * role `validateIssueTypes()` plays for `issue_types`.
176
+ *
177
+ * Uses `/rest/api/3/project/{key}/statuses`, which groups statuses by
178
+ * issue type — Jira workflows can differ per issue type within one
179
+ * project. A configured name is "exists" if ANY issue type in the project
180
+ * has it: good enough to catch a typo, not a guarantee every issue type
181
+ * this map is used against can actually reach it (that's what
182
+ * `syncStatus()`'s own per-call transition lookup is for).
183
+ */
184
+ validateStatusMap(): Promise<Array<{
185
+ state: string;
186
+ jiraStatus: string;
187
+ exists: boolean;
188
+ }>>;
148
189
  comment(issue: Issue, body: string): Promise<void>;
149
190
  /** The single fetch every comment-reading method (`findMarkerComment`, `listComments`) builds on. */
150
191
  private fetchComments;
@@ -52,14 +52,16 @@ export class JiraProvider {
52
52
  email;
53
53
  apiToken;
54
54
  issueTypes;
55
+ statusMap;
55
56
  constructor(baseUrl, // e.g. "https://your-domain.atlassian.net", no trailing slash
56
- projectKey, labelPrefix, email, apiToken, issueTypes) {
57
+ projectKey, labelPrefix, email, apiToken, issueTypes, statusMap = {}) {
57
58
  this.baseUrl = baseUrl;
58
59
  this.projectKey = projectKey;
59
60
  this.labelPrefix = labelPrefix;
60
61
  this.email = email;
61
62
  this.apiToken = apiToken;
62
63
  this.issueTypes = issueTypes;
64
+ this.statusMap = statusMap;
63
65
  }
64
66
  authHeader() {
65
67
  return `Basic ${Buffer.from(`${this.email}:${this.apiToken}`).toString("base64")}`;
@@ -213,8 +215,9 @@ export class JiraProvider {
213
215
  return Object.entries(this.issueTypes).map(([kind, jiraType]) => ({ kind, jiraType, exists: available.has(jiraType) }));
214
216
  }
215
217
  async claim(issue, opts) {
218
+ const toState = opts?.to ?? "working";
216
219
  const from = this.label(opts?.from ?? "ready");
217
- const to = this.label(opts?.to ?? "working");
220
+ const to = this.label(toState);
218
221
  const next = issue.labels.filter((l) => l !== from);
219
222
  next.push(to);
220
223
  await this.jira(`/rest/api/3/issue/${issue.id}`, { method: "PUT", body: JSON.stringify({ fields: { labels: next } }) });
@@ -226,15 +229,72 @@ export class JiraProvider {
226
229
  revert.push(from);
227
230
  await this.jira(`/rest/api/3/issue/${issue.id}`, { method: "PUT", body: JSON.stringify({ fields: { labels: revert } }) }).catch(() => undefined);
228
231
  }
232
+ else {
233
+ await this.syncStatus(issue, toState);
234
+ }
229
235
  return claimed;
230
236
  }
231
237
  async transition(issue, to, detail) {
232
238
  const next = issue.labels.filter((l) => !STATES.some((s) => this.label(s) === l));
233
239
  next.push(this.label(to));
234
240
  await this.jira(`/rest/api/3/issue/${issue.id}`, { method: "PUT", body: JSON.stringify({ fields: { labels: next } }) });
241
+ await this.syncStatus(issue, to);
235
242
  if (detail)
236
243
  await this.comment(issue, detail);
237
244
  }
245
+ /**
246
+ * Best-effort native workflow-status sync — a no-op unless `statusMap`
247
+ * configures a name for `to`. Looked up per call, not cached: the
248
+ * available transitions are FROM-status-dependent, so the same target
249
+ * status can need a different transition id depending where the issue
250
+ * currently sits, and this same issue's status keeps moving across calls
251
+ * as it advances through the build lane. Every failure mode here
252
+ * (unconfigured, unreachable, or a rejected transition) is logged and
253
+ * swallowed, never thrown — see this file's module comment on why a
254
+ * status-sync miss must never break the label update callers depend on.
255
+ */
256
+ async syncStatus(issue, to) {
257
+ const statusName = this.statusMap[to];
258
+ if (!statusName)
259
+ return;
260
+ let transitions;
261
+ try {
262
+ ({ transitions } = await this.jira(`/rest/api/3/issue/${issue.id}/transitions`));
263
+ }
264
+ catch (err) {
265
+ console.error(`spf watch: ${issue.id} — couldn't fetch available Jira transitions to sync status ${JSON.stringify(statusName)}; label already updated — ${err instanceof Error ? err.message : String(err)}`);
266
+ return;
267
+ }
268
+ const match = transitions.find((t) => t.to.name === statusName);
269
+ if (!match) {
270
+ console.error(`spf watch: ${issue.id} has no available transition to Jira status ${JSON.stringify(statusName)} (watch.jira.status_map.${to}) from its current status — skipping status sync, label already updated`);
271
+ return;
272
+ }
273
+ await this.jira(`/rest/api/3/issue/${issue.id}/transitions`, { method: "POST", body: JSON.stringify({ transition: { id: match.id } }) }).catch((err) => {
274
+ console.error(`spf watch: ${issue.id} — Jira transition to ${JSON.stringify(statusName)} failed; label already updated — ${err instanceof Error ? err.message : String(err)}`);
275
+ });
276
+ }
277
+ /**
278
+ * Read-only validation of the configured `status_map` against this
279
+ * project's real statuses — what `spf watch init` and `spf watch`'s own
280
+ * startup check should call to catch a misnamed status before an
281
+ * unattended run silently no-ops its status sync every time, the same
282
+ * role `validateIssueTypes()` plays for `issue_types`.
283
+ *
284
+ * Uses `/rest/api/3/project/{key}/statuses`, which groups statuses by
285
+ * issue type — Jira workflows can differ per issue type within one
286
+ * project. A configured name is "exists" if ANY issue type in the project
287
+ * has it: good enough to catch a typo, not a guarantee every issue type
288
+ * this map is used against can actually reach it (that's what
289
+ * `syncStatus()`'s own per-call transition lookup is for).
290
+ */
291
+ async validateStatusMap() {
292
+ const result = await this.jira(`/rest/api/3/project/${encodeURIComponent(this.projectKey)}/statuses`);
293
+ const available = new Set(result.flatMap((t) => t.statuses.map((s) => s.name)));
294
+ return Object.entries(this.statusMap)
295
+ .filter((entry) => Boolean(entry[1]))
296
+ .map(([state, jiraStatus]) => ({ state, jiraStatus, exists: available.has(jiraStatus) }));
297
+ }
238
298
  async comment(issue, body) {
239
299
  await this.jira(`/rest/api/3/issue/${issue.id}/comment`, { method: "POST", body: JSON.stringify({ body: toAdf(body) }) });
240
300
  }
@@ -16,6 +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 { type NormalizedObservabilityDb, type ObservabilityDbInput } from "./data_types.ts";
19
20
  export declare const PACKAGE_ROOT: string;
20
21
  export declare const ASSETS_DIR: string;
21
22
  /** The prebuilt SPA — a sibling of dist/ and assets/, built once at publish time (see app/). */
@@ -54,9 +55,22 @@ export interface DataPaths {
54
55
  data_dir: string;
55
56
  /** Repo-root-relative, forward-slash — for matching against git's own path output. */
56
57
  data_dir_rel: string;
57
- /** Absolute. */
58
- db_path: string;
59
- /** Absolute. ALWAYS a sibling of `db_path` relocating `data_dir` must never break this. */
58
+ /**
59
+ * The normalized `observability.db` descriptor — `core/trace_db.ts`'s
60
+ * `createTraceDb()` (called by both `Tracer` and `SfDb`) is built against
61
+ * exactly this shape. For `kind: "sqlite"`, `path` here is already
62
+ * resolved ABSOLUTE (byte-for-byte the same value as `db_path` below); for
63
+ * `kind: "d1"` there is no local path at all.
64
+ */
65
+ db: NormalizedObservabilityDb;
66
+ /** Absolute local sqlite path, or `null` for a `kind: "d1"` config — there is no local file to open. */
67
+ db_path: string | null;
68
+ /**
69
+ * Absolute. Anchored directly off `data_dir` (NOT off `db_path`) — session
70
+ * JSONL/envelope artifact files always live on the local filesystem
71
+ * regardless of where the queryable trace mirror (`db` above) lives, so
72
+ * this must resolve even when `db_path` is `null` for a d1-backed repo.
73
+ */
60
74
  sessions_dir: string;
61
75
  }
62
76
  /**
@@ -70,8 +84,31 @@ export interface DataPaths {
70
84
  *
71
85
  * Not otherwise side-effect-free: see `healBrokenDataDir` above, which every
72
86
  * caller needs run before its own first `mkdirSync` against `data_dir`.
87
+ *
88
+ * `rawDb` accepts every form `ObservabilityConfigSchema.db` accepts (bare
89
+ * string, `{kind:"sqlite",...}`, `{kind:"d1",...}`) — normalized here via
90
+ * `resolveObservabilityDb` — so every existing call site
91
+ * (`resolveDataPaths(anchor, cfg.defaults.data_dir, cfg.observability.db)`)
92
+ * keeps compiling and behaving unchanged with no call-site edit needed.
93
+ *
94
+ * For a `"sqlite"`-kind db, `db_path`/`db.path` resolve BYTE-FOR-BYTE
95
+ * identically to before this PR: `path.resolve(repo_root, path)`.
96
+ *
97
+ * For a `"d1"`-kind db there is no local file at all — `db_path` is `null`,
98
+ * and `db` carries the resolved `{kind:"d1", database_id, account_id_env,
99
+ * api_token_env}` for `core/trace_db.ts`'s `createTraceDb()` to open (PR 2:
100
+ * SPF #66). `sessions_dir` has no `db_path` to anchor off in this case, so it
101
+ * falls back to `data_dir/sessions` — session JSONL/envelope artifacts still
102
+ * always live on disk regardless of where the queryable trace mirror lives.
103
+ *
104
+ * For a `"sqlite"`-kind db, `sessions_dir` stays what it always was — ALWAYS
105
+ * a sibling of `db_path` (`dirname(db_path)/sessions`), never `data_dir`
106
+ * directly — `migrate.ts` and `SfDb` both depend on that: a repo whose db
107
+ * lives outside `data_dir` (`observability.db: elsewhere/custom.db` next to
108
+ * `defaults.data_dir: .spf/data`) still has its sessions found. Relocating
109
+ * `data_dir` alone must never orphan an existing db's session artifacts.
73
110
  */
74
- export declare function resolveDataPaths(anchor: RepoAnchor, rawDataDir: string, rawDbPath: string): DataPaths;
111
+ export declare function resolveDataPaths(anchor: RepoAnchor, rawDataDir: string, rawDb: ObservabilityDbInput): DataPaths;
75
112
  /**
76
113
  * Resolve a prompt ref (a `prompt_engineering.system`/`.user` value from
77
114
  * config) to an absolute path, trying every root a ref could sensibly be
@@ -19,6 +19,7 @@
19
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
+ import { resolveObservabilityDb } from "./data_types.js";
22
23
  export const PACKAGE_ROOT = path.resolve(import.meta.dirname, "..", "..");
23
24
  export const ASSETS_DIR = path.join(PACKAGE_ROOT, "assets");
24
25
  /** The prebuilt SPA — a sibling of dist/ and assets/, built once at publish time (see app/). */
@@ -121,14 +122,42 @@ function healBrokenDataDir(data_dir) {
121
122
  *
122
123
  * Not otherwise side-effect-free: see `healBrokenDataDir` above, which every
123
124
  * caller needs run before its own first `mkdirSync` against `data_dir`.
125
+ *
126
+ * `rawDb` accepts every form `ObservabilityConfigSchema.db` accepts (bare
127
+ * string, `{kind:"sqlite",...}`, `{kind:"d1",...}`) — normalized here via
128
+ * `resolveObservabilityDb` — so every existing call site
129
+ * (`resolveDataPaths(anchor, cfg.defaults.data_dir, cfg.observability.db)`)
130
+ * keeps compiling and behaving unchanged with no call-site edit needed.
131
+ *
132
+ * For a `"sqlite"`-kind db, `db_path`/`db.path` resolve BYTE-FOR-BYTE
133
+ * identically to before this PR: `path.resolve(repo_root, path)`.
134
+ *
135
+ * For a `"d1"`-kind db there is no local file at all — `db_path` is `null`,
136
+ * and `db` carries the resolved `{kind:"d1", database_id, account_id_env,
137
+ * api_token_env}` for `core/trace_db.ts`'s `createTraceDb()` to open (PR 2:
138
+ * SPF #66). `sessions_dir` has no `db_path` to anchor off in this case, so it
139
+ * falls back to `data_dir/sessions` — session JSONL/envelope artifacts still
140
+ * always live on disk regardless of where the queryable trace mirror lives.
141
+ *
142
+ * For a `"sqlite"`-kind db, `sessions_dir` stays what it always was — ALWAYS
143
+ * a sibling of `db_path` (`dirname(db_path)/sessions`), never `data_dir`
144
+ * directly — `migrate.ts` and `SfDb` both depend on that: a repo whose db
145
+ * lives outside `data_dir` (`observability.db: elsewhere/custom.db` next to
146
+ * `defaults.data_dir: .spf/data`) still has its sessions found. Relocating
147
+ * `data_dir` alone must never orphan an existing db's session artifacts.
124
148
  */
125
- export function resolveDataPaths(anchor, rawDataDir, rawDbPath) {
149
+ export function resolveDataPaths(anchor, rawDataDir, rawDb) {
126
150
  const data_dir = path.resolve(anchor.repo_root, rawDataDir);
127
151
  healBrokenDataDir(data_dir);
128
152
  const data_dir_rel = path.relative(anchor.repo_root, data_dir).split(path.sep).join("/");
129
- const db_path = path.resolve(anchor.repo_root, rawDbPath);
153
+ const normalized = resolveObservabilityDb(rawDb);
154
+ if (normalized.kind === "d1") {
155
+ const sessions_dir = path.resolve(data_dir, "sessions");
156
+ return { data_dir, data_dir_rel, db: normalized, db_path: null, sessions_dir };
157
+ }
158
+ const db_path = path.resolve(anchor.repo_root, normalized.path);
130
159
  const sessions_dir = path.resolve(path.dirname(db_path), "sessions");
131
- return { data_dir, data_dir_rel, db_path, sessions_dir };
160
+ return { data_dir, data_dir_rel, db: { kind: "sqlite", path: db_path }, db_path, sessions_dir };
132
161
  }
133
162
  /**
134
163
  * Resolve a prompt ref (a `prompt_engineering.system`/`.user` value from
@@ -22,10 +22,10 @@ export interface RunLike {
22
22
  context_handoff_dir: string;
23
23
  repo_root: string;
24
24
  console: {
25
- note: (message: string) => void;
25
+ note: (message: string) => Promise<void>;
26
26
  };
27
27
  tracer: {
28
- event: (record: EventRecord) => string;
28
+ event: (record: EventRecord) => Promise<string>;
29
29
  };
30
30
  adw_id: string;
31
31
  }
@@ -41,7 +41,7 @@ export declare function resolveSuite(run: RunLike, suiteName: string): QualityCh
41
41
  * QualityNotConfigured (via resolveSuite) if the suite or any of its checks
42
42
  * isn't in the config — before any check runs, let alone any agent spawns.
43
43
  */
44
- export declare function runSuite(run: RunLike, suiteName: string): QualityResult;
44
+ export declare function runSuite(run: RunLike, suiteName: string): Promise<QualityResult>;
45
45
  /**
46
46
  * The `test` suite alone — the deterministic test phase most chains use.
47
47
  *
@@ -50,9 +50,9 @@ export declare function runSuite(run: RunLike, suiteName: string): QualityResult
50
50
  * subprocess already knows; the repair loop is unchanged, because a failure
51
51
  * still reaches the builder through `asEnvelope` below.
52
52
  */
53
- export declare function runTests(run: RunLike): QualityResult;
53
+ export declare function runTests(run: RunLike): Promise<QualityResult>;
54
54
  /** Every configured check, across every configured suite's union — the `all` suite. */
55
- export declare function runQuality(run: RunLike): QualityResult;
55
+ export declare function runQuality(run: RunLike): Promise<QualityResult>;
56
56
  /**
57
57
  * Log a deterministic block's verdict — the same shape every chain uses.
58
58
  *
@@ -60,8 +60,8 @@ export declare function runQuality(run: RunLike): QualityResult;
60
60
  * hand; one copy here instead of one per chain.
61
61
  */
62
62
  export declare function record(ph: {
63
- log: (payload: Record<string, unknown>) => void;
64
- }, result: QualityResult): void;
63
+ log: (payload: Record<string, unknown>) => Promise<void>;
64
+ }, result: QualityResult): Promise<void>;
65
65
  /**
66
66
  * Wrap a deterministic result so an agent can be handed it directly.
67
67
  *
@@ -56,13 +56,13 @@ function shellJoin(argv) {
56
56
  .map((arg) => (/[\s"'$`\\!*?[\](){}<>|&;#~]/.test(arg) ? `'${arg.replace(/'/g, `'\\''`)}'` : arg))
57
57
  .join(" ");
58
58
  }
59
- function runCheck(spec, run) {
59
+ async function runCheck(spec, run) {
60
60
  const phase = run.phases[run.phases.length - 1];
61
61
  const outputDir = checkDir(run, spec.name);
62
62
  const outputArtifact = path.join(outputDir, "command.log");
63
63
  const command = shellJoin(spec.argv);
64
64
  const env = operatorEnv(); // the engineer's own shell environment
65
- run.console.note(`quality ${spec.name}: ${command}`);
65
+ await run.console.note(`quality ${spec.name}: ${command}`);
66
66
  const startedAt = nowIso();
67
67
  const clock = performance.now();
68
68
  let stdout = "";
@@ -100,7 +100,7 @@ function runCheck(spec, run) {
100
100
  writeFileSync(outputArtifact, `$ ${command}\nexit: ${returncode}\nduration_seconds: ${duration.toFixed(3)}\n` +
101
101
  `\n--- stdout ---\n${stdout}\n--- stderr ---\n${stderr}\n`);
102
102
  const passed = returncode === 0;
103
- run.tracer.event(makeEventRecord({
103
+ await run.tracer.event(makeEventRecord({
104
104
  adw_id: run.adw_id,
105
105
  phase_id: phase.phase_id,
106
106
  type: "tool_call",
@@ -116,7 +116,7 @@ function runCheck(spec, run) {
116
116
  started_at: startedAt,
117
117
  ended_at: nowIso(),
118
118
  }));
119
- run.console.note(`quality ${spec.name}: ${passed ? "passed" : "failed"} (exit ${returncode}, ${duration.toFixed(1)}s)`);
119
+ await run.console.note(`quality ${spec.name}: ${passed ? "passed" : "failed"} (exit ${returncode}, ${duration.toFixed(1)}s)`);
120
120
  return {
121
121
  name: spec.name,
122
122
  area: spec.area,
@@ -139,9 +139,15 @@ function runCheck(spec, run) {
139
139
  * QualityNotConfigured (via resolveSuite) if the suite or any of its checks
140
140
  * isn't in the config — before any check runs, let alone any agent spawns.
141
141
  */
142
- export function runSuite(run, suiteName) {
142
+ export async function runSuite(run, suiteName) {
143
143
  const specs = resolveSuite(run, suiteName);
144
- const checks = specs.map((spec) => runCheck(spec, run));
144
+ // Sequential, not Promise.all: check output/duration/tool_call ordering has
145
+ // always been one-at-a-time (spawnSync itself was always sequential) — a
146
+ // parallel run would interleave `run.console.note`/`run.tracer.event`
147
+ // calls across checks with no ordering guarantee.
148
+ const checks = [];
149
+ for (const spec of specs)
150
+ checks.push(await runCheck(spec, run));
145
151
  // A failure is the command, its exit code, and what it actually printed —
146
152
  // everything a builder needs to repair without opening a log or being told
147
153
  // what the error "means" by a parser that guessed.
@@ -163,11 +169,11 @@ export function runSuite(run, suiteName) {
163
169
  * subprocess already knows; the repair loop is unchanged, because a failure
164
170
  * still reaches the builder through `asEnvelope` below.
165
171
  */
166
- export function runTests(run) {
172
+ export async function runTests(run) {
167
173
  return runSuite(run, "test");
168
174
  }
169
175
  /** Every configured check, across every configured suite's union — the `all` suite. */
170
- export function runQuality(run) {
176
+ export async function runQuality(run) {
171
177
  return runSuite(run, "all");
172
178
  }
173
179
  /**
@@ -176,9 +182,9 @@ export function runQuality(run) {
176
182
  * Every quality/test phase in every chain reported this same summary by
177
183
  * hand; one copy here instead of one per chain.
178
184
  */
179
- export function record(ph, result) {
185
+ export async function record(ph, result) {
180
186
  const passed = result.checks.filter((c) => c.passed).length;
181
- ph.log({ passed: result.passed, checks: `${passed}/${result.checks.length}`, artifacts: result.artifacts.join(", ") });
187
+ await ph.log({ passed: result.passed, checks: `${passed}/${result.checks.length}`, artifacts: result.artifacts.join(", ") });
182
188
  }
183
189
  /**
184
190
  * Wrap a deterministic result so an agent can be handed it directly.
@@ -41,7 +41,7 @@ export function resolveAuthoringProvider(cfg) {
41
41
  if (!email || !token) {
42
42
  throw new Error('JIRA_EMAIL and JIRA_API_TOKEN must both be set — the refine lane needs an Atlassian account email plus an API token (id.atlassian.com -> Security -> API tokens)');
43
43
  }
44
- return new JiraProvider(cfg.watch.jira.base_url, cfg.watch.jira.project_key, cfg.watch.label_prefix, email, token, cfg.watch.jira.issue_types);
44
+ return new JiraProvider(cfg.watch.jira.base_url, cfg.watch.jira.project_key, cfg.watch.label_prefix, email, token, cfg.watch.jira.issue_types, cfg.watch.jira.status_map);
45
45
  }
46
46
  if (cfg.watch.issue_provider !== "github") {
47
47
  throw new Error(`watch.issue_provider ${JSON.stringify(cfg.watch.issue_provider)} does not support issue authoring — the refine lane needs "github" or "jira"`);
@@ -61,7 +61,7 @@ export function resolveAuthoringProvider(cfg) {
61
61
  if (!token) {
62
62
  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)');
63
63
  }
64
- return new GitHubProvider(repo, cfg.watch.label_prefix, token);
64
+ return new GitHubProvider(repo, cfg.watch.label_prefix, token, cfg.watch.github.project_number, cfg.watch.github.status_map);
65
65
  }
66
66
  function typeLabel(labelPrefix, kind) {
67
67
  return `${labelPrefix}:type:${kind}`;
@@ -20,13 +20,19 @@ interface AgentMapEntry {
20
20
  coding_agent: string;
21
21
  }
22
22
  export interface PhaseHandle {
23
- log(payload: Record<string, unknown>): void;
23
+ log(payload: Record<string, unknown>): Promise<void>;
24
24
  call<T extends EnvelopeBase>(call: AgentCall<T>): Promise<T>;
25
25
  }
26
26
  export interface RunInit {
27
27
  cfg: SFConfig;
28
28
  adwId: string;
29
29
  tracer: Tracer;
30
+ /**
31
+ * `tracer.maxPhaseSeq(adwId)`, resolved by the caller BEFORE constructing
32
+ * `Run` — a real trace-db read now (a network call for a D1-backed repo),
33
+ * and `Run`'s own constructor cannot be async. See `session.ts`'s `ensure()`.
34
+ */
35
+ startSeq: number;
30
36
  engineer: string;
31
37
  /** Absolute. Resolved once, upstream, by paths.resolveAnchor(). */
32
38
  repoRoot: string;
@@ -75,7 +81,7 @@ export declare class Run {
75
81
  private agentMapPath;
76
82
  constructor(init: RunInit);
77
83
  saveAgentMap(agent: string, entry: AgentMapEntry): void;
78
- addUsage(tokens: number, cost: number): void;
84
+ addUsage(tokens: number, cost: number): Promise<void>;
79
85
  phase<T>(params: PhaseParams, fn: (ph: PhaseHandle) => Promise<T>): Promise<T>;
80
86
  /**
81
87
  * Finalize the run and return its exit code. Call this exactly once.
@@ -86,6 +92,6 @@ export declare class Run {
86
92
  * so the exit code, the session status, and the banner are decided
87
93
  * together and cannot disagree.
88
94
  */
89
- finish(accepted?: boolean, reason?: string): number;
95
+ finish(accepted?: boolean, reason?: string): Promise<number>;
90
96
  }
91
97
  export {};
@@ -14,8 +14,20 @@ import * as agents from "./agents.js";
14
14
  import { makeGit } from "./git_helper.js";
15
15
  import { Console } from "./console.js";
16
16
  import { Tracer } from "./tracer.js";
17
- import { makeEventRecord } from "./data_types.js";
17
+ import { makeEventRecord, resolveObservabilityDb } from "./data_types.js";
18
18
  import { ensureDir, nowIso } from "./utils.js";
19
+ /**
20
+ * `Console.sessionFinished`'s trailing `db` row wants a short, human-facing
21
+ * label, not a filesystem path specifically — `cfg.observability.db` is now
22
+ * a discriminated shape (see `data_types.ts`'s migration note), so this
23
+ * normalizes it to whichever of "the sqlite path" or "the D1 database id"
24
+ * is actually meaningful to show, rather than assuming it's always a path
25
+ * string the way this call site did before that field existed.
26
+ */
27
+ function describeObservabilityDb(db) {
28
+ const resolved = resolveObservabilityDb(db);
29
+ return resolved.kind === "sqlite" ? resolved.path : `d1:${resolved.database_id}`;
30
+ }
19
31
  class PhaseHandleImpl {
20
32
  run;
21
33
  phase;
@@ -23,17 +35,17 @@ class PhaseHandleImpl {
23
35
  this.run = run;
24
36
  this.phase = phase;
25
37
  }
26
- log(payload) {
27
- this.run.tracer.event(makeEventRecord({
38
+ async log(payload) {
39
+ await this.run.tracer.event(makeEventRecord({
28
40
  adw_id: this.run.adw_id,
29
41
  phase_id: this.phase.phase_id,
30
42
  type: "log",
31
43
  name: this.phase.params.name,
32
44
  payload,
33
45
  }));
34
- this.run.console.note(Object.entries(payload).map(([k, v]) => `${k}: ${v}`).join(", "));
46
+ await this.run.console.note(Object.entries(payload).map(([k, v]) => `${k}: ${v}`).join(", "));
35
47
  if (this.phase.params.kind === "engineer" && "input" in payload) {
36
- this.run.tracer.sessionRequest(this.run.adw_id, String(payload.input));
48
+ await this.run.tracer.sessionRequest(this.run.adw_id, String(payload.input));
37
49
  }
38
50
  }
39
51
  async call(call) {
@@ -80,7 +92,7 @@ export class Run {
80
92
  this.notify = init.notifier ?? null;
81
93
  this.console = new Console(init.tracer, init.adwId, this.notify, init.chainName || "adw", init.sink, init.observer);
82
94
  this.engineer = init.engineer;
83
- this.seq = init.tracer.maxPhaseSeq(init.adwId);
95
+ this.seq = init.startSeq;
84
96
  this.repo_root = init.repoRoot;
85
97
  this.spf_dir = init.sfDir;
86
98
  this.git = makeGit(init.repoRoot);
@@ -95,12 +107,12 @@ export class Run {
95
107
  this.agent_map[agent] = entry;
96
108
  writeFileSync(this.agentMapPath, JSON.stringify(this.agent_map, null, 2));
97
109
  }
98
- // ── usage (run totals mirror what the tracer accumulates in sqlite) ─────
99
- addUsage(tokens, cost) {
110
+ // ── usage (run totals mirror what the tracer accumulates in the trace db)
111
+ async addUsage(tokens, cost) {
100
112
  this.tokens += tokens;
101
113
  this.cost += cost;
102
- this.tracer.sessionAddUsage(this.adw_id, tokens, cost);
103
- this.console.notifyUsage(this.tokens, this.cost);
114
+ await this.tracer.sessionAddUsage(this.adw_id, tokens, cost);
115
+ await this.console.notifyUsage(this.tokens, this.cost);
104
116
  }
105
117
  // ── the phase primitive ─────────────────────────────────────────────────
106
118
  async phase(params, fn) {
@@ -117,35 +129,35 @@ export class Run {
117
129
  ended_at: null,
118
130
  };
119
131
  this.phases.push(phase);
120
- this.tracer.phaseUpsert(phase);
121
- this.tracer.event(makeEventRecord({
132
+ await this.tracer.phaseUpsert(phase);
133
+ await this.tracer.event(makeEventRecord({
122
134
  adw_id: this.adw_id,
123
135
  phase_id: phase.phase_id,
124
136
  type: "phase_start",
125
137
  name: params.name,
126
138
  payload: { kind: params.kind, owner: params.owner, description: params.description },
127
139
  }));
128
- this.console.phaseStarted(phase);
140
+ await this.console.phaseStarted(phase);
129
141
  const clock = performance.now();
130
142
  try {
131
143
  const result = await fn(new PhaseHandleImpl(this, phase));
132
144
  phase.status = "success";
133
145
  phase.ended_at = nowIso();
134
- this.tracer.event(makeEventRecord({ adw_id: this.adw_id, phase_id: phase.phase_id, type: "phase_end", name: params.name, payload: { status: "success" } }));
135
- this.tracer.phaseUpsert(phase);
136
- this.console.phaseEnded(phase, (performance.now() - clock) / 1000);
146
+ await this.tracer.event(makeEventRecord({ adw_id: this.adw_id, phase_id: phase.phase_id, type: "phase_end", name: params.name, payload: { status: "success" } }));
147
+ await this.tracer.phaseUpsert(phase);
148
+ await this.console.phaseEnded(phase, (performance.now() - clock) / 1000);
137
149
  return result;
138
150
  }
139
151
  catch (error) {
140
152
  phase.status = "fail"; // success must be earned
141
153
  phase.error = String(error?.message ?? error).slice(0, 1000);
142
154
  phase.ended_at = nowIso();
143
- this.tracer.event(makeEventRecord({ adw_id: this.adw_id, phase_id: phase.phase_id, type: "error", name: params.name, payload: { error: phase.error } }));
144
- this.tracer.event(makeEventRecord({ adw_id: this.adw_id, phase_id: phase.phase_id, type: "phase_end", name: params.name, payload: { status: "fail" } }));
145
- this.tracer.phaseUpsert(phase);
146
- this.tracer.sessionFinish(this.adw_id, false);
147
- this.console.phaseEnded(phase, (performance.now() - clock) / 1000);
148
- this.console.sessionFinished(false, this.tokens, this.cost, this.cfg.observability.db);
155
+ await this.tracer.event(makeEventRecord({ adw_id: this.adw_id, phase_id: phase.phase_id, type: "error", name: params.name, payload: { error: phase.error } }));
156
+ await this.tracer.event(makeEventRecord({ adw_id: this.adw_id, phase_id: phase.phase_id, type: "phase_end", name: params.name, payload: { status: "fail" } }));
157
+ await this.tracer.phaseUpsert(phase);
158
+ await this.tracer.sessionFinish(this.adw_id, false);
159
+ await this.console.phaseEnded(phase, (performance.now() - clock) / 1000);
160
+ await this.console.sessionFinished(false, this.tokens, this.cost, describeObservabilityDb(this.cfg.observability.db));
149
161
  throw error;
150
162
  }
151
163
  }
@@ -158,22 +170,22 @@ export class Run {
158
170
  * so the exit code, the session status, and the banner are decided
159
171
  * together and cannot disagree.
160
172
  */
161
- finish(accepted = true, reason = "") {
173
+ async finish(accepted = true, reason = "") {
162
174
  const phasesOk = this.phases.length > 0 && this.phases.every((p) => p.status === "success");
163
175
  const ok = phasesOk && accepted;
164
176
  if (phasesOk && !accepted) {
165
177
  const note = reason || "the run's acceptance criterion was not met";
166
- this.tracer.event(makeEventRecord({
178
+ await this.tracer.event(makeEventRecord({
167
179
  adw_id: this.adw_id,
168
180
  phase_id: this.phases.length > 0 ? this.phases[this.phases.length - 1].phase_id : "",
169
181
  type: "error",
170
182
  name: "not_accepted",
171
183
  payload: { reason: note },
172
184
  }));
173
- this.console.note(`not accepted: ${note}`);
185
+ await this.console.note(`not accepted: ${note}`);
174
186
  }
175
- this.tracer.sessionFinish(this.adw_id, ok);
176
- this.console.sessionFinished(ok, this.tokens, this.cost, this.cfg.observability.db);
187
+ await this.tracer.sessionFinish(this.adw_id, ok);
188
+ await this.console.sessionFinished(ok, this.tokens, this.cost, describeObservabilityDb(this.cfg.observability.db));
177
189
  return ok ? 0 : 1;
178
190
  }
179
191
  }
@@ -30,7 +30,7 @@ import type { SFConfig } from "./data_types.ts";
30
30
  * `releaseOtelExporter`, and harmless for the same reason: that process
31
31
  * exits right after anyway.
32
32
  */
33
- export declare function finalize(adwId: string | null | undefined): void;
33
+ export declare function finalize(adwId: string | null | undefined): Promise<void>;
34
34
  /** Tests only: which adw_ids the process-wide signal handler currently considers active. */
35
35
  export declare function activeRunIdsForTest(): string[];
36
36
  /**
@@ -49,4 +49,4 @@ export declare function ensure(cfg: SFConfig, adwId?: string | null, cwd?: strin
49
49
  renderHooks?: {
50
50
  sink?: (line: string) => void;
51
51
  observer?: RunObserver | null;
52
- }): Run;
52
+ }): Promise<Run>;