@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
package/dist/core/providers.js
CHANGED
|
@@ -21,4 +21,8 @@ export const PROVIDER_ENV_KEYS = {
|
|
|
21
21
|
deepseek: ["DEEPSEEK_API_KEY"],
|
|
22
22
|
together: ["TOGETHER_API_KEY"],
|
|
23
23
|
cerebras: ["CEREBRAS_API_KEY"],
|
|
24
|
+
// Keyless: a local server, not a hosted API — nothing to check for or
|
|
25
|
+
// prompt for. An empty array here means "known provider, needs no key",
|
|
26
|
+
// never "unknown provider" (that's a missing table entry, not `[]`).
|
|
27
|
+
ollama: [],
|
|
24
28
|
};
|
|
@@ -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,152 @@
|
|
|
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
|
+
// Issue authoring always targets the ISSUE tracker's repo — `issue_repo`
|
|
31
|
+
// if set, falling back to plain `repo` (the common case: issue_provider
|
|
32
|
+
// and code_host are both github, so they're the same repo). Reading
|
|
33
|
+
// `repo` alone would be wrong for a github-issues + bitbucket-code setup,
|
|
34
|
+
// where `repo` names the BITBUCKET repo (see WatchConfigSchema's doc
|
|
35
|
+
// comment) — this would try to create GitHub issues against a Bitbucket
|
|
36
|
+
// identifier.
|
|
37
|
+
const repo = cfg.watch.issue_repo.trim() || cfg.watch.repo.trim();
|
|
38
|
+
if (!repo) {
|
|
39
|
+
throw new Error(`watch.repo (or watch.issue_repo, if code_host names a different repo) is not configured — add it to spf.config.yaml's watch: section, e.g. "owner/name"`);
|
|
40
|
+
}
|
|
41
|
+
const token = process.env["GITHUB_TOKEN"];
|
|
42
|
+
if (!token) {
|
|
43
|
+
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)');
|
|
44
|
+
}
|
|
45
|
+
return new GitHubProvider(repo, cfg.watch.label_prefix, token);
|
|
46
|
+
}
|
|
47
|
+
function typeLabel(labelPrefix, kind) {
|
|
48
|
+
return `${labelPrefix}:type:${kind}`;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* The body GitHub actually stores: the refiner's own `## What to build` /
|
|
52
|
+
* `## Acceptance criteria` text, plus a `## Parent` back-reference to the
|
|
53
|
+
* source spec (when there is one — a bare `spf refine` run with no
|
|
54
|
+
* `--issue` has none), plus a `## Blocked by` section with real `#n`
|
|
55
|
+
* references — `to-tickets`' own template shape, ported. Every `blocked_by`
|
|
56
|
+
* key is guaranteed to already be in `byKey` by the time this runs:
|
|
57
|
+
* `topoOrder` visits a node's dependencies before the node itself.
|
|
58
|
+
*/
|
|
59
|
+
function renderBody(node, byKey, specIssueId) {
|
|
60
|
+
const parts = [node.body.trim()];
|
|
61
|
+
if (specIssueId)
|
|
62
|
+
parts.push(`## Parent\n\nDecomposed from #${specIssueId}.`);
|
|
63
|
+
if (node.blocked_by.length > 0) {
|
|
64
|
+
const refs = node.blocked_by.map((key) => {
|
|
65
|
+
const published = byKey.get(key);
|
|
66
|
+
// Defensive only: gates.refinementWellFormed already rejects a
|
|
67
|
+
// blocked_by key that doesn't resolve to another node in the list.
|
|
68
|
+
return published ? `#${published.issue.id}` : key;
|
|
69
|
+
});
|
|
70
|
+
parts.push(`## Blocked by\n\n${refs.map((r) => `- ${r}`).join("\n")}`);
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
73
|
+
parts.push(`## Blocked by\n\nNone (can start immediately).`);
|
|
74
|
+
}
|
|
75
|
+
return parts.join("\n\n");
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Order nodes so every `parent` and every `blocked_by` reference is already
|
|
79
|
+
* published (a real issue, in `byKey`) before the node that names it —
|
|
80
|
+
* required both for the sub-issue link (the parent must exist first) and
|
|
81
|
+
* for `renderBody`'s `#n` references. `gates.refinementWellFormed` has
|
|
82
|
+
* already rejected a cyclic input by the time `publish()` ever runs; the
|
|
83
|
+
* `visiting` check here is a defensive backstop against a caller that
|
|
84
|
+
* skipped the gate, not the primary line of defense.
|
|
85
|
+
*/
|
|
86
|
+
function topoOrder(issues) {
|
|
87
|
+
const byKey = new Map(issues.map((i) => [i.key, i]));
|
|
88
|
+
const ordered = [];
|
|
89
|
+
const done = new Set();
|
|
90
|
+
const visiting = new Set();
|
|
91
|
+
function visit(node) {
|
|
92
|
+
if (done.has(node.key))
|
|
93
|
+
return;
|
|
94
|
+
if (visiting.has(node.key)) {
|
|
95
|
+
throw new Error(`refine: dependency cycle detected at key ${JSON.stringify(node.key)} — gates.refinementWellFormed should have caught this before publish ran`);
|
|
96
|
+
}
|
|
97
|
+
visiting.add(node.key);
|
|
98
|
+
for (const depKey of [node.parent, ...node.blocked_by]) {
|
|
99
|
+
if (!depKey)
|
|
100
|
+
continue;
|
|
101
|
+
const dep = byKey.get(depKey);
|
|
102
|
+
if (dep)
|
|
103
|
+
visit(dep);
|
|
104
|
+
}
|
|
105
|
+
visiting.delete(node.key);
|
|
106
|
+
done.add(node.key);
|
|
107
|
+
ordered.push(node);
|
|
108
|
+
}
|
|
109
|
+
for (const issue of issues)
|
|
110
|
+
visit(issue);
|
|
111
|
+
return ordered;
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Create every node in `issues`, in dependency order, with its
|
|
115
|
+
* `<prefix>:type:<kind>` label (plus `<prefix>:refined` on leaves only —
|
|
116
|
+
* see `WatchState`'s doc comment in `provider.ts`), link each to its parent
|
|
117
|
+
* via the tracker's native hierarchy, and render real `#n` references into
|
|
118
|
+
* `## Blocked by`. Returns what it created, in creation order.
|
|
119
|
+
*
|
|
120
|
+
* Not transactional: if a create or link call throws partway through, the
|
|
121
|
+
* nodes already published stay published, orphaned from whatever hadn't run
|
|
122
|
+
* yet. No rollback is attempted — the gate already ran, so this only fails
|
|
123
|
+
* on a live tracker error (rate limit, network), which a human re-running
|
|
124
|
+
* the spec (once the marker's `refined` list explains what already exists)
|
|
125
|
+
* can sort out same as any other `spf watch` failure.
|
|
126
|
+
*/
|
|
127
|
+
export async function publish(tracker, issues, opts) {
|
|
128
|
+
const ordered = topoOrder(issues);
|
|
129
|
+
const childKeys = new Set(issues.filter((i) => i.parent).map((i) => i.parent));
|
|
130
|
+
const byKey = new Map();
|
|
131
|
+
const created = [];
|
|
132
|
+
for (const node of ordered) {
|
|
133
|
+
const isLeaf = !childKeys.has(node.key);
|
|
134
|
+
const labels = [typeLabel(opts.labelPrefix, node.kind)];
|
|
135
|
+
if (isLeaf)
|
|
136
|
+
labels.push(`${opts.labelPrefix}:refined`);
|
|
137
|
+
const body = renderBody(node, byKey, opts.specIssueId);
|
|
138
|
+
const issue = await tracker.createIssue({ title: node.title, body, labels });
|
|
139
|
+
const published = { key: node.key, issue, kind: node.kind, isLeaf };
|
|
140
|
+
byKey.set(node.key, published);
|
|
141
|
+
created.push(published);
|
|
142
|
+
if (node.parent) {
|
|
143
|
+
const parent = byKey.get(node.parent);
|
|
144
|
+
// topoOrder guarantees the parent was visited (and thus published)
|
|
145
|
+
// first; a missing entry here would mean the gate let an
|
|
146
|
+
// unresolved parent through, which refinementWellFormed rejects.
|
|
147
|
+
if (parent)
|
|
148
|
+
await tracker.linkChild(parent.issue, issue);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return created;
|
|
152
|
+
}
|
package/dist/core/session.js
CHANGED
|
@@ -11,6 +11,15 @@ import { Run } from "./runner.js";
|
|
|
11
11
|
import { Tracer } from "./tracer.js";
|
|
12
12
|
import { engineerName, newId } from "./utils.js";
|
|
13
13
|
import { resolveNotifier } from "./notify/notifier.js";
|
|
14
|
+
import * as otel from "./otel.js";
|
|
15
|
+
/**
|
|
16
|
+
* How long a signalled run may spend pushing spans before it exits anyway.
|
|
17
|
+
* Short on purpose: someone who just pressed ^C is waiting, and an
|
|
18
|
+
* observability projection is never worth making a kill feel broken. The
|
|
19
|
+
* budget is enforced inside `otel.flushAll()` (a raced, unref'd deadline), so
|
|
20
|
+
* an unreachable collector costs exactly this and not one tick more.
|
|
21
|
+
*/
|
|
22
|
+
const SIGNAL_DRAIN_MS = 750;
|
|
14
23
|
/**
|
|
15
24
|
* A killed run still closes its own trace.
|
|
16
25
|
*
|
|
@@ -20,11 +29,31 @@ import { resolveNotifier } from "./notify/notifier.js";
|
|
|
20
29
|
* flight that is already dead. Handling the signal both finalizes here and
|
|
21
30
|
* lets the phase's try/catch record the phase as failed on the way out
|
|
22
31
|
* (best-effort: a signal can still land mid-write).
|
|
32
|
+
*
|
|
33
|
+
* SQLite is written FIRST and synchronously, exactly as before — the otel
|
|
34
|
+
* drain is appended after it and can only ever cost time, never correctness.
|
|
35
|
+
* (`notify` has no equivalent drain on this path: its in-flight webhooks are
|
|
36
|
+
* dropped on a signal today. Fixing that means touching the notifier's
|
|
37
|
+
* lifecycle, which is outside this change; only the otel path is drained here.)
|
|
38
|
+
* A second signal during the drain exits immediately — someone pressing ^C
|
|
39
|
+
* twice means "now", and a shutdown path that ignores that is a hang.
|
|
23
40
|
*/
|
|
24
41
|
function finalizeWhenKilled(run) {
|
|
42
|
+
let draining = false;
|
|
25
43
|
const handler = (signal) => {
|
|
44
|
+
const code = 128 + (signal === "SIGINT" ? 2 : 15);
|
|
45
|
+
if (draining)
|
|
46
|
+
process.exit(code);
|
|
47
|
+
draining = true;
|
|
26
48
|
run.tracer.sessionFinish(run.adw_id, false); // also closes process rows
|
|
27
|
-
|
|
49
|
+
// Unconfigured (the default) exits SYNCHRONOUSLY, exactly as it did before
|
|
50
|
+
// otel existed — no extra tick between the signal and the exit for the
|
|
51
|
+
// repos that never opted in.
|
|
52
|
+
if (!run.tracer.otel)
|
|
53
|
+
process.exit(code);
|
|
54
|
+
// Bounded and never-throwing: flushAll() swallows its own failures and
|
|
55
|
+
// resolves on its own deadline, so this always reaches process.exit().
|
|
56
|
+
void otel.flushAll(SIGNAL_DRAIN_MS).then(() => process.exit(code), () => process.exit(code));
|
|
28
57
|
};
|
|
29
58
|
process.on("SIGTERM", handler);
|
|
30
59
|
process.on("SIGINT", handler);
|
|
@@ -44,7 +73,15 @@ export function ensure(cfg, adwId, cwd, chainName) {
|
|
|
44
73
|
const id = adwId || newId(8);
|
|
45
74
|
const anchor = paths.resolveAnchor(cwd);
|
|
46
75
|
const dataPaths = paths.resolveDataPaths(anchor, cfg.defaults.data_dir, cfg.observability.db);
|
|
47
|
-
|
|
76
|
+
// `null` unless `observability.otel` is configured — no environment variable
|
|
77
|
+
// can turn this on (see core/otel.ts's EXPLICIT CONFIG ONLY). Constructed
|
|
78
|
+
// BEFORE the Tracer because the Tracer's write methods are the fan-out
|
|
79
|
+
// seams: SQLite stays the source of truth, otel is a projection off it, and
|
|
80
|
+
// registering here (module-level LIVE, exactly like resolveNotifier) is what
|
|
81
|
+
// lets the CLI's finally block and the signal handler above drain it without
|
|
82
|
+
// threading a handle through every call site.
|
|
83
|
+
const otelExporter = otel.resolveOtelExporter(cfg, { adwId: id, chainName: chainName || "adw" });
|
|
84
|
+
const tracer = new Tracer(dataPaths.db_path, path.join(dataPaths.sessions_dir, id, "events.jsonl"), otelExporter);
|
|
48
85
|
const run = new Run({
|
|
49
86
|
cfg,
|
|
50
87
|
adwId: id,
|
package/dist/core/tracer.d.ts
CHANGED
|
@@ -2,18 +2,47 @@
|
|
|
2
2
|
* Tracer: every event lands in JSONL and SQLite AS IT HAPPENS.
|
|
3
3
|
*
|
|
4
4
|
* Files are the raw record; spf.db is the queryable mirror the UI polls.
|
|
5
|
-
* No push transport — the flow is always: agents -> sqlite -> web ui.
|
|
6
5
|
* WAL mode so the UI can read while ADW processes write.
|
|
6
|
+
*
|
|
7
|
+
* No push transport in the CONTROL flow — that is always, still, and only:
|
|
8
|
+
* agents -> sqlite -> web ui. SQLite is the source of truth; nothing
|
|
9
|
+
* downstream of it can affect a phase, a gate, or a run outcome.
|
|
10
|
+
*
|
|
11
|
+
* The one amendment: when (and only when) `observability.otel` is configured,
|
|
12
|
+
* each write method below ends with a single fan-out line to an optional
|
|
13
|
+
* OtelExporter — a lossy, allowlisted PROJECTION of what was just written,
|
|
14
|
+
* pushed to an OTLP endpoint fire-and-forget. It is deliberately NOT a second
|
|
15
|
+
* record: it never blocks, never throws into a caller (see `fanOut`), drops
|
|
16
|
+
* spans under backpressure, and carries only the allowlisted subset of fields
|
|
17
|
+
* (never `EventRecord.payload`, never the request text, never envelope
|
|
18
|
+
* contents — `core/otel.ts`'s header has the full list and the reasons).
|
|
19
|
+
* Methods whose data is entirely outside that allowlist —
|
|
20
|
+
* `sessionRequest` (the operator's prompt), `envelopeRow` (agent output),
|
|
21
|
+
* `processStart`/`processEnd` (pids) — have NO fan-out line on purpose. Do not
|
|
22
|
+
* add one.
|
|
7
23
|
*/
|
|
8
24
|
import { Database } from "./sqlite.ts";
|
|
9
25
|
import type { AgentConfig, EventRecord, GateReport, Phase } from "./data_types.ts";
|
|
26
|
+
import type { OtelExporter } from "./otel.ts";
|
|
10
27
|
export declare class Tracer {
|
|
11
28
|
db: Database;
|
|
12
29
|
dbPath: string;
|
|
13
30
|
eventsJsonl: string;
|
|
14
|
-
|
|
31
|
+
/** `null` unless `observability.otel` is configured — see the header. */
|
|
32
|
+
otel: OtelExporter | null;
|
|
33
|
+
constructor(dbPath: string, eventsJsonl: string, otel?: OtelExporter | null);
|
|
15
34
|
/** Additive column migrations, so a db from an older SPF still opens. */
|
|
16
35
|
private migrate;
|
|
36
|
+
/**
|
|
37
|
+
* The ONE door to the optional otel projection, and the only reason a fan-out
|
|
38
|
+
* line is safe to put at the end of a synchronous write method: it is a
|
|
39
|
+
* no-op when unconfigured, and it swallows everything. An exporter bug, a
|
|
40
|
+
* malformed span, an exhausted queue — none of it may ever surface as a
|
|
41
|
+
* failed phase, because export is not allowed to dispose of anything. The
|
|
42
|
+
* exporter's own methods are synchronous enqueues; the network happens later,
|
|
43
|
+
* on an unref'd timer.
|
|
44
|
+
*/
|
|
45
|
+
private fanOut;
|
|
17
46
|
event(record: EventRecord): string;
|
|
18
47
|
sessionStart(adwId: string, engineer: string, adwName?: string | null): void;
|
|
19
48
|
sessionRequest(adwId: string, request: string): void;
|
package/dist/core/tracer.js
CHANGED
|
@@ -2,8 +2,24 @@
|
|
|
2
2
|
* Tracer: every event lands in JSONL and SQLite AS IT HAPPENS.
|
|
3
3
|
*
|
|
4
4
|
* Files are the raw record; spf.db is the queryable mirror the UI polls.
|
|
5
|
-
* No push transport — the flow is always: agents -> sqlite -> web ui.
|
|
6
5
|
* WAL mode so the UI can read while ADW processes write.
|
|
6
|
+
*
|
|
7
|
+
* No push transport in the CONTROL flow — that is always, still, and only:
|
|
8
|
+
* agents -> sqlite -> web ui. SQLite is the source of truth; nothing
|
|
9
|
+
* downstream of it can affect a phase, a gate, or a run outcome.
|
|
10
|
+
*
|
|
11
|
+
* The one amendment: when (and only when) `observability.otel` is configured,
|
|
12
|
+
* each write method below ends with a single fan-out line to an optional
|
|
13
|
+
* OtelExporter — a lossy, allowlisted PROJECTION of what was just written,
|
|
14
|
+
* pushed to an OTLP endpoint fire-and-forget. It is deliberately NOT a second
|
|
15
|
+
* record: it never blocks, never throws into a caller (see `fanOut`), drops
|
|
16
|
+
* spans under backpressure, and carries only the allowlisted subset of fields
|
|
17
|
+
* (never `EventRecord.payload`, never the request text, never envelope
|
|
18
|
+
* contents — `core/otel.ts`'s header has the full list and the reasons).
|
|
19
|
+
* Methods whose data is entirely outside that allowlist —
|
|
20
|
+
* `sessionRequest` (the operator's prompt), `envelopeRow` (agent output),
|
|
21
|
+
* `processStart`/`processEnd` (pids) — have NO fan-out line on purpose. Do not
|
|
22
|
+
* add one.
|
|
7
23
|
*/
|
|
8
24
|
import { Database } from "./sqlite.js";
|
|
9
25
|
import { appendFileSync, mkdirSync } from "node:fs";
|
|
@@ -97,7 +113,10 @@ export class Tracer {
|
|
|
97
113
|
db;
|
|
98
114
|
dbPath;
|
|
99
115
|
eventsJsonl;
|
|
100
|
-
|
|
116
|
+
/** `null` unless `observability.otel` is configured — see the header. */
|
|
117
|
+
otel;
|
|
118
|
+
constructor(dbPath, eventsJsonl, otel) {
|
|
119
|
+
this.otel = otel ?? null;
|
|
101
120
|
mkdirSync(path.dirname(dbPath), { recursive: true });
|
|
102
121
|
this.dbPath = dbPath;
|
|
103
122
|
this.eventsJsonl = eventsJsonl;
|
|
@@ -118,6 +137,27 @@ export class Tracer {
|
|
|
118
137
|
}
|
|
119
138
|
}
|
|
120
139
|
}
|
|
140
|
+
/**
|
|
141
|
+
* The ONE door to the optional otel projection, and the only reason a fan-out
|
|
142
|
+
* line is safe to put at the end of a synchronous write method: it is a
|
|
143
|
+
* no-op when unconfigured, and it swallows everything. An exporter bug, a
|
|
144
|
+
* malformed span, an exhausted queue — none of it may ever surface as a
|
|
145
|
+
* failed phase, because export is not allowed to dispose of anything. The
|
|
146
|
+
* exporter's own methods are synchronous enqueues; the network happens later,
|
|
147
|
+
* on an unref'd timer.
|
|
148
|
+
*/
|
|
149
|
+
fanOut(action) {
|
|
150
|
+
if (!this.otel)
|
|
151
|
+
return;
|
|
152
|
+
try {
|
|
153
|
+
action(this.otel);
|
|
154
|
+
}
|
|
155
|
+
catch {
|
|
156
|
+
// Deliberately silent: a logged line per event on a hot path would be
|
|
157
|
+
// its own failure mode, and otel.ts already logs its own send failures
|
|
158
|
+
// exactly once.
|
|
159
|
+
}
|
|
160
|
+
}
|
|
121
161
|
// ── events ──────────────────────────────────────────────────────────────
|
|
122
162
|
event(record) {
|
|
123
163
|
const eventId = `evt_${newId(12)}`;
|
|
@@ -128,23 +168,28 @@ export class Tracer {
|
|
|
128
168
|
.query(`INSERT INTO events (event_id, adw_id, phase_id, parent_id, type, name,
|
|
129
169
|
payload_json, tokens, started_at, ended_at) VALUES (?,?,?,?,?,?,?,?,?,?)`)
|
|
130
170
|
.run(eventId, record.adw_id, record.phase_id, record.parent_id, record.type, record.name, JSON.stringify(record.payload), record.tokens ?? null, record.started_at || ts, record.ended_at ?? null);
|
|
171
|
+
this.fanOut((otel) => otel.recordEvent(record, eventId, ts)); // otel projection — see fanOut
|
|
131
172
|
return eventId;
|
|
132
173
|
}
|
|
133
174
|
// ── sessions ────────────────────────────────────────────────────────────
|
|
134
175
|
sessionStart(adwId, engineer, adwName) {
|
|
176
|
+
const startedAt = nowIso();
|
|
135
177
|
this.db
|
|
136
178
|
.query(`INSERT INTO sessions (adw_id, status, engineer, started_at) VALUES (?,?,?,?)
|
|
137
179
|
ON CONFLICT(adw_id) DO UPDATE SET status='running'`)
|
|
138
|
-
.run(adwId, "running", engineer,
|
|
139
|
-
if (
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
180
|
+
.run(adwId, "running", engineer, startedAt);
|
|
181
|
+
if (adwName) {
|
|
182
|
+
// A joined session chains ADWs — record each distinct one, in run order.
|
|
183
|
+
const row = this.db.query("SELECT adw_name FROM sessions WHERE adw_id=?").get(adwId);
|
|
184
|
+
const names = row?.adw_name ? row.adw_name.split(" + ") : [];
|
|
185
|
+
if (!names.includes(adwName)) {
|
|
186
|
+
names.push(adwName);
|
|
187
|
+
this.db.query("UPDATE sessions SET adw_name=? WHERE adw_id=?").run(names.join(" + "), adwId);
|
|
188
|
+
}
|
|
147
189
|
}
|
|
190
|
+
// otel projection: the run's clock only. `engineer` is a person's name —
|
|
191
|
+
// outside the allowlist, and not a measure of anything.
|
|
192
|
+
this.fanOut((otel) => otel.recordSessionStart(startedAt));
|
|
148
193
|
}
|
|
149
194
|
sessionRequest(adwId, request) {
|
|
150
195
|
this.db.query("UPDATE sessions SET request=? WHERE adw_id=?").run(request.slice(0, 500), adwId);
|
|
@@ -154,6 +199,7 @@ export class Tracer {
|
|
|
154
199
|
.query("UPDATE sessions SET status=?, ended_at=? WHERE adw_id=?")
|
|
155
200
|
.run(ok ? "success" : "fail", nowIso(), adwId);
|
|
156
201
|
this.processesEndAll(adwId); // nothing of this run is alive any more
|
|
202
|
+
this.fanOut((otel) => otel.recordSessionFinish(ok)); // otel projection: emits the root run span, once
|
|
157
203
|
}
|
|
158
204
|
sessionAddUsage(adwId, tokens, cost) {
|
|
159
205
|
this.db
|
|
@@ -208,6 +254,9 @@ export class Tracer {
|
|
|
208
254
|
ON CONFLICT(phase_id) DO UPDATE SET status=excluded.status,
|
|
209
255
|
attempt=excluded.attempt, error=excluded.error, ended_at=excluded.ended_at`)
|
|
210
256
|
.run(phase.phase_id, phase.adw_id, phase.seq, p.name, p.kind, p.owner, p.description, phase.status, phase.attempt, p.retries, phase.error ?? null, phase.started_at ?? null, phase.ended_at ?? null);
|
|
257
|
+
// otel projection: a no-op on the start-of-phase upsert (no ended_at yet) —
|
|
258
|
+
// phase spans are emitted at phase END only. `phase.error` never crosses.
|
|
259
|
+
this.fanOut((otel) => otel.recordPhase(phase));
|
|
211
260
|
}
|
|
212
261
|
// ── envelopes / gates / agent sessions ──────────────────────────────────
|
|
213
262
|
envelopeRow(phase, agent, outputType, payloadJson, valid, attempt) {
|
|
@@ -222,6 +271,10 @@ export class Tracer {
|
|
|
222
271
|
.query(`INSERT INTO gate_results (adw_id, phase_id, attempt, gate, passed,
|
|
223
272
|
violations_json, checks_json, created_at) VALUES (?,?,?,?,?,?,?,?)`)
|
|
224
273
|
.run(phase.adw_id, phase.phase_id, attempt, gate, report.passed ? 1 : 0, JSON.stringify(report.violations), JSON.stringify(report.checks), nowIso());
|
|
274
|
+
// otel projection: gate name + verdict + violation COUNT as a span event on
|
|
275
|
+
// the phase span. The violation and check TEXT stays here in SQLite — it
|
|
276
|
+
// quotes the agent's claim and the repo's files.
|
|
277
|
+
this.fanOut((otel) => otel.recordGate(phase, gate, report, attempt));
|
|
225
278
|
}
|
|
226
279
|
/**
|
|
227
280
|
* The agent's config row is the source of truth for its label and color.
|
|
@@ -242,5 +295,10 @@ export class Tracer {
|
|
|
242
295
|
context_window=excluded.context_window,
|
|
243
296
|
last_used_at=excluded.last_used_at`)
|
|
244
297
|
.run(adwId, agent.name, agent.coding_agent, agent.model, agent.color, sessionId, contextTokens, contextWindow, ts, ts);
|
|
298
|
+
// otel projection: the TYPED source of an agent's model + backend for its
|
|
299
|
+
// span (agents.ts writes this row before the agent_end event, which is what
|
|
300
|
+
// lets otel.ts avoid reading the agent_start payload at all). `sessionId`
|
|
301
|
+
// is not exported — it is a coding-agent handle, not a measure.
|
|
302
|
+
this.fanOut((otel) => otel.recordAgentSession(agent));
|
|
245
303
|
}
|
|
246
304
|
}
|
package/dist/core/watch.d.ts
CHANGED
|
@@ -6,6 +6,32 @@ export interface ChainRunResult {
|
|
|
6
6
|
adwId: string;
|
|
7
7
|
/** Shown to the engineer via a `blocked` comment on a failed/no-op run. */
|
|
8
8
|
detail: string;
|
|
9
|
+
/**
|
|
10
|
+
* A short, already-sanitized/truncated digest of the reviewer's verdict
|
|
11
|
+
* (approved/blocking/findings), read back best-effort from the sessions DB
|
|
12
|
+
* — see `cli/commands/watch.ts`'s `runChain`. `undefined` when the chain
|
|
13
|
+
* that ran has no reviewer step, or the DB read/parse failed; `runIssue`
|
|
14
|
+
* below falls back to `reviewRequired` to tell those two apart in the PR
|
|
15
|
+
* body and `pr_opened` notification.
|
|
16
|
+
*/
|
|
17
|
+
reviewSummary?: string;
|
|
18
|
+
/** Whether the chain that ran declares a "reviewer" in its `requiredAgents` — distinguishes "reviewer approved" from "nothing reviewed this change" when `reviewSummary` is absent. */
|
|
19
|
+
reviewRequired?: boolean;
|
|
20
|
+
}
|
|
21
|
+
/** One issue the refine lane created — enough for `finishSpec`'s summary comment and the marker's idempotency record. */
|
|
22
|
+
export interface RefinedIssueRef {
|
|
23
|
+
id: string;
|
|
24
|
+
title: string;
|
|
25
|
+
kind: string;
|
|
26
|
+
isLeaf: boolean;
|
|
27
|
+
}
|
|
28
|
+
export interface RefineRunResult {
|
|
29
|
+
accepted: boolean;
|
|
30
|
+
adwId: string;
|
|
31
|
+
/** Shown to the engineer via a `blocked` comment on a failed/no-op run. */
|
|
32
|
+
detail: string;
|
|
33
|
+
/** What `steps.publishIssues()` created, read back from its side-channel file — see `cli/commands/watch.ts`'s `runRefine`. Empty when `!accepted`. */
|
|
34
|
+
created: RefinedIssueRef[];
|
|
9
35
|
}
|
|
10
36
|
export interface WatchDeps {
|
|
11
37
|
provider: IssueProvider;
|
|
@@ -17,6 +43,23 @@ export interface WatchDeps {
|
|
|
17
43
|
chain: string;
|
|
18
44
|
baseBranch: string;
|
|
19
45
|
concurrency: number;
|
|
46
|
+
/**
|
|
47
|
+
* The second lane — decomposing a `<prefix>:spec-ready` product spec
|
|
48
|
+
* instead of building a `<prefix>:ready` issue. `false` (the default) is
|
|
49
|
+
* a complete no-op: `claimSpecs`/`reconcileRefining` return immediately,
|
|
50
|
+
* so an existing `spf watch` config sees no new poll traffic at all until
|
|
51
|
+
* this is turned on. See `WatchConfigSchema`'s `refine` field.
|
|
52
|
+
*/
|
|
53
|
+
refineEnabled: boolean;
|
|
54
|
+
refineConcurrency: number;
|
|
55
|
+
refineChain: string;
|
|
56
|
+
/** Same shape as `runChain`, for the refine lane — see its own doc comment for why the two aren't unified into one callback. */
|
|
57
|
+
runRefine: (opts: {
|
|
58
|
+
prompt: string;
|
|
59
|
+
cwd: string;
|
|
60
|
+
adwId: string;
|
|
61
|
+
issueId: string;
|
|
62
|
+
}) => Promise<RefineRunResult>;
|
|
20
63
|
worktreesDir: string;
|
|
21
64
|
/**
|
|
22
65
|
* Symlink (or otherwise wire up) `<worktreePath>/.spf/data` to the MAIN
|
|
@@ -49,9 +92,22 @@ export interface WatchDeps {
|
|
|
49
92
|
}
|
|
50
93
|
export interface WatchRunState {
|
|
51
94
|
inflight: Set<string>;
|
|
95
|
+
/**
|
|
96
|
+
* Separate from `inflight` — not a defensive copy of the same set, a
|
|
97
|
+
* genuinely different budget. The refine lane's `claimSpecs` caps against
|
|
98
|
+
* `refineConcurrency`, independent of the build lane's `concurrency`; a
|
|
99
|
+
* shared set would conflate "how many specs are being refined" with "how
|
|
100
|
+
* many issues are being built" and make either budget impossible to
|
|
101
|
+
* enforce on its own. The two never collide on an id in practice (a
|
|
102
|
+
* `spec-ready` issue and a `ready` issue are never the same issue), but
|
|
103
|
+
* that isn't why this is separate — the budgets are what require it.
|
|
104
|
+
*/
|
|
105
|
+
refining: Set<string>;
|
|
52
106
|
}
|
|
53
107
|
export declare function createWatchState(): WatchRunState;
|
|
54
108
|
export declare function branchNameFor(issue: Issue): string;
|
|
109
|
+
/** 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. */
|
|
110
|
+
export declare function refineBranchNameFor(issue: Issue): string;
|
|
55
111
|
/**
|
|
56
112
|
* Any issue labeled `working` that THIS process isn't tracking is an
|
|
57
113
|
* orphan — a daemon restart, or another instance's claim this process
|
|
@@ -59,9 +115,19 @@ export declare function branchNameFor(issue: Issue): string;
|
|
|
59
115
|
* merged) PR; otherwise retry up to `MAX_ORPHAN_ATTEMPTS`, then give up.
|
|
60
116
|
*/
|
|
61
117
|
export declare function reconcileOrphans(deps: WatchDeps, state: WatchRunState): Promise<void>;
|
|
118
|
+
/**
|
|
119
|
+
* The refine lane's own `reconcileOrphans` — a `refining`-labeled spec this
|
|
120
|
+
* process isn't tracking is either a completed publish that crashed before
|
|
121
|
+
* its own `transition(issue, "done")` ran (resume: finish it, no re-run),
|
|
122
|
+
* or a genuine orphan (retry up to `MAX_ORPHAN_ATTEMPTS`, then give up).
|
|
123
|
+
* A no-op entirely when `watch.refine` is off — see `WatchDeps.refineEnabled`.
|
|
124
|
+
*/
|
|
125
|
+
export declare function reconcileRefining(deps: WatchDeps, state: WatchRunState): Promise<void>;
|
|
62
126
|
/** Poll every `review`-labeled issue's PR for merged (-> done) or closed-without-merging (-> blocked). */
|
|
63
127
|
export declare function finishReviews(deps: WatchDeps): Promise<void>;
|
|
64
128
|
/** Claim as many `ready` issues as the concurrency budget allows, and kick off `runIssue` for each in the background. */
|
|
65
129
|
export declare function claimNewWork(deps: WatchDeps, state: WatchRunState): Promise<void>;
|
|
66
|
-
/**
|
|
130
|
+
/** 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. */
|
|
131
|
+
export declare function claimSpecs(deps: WatchDeps, state: WatchRunState): Promise<void>;
|
|
132
|
+
/** 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. */
|
|
67
133
|
export declare function tick(deps: WatchDeps, state: WatchRunState): Promise<void>;
|