@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/test/watch.test.js
CHANGED
|
@@ -1,7 +1,21 @@
|
|
|
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
|
+
import { escapeForMarkup, formatReviewDigest, truncateDigest } from "../cli/commands/watch.js";
|
|
6
|
+
/** A minimal, schema-shaped ReviewOutputT — only the fields formatReviewDigest reads. */
|
|
7
|
+
function reviewOutput(overrides = {}) {
|
|
8
|
+
return {
|
|
9
|
+
status: "success",
|
|
10
|
+
summary: "",
|
|
11
|
+
artifacts: [],
|
|
12
|
+
notes_for_next_agent: "",
|
|
13
|
+
approved: false,
|
|
14
|
+
findings: [],
|
|
15
|
+
blocking: [],
|
|
16
|
+
...overrides,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
5
19
|
/** In-memory fake — exactly the seam `provider.ts` exists for. */
|
|
6
20
|
class FakeProvider {
|
|
7
21
|
entries = new Map();
|
|
@@ -21,19 +35,21 @@ class FakeProvider {
|
|
|
21
35
|
async listInState(state) {
|
|
22
36
|
return [...this.entries.values()].filter((e) => e.state === state).map((e) => e.issue);
|
|
23
37
|
}
|
|
24
|
-
async claim(issue) {
|
|
38
|
+
async claim(issue, opts) {
|
|
25
39
|
this.claimCalls.push(issue.id);
|
|
26
40
|
const entry = this.entries.get(issue.id);
|
|
27
|
-
|
|
41
|
+
const from = opts?.from ?? "ready";
|
|
42
|
+
const to = opts?.to ?? "working";
|
|
43
|
+
if (entry.state !== from)
|
|
28
44
|
return false;
|
|
29
|
-
entry.state =
|
|
45
|
+
entry.state = to;
|
|
30
46
|
return true;
|
|
31
47
|
}
|
|
32
48
|
async transition(issue, to, detail) {
|
|
33
49
|
this.entries.get(issue.id).state = to;
|
|
34
50
|
this.transitions.push({ id: issue.id, to, detail });
|
|
35
51
|
}
|
|
36
|
-
async comment() { }
|
|
52
|
+
async comment(_issue, _body) { }
|
|
37
53
|
async readMarker(issue) {
|
|
38
54
|
return this.entries.get(issue.id)?.marker ?? null;
|
|
39
55
|
}
|
|
@@ -48,7 +64,7 @@ class FakeCodeHost {
|
|
|
48
64
|
nextPrNumber = 1000;
|
|
49
65
|
async openPr(opts) {
|
|
50
66
|
const number = this.nextPrNumber++;
|
|
51
|
-
this.openedPrs.push({ title: opts.title, branch: opts.branch });
|
|
67
|
+
this.openedPrs.push({ title: opts.title, branch: opts.branch, body: opts.body });
|
|
52
68
|
this.prs.set(number, { merged: false, state: "open", ciStatus: "pending" });
|
|
53
69
|
return { number, branch: opts.branch, url: `https://example.invalid/pr/${number}` };
|
|
54
70
|
}
|
|
@@ -91,6 +107,12 @@ function makeDeps(provider, codeHost, overrides = {}) {
|
|
|
91
107
|
chain: "plan-build-test",
|
|
92
108
|
baseBranch: "main",
|
|
93
109
|
concurrency: 2,
|
|
110
|
+
// Off by default, like WatchRefineConfigSchema itself — a test that
|
|
111
|
+
// doesn't override these never touches the refine lane at all.
|
|
112
|
+
refineEnabled: false,
|
|
113
|
+
refineConcurrency: 1,
|
|
114
|
+
refineChain: "refine",
|
|
115
|
+
runRefine: async (opts) => ({ accepted: true, adwId: opts.adwId, detail: "", created: [] }),
|
|
94
116
|
worktreesDir: "/tmp/spf-watch-test-worktrees",
|
|
95
117
|
linkDataDir: () => { },
|
|
96
118
|
dryRun: false,
|
|
@@ -382,6 +404,275 @@ test("reconcileOrphans: a routine orphan resume/retry notifies nothing — only
|
|
|
382
404
|
await reconcileOrphans(makeDeps(provider, codeHost, { notify }), state);
|
|
383
405
|
assert.deepEqual(events, []);
|
|
384
406
|
});
|
|
407
|
+
// ── the review digest threaded into the PR + pr_opened notification ────────
|
|
408
|
+
test("claimNewWork: a reviewSummary from runChain lands in the PR body and the pr_opened event's detail/fields", async () => {
|
|
409
|
+
const provider = new FakeProvider();
|
|
410
|
+
provider.addIssue("60", "Add a /health endpoint");
|
|
411
|
+
const codeHost = new FakeCodeHost();
|
|
412
|
+
const state = createWatchState();
|
|
413
|
+
const { events, notify } = collectNotifications();
|
|
414
|
+
const deps = makeDeps(provider, codeHost, {
|
|
415
|
+
notify,
|
|
416
|
+
runChain: async (opts) => ({
|
|
417
|
+
accepted: true,
|
|
418
|
+
adwId: opts.adwId,
|
|
419
|
+
detail: "",
|
|
420
|
+
reviewRequired: true,
|
|
421
|
+
reviewSummary: "Reviewer verdict: changes requested.\nBlocking:\n- add a test",
|
|
422
|
+
}),
|
|
423
|
+
});
|
|
424
|
+
await claimNewWork(deps, state);
|
|
425
|
+
await waitUntil(() => state.inflight.size === 0);
|
|
426
|
+
assert.match(codeHost.openedPrs[0].body, /Automated by `spf watch`/);
|
|
427
|
+
assert.match(codeHost.openedPrs[0].body, /Reviewer verdict: changes requested\.\nBlocking:\n- add a test/);
|
|
428
|
+
const prOpened = events.find((e) => e.kind === "pr_opened");
|
|
429
|
+
assert.equal(prOpened.detail, "Reviewer verdict: changes requested.\nBlocking:\n- add a test");
|
|
430
|
+
assert.deepEqual(prOpened.fields.find(([k]) => k === "review"), ["review", "reviewed"]);
|
|
431
|
+
});
|
|
432
|
+
test("claimNewWork: a reviewer-required chain with no readable verdict says so, distinctly from no reviewer at all", async () => {
|
|
433
|
+
const provider = new FakeProvider();
|
|
434
|
+
provider.addIssue("61", "Add a /health endpoint");
|
|
435
|
+
const codeHost = new FakeCodeHost();
|
|
436
|
+
const state = createWatchState();
|
|
437
|
+
const { events, notify } = collectNotifications();
|
|
438
|
+
const deps = makeDeps(provider, codeHost, {
|
|
439
|
+
notify,
|
|
440
|
+
runChain: async (opts) => ({ accepted: true, adwId: opts.adwId, detail: "", reviewRequired: true }),
|
|
441
|
+
});
|
|
442
|
+
await claimNewWork(deps, state);
|
|
443
|
+
await waitUntil(() => state.inflight.size === 0);
|
|
444
|
+
assert.match(codeHost.openedPrs[0].body, /no verdict could be read back/);
|
|
445
|
+
const prOpened = events.find((e) => e.kind === "pr_opened");
|
|
446
|
+
assert.deepEqual(prOpened.fields.find(([k]) => k === "review"), ["review", "reviewer ran, no verdict"]);
|
|
447
|
+
});
|
|
448
|
+
test("claimNewWork: a chain with no reviewer step says nothing reviewed this change", async () => {
|
|
449
|
+
const provider = new FakeProvider();
|
|
450
|
+
provider.addIssue("62", "Add a /health endpoint");
|
|
451
|
+
const codeHost = new FakeCodeHost();
|
|
452
|
+
const state = createWatchState();
|
|
453
|
+
const { events, notify } = collectNotifications();
|
|
454
|
+
// makeDeps' default runChain returns neither reviewRequired nor reviewSummary.
|
|
455
|
+
const deps = makeDeps(provider, codeHost, { notify });
|
|
456
|
+
await claimNewWork(deps, state);
|
|
457
|
+
await waitUntil(() => state.inflight.size === 0);
|
|
458
|
+
assert.match(codeHost.openedPrs[0].body, /Nothing reviewed this change — chain `plan-build-test` has no reviewer step\./);
|
|
459
|
+
const prOpened = events.find((e) => e.kind === "pr_opened");
|
|
460
|
+
assert.deepEqual(prOpened.fields.find(([k]) => k === "review"), ["review", "not reviewed"]);
|
|
461
|
+
});
|
|
462
|
+
// ── the refine lane ──────────────────────────────────────────────────────
|
|
463
|
+
test("refineBranchNameFor: same sanitizer as branchNameFor, different prefix", () => {
|
|
464
|
+
assert.equal(refineBranchNameFor({ id: "9", title: "Team invitations spec", body: "", labels: [] }), "spf-refine/9-team-invitations-spec");
|
|
465
|
+
});
|
|
466
|
+
test("claimSpecs: a disabled refine lane never lists spec-ready issues or claims anything", async () => {
|
|
467
|
+
const provider = new FakeProvider();
|
|
468
|
+
provider.addIssue("100", "A spec", "spec-ready");
|
|
469
|
+
const codeHost = new FakeCodeHost();
|
|
470
|
+
const state = createWatchState();
|
|
471
|
+
provider.listInState = async () => {
|
|
472
|
+
throw new Error("listInState should never be called when refineEnabled is false");
|
|
473
|
+
};
|
|
474
|
+
await claimSpecs(makeDeps(provider, codeHost, { refineEnabled: false }), state);
|
|
475
|
+
assert.equal(provider.claimCalls.length, 0);
|
|
476
|
+
});
|
|
477
|
+
test("claimSpecs: claims a spec-ready spec, publishes, and finishes it to done with a summary comment", async () => {
|
|
478
|
+
const provider = new FakeProvider();
|
|
479
|
+
provider.addIssue("101", "Team invitations", "spec-ready");
|
|
480
|
+
const codeHost = new FakeCodeHost();
|
|
481
|
+
const state = createWatchState();
|
|
482
|
+
const comments = [];
|
|
483
|
+
provider.comment = async (issue, body) => {
|
|
484
|
+
comments.push({ id: issue.id, body });
|
|
485
|
+
};
|
|
486
|
+
const deps = makeDeps(provider, codeHost, {
|
|
487
|
+
refineEnabled: true,
|
|
488
|
+
runRefine: async (opts) => ({
|
|
489
|
+
accepted: true,
|
|
490
|
+
adwId: opts.adwId,
|
|
491
|
+
detail: "",
|
|
492
|
+
created: [
|
|
493
|
+
{ id: "200", title: "Owner invites by email", kind: "story", isLeaf: true },
|
|
494
|
+
{ id: "199", title: "Invitations feature", kind: "feature", isLeaf: false },
|
|
495
|
+
],
|
|
496
|
+
}),
|
|
497
|
+
});
|
|
498
|
+
await claimSpecs(deps, state);
|
|
499
|
+
await waitUntil(() => state.refining.size === 0);
|
|
500
|
+
assert.deepEqual(provider.claimCalls, ["101"]);
|
|
501
|
+
assert.deepEqual(provider.transitions.map((t) => t.to), ["done"]);
|
|
502
|
+
assert.equal(comments.length, 1);
|
|
503
|
+
assert.match(comments[0].body, /#200 \(story\): Owner invites by email/);
|
|
504
|
+
assert.deepEqual(provider.entries.get("101").marker?.refined, ["200", "199"]);
|
|
505
|
+
});
|
|
506
|
+
test("claimSpecs: never claims more than refineConcurrency in one tick, independent of the build lane's own budget", async () => {
|
|
507
|
+
const provider = new FakeProvider();
|
|
508
|
+
provider.addIssue("110", "one spec", "spec-ready");
|
|
509
|
+
provider.addIssue("111", "two spec", "spec-ready");
|
|
510
|
+
const codeHost = new FakeCodeHost();
|
|
511
|
+
const state = createWatchState();
|
|
512
|
+
const deps = makeDeps(provider, codeHost, {
|
|
513
|
+
refineEnabled: true,
|
|
514
|
+
refineConcurrency: 1,
|
|
515
|
+
runRefine: () => new Promise(() => { }), // never resolves — keeps the claim "in flight" for this assertion
|
|
516
|
+
});
|
|
517
|
+
await claimSpecs(deps, state);
|
|
518
|
+
assert.equal(state.refining.size, 1);
|
|
519
|
+
assert.equal(provider.claimCalls.length, 1);
|
|
520
|
+
});
|
|
521
|
+
test("claimSpecs: dry-run claims no spec and never calls runRefine", async () => {
|
|
522
|
+
const provider = new FakeProvider();
|
|
523
|
+
provider.addIssue("120", "dry run spec", "spec-ready");
|
|
524
|
+
const codeHost = new FakeCodeHost();
|
|
525
|
+
const state = createWatchState();
|
|
526
|
+
let called = false;
|
|
527
|
+
const deps = makeDeps(provider, codeHost, {
|
|
528
|
+
refineEnabled: true,
|
|
529
|
+
dryRun: true,
|
|
530
|
+
runRefine: async (opts) => {
|
|
531
|
+
called = true;
|
|
532
|
+
return { accepted: true, adwId: opts.adwId, detail: "", created: [] };
|
|
533
|
+
},
|
|
534
|
+
});
|
|
535
|
+
await claimSpecs(deps, state);
|
|
536
|
+
assert.equal(provider.claimCalls.length, 0);
|
|
537
|
+
assert.equal(called, false);
|
|
538
|
+
assert.equal(provider.entries.get("120").state, "spec-ready");
|
|
539
|
+
});
|
|
540
|
+
test("claimSpecs: a rejected refine chain blocks the spec with the failure detail, without publishing anything", async () => {
|
|
541
|
+
const provider = new FakeProvider();
|
|
542
|
+
provider.addIssue("130", "unrefinable spec", "spec-ready");
|
|
543
|
+
const codeHost = new FakeCodeHost();
|
|
544
|
+
const state = createWatchState();
|
|
545
|
+
const deps = makeDeps(provider, codeHost, {
|
|
546
|
+
refineEnabled: true,
|
|
547
|
+
runRefine: async (opts) => ({ accepted: false, adwId: opts.adwId, detail: "refiner failed gates", created: [] }),
|
|
548
|
+
});
|
|
549
|
+
await claimSpecs(deps, state);
|
|
550
|
+
await waitUntil(() => state.refining.size === 0);
|
|
551
|
+
assert.deepEqual(provider.transitions, [{ id: "130", to: "blocked", detail: "refiner failed gates" }]);
|
|
552
|
+
});
|
|
553
|
+
test("claimSpecs: a re-claimed spec whose marker already lists published issues skips runRefine entirely", async () => {
|
|
554
|
+
const provider = new FakeProvider();
|
|
555
|
+
provider.addIssue("140", "already published", "spec-ready", { refined: ["300", "301"] });
|
|
556
|
+
const codeHost = new FakeCodeHost();
|
|
557
|
+
const state = createWatchState();
|
|
558
|
+
let called = false;
|
|
559
|
+
const deps = makeDeps(provider, codeHost, {
|
|
560
|
+
refineEnabled: true,
|
|
561
|
+
runRefine: async (opts) => {
|
|
562
|
+
called = true;
|
|
563
|
+
return { accepted: true, adwId: opts.adwId, detail: "", created: [] };
|
|
564
|
+
},
|
|
565
|
+
});
|
|
566
|
+
await claimSpecs(deps, state);
|
|
567
|
+
await waitUntil(() => state.refining.size === 0);
|
|
568
|
+
assert.equal(called, false, "to-tickets itself has no idempotency guard — this is the one this lane adds");
|
|
569
|
+
assert.deepEqual(provider.transitions.map((t) => t.to), ["done"]);
|
|
570
|
+
});
|
|
571
|
+
test("reconcileRefining: a refining spec whose marker already lists published issues finishes it without re-running the refiner", async () => {
|
|
572
|
+
const provider = new FakeProvider();
|
|
573
|
+
provider.addIssue("150", "orphaned after publish", "refining", { refined: ["400"] });
|
|
574
|
+
const codeHost = new FakeCodeHost();
|
|
575
|
+
const state = createWatchState();
|
|
576
|
+
await reconcileRefining(makeDeps(provider, codeHost, { refineEnabled: true }), state);
|
|
577
|
+
assert.deepEqual(provider.transitions, [{ id: "150", to: "done", detail: undefined }]);
|
|
578
|
+
});
|
|
579
|
+
test("reconcileRefining: a refining spec with no marker retries up to the cap, then blocks", async () => {
|
|
580
|
+
const provider = new FakeProvider();
|
|
581
|
+
provider.addIssue("151", "orphaned, no marker", "refining", null);
|
|
582
|
+
const codeHost = new FakeCodeHost();
|
|
583
|
+
const state = createWatchState();
|
|
584
|
+
const deps = makeDeps(provider, codeHost, { refineEnabled: true });
|
|
585
|
+
await reconcileRefining(deps, state); // attempt 1 -> spec-ready
|
|
586
|
+
assert.equal(provider.entries.get("151").state, "spec-ready");
|
|
587
|
+
provider.entries.get("151").state = "refining";
|
|
588
|
+
await reconcileRefining(deps, state); // attempt 2 -> spec-ready
|
|
589
|
+
assert.equal(provider.entries.get("151").state, "spec-ready");
|
|
590
|
+
provider.entries.get("151").state = "refining";
|
|
591
|
+
await reconcileRefining(deps, state); // attempt 3 exceeds MAX_ORPHAN_ATTEMPTS (2) -> blocked
|
|
592
|
+
assert.equal(provider.entries.get("151").state, "blocked");
|
|
593
|
+
assert.match(provider.transitions.at(-1)?.detail ?? "", /Gave up after 2 orphaned refine attempts/);
|
|
594
|
+
});
|
|
595
|
+
test("reconcileRefining: a disabled refine lane is a complete no-op", async () => {
|
|
596
|
+
const provider = new FakeProvider();
|
|
597
|
+
provider.addIssue("152", "should be ignored", "refining", null);
|
|
598
|
+
const codeHost = new FakeCodeHost();
|
|
599
|
+
const state = createWatchState();
|
|
600
|
+
provider.listInState = async () => {
|
|
601
|
+
throw new Error("listInState should never be called when refineEnabled is false");
|
|
602
|
+
};
|
|
603
|
+
await reconcileRefining(makeDeps(provider, codeHost, { refineEnabled: false }), state);
|
|
604
|
+
assert.equal(provider.transitions.length, 0);
|
|
605
|
+
});
|
|
606
|
+
test("claim -> refine -> published fires issue_claimed then spec_refined, both info-level", async () => {
|
|
607
|
+
const provider = new FakeProvider();
|
|
608
|
+
provider.addIssue("160", "A spec", "spec-ready");
|
|
609
|
+
const codeHost = new FakeCodeHost();
|
|
610
|
+
const state = createWatchState();
|
|
611
|
+
const { events, notify } = collectNotifications();
|
|
612
|
+
const deps = makeDeps(provider, codeHost, {
|
|
613
|
+
refineEnabled: true,
|
|
614
|
+
notify,
|
|
615
|
+
runRefine: async (opts) => ({ accepted: true, adwId: opts.adwId, detail: "", created: [{ id: "500", title: "A story", kind: "story", isLeaf: true }] }),
|
|
616
|
+
});
|
|
617
|
+
await claimSpecs(deps, state);
|
|
618
|
+
await waitUntil(() => state.refining.size === 0);
|
|
619
|
+
assert.deepEqual(events.map((e) => e.kind), ["issue_claimed", "spec_refined"]);
|
|
620
|
+
assert.ok(events.every((e) => e.level === "info"));
|
|
621
|
+
});
|
|
622
|
+
// ── escapeForMarkup / truncateDigest / formatReviewDigest ───────────────────
|
|
623
|
+
// These three are what actually assembles the digest — the tests above only
|
|
624
|
+
// exercise core/watch.ts's three-way reviewLine selection via a stubbed
|
|
625
|
+
// runChain, so none of them ever call into these. Slack/GitHub markup
|
|
626
|
+
// escaping is the one security-relevant behavior spec item B calls out by
|
|
627
|
+
// name (a reviewer's findings are LLM-authored, hence untrusted), so it needs
|
|
628
|
+
// direct coverage.
|
|
629
|
+
test("escapeForMarkup: a Slack link-forgery attempt is neutralized, and & is escaped without double-escaping", () => {
|
|
630
|
+
const escaped = escapeForMarkup("<https://evil.example/|click here> & enjoy");
|
|
631
|
+
assert.equal(escaped, "<https://evil.example/|click here> & enjoy");
|
|
632
|
+
assert.ok(!escaped.includes("<") && !escaped.includes(">"), "no raw angle bracket must survive — that's what prevents a forged Slack mrkdwn link");
|
|
633
|
+
});
|
|
634
|
+
test("formatReviewDigest: an approved review's met findings are the actual signal, and are surfaced (not silently dropped)", () => {
|
|
635
|
+
const review = reviewOutput({
|
|
636
|
+
approved: true,
|
|
637
|
+
findings: [
|
|
638
|
+
{ requirement: "endpoint returns 200", met: true, evidence: "curl output attached" },
|
|
639
|
+
{ requirement: "handles missing auth header", met: true, evidence: "" },
|
|
640
|
+
{ requirement: "logs the request id", met: true, evidence: "trace shows request_id field" },
|
|
641
|
+
],
|
|
642
|
+
blocking: [],
|
|
643
|
+
});
|
|
644
|
+
const digest = formatReviewDigest(review);
|
|
645
|
+
assert.match(digest, /^Reviewer verdict: approved\./);
|
|
646
|
+
assert.match(digest, /Verified 3 requirement\(s\):/);
|
|
647
|
+
assert.match(digest, /- endpoint returns 200 — curl output attached/);
|
|
648
|
+
assert.match(digest, /- handles missing auth header/);
|
|
649
|
+
assert.match(digest, /- logs the request id — trace shows request_id field/);
|
|
650
|
+
});
|
|
651
|
+
test("formatReviewDigest: blocking items and unmet findings are escaped before being joined in", () => {
|
|
652
|
+
const review = reviewOutput({
|
|
653
|
+
approved: false,
|
|
654
|
+
blocking: ["contains a <https://evil.example/|forged link> & needs fixing"],
|
|
655
|
+
findings: [{ requirement: "auth check <script>", met: false, evidence: "not implemented & untested" }],
|
|
656
|
+
});
|
|
657
|
+
const digest = formatReviewDigest(review);
|
|
658
|
+
assert.match(digest, /^Reviewer verdict: changes requested\./);
|
|
659
|
+
assert.match(digest, /- contains a <https:\/\/evil\.example\/\|forged link> & needs fixing/);
|
|
660
|
+
assert.match(digest, /- auth check <script> — not implemented & untested/);
|
|
661
|
+
assert.ok(!digest.includes("<script>") && !digest.includes("<https"), "raw markup from an unmet finding must not survive into the digest");
|
|
662
|
+
});
|
|
663
|
+
test("truncateDigest: a digest over the cap is cut at exactly the codepoint boundary, without splitting a surrogate pair", () => {
|
|
664
|
+
// An astral-plane emoji (2 UTF-16 code units, 1 codepoint) placed exactly
|
|
665
|
+
// at the 1200th codepoint — a naive `.slice(0, 1200)` on UTF-16 units would
|
|
666
|
+
// land mid-pair and emit a lone, invalid surrogate instead of the emoji.
|
|
667
|
+
const emoji = "\u{1F600}"; // grinning face, codepoint #1200
|
|
668
|
+
const filler = "a".repeat(1199); // codepoints #1-1199
|
|
669
|
+
const text = filler + emoji + "more text that must be cut off entirely";
|
|
670
|
+
const result = truncateDigest(text);
|
|
671
|
+
assert.equal(result, filler + emoji + "…", "must keep exactly 1200 whole codepoints (the emoji intact) then the ellipsis");
|
|
672
|
+
assert.ok(result.endsWith("…"), "a truncated digest must end with the ellipsis marker");
|
|
673
|
+
const short = "short digest, well under the cap";
|
|
674
|
+
assert.equal(truncateDigest(short), short, "text under the cap must pass through unchanged, with no ellipsis appended");
|
|
675
|
+
});
|
|
385
676
|
test("tick: a per-stage error is caught and fires exactly one watch_error", async () => {
|
|
386
677
|
const provider = new FakeProvider();
|
|
387
678
|
const codeHost = new FakeCodeHost();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gr8ful/spf",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Super Portable Factory — a global CLI for repeatable agents-plus-code workflows (ADWs)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -18,10 +18,6 @@
|
|
|
18
18
|
"README.md",
|
|
19
19
|
"LICENSE"
|
|
20
20
|
],
|
|
21
|
-
"exports": {
|
|
22
|
-
".": "./dist/index.js",
|
|
23
|
-
"./core": "./dist/core/index.js"
|
|
24
|
-
},
|
|
25
21
|
"engines": {
|
|
26
22
|
"node": ">=22.19.0"
|
|
27
23
|
},
|
|
@@ -44,6 +40,7 @@
|
|
|
44
40
|
"tag:patch": "node scripts/tag-release.js patch"
|
|
45
41
|
},
|
|
46
42
|
"dependencies": {
|
|
43
|
+
"@earendil-works/pi-ai": "0.83.0",
|
|
47
44
|
"@flue/runtime": "2.0.3",
|
|
48
45
|
"@hono/node-server": "^2.1.1",
|
|
49
46
|
"@valibot/to-json-schema": "^1.7.1",
|
|
@@ -51,6 +48,9 @@
|
|
|
51
48
|
"valibot": "^1.4.2",
|
|
52
49
|
"yaml": "^2.5.1"
|
|
53
50
|
},
|
|
51
|
+
"overrides": {
|
|
52
|
+
"@earendil-works/pi-ai": "0.83.0"
|
|
53
|
+
},
|
|
54
54
|
"devDependencies": {
|
|
55
55
|
"@types/node": "^22.10.0",
|
|
56
56
|
"lefthook": "^2.1.10",
|