@gr8ful/spf 0.2.1 → 0.4.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 +106 -6
- package/assets/defaults/spf.config.yaml +16 -0
- package/assets/prompts/refiner/system.md +53 -0
- package/assets/prompts/refiner/user.md +70 -0
- package/assets/skill/references/config.md +83 -3
- package/assets/templates/ts-cc.spf.config.yaml +3 -3
- package/assets/templates/ts.spf.config.yaml +22 -2
- package/dist/chains/context.d.ts +9 -0
- package/dist/chains/index.js +5 -0
- package/dist/chains/steps.d.ts +24 -0
- package/dist/chains/steps.js +55 -4
- package/dist/cli/commands/doctor.js +18 -0
- package/dist/cli/commands/init.js +44 -3
- package/dist/cli/commands/install-skill.js +5 -2
- package/dist/cli/commands/list.js +1 -0
- package/dist/cli/commands/run.js +5 -1
- package/dist/cli/commands/watch.js +86 -8
- package/dist/cli/index.js +7 -3
- package/dist/cli/interview.d.ts +2 -0
- package/dist/cli/interview.js +107 -3
- package/dist/core/agents.js +4 -1
- package/dist/core/console.d.ts +13 -1
- package/dist/core/console.js +51 -1
- package/dist/core/data_types.d.ts +133 -0
- package/dist/core/data_types.js +72 -0
- package/dist/core/gates.d.ts +13 -0
- package/dist/core/gates.js +103 -0
- 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 +32 -0
- package/dist/core/notify/channel.js +14 -0
- package/dist/core/notify/notifier.d.ts +42 -0
- package/dist/core/notify/notifier.js +100 -0
- package/dist/core/notify/slack_channel.d.ts +13 -0
- package/dist/core/notify/slack_channel.js +30 -0
- package/dist/core/notify/teams_channel.d.ts +17 -0
- package/dist/core/notify/teams_channel.js +38 -0
- package/dist/core/notify/webhook_channel.d.ts +13 -0
- package/dist/core/notify/webhook_channel.js +19 -0
- package/dist/core/refine.d.ts +39 -0
- package/dist/core/refine.js +144 -0
- package/dist/core/runner.d.ts +7 -0
- package/dist/core/runner.js +4 -1
- package/dist/core/session.js +3 -0
- package/dist/core/watch.d.ts +66 -1
- package/dist/core/watch.js +267 -15
- package/dist/test/chains.test.js +1 -0
- package/dist/test/data_types.test.js +34 -1
- package/dist/test/init_command.test.js +17 -0
- package/dist/test/interview.test.js +119 -0
- package/dist/test/notify.test.d.ts +1 -0
- package/dist/test/notify.test.js +174 -0
- package/dist/test/refine.test.d.ts +1 -0
- package/dist/test/refine.test.js +126 -0
- package/dist/test/watch.test.js +286 -5
- package/package.json +1 -1
package/dist/core/console.js
CHANGED
|
@@ -45,13 +45,21 @@ function panel(lines, title, borderColor) {
|
|
|
45
45
|
export class Console {
|
|
46
46
|
tracer;
|
|
47
47
|
adwId;
|
|
48
|
+
notifier;
|
|
49
|
+
chainName;
|
|
48
50
|
phaseId = ""; // current lane — log events attach to it
|
|
49
51
|
phaseName = "";
|
|
50
52
|
results = []; // phase statuses, for the summary
|
|
51
53
|
finished = false; // the summary panel prints once
|
|
52
|
-
constructor(tracer, adwId
|
|
54
|
+
constructor(tracer, adwId,
|
|
55
|
+
/** `null` when notifications are off — every call site below guards with `?.`. */
|
|
56
|
+
notifier = null,
|
|
57
|
+
/** The CLI chain name (`"plan-build-test"`), for a notification's title — see session.ts. */
|
|
58
|
+
chainName = "adw") {
|
|
53
59
|
this.tracer = tracer;
|
|
54
60
|
this.adwId = adwId;
|
|
61
|
+
this.notifier = notifier;
|
|
62
|
+
this.chainName = chainName;
|
|
55
63
|
}
|
|
56
64
|
// ── the one helper: print AND trace, always together ────────────────────
|
|
57
65
|
emit(line, level = "info") {
|
|
@@ -67,6 +75,15 @@ export class Console {
|
|
|
67
75
|
// ── session ─────────────────────────────────────────────────────────────
|
|
68
76
|
sessionStarted(adwId, engineer) {
|
|
69
77
|
this.emit(`${paint("bold cyan", "adw_id:")} ${paint("bold", adwId)} ${paint("dim", "engineer")} ${engineer}`);
|
|
78
|
+
this.notifier?.send({
|
|
79
|
+
kind: "run_started",
|
|
80
|
+
level: "info",
|
|
81
|
+
title: `run started — ${this.chainName}`,
|
|
82
|
+
fields: [
|
|
83
|
+
["adw_id", adwId],
|
|
84
|
+
["engineer", engineer],
|
|
85
|
+
],
|
|
86
|
+
});
|
|
70
87
|
}
|
|
71
88
|
sessionFinished(ok, tokens, cost, dbPath) {
|
|
72
89
|
if (this.finished)
|
|
@@ -93,6 +110,17 @@ export class Console {
|
|
|
93
110
|
name: this.phaseName || "console",
|
|
94
111
|
payload: { message: plain, level: ok ? "info" : "error" },
|
|
95
112
|
}));
|
|
113
|
+
this.notifier?.send({
|
|
114
|
+
kind: ok ? "run_finished" : "run_failed",
|
|
115
|
+
level: ok ? "info" : "error",
|
|
116
|
+
title: `run ${ok ? "finished" : "failed"} — ${this.chainName}`,
|
|
117
|
+
fields: [
|
|
118
|
+
["adw_id", this.adwId],
|
|
119
|
+
["phases", `${passed}/${this.results.length}`],
|
|
120
|
+
["tokens", tokens.toLocaleString()],
|
|
121
|
+
["cost", `$${cost.toFixed(4)}`],
|
|
122
|
+
],
|
|
123
|
+
});
|
|
96
124
|
}
|
|
97
125
|
// ── phases ──────────────────────────────────────────────────────────────
|
|
98
126
|
phaseStarted(phase) {
|
|
@@ -113,6 +141,19 @@ export class Console {
|
|
|
113
141
|
if (!ok && phase.error)
|
|
114
142
|
line += ` ${paint("red", clip(phase.error))}`;
|
|
115
143
|
this.emit(line, ok ? "info" : "error");
|
|
144
|
+
if (!ok) {
|
|
145
|
+
this.notifier?.send({
|
|
146
|
+
kind: "phase_failed",
|
|
147
|
+
level: "error",
|
|
148
|
+
title: `phase failed — ${phase.params.name}`,
|
|
149
|
+
detail: phase.error ?? undefined,
|
|
150
|
+
fields: [
|
|
151
|
+
["adw_id", this.adwId],
|
|
152
|
+
["chain", this.chainName],
|
|
153
|
+
["owner", phase.params.owner],
|
|
154
|
+
],
|
|
155
|
+
});
|
|
156
|
+
}
|
|
116
157
|
this.phaseId = "";
|
|
117
158
|
this.phaseName = "";
|
|
118
159
|
}
|
|
@@ -129,6 +170,15 @@ export class Console {
|
|
|
129
170
|
}
|
|
130
171
|
retry(name, attempt, limit, reason) {
|
|
131
172
|
this.emit(` ${paint("yellow", "⟳")} ${name} retry ${attempt}/${limit} ${paint("dim", `— same session · ${clip(reason)}`)}`, "warn");
|
|
173
|
+
// info-level: routine self-healing, same as watch's own untracked
|
|
174
|
+
// orphan retries — visible under `events: all`, silent under `errors`.
|
|
175
|
+
this.notifier?.send({
|
|
176
|
+
kind: "phase_retry",
|
|
177
|
+
level: "info",
|
|
178
|
+
title: `retry ${attempt}/${limit} — ${name}`,
|
|
179
|
+
detail: reason,
|
|
180
|
+
fields: [["adw_id", this.adwId]],
|
|
181
|
+
});
|
|
132
182
|
}
|
|
133
183
|
// ── verification ────────────────────────────────────────────────────────
|
|
134
184
|
/** A gate reports WHAT it checked, not just whether it passed. */
|
|
@@ -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>;
|
|
@@ -383,6 +422,22 @@ export declare const WatchJiraConfigSchema: v.ObjectSchema<{
|
|
|
383
422
|
readonly project_key: v.OptionalSchema<v.StringSchema<undefined>, "">;
|
|
384
423
|
}, undefined>;
|
|
385
424
|
export type WatchJiraConfig = v.InferOutput<typeof WatchJiraConfigSchema>;
|
|
425
|
+
/**
|
|
426
|
+
* The second `spf watch` lane: decompose a `<prefix>:spec-ready` product
|
|
427
|
+
* spec into a feature/story tree of real issues, instead of running
|
|
428
|
+
* `watch.chain` against it directly (a spec is not individually workable —
|
|
429
|
+
* see `core/refine.ts`). Off by default so an existing `watch:` config's
|
|
430
|
+
* behavior is unchanged by upgrading; turning it on with
|
|
431
|
+
* `issue_provider: jira` fails loudly at `spf watch` startup, since
|
|
432
|
+
* `JiraProvider` doesn't implement `IssueAuthoringProvider` (create/link)
|
|
433
|
+
* yet — see its module comment.
|
|
434
|
+
*/
|
|
435
|
+
export declare const WatchRefineConfigSchema: v.ObjectSchema<{
|
|
436
|
+
readonly enabled: v.OptionalSchema<v.BooleanSchema<undefined>, false>;
|
|
437
|
+
readonly chain: v.OptionalSchema<v.StringSchema<undefined>, "refine">;
|
|
438
|
+
readonly concurrency: v.OptionalSchema<v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 1, undefined>]>, 1>;
|
|
439
|
+
}, undefined>;
|
|
440
|
+
export type WatchRefineConfig = v.InferOutput<typeof WatchRefineConfigSchema>;
|
|
386
441
|
export declare const WatchConfigSchema: v.ObjectSchema<{
|
|
387
442
|
readonly issue_provider: v.OptionalSchema<v.PicklistSchema<["github", "jira"], undefined>, "github">;
|
|
388
443
|
readonly code_host: v.OptionalSchema<v.PicklistSchema<["github", "bitbucket"], undefined>, "github">;
|
|
@@ -399,8 +454,53 @@ export declare const WatchConfigSchema: v.ObjectSchema<{
|
|
|
399
454
|
base_url: string;
|
|
400
455
|
project_key: string;
|
|
401
456
|
}>;
|
|
457
|
+
readonly refine: v.OptionalSchema<v.ObjectSchema<{
|
|
458
|
+
readonly enabled: v.OptionalSchema<v.BooleanSchema<undefined>, false>;
|
|
459
|
+
readonly chain: v.OptionalSchema<v.StringSchema<undefined>, "refine">;
|
|
460
|
+
readonly concurrency: v.OptionalSchema<v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 1, undefined>]>, 1>;
|
|
461
|
+
}, undefined>, () => {
|
|
462
|
+
enabled: boolean;
|
|
463
|
+
chain: string;
|
|
464
|
+
concurrency: number;
|
|
465
|
+
}>;
|
|
402
466
|
}, undefined>;
|
|
403
467
|
export type WatchConfig = v.InferOutput<typeof WatchConfigSchema>;
|
|
468
|
+
/**
|
|
469
|
+
* Optional outbound push for unattended work (`spf watch`, any chain run) —
|
|
470
|
+
* everything else (`spf doctor`, `list`, `sessions`, ...) is interactive, so
|
|
471
|
+
* it stays console-only on purpose; see `core/notify/notifier.ts`.
|
|
472
|
+
*
|
|
473
|
+
* `events` is the whole filter: "off" sends nothing, "errors" sends only
|
|
474
|
+
* NotifyEvents whose `level` is "error", "all" sends every curated
|
|
475
|
+
* milestone. A channel's own `events` overrides the top-level scope for
|
|
476
|
+
* just that channel (e.g. Slack gets everything, Teams gets errors only).
|
|
477
|
+
*
|
|
478
|
+
* `webhook_url_env` names the .env key holding the secret URL — never the
|
|
479
|
+
* URL itself, matching GITHUB_TOKEN/JIRA_API_TOKEN. Empty = the kind's own
|
|
480
|
+
* default key (see core/notify/notifier.ts's DEFAULT_ENV_KEY).
|
|
481
|
+
*/
|
|
482
|
+
export declare const NotifyScopeSchema: v.PicklistSchema<["off", "errors", "all"], undefined>;
|
|
483
|
+
export type NotifyScope = v.InferOutput<typeof NotifyScopeSchema>;
|
|
484
|
+
export declare const NotifyChannelKindSchema: v.PicklistSchema<["slack", "teams", "webhook"], undefined>;
|
|
485
|
+
export type NotifyChannelKind = v.InferOutput<typeof NotifyChannelKindSchema>;
|
|
486
|
+
export declare const NotifyChannelSchema: v.ObjectSchema<{
|
|
487
|
+
readonly kind: v.PicklistSchema<["slack", "teams", "webhook"], undefined>;
|
|
488
|
+
readonly webhook_url_env: v.OptionalSchema<v.StringSchema<undefined>, "">;
|
|
489
|
+
readonly events: v.OptionalSchema<v.NullableSchema<v.PicklistSchema<["off", "errors", "all"], undefined>, undefined>, undefined>;
|
|
490
|
+
readonly name: v.OptionalSchema<v.StringSchema<undefined>, "">;
|
|
491
|
+
}, undefined>;
|
|
492
|
+
export type NotifyChannel = v.InferOutput<typeof NotifyChannelSchema>;
|
|
493
|
+
export declare const NotificationsConfigSchema: v.ObjectSchema<{
|
|
494
|
+
readonly events: v.OptionalSchema<v.PicklistSchema<["off", "errors", "all"], undefined>, "off">;
|
|
495
|
+
readonly timeout_ms: v.OptionalSchema<v.NumberSchema<undefined>, 5000>;
|
|
496
|
+
readonly channels: v.OptionalSchema<v.ArraySchema<v.ObjectSchema<{
|
|
497
|
+
readonly kind: v.PicklistSchema<["slack", "teams", "webhook"], undefined>;
|
|
498
|
+
readonly webhook_url_env: v.OptionalSchema<v.StringSchema<undefined>, "">;
|
|
499
|
+
readonly events: v.OptionalSchema<v.NullableSchema<v.PicklistSchema<["off", "errors", "all"], undefined>, undefined>, undefined>;
|
|
500
|
+
readonly name: v.OptionalSchema<v.StringSchema<undefined>, "">;
|
|
501
|
+
}, undefined>, undefined>, () => never[]>;
|
|
502
|
+
}, undefined>;
|
|
503
|
+
export type NotificationsConfig = v.InferOutput<typeof NotificationsConfigSchema>;
|
|
404
504
|
export declare const SFConfigSchema: v.ObjectSchema<{
|
|
405
505
|
readonly defaults: v.OptionalSchema<v.ObjectSchema<{
|
|
406
506
|
readonly coding_agent: v.OptionalSchema<v.PicklistSchema<["flue", "claude_code"], undefined>, "flue">;
|
|
@@ -480,6 +580,15 @@ export declare const SFConfigSchema: v.ObjectSchema<{
|
|
|
480
580
|
base_url: string;
|
|
481
581
|
project_key: string;
|
|
482
582
|
}>;
|
|
583
|
+
readonly refine: v.OptionalSchema<v.ObjectSchema<{
|
|
584
|
+
readonly enabled: v.OptionalSchema<v.BooleanSchema<undefined>, false>;
|
|
585
|
+
readonly chain: v.OptionalSchema<v.StringSchema<undefined>, "refine">;
|
|
586
|
+
readonly concurrency: v.OptionalSchema<v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 1, undefined>]>, 1>;
|
|
587
|
+
}, undefined>, () => {
|
|
588
|
+
enabled: boolean;
|
|
589
|
+
chain: string;
|
|
590
|
+
concurrency: number;
|
|
591
|
+
}>;
|
|
483
592
|
}, undefined>, () => {
|
|
484
593
|
issue_provider: "github" | "jira";
|
|
485
594
|
code_host: "bitbucket" | "github";
|
|
@@ -493,6 +602,30 @@ export declare const SFConfigSchema: v.ObjectSchema<{
|
|
|
493
602
|
base_url: string;
|
|
494
603
|
project_key: string;
|
|
495
604
|
};
|
|
605
|
+
refine: {
|
|
606
|
+
enabled: boolean;
|
|
607
|
+
chain: string;
|
|
608
|
+
concurrency: number;
|
|
609
|
+
};
|
|
610
|
+
}>;
|
|
611
|
+
readonly notifications: v.OptionalSchema<v.ObjectSchema<{
|
|
612
|
+
readonly events: v.OptionalSchema<v.PicklistSchema<["off", "errors", "all"], undefined>, "off">;
|
|
613
|
+
readonly timeout_ms: v.OptionalSchema<v.NumberSchema<undefined>, 5000>;
|
|
614
|
+
readonly channels: v.OptionalSchema<v.ArraySchema<v.ObjectSchema<{
|
|
615
|
+
readonly kind: v.PicklistSchema<["slack", "teams", "webhook"], undefined>;
|
|
616
|
+
readonly webhook_url_env: v.OptionalSchema<v.StringSchema<undefined>, "">;
|
|
617
|
+
readonly events: v.OptionalSchema<v.NullableSchema<v.PicklistSchema<["off", "errors", "all"], undefined>, undefined>, undefined>;
|
|
618
|
+
readonly name: v.OptionalSchema<v.StringSchema<undefined>, "">;
|
|
619
|
+
}, undefined>, undefined>, () => never[]>;
|
|
620
|
+
}, undefined>, () => {
|
|
621
|
+
events: "all" | "errors" | "off";
|
|
622
|
+
timeout_ms: number;
|
|
623
|
+
channels: {
|
|
624
|
+
kind: "slack" | "teams" | "webhook";
|
|
625
|
+
webhook_url_env: string;
|
|
626
|
+
events?: "all" | "errors" | "off" | null | undefined;
|
|
627
|
+
name: string;
|
|
628
|
+
}[];
|
|
496
629
|
}>;
|
|
497
630
|
}, undefined>;
|
|
498
631
|
export type SFConfig = v.InferOutput<typeof SFConfigSchema>;
|
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"]);
|
|
@@ -299,6 +325,21 @@ export const WatchJiraConfigSchema = v.object({
|
|
|
299
325
|
base_url: v.optional(v.string(), ""), // e.g. "https://your-domain.atlassian.net"
|
|
300
326
|
project_key: v.optional(v.string(), ""), // e.g. "PROJ"
|
|
301
327
|
});
|
|
328
|
+
/**
|
|
329
|
+
* The second `spf watch` lane: decompose a `<prefix>:spec-ready` product
|
|
330
|
+
* spec into a feature/story tree of real issues, instead of running
|
|
331
|
+
* `watch.chain` against it directly (a spec is not individually workable —
|
|
332
|
+
* see `core/refine.ts`). Off by default so an existing `watch:` config's
|
|
333
|
+
* behavior is unchanged by upgrading; turning it on with
|
|
334
|
+
* `issue_provider: jira` fails loudly at `spf watch` startup, since
|
|
335
|
+
* `JiraProvider` doesn't implement `IssueAuthoringProvider` (create/link)
|
|
336
|
+
* yet — see its module comment.
|
|
337
|
+
*/
|
|
338
|
+
export const WatchRefineConfigSchema = v.object({
|
|
339
|
+
enabled: v.optional(v.boolean(), false),
|
|
340
|
+
chain: v.optional(v.string(), "refine"),
|
|
341
|
+
concurrency: v.optional(v.pipe(v.number(), v.integer(), v.minValue(1)), 1),
|
|
342
|
+
});
|
|
302
343
|
export const WatchConfigSchema = v.object({
|
|
303
344
|
issue_provider: v.optional(WatchIssueProviderSchema, "github"),
|
|
304
345
|
code_host: v.optional(WatchCodeHostSchema, "github"),
|
|
@@ -309,6 +350,36 @@ export const WatchConfigSchema = v.object({
|
|
|
309
350
|
poll_ms: v.optional(v.number(), 60_000),
|
|
310
351
|
concurrency: v.optional(v.pipe(v.number(), v.integer(), v.minValue(1)), 2),
|
|
311
352
|
jira: v.optional(WatchJiraConfigSchema, () => v.parse(WatchJiraConfigSchema, {})),
|
|
353
|
+
refine: v.optional(WatchRefineConfigSchema, () => v.parse(WatchRefineConfigSchema, {})),
|
|
354
|
+
});
|
|
355
|
+
/**
|
|
356
|
+
* Optional outbound push for unattended work (`spf watch`, any chain run) —
|
|
357
|
+
* everything else (`spf doctor`, `list`, `sessions`, ...) is interactive, so
|
|
358
|
+
* it stays console-only on purpose; see `core/notify/notifier.ts`.
|
|
359
|
+
*
|
|
360
|
+
* `events` is the whole filter: "off" sends nothing, "errors" sends only
|
|
361
|
+
* NotifyEvents whose `level` is "error", "all" sends every curated
|
|
362
|
+
* milestone. A channel's own `events` overrides the top-level scope for
|
|
363
|
+
* just that channel (e.g. Slack gets everything, Teams gets errors only).
|
|
364
|
+
*
|
|
365
|
+
* `webhook_url_env` names the .env key holding the secret URL — never the
|
|
366
|
+
* URL itself, matching GITHUB_TOKEN/JIRA_API_TOKEN. Empty = the kind's own
|
|
367
|
+
* default key (see core/notify/notifier.ts's DEFAULT_ENV_KEY).
|
|
368
|
+
*/
|
|
369
|
+
export const NotifyScopeSchema = v.picklist(["off", "errors", "all"]);
|
|
370
|
+
export const NotifyChannelKindSchema = v.picklist(["slack", "teams", "webhook"]);
|
|
371
|
+
export const NotifyChannelSchema = v.object({
|
|
372
|
+
kind: NotifyChannelKindSchema,
|
|
373
|
+
webhook_url_env: v.optional(v.string(), ""),
|
|
374
|
+
events: v.optional(v.nullable(NotifyScopeSchema)),
|
|
375
|
+
// Shown in warning lines / message footers to tell two channels of the
|
|
376
|
+
// same kind apart (e.g. two webhook: entries) — cosmetic only.
|
|
377
|
+
name: v.optional(v.string(), ""),
|
|
378
|
+
});
|
|
379
|
+
export const NotificationsConfigSchema = v.object({
|
|
380
|
+
events: v.optional(NotifyScopeSchema, "off"),
|
|
381
|
+
timeout_ms: v.optional(v.number(), 5_000),
|
|
382
|
+
channels: v.optional(v.array(NotifyChannelSchema), () => []),
|
|
312
383
|
});
|
|
313
384
|
export const SFConfigSchema = v.object({
|
|
314
385
|
defaults: v.optional(ConfigDefaultsSchema, () => v.parse(ConfigDefaultsSchema, {})),
|
|
@@ -316,6 +387,7 @@ export const SFConfigSchema = v.object({
|
|
|
316
387
|
agents: v.optional(v.array(AgentConfigSchema), () => []),
|
|
317
388
|
quality: v.optional(QualityConfigSchema, () => v.parse(QualityConfigSchema, {})),
|
|
318
389
|
watch: v.optional(WatchConfigSchema, () => v.parse(WatchConfigSchema, {})),
|
|
390
|
+
notifications: v.optional(NotificationsConfigSchema, () => v.parse(NotificationsConfigSchema, {})),
|
|
319
391
|
});
|
|
320
392
|
export function makeEventRecord(input) {
|
|
321
393
|
return {
|
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) => {
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* GitHub REST implementation of
|
|
3
|
-
* — one class, since GitHub natively is
|
|
4
|
-
*
|
|
2
|
+
* GitHub REST implementation of `IssueProvider`, `CodeHostProvider`, AND
|
|
3
|
+
* `IssueAuthoringProvider` — one class, since GitHub natively is an issue
|
|
4
|
+
* tracker, a code host, and (via sub-issues) an issue-hierarchy API all at
|
|
5
|
+
* once — via Node 22's native `fetch()`: deliberately not `octokit`, whose
|
|
5
6
|
* full meta-package resolves to ~82MB of installed dependencies (`@octokit/app`,
|
|
6
|
-
* `oauth-app`, `webhooks`, ...) for what `spf watch` actually needs, which
|
|
7
|
-
*
|
|
7
|
+
* `oauth-app`, `webhooks`, ...) for what `spf watch` actually needs, which is
|
|
8
|
+
* a couple dozen REST calls, none of them exotic. `spf`'s own package stays
|
|
9
|
+
* dependency-free either way.
|
|
8
10
|
*
|
|
9
11
|
* Auth is a classic PAT via `GITHUB_TOKEN` (`repo` scope), read once at
|
|
10
12
|
* construction — matching the reference implementation's pattern and this
|
|
@@ -13,8 +15,11 @@
|
|
|
13
15
|
* makes (a repo with >100 open `<prefix>:ready` issues at once is not this
|
|
14
16
|
* version's problem to solve).
|
|
15
17
|
*/
|
|
16
|
-
import type { CodeHostProvider, EnsureLabelsResult, Issue, IssueProvider, PrRef, PrStatus, WatchMarker, WatchState } from "./provider.ts";
|
|
17
|
-
|
|
18
|
+
import type { CodeHostProvider, EnsureLabelsResult, Issue, IssueAuthoringProvider, IssueProvider, PrRef, PrStatus, WatchMarker, WatchState } from "./provider.ts";
|
|
19
|
+
/** The refine lane's leaf/container taxonomy — see `data_types.ts`'s `RefinedIssueSchema.kind`. Not a `WatchState`: these never appear on the left of a `transition()` call, so `transition()` never strips them. */
|
|
20
|
+
export declare const ISSUE_KINDS: readonly ["epic", "feature", "story", "bug", "task"];
|
|
21
|
+
export type IssueKind = (typeof ISSUE_KINDS)[number];
|
|
22
|
+
export declare class GitHubProvider implements IssueProvider, CodeHostProvider, IssueAuthoringProvider {
|
|
18
23
|
private readonly repo;
|
|
19
24
|
private readonly labelPrefix;
|
|
20
25
|
private readonly token;
|
|
@@ -22,14 +27,17 @@ export declare class GitHubProvider implements IssueProvider, CodeHostProvider {
|
|
|
22
27
|
labelPrefix: string, token: string);
|
|
23
28
|
private gh;
|
|
24
29
|
private label;
|
|
30
|
+
private typeLabel;
|
|
25
31
|
/** `null` on a real 404 (label doesn't exist yet) — any other non-2xx still throws, same as `gh()`. */
|
|
26
32
|
private getLabel;
|
|
27
33
|
/**
|
|
28
|
-
* Idempotent by inspection, not by "create and catch a 422": GET
|
|
34
|
+
* Idempotent by inspection, not by "create and catch a 422": GET the
|
|
29
35
|
* label first, then create/update/leave alone depending on what's
|
|
30
36
|
* actually there. One fewer request in the common "already correct"
|
|
31
37
|
* case, and no brittle matching against GitHub's error-message text.
|
|
38
|
+
* Shared by `ensureLabels()`'s state-label and type-label passes.
|
|
32
39
|
*/
|
|
40
|
+
private ensureOneLabel;
|
|
33
41
|
ensureLabels(): Promise<EnsureLabelsResult>;
|
|
34
42
|
private toIssue;
|
|
35
43
|
private listByLabel;
|
|
@@ -37,7 +45,10 @@ export declare class GitHubProvider implements IssueProvider, CodeHostProvider {
|
|
|
37
45
|
listInState(state: WatchState, opts?: {
|
|
38
46
|
includeAll?: boolean;
|
|
39
47
|
}): Promise<Issue[]>;
|
|
40
|
-
claim(issue: Issue
|
|
48
|
+
claim(issue: Issue, opts?: {
|
|
49
|
+
from?: WatchState;
|
|
50
|
+
to?: WatchState;
|
|
51
|
+
}): Promise<boolean>;
|
|
41
52
|
transition(issue: Issue, to: WatchState, detail?: string): Promise<void>;
|
|
42
53
|
comment(issue: Issue, body: string): Promise<void>;
|
|
43
54
|
openPr(opts: {
|
|
@@ -47,6 +58,21 @@ export declare class GitHubProvider implements IssueProvider, CodeHostProvider {
|
|
|
47
58
|
base: string;
|
|
48
59
|
}): Promise<PrRef>;
|
|
49
60
|
prStatus(pr: PrRef): Promise<PrStatus>;
|
|
61
|
+
/** `IssueAuthoringProvider` — the refine lane's own need (see `provider.ts`'s module doc). */
|
|
62
|
+
createIssue(input: {
|
|
63
|
+
title: string;
|
|
64
|
+
body: string;
|
|
65
|
+
labels: string[];
|
|
66
|
+
}): Promise<Issue>;
|
|
67
|
+
/**
|
|
68
|
+
* `POST /repos/{o}/{r}/issues/{parent_number}/sub_issues` — GitHub's
|
|
69
|
+
* native sub-issue link. Confirmed against GitHub's own REST docs: the
|
|
70
|
+
* body param is `sub_issue_id`, the CHILD's database id, not its issue
|
|
71
|
+
* number — hence `linkChild` requiring `child.internal_id` rather than
|
|
72
|
+
* `child.id`. GitHub's documented limits (not enforced client-side here):
|
|
73
|
+
* 100 sub-issues per parent, 8 levels of nesting.
|
|
74
|
+
*/
|
|
75
|
+
linkChild(parent: Issue, child: Issue): Promise<void>;
|
|
50
76
|
private findMarkerComment;
|
|
51
77
|
readMarker(issue: Issue): Promise<WatchMarker | null>;
|
|
52
78
|
writeMarker(issue: Issue, marker: WatchMarker): Promise<void>;
|