@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.
Files changed (91) hide show
  1. package/README.md +177 -9
  2. package/assets/defaults/spf.config.yaml +22 -0
  3. package/assets/prompts/refiner/system.md +53 -0
  4. package/assets/prompts/refiner/user.md +70 -0
  5. package/assets/prompts/reviewer/system.md +1 -1
  6. package/assets/skill/SKILL.md +1 -0
  7. package/assets/skill/cookbooks/authoring_chains.md +90 -7
  8. package/assets/skill/cookbooks/ocr_reviewer.md +196 -0
  9. package/assets/skill/cookbooks/roster.md +15 -4
  10. package/assets/skill/cookbooks/spf_overview.md +1 -0
  11. package/assets/skill/references/config.md +104 -3
  12. package/assets/skill/references/observability.md +11 -2
  13. package/assets/templates/ts-cc.spf.config.yaml +3 -3
  14. package/assets/templates/ts-flue-ollama.spf.config.yaml +67 -0
  15. package/assets/templates/ts.spf.config.yaml +15 -2
  16. package/dist/chains/context.d.ts +39 -0
  17. package/dist/chains/index.d.ts +94 -10
  18. package/dist/chains/index.js +75 -5
  19. package/dist/chains/repo_chains.d.ts +139 -0
  20. package/dist/chains/repo_chains.js +428 -0
  21. package/dist/chains/simple_sdlc.d.ts +74 -1
  22. package/dist/chains/simple_sdlc.js +134 -4
  23. package/dist/chains/steps.d.ts +237 -18
  24. package/dist/chains/steps.js +477 -58
  25. package/dist/cli/ask.d.ts +14 -1
  26. package/dist/cli/ask.js +32 -2
  27. package/dist/cli/commands/doctor.d.ts +1 -1
  28. package/dist/cli/commands/doctor.js +324 -10
  29. package/dist/cli/commands/init.d.ts +12 -0
  30. package/dist/cli/commands/init.js +108 -4
  31. package/dist/cli/commands/install-skill.js +5 -2
  32. package/dist/cli/commands/list.js +43 -5
  33. package/dist/cli/commands/run.js +29 -2
  34. package/dist/cli/commands/watch.d.ts +18 -0
  35. package/dist/cli/commands/watch.js +214 -16
  36. package/dist/cli/index.js +63 -6
  37. package/dist/cli/interview.js +81 -9
  38. package/dist/core/agent_cc.d.ts +40 -1
  39. package/dist/core/agent_cc.js +51 -4
  40. package/dist/core/agent_flue.js +28 -4
  41. package/dist/core/agents.d.ts +8 -0
  42. package/dist/core/agents.js +43 -3
  43. package/dist/core/data_types.d.ts +182 -4
  44. package/dist/core/data_types.js +141 -2
  45. package/dist/core/gates.d.ts +13 -0
  46. package/dist/core/gates.js +103 -0
  47. package/dist/core/git_helper.d.ts +29 -0
  48. package/dist/core/git_helper.js +41 -1
  49. package/dist/core/issues/github_provider.d.ts +35 -9
  50. package/dist/core/issues/github_provider.js +76 -28
  51. package/dist/core/issues/jira_provider.d.ts +14 -1
  52. package/dist/core/issues/jira_provider.js +9 -7
  53. package/dist/core/issues/provider.d.ts +77 -15
  54. package/dist/core/issues/provider.js +7 -4
  55. package/dist/core/notify/channel.d.ts +1 -1
  56. package/dist/core/ollama_provider.d.ts +70 -0
  57. package/dist/core/ollama_provider.js +208 -0
  58. package/dist/core/otel.d.ts +352 -0
  59. package/dist/core/otel.js +793 -0
  60. package/dist/core/providers.js +4 -0
  61. package/dist/core/refine.d.ts +39 -0
  62. package/dist/core/refine.js +152 -0
  63. package/dist/core/session.js +39 -2
  64. package/dist/core/tracer.d.ts +31 -2
  65. package/dist/core/tracer.js +69 -11
  66. package/dist/core/watch.d.ts +67 -1
  67. package/dist/core/watch.js +217 -13
  68. package/dist/test/chains.test.js +9 -3
  69. package/dist/test/data_types.test.js +140 -2
  70. package/dist/test/git_helper.test.d.ts +1 -0
  71. package/dist/test/git_helper.test.js +59 -0
  72. package/dist/test/hermetic_git.d.ts +1 -0
  73. package/dist/test/hermetic_git.js +22 -0
  74. package/dist/test/init_command.test.d.ts +14 -1
  75. package/dist/test/init_command.test.js +71 -1
  76. package/dist/test/interview.test.d.ts +15 -1
  77. package/dist/test/interview.test.js +131 -3
  78. package/dist/test/ollama_provider.test.d.ts +1 -0
  79. package/dist/test/ollama_provider.test.js +103 -0
  80. package/dist/test/otel.test.d.ts +26 -0
  81. package/dist/test/otel.test.js +512 -0
  82. package/dist/test/refine.test.d.ts +1 -0
  83. package/dist/test/refine.test.js +189 -0
  84. package/dist/test/repo_chains.test.d.ts +21 -0
  85. package/dist/test/repo_chains.test.js +416 -0
  86. package/dist/test/signoff.test.d.ts +1 -0
  87. package/dist/test/signoff.test.js +329 -0
  88. package/dist/test/ui_server.test.d.ts +7 -1
  89. package/dist/test/ui_server.test.js +1 -0
  90. package/dist/test/watch.test.js +297 -6
  91. package/package.json +5 -5
@@ -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
- if (entry.state !== "ready")
41
+ const from = opts?.from ?? "ready";
42
+ const to = opts?.to ?? "working";
43
+ if (entry.state !== from)
28
44
  return false;
29
- entry.state = "working";
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, "&lt;https://evil.example/|click here&gt; &amp; 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 &lt;https:\/\/evil\.example\/\|forged link&gt; &amp; needs fixing/);
660
+ assert.match(digest, /- auth check &lt;script&gt; — not implemented &amp; 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.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",