@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.
- package/README.md +177 -9
- package/assets/defaults/spf.config.yaml +22 -0
- package/assets/prompts/refiner/system.md +53 -0
- package/assets/prompts/refiner/user.md +70 -0
- package/assets/prompts/reviewer/system.md +1 -1
- package/assets/skill/SKILL.md +1 -0
- package/assets/skill/cookbooks/authoring_chains.md +90 -7
- package/assets/skill/cookbooks/ocr_reviewer.md +196 -0
- package/assets/skill/cookbooks/roster.md +15 -4
- package/assets/skill/cookbooks/spf_overview.md +1 -0
- package/assets/skill/references/config.md +104 -3
- package/assets/skill/references/observability.md +11 -2
- package/assets/templates/ts-cc.spf.config.yaml +3 -3
- package/assets/templates/ts-flue-ollama.spf.config.yaml +67 -0
- package/assets/templates/ts.spf.config.yaml +15 -2
- package/dist/chains/context.d.ts +39 -0
- package/dist/chains/index.d.ts +94 -10
- package/dist/chains/index.js +75 -5
- package/dist/chains/repo_chains.d.ts +139 -0
- package/dist/chains/repo_chains.js +428 -0
- package/dist/chains/simple_sdlc.d.ts +74 -1
- package/dist/chains/simple_sdlc.js +134 -4
- package/dist/chains/steps.d.ts +237 -18
- package/dist/chains/steps.js +477 -58
- package/dist/cli/ask.d.ts +14 -1
- package/dist/cli/ask.js +32 -2
- package/dist/cli/commands/doctor.d.ts +1 -1
- package/dist/cli/commands/doctor.js +324 -10
- package/dist/cli/commands/init.d.ts +12 -0
- package/dist/cli/commands/init.js +108 -4
- package/dist/cli/commands/install-skill.js +5 -2
- package/dist/cli/commands/list.js +43 -5
- package/dist/cli/commands/run.js +29 -2
- package/dist/cli/commands/watch.d.ts +18 -0
- package/dist/cli/commands/watch.js +214 -16
- package/dist/cli/index.js +63 -6
- package/dist/cli/interview.js +81 -9
- package/dist/core/agent_cc.d.ts +40 -1
- package/dist/core/agent_cc.js +51 -4
- package/dist/core/agent_flue.js +28 -4
- package/dist/core/agents.d.ts +8 -0
- package/dist/core/agents.js +43 -3
- package/dist/core/data_types.d.ts +182 -4
- package/dist/core/data_types.js +141 -2
- package/dist/core/gates.d.ts +13 -0
- package/dist/core/gates.js +103 -0
- package/dist/core/git_helper.d.ts +29 -0
- package/dist/core/git_helper.js +41 -1
- package/dist/core/issues/github_provider.d.ts +35 -9
- package/dist/core/issues/github_provider.js +76 -28
- package/dist/core/issues/jira_provider.d.ts +14 -1
- package/dist/core/issues/jira_provider.js +9 -7
- package/dist/core/issues/provider.d.ts +77 -15
- package/dist/core/issues/provider.js +7 -4
- package/dist/core/notify/channel.d.ts +1 -1
- package/dist/core/ollama_provider.d.ts +70 -0
- package/dist/core/ollama_provider.js +208 -0
- package/dist/core/otel.d.ts +352 -0
- package/dist/core/otel.js +793 -0
- package/dist/core/providers.js +4 -0
- package/dist/core/refine.d.ts +39 -0
- package/dist/core/refine.js +152 -0
- package/dist/core/session.js +39 -2
- package/dist/core/tracer.d.ts +31 -2
- package/dist/core/tracer.js +69 -11
- package/dist/core/watch.d.ts +67 -1
- package/dist/core/watch.js +217 -13
- package/dist/test/chains.test.js +9 -3
- package/dist/test/data_types.test.js +140 -2
- package/dist/test/git_helper.test.d.ts +1 -0
- package/dist/test/git_helper.test.js +59 -0
- package/dist/test/hermetic_git.d.ts +1 -0
- package/dist/test/hermetic_git.js +22 -0
- package/dist/test/init_command.test.d.ts +14 -1
- package/dist/test/init_command.test.js +71 -1
- package/dist/test/interview.test.d.ts +15 -1
- package/dist/test/interview.test.js +131 -3
- package/dist/test/ollama_provider.test.d.ts +1 -0
- package/dist/test/ollama_provider.test.js +103 -0
- package/dist/test/otel.test.d.ts +26 -0
- package/dist/test/otel.test.js +512 -0
- package/dist/test/refine.test.d.ts +1 -0
- package/dist/test/refine.test.js +189 -0
- package/dist/test/repo_chains.test.d.ts +21 -0
- package/dist/test/repo_chains.test.js +416 -0
- package/dist/test/signoff.test.d.ts +1 -0
- package/dist/test/signoff.test.js +329 -0
- package/dist/test/ui_server.test.d.ts +7 -1
- package/dist/test/ui_server.test.js +1 -0
- package/dist/test/watch.test.js +297 -6
- 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.
|
|
374
|
-
*
|
|
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:
|
|
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:
|
|
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.
|
package/dist/core/data_types.js
CHANGED
|
@@ -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.
|
|
293
|
-
*
|
|
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: "",
|
package/dist/core/gates.d.ts
CHANGED
|
@@ -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;
|
package/dist/core/gates.js
CHANGED
|
@@ -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) => {
|