@gr8ful/spf 0.3.0 → 0.5.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 (91) hide show
  1. package/README.md +177 -9
  2. package/assets/defaults/spf.config.yaml +22 -0
  3. package/assets/prompts/refiner/system.md +53 -0
  4. package/assets/prompts/refiner/user.md +70 -0
  5. package/assets/prompts/reviewer/system.md +1 -1
  6. package/assets/skill/SKILL.md +1 -0
  7. package/assets/skill/cookbooks/authoring_chains.md +90 -7
  8. package/assets/skill/cookbooks/ocr_reviewer.md +196 -0
  9. package/assets/skill/cookbooks/roster.md +15 -4
  10. package/assets/skill/cookbooks/spf_overview.md +1 -0
  11. package/assets/skill/references/config.md +104 -3
  12. package/assets/skill/references/observability.md +11 -2
  13. package/assets/templates/ts-cc.spf.config.yaml +3 -3
  14. package/assets/templates/ts-flue-ollama.spf.config.yaml +67 -0
  15. package/assets/templates/ts.spf.config.yaml +15 -2
  16. package/dist/chains/context.d.ts +39 -0
  17. package/dist/chains/index.d.ts +94 -10
  18. package/dist/chains/index.js +75 -5
  19. package/dist/chains/repo_chains.d.ts +139 -0
  20. package/dist/chains/repo_chains.js +428 -0
  21. package/dist/chains/simple_sdlc.d.ts +74 -1
  22. package/dist/chains/simple_sdlc.js +134 -4
  23. package/dist/chains/steps.d.ts +237 -18
  24. package/dist/chains/steps.js +477 -58
  25. package/dist/cli/ask.d.ts +14 -1
  26. package/dist/cli/ask.js +32 -2
  27. package/dist/cli/commands/doctor.d.ts +1 -1
  28. package/dist/cli/commands/doctor.js +324 -10
  29. package/dist/cli/commands/init.d.ts +12 -0
  30. package/dist/cli/commands/init.js +108 -4
  31. package/dist/cli/commands/install-skill.js +5 -2
  32. package/dist/cli/commands/list.js +43 -5
  33. package/dist/cli/commands/run.js +29 -2
  34. package/dist/cli/commands/watch.d.ts +18 -0
  35. package/dist/cli/commands/watch.js +214 -16
  36. package/dist/cli/index.js +63 -6
  37. package/dist/cli/interview.js +81 -9
  38. package/dist/core/agent_cc.d.ts +40 -1
  39. package/dist/core/agent_cc.js +51 -4
  40. package/dist/core/agent_flue.js +28 -4
  41. package/dist/core/agents.d.ts +8 -0
  42. package/dist/core/agents.js +43 -3
  43. package/dist/core/data_types.d.ts +182 -4
  44. package/dist/core/data_types.js +141 -2
  45. package/dist/core/gates.d.ts +13 -0
  46. package/dist/core/gates.js +103 -0
  47. package/dist/core/git_helper.d.ts +29 -0
  48. package/dist/core/git_helper.js +41 -1
  49. package/dist/core/issues/github_provider.d.ts +35 -9
  50. package/dist/core/issues/github_provider.js +76 -28
  51. package/dist/core/issues/jira_provider.d.ts +14 -1
  52. package/dist/core/issues/jira_provider.js +9 -7
  53. package/dist/core/issues/provider.d.ts +77 -15
  54. package/dist/core/issues/provider.js +7 -4
  55. package/dist/core/notify/channel.d.ts +1 -1
  56. package/dist/core/ollama_provider.d.ts +70 -0
  57. package/dist/core/ollama_provider.js +208 -0
  58. package/dist/core/otel.d.ts +352 -0
  59. package/dist/core/otel.js +793 -0
  60. package/dist/core/providers.js +4 -0
  61. package/dist/core/refine.d.ts +39 -0
  62. package/dist/core/refine.js +152 -0
  63. package/dist/core/session.js +39 -2
  64. package/dist/core/tracer.d.ts +31 -2
  65. package/dist/core/tracer.js +69 -11
  66. package/dist/core/watch.d.ts +67 -1
  67. package/dist/core/watch.js +217 -13
  68. package/dist/test/chains.test.js +9 -3
  69. package/dist/test/data_types.test.js +140 -2
  70. package/dist/test/git_helper.test.d.ts +1 -0
  71. package/dist/test/git_helper.test.js +59 -0
  72. package/dist/test/hermetic_git.d.ts +1 -0
  73. package/dist/test/hermetic_git.js +22 -0
  74. package/dist/test/init_command.test.d.ts +14 -1
  75. package/dist/test/init_command.test.js +71 -1
  76. package/dist/test/interview.test.d.ts +15 -1
  77. package/dist/test/interview.test.js +131 -3
  78. package/dist/test/ollama_provider.test.d.ts +1 -0
  79. package/dist/test/ollama_provider.test.js +103 -0
  80. package/dist/test/otel.test.d.ts +26 -0
  81. package/dist/test/otel.test.js +512 -0
  82. package/dist/test/refine.test.d.ts +1 -0
  83. package/dist/test/refine.test.js +189 -0
  84. package/dist/test/repo_chains.test.d.ts +21 -0
  85. package/dist/test/repo_chains.test.js +416 -0
  86. package/dist/test/signoff.test.d.ts +1 -0
  87. package/dist/test/signoff.test.js +329 -0
  88. package/dist/test/ui_server.test.d.ts +7 -1
  89. package/dist/test/ui_server.test.js +1 -0
  90. package/dist/test/watch.test.js +297 -6
  91. package/package.json +5 -5
@@ -150,6 +150,45 @@ export declare const DocumentOutput: EnvelopeType<{
150
150
  commit_message: string;
151
151
  }>;
152
152
  export type DocumentOutputT = v.InferOutput<typeof DocumentOutput.schema>;
