@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
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Microsoft Teams via a Power Automate "Workflows" webhook, posting an
|
|
3
|
+
* Adaptive Card. This is the ONLY supported path: the legacy Office 365
|
|
4
|
+
* connector webhook (a bare `MessageCard`/`@type` POST straight to a
|
|
5
|
+
* channel-configured URL) has been retired by Microsoft. Set up: in the
|
|
6
|
+
* target channel, add a Workflows webhook template (naming has shifted
|
|
7
|
+
* between Microsoft revisions — search for one along the lines of "Post to
|
|
8
|
+
* a channel when a webhook request is received") and copy the generated URL.
|
|
9
|
+
* https://support.microsoft.com/en-us/office/post-a-workflow-when-a-webhook-request-is-received-in-microsoft-teams-8ae491c7-0394-4861-ba59-055e33f75498
|
|
10
|
+
*/
|
|
11
|
+
import type { NotificationChannel, NotifyEvent } from "./channel.ts";
|
|
12
|
+
export declare class TeamsChannel implements NotificationChannel {
|
|
13
|
+
private readonly webhookUrl;
|
|
14
|
+
readonly label: string;
|
|
15
|
+
constructor(webhookUrl: string, name?: string);
|
|
16
|
+
send(event: NotifyEvent, timeoutMs: number): Promise<void>;
|
|
17
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
export class TeamsChannel {
|
|
2
|
+
webhookUrl;
|
|
3
|
+
label;
|
|
4
|
+
constructor(webhookUrl, name = "") {
|
|
5
|
+
this.webhookUrl = webhookUrl;
|
|
6
|
+
this.label = name ? `teams (${name})` : "teams";
|
|
7
|
+
}
|
|
8
|
+
async send(event, timeoutMs) {
|
|
9
|
+
const color = event.level === "error" ? "attention" : "good";
|
|
10
|
+
const facts = event.fields.map(([title, value]) => ({ title, value }));
|
|
11
|
+
const card = {
|
|
12
|
+
type: "AdaptiveCard",
|
|
13
|
+
$schema: "http://adaptivecards.io/schemas/adaptive-card.json",
|
|
14
|
+
version: "1.4",
|
|
15
|
+
body: [
|
|
16
|
+
{ type: "TextBlock", text: event.title, weight: "bolder", size: "medium", color, wrap: true },
|
|
17
|
+
...(event.detail ? [{ type: "TextBlock", text: event.detail.slice(0, 2900), wrap: true }] : []),
|
|
18
|
+
...(facts.length > 0 ? [{ type: "FactSet", facts }] : []),
|
|
19
|
+
],
|
|
20
|
+
...(event.url
|
|
21
|
+
? { actions: [{ type: "Action.OpenUrl", title: "Open", url: event.url }] }
|
|
22
|
+
: {}),
|
|
23
|
+
};
|
|
24
|
+
const body = {
|
|
25
|
+
type: "message",
|
|
26
|
+
attachments: [{ contentType: "application/vnd.microsoft.card.adaptive", contentUrl: null, content: card }],
|
|
27
|
+
};
|
|
28
|
+
const response = await fetch(this.webhookUrl, {
|
|
29
|
+
method: "POST",
|
|
30
|
+
headers: { "Content-Type": "application/json" },
|
|
31
|
+
body: JSON.stringify(body),
|
|
32
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
33
|
+
});
|
|
34
|
+
if (!response.ok) {
|
|
35
|
+
throw new Error(`teams webhook -> ${response.status}: ${(await response.text().catch(() => "")).slice(0, 300)}`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A generic webhook: `POST` the `NotifyEvent` as raw JSON, no vendor shape.
|
|
3
|
+
* Covers Discord/n8n/Zapier/a homegrown receiver with no new channel module
|
|
4
|
+
* each time, and is what `notify.test.ts` posts against a real local
|
|
5
|
+
* `node:http` receiver instead of mocking `fetch`.
|
|
6
|
+
*/
|
|
7
|
+
import type { NotificationChannel, NotifyEvent } from "./channel.ts";
|
|
8
|
+
export declare class WebhookChannel implements NotificationChannel {
|
|
9
|
+
private readonly url;
|
|
10
|
+
readonly label: string;
|
|
11
|
+
constructor(url: string, name?: string);
|
|
12
|
+
send(event: NotifyEvent, timeoutMs: number): Promise<void>;
|
|
13
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export class WebhookChannel {
|
|
2
|
+
url;
|
|
3
|
+
label;
|
|
4
|
+
constructor(url, name = "") {
|
|
5
|
+
this.url = url;
|
|
6
|
+
this.label = name ? `webhook (${name})` : "webhook";
|
|
7
|
+
}
|
|
8
|
+
async send(event, timeoutMs) {
|
|
9
|
+
const response = await fetch(this.url, {
|
|
10
|
+
method: "POST",
|
|
11
|
+
headers: { "Content-Type": "application/json" },
|
|
12
|
+
body: JSON.stringify(event),
|
|
13
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
14
|
+
});
|
|
15
|
+
if (!response.ok) {
|
|
16
|
+
throw new Error(`webhook -> ${response.status}: ${(await response.text().catch(() => "")).slice(0, 300)}`);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { Issue, IssueAuthoringProvider } from "./issues/provider.ts";
|
|
2
|
+
import type { RefinedIssue, SFConfig } from "./data_types.ts";
|
|
3
|
+
export interface PublishedIssue {
|
|
4
|
+
/** The `RefinedIssue.key` this came from — a run-local id, never a tracker id. */
|
|
5
|
+
key: string;
|
|
6
|
+
issue: Issue;
|
|
7
|
+
kind: RefinedIssue["kind"];
|
|
8
|
+
/** A node nothing else names as `parent` — the independently-workable unit `spf:refined` goes on. */
|
|
9
|
+
isLeaf: boolean;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* `IssueAuthoringProvider` has a real implementation only on `GitHubProvider`
|
|
13
|
+
* today — see `jira_provider.ts`'s module comment on why Jira isn't wired up
|
|
14
|
+
* yet. Throws rather than returning `null` so a `code` phase calling this
|
|
15
|
+
* (`steps.publishIssues()`) fails the phase with a clear, specific reason —
|
|
16
|
+
* the same "fail loudly, never silently do nothing" contract
|
|
17
|
+
* `agents.validate()` uses for an unconfigured quality suite.
|
|
18
|
+
*/
|
|
19
|
+
export declare function resolveAuthoringProvider(cfg: SFConfig): IssueAuthoringProvider;
|
|
20
|
+
export interface PublishOptions {
|
|
21
|
+
labelPrefix: string;
|
|
22
|
+
/** The originating spec issue's id, for every created issue's `## Parent` back-reference. `null`/omitted for a manual run with no source issue. */
|
|
23
|
+
specIssueId?: string | null;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Create every node in `issues`, in dependency order, with its
|
|
27
|
+
* `<prefix>:type:<kind>` label (plus `<prefix>:refined` on leaves only —
|
|
28
|
+
* see `WatchState`'s doc comment in `provider.ts`), link each to its parent
|
|
29
|
+
* via the tracker's native hierarchy, and render real `#n` references into
|
|
30
|
+
* `## Blocked by`. Returns what it created, in creation order.
|
|
31
|
+
*
|
|
32
|
+
* Not transactional: if a create or link call throws partway through, the
|
|
33
|
+
* nodes already published stay published, orphaned from whatever hadn't run
|
|
34
|
+
* yet. No rollback is attempted — the gate already ran, so this only fails
|
|
35
|
+
* on a live tracker error (rate limit, network), which a human re-running
|
|
36
|
+
* the spec (once the marker's `refined` list explains what already exists)
|
|
37
|
+
* can sort out same as any other `spf watch` failure.
|
|
38
|
+
*/
|
|
39
|
+
export declare function publish(tracker: IssueAuthoringProvider, issues: RefinedIssue[], opts: PublishOptions): Promise<PublishedIssue[]>;
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Publish logic for the refine lane: turn a gated `RefineOutput.issues` list
|
|
3
|
+
* (a product spec decomposed into a feature/story tree — see
|
|
4
|
+
* `RefinedIssueSchema` in `data_types.ts`) into real tracker issues, in
|
|
5
|
+
* dependency order, with the right labels and parent/child links. Nothing
|
|
6
|
+
* else: no marker bookkeeping, no `transition()`, no comment posted back
|
|
7
|
+
* onto the spec issue. Those are watch-lifecycle concerns `core/watch.ts`'s
|
|
8
|
+
* `runSpec` owns, exactly the way `runIssue` (the build lane) owns
|
|
9
|
+
* `openPr`/`transition` rather than a chain step doing it — this file is
|
|
10
|
+
* "given issues and a tracker, create them correctly," the one part
|
|
11
|
+
* `to-tickets` (the skill this lane's prompt is ported from) leaves
|
|
12
|
+
* entirely unspecified, and the part that makes a re-run duplicate every
|
|
13
|
+
* ticket (see `WatchMarker.refined`'s doc comment in `provider.ts` for how
|
|
14
|
+
* `runSpec` closes that gap using what this module returns).
|
|
15
|
+
*/
|
|
16
|
+
import { GitHubProvider } from "./issues/github_provider.js";
|
|
17
|
+
/**
|
|
18
|
+
* `IssueAuthoringProvider` has a real implementation only on `GitHubProvider`
|
|
19
|
+
* today — see `jira_provider.ts`'s module comment on why Jira isn't wired up
|
|
20
|
+
* yet. Throws rather than returning `null` so a `code` phase calling this
|
|
21
|
+
* (`steps.publishIssues()`) fails the phase with a clear, specific reason —
|
|
22
|
+
* the same "fail loudly, never silently do nothing" contract
|
|
23
|
+
* `agents.validate()` uses for an unconfigured quality suite.
|
|
24
|
+
*/
|
|
25
|
+
export function resolveAuthoringProvider(cfg) {
|
|
26
|
+
if (cfg.watch.issue_provider !== "github") {
|
|
27
|
+
throw new Error(`watch.issue_provider ${JSON.stringify(cfg.watch.issue_provider)} does not support issue authoring — ` +
|
|
28
|
+
`the refine lane needs "github" (see jira_provider.ts's module comment on why Jira isn't wired up yet)`);
|
|
29
|
+
}
|
|
30
|
+
if (!cfg.watch.repo.trim()) {
|
|
31
|
+
throw new Error(`watch.repo is not configured — add it to spf.config.yaml's watch: section, e.g. "owner/name"`);
|
|
32
|
+
}
|
|
33
|
+
const token = process.env["GITHUB_TOKEN"];
|
|
34
|
+
if (!token) {
|
|
35
|
+
throw new Error('GITHUB_TOKEN is not set — the refine lane needs a classic PAT with "repo" scope (or "public_repo" for a public-only repo)');
|
|
36
|
+
}
|
|
37
|
+
return new GitHubProvider(cfg.watch.repo, cfg.watch.label_prefix, token);
|
|
38
|
+
}
|
|
39
|
+
function typeLabel(labelPrefix, kind) {
|
|
40
|
+
return `${labelPrefix}:type:${kind}`;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* The body GitHub actually stores: the refiner's own `## What to build` /
|
|
44
|
+
* `## Acceptance criteria` text, plus a `## Parent` back-reference to the
|
|
45
|
+
* source spec (when there is one — a bare `spf refine` run with no
|
|
46
|
+
* `--issue` has none), plus a `## Blocked by` section with real `#n`
|
|
47
|
+
* references — `to-tickets`' own template shape, ported. Every `blocked_by`
|
|
48
|
+
* key is guaranteed to already be in `byKey` by the time this runs:
|
|
49
|
+
* `topoOrder` visits a node's dependencies before the node itself.
|
|
50
|
+
*/
|
|
51
|
+
function renderBody(node, byKey, specIssueId) {
|
|
52
|
+
const parts = [node.body.trim()];
|
|
53
|
+
if (specIssueId)
|
|
54
|
+
parts.push(`## Parent\n\nDecomposed from #${specIssueId}.`);
|
|
55
|
+
if (node.blocked_by.length > 0) {
|
|
56
|
+
const refs = node.blocked_by.map((key) => {
|
|
57
|
+
const published = byKey.get(key);
|
|
58
|
+
// Defensive only: gates.refinementWellFormed already rejects a
|
|
59
|
+
// blocked_by key that doesn't resolve to another node in the list.
|
|
60
|
+
return published ? `#${published.issue.id}` : key;
|
|
61
|
+
});
|
|
62
|
+
parts.push(`## Blocked by\n\n${refs.map((r) => `- ${r}`).join("\n")}`);
|
|
63
|
+
}
|
|
64
|
+
else {
|
|
65
|
+
parts.push(`## Blocked by\n\nNone (can start immediately).`);
|
|
66
|
+
}
|
|
67
|
+
return parts.join("\n\n");
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Order nodes so every `parent` and every `blocked_by` reference is already
|
|
71
|
+
* published (a real issue, in `byKey`) before the node that names it —
|
|
72
|
+
* required both for the sub-issue link (the parent must exist first) and
|
|
73
|
+
* for `renderBody`'s `#n` references. `gates.refinementWellFormed` has
|
|
74
|
+
* already rejected a cyclic input by the time `publish()` ever runs; the
|
|
75
|
+
* `visiting` check here is a defensive backstop against a caller that
|
|
76
|
+
* skipped the gate, not the primary line of defense.
|
|
77
|
+
*/
|
|
78
|
+
function topoOrder(issues) {
|
|
79
|
+
const byKey = new Map(issues.map((i) => [i.key, i]));
|
|
80
|
+
const ordered = [];
|
|
81
|
+
const done = new Set();
|
|
82
|
+
const visiting = new Set();
|
|
83
|
+
function visit(node) {
|
|
84
|
+
if (done.has(node.key))
|
|
85
|
+
return;
|
|
86
|
+
if (visiting.has(node.key)) {
|
|
87
|
+
throw new Error(`refine: dependency cycle detected at key ${JSON.stringify(node.key)} — gates.refinementWellFormed should have caught this before publish ran`);
|
|
88
|
+
}
|
|
89
|
+
visiting.add(node.key);
|
|
90
|
+
for (const depKey of [node.parent, ...node.blocked_by]) {
|
|
91
|
+
if (!depKey)
|
|
92
|
+
continue;
|
|
93
|
+
const dep = byKey.get(depKey);
|
|
94
|
+
if (dep)
|
|
95
|
+
visit(dep);
|
|
96
|
+
}
|
|
97
|
+
visiting.delete(node.key);
|
|
98
|
+
done.add(node.key);
|
|
99
|
+
ordered.push(node);
|
|
100
|
+
}
|
|
101
|
+
for (const issue of issues)
|
|
102
|
+
visit(issue);
|
|
103
|
+
return ordered;
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Create every node in `issues`, in dependency order, with its
|
|
107
|
+
* `<prefix>:type:<kind>` label (plus `<prefix>:refined` on leaves only —
|
|
108
|
+
* see `WatchState`'s doc comment in `provider.ts`), link each to its parent
|
|
109
|
+
* via the tracker's native hierarchy, and render real `#n` references into
|
|
110
|
+
* `## Blocked by`. Returns what it created, in creation order.
|
|
111
|
+
*
|
|
112
|
+
* Not transactional: if a create or link call throws partway through, the
|
|
113
|
+
* nodes already published stay published, orphaned from whatever hadn't run
|
|
114
|
+
* yet. No rollback is attempted — the gate already ran, so this only fails
|
|
115
|
+
* on a live tracker error (rate limit, network), which a human re-running
|
|
116
|
+
* the spec (once the marker's `refined` list explains what already exists)
|
|
117
|
+
* can sort out same as any other `spf watch` failure.
|
|
118
|
+
*/
|
|
119
|
+
export async function publish(tracker, issues, opts) {
|
|
120
|
+
const ordered = topoOrder(issues);
|
|
121
|
+
const childKeys = new Set(issues.filter((i) => i.parent).map((i) => i.parent));
|
|
122
|
+
const byKey = new Map();
|
|
123
|
+
const created = [];
|
|
124
|
+
for (const node of ordered) {
|
|
125
|
+
const isLeaf = !childKeys.has(node.key);
|
|
126
|
+
const labels = [typeLabel(opts.labelPrefix, node.kind)];
|
|
127
|
+
if (isLeaf)
|
|
128
|
+
labels.push(`${opts.labelPrefix}:refined`);
|
|
129
|
+
const body = renderBody(node, byKey, opts.specIssueId);
|
|
130
|
+
const issue = await tracker.createIssue({ title: node.title, body, labels });
|
|
131
|
+
const published = { key: node.key, issue, kind: node.kind, isLeaf };
|
|
132
|
+
byKey.set(node.key, published);
|
|
133
|
+
created.push(published);
|
|
134
|
+
if (node.parent) {
|
|
135
|
+
const parent = byKey.get(node.parent);
|
|
136
|
+
// topoOrder guarantees the parent was visited (and thus published)
|
|
137
|
+
// first; a missing entry here would mean the gate let an
|
|
138
|
+
// unresolved parent through, which refinementWellFormed rejects.
|
|
139
|
+
if (parent)
|
|
140
|
+
await tracker.linkChild(parent.issue, issue);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return created;
|
|
144
|
+
}
|
package/dist/core/runner.d.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { type GitHandle } from "./git_helper.ts";
|
|
|
12
12
|
import { Console } from "./console.ts";
|
|
13
13
|
import { Tracer } from "./tracer.ts";
|
|
14
14
|
import { type AgentCall, type EnvelopeBase, type Phase, type PhaseParams, type SFConfig } from "./data_types.ts";
|
|
15
|
+
import type { Notifier } from "./notify/notifier.ts";
|
|
15
16
|
interface AgentMapEntry {
|
|
16
17
|
session_id: string;
|
|
17
18
|
model: string;
|
|
@@ -32,12 +33,18 @@ export interface RunInit {
|
|
|
32
33
|
sfDir: string | null;
|
|
33
34
|
/** Absolute. Resolved once, upstream, by paths.resolveDataPaths(). */
|
|
34
35
|
dataDir: string;
|
|
36
|
+
/** The CLI chain name, for a notification's title — see session.ts. */
|
|
37
|
+
chainName?: string;
|
|
38
|
+
/** `null`/omitted when notifications are off (the default) or no channel resolved. */
|
|
39
|
+
notifier?: Notifier | null;
|
|
35
40
|
}
|
|
36
41
|
export declare class Run {
|
|
37
42
|
cfg: SFConfig;
|
|
38
43
|
adw_id: string;
|
|
39
44
|
tracer: Tracer;
|
|
40
45
|
console: Console;
|
|
46
|
+
/** `null` when notifications are off — `run.notify?.send(...)` at any future call site. */
|
|
47
|
+
notify: Notifier | null;
|
|
41
48
|
engineer: string;
|
|
42
49
|
phases: Phase[];
|
|
43
50
|
tokens: number;
|
package/dist/core/runner.js
CHANGED
|
@@ -48,6 +48,8 @@ export class Run {
|
|
|
48
48
|
adw_id;
|
|
49
49
|
tracer;
|
|
50
50
|
console;
|
|
51
|
+
/** `null` when notifications are off — `run.notify?.send(...)` at any future call site. */
|
|
52
|
+
notify;
|
|
51
53
|
engineer;
|
|
52
54
|
phases = [];
|
|
53
55
|
tokens = 0;
|
|
@@ -68,7 +70,8 @@ export class Run {
|
|
|
68
70
|
this.cfg = init.cfg;
|
|
69
71
|
this.adw_id = init.adwId;
|
|
70
72
|
this.tracer = init.tracer;
|
|
71
|
-
this.
|
|
73
|
+
this.notify = init.notifier ?? null;
|
|
74
|
+
this.console = new Console(init.tracer, init.adwId, this.notify, init.chainName || "adw");
|
|
72
75
|
this.engineer = init.engineer;
|
|
73
76
|
this.seq = init.tracer.maxPhaseSeq(init.adwId);
|
|
74
77
|
this.repo_root = init.repoRoot;
|
package/dist/core/session.js
CHANGED
|
@@ -10,6 +10,7 @@ import * as paths from "./paths.js";
|
|
|
10
10
|
import { Run } from "./runner.js";
|
|
11
11
|
import { Tracer } from "./tracer.js";
|
|
12
12
|
import { engineerName, newId } from "./utils.js";
|
|
13
|
+
import { resolveNotifier } from "./notify/notifier.js";
|
|
13
14
|
/**
|
|
14
15
|
* A killed run still closes its own trace.
|
|
15
16
|
*
|
|
@@ -52,6 +53,8 @@ export function ensure(cfg, adwId, cwd, chainName) {
|
|
|
52
53
|
repoRoot: anchor.repo_root,
|
|
53
54
|
sfDir: anchor.spf_dir,
|
|
54
55
|
dataDir: dataPaths.data_dir,
|
|
56
|
+
chainName: chainName || "adw",
|
|
57
|
+
notifier: resolveNotifier(cfg),
|
|
55
58
|
});
|
|
56
59
|
const scriptPath = process.argv[1] || "adw";
|
|
57
60
|
tracer.sessionStart(id, run.engineer, chainName || "adw");
|
package/dist/core/watch.d.ts
CHANGED
|
@@ -1,11 +1,27 @@
|
|
|
1
1
|
import type { GitHandle } from "./git_helper.ts";
|
|
2
2
|
import type { CodeHostProvider, Issue, IssueProvider } from "./issues/provider.ts";
|
|
3
|
+
import type { NotifyEvent } from "./notify/channel.ts";
|
|
3
4
|
export interface ChainRunResult {
|
|
4
5
|
accepted: boolean;
|
|
5
6
|
adwId: string;
|
|
6
7
|
/** Shown to the engineer via a `blocked` comment on a failed/no-op run. */
|
|
7
8
|
detail: string;
|
|
8
9
|
}
|
|
10
|
+
/** One issue the refine lane created — enough for `finishSpec`'s summary comment and the marker's idempotency record. */
|
|
11
|
+
export interface RefinedIssueRef {
|
|
12
|
+
id: string;
|
|
13
|
+
title: string;
|
|
14
|
+
kind: string;
|
|
15
|
+
isLeaf: boolean;
|
|
16
|
+
}
|
|
17
|
+
export interface RefineRunResult {
|
|
18
|
+
accepted: boolean;
|
|
19
|
+
adwId: string;
|
|
20
|
+
/** Shown to the engineer via a `blocked` comment on a failed/no-op run. */
|
|
21
|
+
detail: string;
|
|
22
|
+
/** What `steps.publishIssues()` created, read back from its side-channel file — see `cli/commands/watch.ts`'s `runRefine`. Empty when `!accepted`. */
|
|
23
|
+
created: RefinedIssueRef[];
|
|
24
|
+
}
|
|
9
25
|
export interface WatchDeps {
|
|
10
26
|
provider: IssueProvider;
|
|
11
27
|
codeHost: CodeHostProvider;
|
|
@@ -16,6 +32,23 @@ export interface WatchDeps {
|
|
|
16
32
|
chain: string;
|
|
17
33
|
baseBranch: string;
|
|
18
34
|
concurrency: number;
|
|
35
|
+
/**
|
|
36
|
+
* The second lane — decomposing a `<prefix>:spec-ready` product spec
|
|
37
|
+
* instead of building a `<prefix>:ready` issue. `false` (the default) is
|
|
38
|
+
* a complete no-op: `claimSpecs`/`reconcileRefining` return immediately,
|
|
39
|
+
* so an existing `spf watch` config sees no new poll traffic at all until
|
|
40
|
+
* this is turned on. See `WatchConfigSchema`'s `refine` field.
|
|
41
|
+
*/
|
|
42
|
+
refineEnabled: boolean;
|
|
43
|
+
refineConcurrency: number;
|
|
44
|
+
refineChain: string;
|
|
45
|
+
/** Same shape as `runChain`, for the refine lane — see its own doc comment for why the two aren't unified into one callback. */
|
|
46
|
+
runRefine: (opts: {
|
|
47
|
+
prompt: string;
|
|
48
|
+
cwd: string;
|
|
49
|
+
adwId: string;
|
|
50
|
+
issueId: string;
|
|
51
|
+
}) => Promise<RefineRunResult>;
|
|
19
52
|
worktreesDir: string;
|
|
20
53
|
/**
|
|
21
54
|
* Symlink (or otherwise wire up) `<worktreePath>/.spf/data` to the MAIN
|
|
@@ -36,12 +69,34 @@ export interface WatchDeps {
|
|
|
36
69
|
adwId: string;
|
|
37
70
|
}) => Promise<ChainRunResult>;
|
|
38
71
|
log: (message: string) => void;
|
|
72
|
+
/**
|
|
73
|
+
* Structured push, alongside `log`'s plain string — a required field, like
|
|
74
|
+
* `log`, so a test must consciously supply one (a no-op fake is fine).
|
|
75
|
+
* Fired only at meaningful state transitions (claim, PR, done, blocked,
|
|
76
|
+
* error) — NOT at routine self-healing (an orphan resume/retry, a lost
|
|
77
|
+
* claim race, a cleanup warning), which recovers on its own and would
|
|
78
|
+
* just be noise in a channel.
|
|
79
|
+
*/
|
|
80
|
+
notify: (event: NotifyEvent) => void;
|
|
39
81
|
}
|
|
40
82
|
export interface WatchRunState {
|
|
41
83
|
inflight: Set<string>;
|
|
84
|
+
/**
|
|
85
|
+
* Separate from `inflight` — not a defensive copy of the same set, a
|
|
86
|
+
* genuinely different budget. The refine lane's `claimSpecs` caps against
|
|
87
|
+
* `refineConcurrency`, independent of the build lane's `concurrency`; a
|
|
88
|
+
* shared set would conflate "how many specs are being refined" with "how
|
|
89
|
+
* many issues are being built" and make either budget impossible to
|
|
90
|
+
* enforce on its own. The two never collide on an id in practice (a
|
|
91
|
+
* `spec-ready` issue and a `ready` issue are never the same issue), but
|
|
92
|
+
* that isn't why this is separate — the budgets are what require it.
|
|
93
|
+
*/
|
|
94
|
+
refining: Set<string>;
|
|
42
95
|
}
|
|
43
96
|
export declare function createWatchState(): WatchRunState;
|
|
44
97
|
export declare function branchNameFor(issue: Issue): string;
|
|
98
|
+
/** Same idea as `branchNameFor`, for the refine lane's throwaway worktree — a spec never gets a PR, so this branch is only ever fetched-from-and-thrown-away, never pushed. */
|
|
99
|
+
export declare function refineBranchNameFor(issue: Issue): string;
|
|
45
100
|
/**
|
|
46
101
|
* Any issue labeled `working` that THIS process isn't tracking is an
|
|
47
102
|
* orphan — a daemon restart, or another instance's claim this process
|
|
@@ -49,9 +104,19 @@ export declare function branchNameFor(issue: Issue): string;
|
|
|
49
104
|
* merged) PR; otherwise retry up to `MAX_ORPHAN_ATTEMPTS`, then give up.
|
|
50
105
|
*/
|
|
51
106
|
export declare function reconcileOrphans(deps: WatchDeps, state: WatchRunState): Promise<void>;
|
|
107
|
+
/**
|
|
108
|
+
* The refine lane's own `reconcileOrphans` — a `refining`-labeled spec this
|
|
109
|
+
* process isn't tracking is either a completed publish that crashed before
|
|
110
|
+
* its own `transition(issue, "done")` ran (resume: finish it, no re-run),
|
|
111
|
+
* or a genuine orphan (retry up to `MAX_ORPHAN_ATTEMPTS`, then give up).
|
|
112
|
+
* A no-op entirely when `watch.refine` is off — see `WatchDeps.refineEnabled`.
|
|
113
|
+
*/
|
|
114
|
+
export declare function reconcileRefining(deps: WatchDeps, state: WatchRunState): Promise<void>;
|
|
52
115
|
/** Poll every `review`-labeled issue's PR for merged (-> done) or closed-without-merging (-> blocked). */
|
|
53
116
|
export declare function finishReviews(deps: WatchDeps): Promise<void>;
|
|
54
117
|
/** Claim as many `ready` issues as the concurrency budget allows, and kick off `runIssue` for each in the background. */
|
|
55
118
|
export declare function claimNewWork(deps: WatchDeps, state: WatchRunState): Promise<void>;
|
|
56
|
-
/**
|
|
119
|
+
/** Claim as many `spec-ready` specs as `refineConcurrency` allows, and kick off `runSpec` for each in the background. A no-op when `watch.refine` is off. */
|
|
120
|
+
export declare function claimSpecs(deps: WatchDeps, state: WatchRunState): Promise<void>;
|
|
121
|
+
/** One poll tick: reconcile both lanes, finish reviews, then claim both lanes — each stage independently caught, so one stage's error never blocks the rest. */
|
|
57
122
|
export declare function tick(deps: WatchDeps, state: WatchRunState): Promise<void>;
|