@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/watch.js
CHANGED
|
@@ -1,10 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The `spf watch` state machine
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* The `spf watch` state machine — two lanes over the same poll loop.
|
|
3
|
+
*
|
|
4
|
+
* The build lane: poll -> claim -> run a chain -> PR -> done/blocked.
|
|
5
|
+
* The refine lane (`watch.refine.enabled`, off by default): poll a
|
|
6
|
+
* `spec-ready` product spec -> claim -> decompose it into a feature/story
|
|
7
|
+
* tree -> publish those as real issues -> done/blocked. A spec is not
|
|
8
|
+
* individually workable, so this lane never opens a PR — it hands the build
|
|
9
|
+
* lane its next batch of `ready`-able work instead (see `core/refine.ts`).
|
|
10
|
+
*
|
|
11
|
+
* Provider-agnostic (drives whatever `IssueProvider` it's given) and
|
|
12
|
+
* chain-agnostic (drives whatever `runChain`/`runRefine` callback it's
|
|
5
13
|
* given) — deliberately kept out of `src/chains/`'s dependency direction,
|
|
6
|
-
* so this stays testable against a fake provider and
|
|
7
|
-
*
|
|
14
|
+
* so this stays testable against a fake provider and fake callbacks with no
|
|
15
|
+
* chain registry involved.
|
|
8
16
|
*
|
|
9
17
|
* Design lifted from the user's own GitHub-poller reference implementation
|
|
10
18
|
* (a label-as-state-machine daemon), leaned down for a v1: no per-issue
|
|
@@ -30,24 +38,33 @@
|
|
|
30
38
|
import path from "node:path";
|
|
31
39
|
const MAX_ORPHAN_ATTEMPTS = 2;
|
|
32
40
|
export function createWatchState() {
|
|
33
|
-
return { inflight: new Set() };
|
|
41
|
+
return { inflight: new Set(), refining: new Set() };
|
|
34
42
|
}
|
|
35
|
-
|
|
36
|
-
|
|
43
|
+
function slugifyTitle(title) {
|
|
44
|
+
return (title
|
|
37
45
|
.toLowerCase()
|
|
38
46
|
.split(/\s+/)
|
|
39
47
|
.filter(Boolean)
|
|
40
48
|
.slice(0, 5)
|
|
41
49
|
.join("-")
|
|
42
|
-
.replace(/[^a-z0-9-]/g, "");
|
|
50
|
+
.replace(/[^a-z0-9-]/g, "") || "issue");
|
|
51
|
+
}
|
|
52
|
+
export function branchNameFor(issue) {
|
|
43
53
|
// The issue's own id in the branch name isn't just labeling: Jira's
|
|
44
54
|
// Bitbucket integration auto-links a PR to the issue when its key
|
|
45
55
|
// appears anywhere in the branch name, no explicit API call needed.
|
|
46
|
-
return `spf-watch/${issue.id}-${
|
|
56
|
+
return `spf-watch/${issue.id}-${slugifyTitle(issue.title)}`.slice(0, 200);
|
|
57
|
+
}
|
|
58
|
+
/** 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. */
|
|
59
|
+
export function refineBranchNameFor(issue) {
|
|
60
|
+
return `spf-refine/${issue.id}-${slugifyTitle(issue.title)}`.slice(0, 200);
|
|
47
61
|
}
|
|
48
62
|
function worktreePathFor(deps, issue) {
|
|
49
63
|
return path.join(deps.worktreesDir, `issue-${issue.id}`);
|
|
50
64
|
}
|
|
65
|
+
function specWorktreePathFor(deps, issue) {
|
|
66
|
+
return path.join(deps.worktreesDir, `spec-${issue.id}`);
|
|
67
|
+
}
|
|
51
68
|
function cleanupWorktree(deps, marker) {
|
|
52
69
|
if (!marker)
|
|
53
70
|
return;
|
|
@@ -106,6 +123,78 @@ export async function reconcileOrphans(deps, state) {
|
|
|
106
123
|
}
|
|
107
124
|
}
|
|
108
125
|
}
|
|
126
|
+
/**
|
|
127
|
+
* Post the summary comment on a decomposed spec and transition it to `done`
|
|
128
|
+
* — the refine lane's one shared finishing move, reached from both the
|
|
129
|
+
* normal path (`runSpec`, right after a successful publish) and the
|
|
130
|
+
* orphan-resume path (`reconcileRefining`, when a completed publish's
|
|
131
|
+
* marker survived a crash the `transition` itself didn't). `created` only
|
|
132
|
+
* has titles/kinds in the normal path — an orphan resume has nothing but
|
|
133
|
+
* the ids `WatchMarker.refined` recorded, and the comment degrades to a
|
|
134
|
+
* bare list of `#id`s rather than blocking on a re-fetch.
|
|
135
|
+
*/
|
|
136
|
+
async function finishSpec(deps, issue, created) {
|
|
137
|
+
const body = created.length > 0
|
|
138
|
+
? `spf watch refined this spec into ${created.length} issue(s):\n\n` +
|
|
139
|
+
created.map((c) => (c.title ? `- #${c.id} (${c.kind}): ${c.title}` : `- #${c.id}`)).join("\n") +
|
|
140
|
+
`\n\nPromote any of them to \`${deps.labelPrefix}:ready\` when it's worth building.`
|
|
141
|
+
: `spf watch refined this spec but the refiner produced no issues.`;
|
|
142
|
+
deps.notify({
|
|
143
|
+
kind: "spec_refined",
|
|
144
|
+
level: "info",
|
|
145
|
+
title: `spec ${issue.id} refined`,
|
|
146
|
+
detail: `${created.length} issue(s) created.`,
|
|
147
|
+
fields: [["issue", issue.id], ["title", issue.title], ["created", String(created.length)]],
|
|
148
|
+
});
|
|
149
|
+
if (!deps.dryRun) {
|
|
150
|
+
await deps.provider.comment(issue, body);
|
|
151
|
+
await deps.provider.transition(issue, "done");
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* The refine lane's own `reconcileOrphans` — a `refining`-labeled spec this
|
|
156
|
+
* process isn't tracking is either a completed publish that crashed before
|
|
157
|
+
* its own `transition(issue, "done")` ran (resume: finish it, no re-run),
|
|
158
|
+
* or a genuine orphan (retry up to `MAX_ORPHAN_ATTEMPTS`, then give up).
|
|
159
|
+
* A no-op entirely when `watch.refine` is off — see `WatchDeps.refineEnabled`.
|
|
160
|
+
*/
|
|
161
|
+
export async function reconcileRefining(deps, state) {
|
|
162
|
+
if (!deps.refineEnabled)
|
|
163
|
+
return;
|
|
164
|
+
const refining = await deps.provider.listInState("refining");
|
|
165
|
+
for (const issue of refining) {
|
|
166
|
+
if (state.refining.has(issue.id))
|
|
167
|
+
continue;
|
|
168
|
+
const marker = await deps.provider.readMarker(issue);
|
|
169
|
+
if (marker?.refined && marker.refined.length > 0) {
|
|
170
|
+
deps.log(`watch: spec ${issue.id} orphaned after publish already completed — finishing`);
|
|
171
|
+
await finishSpec(deps, issue, marker.refined.map((id) => ({ id })));
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
const attempt = (marker?.attempt ?? 0) + 1;
|
|
175
|
+
if (attempt <= MAX_ORPHAN_ATTEMPTS) {
|
|
176
|
+
deps.log(`watch: spec ${issue.id} orphaned mid-refine, retry ${attempt}/${MAX_ORPHAN_ATTEMPTS} — back to spec-ready`);
|
|
177
|
+
if (!deps.dryRun) {
|
|
178
|
+
await deps.provider.writeMarker(issue, { ...marker, attempt });
|
|
179
|
+
await deps.provider.transition(issue, "spec-ready");
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
else {
|
|
183
|
+
deps.log(`watch: spec ${issue.id} orphaned past ${MAX_ORPHAN_ATTEMPTS} attempts — blocked`);
|
|
184
|
+
deps.notify({
|
|
185
|
+
kind: "issue_blocked",
|
|
186
|
+
level: "error",
|
|
187
|
+
title: `spec ${issue.id} blocked`,
|
|
188
|
+
detail: `Gave up after ${MAX_ORPHAN_ATTEMPTS} orphaned refine attempts.`,
|
|
189
|
+
fields: [["issue", issue.id], ["title", issue.title]],
|
|
190
|
+
});
|
|
191
|
+
if (!deps.dryRun) {
|
|
192
|
+
await deps.provider.transition(issue, "blocked", `Gave up after ${MAX_ORPHAN_ATTEMPTS} orphaned refine attempts.`);
|
|
193
|
+
cleanupWorktree(deps, marker);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
109
198
|
/** Poll every `review`-labeled issue's PR for merged (-> done) or closed-without-merging (-> blocked). */
|
|
110
199
|
export async function finishReviews(deps) {
|
|
111
200
|
const reviewing = await deps.provider.listInState("review", { includeAll: true });
|
|
@@ -195,6 +284,15 @@ async function runIssue(deps, issue) {
|
|
|
195
284
|
return;
|
|
196
285
|
}
|
|
197
286
|
wtGit.push("origin", branch);
|
|
287
|
+
// The human merging this PR sees whatever the reviewer found — or, if
|
|
288
|
+
// nothing reviewed this change at all, is told that plainly rather than
|
|
289
|
+
// left to assume a silent approval. `reviewSummary` is already
|
|
290
|
+
// sanitized/truncated by the caller (see runChain's own doc comment).
|
|
291
|
+
const reviewLine = result.reviewSummary
|
|
292
|
+
? result.reviewSummary
|
|
293
|
+
: result.reviewRequired
|
|
294
|
+
? "Reviewer ran, but no verdict could be read back from the session data."
|
|
295
|
+
: `Nothing reviewed this change — chain \`${deps.chain}\` has no reviewer step.`;
|
|
198
296
|
// No cross-linking magic keyword here on purpose (a code host paired
|
|
199
297
|
// with a different tracker has no "Closes #n" convention to hook into
|
|
200
298
|
// — see provider.ts) — the issue id in the title/body is plain text
|
|
@@ -203,7 +301,7 @@ async function runIssue(deps, issue) {
|
|
|
203
301
|
const pr = await deps.codeHost.openPr({
|
|
204
302
|
branch,
|
|
205
303
|
title: `${issue.title} (${issue.id})`,
|
|
206
|
-
body: `Automated by \`spf watch\` — chain \`${deps.chain}\`, adw_id \`${adwId}\`, issue ${issue.id}
|
|
304
|
+
body: `Automated by \`spf watch\` — chain \`${deps.chain}\`, adw_id \`${adwId}\`, issue ${issue.id}.\n\n${reviewLine}`,
|
|
207
305
|
base: deps.baseBranch,
|
|
208
306
|
});
|
|
209
307
|
await deps.provider.writeMarker(issue, { worktree: worktreePath, branch, pr: pr.number, attempt: 0 });
|
|
@@ -213,7 +311,13 @@ async function runIssue(deps, issue) {
|
|
|
213
311
|
kind: "pr_opened",
|
|
214
312
|
level: "info",
|
|
215
313
|
title: `PR #${pr.number} opened`,
|
|
216
|
-
|
|
314
|
+
detail: reviewLine,
|
|
315
|
+
fields: [
|
|
316
|
+
["issue", issue.id],
|
|
317
|
+
["title", issue.title],
|
|
318
|
+
["chain", deps.chain],
|
|
319
|
+
["review", result.reviewSummary ? "reviewed" : result.reviewRequired ? "reviewer ran, no verdict" : "not reviewed"],
|
|
320
|
+
],
|
|
217
321
|
url: pr.url || undefined,
|
|
218
322
|
});
|
|
219
323
|
}
|
|
@@ -231,6 +335,72 @@ async function runIssue(deps, issue) {
|
|
|
231
335
|
cleanupWorktree(deps, { worktree: worktreePath, branch });
|
|
232
336
|
}
|
|
233
337
|
}
|
|
338
|
+
/**
|
|
339
|
+
* One spec's full claim -> decompose -> publish path, run in the background
|
|
340
|
+
* — `claimSpecs` doesn't await this. The build lane's `runIssue`, minus the
|
|
341
|
+
* PR half: no `diffFiles` check (the refiner has `writes: []`, so an empty
|
|
342
|
+
* diff is the CORRECT outcome, not a failure), no push, no `openPr`. Its
|
|
343
|
+
* mirror image is "publish, then finish" instead of "commit, then review".
|
|
344
|
+
*/
|
|
345
|
+
async function runSpec(deps, issue) {
|
|
346
|
+
const branch = refineBranchNameFor(issue);
|
|
347
|
+
const worktreePath = specWorktreePathFor(deps, issue);
|
|
348
|
+
const adwId = `spec-${issue.id}`;
|
|
349
|
+
try {
|
|
350
|
+
// A spec re-claimed after a completed publish (the transition/comment
|
|
351
|
+
// that should have followed never ran — a crash, a kill) already has
|
|
352
|
+
// its answer on disk: skip straight to finishing rather than asking the
|
|
353
|
+
// refiner to redo work that already exists on the tracker. `to-tickets`
|
|
354
|
+
// (the skill this lane's prompt is ported from) has no such guard and
|
|
355
|
+
// would duplicate every issue on a re-run.
|
|
356
|
+
const existingMarker = await deps.provider.readMarker(issue);
|
|
357
|
+
if (existingMarker?.refined && existingMarker.refined.length > 0) {
|
|
358
|
+
deps.log(`watch: spec ${issue.id}: a previous attempt already published ${existingMarker.refined.length} issue(s) — finishing without re-running the refiner`);
|
|
359
|
+
await finishSpec(deps, issue, existingMarker.refined.map((id) => ({ id })));
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
// See runIssue's identical comment: worktreePath/branch are deterministic
|
|
363
|
+
// from issue.id, so a leftover from a killed prior attempt is the only
|
|
364
|
+
// way either could already exist — clear it unconditionally.
|
|
365
|
+
cleanupWorktree(deps, { worktree: worktreePath, branch });
|
|
366
|
+
deps.git.fetch("origin", deps.baseBranch);
|
|
367
|
+
deps.git.worktreeAdd(worktreePath, branch, `origin/${deps.baseBranch}`);
|
|
368
|
+
deps.linkDataDir(worktreePath);
|
|
369
|
+
await deps.provider.writeMarker(issue, { worktree: worktreePath, branch, attempt: 0 });
|
|
370
|
+
const prompt = `${issue.title}\n\n${issue.body}`.trim();
|
|
371
|
+
const result = await deps.runRefine({ prompt, cwd: worktreePath, adwId, issueId: issue.id });
|
|
372
|
+
if (!result.accepted) {
|
|
373
|
+
deps.log(`watch: spec ${issue.id}: refine chain "${deps.refineChain}" did not succeed — blocked`);
|
|
374
|
+
const detail = result.detail || `Refine chain "${deps.refineChain}" (adw_id ${adwId}) did not complete successfully. Run \`spf phases ${adwId}\` for detail.`;
|
|
375
|
+
deps.notify({
|
|
376
|
+
kind: "issue_blocked",
|
|
377
|
+
level: "error",
|
|
378
|
+
title: `spec ${issue.id} blocked`,
|
|
379
|
+
detail,
|
|
380
|
+
fields: [["issue", issue.id], ["title", issue.title], ["chain", deps.refineChain], ["adw_id", adwId]],
|
|
381
|
+
});
|
|
382
|
+
await deps.provider.transition(issue, "blocked", detail);
|
|
383
|
+
cleanupWorktree(deps, { worktree: worktreePath, branch });
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
await deps.provider.writeMarker(issue, { worktree: worktreePath, branch, attempt: 0, refined: result.created.map((c) => c.id) });
|
|
387
|
+
await finishSpec(deps, issue, result.created);
|
|
388
|
+
cleanupWorktree(deps, { worktree: worktreePath, branch });
|
|
389
|
+
}
|
|
390
|
+
catch (error) {
|
|
391
|
+
const message = error.message;
|
|
392
|
+
deps.log(`watch: spec ${issue.id}: refine error: ${message}`);
|
|
393
|
+
deps.notify({
|
|
394
|
+
kind: "watch_error",
|
|
395
|
+
level: "error",
|
|
396
|
+
title: `spec ${issue.id} errored`,
|
|
397
|
+
detail: message,
|
|
398
|
+
fields: [["issue", issue.id], ["title", issue.title]],
|
|
399
|
+
});
|
|
400
|
+
await deps.provider.transition(issue, "blocked", `spf watch refine error: ${message}`).catch(() => undefined);
|
|
401
|
+
cleanupWorktree(deps, { worktree: worktreePath, branch });
|
|
402
|
+
}
|
|
403
|
+
}
|
|
234
404
|
/** Claim as many `ready` issues as the concurrency budget allows, and kick off `runIssue` for each in the background. */
|
|
235
405
|
export async function claimNewWork(deps, state) {
|
|
236
406
|
if (state.inflight.size >= deps.concurrency)
|
|
@@ -261,6 +431,38 @@ export async function claimNewWork(deps, state) {
|
|
|
261
431
|
runIssue(deps, issue).finally(() => state.inflight.delete(issue.id));
|
|
262
432
|
}
|
|
263
433
|
}
|
|
434
|
+
/** 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. */
|
|
435
|
+
export async function claimSpecs(deps, state) {
|
|
436
|
+
if (!deps.refineEnabled)
|
|
437
|
+
return;
|
|
438
|
+
if (state.refining.size >= deps.refineConcurrency)
|
|
439
|
+
return;
|
|
440
|
+
const eligible = await deps.provider.listInState("spec-ready");
|
|
441
|
+
for (const issue of eligible) {
|
|
442
|
+
if (state.refining.size >= deps.refineConcurrency)
|
|
443
|
+
break;
|
|
444
|
+
if (state.refining.has(issue.id))
|
|
445
|
+
continue;
|
|
446
|
+
if (deps.dryRun) {
|
|
447
|
+
deps.log(`watch: [dry-run] would claim spec ${issue.id} (${issue.title}) and run refine chain "${deps.refineChain}"`);
|
|
448
|
+
continue;
|
|
449
|
+
}
|
|
450
|
+
const claimed = await deps.provider.claim(issue, { from: "spec-ready", to: "refining" });
|
|
451
|
+
if (!claimed) {
|
|
452
|
+
deps.log(`watch: spec ${issue.id} lost the claim race this tick — skipping`);
|
|
453
|
+
continue;
|
|
454
|
+
}
|
|
455
|
+
deps.log(`watch: claimed spec ${issue.id}: ${issue.title}`);
|
|
456
|
+
deps.notify({
|
|
457
|
+
kind: "issue_claimed",
|
|
458
|
+
level: "info",
|
|
459
|
+
title: `spec ${issue.id} claimed`,
|
|
460
|
+
fields: [["issue", issue.id], ["title", issue.title], ["chain", deps.refineChain]],
|
|
461
|
+
});
|
|
462
|
+
state.refining.add(issue.id);
|
|
463
|
+
runSpec(deps, issue).finally(() => state.refining.delete(issue.id));
|
|
464
|
+
}
|
|
465
|
+
}
|
|
264
466
|
function tickErrorHandler(deps, stage) {
|
|
265
467
|
return (error) => {
|
|
266
468
|
const message = error.message;
|
|
@@ -268,9 +470,11 @@ function tickErrorHandler(deps, stage) {
|
|
|
268
470
|
deps.notify({ kind: "watch_error", level: "error", title: `watch: ${stage} error`, detail: message, fields: [] });
|
|
269
471
|
};
|
|
270
472
|
}
|
|
271
|
-
/** One poll tick: reconcile, finish, claim — each independently caught, so one
|
|
473
|
+
/** 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. */
|
|
272
474
|
export async function tick(deps, state) {
|
|
273
475
|
await reconcileOrphans(deps, state).catch(tickErrorHandler(deps, "reconcileOrphans"));
|
|
476
|
+
await reconcileRefining(deps, state).catch(tickErrorHandler(deps, "reconcileRefining"));
|
|
274
477
|
await finishReviews(deps).catch(tickErrorHandler(deps, "finishReviews"));
|
|
478
|
+
await claimSpecs(deps, state).catch(tickErrorHandler(deps, "claimSpecs"));
|
|
275
479
|
await claimNewWork(deps, state).catch(tickErrorHandler(deps, "claimNewWork"));
|
|
276
480
|
}
|
package/dist/test/chains.test.js
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
import { test } from "node:test";
|
|
13
13
|
import assert from "node:assert/strict";
|
|
14
|
-
import { CHAINS, findChain, resolveRequiredAgents } from "../chains/index.js";
|
|
14
|
+
import { CHAINS, findChain, resolveRequiredAgents, resolveRequiredSuites } from "../chains/index.js";
|
|
15
15
|
// name -> [phases, requiredAgents (with no options), requiredSuites]
|
|
16
16
|
const EXPECTED = {
|
|
17
17
|
prompt: { phases: "engineer(request) -> <agent>", agents: ["builder"], suites: [] },
|
|
@@ -41,9 +41,10 @@ const EXPECTED = {
|
|
|
41
41
|
},
|
|
42
42
|
quality: { phases: "engineer(request) -> code(quality)", agents: [], suites: ["all"] },
|
|
43
43
|
document: { phases: "engineer(request) -> code(changes) -> documenter", agents: ["documenter"], suites: [] },
|
|
44
|
+
refine: { phases: "engineer(request) -> refiner -> code(publish)", agents: ["refiner"], suites: [] },
|
|
44
45
|
"simple-sdlc": {
|
|
45
46
|
phases: "engineer(request) -> planner -> git(commit_plan) -> builder -> code(test) [-> builder(fix) -> code(test) ...] " +
|
|
46
|
-
"-> reviewer [-> builder(revise) -> reviewer ...] -> code(retest, if revised) -> git(commit_build) " +
|
|
47
|
+
"-> reviewer [-> builder(revise) -> reviewer ...] -> code(retest, if revised) -> engineer(signoff) -> git(commit_build) " +
|
|
47
48
|
"-> code(changes) -> documenter -> git(commit_docs)",
|
|
48
49
|
agents: ["planner", "builder", "reviewer", "documenter"],
|
|
49
50
|
suites: ["test"],
|
|
@@ -58,7 +59,7 @@ for (const chain of CHAINS) {
|
|
|
58
59
|
test(`${chain.name}: derived phases/requiredAgents/requiredSuites match what was hand-verified against \`spf list\``, () => {
|
|
59
60
|
assert.equal(chain.phases, expected.phases);
|
|
60
61
|
assert.deepEqual(resolveRequiredAgents(chain, {}), expected.agents);
|
|
61
|
-
assert.deepEqual(chain
|
|
62
|
+
assert.deepEqual(resolveRequiredSuites(chain, {}), expected.suites);
|
|
62
63
|
});
|
|
63
64
|
}
|
|
64
65
|
test("prompt: requiredAgents depends on --agent, not a fixed list — the one dynamic case", () => {
|
|
@@ -66,6 +67,11 @@ test("prompt: requiredAgents depends on --agent, not a fixed list — the one dy
|
|
|
66
67
|
assert.deepEqual(resolveRequiredAgents(chain, {}), ["builder"], "no --agent -> falls back to builder");
|
|
67
68
|
assert.deepEqual(resolveRequiredAgents(chain, { agent: "planner" }), ["planner"], "--agent overrides the default");
|
|
68
69
|
});
|
|
70
|
+
test("plan-build-test: requiredSuites depends on --suite, the same way prompt's agent does", () => {
|
|
71
|
+
const chain = findChain("plan-build-test");
|
|
72
|
+
assert.deepEqual(resolveRequiredSuites(chain, {}), ["test"], "no --suite -> falls back to the compiled-in default");
|
|
73
|
+
assert.deepEqual(resolveRequiredSuites(chain, { suite: "custom" }), ["custom"], "--suite overrides the default");
|
|
74
|
+
});
|
|
69
75
|
test("every chain but simple-sdlc is a steps list; simple-sdlc alone uses the imperative run() escape hatch", () => {
|
|
70
76
|
for (const chain of CHAINS) {
|
|
71
77
|
if (chain.name === "simple-sdlc") {
|
|
@@ -14,8 +14,8 @@ import { tmpdir } from "node:os";
|
|
|
14
14
|
import { join } from "node:path";
|
|
15
15
|
import * as v from "valibot";
|
|
16
16
|
import { toJsonSchema } from "@valibot/to-json-schema";
|
|
17
|
-
import { AgentConfigSchema, BuildOutput, ChangesOutput, DocumentOutput, GenericOutput, NotificationsConfigSchema, PhaseParamsSchema, PlanOutput, ReviewOutput, ScoutOutput, VerifyOutput, makePhaseParams, } from "../core/data_types.js";
|
|
18
|
-
import { loadConfig } from "../core/agents.js";
|
|
17
|
+
import { AgentConfigSchema, BuildOutput, ChangesOutput, DocumentOutput, EventRecordTypeSchema, GenericOutput, NotificationsConfigSchema, PhaseParamsSchema, PlanOutput, ReviewConfigSchema, ReviewOutput, ScoutOutput, VerifyOutput, makePhaseParams, } from "../core/data_types.js";
|
|
18
|
+
import { agentEnv, loadConfig } from "../core/agents.js";
|
|
19
19
|
test("writes: three-state semantics — absent, null, and [] all mean something different", () => {
|
|
20
20
|
const base = { name: "builder", prompt_engineering: { system: "s.md", user: "u.md" } };
|
|
21
21
|
const unrestricted = v.parse(AgentConfigSchema, base);
|
|
@@ -80,3 +80,141 @@ test("notifications survives loadConfig's merge — key-by-key like observabilit
|
|
|
80
80
|
rmSync(dir, { recursive: true, force: true });
|
|
81
81
|
}
|
|
82
82
|
});
|
|
83
|
+
test("ReviewConfigSchema: defaults to require_human_signoff=false, signoff_timeout_seconds=300", () => {
|
|
84
|
+
const parsed = v.parse(ReviewConfigSchema, {});
|
|
85
|
+
assert.equal(parsed.require_human_signoff, false, "this release fails OPEN by default — see the schema's own doc comment");
|
|
86
|
+
assert.equal(parsed.signoff_timeout_seconds, 300);
|
|
87
|
+
});
|
|
88
|
+
// The silent-drop trap: mergeRawConfig (core/agents.ts) is a FIXED-SHAPE
|
|
89
|
+
// object literal, so a `review:` key in a real config file that isn't named
|
|
90
|
+
// on both sides of that literal is dropped before SFConfigSchema ever sees
|
|
91
|
+
// it — parsing would then succeed anyway, quietly, on the schema default.
|
|
92
|
+
// This is adversarial history on this branch, not a hypothetical: it is
|
|
93
|
+
// exactly the bug `observability`/`notifications` already guard against, and
|
|
94
|
+
// `review` gets the same guard the day it is added.
|
|
95
|
+
test("review survives loadConfig's merge — the silent-drop trap mergeRawConfig's fixed-shape literal sets, for a single config file", () => {
|
|
96
|
+
const dir = mkdtempSync(join(tmpdir(), "spf-review-merge-test-"));
|
|
97
|
+
try {
|
|
98
|
+
const configPath = join(dir, "spf.config.yaml");
|
|
99
|
+
writeFileSync(configPath, "review:\n require_human_signoff: true\n signoff_timeout_seconds: 45\n");
|
|
100
|
+
const cfg = loadConfig([configPath]);
|
|
101
|
+
assert.equal(cfg.review.require_human_signoff, true, "a review: value from a real config file must reach SFConfig, not be dropped by mergeRawConfig's object literal");
|
|
102
|
+
assert.equal(cfg.review.signoff_timeout_seconds, 45);
|
|
103
|
+
}
|
|
104
|
+
finally {
|
|
105
|
+
rmSync(dir, { recursive: true, force: true });
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
test("review merges key-by-key across two layered config files, like observability/notifications", () => {
|
|
109
|
+
const dir = mkdtempSync(join(tmpdir(), "spf-review-merge-layered-test-"));
|
|
110
|
+
try {
|
|
111
|
+
const base = join(dir, "base.yaml");
|
|
112
|
+
const override = join(dir, "override.yaml");
|
|
113
|
+
writeFileSync(base, "review:\n require_human_signoff: false\n signoff_timeout_seconds: 120\n");
|
|
114
|
+
writeFileSync(override, "review:\n require_human_signoff: true\n");
|
|
115
|
+
const cfg = loadConfig([base, override]);
|
|
116
|
+
assert.equal(cfg.review.require_human_signoff, true, "override wins for the key it names");
|
|
117
|
+
assert.equal(cfg.review.signoff_timeout_seconds, 120, "unset in the override -> the base's value survives, key-by-key, not a whole-block replace");
|
|
118
|
+
}
|
|
119
|
+
finally {
|
|
120
|
+
rmSync(dir, { recursive: true, force: true });
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
// observability.otel is the one nested OBJECT under a top-level key that
|
|
124
|
+
// mergeRawConfig spreads field-by-field, so its merge semantics differ from its
|
|
125
|
+
// siblings' and are worth pinning: `otel:` is replaced as a WHOLE OBJECT by an
|
|
126
|
+
// override that names it (you never want a half-merged endpoint/headers pair —
|
|
127
|
+
// that is how an auth token gets POSTed to the wrong collector), while
|
|
128
|
+
// `observability`'s other keys still merge key-by-key around it. Also the
|
|
129
|
+
// activation contract: absent by default, so no repo starts exporting because
|
|
130
|
+
// it upgraded.
|
|
131
|
+
test("observability.otel survives loadConfig's merge — absent by default, whole-object replace on override", () => {
|
|
132
|
+
const dir = mkdtempSync(join(tmpdir(), "spf-otel-merge-test-"));
|
|
133
|
+
try {
|
|
134
|
+
const bare = join(dir, "bare.yaml");
|
|
135
|
+
writeFileSync(bare, "observability:\n poll_ms: 250\n");
|
|
136
|
+
assert.equal(loadConfig([bare]).observability.otel, undefined, "no otel: block -> export stays off, the default for every repo");
|
|
137
|
+
const base = join(dir, "base.yaml");
|
|
138
|
+
const override = join(dir, "override.yaml");
|
|
139
|
+
writeFileSync(base, "observability:\n poll_ms: 250\n otel:\n endpoint: http://base-collector:4318/v1/traces\n headers: {authorization: base-token}\n service_name: base\n");
|
|
140
|
+
writeFileSync(override, "observability:\n otel:\n endpoint: http://override-collector:4318/v1/traces\n");
|
|
141
|
+
const cfg = loadConfig([base, override]);
|
|
142
|
+
assert.equal(cfg.observability.poll_ms, 250, "observability's other keys still merge key-by-key around otel");
|
|
143
|
+
assert.equal(cfg.observability.otel?.endpoint, "http://override-collector:4318/v1/traces");
|
|
144
|
+
assert.equal(cfg.observability.otel?.headers, undefined, "whole-object replace: the base's auth header does NOT follow the override's endpoint");
|
|
145
|
+
assert.equal(cfg.observability.otel?.service_name, "spf", "and the base's service_name doesn't either — the schema default applies");
|
|
146
|
+
// A single config file must reach SFConfig at all — the silent-drop trap
|
|
147
|
+
// mergeRawConfig's fixed-shape object literal sets for any new key.
|
|
148
|
+
const single = join(dir, "single.yaml");
|
|
149
|
+
writeFileSync(single, "observability:\n otel:\n endpoint: https://collector.example.com/v1/traces\n");
|
|
150
|
+
assert.equal(loadConfig([single]).observability.otel?.endpoint, "https://collector.example.com/v1/traces");
|
|
151
|
+
// A typo fails at config load, not as a silent per-run export failure.
|
|
152
|
+
const bad = join(dir, "bad.yaml");
|
|
153
|
+
writeFileSync(bad, "observability:\n otel:\n endpoint: not-a-url\n");
|
|
154
|
+
assert.throws(() => loadConfig([bad]), /invalid config/, "endpoint is URL-validated");
|
|
155
|
+
}
|
|
156
|
+
finally {
|
|
157
|
+
rmSync(dir, { recursive: true, force: true });
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
test("EventRecordTypeSchema accepts every observed event type and rejects an unknown one", () => {
|
|
161
|
+
for (const type of ["phase_start", "agent_start", "tool_call", "handoff", "gate_pass", "gate_fail", "log", "agent_end", "phase_end", "error"]) {
|
|
162
|
+
assert.equal(v.parse(EventRecordTypeSchema, type), type);
|
|
163
|
+
}
|
|
164
|
+
assert.throws(() => v.parse(EventRecordTypeSchema, "bogus_type"), "an event type outside the observed set must be rejected, not silently stored");
|
|
165
|
+
});
|
|
166
|
+
test("AgentConfigSchema: env_allowlist is optional and defaults to undefined (byte-identical to before this field existed)", () => {
|
|
167
|
+
const base = { name: "builder", prompt_engineering: { system: "s.md", user: "u.md" } };
|
|
168
|
+
const unset = v.parse(AgentConfigSchema, base);
|
|
169
|
+
assert.equal(unset.env_allowlist, undefined);
|
|
170
|
+
const allowlisted = v.parse(AgentConfigSchema, { ...base, env_allowlist: ["MY_API_KEY"] });
|
|
171
|
+
assert.deepEqual(allowlisted.env_allowlist, ["MY_API_KEY"]);
|
|
172
|
+
const nulled = v.parse(AgentConfigSchema, { ...base, env_allowlist: null });
|
|
173
|
+
assert.equal(nulled.env_allowlist, null, "null must parse (the 'unrestricted' spelling config.md teaches for writes)");
|
|
174
|
+
});
|
|
175
|
+
// This is the load-bearing half of the field: the schema merely declares
|
|
176
|
+
// the shape, agentEnv() is what actually filters the operator's own
|
|
177
|
+
// environment down to the allowlist. A test that only exercised the schema
|
|
178
|
+
// (as the previous version of this test did) would never notice agentEnv
|
|
179
|
+
// silently failing to filter anything.
|
|
180
|
+
test("agentEnv: filters the operator environment down to the allowlist plus the baseline keys", () => {
|
|
181
|
+
const base = { name: "builder", prompt_engineering: { system: "s.md", user: "u.md" } };
|
|
182
|
+
const savedApiKey = process.env["MY_API_KEY"];
|
|
183
|
+
const savedSecret = process.env["SECRET_TOKEN"];
|
|
184
|
+
try {
|
|
185
|
+
process.env["MY_API_KEY"] = "abc123";
|
|
186
|
+
process.env["SECRET_TOKEN"] = "should-never-appear";
|
|
187
|
+
// unset -> undefined: both backends fall back to `request.env ?? operatorEnv()`,
|
|
188
|
+
// so this is byte-identical to the unfiltered behavior that predates the field.
|
|
189
|
+
const unset = v.parse(AgentConfigSchema, base);
|
|
190
|
+
assert.equal(agentEnv(unset), undefined);
|
|
191
|
+
// [] -> baseline-only: every ENV_BASELINE_KEYS entry actually present in
|
|
192
|
+
// process.env, and nothing else — SECRET_TOKEN must not leak through.
|
|
193
|
+
const empty = v.parse(AgentConfigSchema, { ...base, env_allowlist: [] });
|
|
194
|
+
const baselineOnly = agentEnv(empty);
|
|
195
|
+
const baselineKeys = ["PATH", "HOME", "USER", "LANG", "TERM", "TMPDIR"];
|
|
196
|
+
for (const key of Object.keys(baselineOnly)) {
|
|
197
|
+
assert.ok(baselineKeys.includes(key), `${key} is not a baseline key — [] must yield baseline-only`);
|
|
198
|
+
}
|
|
199
|
+
assert.equal(baselineOnly["SECRET_TOKEN"], undefined);
|
|
200
|
+
assert.equal(baselineOnly["MY_API_KEY"], undefined);
|
|
201
|
+
// ["MY_API_KEY"] -> baseline + MY_API_KEY, with SECRET_TOKEN provably absent.
|
|
202
|
+
const allowlisted = v.parse(AgentConfigSchema, { ...base, env_allowlist: ["MY_API_KEY"] });
|
|
203
|
+
const filtered = agentEnv(allowlisted);
|
|
204
|
+
assert.equal(filtered["MY_API_KEY"], "abc123");
|
|
205
|
+
assert.equal(filtered["SECRET_TOKEN"], undefined, "an unrelated secret must not survive the filter");
|
|
206
|
+
for (const key of Object.keys(filtered)) {
|
|
207
|
+
assert.ok(baselineKeys.includes(key) || key === "MY_API_KEY", `${key} leaked through the allowlist unexpectedly`);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
finally {
|
|
211
|
+
if (savedApiKey === undefined)
|
|
212
|
+
delete process.env["MY_API_KEY"];
|
|
213
|
+
else
|
|
214
|
+
process.env["MY_API_KEY"] = savedApiKey;
|
|
215
|
+
if (savedSecret === undefined)
|
|
216
|
+
delete process.env["SECRET_TOKEN"];
|
|
217
|
+
else
|
|
218
|
+
process.env["SECRET_TOKEN"] = savedSecret;
|
|
219
|
+
}
|
|
220
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import "./hermetic_git.ts";
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import "./hermetic_git.js";
|
|
2
|
+
/**
|
|
3
|
+
* `findRepoRoot()`'s contract is "never throws, always returns some root."
|
|
4
|
+
* `isRepoAt()` only proves `git rev-parse --git-dir` succeeds, which is also
|
|
5
|
+
* true inside a bare repo and inside a `.git/` directory itself — neither
|
|
6
|
+
* has a work tree, so the follow-up `--show-toplevel` call fails there even
|
|
7
|
+
* though `isRepoAt()` said yes. Regression coverage for that gap: before the
|
|
8
|
+
* fix, `findRepoRoot()` let that failure propagate as an uncaught throw,
|
|
9
|
+
* which — because `cli/index.ts` calls `paths.resolveAnchor()` outside its
|
|
10
|
+
* top-level try/catch — crashed every command (including `spf --version`)
|
|
11
|
+
* when run from a bare repo or from inside `.git/`.
|
|
12
|
+
*/
|
|
13
|
+
import { test } from "node:test";
|
|
14
|
+
import assert from "node:assert/strict";
|
|
15
|
+
import { execFileSync } from "node:child_process";
|
|
16
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
17
|
+
import { tmpdir } from "node:os";
|
|
18
|
+
import path from "node:path";
|
|
19
|
+
import { findRepoRoot } from "../core/git_helper.js";
|
|
20
|
+
test("findRepoRoot falls back to cwd inside a bare repo (no work tree)", () => {
|
|
21
|
+
const dir = mkdtempSync(path.join(tmpdir(), "spf-bare-"));
|
|
22
|
+
try {
|
|
23
|
+
execFileSync("git", ["init", "--bare", dir], { stdio: "ignore" });
|
|
24
|
+
const root = findRepoRoot(dir);
|
|
25
|
+
assert.equal(root, path.resolve(dir));
|
|
26
|
+
}
|
|
27
|
+
finally {
|
|
28
|
+
rmSync(dir, { recursive: true, force: true });
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
test("findRepoRoot falls back to cwd inside a repo's .git directory", () => {
|
|
32
|
+
const dir = mkdtempSync(path.join(tmpdir(), "spf-dotgit-"));
|
|
33
|
+
try {
|
|
34
|
+
execFileSync("git", ["init"], { cwd: dir, stdio: "ignore" });
|
|
35
|
+
const gitDir = path.join(dir, ".git");
|
|
36
|
+
const root = findRepoRoot(gitDir);
|
|
37
|
+
assert.equal(root, path.resolve(gitDir));
|
|
38
|
+
}
|
|
39
|
+
finally {
|
|
40
|
+
rmSync(dir, { recursive: true, force: true });
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
test("findRepoRoot resolves the toplevel of an ordinary work tree", () => {
|
|
44
|
+
const dir = mkdtempSync(path.join(tmpdir(), "spf-worktree-"));
|
|
45
|
+
try {
|
|
46
|
+
execFileSync("git", ["init"], { cwd: dir, stdio: "ignore" });
|
|
47
|
+
const sub = path.join(dir, "nested");
|
|
48
|
+
execFileSync("node", ["-e", `require("fs").mkdirSync(${JSON.stringify(sub)})`]);
|
|
49
|
+
const root = findRepoRoot(sub);
|
|
50
|
+
// git may resolve symlinked tmpdirs (e.g. macOS /tmp -> /private/tmp) —
|
|
51
|
+
// compare against what git itself reports as the raw toplevel is what
|
|
52
|
+
// resolveAnchor ultimately does too, so lean on findRepoRoot from the
|
|
53
|
+
// repo root itself for a stable assertion.
|
|
54
|
+
assert.equal(root, findRepoRoot(dir));
|
|
55
|
+
}
|
|
56
|
+
finally {
|
|
57
|
+
rmSync(dir, { recursive: true, force: true });
|
|
58
|
+
}
|
|
59
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Import this FIRST in any test file that spawns `git`.
|
|
3
|
+
*
|
|
4
|
+
* When the suite runs inside a git hook (lefthook's `pre-push`), git exports
|
|
5
|
+
* GIT_DIR — and sometimes GIT_WORK_TREE/GIT_INDEX_FILE — into every child
|
|
6
|
+
* process, and those beat `cwd` for every git invocation. A test that does
|
|
7
|
+
* `git init` / `git remote add` / `git commit` in a scratch tmpdir then
|
|
8
|
+
* silently operates on the REAL repository being pushed. Observed damage
|
|
9
|
+
* before this guard existed: a stray empty "init" commit landed on the
|
|
10
|
+
* checked-out branch, and `git init` re-initialized the shared `.git`
|
|
11
|
+
* directory as BARE (git treats a target directory named `.git` as a bare
|
|
12
|
+
* repo), breaking `git status` in the main checkout until `core.bare` was
|
|
13
|
+
* flipped back.
|
|
14
|
+
*
|
|
15
|
+
* Deleting the variables at module load — each test file is its own
|
|
16
|
+
* `node --test` process — makes `cwd` authoritative again for the whole
|
|
17
|
+
* file, including git calls made by the code under test.
|
|
18
|
+
*/
|
|
19
|
+
for (const key of ["GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_PREFIX", "GIT_COMMON_DIR", "GIT_OBJECT_DIRECTORY"]) {
|
|
20
|
+
delete process.env[key];
|
|
21
|
+
}
|
|
22
|
+
export {};
|
|
@@ -1 +1,14 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Regression guard for `spf init`'s non-interactive paths — `--yes`,
|
|
3
|
+
* `--template <name>`, and (implicitly, since the test runner's stdin/stdout
|
|
4
|
+
* are never a TTY) plain `spf init` with no flags. All three must keep
|
|
5
|
+
* writing the exact same content they always have, byte for byte, and must
|
|
6
|
+
* never touch stdin — a scripted `spf init` in CI must not hang.
|
|
7
|
+
*
|
|
8
|
+
* The interactive interview itself is covered directly against `Asker`
|
|
9
|
+
* in `interview.test.ts`; `initCommand`'s interactive branch always calls
|
|
10
|
+
* the real `createAsker()` (backed by `node:readline` on `process.stdin`),
|
|
11
|
+
* which has nothing to read in a test process — exercising it here would
|
|
12
|
+
* just hang, so it isn't.
|
|
13
|
+
*/
|
|
14
|
+
import "./hermetic_git.js";
|