@gr8ful/spf 0.15.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 (64) hide show
  1. package/README.md +15 -5
  2. package/assets/skill/references/config.md +9 -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 +109 -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 +27 -27
  24. package/dist/cli/index.js +3 -1
  25. package/dist/cli/interview.d.ts +1 -0
  26. package/dist/cli/interview.js +86 -4
  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 +126 -12
  34. package/dist/core/data_types.js +101 -4
  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/paths.d.ts +41 -4
  39. package/dist/core/paths.js +32 -3
  40. package/dist/core/quality.d.ts +7 -7
  41. package/dist/core/quality.js +16 -10
  42. package/dist/core/runner.d.ts +9 -3
  43. package/dist/core/runner.js +39 -27
  44. package/dist/core/session.d.ts +2 -2
  45. package/dist/core/session.js +39 -18
  46. package/dist/core/sqlite.d.ts +14 -7
  47. package/dist/core/sqlite.js +14 -7
  48. package/dist/core/trace_db.d.ts +118 -0
  49. package/dist/core/trace_db.js +278 -0
  50. package/dist/core/tracer.d.ts +64 -34
  51. package/dist/core/tracer.js +141 -69
  52. package/dist/core/watch.d.ts +4 -4
  53. package/dist/core/watch.js +2 -2
  54. package/dist/ui/server/app.js +10 -10
  55. package/dist/ui/server/db.d.ts +89 -21
  56. package/dist/ui/server/db.js +235 -99
  57. package/dist/ui/server/serve.d.ts +5 -1
  58. package/dist/ui/server/serve.js +4 -5
  59. package/package.json +1 -1
  60. package/web/assets/index-CQ3k1Y1-.css +1 -0
  61. package/web/assets/index-CU8tom6S.js +21 -0
  62. package/web/index.html +2 -2
  63. package/web/assets/index-CRujNW-1.js +0 -11
  64. package/web/assets/index-Cto6nuQL.css +0 -1
@@ -671,7 +671,7 @@ export interface SandboxSpec {
671
671
  }