153
+ /**
154
+ * One node in a decomposed product spec — a feature/epic container, or a
155
+ * story/bug/task leaf. Flat with a `parent` key, not nested JSON: a model
156
+ * emits a flat list far more reliably than a recursive tree, and a flat
157
+ * shape is what lets `blocked_by` reference ANY other node, container or
158
+ * leaf, not just siblings under the same parent.
159
+ *
160
+ * `key` is the refiner's own local id for this run (e.g. "F1", "S1.1") —
161
+ * scoped to one `RefineOutput`, never a tracker id; `core/refine.ts`
162
+ * resolves `key`s to real issue numbers as it creates them, in dependency
163
+ * order. See `gates.refinementWellFormed` for the shape rules enforced on
164
+ * this list before publish ever runs (unique keys, resolvable references,
165
+ * no cycles, container/leaf kind agreement, at least one leaf).
166
+ */
167
+ export declare const RefinedIssueSchema: v.ObjectSchema<{
168
+ readonly key: v.StringSchema<undefined>;
169
+ readonly kind: v.PicklistSchema<["epic", "feature", "story", "bug", "task"], undefined>;
170
+ readonly title: v.StringSchema<undefined>;
171
+ readonly body: v.StringSchema<undefined>;
172
+ readonly parent: v.OptionalSchema<v.StringSchema<undefined>, "">;
173
+ readonly blocked_by: v.OptionalSchema<v.ArraySchema<v.StringSchema<undefined>, undefined>, () => never[]>;
174
+ }, undefined>;
175
+ export type RefinedIssue = v.InferOutput<typeof RefinedIssueSchema>;
176
+ /** A product spec decomposed into a feature/story tree — see `steps.refine()` and `core/refine.ts`. */
177
+ export declare const RefineOutput: EnvelopeType<{
178
+ status: "fail" | "success";
179
+ summary: string;
180
+ artifacts: string[];
181
+ notes_for_next_agent: string;
182
+ issues: {
183
+ key: string;
184
+ kind: "bug" | "epic" | "feature" | "story" | "task";
185
+ title: string;
186
+ body: string;
187
+ parent: string;
188
+ blocked_by: string[];
189
+ }[];
190
+ }>;
191
+ export type RefineOutputT = v.InferOutput<typeof RefineOutput.schema>;
153
192
  export declare const QualityAreaSchema: v.PicklistSchema<["frontend", "backend"], undefined>;
154
193
  export type QualityArea = v.InferOutput<typeof QualityAreaSchema>;
155
194
  export declare const QualityOperationSchema: v.PicklistSchema<["lint", "typecheck", "build"], undefined>;
@@ -337,6 +376,7 @@ export declare const AgentConfigSchema: v.ObjectSchema<{
337
376
  readonly harness_engineering: v.OptionalSchema<v.ArraySchema<v.StringSchema<undefined>, undefined>, () => never[]>;
338
377
  readonly tools: v.OptionalSchema<v.NullableSchema<v.ArraySchema<v.StringSchema<undefined>, undefined>, undefined>, undefined>;
339
378
  readonly writes: v.OptionalSchema<v.NullableSchema<v.ArraySchema<v.StringSchema<undefined>, undefined>, undefined>, undefined>;
379
+ readonly env_allowlist: v.OptionalSchema<v.NullableSchema<v.ArraySchema<v.StringSchema<undefined>, undefined>, undefined>, undefined>;
340
380
  }, undefined>;
341
381
  export type AgentConfig = v.InferOutput<typeof AgentConfigSchema>;
342
382
  export declare const ConfigDefaultsSchema: v.ObjectSchema<{
@@ -350,9 +390,35 @@ export declare const ConfigDefaultsSchema: v.ObjectSchema<{
350
390
  readonly data_dir: v.OptionalSchema<v.StringSchema<undefined>, ".spf/data">;
351
391
  }, undefined>;
352
392
  export type ConfigDefaults = v.InferOutput<typeof ConfigDefaultsSchema>;
393
+ /**
394
+ * OpenTelemetry span export — OFF unless this block exists, and `endpoint` is
395
+ * required BY THIS SCHEMA rather than defaulted, because presence of the block
396
+ * IS the activation switch. No ambient environment variable can turn export on
397
+ * (notably NOT `OTEL_EXPORTER_OTLP_ENDPOINT`): an unrelated shell variable must
398
+ * never become a data-egress switch. See `core/otel.ts`'s header for the full
399
+ * set of constraints, including the attribute allowlist that keeps repo source
400
+ * code (tool args, prompts, envelopes, the request text) off the wire.
401
+ *
402
+ * `endpoint` is URL-validated so a typo fails at config load rather than as a
403
+ * silent per-run export failure. Either the full OTLP traces path
404
+ * (`https://collector:4318/v1/traces`) or a bare origin (`/v1/traces` is
405
+ * appended — see `resolveTracesUrl`). `headers` is where a collector's auth
406
+ * token goes; its VALUES are treated as secrets and never logged.
407
+ */
408
+ export declare const OTelConfigSchema: v.ObjectSchema<{
409
+ readonly endpoint: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.UrlAction<string, undefined>]>;
410
+ readonly headers: v.OptionalSchema<v.RecordSchema<v.StringSchema<undefined>, v.StringSchema<undefined>, undefined>, undefined>;
411
+ readonly service_name: v.OptionalSchema<v.StringSchema<undefined>, "spf">;
412
+ }, undefined>;
413
+ export type OTelConfig = v.InferOutput<typeof OTelConfigSchema>;
353
414
  export declare const ObservabilityConfigSchema: v.ObjectSchema<{
354
415
  readonly db: v.OptionalSchema<v.StringSchema<undefined>, ".spf/data/spf.db">;
355
416
  readonly poll_ms: v.OptionalSchema<v.NumberSchema<undefined>, 500>;
417
+ readonly otel: v.OptionalSchema<v.ObjectSchema<{
418
+ readonly endpoint: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.UrlAction<string, undefined>]>;
419
+ readonly headers: v.OptionalSchema<v.RecordSchema<v.StringSchema<undefined>, v.StringSchema<undefined>, undefined>, undefined>;
420
+ readonly service_name: v.OptionalSchema<v.StringSchema<undefined>, "spf">;
421
+ }, undefined>, undefined>;
356
422
  }, undefined>;
