@gr8ful/spf 0.9.2 → 0.10.1

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 (37) hide show
  1. package/README.md +56 -0
  2. package/assets/defaults/spf.config.yaml +75 -0
  3. package/assets/skill/references/config.md +98 -4
  4. package/dist/chains/index.d.ts +2 -0
  5. package/dist/chains/index.js +4 -0
  6. package/dist/cli/commands/doctor.js +339 -2
  7. package/dist/cli/commands/fanout.d.ts +7 -14
  8. package/dist/cli/commands/fanout.js +45 -39
  9. package/dist/cli/commands/loop.d.ts +2 -0
  10. package/dist/cli/commands/loop.js +198 -0
  11. package/dist/cli/commands/run.js +14 -4
  12. package/dist/cli/commands/watch.d.ts +29 -1
  13. package/dist/cli/commands/watch.js +219 -64
  14. package/dist/cli/index.js +14 -0
  15. package/dist/core/agent_cc.d.ts +11 -0
  16. package/dist/core/agent_cc.js +25 -2
  17. package/dist/core/agent_flue.js +14 -5
  18. package/dist/core/agents.d.ts +61 -1
  19. package/dist/core/agents.js +363 -6
  20. package/dist/core/data_types.d.ts +316 -0
  21. package/dist/core/data_types.js +143 -0
  22. package/dist/core/loop.d.ts +230 -0
  23. package/dist/core/loop.js +290 -0
  24. package/dist/core/quality.d.ts +1 -2
  25. package/dist/core/sandbox.d.ts +236 -0
  26. package/dist/core/sandbox.js +655 -0
  27. package/dist/core/sandbox_cloudflare.d.ts +137 -0
  28. package/dist/core/sandbox_cloudflare.js +505 -0
  29. package/dist/core/sandbox_opensandbox.d.ts +59 -0
  30. package/dist/core/sandbox_opensandbox.js +484 -0
  31. package/dist/core/sandbox_sdk_types.d.ts +171 -0
  32. package/dist/core/sandbox_sdk_types.js +20 -0
  33. package/dist/core/watch.d.ts +56 -0
  34. package/dist/core/watch.js +358 -52
  35. package/dist/core/worktree_data.d.ts +1 -0
  36. package/dist/core/worktree_data.js +37 -0
  37. package/package.json +1 -1
@@ -55,11 +55,17 @@
55
55
  * retry loop, no auto-merge; those remain deliberately out of scope.
56
56
  */
57
57
  import path from "node:path";
58
+ import { attemptAdwId, attemptBranch, attemptWorktreePath, runBestOf } from "./fanout.js";
58
59
  import { PRIORITY_RANK } from "./data_types.js";
59
60
  import { parseRefineMarker } from "./refine.js";
61
+ import { newId } from "./utils.js";
60
62
  const MAX_ORPHAN_ATTEMPTS = 2;
61
63
  /** GitHub's own documented sub-issue nesting cap (see `github_provider.ts`'s `linkChild` doc comment) — `rollUp`'s own recursion bound, so a malformed/cyclic hierarchy can't spin forever. */
62
64
  const MAX_ROLLUP_DEPTH = 8;
65
+ /** Twin of `MAX_N` in `cli/commands/fanout.ts` — the fan-out lane's own attempt-count ceiling (`watch.fanout.n`'s schema bound), duplicated as a literal so the pre-sweep (§8.5) can size itself without importing a CLI command module. */
66
+ const MAX_FANOUT_N = 8;
67
+ /** §8.7's salt ceiling — and therefore §8.5's sweep range too. Bounds an otherwise-unbounded probe/sweep against an issue fanned out over and over. */
68
+ const MAX_FANOUT_SALT = 8;
63
69
  export function createWatchState() {
64
70
  return { inflight: new Set(), refining: new Set(), inflightParents: new Map() };
65
71
  }