672
672
  export declare const AgentConfigSchema: v.ObjectSchema<{
673
673
  readonly name: v.StringSchema<undefined>;
674
- readonly coding_agent: v.OptionalSchema<v.PicklistSchema<["flue", "claude_code"], undefined>, "flue">;
674
+ readonly coding_agent: v.OptionalSchema<v.PicklistSchema<["flue", "claude_code", "opencode"], undefined>, "flue">;
675
675
  readonly model: v.OptionalSchema<v.StringSchema<undefined>, "google/gemini-3.6-flash">;
676
676
  readonly thinking: v.OptionalSchema<v.PicklistSchema<["off", "minimal", "low", "medium", "high", "xhigh", "max"], undefined>, "medium">;
677
677
  readonly color: v.OptionalSchema<v.StringSchema<undefined>, "">;
@@ -688,7 +688,7 @@ export declare const AgentConfigSchema: v.ObjectSchema<{
688
688
  }, undefined>;
689
689
  export type AgentConfig = v.InferOutput<typeof AgentConfigSchema>;
690
690
  export declare const ConfigDefaultsSchema: v.ObjectSchema<{
691
- readonly coding_agent: v.OptionalSchema<v.PicklistSchema<["flue", "claude_code"], undefined>, "flue">;
691
+ readonly coding_agent: v.OptionalSchema<v.PicklistSchema<["flue", "claude_code", "opencode"], undefined>, "flue">;
692
692
  readonly model: v.OptionalSchema<v.StringSchema<undefined>, "google/gemini-3.6-flash">;
693
693
  readonly thinking: v.OptionalSchema<v.PicklistSchema<["off", "minimal", "low", "medium", "high", "xhigh", "max"], undefined>, "medium">;
694
694
  readonly color: v.OptionalSchema<v.StringSchema<undefined>, "">;
@@ -763,8 +763,106 @@ export declare const OTelConfigSchema: v.ObjectSchema<{
763
763
  readonly service_name: v.OptionalSchema<v.StringSchema<undefined>, "spf">;
764
764
  }, undefined>;
765
765
  export type OTelConfig = v.InferOutput<typeof OTelConfigSchema>;
766
+ /**
767
+ * MIGRATION NOTE (BT-issue #66, 3 PRs): `observability.db` used to be ONLY a
768
+ * bare string (a local sqlite path, defaulting to ".spf/data/spf.db"). This
769
+ * is now a discriminated shape that additionally accepts an explicit
770
+ * `{ kind: "sqlite", path? }` object (equivalent to the string form, just
771
+ * spelled out) and a `{ kind: "d1", database_id, ... }` object that names a
772
+ * remote Cloudflare D1 database instead of a local file. The bare-string
773
+ * form is unchanged and remains the default for every existing config —
774
+ * nothing in the wild needs to change. `resolveObservabilityDb` below
775
+ * normalizes all three accepted forms to one shape.
776
+ *
777
+ * PR 1 (this schema + normalizer + `paths.resolveDataPaths` plumbing) left
778
+ * `resolveDataPaths` refusing a `kind: "d1"` config outright — no adapter
779
+ * existed yet. PR 2 (`core/trace_db.ts`'s `TraceDb` interface, `LocalTraceDb`,
780
+ * `D1TraceDb`, and `createTraceDb()`, wired through `Tracer`/`SfDb`) removes
781
+ * that refusal: `resolveDataPaths` now returns a usable descriptor for
782
+ * either kind, and both backends are real. PR 3 wires `spf doctor`/`spf
783
+ * init`'s interview flow to the new option.
784
+ */
785
+ export declare const SqliteDbConfigSchema: v.ObjectSchema<{
786
+ readonly kind: v.LiteralSchema<"sqlite", undefined>;
787
+ readonly path: v.OptionalSchema<v.StringSchema<undefined>, ".spf/data/spf.db">;
788
+ }, undefined>;
789
+ export type SqliteDbConfig = v.InferOutput<typeof SqliteDbConfigSchema>;
790
+ /**
791
+ * `account_id_env`/`api_token_env` default to the SAME env var names
792
+ * `core/cloudflare_provider.ts` already reads for the Cloudflare Workers AI
793
+ * provider (`CLOUDFLARE_ACCOUNT_ID`/`CLOUDFLARE_API_TOKEN`, see
794
+ * `core/providers.ts`'s `PROVIDER_ENV_KEYS.cloudflare`) — one Cloudflare
795
+ * account's credentials, read from the same two env vars, cover both
796
+ * Workers AI and a D1-backed trace db unless a config explicitly points
797
+ * elsewhere (e.g. a second Cloudflare account) by naming different env vars.
798
+ */
799
+ export declare const D1DbConfigSchema: v.ObjectSchema<{
800
+ readonly kind: v.LiteralSchema<"d1", undefined>;
801
+ readonly database_id: v.StringSchema<undefined>;
802
+ readonly account_id_env: v.OptionalSchema<v.StringSchema<undefined>, "CLOUDFLARE_ACCOUNT_ID">;
803
+ readonly api_token_env: v.OptionalSchema<v.StringSchema<undefined>, "CLOUDFLARE_API_TOKEN">;
804
+ }, undefined>;
805
+ export type D1DbConfig = v.InferOutput<typeof D1DbConfigSchema>;
806
+ export declare const ObservabilityDbSchema: v.SchemaWithPipe<readonly [v.UnknownSchema, v.RawTransformAction<unknown, string | {
807
+ kind: "sqlite";
808
+ path: string;
809
+ } | {
810
+ kind: "d1";
811
+ database_id: string;
812
+ account_id_env: string;
813
+ api_token_env: string;
814
+ }>]>;
815
+ export type ObservabilityDbConfig = v.InferOutput<typeof ObservabilityDbSchema>;
816
+ /**
817
+ * What `resolveObservabilityDb` ACCEPTS — deliberately more permissive than
818
+ * `ObservabilityDbConfig` (that type is the schema's OUTPUT, post-default,
819
+ * so every optional field reads as required). This is the pre-default INPUT
820
+ * shape — `path`/`account_id_env`/`api_token_env` all optional, exactly
821
+ * what a hand-written `{kind:"sqlite"}` or `{kind:"d1", database_id:"..."}`
822
+ * literal looks like before `v.parse` fills its defaults in. An already-
823
+ * parsed `ObservabilityDbConfig` (every optional field present) satisfies
824
+ * this too, so the one normalizer works on config fresh off `v.parse` AND
825
+ * on a literal built by hand (e.g. in a test, or by a future caller that
826
+ * hasn't gone through the schema).
827
+ */
828
+ export type ObservabilityDbInput = string | {
829
+ kind: "sqlite";
830
+ path?: string;
831
+ } | {
832
+ kind: "d1";
833
+ database_id: string;
834
+ account_id_env?: string;
835
+ api_token_env?: string;
836
+ };
837
+ /** What `resolveObservabilityDb` normalizes any of the three accepted `db:` forms to. PR 2's D1 adapter is built against this shape. */
838
+ export type NormalizedObservabilityDb = {
839
+ kind: "sqlite";
840
+ path: string;
841
+ } | {
842
+ kind: "d1";
843
+ database_id: string;
844
+ account_id_env: string;
845
+ api_token_env: string;
846
+ };
847
+ /**
848
+ * Normalizes `observability.db` — bare string, `{kind:"sqlite",...}`, or
849
+ * `{kind:"d1",...}` — to one shape. Pure and side-effect-free: does NOT
850
+ * resolve `path` to an absolute path (that's `paths.resolveDataPaths`'s job,
851
+ * which needs `repo_root` to do it) and does NOT read the D1 env vars it
852
+ * names (that's PR 2's adapter's job, at the point it actually needs to
853
+ * authenticate).
854
+ */
855
+ export declare function resolveObservabilityDb(db: ObservabilityDbInput): NormalizedObservabilityDb;
766
856
  export declare const ObservabilityConfigSchema: v.ObjectSchema<{
767
- readonly db: v.OptionalSchema<v.StringSchema<undefined>, ".spf/data/spf.db">;
857
+ readonly db: v.OptionalSchema<v.SchemaWithPipe<readonly [v.UnknownSchema, v.RawTransformAction<unknown, string | {
858
+ kind: "sqlite";
859
+ path: string;
860
+ } | {
861
+ kind: "d1";
862
+ database_id: string;
863
+ account_id_env: string;
864
+ api_token_env: string;
865
+ }>]>, ".spf/data/spf.db">;
768
866
  readonly poll_ms: v.OptionalSchema<v.NumberSchema<undefined>, 500>;
769
867
  readonly otel: v.OptionalSchema<v.ObjectSchema<{
770
868
  readonly endpoint: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.UrlAction<string, undefined>]>;
@@ -1304,7 +1402,7 @@ export type ReviewConfig = v.InferOutput<typeof ReviewConfigSchema>;
1304
1402
  */
1305
1403
  export declare const TierSchema: v.ObjectSchema<{
1306
1404
  readonly name: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.MinLengthAction<string, 1, undefined>]>;
1307
- readonly coding_agent: v.OptionalSchema<v.PicklistSchema<["flue", "claude_code"], undefined>, "flue">;
1405
+ readonly coding_agent: v.OptionalSchema<v.PicklistSchema<["flue", "claude_code", "opencode"], undefined>, "flue">;
1308
1406
  readonly model: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.MinLengthAction<string, 1, undefined>]>;
1309
1407
  }, undefined>;
1310
1408
  export type Tier = v.InferOutput<typeof TierSchema>;
@@ -1339,7 +1437,7 @@ export declare const TieringConfigSchema: v.ObjectSchema<{
1339
1437
  readonly enabled: v.OptionalSchema<v.BooleanSchema<undefined>, false>;
1340
1438
  readonly tiers: v.OptionalSchema<v.ArraySchema<v.ObjectSchema<{
1341
1439
  readonly name: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.MinLengthAction<string, 1, undefined>]>;
1342
- readonly coding_agent: v.OptionalSchema<v.PicklistSchema<["flue", "claude_code"], undefined>, "flue">;
1440
+ readonly coding_agent: v.OptionalSchema<v.PicklistSchema<["flue", "claude_code", "opencode"], undefined>, "flue">;
1343
1441
  readonly model: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.MinLengthAction<string, 1, undefined>]>;
1344
1442
  }, undefined>, undefined>, () => never[]>;