357
423
  export type ObservabilityConfig = v.InferOutput<typeof ObservabilityConfigSchema>;
358
424
  /**
@@ -370,8 +436,25 @@ export type ObservabilityConfig = v.InferOutput<typeof ObservabilityConfigSchema
370
436
  * watch` startup, not here — an empty string parses fine (this schema has
371
437
  * no opinion on whether watch is even configured), matching the same
372
438
  * "fails loudly before anything spawns, not eagerly at parse time" pattern
373
- * `quality:` already uses. Its shape depends on `code_host`: "owner/name"
374
- * for github, "workspace/repo_slug" for bitbucket.
439
+ * `quality:` already uses. `repo` always names `code_host`'s own repo —
440
+ * its shape depends on `code_host`: "owner/name" for github,
441
+ * "workspace/repo_slug" for bitbucket. `resolveCodeHostProvider`
442
+ * (`cli/commands/watch.ts`) always reads it.
443
+ *
444
+ * `issue_repo` exists for exactly one combination where that single field
445
+ * stops being enough: `issue_provider: github` with `code_host: bitbucket`
446
+ * — GitHub-issues-against-a-Bitbucket-repo, a real setup this project
447
+ * explicitly supports, where the issue tracker and the code host are
448
+ * genuinely different repos in different systems, not the same repo worn
449
+ * two ways. Every other combination stays a single field: `issue_provider:
450
+ * github` + `code_host: github` is one repo by construction; `issue_provider:
451
+ * jira` never reads `repo` at all (it uses `jira.base_url`/`project_key`),
452
+ * so `repo` unambiguously belongs to whichever code host is configured.
453
+ * `resolveIssueProvider`'s github branch and `core/refine.ts`'s
454
+ * `resolveAuthoringProvider` (issue authoring always targets the issue
455
+ * tracker, never the code host) both read `issue_repo || repo` — leaving
456
+ * `issue_repo` unset is a complete no-op, so no existing config needs to
457
+ * change.
375
458
  */
376
459
  export declare const WatchIssueProviderSchema: v.PicklistSchema<["github", "jira"], undefined>;
377
460
  export type WatchIssueProviderKind = v.InferOutput<typeof WatchIssueProviderSchema>;
@@ -383,10 +466,27 @@ export declare const WatchJiraConfigSchema: v.ObjectSchema<{
383
466
  readonly project_key: v.OptionalSchema<v.StringSchema<undefined>, "">;
384
467
  }, undefined>;
385
468
  export type WatchJiraConfig = v.InferOutput<typeof WatchJiraConfigSchema>;
469
+ /**
470
+ * The second `spf watch` lane: decompose a `<prefix>:spec-ready` product
471
+ * spec into a feature/story tree of real issues, instead of running
472
+ * `watch.chain` against it directly (a spec is not individually workable —
473
+ * see `core/refine.ts`). Off by default so an existing `watch:` config's
474
+ * behavior is unchanged by upgrading; turning it on with
475
+ * `issue_provider: jira` fails loudly at `spf watch` startup, since
476
+ * `JiraProvider` doesn't implement `IssueAuthoringProvider` (create/link)
477
+ * yet — see its module comment.
478
+ */
479
+ export declare const WatchRefineConfigSchema: v.ObjectSchema<{
480
+ readonly enabled: v.OptionalSchema<v.BooleanSchema<undefined>, false>;
481
+ readonly chain: v.OptionalSchema<v.StringSchema<undefined>, "refine">;
482
+ readonly concurrency: v.OptionalSchema<v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 1, undefined>]>, 1>;
483
+ }, undefined>;
484
+ export type WatchRefineConfig = v.InferOutput<typeof WatchRefineConfigSchema>;
386
485
  export declare const WatchConfigSchema: v.ObjectSchema<{
387
486
  readonly issue_provider: v.OptionalSchema<v.PicklistSchema<["github", "jira"], undefined>, "github">;
388
487
  readonly code_host: v.OptionalSchema<v.PicklistSchema<["github", "bitbucket"], undefined>, "github">;
389
488
  readonly repo: v.OptionalSchema<v.StringSchema<undefined>, "">;
489
+ readonly issue_repo: v.OptionalSchema<v.StringSchema<undefined>, "">;
390
490
  readonly label_prefix: v.OptionalSchema<v.StringSchema<undefined>, "spf">;
391
491
  readonly chain: v.OptionalSchema<v.StringSchema<undefined>, "plan-build-test">;
392
492
  readonly base_branch: v.OptionalSchema<v.StringSchema<undefined>, "main">;
@@ -399,6 +499,15 @@ export declare const WatchConfigSchema: v.ObjectSchema<{
399
499
  base_url: string;
400
500
  project_key: string;
401
501
  }>;
502
+ readonly refine: v.OptionalSchema<v.ObjectSchema<{
503
+ readonly enabled: v.OptionalSchema<v.BooleanSchema<undefined>, false>;
504
+ readonly chain: v.OptionalSchema<v.StringSchema<undefined>, "refine">;
505
+ readonly concurrency: v.OptionalSchema<v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 1, undefined>]>, 1>;
506
+ }, undefined>, () => {
507
+ enabled: boolean;
508
+ chain: string;
509
+ concurrency: number;
510
+ }>;
402
511
  }, undefined>;
403
512
  export type WatchConfig = v.InferOutput<typeof WatchConfigSchema>;