@@ -507,7 +513,10 @@ export async function finishReviews(deps) {
507
513
  deps.log(`watch: ${issue.id}'s PR #${marker.pr} merged — done`);
508
514
  deps.notify({
509
515
  kind: "issue_done",
510
- level: "info",
516
+ // Same reasoning as pr_opened above: a merge landing is a milestone
517
+ // worth a human's attention (it's what closes the loop on the PR
518
+ // that alerted them in the first place), not a routine "info" tick.
519
+ level: "notice",
511
520
  title: `issue ${issue.id} done`,
512
521
  detail: `PR #${marker.pr} merged.`,
513
522
  fields: [["issue", issue.id], ["title", issue.title], ["pr", `#${marker.pr}`]],
@@ -542,8 +551,337 @@ export async function finishReviews(deps) {
542
551
  }
543
552
  }
544
553
  }
545
- /** One issue's full claim -> chain -> PR path, run in the background — `claimNewWork` doesn't await this. */
546
- async function runIssue(deps, issue) {
554
+ /**
555
+ * The half of a claimed issue's path that runs once a WINNING tree exists:
556
+ * empty-diff check, push, PR, marker, transition, notify. Called by
557
+ * `runIssueSingle` with its sole attempt's tree and by `runIssueFanout` with
558
+ * `pickBest`'s winner — it never learns which, because nothing about opening
559
+ * a PR depends on how many candidates were considered.
560
+ *
561
+ * `diffBase` is a PARAMETER, not `origin/${deps.baseBranch}` recomputed here:
562
+ * the fan-out lane pins one base SHA for the whole fan-out (see
563
+ * `runIssueFanout`) and the winner must be diffed against the SAME commit its
564
+ * siblings were ranked against, not against wherever `origin/<base>` has
565
+ * moved to by now. The single lane passes `origin/${deps.baseBranch}` — the
566
+ * literal expression it used inline before this extraction.
567
+ *
568
+ * `extraNotifyFields`, when given, are appended to the `pr_opened` notify's
569
+ * `fields` — additive, never replacing any of the four fields below. The
570
+ * fan-out lane uses this for one `["fanout", "<n> attempts, won by <adw_id>"]`
571
+ * entry; the single lane passes nothing, so its notify is byte-identical to
572
+ * before this extraction.
573
+ *
574
+ * IT CATCHES NOTHING, deliberately. The try/catch that covers this region
575
+ * lives one level up, in each lane's own caller, and this extraction does not
576
+ * move it. So EVERY caller must still hold the winner's `{worktreePath,
577
+ * branch}` in whatever its own catch cleans up, for the whole duration of
578
+ * this call: the throws in here are ordinary (`push` rejected, `openPr` 5xx,
579
+ * a tracker write failing), and on any of them the caller's catch is the only
580
+ * thing that removes the tree and deletes the branch.
581
+ */
582
+ async function openPrForWinner(deps, issue, won) {
583
+ const wtGit = deps.worktreeGit(won.worktreePath);
584
+ if (wtGit.diffFiles(won.diffBase).length === 0) {
585
+ deps.log(`watch: ${issue.id}: chain succeeded but committed nothing — blocked`);
586
+ deps.notify({
587
+ kind: "issue_blocked",
588
+ level: "notice",
589
+ title: `issue ${issue.id} blocked`,
590
+ detail: `Chain "${deps.chain}" (adw_id ${won.adwId}) completed but left no committed changes.`,
591
+ fields: [["issue", issue.id], ["title", issue.title], ["chain", deps.chain], ["adw_id", won.adwId]],
592
+ });
593
+ await deps.provider.transition(issue, "blocked", `Chain "${deps.chain}" (adw_id ${won.adwId}) completed but left no committed changes.`);
594
+ cleanupWorktree(deps, { worktree: won.worktreePath, branch: won.branch });
595
+ return;
596
+ }
597
+ wtGit.push("origin", won.branch);
598
+ // The human merging this PR sees whatever the reviewer found — or, if
599
+ // nothing reviewed this change at all, is told that plainly rather than
600
+ // left to assume a silent approval. `reviewSummary` is already
601
+ // sanitized/truncated by the caller (see runChain's own doc comment).
602
+ const reviewLine = won.reviewSummary
603
+ ? won.reviewSummary
604
+ : won.reviewRequired
605
+ ? "Reviewer ran, but no verdict could be read back from the session data."
606
+ : `Nothing reviewed this change — chain \`${deps.chain}\` has no reviewer step.`;
607
+ // The reviewer's biggest complaint about a bare "automated by spf watch"
608
+ // body: it never says what was actually asked for, only chain plumbing.
609
+ // The issue body IS that ask (it's the same text `runIssue`/`runChain`
610
+ // built the prompt from) and is already sitting on `issue` — no new DB
611
+ // read needed to surface it. Capped defensively: a Jira description can
612
+ // be arbitrarily long, and this is a PR body, not the source of record.
613
+ const MAX_ISSUE_BODY_CHARS = 4000;
614
+ const issueBody = issue.body.trim();
615
+ const askSection = issueBody.length > MAX_ISSUE_BODY_CHARS ? `${issueBody.slice(0, MAX_ISSUE_BODY_CHARS)}\n\n_(truncated)_` : issueBody || "_(no description on the issue)_";
616
+ // No cross-linking magic keyword here on purpose (a code host paired
617
+ // with a different tracker has no "Closes #n" convention to hook into
618
+ // — see provider.ts) — the issue id in the title/body is plain text
619
+ // for humans, and, on a Jira+Bitbucket pairing, exactly what Jira's own
620
+ // Bitbucket integration scans for to link the PR automatically.
621
+ const pr = await deps.codeHost.openPr({
622
+ branch: won.branch,
623
+ title: `${issue.title} (${issue.id})`,
624
+ body: `${askSection}\n\n---\n\n**Review:** ${reviewLine}\n\n_Automated by \`spf watch\` — chain \`${deps.chain}\`, adw_id \`${won.adwId}\`, issue ${issue.id}._`,
625
+ base: deps.baseBranch,
626
+ });
627
+ await deps.provider.writeMarker(issue, { worktree: won.worktreePath, branch: won.branch, pr: pr.number, attempt: 0 });
628
+ await deps.provider.transition(issue, "review");
629
+ deps.log(`watch: ${issue.id}: opened PR #${pr.number} — review`);
630
+ deps.notify({
631
+ kind: "pr_opened",
632
+ // Unlike `issue_claimed`/`issue_done` (routine milestones), an opened PR
633
+ // is a standing ask for a human reviewer — the same "needs a human, not
634
+ // a failure" bucket as `issue_blocked`, so it ships to an
635
+ // `attention`-scoped channel, not just `all`.
636
+ level: "notice",
637
+ title: `PR #${pr.number} opened`,
638
+ detail: reviewLine,
639
+ fields: [
640
+ ["issue", issue.id],
641
+ ["title", issue.title],
642
+ ["chain", deps.chain],
643
+ ["review", won.reviewSummary ? "reviewed" : won.reviewRequired ? "reviewer ran, no verdict" : "not reviewed"],
644
+ ...(won.extraNotifyFields ?? []),
645
+ ],
646
+ url: pr.url || undefined,
647
+ });
648
+ }
649
+ /**
650
+ * `issue-<id>` at `r = 0` — matching `runIssueSingle`'s own adw_id scheme, so
651
+ * the common case (an issue's first fan-out) is the id a human would guess
652
+ * (`spf phases issue-42-2` works as advertised). `r > 0` salts it — see
653
+ * `runIssueFanout`'s salt probe for why a PROBED salt, not a counter anyone
654
+ * maintains, decides `r`.
655
+ */
656
+ function baseFor(issue, r) {
657
+ return r === 0 ? `issue-${issue.id}` : `issue-${issue.id}-r${r}`;
658
+ }
659
+ /**
660
+ * Tear down a whole SET of worktree/branch pairs — EVERY worktree in the
661
+ * list first, then every branch, never pair by pair. The pairs a fan-out
662
+ * pre-sweep has to cover are cross-linked (the winner's renamed branch can
663
+ * live in a *different* entry's fan-out worktree — see the rename step in
664
+ * `runIssueFanout`), and `git branch -D` refuses to delete a branch that is
665
+ * checked out in ANY worktree still on disk while `deleteLocalBranch`
666
+ * swallows that refusal (a bare `spawnSync` whose status is never read —
667
+ * `git_helper.ts`). A name-by-name (pairwise) sweep can therefore try to
668
+ * delete a branch that is still checked out in an entry later in the SAME
669
+ * list, silently fail, and leave the issue permanently wedged the next time
670
+ * something tries to create that branch. Two full passes over the whole list
671
+ * remove that ordering dependency entirely — the same rule
672
+ * `removeRunWorktree` documents for its own single pair (worktree before
673
+ * branch), applied at the granularity a sweep across many pairs needs.
674
+ */
675
+ function sweepPairs(deps, pairs) {
676
+ for (const p of pairs) {
677
+ if (!p.worktree)
678
+ continue;
679
+ try {
680
+ deps.git.worktreeRemove(p.worktree);
681
+ }
682
+ catch (error) {
683
+ deps.log(`watch: sweep warning: ${error.message}`);
684
+ }
685
+ }
686
+ for (const p of pairs) {
687
+ if (!p.branch)
688
+ continue;
689
+ try {
690
+ deps.git.deleteLocalBranch(p.branch);
691
+ }
692
+ catch (error) {
693
+ deps.log(`watch: sweep warning: ${error.message}`);
694
+ }
695
+ }
696
+ }
697
+ /**
698
+ * `watch.fanout.n > 1`: run `n` sibling attempts of this issue's prompt via
699
+ * `core/fanout.ts`'s `runBestOf`, let it pick a winner, rename the winner's
700
+ * branch onto the canonical `spf-watch/<id>-<slug>` name, then hand it to
701
+ * `openPrForWinner` — the same tail `runIssueSingle` uses.
702
+ *
703
+ * `won` is a single mutable local (unlike the single lane's deterministic
704
+ * `worktreePath`/`branch` locals) because the fan-out lane's cleanup
705
+ * coordinates change three times over the course of one claim: null while
706
+ * nothing of watch's own exists yet, the winner's fan-out tree/branch right
707
+ * after selection, then the winner's tree on the renamed canonical branch
708
+ * from the rename onward — including all the way through `openPrForWinner`,
709
+ * which catches nothing itself. `won` is intentionally never nulled before
710
+ * that call: doing so would disarm this catch for `push`/`openPr` failures,
711
+ * the single most failure-prone part of the whole flow, and leak a full
712
+ * checkout plus the canonical branch on every one of them.
713
+ */
714
+ async function runIssueFanout(deps, issue, fanout) {
715
+ const n = fanout.n;
716
+ let won = null;
717
+ try {
718
+ // Read for its PAIR (the pre-sweep needs to know what a previous cycle
719
+ // recorded), never for `attempt` — see the salt probe below for why that
720
+ // field cannot be the fan-out lane's re-claim counter.
721
+ const marker0 = await deps.provider.readMarker(issue);
722
+ // SALT PROBE (§8.7 in the design doc): the smallest r whose n attempt
723
+ // ids have NO session rows yet. `WatchMarker.attempt` only advances on
724
+ // reconcileOrphans' retry path — an ordinary blocked -> re-labeled ready
725
+ // -> re-claim never touches it — so it cannot answer "is this base
726
+ // still clean," but the sessions a previous claim's attempts actually
727
+ // wrote can (adwIdsFree's own doc comment has the full argument).
728
+ let salt = 0;
729
+ for (; salt <= MAX_FANOUT_SALT; salt++) {
730
+ const ids = Array.from({ length: n }, (_, i) => attemptAdwId(baseFor(issue, salt), i + 1));
731
+ if (fanout.adwIdsFree(ids))
732
+ break;
733
+ }
734
+ const baseAdwId = salt <= MAX_FANOUT_SALT ? baseFor(issue, salt) : `issue-${issue.id}-x${newId(4)}`;
735
+ if (salt > MAX_FANOUT_SALT) {
736
+ deps.log(`watch: ${issue.id}: exhausted ${MAX_FANOUT_SALT + 1} deterministic fan-out base id(s) — falling back to a random one (${baseAdwId})`);
737
+ }
738
+ // PRE-SWEEP: one list, two passes (sweepPairs) — marker0's own recorded
739
+ // pair (the only way to reach a stale RENAMED winner from a crash after
740
+ // §8.3's rename), the canonical single-lane pair (the rename target
741
+ // below must find it free), and every deterministic attempt name under
742
+ // every base this claim can have used (0..salt inclusive — bases above
743
+ // salt were never reached, so nothing can be there).
744
+ const branch = branchNameFor(issue);
745
+ const worktreePath = worktreePathFor(deps, issue);
746
+ const pairs = [
747
+ { worktree: marker0?.worktree, branch: marker0?.branch },
748
+ { worktree: worktreePath, branch },
749
+ ];
750
+ for (let r = 0; r <= Math.min(salt, MAX_FANOUT_SALT); r++) {
751
+ const base = baseFor(issue, r);
752
+ for (let i = 1; i <= MAX_FANOUT_N; i++) {
753
+ pairs.push({
754
+ worktree: attemptWorktreePath(deps.worktreesDir, base, i),
755
+ branch: attemptBranch(base, i),
756
+ });
757
+ }
758
+ }
759
+ sweepPairs(deps, pairs);
760
+ // The base is fetched and resolved to a SHA exactly ONCE, loudly, here —
761
+ // matching the single lane's own unguarded, fatal-on-failure fetch
762
+ // (`runIssueSingle`) rather than `runBestOf`'s own per-attempt,
763
+ // failure-swallowing fetch. `pinnedGit` suppresses the N redundant
764
+ // fetches `runBestOf` would otherwise issue against a base already
765
+ // fetched here, and pins every attempt (and the eventual `diffFiles`
766
+ // check in `openPrForWinner`) to the identical commit even if
767
+ // `origin/<base>` moves mid-fan-out.
768
+ deps.git.fetch("origin", deps.baseBranch);
769
+ const baseSha = deps.git.rev(`origin/${deps.baseBranch}`);
770
+ const pinnedGit = { ...deps.git, fetch: () => { } };
771
+ // CLAIM-TIME MARKER WRITE — mirrors the single lane's own write
772
+ // (`runIssueSingle`) and, like it, drops `pr`. Without this, a marker
773
+ // surviving from a PREVIOUS cycle (e.g. a human hand-returning a
774
+ // `review` issue with an open PR back to `ready`) would carry that
775
+ // stale `pr` through the sweep and the whole fan-out window untouched —
776
+ // and if the daemon dies mid-fan-out, `reconcileOrphans` would read that
777
+ // stale `marker.pr` on restart, find it open or merged, and resume the
778
+ // issue straight to `review` (its RESUME path, never reached again from
779
+ // `ready`/`blocked`), leaking this claim's worktrees/branches forever
780
+ // with nothing left to sweep them on a future claim. `attempt` is
781
+ // PRESERVED here (not reset to 0) so the orphan-retry counter survives a
782
+ // claim that has not yet produced a winner; it only becomes the literal
783
+ // `0` once a real winner marker is written below, matching the single
784
+ // lane's own reset at that point.
785
+ await deps.provider.writeMarker(issue, { attempt: marker0?.attempt ?? 0 });
786
+ const result = await runBestOf({
787
+ chainName: deps.chain,
788
+ prompt: `${issue.title}\n\n${issue.body}`.trim(),
789
+ n,
790
+ concurrency: fanout.concurrency,
791
+ baseAdwId,
792
+ baseBranch: baseSha,
793
+ repoRoot: fanout.repoRoot,
794
+ worktreesDir: deps.worktreesDir,
795
+ git: pinnedGit,
796
+ linkDataDir: deps.linkDataDir,
797
+ runAttempt: fanout.runAttempt,
798
+ readMetrics: fanout.readMetrics,
799
+ log: deps.log,
800
+ });
801
+ deps.log(`watch: ${issue.id}: best of ${n} — ${result.basis}`);
802
+ if (!result.winner) {
803
+ const first = attemptAdwId(baseAdwId, 1);
804
+ const last = attemptAdwId(baseAdwId, n);
805
+ const detail = `Chain "${deps.chain}" did not succeed in any of ${n} attempt(s) (adw_ids ${first}..${last}): ${result.basis}. ` +
806
+ `Run \`spf phases <adw_id>\` on any attempt for detail.`;
807
+ deps.notify({
808
+ kind: "issue_blocked",
809
+ level: "notice",
810
+ title: `issue ${issue.id} blocked`,
811
+ detail,
812
+ fields: [
813
+ ["issue", issue.id],
814
+ ["title", issue.title],
815
+ ["chain", deps.chain],
816
+ ["adw_id", `${baseAdwId}-1..${n}`],
817
+ ["attempts", String(n)],
818
+ ],
819
+ });
820
+ await deps.provider.transition(issue, "blocked", detail);
821
+ // NO cleanupWorktree call: `won` is still null — nothing of watch's
822
+ // own survived `runBestOf`, which already cleaned every attempt it
823
+ // owned (losers as it went, and there is no winner to keep here).
824
+ return;
825
+ }
826
+ const winner = result.winner;
827
+ won = { worktree: winner.worktree, branch: winner.branch }; // still spf/fanout/*
828
+ await deps.provider.writeMarker(issue, { worktree: won.worktree, branch: won.branch, attempt: 0 });
829
+ // RENAME (§8.3): `spf/fanout/*` branches are never pushed by SPF
830
+ // (`core/fanout.ts`'s own stated contract) — pushing one from watch
831
+ // would contradict the module this composes. Renaming preserves
832
+ // everything downstream that reads a branch name (WatchMarker.branch,
833
+ // the PR head, finishReviews' cleanup, the Jira/Bitbucket branch-name
834
+ // auto-link). Order matters: `createBranch` (a `git checkout -b` INSIDE
835
+ // the winner's worktree) must run before `deleteLocalBranch` on the old
836
+ // name, because git refuses to delete a branch checked out in a
837
+ // worktree — the winner's own worktree is that checkout until this line
838
+ // runs. `createBranch` CAN throw (most commonly: the canonical name
839
+ // already exists) — the pre-sweep above is what keeps that name free by
840
+ // the time this runs; if it still throws, `won` has not been reassigned
841
+ // yet, so the catch below cleans the pre-rename pair, correctly.
842
+ const wtGit = deps.worktreeGit(winner.worktree);
843
+ wtGit.createBranch(branch);
844
+ deps.git.deleteLocalBranch(winner.branch);
845
+ won = { worktree: winner.worktree, branch };
846
+ await deps.provider.writeMarker(issue, { worktree: won.worktree, branch: won.branch, attempt: 0 });
847
+ const review = fanout.reviewFor({ cwd: winner.worktree, adwId: winner.adw_id, chainOptions: deps.chainOptions });
848
+ // `won` stays set from here on — openPrForWinner catches nothing, so
849
+ // this function's own catch (below) is the only cleanup for a throw
850
+ // from push/openPr/writeMarker/transition/notify inside it, exactly as
851
+ // runIssueSingle's catch is for the single lane.
852
+ await openPrForWinner(deps, issue, {
853
+ branch: won.branch,
854
+ worktreePath: won.worktree,
855
+ adwId: winner.adw_id,
856
+ diffBase: baseSha,
857
+ reviewRequired: review.reviewRequired,
858
+ reviewSummary: review.reviewSummary,
859
+ extraNotifyFields: [["fanout", `${n} attempts, won by ${winner.adw_id}`]],
860
+ });
861
+ }
862
+ catch (error) {
863
+ const message = error.message;
864
+ deps.log(`watch: ${issue.id}: error: ${message}`);
865
+ deps.notify({
866
+ kind: "watch_error",
867
+ level: "error",
868
+ title: `issue ${issue.id} errored`,
869
+ detail: message,
870
+ fields: [["issue", issue.id], ["title", issue.title]],
871
+ });
872
+ await deps.provider.transition(issue, "blocked", `spf watch error: ${message}`).catch(() => undefined);
873
+ cleanupWorktree(deps, won);
874
+ }
875
+ }
876
+ /**
877
+ * Today's single-dispatch path — `runIssue`'s entire body before fan-out
878
+ * existed, moved intact with its post-win tail extracted into
879
+ * `openPrForWinner` above: same statements, same order, same values. This is
880
+ * what makes `watch.fanout.n: 1` (the default) a no-op for the running
881
+ * daemon: same adw_id, same worktree, same fetch semantics, same marker,
882
+ * same PR, same notifications as before this feature existed.
883
+ */
884
+ async function runIssueSingle(deps, issue) {
547
885
  const branch = branchNameFor(issue);
548
886
  const worktreePath = worktreePathFor(deps, issue);
549
887
  const adwId = `issue-${issue.id}`;
@@ -578,56 +916,13 @@ async function runIssue(deps, issue) {
578
916
  cleanupWorktree(deps, { worktree: worktreePath, branch });
579
917
  return;
580
918
  }
581
- const wtGit = deps.worktreeGit(worktreePath);
582
- if (wtGit.diffFiles(`origin/${deps.baseBranch}`).length === 0) {
583
- deps.log(`watch: ${issue.id}: chain succeeded but committed nothing — blocked`);
584
- deps.notify({
585
- kind: "issue_blocked",
586
- level: "notice",
587
- title: `issue ${issue.id} blocked`,
588
- detail: `Chain "${deps.chain}" (adw_id ${adwId}) completed but left no committed changes.`,
589
- fields: [["issue", issue.id], ["title", issue.title], ["chain", deps.chain], ["adw_id", adwId]],
590
- });
591
- await deps.provider.transition(issue, "blocked", `Chain "${deps.chain}" (adw_id ${adwId}) completed but left no committed changes.`);
592
- cleanupWorktree(deps, { worktree: worktreePath, branch });
593
- return;
594
- }
595
- wtGit.push("origin", branch);
596
- // The human merging this PR sees whatever the reviewer found — or, if
597
- // nothing reviewed this change at all, is told that plainly rather than
598
- // left to assume a silent approval. `reviewSummary` is already
599
- // sanitized/truncated by the caller (see runChain's own doc comment).
600
- const reviewLine = result.reviewSummary
601
- ? result.reviewSummary
602
- : result.reviewRequired
603
- ? "Reviewer ran, but no verdict could be read back from the session data."
604
- : `Nothing reviewed this change — chain \`${deps.chain}\` has no reviewer step.`;
605
- // No cross-linking magic keyword here on purpose (a code host paired
606
- // with a different tracker has no "Closes #n" convention to hook into
607
- // — see provider.ts) — the issue id in the title/body is plain text
608
- // for humans, and, on a Jira+Bitbucket pairing, exactly what Jira's own
609
- // Bitbucket integration scans for to link the PR automatically.
610
- const pr = await deps.codeHost.openPr({
919
+ await openPrForWinner(deps, issue, {
611
920
  branch,
612
- title: `${issue.title} (${issue.id})`,
613
- body: `Automated by \`spf watch\` — chain \`${deps.chain}\`, adw_id \`${adwId}\`, issue ${issue.id}.\n\n${reviewLine}`,
614
- base: deps.baseBranch,
615
- });
616
- await deps.provider.writeMarker(issue, { worktree: worktreePath, branch, pr: pr.number, attempt: 0 });
617
- await deps.provider.transition(issue, "review");
618
- deps.log(`watch: ${issue.id}: opened PR #${pr.number} — review`);
619
- deps.notify({
620
- kind: "pr_opened",
621
- level: "info",
622
- title: `PR #${pr.number} opened`,
623
- detail: reviewLine,
624
- fields: [
625
- ["issue", issue.id],
626
- ["title", issue.title],
627
- ["chain", deps.chain],
628
- ["review", result.reviewSummary ? "reviewed" : result.reviewRequired ? "reviewer ran, no verdict" : "not reviewed"],
629
- ],
630
- url: pr.url || undefined,
921
+ worktreePath,
922
+ adwId,
923
+ diffBase: `origin/${deps.baseBranch}`,
924
+ reviewRequired: result.reviewRequired,
925
+ reviewSummary: result.reviewSummary,
631
926
  });
632
927
  }
633
928
  catch (error) {
@@ -644,6 +939,17 @@ async function runIssue(deps, issue) {
644
939
  cleanupWorktree(deps, { worktree: worktreePath, branch });
645
940
  }
646
941
  }
942
+ /**
943
+ * One issue's full claim -> chain -> PR path, run in the background —
944
+ * `claimNewWork` doesn't await this. `deps.fanout` unset (or `n <= 1`) takes
945
+ * the single-dispatch path exactly as it always has; `watch.fanout.n > 1`
946
+ * fans out via `runBestOf` first.
947
+ */
948
+ async function runIssue(deps, issue) {
949
+ if (!deps.fanout || deps.fanout.n <= 1)
950
+ return runIssueSingle(deps, issue);
951
+ return runIssueFanout(deps, issue, deps.fanout);
952
+ }
647
953
  /**
648
954
  * The spec's own `<prefix>:priority:pN` label, or `null` if it was never
649
955
  * set. Deliberately NOT `issuePriority`'s default-to-`p2` behavior: `null`
@@ -0,0 +1 @@
1
+ export declare function excludeSpfDataFromGit(worktreePath: string): void;
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Keep a worktree's `.spf/data` symlink invisible to `git status`/`git add -A`.
3
+ *
4
+ * Moved here (out of `cli/commands/fanout.ts`) so both `cli/commands/fanout.ts`
5
+ * and `cli/commands/watch.ts` can call it without either command importing the
6
+ * other — `spawnSync` + `node:fs` only, no chains, no db, no sandbox, matching
7
+ * `core/git_helper.ts`'s own dependency discipline.
8
+ *
9
+ * `.spf/data/` — a trailing-slash DIRECTORY pattern, the shape a tracked
10
+ * `.gitignore` uses — does NOT match a SYMLINK of the same name, so without
11
+ * this, `git status` shows `?? .spf/data` and a committing chain's
12
+ * `git_helper.commitAll` (`git add -A`) stages it. Following a printed
13
+ * `git merge <branch>` instruction then fast-forwards the symlink straight
14
+ * into the MAIN repo, replacing its real `.spf/data` directory with a symlink
15
+ * pointing back at itself — destroying the trace db (`ls .spf/data` becomes
16
+ * `ELOOP`; see `paths.healBrokenDataDir` for the recovery side of this same
17
+ * bug). `info/exclude` is per-worktree, local-only, and idempotent to append
18
+ * to — the opposite of adding a pattern to a tracked `.gitignore`, which would
19
+ * ship this workaround into every clone for a symlink only a scoped-worktree
20
+ * caller (fan-out attempts, `spf watch`'s own build-lane worktrees) ever
21
+ * creates.
22
+ */
23
+ import { spawnSync } from "node:child_process";
24
+ import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
25
+ import path from "node:path";
26
+ export function excludeSpfDataFromGit(worktreePath) {
27
+ const resolved = spawnSync("git", ["rev-parse", "--git-path", "info/exclude"], { cwd: worktreePath, encoding: "utf-8" });
28
+ if (resolved.status !== 0)
29
+ return; // best-effort: worst case is the pre-existing staging risk, not a crash
30
+ const excludePath = path.resolve(worktreePath, resolved.stdout.trim());
31
+ const line = ".spf/data";
32
+ const existing = existsSync(excludePath) ? readFileSync(excludePath, "utf-8") : "";
33
+ if (existing.split("\n").some((l) => l.trim() === line))
34
+ return;
35
+ mkdirSync(path.dirname(excludePath), { recursive: true });
36
+ appendFileSync(excludePath, `${existing && !existing.endsWith("\n") ? "\n" : ""}${line}\n`);
37
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gr8ful/spf",
3
- "version": "0.9.2",
3
+ "version": "0.10.1",
4
4
  "description": "Super Portable Factory — a global CLI for repeatable agents-plus-code workflows (ADWs)",
5
5
  "type": "module",
6
6
  "license": "MIT",