1345
1443
  readonly roles: v.OptionalSchema<v.RecordSchema<v.StringSchema<undefined>, v.StringSchema<undefined>, undefined>, () => {}>;
@@ -1348,7 +1446,7 @@ export type TieringConfig = v.InferOutput<typeof TieringConfigSchema>;
1348
1446
  export declare const SFConfigSchema: v.ObjectSchema<{
1349
1447
  readonly env: v.OptionalSchema<v.RecordSchema<v.StringSchema<undefined>, v.StringSchema<undefined>, undefined>, () => {}>;
1350
1448
  readonly defaults: v.OptionalSchema<v.ObjectSchema<{
1351
- readonly coding_agent: v.OptionalSchema<v.PicklistSchema<["flue", "claude_code"], undefined>, "flue">;
1449
+ readonly coding_agent: v.OptionalSchema<v.PicklistSchema<["flue", "claude_code", "opencode"], undefined>, "flue">;
1352
1450
  readonly model: v.OptionalSchema<v.StringSchema<undefined>, "google/gemini-3.6-flash">;
1353
1451
  readonly thinking: v.OptionalSchema<v.PicklistSchema<["off", "minimal", "low", "medium", "high", "xhigh", "max"], undefined>, "medium">;
1354
1452
  readonly color: v.OptionalSchema<v.StringSchema<undefined>, "">;
@@ -1401,7 +1499,7 @@ export declare const SFConfigSchema: v.ObjectSchema<{
1401
1499
  readonly max_run_cost: v.OptionalSchema<v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.GtValueAction<number, 0, undefined>]>, undefined>;
1402
1500
  readonly max_run_tokens: v.OptionalSchema<v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.GtValueAction<number, 0, undefined>]>, undefined>;
1403
1501
  }, undefined>, () => {
1404
- coding_agent: "claude_code" | "flue";
1502
+ coding_agent: "claude_code" | "flue" | "opencode";
1405
1503
  model: string;
1406
1504
  thinking: "high" | "low" | "max" | "medium" | "minimal" | "off" | "xhigh";
1407
1505
  color: string;
@@ -1413,7 +1511,15 @@ export declare const SFConfigSchema: v.ObjectSchema<{
1413
1511
  max_run_tokens?: number | undefined;
1414
1512
  }>;
1415
1513
  readonly observability: v.OptionalSchema<v.ObjectSchema<{
1416
- readonly db: v.OptionalSchema<v.StringSchema<undefined>, ".spf/data/spf.db">;
1514
+ readonly db: v.OptionalSchema<v.SchemaWithPipe<readonly [v.UnknownSchema, v.RawTransformAction<unknown, string | {
1515
+ kind: "sqlite";
1516
+ path: string;
1517
+ } | {
1518
+ kind: "d1";
1519
+ database_id: string;
1520
+ account_id_env: string;
1521
+ api_token_env: string;
1522
+ }>]>, ".spf/data/spf.db">;
1417
1523
  readonly poll_ms: v.OptionalSchema<v.NumberSchema<undefined>, 500>;
1418
1524
  readonly otel: v.OptionalSchema<v.ObjectSchema<{
1419
1525
  readonly endpoint: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.UrlAction<string, undefined>]>;
@@ -1421,7 +1527,15 @@ export declare const SFConfigSchema: v.ObjectSchema<{
1421
1527
  readonly service_name: v.OptionalSchema<v.StringSchema<undefined>, "spf">;
1422
1528
  }, undefined>, undefined>;
1423
1529
  }, undefined>, () => {
1424
- db: string;
1530
+ db: string | {
1531
+ kind: "sqlite";
1532
+ path: string;
1533
+ } | {
1534
+ kind: "d1";
1535
+ database_id: string;
1536
+ account_id_env: string;
1537
+ api_token_env: string;
1538
+ };
1425
1539
  poll_ms: number;
1426
1540
  otel?: {
1427
1541
  endpoint: string;
@@ -1433,7 +1547,7 @@ export declare const SFConfigSchema: v.ObjectSchema<{
1433
1547
  }>;
1434
1548
  readonly agents: v.OptionalSchema<v.ArraySchema<v.ObjectSchema<{
1435
1549
  readonly name: v.StringSchema<undefined>;
1436
- readonly coding_agent: v.OptionalSchema<v.PicklistSchema<["flue", "claude_code"], undefined>, "flue">;
1550
+ readonly coding_agent: v.OptionalSchema<v.PicklistSchema<["flue", "claude_code", "opencode"], undefined>, "flue">;
1437
1551
  readonly model: v.OptionalSchema<v.StringSchema<undefined>, "google/gemini-3.6-flash">;
1438
1552
  readonly thinking: v.OptionalSchema<v.PicklistSchema<["off", "minimal", "low", "medium", "high", "xhigh", "max"], undefined>, "medium">;
1439
1553
  readonly color: v.OptionalSchema<v.StringSchema<undefined>, "">;
@@ -1694,7 +1808,7 @@ export declare const SFConfigSchema: v.ObjectSchema<{
1694
1808
  readonly enabled: v.OptionalSchema<v.BooleanSchema<undefined>, false>;
1695
1809
  readonly tiers: v.OptionalSchema<v.ArraySchema<v.ObjectSchema<{
1696
1810
  readonly name: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.MinLengthAction<string, 1, undefined>]>;
1697
- readonly coding_agent: v.OptionalSchema<v.PicklistSchema<["flue", "claude_code"], undefined>, "flue">;
1811
+ readonly coding_agent: v.OptionalSchema<v.PicklistSchema<["flue", "claude_code", "opencode"], undefined>, "flue">;
1698
1812
  readonly model: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.MinLengthAction<string, 1, undefined>]>;
1699
1813
  }, undefined>, undefined>, () => never[]>;
1700
1814
  readonly roles: v.OptionalSchema<v.RecordSchema<v.StringSchema<undefined>, v.StringSchema<undefined>, undefined>, () => {}>;
@@ -1702,7 +1816,7 @@ export declare const SFConfigSchema: v.ObjectSchema<{
1702
1816
  enabled: boolean;
1703
1817
  tiers: {
1704
1818
  name: string;
1705
- coding_agent: "claude_code" | "flue";
1819
+ coding_agent: "claude_code" | "flue" | "opencode";
1706
1820
  model: string;
1707
1821
  }[];
1708
1822
  roles: {
@@ -475,7 +475,7 @@ export const SandboxConfigSchema = v.object({
475
475
  });
476
476
  export const AgentConfigSchema = v.object({
477
477
  name: v.string(),
478
- coding_agent: v.optional(v.picklist(["flue", "claude_code"]), "flue"),
478
+ coding_agent: v.optional(v.picklist(["flue", "claude_code", "opencode"]), "flue"),
479
479
  model: v.optional(v.string(), "google/gemini-3.6-flash"),
480
480
  thinking: v.optional(ThinkingLevelSchema, "medium"),
481
481
  color: v.optional(v.string(), ""), // hex swatch for this agent's lane in the UI
@@ -510,7 +510,7 @@ export const AgentConfigSchema = v.object({
510
510
  sandbox: v.optional(v.nullable(SandboxBackendSchema)),
511
511
  });
512
512
  export const ConfigDefaultsSchema = v.object({
513
- coding_agent: v.optional(v.picklist(["flue", "claude_code"]), "flue"),
513
+ coding_agent: v.optional(v.picklist(["flue", "claude_code", "opencode"]), "flue"),
514
514
  model: v.optional(v.string(), "google/gemini-3.6-flash"),
515
515
  thinking: v.optional(ThinkingLevelSchema, "medium"),
516
516
  color: v.optional(v.string(), ""),
@@ -587,8 +587,97 @@ export const OTelConfigSchema = v.object({
587
587
  headers: v.optional(v.record(v.string(), v.string()), undefined),
588
588
  service_name: v.optional(v.string(), "spf"),
589
589
  });
590
+ /**
591
+ * MIGRATION NOTE (BT-issue #66, 3 PRs): `observability.db` used to be ONLY a
592
+ * bare string (a local sqlite path, defaulting to ".spf/data/spf.db"). This
593
+ * is now a discriminated shape that additionally accepts an explicit
594
+ * `{ kind: "sqlite", path? }` object (equivalent to the string form, just
595
+ * spelled out) and a `{ kind: "d1", database_id, ... }` object that names a
596
+ * remote Cloudflare D1 database instead of a local file. The bare-string
597
+ * form is unchanged and remains the default for every existing config —
598
+ * nothing in the wild needs to change. `resolveObservabilityDb` below
599
+ * normalizes all three accepted forms to one shape.
600
+ *
601
+ * PR 1 (this schema + normalizer + `paths.resolveDataPaths` plumbing) left
602
+ * `resolveDataPaths` refusing a `kind: "d1"` config outright — no adapter
603
+ * existed yet. PR 2 (`core/trace_db.ts`'s `TraceDb` interface, `LocalTraceDb`,
604
+ * `D1TraceDb`, and `createTraceDb()`, wired through `Tracer`/`SfDb`) removes
605
+ * that refusal: `resolveDataPaths` now returns a usable descriptor for
606
+ * either kind, and both backends are real. PR 3 wires `spf doctor`/`spf
607
+ * init`'s interview flow to the new option.
608
+ */
609
+ export const SqliteDbConfigSchema = v.object({
610
+ kind: v.literal("sqlite"),
611
+ path: v.optional(v.string(), ".spf/data/spf.db"),
612
+ });
613
+ /**
614
+ * `account_id_env`/`api_token_env` default to the SAME env var names
615
+ * `core/cloudflare_provider.ts` already reads for the Cloudflare Workers AI
616
+ * provider (`CLOUDFLARE_ACCOUNT_ID`/`CLOUDFLARE_API_TOKEN`, see
617
+ * `core/providers.ts`'s `PROVIDER_ENV_KEYS.cloudflare`) — one Cloudflare
618
+ * account's credentials, read from the same two env vars, cover both
619
+ * Workers AI and a D1-backed trace db unless a config explicitly points
620
+ * elsewhere (e.g. a second Cloudflare account) by naming different env vars.
621
+ */
622
+ export const D1DbConfigSchema = v.object({
623
+ kind: v.literal("d1"),
624
+ database_id: v.string(),
625
+ account_id_env: v.optional(v.string(), "CLOUDFLARE_ACCOUNT_ID"),
626
+ api_token_env: v.optional(v.string(), "CLOUDFLARE_API_TOKEN"),
627
+ });
628
+ /**
629
+ * A plain `v.union([v.string(), v.variant("kind", [...])])` was tried first
630
+ * and rejected: valibot's plain union reports every failed branch with the
631
+ * same generic "Expected (string | Object) but received Object", swallowing
632
+ * `v.variant`'s own precise "kind must be sqlite or d1" / "database_id is
633
+ * required" messages. This `rawTransform` dispatches on `typeof value`
634
+ * itself — string passes through unchanged (the back-compat form), anything
635
+ * else is handed to `v.variant("kind", ...)` and ITS issues (which already
636
+ * name the offending field) are re-surfaced verbatim, prefixed with
637
+ * `observability.db:` so a nested failure is still easy to place in a large
638
+ * config file. See `src/test/data_types.test.ts` for the exact messages
639
+ * this produces for each rejected shape.
640
+ */
641
+ const ObservabilityDbVariantSchema = v.variant("kind", [SqliteDbConfigSchema, D1DbConfigSchema]);
642
+ export const ObservabilityDbSchema = v.pipe(v.unknown(), v.rawTransform(({ dataset, addIssue, NEVER }) => {
643
+ const value = dataset.value;
644
+ if (typeof value === "string")
645
+ return value;
646
+ if (value && typeof value === "object" && !Array.isArray(value)) {
647
+ const result = v.safeParse(ObservabilityDbVariantSchema, value);
648
+ if (result.success)
649
+ return result.output;
650
+ for (const issue of result.issues)
651
+ addIssue({ message: `observability.db: ${issue.message}` });
652
+ return NEVER;
653
+ }
654
+ addIssue({
655
+ message: `observability.db must be a string path, or an object with kind: "sqlite" or kind: "d1" (got ${JSON.stringify(value)})`,
656
+ });
657
+ return NEVER;
658
+ }));
659
+ /**
660
+ * Normalizes `observability.db` — bare string, `{kind:"sqlite",...}`, or
661
+ * `{kind:"d1",...}` — to one shape. Pure and side-effect-free: does NOT
662
+ * resolve `path` to an absolute path (that's `paths.resolveDataPaths`'s job,
663
+ * which needs `repo_root` to do it) and does NOT read the D1 env vars it
664
+ * names (that's PR 2's adapter's job, at the point it actually needs to
665
+ * authenticate).
666
+ */
667
+ export function resolveObservabilityDb(db) {
668
+ if (typeof db === "string")
669
+ return { kind: "sqlite", path: db };
670
+ if (db.kind === "sqlite")
671
+ return { kind: "sqlite", path: db.path ?? ".spf/data/spf.db" };
672
+ return {
673
+ kind: "d1",
674
+ database_id: db.database_id,
675
+ account_id_env: db.account_id_env ?? "CLOUDFLARE_ACCOUNT_ID",
676
+ api_token_env: db.api_token_env ?? "CLOUDFLARE_API_TOKEN",
677
+ };
678
+ }
590
679
  export const ObservabilityConfigSchema = v.object({
591
- db: v.optional(v.string(), ".spf/data/spf.db"),
680
+ db: v.optional(ObservabilityDbSchema, ".spf/data/spf.db"),
592
681
  poll_ms: v.optional(v.number(), 500),
593
682
  // Absent by default. `agents.ts`'s mergeRawConfig spreads `observability`
594
683
  // field-by-field, so this nested object merges as a WHOLE-OBJECT replace on
@@ -596,6 +685,14 @@ export const ObservabilityConfigSchema = v.object({
596
685
  // which is the semantics you want for an endpoint + its headers (a
597
686
  // half-merged pair of the two would send tokens to the wrong collector).
598
687
  // Pinned by a merge-survival test in src/test/data_types.test.ts.
688
+ //
689
+ // `db` merges the same whole-value way: `mergeRawConfig`'s `observability`
690
+ // spread is key-by-key over `observability`'s OWN keys (db/poll_ms/otel),
691
+ // never deeper — an override's `db:` (string or object, either kind)
692
+ // replaces the base's `db:` in full, so a `{kind:"d1",...}` override can
693
+ // never end up with a stray `path` key leaked in from a `{kind:"sqlite",
694
+ // path:...}` base (or vice versa). See the merge-survival test for `db` in
695
+ // src/test/data_types.test.ts.
599
696
  otel: v.optional(OTelConfigSchema),
600
697
  });
601
698
  /**
@@ -962,7 +1059,7 @@ export const ReviewConfigSchema = v.object({
962
1059
  */
963
1060
  export const TierSchema = v.object({
964
1061
  name: v.pipe(v.string(), v.minLength(1)),
965
- coding_agent: v.optional(v.picklist(["flue", "claude_code"]), "flue"),
1062
+ coding_agent: v.optional(v.picklist(["flue", "claude_code", "opencode"]), "flue"),
966
1063
  model: v.pipe(v.string(), v.minLength(1)),
967
1064
  });
968
1065
  /**
@@ -204,7 +204,7 @@ export interface FanoutDeps {
204
204
  /** Run one attempt's chain in `cwd`, returning its exit code. */
205
205
  runAttempt: (dispatch: AttemptDispatch) => Promise<number>;
206
206
  /** Read this adw_id's gate/usage rows from the SHARED db. Must not throw — return `ZERO_METRICS` on any hiccup. */
207
- readMetrics: (adwId: string) => AttemptMetrics;
207
+ readMetrics: (adwId: string) => Promise<AttemptMetrics>;
208
208
  log: (message: string) => void;
209
209
  /**
210
210
  * Opt-in first-past-the-post. Default (unset/false): every attempt that
@@ -239,7 +239,7 @@ export async function runBestOf(deps) {
239
239
  decided = true;
240
240
  let metrics = ZERO_METRICS;
241
241
  try {
242
- metrics = deps.readMetrics(adwId);
242
+ metrics = await deps.readMetrics(adwId);
243
243
  }
244
244
  catch (caught) {
245
245
  // Metrics are for RANKING, never for correctness — an attempt whose
@@ -96,7 +96,20 @@ export function diffMatchesClaims(envelope, run) {
96
96
  continue;
97
97
  }
98
98
  const exists = existsSync(resolved);
99
- report.check(f, exists, exists ? `exists, ${size(resolved)}` : "claimed changed file does not exist");
99
+ // A bulk/mechanical change (a repo-wide formatter, a codemod) tempts an
100
+ // agent to compress many identical edits into one descriptive sentence
101
+ // ("139 files reformatted (app/, components/, ...)") instead of listing
102
+ // each path — that string obviously never resolves, but the plain
103
+ // "does not exist" message gives no hint why, so a retry repeats the
104
+ // same mistake. Whitespace inside an otherwise-nonexistent claim is the
105
+ // cheap, cheap-to-check signal: a real repo-relative path is never a
106
+ // prose fragment. Never suppresses the failure itself, only clarifies it.
107
+ const looksLikeProse = !exists && /\s/.test(f);
108
+ report.check(f, exists, exists
109
+ ? `exists, ${size(resolved)}`
110
+ : looksLikeProse
111
+ ? "claimed changed file does not exist — this looks like a summary sentence, not a path; list every changed file individually, even for a bulk/mechanical change"
112
+ : "claimed changed file does not exist");
100
113
  }
101
114
  return report;
102
115
  }
@@ -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.