@gr8ful/spf 0.3.0 → 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 +55 -5
- 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 +38 -2
- package/assets/templates/ts-cc.spf.config.yaml +3 -3
- package/assets/templates/ts.spf.config.yaml +10 -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 +6 -0
- package/dist/cli/commands/init.js +30 -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 +69 -8
- package/dist/cli/index.js +3 -3
- package/dist/cli/interview.js +17 -0
- package/dist/core/data_types.d.ts +78 -0
- package/dist/core/data_types.js +42 -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 +1 -1
- package/dist/core/refine.d.ts +39 -0
- package/dist/core/refine.js +144 -0
- package/dist/core/watch.d.ts +56 -1
- package/dist/core/watch.js +200 -11
- package/dist/test/chains.test.js +1 -0
- package/dist/test/init_command.test.js +17 -0
- package/dist/test/interview.test.js +4 -3
- package/dist/test/refine.test.d.ts +1 -0
- package/dist/test/refine.test.js +126 -0
- package/dist/test/watch.test.js +173 -5
- package/package.json +1 -1
package/dist/test/watch.test.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { test } from "node:test";
|
|
2
2
|
import assert from "node:assert/strict";
|
|
3
3
|
import path from "node:path";
|
|
4
|
-
import { branchNameFor, claimNewWork, createWatchState, finishReviews, reconcileOrphans, tick } from "../core/watch.js";
|
|
4
|
+
import { branchNameFor, claimNewWork, claimSpecs, createWatchState, finishReviews, reconcileOrphans, reconcileRefining, refineBranchNameFor, tick, } from "../core/watch.js";
|
|
5
5
|
/** In-memory fake — exactly the seam `provider.ts` exists for. */
|
|
6
6
|
class FakeProvider {
|
|
7
7
|
entries = new Map();
|
|
@@ -21,19 +21,21 @@ class FakeProvider {
|
|
|
21
21
|
async listInState(state) {
|
|
22
22
|
return [...this.entries.values()].filter((e) => e.state === state).map((e) => e.issue);
|
|
23
23
|
}
|
|
24
|
-
async claim(issue) {
|
|
24
|
+
async claim(issue, opts) {
|
|
25
25
|
this.claimCalls.push(issue.id);
|
|
26
26
|
const entry = this.entries.get(issue.id);
|
|
27
|
-
|
|
27
|
+
const from = opts?.from ?? "ready";
|
|
28
|
+
const to = opts?.to ?? "working";
|
|
29
|
+
if (entry.state !== from)
|
|
28
30
|
return false;
|
|
29
|
-
entry.state =
|
|
31
|
+
entry.state = to;
|
|
30
32
|
return true;
|
|
31
33
|
}
|
|
32
34
|
async transition(issue, to, detail) {
|
|
33
35
|
this.entries.get(issue.id).state = to;
|
|
34
36
|
this.transitions.push({ id: issue.id, to, detail });
|
|
35
37
|
}
|
|
36
|
-
async comment() { }
|
|
38
|
+
async comment(_issue, _body) { }
|
|
37
39
|
async readMarker(issue) {
|
|
38
40
|
return this.entries.get(issue.id)?.marker ?? null;
|
|
39
41
|
}
|
|
@@ -91,6 +93,12 @@ function makeDeps(provider, codeHost, overrides = {}) {
|
|
|
91
93
|
chain: "plan-build-test",
|
|
92
94
|
baseBranch: "main",
|
|
93
95
|
concurrency: 2,
|
|
96
|
+
// Off by default, like WatchRefineConfigSchema itself — a test that
|
|
97
|
+
// doesn't override these never touches the refine lane at all.
|
|
98
|
+
refineEnabled: false,
|
|
99
|
+
refineConcurrency: 1,
|
|
100
|
+
refineChain: "refine",
|
|
101
|
+
runRefine: async (opts) => ({ accepted: true, adwId: opts.adwId, detail: "", created: [] }),
|
|
94
102
|
worktreesDir: "/tmp/spf-watch-test-worktrees",
|
|
95
103
|
linkDataDir: () => { },
|
|
96
104
|
dryRun: false,
|
|
@@ -382,6 +390,166 @@ test("reconcileOrphans: a routine orphan resume/retry notifies nothing — only
|
|
|
382
390
|
await reconcileOrphans(makeDeps(provider, codeHost, { notify }), state);
|
|
383
391
|
assert.deepEqual(events, []);
|
|
384
392
|
});
|
|
393
|
+
// ── the refine lane ──────────────────────────────────────────────────────
|
|
394
|
+
test("refineBranchNameFor: same sanitizer as branchNameFor, different prefix", () => {
|
|
395
|
+
assert.equal(refineBranchNameFor({ id: "9", title: "Team invitations spec", body: "", labels: [] }), "spf-refine/9-team-invitations-spec");
|
|
396
|
+
});
|
|
397
|
+
test("claimSpecs: a disabled refine lane never lists spec-ready issues or claims anything", async () => {
|
|
398
|
+
const provider = new FakeProvider();
|
|
399
|
+
provider.addIssue("100", "A spec", "spec-ready");
|
|
400
|
+
const codeHost = new FakeCodeHost();
|
|
401
|
+
const state = createWatchState();
|
|
402
|
+
provider.listInState = async () => {
|
|
403
|
+
throw new Error("listInState should never be called when refineEnabled is false");
|
|
404
|
+
};
|
|
405
|
+
await claimSpecs(makeDeps(provider, codeHost, { refineEnabled: false }), state);
|
|
406
|
+
assert.equal(provider.claimCalls.length, 0);
|
|
407
|
+
});
|
|
408
|
+
test("claimSpecs: claims a spec-ready spec, publishes, and finishes it to done with a summary comment", async () => {
|
|
409
|
+
const provider = new FakeProvider();
|
|
410
|
+
provider.addIssue("101", "Team invitations", "spec-ready");
|
|
411
|
+
const codeHost = new FakeCodeHost();
|
|
412
|
+
const state = createWatchState();
|
|
413
|
+
const comments = [];
|
|
414
|
+
provider.comment = async (issue, body) => {
|
|
415
|
+
comments.push({ id: issue.id, body });
|
|
416
|
+
};
|
|
417
|
+
const deps = makeDeps(provider, codeHost, {
|
|
418
|
+
refineEnabled: true,
|
|
419
|
+
runRefine: async (opts) => ({
|
|
420
|
+
accepted: true,
|
|
421
|
+
adwId: opts.adwId,
|
|
422
|
+
detail: "",
|
|
423
|
+
created: [
|
|
424
|
+
{ id: "200", title: "Owner invites by email", kind: "story", isLeaf: true },
|
|
425
|
+
{ id: "199", title: "Invitations feature", kind: "feature", isLeaf: false },
|
|
426
|
+
],
|
|
427
|
+
}),
|
|
428
|
+
});
|
|
429
|
+
await claimSpecs(deps, state);
|
|
430
|
+
await waitUntil(() => state.refining.size === 0);
|
|
431
|
+
assert.deepEqual(provider.claimCalls, ["101"]);
|
|
432
|
+
assert.deepEqual(provider.transitions.map((t) => t.to), ["done"]);
|
|
433
|
+
assert.equal(comments.length, 1);
|
|
434
|
+
assert.match(comments[0].body, /#200 \(story\): Owner invites by email/);
|
|
435
|
+
assert.deepEqual(provider.entries.get("101").marker?.refined, ["200", "199"]);
|
|
436
|
+
});
|
|
437
|
+
test("claimSpecs: never claims more than refineConcurrency in one tick, independent of the build lane's own budget", async () => {
|
|
438
|
+
const provider = new FakeProvider();
|
|
439
|
+
provider.addIssue("110", "one spec", "spec-ready");
|
|
440
|
+
provider.addIssue("111", "two spec", "spec-ready");
|
|
441
|
+
const codeHost = new FakeCodeHost();
|
|
442
|
+
const state = createWatchState();
|
|
443
|
+
const deps = makeDeps(provider, codeHost, {
|
|
444
|
+
refineEnabled: true,
|
|
445
|
+
refineConcurrency: 1,
|
|
446
|
+
runRefine: () => new Promise(() => { }), // never resolves — keeps the claim "in flight" for this assertion
|
|
447
|
+
});
|
|
448
|
+
await claimSpecs(deps, state);
|
|
449
|
+
assert.equal(state.refining.size, 1);
|
|
450
|
+
assert.equal(provider.claimCalls.length, 1);
|
|
451
|
+
});
|
|
452
|
+
test("claimSpecs: dry-run claims no spec and never calls runRefine", async () => {
|
|
453
|
+
const provider = new FakeProvider();
|
|
454
|
+
provider.addIssue("120", "dry run spec", "spec-ready");
|
|
455
|
+
const codeHost = new FakeCodeHost();
|
|
456
|
+
const state = createWatchState();
|
|
457
|
+
let called = false;
|
|
458
|
+
const deps = makeDeps(provider, codeHost, {
|
|
459
|
+
refineEnabled: true,
|
|
460
|
+
dryRun: true,
|
|
461
|
+
runRefine: async (opts) => {
|
|
462
|
+
called = true;
|
|
463
|
+
return { accepted: true, adwId: opts.adwId, detail: "", created: [] };
|
|
464
|
+
},
|
|
465
|
+
});
|
|
466
|
+
await claimSpecs(deps, state);
|
|
467
|
+
assert.equal(provider.claimCalls.length, 0);
|
|
468
|
+
assert.equal(called, false);
|
|
469
|
+
assert.equal(provider.entries.get("120").state, "spec-ready");
|
|
470
|
+
});
|
|
471
|
+
test("claimSpecs: a rejected refine chain blocks the spec with the failure detail, without publishing anything", async () => {
|
|
472
|
+
const provider = new FakeProvider();
|
|
473
|
+
provider.addIssue("130", "unrefinable spec", "spec-ready");
|
|
474
|
+
const codeHost = new FakeCodeHost();
|
|
475
|
+
const state = createWatchState();
|
|
476
|
+
const deps = makeDeps(provider, codeHost, {
|
|
477
|
+
refineEnabled: true,
|
|
478
|
+
runRefine: async (opts) => ({ accepted: false, adwId: opts.adwId, detail: "refiner failed gates", created: [] }),
|
|
479
|
+
});
|
|
480
|
+
await claimSpecs(deps, state);
|
|
481
|
+
await waitUntil(() => state.refining.size === 0);
|
|
482
|
+
assert.deepEqual(provider.transitions, [{ id: "130", to: "blocked", detail: "refiner failed gates" }]);
|
|
483
|
+
});
|
|
484
|
+
test("claimSpecs: a re-claimed spec whose marker already lists published issues skips runRefine entirely", async () => {
|
|
485
|
+
const provider = new FakeProvider();
|
|
486
|
+
provider.addIssue("140", "already published", "spec-ready", { refined: ["300", "301"] });
|
|
487
|
+
const codeHost = new FakeCodeHost();
|
|
488
|
+
const state = createWatchState();
|
|
489
|
+
let called = false;
|
|
490
|
+
const deps = makeDeps(provider, codeHost, {
|
|
491
|
+
refineEnabled: true,
|
|
492
|
+
runRefine: async (opts) => {
|
|
493
|
+
called = true;
|
|
494
|
+
return { accepted: true, adwId: opts.adwId, detail: "", created: [] };
|
|
495
|
+
},
|
|
496
|
+
});
|
|
497
|
+
await claimSpecs(deps, state);
|
|
498
|
+
await waitUntil(() => state.refining.size === 0);
|
|
499
|
+
assert.equal(called, false, "to-tickets itself has no idempotency guard — this is the one this lane adds");
|
|
500
|
+
assert.deepEqual(provider.transitions.map((t) => t.to), ["done"]);
|
|
501
|
+
});
|
|
502
|
+
test("reconcileRefining: a refining spec whose marker already lists published issues finishes it without re-running the refiner", async () => {
|
|
503
|
+
const provider = new FakeProvider();
|
|
504
|
+
provider.addIssue("150", "orphaned after publish", "refining", { refined: ["400"] });
|
|
505
|
+
const codeHost = new FakeCodeHost();
|
|
506
|
+
const state = createWatchState();
|
|
507
|
+
await reconcileRefining(makeDeps(provider, codeHost, { refineEnabled: true }), state);
|
|
508
|
+
assert.deepEqual(provider.transitions, [{ id: "150", to: "done", detail: undefined }]);
|
|
509
|
+
});
|
|
510
|
+
test("reconcileRefining: a refining spec with no marker retries up to the cap, then blocks", async () => {
|
|
511
|
+
const provider = new FakeProvider();
|
|
512
|
+
provider.addIssue("151", "orphaned, no marker", "refining", null);
|
|
513
|
+
const codeHost = new FakeCodeHost();
|
|
514
|
+
const state = createWatchState();
|
|
515
|
+
const deps = makeDeps(provider, codeHost, { refineEnabled: true });
|
|
516
|
+
await reconcileRefining(deps, state); // attempt 1 -> spec-ready
|
|
517
|
+
assert.equal(provider.entries.get("151").state, "spec-ready");
|
|
518
|
+
provider.entries.get("151").state = "refining";
|
|
519
|
+
await reconcileRefining(deps, state); // attempt 2 -> spec-ready
|
|
520
|
+
assert.equal(provider.entries.get("151").state, "spec-ready");
|
|
521
|
+
provider.entries.get("151").state = "refining";
|
|
522
|
+
await reconcileRefining(deps, state); // attempt 3 exceeds MAX_ORPHAN_ATTEMPTS (2) -> blocked
|
|
523
|
+
assert.equal(provider.entries.get("151").state, "blocked");
|
|
524
|
+
assert.match(provider.transitions.at(-1)?.detail ?? "", /Gave up after 2 orphaned refine attempts/);
|
|
525
|
+
});
|
|
526
|
+
test("reconcileRefining: a disabled refine lane is a complete no-op", async () => {
|
|
527
|
+
const provider = new FakeProvider();
|
|
528
|
+
provider.addIssue("152", "should be ignored", "refining", null);
|
|
529
|
+
const codeHost = new FakeCodeHost();
|
|
530
|
+
const state = createWatchState();
|
|
531
|
+
provider.listInState = async () => {
|
|
532
|
+
throw new Error("listInState should never be called when refineEnabled is false");
|
|
533
|
+
};
|
|
534
|
+
await reconcileRefining(makeDeps(provider, codeHost, { refineEnabled: false }), state);
|
|
535
|
+
assert.equal(provider.transitions.length, 0);
|
|
536
|
+
});
|
|
537
|
+
test("claim -> refine -> published fires issue_claimed then spec_refined, both info-level", async () => {
|
|
538
|
+
const provider = new FakeProvider();
|
|
539
|
+
provider.addIssue("160", "A spec", "spec-ready");
|
|
540
|
+
const codeHost = new FakeCodeHost();
|
|
541
|
+
const state = createWatchState();
|
|
542
|
+
const { events, notify } = collectNotifications();
|
|
543
|
+
const deps = makeDeps(provider, codeHost, {
|
|
544
|
+
refineEnabled: true,
|
|
545
|
+
notify,
|
|
546
|
+
runRefine: async (opts) => ({ accepted: true, adwId: opts.adwId, detail: "", created: [{ id: "500", title: "A story", kind: "story", isLeaf: true }] }),
|
|
547
|
+
});
|
|
548
|
+
await claimSpecs(deps, state);
|
|
549
|
+
await waitUntil(() => state.refining.size === 0);
|
|
550
|
+
assert.deepEqual(events.map((e) => e.kind), ["issue_claimed", "spec_refined"]);
|
|
551
|
+
assert.ok(events.every((e) => e.level === "info"));
|
|
552
|
+
});
|
|
385
553
|
test("tick: a per-stage error is caught and fires exactly one watch_error", async () => {
|
|
386
554
|
const provider = new FakeProvider();
|
|
387
555
|
const codeHost = new FakeCodeHost();
|