404
513
  /**
@@ -437,6 +546,34 @@ export declare const NotificationsConfigSchema: v.ObjectSchema<{
437
546
  }, undefined>, undefined>, () => never[]>;
438
547
  }, undefined>;
439
548
  export type NotificationsConfig = v.InferOutput<typeof NotificationsConfigSchema>;
549
+ /**
550
+ * `simple_sdlc`'s human-signoff gate — see the ACCEPTED ADVERSARIAL
551
+ * AMENDMENTS on the review-accountability thread. `simple_sdlc.ts`'s
552
+ * `commit_build` predicate is the ONLY place in this codebase where an AI
553
+ * reviewer's `approved` flag gates a commit; `decideSignoff` (same file)
554
+ * turns that PROPOSAL into a human's DISPOSAL wherever a human is at the
555
+ * keyboard, and reads these two knobs when there isn't one.
556
+ *
557
+ * `require_human_signoff` defaults to FALSE for this release, deliberately:
558
+ * flipping it to fail-closed-by-default would break every unattended
559
+ * `simple-sdlc` run (`spf watch`, CI) the day this shipped, before `spf
560
+ * watch` itself is signoff-aware (its own human gate today is the PR merge,
561
+ * now informed by the reviewer digest — see `cli/commands/watch.ts`). An
562
+ * unattended run instead proceeds on the AI verdict alone with a LOUD
563
+ * one-time warning (`decideSignoff`'s `AI_ONLY_SIGNOFF_WARNING`) until that
564
+ * changes. Set `true` and an unattended run fails the phase CLOSED instead —
565
+ * see `decideSignoff`'s fail-closed branch — rather than silently
566
+ * auto-approving because nobody typed at a prompt that was never shown.
567
+ *
568
+ * `signoff_timeout_seconds` bounds the interactive prompt itself: expiry
569
+ * means NOT accepted (the confirm's own default), never an unbounded stdin
570
+ * read inside `run.phase()` — see `cli/ask.ts`'s `confirm(..., {timeoutMs})`.
571
+ */
572
+ export declare const ReviewConfigSchema: v.ObjectSchema<{
573
+ readonly require_human_signoff: v.OptionalSchema<v.BooleanSchema<undefined>, false>;
574
+ readonly signoff_timeout_seconds: v.OptionalSchema<v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.MinValueAction<number, 1, undefined>]>, 300>;
575
+ }, undefined>;
576
+ export type ReviewConfig = v.InferOutput<typeof ReviewConfigSchema>;
440
577
  export declare const SFConfigSchema: v.ObjectSchema<{
441
578
  readonly defaults: v.OptionalSchema<v.ObjectSchema<{
442
579
  readonly coding_agent: v.OptionalSchema<v.PicklistSchema<["flue", "claude_code"], undefined>, "flue">;
@@ -460,9 +597,21 @@ export declare const SFConfigSchema: v.ObjectSchema<{
460
597
  readonly observability: v.OptionalSchema<v.ObjectSchema<{
461
598
  readonly db: v.OptionalSchema<v.StringSchema<undefined>, ".spf/data/spf.db">;
462
599
  readonly poll_ms: v.OptionalSchema<v.NumberSchema<undefined>, 500>;
600
+ readonly otel: v.OptionalSchema<v.ObjectSchema<{
601
+ readonly endpoint: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.UrlAction<string, undefined>]>;
602
+ readonly headers: v.OptionalSchema<v.RecordSchema<v.StringSchema<undefined>, v.StringSchema<undefined>, undefined>, undefined>;
603
+ readonly service_name: v.OptionalSchema<v.StringSchema<undefined>, "spf">;
604
+ }, undefined>, undefined>;
463
605
  }, undefined>, () => {
464
606
  db: string;
465
607
  poll_ms: number;
608
+ otel?: {
609
+ endpoint: string;
610
+ headers?: {
611
+ [x: string]: string;
612
+ } | undefined;
613
+ service_name: string;
614
+ } | undefined;
466
615
  }>;
467
616
  readonly agents: v.OptionalSchema<v.ArraySchema<v.ObjectSchema<{
468
617
  readonly name: v.StringSchema<undefined>;
@@ -478,6 +627,7 @@ export declare const SFConfigSchema: v.ObjectSchema<{
478
627
  readonly harness_engineering: v.OptionalSchema<v.ArraySchema<v.StringSchema<undefined>, undefined>, () => never[]>;
479
628
  readonly tools: v.OptionalSchema<v.NullableSchema<v.ArraySchema<v.StringSchema<undefined>, undefined>, undefined>, undefined>;
480
629
  readonly writes: v.OptionalSchema<v.NullableSchema<v.ArraySchema<v.StringSchema<undefined>, undefined>, undefined>, undefined>;
630
+ readonly env_allowlist: v.OptionalSchema<v.NullableSchema<v.ArraySchema<v.StringSchema<undefined>, undefined>, undefined>, undefined>;
481
631
  }, undefined>, undefined>, () => never[]>;
482
632
  readonly quality: v.OptionalSchema<v.ObjectSchema<{
483
633
  readonly checks: v.OptionalSchema<v.ArraySchema<v.ObjectSchema<{
@@ -504,6 +654,7 @@ export declare const SFConfigSchema: v.ObjectSchema<{
504
654
  readonly issue_provider: v.OptionalSchema<v.PicklistSchema<["github", "jira"], undefined>, "github">;
505
655
  readonly code_host: v.OptionalSchema<v.PicklistSchema<["github", "bitbucket"], undefined>, "github">;
506
656
  readonly repo: v.OptionalSchema<v.StringSchema<undefined>, "">;
657
+ readonly issue_repo: v.OptionalSchema<v.StringSchema<undefined>, "">;
507
658
  readonly label_prefix: v.OptionalSchema<v.StringSchema<undefined>, "spf">;
508
659
  readonly chain: v.OptionalSchema<v.StringSchema<undefined>, "plan-build-test">;
509
660
  readonly base_branch: v.OptionalSchema<v.StringSchema<undefined>, "main">;
@@ -516,10 +667,20 @@ export declare const SFConfigSchema: v.ObjectSchema<{
516
667
  base_url: string;
517
668
  project_key: string;
518
669
  }>;
670
+ readonly refine: v.OptionalSchema<v.ObjectSchema<{
671
+ readonly enabled: v.OptionalSchema<v.BooleanSchema<undefined>, false>;
672
+ readonly chain: v.OptionalSchema<v.StringSchema<undefined>, "refine">;
673
+ readonly concurrency: v.OptionalSchema<v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 1, undefined>]>, 1>;
674
+ }, undefined>, () => {
675
+ enabled: boolean;
676
+ chain: string;
677
+ concurrency: number;
678
+ }>;
519
679
  }, undefined>, () => {
520
680
  issue_provider: "github" | "jira";
521
681
  code_host: "bitbucket" | "github";
522
682
  repo: string;
683
+ issue_repo: string;
523
684
  label_prefix: string;
524
685
  chain: string;
525
686
  base_branch: string;
@@ -529,6 +690,11 @@ export declare const SFConfigSchema: v.ObjectSchema<{
529
690
  base_url: string;
530
691
  project_key: string;
531
692
  };
693
+ refine: {
694
+ enabled: boolean;
695
+ chain: string;
696
+ concurrency: number;
697
+ };
532
698
  }>;
533
699
  readonly notifications: v.OptionalSchema<v.ObjectSchema<{
534
700
  readonly events: v.OptionalSchema<v.PicklistSchema<["off", "errors", "all"], undefined>, "off">;
@@ -549,13 +715,24 @@ export declare const SFConfigSchema: v.ObjectSchema<{
549
715
  name: string;
550
716
  }[];
551
717
  }>;
718
+ readonly review: v.OptionalSchema<v.ObjectSchema<{
719
+ readonly require_human_signoff: v.OptionalSchema<v.BooleanSchema<undefined>, false>;
720
+ readonly signoff_timeout_seconds: v.OptionalSchema<v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.MinValueAction<number, 1, undefined>]>, 300>;
721
+ }, undefined>, () => {
722
+ require_human_signoff: boolean;
723
+ signoff_timeout_seconds: number;
724
+ }>;
552
725
  }, undefined>;
553
726
  export type SFConfig = v.InferOutput<typeof SFConfigSchema>;
727
+ /** The full set of event kinds any tracer.event()/makeEventRecord() call site emits — kept in sync with src/ui/shared/types.ts's EventType. */
728
+ export declare const EVENT_RECORD_TYPES: readonly ["phase_start", "agent_start", "tool_call", "handoff", "gate_pass", "gate_fail", "log", "agent_end", "phase_end", "error"];
729
+ export declare const EventRecordTypeSchema: v.PicklistSchema<readonly ["phase_start", "agent_start", "tool_call", "handoff", "gate_pass", "gate_fail", "log", "agent_end", "phase_end", "error"], undefined>;
730
+ export type EventRecordType = v.InferOutput<typeof EventRecordTypeSchema>;
554
731
  /** One traced event, always logged against adw_id + phase. */
555
732
  export interface EventRecord {
556
733
  adw_id: string;
557
734
  phase_id: string;
558
- type: string;
735
+ type: EventRecordType;
559
736
  name: string;
560
737
  payload: Record<string, unknown>;
561
738
  parent_id: string;
@@ -565,7 +742,7 @@ export interface EventRecord {
565
742
  }
566
743
  export declare function makeEventRecord(input: Partial<EventRecord> & {
567
744
  adw_id: string;
568
- type: string;
745
+ type: EventRecordType;
569
746
  }): EventRecord;
570
747
  /**
571
748
  * Everything one coding-agent dispatch+read turn needs, whichever backend
@@ -597,6 +774,7 @@ export interface AgentRequest {
597
774
  output_type_name: string;
598
775
  cwd: string;
599
776
  flue_db_path: string;
777
+ env?: Record<string, string>;
600
778
  }
601
779
  /**
602
780
  * Tokens and the dollars they cost, per component, summed over a call.
@@ -103,6 +103,32 @@ export const DocumentOutput = envelopeType("DocumentOutput", {
103
103
  documented_files: v.optional(v.array(v.string()), () => []),
104
104
  commit_message: v.optional(v.string(), ""),
105
105
  });
106
+ /**
107
+ * One node in a decomposed product spec — a feature/epic container, or a
108
+ * story/bug/task leaf. Flat with a `parent` key, not nested JSON: a model
109
+ * emits a flat list far more reliably than a recursive tree, and a flat
110
+ * shape is what lets `blocked_by` reference ANY other node, container or
111
+ * leaf, not just siblings under the same parent.
112
+ *
113
+ * `key` is the refiner's own local id for this run (e.g. "F1", "S1.1") —
114
+ * scoped to one `RefineOutput`, never a tracker id; `core/refine.ts`
115
+ * resolves `key`s to real issue numbers as it creates them, in dependency
116
+ * order. See `gates.refinementWellFormed` for the shape rules enforced on
117
+ * this list before publish ever runs (unique keys, resolvable references,
118
+ * no cycles, container/leaf kind agreement, at least one leaf).
119
+ */
120
+ export const RefinedIssueSchema = v.object({
121
+ key: v.string(),
122
+ kind: v.picklist(["epic", "feature", "story", "bug", "task"]),
123
+ title: v.string(),
124
+ body: v.string(), // "## What to build" / "## Acceptance criteria" — see assets/prompts/refiner/user.md
125
+ parent: v.optional(v.string(), ""), // another node's `key`; "" = top level
126
+ blocked_by: v.optional(v.array(v.string()), () => []), // other nodes' `key`s that must land first
127
+ });
128
+ /** A product spec decomposed into a feature/story tree — see `steps.refine()` and `core/refine.ts`. */
129
+ export const RefineOutput = envelopeType("RefineOutput", {
130
+ issues: v.optional(v.array(RefinedIssueSchema), () => []),
131
+ });
106
132
  // ── Deterministic quality blocks ─────────────────────────────────────────────
107
133
  export const QualityAreaSchema = v.picklist(["frontend", "backend"]);
108
134
  export const QualityOperationSchema = v.picklist(["lint", "typecheck", "build"]);
@@ -255,6 +281,15 @@ export const AgentConfigSchema = v.object({
255
281
  // [...] -> only these. A trailing "/" means a directory prefix; a "*"
256
282
  // makes it a glob; anything else is an exact path.
257
283
  writes: v.optional(v.nullable(v.array(v.string()))),
284
+ // Opt-in env allowlist for this agent's subprocess/sandbox environment.
285
+ // undefined/null (the default) -> unrestricted: the full operator
286
+ // environment is passed through, byte-identical to before this field
287
+ // existed — same three-state shape as `writes` above, so `null` (the
288
+ // spelling config.md teaches for "unrestricted") parses instead of
289
+ // rejecting. Set -> only these keys, plus the baseline
290
+ // (PATH/HOME/USER/LANG/TERM/TMPDIR) that agent_flue.ts's local() sandbox
291
+ // would keep anyway.
292
+ env_allowlist: v.optional(v.nullable(v.array(v.string()))),
258
293
  });
259
294
  export const ConfigDefaultsSchema = v.object({
260
295
  coding_agent: v.optional(v.picklist(["flue", "claude_code"]), "flue"),
@@ -270,9 +305,36 @@ export const ConfigDefaultsSchema = v.object({
270
305
  protected_files: v.optional(v.array(v.string()), () => [".spf/", "spf.config.yaml"]),
271
306
  data_dir: v.optional(v.string(), ".spf/data"),
272
307
  });
308
+ /**
309
+ * OpenTelemetry span export — OFF unless this block exists, and `endpoint` is
310
+ * required BY THIS SCHEMA rather than defaulted, because presence of the block
311
+ * IS the activation switch. No ambient environment variable can turn export on
312
+ * (notably NOT `OTEL_EXPORTER_OTLP_ENDPOINT`): an unrelated shell variable must
313
+ * never become a data-egress switch. See `core/otel.ts`'s header for the full
314
+ * set of constraints, including the attribute allowlist that keeps repo source
315
+ * code (tool args, prompts, envelopes, the request text) off the wire.
316
+ *
317
+ * `endpoint` is URL-validated so a typo fails at config load rather than as a
318
+ * silent per-run export failure. Either the full OTLP traces path
319
+ * (`https://collector:4318/v1/traces`) or a bare origin (`/v1/traces` is
320
+ * appended — see `resolveTracesUrl`). `headers` is where a collector's auth
321
+ * token goes; its VALUES are treated as secrets and never logged.
322
+ */
323
+ export const OTelConfigSchema = v.object({
324
+ endpoint: v.pipe(v.string(), v.url()),
325
+ headers: v.optional(v.record(v.string(), v.string()), undefined),
326
+ service_name: v.optional(v.string(), "spf"),
327
+ });
273
328
  export const ObservabilityConfigSchema = v.object({
274
329
  db: v.optional(v.string(), ".spf/data/spf.db"),
275
330
  poll_ms: v.optional(v.number(), 500),
331
+ // Absent by default. `agents.ts`'s mergeRawConfig spreads `observability`
332
+ // field-by-field, so this nested object merges as a WHOLE-OBJECT replace on
333
+ // override — an override that sets `otel:` replaces the base's entirely,
334
+ // which is the semantics you want for an endpoint + its headers (a
335
+ // half-merged pair of the two would send tokens to the wrong collector).
336
+ // Pinned by a merge-survival test in src/test/data_types.test.ts.
337
+ otel: v.optional(OTelConfigSchema),
276
338
  });
277
339
  /**
278
340
  * `spf watch`'s configuration. `issue_provider` (the tracker) and
@@ -289,8 +351,25 @@ export const ObservabilityConfigSchema = v.object({
289
351
  * watch` startup, not here — an empty string parses fine (this schema has
290
352
  * no opinion on whether watch is even configured), matching the same
291
353
  * "fails loudly before anything spawns, not eagerly at parse time" pattern
292
- * `quality:` already uses. Its shape depends on `code_host`: "owner/name"
293
- * for github, "workspace/repo_slug" for bitbucket.
354
+ * `quality:` already uses. `repo` always names `code_host`'s own repo —
355
+ * its shape depends on `code_host`: "owner/name" for github,
356
+ * "workspace/repo_slug" for bitbucket. `resolveCodeHostProvider`
357
+ * (`cli/commands/watch.ts`) always reads it.
358
+ *
359
+ * `issue_repo` exists for exactly one combination where that single field
360
+ * stops being enough: `issue_provider: github` with `code_host: bitbucket`
361
+ * — GitHub-issues-against-a-Bitbucket-repo, a real setup this project
362
+ * explicitly supports, where the issue tracker and the code host are
363
+ * genuinely different repos in different systems, not the same repo worn
364
+ * two ways. Every other combination stays a single field: `issue_provider:
365
+ * github` + `code_host: github` is one repo by construction; `issue_provider:
366
+ * jira` never reads `repo` at all (it uses `jira.base_url`/`project_key`),
367
+ * so `repo` unambiguously belongs to whichever code host is configured.
368
+ * `resolveIssueProvider`'s github branch and `core/refine.ts`'s
369
+ * `resolveAuthoringProvider` (issue authoring always targets the issue
370
+ * tracker, never the code host) both read `issue_repo || repo` — leaving
371
+ * `issue_repo` unset is a complete no-op, so no existing config needs to
372
+ * change.
294
373
  */
295
374
  export const WatchIssueProviderSchema = v.picklist(["github", "jira"]);
296
375
  export const WatchCodeHostSchema = v.picklist(["github", "bitbucket"]);
@@ -299,16 +378,33 @@ export const WatchJiraConfigSchema = v.object({
299
378
  base_url: v.optional(v.string(), ""), // e.g. "https://your-domain.atlassian.net"
300
379
  project_key: v.optional(v.string(), ""), // e.g. "PROJ"
301
380
  });
381
+ /**
382
+ * The second `spf watch` lane: decompose a `<prefix>:spec-ready` product
383
+ * spec into a feature/story tree of real issues, instead of running
384
+ * `watch.chain` against it directly (a spec is not individually workable —
385
+ * see `core/refine.ts`). Off by default so an existing `watch:` config's
386
+ * behavior is unchanged by upgrading; turning it on with
387
+ * `issue_provider: jira` fails loudly at `spf watch` startup, since
388
+ * `JiraProvider` doesn't implement `IssueAuthoringProvider` (create/link)
389
+ * yet — see its module comment.
390
+ */
391
+ export const WatchRefineConfigSchema = v.object({
392
+ enabled: v.optional(v.boolean(), false),
393
+ chain: v.optional(v.string(), "refine"),
394
+ concurrency: v.optional(v.pipe(v.number(), v.integer(), v.minValue(1)), 1),
395
+ });
302
396
  export const WatchConfigSchema = v.object({
303
397
  issue_provider: v.optional(WatchIssueProviderSchema, "github"),
304
398
  code_host: v.optional(WatchCodeHostSchema, "github"),
305
399
  repo: v.optional(v.string(), ""),
400
+ issue_repo: v.optional(v.string(), ""),
306
401
  label_prefix: v.optional(v.string(), "spf"),
307
402
  chain: v.optional(v.string(), "plan-build-test"),
308
403
  base_branch: v.optional(v.string(), "main"),
309
404
  poll_ms: v.optional(v.number(), 60_000),
310
405
  concurrency: v.optional(v.pipe(v.number(), v.integer(), v.minValue(1)), 2),
311
406
  jira: v.optional(WatchJiraConfigSchema, () => v.parse(WatchJiraConfigSchema, {})),
407
+ refine: v.optional(WatchRefineConfigSchema, () => v.parse(WatchRefineConfigSchema, {})),
312
408
  });
313
409
  /**
314
410
  * Optional outbound push for unattended work (`spf watch`, any chain run) —
@@ -339,6 +435,33 @@ export const NotificationsConfigSchema = v.object({
339
435
  timeout_ms: v.optional(v.number(), 5_000),
340
436
  channels: v.optional(v.array(NotifyChannelSchema), () => []),
341
437
  });
438
+ /**
439
+ * `simple_sdlc`'s human-signoff gate — see the ACCEPTED ADVERSARIAL
440
+ * AMENDMENTS on the review-accountability thread. `simple_sdlc.ts`'s
441
+ * `commit_build` predicate is the ONLY place in this codebase where an AI
442
+ * reviewer's `approved` flag gates a commit; `decideSignoff` (same file)
443
+ * turns that PROPOSAL into a human's DISPOSAL wherever a human is at the
444
+ * keyboard, and reads these two knobs when there isn't one.
445
+ *
446
+ * `require_human_signoff` defaults to FALSE for this release, deliberately:
447
+ * flipping it to fail-closed-by-default would break every unattended
448
+ * `simple-sdlc` run (`spf watch`, CI) the day this shipped, before `spf
449
+ * watch` itself is signoff-aware (its own human gate today is the PR merge,
450
+ * now informed by the reviewer digest — see `cli/commands/watch.ts`). An
451
+ * unattended run instead proceeds on the AI verdict alone with a LOUD
452
+ * one-time warning (`decideSignoff`'s `AI_ONLY_SIGNOFF_WARNING`) until that
453
+ * changes. Set `true` and an unattended run fails the phase CLOSED instead —
454
+ * see `decideSignoff`'s fail-closed branch — rather than silently
455
+ * auto-approving because nobody typed at a prompt that was never shown.
456
+ *
457
+ * `signoff_timeout_seconds` bounds the interactive prompt itself: expiry
458
+ * means NOT accepted (the confirm's own default), never an unbounded stdin
459
+ * read inside `run.phase()` — see `cli/ask.ts`'s `confirm(..., {timeoutMs})`.
460
+ */
461
+ export const ReviewConfigSchema = v.object({
462
+ require_human_signoff: v.optional(v.boolean(), false),
463
+ signoff_timeout_seconds: v.optional(v.pipe(v.number(), v.minValue(1)), 300),
464
+ });
342
465
  export const SFConfigSchema = v.object({
343
466
  defaults: v.optional(ConfigDefaultsSchema, () => v.parse(ConfigDefaultsSchema, {})),
344
467
  observability: v.optional(ObservabilityConfigSchema, () => v.parse(ObservabilityConfigSchema, {})),
@@ -346,7 +469,23 @@ export const SFConfigSchema = v.object({
346
469
  quality: v.optional(QualityConfigSchema, () => v.parse(QualityConfigSchema, {})),
347
470
  watch: v.optional(WatchConfigSchema, () => v.parse(WatchConfigSchema, {})),
348
471
  notifications: v.optional(NotificationsConfigSchema, () => v.parse(NotificationsConfigSchema, {})),
472
+ review: v.optional(ReviewConfigSchema, () => v.parse(ReviewConfigSchema, {})),
349
473
  });
474
+ // ── Tracing ──────────────────────────────────────────────────────────────────
475
+ /** The full set of event kinds any tracer.event()/makeEventRecord() call site emits — kept in sync with src/ui/shared/types.ts's EventType. */
476
+ export const EVENT_RECORD_TYPES = [
477
+ "phase_start",
478
+ "agent_start",
479
+ "tool_call",
480
+ "handoff",
481
+ "gate_pass",
482
+ "gate_fail",
483
+ "log",
484
+ "agent_end",
485
+ "phase_end",
486
+ "error",
487
+ ];
488
+ export const EventRecordTypeSchema = v.picklist(EVENT_RECORD_TYPES);
350
489
  export function makeEventRecord(input) {
351
490
  return {
352
491
  phase_id: "",
@@ -29,5 +29,18 @@ export declare function diffMatchesClaims(envelope: EnvelopeBase, run: RunContex
29
29
  * reading a line of the diff.
30
30
  */
31
31
  export declare function verdictConsistent(envelope: EnvelopeBase, _run: RunContext): GateReport;
32
+ /**
33
+ * The gate that turns `to-tickets`' flat, untyped ticket list into an
34
+ * actually-enforced feature/story-or-bug tree — see `RefinedIssueSchema`'s
35
+ * doc comment in `data_types.ts`. Checks the envelope's `issues` list
36
+ * against itself, never anything already published: `core/refine.ts` never
37
+ * gets a chance to publish a malformed tree in the first place, because a
38
+ * violation here re-prompts the SAME refiner session before `steps.refine()`
39
+ * ever hands off to `steps.publishIssues()`.
40
+ *
41
+ * "Container" and "leaf" are derived from the graph, not asserted by the
42
+ * agent: a node is a container iff some other node names it as `parent`.
43
+ */
44
+ export declare function refinementWellFormed(envelope: EnvelopeBase, _run: RunContext): GateReport;
32
45
  /** Gate factory: the given shell command must exit 0, run from run.repo_root. */
33
46
  export declare function testsPass(command: string): GateFn;
@@ -128,6 +128,109 @@ export function verdictConsistent(envelope, _run) {
128
128
  : "approved=false but no blocking item or unmet requirement was given");
129
129
  return report;
130
130
  }
131
+ const CONTAINER_KINDS = new Set(["epic", "feature"]);
132
+ const LEAF_KINDS = new Set(["story", "bug", "task"]);
133
+ /**
134
+ * The gate that turns `to-tickets`' flat, untyped ticket list into an
135
+ * actually-enforced feature/story-or-bug tree — see `RefinedIssueSchema`'s
136
+ * doc comment in `data_types.ts`. Checks the envelope's `issues` list
137
+ * against itself, never anything already published: `core/refine.ts` never
138
+ * gets a chance to publish a malformed tree in the first place, because a
139
+ * violation here re-prompts the SAME refiner session before `steps.refine()`
140
+ * ever hands off to `steps.publishIssues()`.
141
+ *
142
+ * "Container" and "leaf" are derived from the graph, not asserted by the
143
+ * agent: a node is a container iff some other node names it as `parent`.
144
+ */
145
+ export function refinementWellFormed(envelope, _run) {
146
+ const report = new GateReport();
147
+ const issues = envelope.issues ?? [];
148
+ if (issues.length === 0) {
149
+ report.check("issues", false, "a refinement produced no issues at all — decompose the spec into at least one leaf");
150
+ return report;
151
+ }
152
+ const byKey = new Map();
153
+ for (const issue of issues) {
154
+ if (byKey.has(issue.key)) {
155
+ report.check(`key ${JSON.stringify(issue.key)}`, false, "duplicate key — every node needs a unique key within this refinement");
156
+ }
157
+ else {
158
+ byKey.set(issue.key, issue);
159
+ }
160
+ }
161
+ const childKeys = new Set(); // keys named as some OTHER node's parent -> that node is a container
162
+ for (const issue of issues) {
163
+ if (!issue.parent)
164
+ continue;
165
+ if (!byKey.has(issue.parent)) {
166
+ report.check(`${issue.key}.parent`, false, `parent ${JSON.stringify(issue.parent)} does not match any issue's key`);
167
+ continue;
168
+ }
169
+ childKeys.add(issue.parent);
170
+ }
171
+ for (const issue of issues) {
172
+ for (const blocker of issue.blocked_by) {
173
+ if (!byKey.has(blocker)) {
174
+ report.check(`${issue.key}.blocked_by`, false, `blocked_by ${JSON.stringify(blocker)} does not match any issue's key`);
175
+ }
176
+ }
177
+ }
178
+ // Cycle check over the union of parent + blocked_by edges — both mean
179
+ // "must exist before this node" from core/refine.ts's own topological
180
+ // publish order, so a cycle in either (or across both) would hang it.
181
+ const WHITE = 0;
182
+ const GRAY = 1;
183
+ const BLACK = 2;
184
+ const color = new Map();
185
+ let cyclic = false;
186
+ const edgesFrom = (key) => {
187
+ const issue = byKey.get(key);
188
+ if (!issue)
189
+ return [];
190
+ const out = [];
191
+ if (issue.parent && byKey.has(issue.parent))
192
+ out.push(issue.parent);
193
+ for (const b of issue.blocked_by)
194
+ if (byKey.has(b))
195
+ out.push(b);
196
+ return out;
197
+ };
198
+ const visit = (key) => {
199
+ if (cyclic)
200
+ return;
201
+ color.set(key, GRAY);
202
+ for (const next of edgesFrom(key)) {
203
+ const c = color.get(next) ?? WHITE;
204
+ if (c === GRAY) {
205
+ cyclic = true;
206
+ return;
207
+ }
208
+ if (c === WHITE)
209
+ visit(next);
210
+ }
211
+ color.set(key, BLACK);
212
+ };
213
+ for (const issue of issues) {
214
+ if ((color.get(issue.key) ?? WHITE) === WHITE)
215
+ visit(issue.key);
216
+ }
217
+ report.check("dependency graph", !cyclic, cyclic ? "parent/blocked_by edges form a cycle — nothing to publish first" : "acyclic");
218
+ for (const issue of issues) {
219
+ const isContainer = childKeys.has(issue.key);
220
+ if (isContainer && !CONTAINER_KINDS.has(issue.kind)) {
221
+ report.check(`${issue.key}.kind`, false, `has children but kind is ${JSON.stringify(issue.kind)} — a container must be "epic" or "feature"`);
222
+ }
223
+ else if (!isContainer && !LEAF_KINDS.has(issue.kind)) {
224
+ report.check(`${issue.key}.kind`, false, `has no children but kind is ${JSON.stringify(issue.kind)} — a leaf must be "story", "bug", or "task"`);
225
+ }
226
+ else {
227
+ report.check(`${issue.key}.kind`, true, isContainer ? "container" : "leaf");
228
+ }
229
+ }
230
+ const leafCount = issues.filter((i) => !childKeys.has(i.key)).length;
231
+ report.check("has leaves", leafCount > 0, leafCount > 0 ? `${leafCount} leaf issue(s)` : "every node is a container — nothing here is independently workable");
232
+ return report;
233
+ }
131
234
  /** Gate factory: the given shell command must exit 0, run from run.repo_root. */
132
235
  export function testsPass(command) {
133
236
  const gate = (_envelope, run) => {