@gr8ful/spf 0.9.2 → 0.10.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 (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 +354 -51
  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
  }
@@ -542,8 +548,337 @@ export async function finishReviews(deps) {
542
548
  }
543
549
  }
544
550
  }
545
- /** One issue's full claim -> chain -> PR path, run in the background — `claimNewWork` doesn't await this. */
546
- async function runIssue(deps, issue) {
551
+ /**
552
+ * The half of a claimed issue's path that runs once a WINNING tree exists:
553
+ * empty-diff check, push, PR, marker, transition, notify. Called by
554
+ * `runIssueSingle` with its sole attempt's tree and by `runIssueFanout` with
555
+ * `pickBest`'s winner — it never learns which, because nothing about opening
556
+ * a PR depends on how many candidates were considered.
557
+ *
558
+ * `diffBase` is a PARAMETER, not `origin/${deps.baseBranch}` recomputed here:
559
+ * the fan-out lane pins one base SHA for the whole fan-out (see
560
+ * `runIssueFanout`) and the winner must be diffed against the SAME commit its
561
+ * siblings were ranked against, not against wherever `origin/<base>` has
562
+ * moved to by now. The single lane passes `origin/${deps.baseBranch}` — the
563
+ * literal expression it used inline before this extraction.
564
+ *
565
+ * `extraNotifyFields`, when given, are appended to the `pr_opened` notify's
566
+ * `fields` — additive, never replacing any of the four fields below. The
567
+ * fan-out lane uses this for one `["fanout", "<n> attempts, won by <adw_id>"]`
568
+ * entry; the single lane passes nothing, so its notify is byte-identical to
569
+ * before this extraction.
570
+ *
571
+ * IT CATCHES NOTHING, deliberately. The try/catch that covers this region
572
+ * lives one level up, in each lane's own caller, and this extraction does not
573
+ * move it. So EVERY caller must still hold the winner's `{worktreePath,
574
+ * branch}` in whatever its own catch cleans up, for the whole duration of
575
+ * this call: the throws in here are ordinary (`push` rejected, `openPr` 5xx,
576
+ * a tracker write failing), and on any of them the caller's catch is the only
577
+ * thing that removes the tree and deletes the branch.
578
+ */
579
+ async function openPrForWinner(deps, issue, won) {
580
+ const wtGit = deps.worktreeGit(won.worktreePath);
581
+ if (wtGit.diffFiles(won.diffBase).length === 0) {
582
+ deps.log(`watch: ${issue.id}: chain succeeded but committed nothing — blocked`);
583
+ deps.notify({
584
+ kind: "issue_blocked",
585
+ level: "notice",
586
+ title: `issue ${issue.id} blocked`,
587
+ detail: `Chain "${deps.chain}" (adw_id ${won.adwId}) completed but left no committed changes.`,
588
+ fields: [["issue", issue.id], ["title", issue.title], ["chain", deps.chain], ["adw_id", won.adwId]],
589
+ });
590
+ await deps.provider.transition(issue, "blocked", `Chain "${deps.chain}" (adw_id ${won.adwId}) completed but left no committed changes.`);
591
+ cleanupWorktree(deps, { worktree: won.worktreePath, branch: won.branch });
592
+ return;
593
+ }
594
+ wtGit.push("origin", won.branch);
595
+ // The human merging this PR sees whatever the reviewer found — or, if
596
+ // nothing reviewed this change at all, is told that plainly rather than
597
+ // left to assume a silent approval. `reviewSummary` is already
598
+ // sanitized/truncated by the caller (see runChain's own doc comment).
599
+ const reviewLine = won.reviewSummary
600
+ ? won.reviewSummary
601
+ : won.reviewRequired
602
+ ? "Reviewer ran, but no verdict could be read back from the session data."
603
+ : `Nothing reviewed this change — chain \`${deps.chain}\` has no reviewer step.`;
604
+ // The reviewer's biggest complaint about a bare "automated by spf watch"
605
+ // body: it never says what was actually asked for, only chain plumbing.
606
+ // The issue body IS that ask (it's the same text `runIssue`/`runChain`
607
+ // built the prompt from) and is already sitting on `issue` — no new DB
608
+ // read needed to surface it. Capped defensively: a Jira description can
609
+ // be arbitrarily long, and this is a PR body, not the source of record.
610
+ const MAX_ISSUE_BODY_CHARS = 4000;
611
+ const issueBody = issue.body.trim();
612
+ const askSection = issueBody.length > MAX_ISSUE_BODY_CHARS ? `${issueBody.slice(0, MAX_ISSUE_BODY_CHARS)}\n\n_(truncated)_` : issueBody || "_(no description on the issue)_";
613
+ // No cross-linking magic keyword here on purpose (a code host paired
614
+ // with a different tracker has no "Closes #n" convention to hook into
615
+ // — see provider.ts) — the issue id in the title/body is plain text
616
+ // for humans, and, on a Jira+Bitbucket pairing, exactly what Jira's own
617
+ // Bitbucket integration scans for to link the PR automatically.
618
+ const pr = await deps.codeHost.openPr({
619
+ branch: won.branch,
620
+ title: `${issue.title} (${issue.id})`,
621
+ body: `${askSection}\n\n---\n\n**Review:** ${reviewLine}\n\n_Automated by \`spf watch\` — chain \`${deps.chain}\`, adw_id \`${won.adwId}\`, issue ${issue.id}._`,
622
+ base: deps.baseBranch,
623
+ });
624
+ await deps.provider.writeMarker(issue, { worktree: won.worktreePath, branch: won.branch, pr: pr.number, attempt: 0 });
625
+ await deps.provider.transition(issue, "review");
626
+ deps.log(`watch: ${issue.id}: opened PR #${pr.number} — review`);
627
+ deps.notify({
628
+ kind: "pr_opened",
629
+ // Unlike `issue_claimed`/`issue_done` (routine milestones), an opened PR
630
+ // is a standing ask for a human reviewer — the same "needs a human, not
631
+ // a failure" bucket as `issue_blocked`, so it ships to an
632
+ // `attention`-scoped channel, not just `all`.
633
+ level: "notice",
634
+ title: `PR #${pr.number} opened`,
635
+ detail: reviewLine,
636
+ fields: [
637
+ ["issue", issue.id],
638
+ ["title", issue.title],
639
+ ["chain", deps.chain],
640
+ ["review", won.reviewSummary ? "reviewed" : won.reviewRequired ? "reviewer ran, no verdict" : "not reviewed"],
641
+ ...(won.extraNotifyFields ?? []),
642
+ ],
643
+ url: pr.url || undefined,
644
+ });
645
+ }
646
+ /**
647
+ * `issue-<id>` at `r = 0` — matching `runIssueSingle`'s own adw_id scheme, so
648
+ * the common case (an issue's first fan-out) is the id a human would guess
649
+ * (`spf phases issue-42-2` works as advertised). `r > 0` salts it — see
650
+ * `runIssueFanout`'s salt probe for why a PROBED salt, not a counter anyone
651
+ * maintains, decides `r`.
652
+ */
653
+ function baseFor(issue, r) {
654
+ return r === 0 ? `issue-${issue.id}` : `issue-${issue.id}-r${r}`;
655
+ }
656
+ /**
657
+ * Tear down a whole SET of worktree/branch pairs — EVERY worktree in the
658
+ * list first, then every branch, never pair by pair. The pairs a fan-out
659
+ * pre-sweep has to cover are cross-linked (the winner's renamed branch can
660
+ * live in a *different* entry's fan-out worktree — see the rename step in
661
+ * `runIssueFanout`), and `git branch -D` refuses to delete a branch that is
662
+ * checked out in ANY worktree still on disk while `deleteLocalBranch`
663
+ * swallows that refusal (a bare `spawnSync` whose status is never read —
664
+ * `git_helper.ts`). A name-by-name (pairwise) sweep can therefore try to
665
+ * delete a branch that is still checked out in an entry later in the SAME
666
+ * list, silently fail, and leave the issue permanently wedged the next time
667
+ * something tries to create that branch. Two full passes over the whole list
668
+ * remove that ordering dependency entirely — the same rule
669
+ * `removeRunWorktree` documents for its own single pair (worktree before
670
+ * branch), applied at the granularity a sweep across many pairs needs.
671
+ */
672
+ function sweepPairs(deps, pairs) {
673
+ for (const p of pairs) {
674
+ if (!p.worktree)
675
+ continue;
676
+ try {
677
+ deps.git.worktreeRemove(p.worktree);
678
+ }
679
+ catch (error) {
680
+ deps.log(`watch: sweep warning: ${error.message}`);
681
+ }
682
+ }
683
+ for (const p of pairs) {
684
+ if (!p.branch)
685
+ continue;
686
+ try {
687
+ deps.git.deleteLocalBranch(p.branch);
688
+ }
689
+ catch (error) {
690
+ deps.log(`watch: sweep warning: ${error.message}`);
691
+ }
692
+ }
693
+ }
694
+ /**
695
+ * `watch.fanout.n > 1`: run `n` sibling attempts of this issue's prompt via
696
+ * `core/fanout.ts`'s `runBestOf`, let it pick a winner, rename the winner's
697
+ * branch onto the canonical `spf-watch/<id>-<slug>` name, then hand it to
698
+ * `openPrForWinner` — the same tail `runIssueSingle` uses.
699
+ *
700
+ * `won` is a single mutable local (unlike the single lane's deterministic
701
+ * `worktreePath`/`branch` locals) because the fan-out lane's cleanup
702
+ * coordinates change three times over the course of one claim: null while
703
+ * nothing of watch's own exists yet, the winner's fan-out tree/branch right
704
+ * after selection, then the winner's tree on the renamed canonical branch
705
+ * from the rename onward — including all the way through `openPrForWinner`,
706
+ * which catches nothing itself. `won` is intentionally never nulled before
707
+ * that call: doing so would disarm this catch for `push`/`openPr` failures,
708
+ * the single most failure-prone part of the whole flow, and leak a full
709
+ * checkout plus the canonical branch on every one of them.
710
+ */
711
+ async function runIssueFanout(deps, issue, fanout) {
712
+ const n = fanout.n;
713
+ let won = null;
714
+ try {
715
+ // Read for its PAIR (the pre-sweep needs to know what a previous cycle
716
+ // recorded), never for `attempt` — see the salt probe below for why that
717
+ // field cannot be the fan-out lane's re-claim counter.
718
+ const marker0 = await deps.provider.readMarker(issue);
719
+ // SALT PROBE (§8.7 in the design doc): the smallest r whose n attempt
720
+ // ids have NO session rows yet. `WatchMarker.attempt` only advances on
721
+ // reconcileOrphans' retry path — an ordinary blocked -> re-labeled ready
722
+ // -> re-claim never touches it — so it cannot answer "is this base
723
+ // still clean," but the sessions a previous claim's attempts actually
724
+ // wrote can (adwIdsFree's own doc comment has the full argument).
725
+ let salt = 0;
726
+ for (; salt <= MAX_FANOUT_SALT; salt++) {
727
+ const ids = Array.from({ length: n }, (_, i) => attemptAdwId(baseFor(issue, salt), i + 1));
728
+ if (fanout.adwIdsFree(ids))
729
+ break;
730
+ }
731
+ const baseAdwId = salt <= MAX_FANOUT_SALT ? baseFor(issue, salt) : `issue-${issue.id}-x${newId(4)}`;
732
+ if (salt > MAX_FANOUT_SALT) {
733
+ deps.log(`watch: ${issue.id}: exhausted ${MAX_FANOUT_SALT + 1} deterministic fan-out base id(s) — falling back to a random one (${baseAdwId})`);
734
+ }
735
+ // PRE-SWEEP: one list, two passes (sweepPairs) — marker0's own recorded
736
+ // pair (the only way to reach a stale RENAMED winner from a crash after
737
+ // §8.3's rename), the canonical single-lane pair (the rename target
738
+ // below must find it free), and every deterministic attempt name under
739
+ // every base this claim can have used (0..salt inclusive — bases above
740
+ // salt were never reached, so nothing can be there).
741
+ const branch = branchNameFor(issue);
742
+ const worktreePath = worktreePathFor(deps, issue);
743
+ const pairs = [
744
+ { worktree: marker0?.worktree, branch: marker0?.branch },
745
+ { worktree: worktreePath, branch },
746
+ ];
747
+ for (let r = 0; r <= Math.min(salt, MAX_FANOUT_SALT); r++) {
748
+ const base = baseFor(issue, r);
749
+ for (let i = 1; i <= MAX_FANOUT_N; i++) {
750
+ pairs.push({
751
+ worktree: attemptWorktreePath(deps.worktreesDir, base, i),
752
+ branch: attemptBranch(base, i),
753
+ });
754
+ }
755
+ }
756
+ sweepPairs(deps, pairs);
757
+ // The base is fetched and resolved to a SHA exactly ONCE, loudly, here —
758
+ // matching the single lane's own unguarded, fatal-on-failure fetch
759
+ // (`runIssueSingle`) rather than `runBestOf`'s own per-attempt,
760
+ // failure-swallowing fetch. `pinnedGit` suppresses the N redundant
761
+ // fetches `runBestOf` would otherwise issue against a base already
762
+ // fetched here, and pins every attempt (and the eventual `diffFiles`
763
+ // check in `openPrForWinner`) to the identical commit even if
764
+ // `origin/<base>` moves mid-fan-out.
765
+ deps.git.fetch("origin", deps.baseBranch);
766
+ const baseSha = deps.git.rev(`origin/${deps.baseBranch}`);
767
+ const pinnedGit = { ...deps.git, fetch: () => { } };
768
+ // CLAIM-TIME MARKER WRITE — mirrors the single lane's own write
769
+ // (`runIssueSingle`) and, like it, drops `pr`. Without this, a marker
770
+ // surviving from a PREVIOUS cycle (e.g. a human hand-returning a
771
+ // `review` issue with an open PR back to `ready`) would carry that
772
+ // stale `pr` through the sweep and the whole fan-out window untouched —
773
+ // and if the daemon dies mid-fan-out, `reconcileOrphans` would read that
774
+ // stale `marker.pr` on restart, find it open or merged, and resume the
775
+ // issue straight to `review` (its RESUME path, never reached again from
776
+ // `ready`/`blocked`), leaking this claim's worktrees/branches forever
777
+ // with nothing left to sweep them on a future claim. `attempt` is
778
+ // PRESERVED here (not reset to 0) so the orphan-retry counter survives a
779
+ // claim that has not yet produced a winner; it only becomes the literal
780
+ // `0` once a real winner marker is written below, matching the single
781
+ // lane's own reset at that point.
782
+ await deps.provider.writeMarker(issue, { attempt: marker0?.attempt ?? 0 });
783
+ const result = await runBestOf({
784
+ chainName: deps.chain,
785
+ prompt: `${issue.title}\n\n${issue.body}`.trim(),
786
+ n,
787
+ concurrency: fanout.concurrency,
788
+ baseAdwId,
789
+ baseBranch: baseSha,
790
+ repoRoot: fanout.repoRoot,
791
+ worktreesDir: deps.worktreesDir,
792
+ git: pinnedGit,
793
+ linkDataDir: deps.linkDataDir,
794
+ runAttempt: fanout.runAttempt,
795
+ readMetrics: fanout.readMetrics,
796
+ log: deps.log,
797
+ });
798
+ deps.log(`watch: ${issue.id}: best of ${n} — ${result.basis}`);
799
+ if (!result.winner) {
800
+ const first = attemptAdwId(baseAdwId, 1);
801
+ const last = attemptAdwId(baseAdwId, n);
802
+ const detail = `Chain "${deps.chain}" did not succeed in any of ${n} attempt(s) (adw_ids ${first}..${last}): ${result.basis}. ` +
803
+ `Run \`spf phases <adw_id>\` on any attempt for detail.`;
804
+ deps.notify({
805
+ kind: "issue_blocked",
806
+ level: "notice",
807
+ title: `issue ${issue.id} blocked`,
808
+ detail,
809
+ fields: [
810
+ ["issue", issue.id],
811
+ ["title", issue.title],
812
+ ["chain", deps.chain],
813
+ ["adw_id", `${baseAdwId}-1..${n}`],
814
+ ["attempts", String(n)],
815
+ ],
816
+ });
817
+ await deps.provider.transition(issue, "blocked", detail);
818
+ // NO cleanupWorktree call: `won` is still null — nothing of watch's
819
+ // own survived `runBestOf`, which already cleaned every attempt it
820
+ // owned (losers as it went, and there is no winner to keep here).
821
+ return;
822
+ }
823
+ const winner = result.winner;
824
+ won = { worktree: winner.worktree, branch: winner.branch }; // still spf/fanout/*
825
+ await deps.provider.writeMarker(issue, { worktree: won.worktree, branch: won.branch, attempt: 0 });
826
+ // RENAME (§8.3): `spf/fanout/*` branches are never pushed by SPF
827
+ // (`core/fanout.ts`'s own stated contract) — pushing one from watch
828
+ // would contradict the module this composes. Renaming preserves
829
+ // everything downstream that reads a branch name (WatchMarker.branch,
830
+ // the PR head, finishReviews' cleanup, the Jira/Bitbucket branch-name
831
+ // auto-link). Order matters: `createBranch` (a `git checkout -b` INSIDE
832
+ // the winner's worktree) must run before `deleteLocalBranch` on the old
833
+ // name, because git refuses to delete a branch checked out in a
834
+ // worktree — the winner's own worktree is that checkout until this line
835
+ // runs. `createBranch` CAN throw (most commonly: the canonical name
836
+ // already exists) — the pre-sweep above is what keeps that name free by
837
+ // the time this runs; if it still throws, `won` has not been reassigned
838
+ // yet, so the catch below cleans the pre-rename pair, correctly.
839
+ const wtGit = deps.worktreeGit(winner.worktree);
840
+ wtGit.createBranch(branch);
841
+ deps.git.deleteLocalBranch(winner.branch);
842
+ won = { worktree: winner.worktree, branch };
843
+ await deps.provider.writeMarker(issue, { worktree: won.worktree, branch: won.branch, attempt: 0 });
844
+ const review = fanout.reviewFor({ cwd: winner.worktree, adwId: winner.adw_id, chainOptions: deps.chainOptions });
845
+ // `won` stays set from here on — openPrForWinner catches nothing, so
846
+ // this function's own catch (below) is the only cleanup for a throw
847
+ // from push/openPr/writeMarker/transition/notify inside it, exactly as
848
+ // runIssueSingle's catch is for the single lane.
849
+ await openPrForWinner(deps, issue, {
850
+ branch: won.branch,
851
+ worktreePath: won.worktree,
852
+ adwId: winner.adw_id,
853
+ diffBase: baseSha,
854
+ reviewRequired: review.reviewRequired,
855
+ reviewSummary: review.reviewSummary,
856
+ extraNotifyFields: [["fanout", `${n} attempts, won by ${winner.adw_id}`]],
857
+ });
858
+ }
859
+ catch (error) {
860
+ const message = error.message;
861
+ deps.log(`watch: ${issue.id}: error: ${message}`);
862
+ deps.notify({
863
+ kind: "watch_error",
864
+ level: "error",
865
+ title: `issue ${issue.id} errored`,
866
+ detail: message,
867
+ fields: [["issue", issue.id], ["title", issue.title]],
868
+ });
869
+ await deps.provider.transition(issue, "blocked", `spf watch error: ${message}`).catch(() => undefined);
870
+ cleanupWorktree(deps, won);
871
+ }
872
+ }
873
+ /**
874
+ * Today's single-dispatch path — `runIssue`'s entire body before fan-out
875
+ * existed, moved intact with its post-win tail extracted into
876
+ * `openPrForWinner` above: same statements, same order, same values. This is
877
+ * what makes `watch.fanout.n: 1` (the default) a no-op for the running
878
+ * daemon: same adw_id, same worktree, same fetch semantics, same marker,
879
+ * same PR, same notifications as before this feature existed.
880
+ */
881
+ async function runIssueSingle(deps, issue) {
547
882
  const branch = branchNameFor(issue);
548
883
  const worktreePath = worktreePathFor(deps, issue);
549
884
  const adwId = `issue-${issue.id}`;
@@ -578,56 +913,13 @@ async function runIssue(deps, issue) {
578
913
  cleanupWorktree(deps, { worktree: worktreePath, branch });
579
914
  return;
580
915
  }
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({
916
+ await openPrForWinner(deps, issue, {
611
917
  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,
918
+ worktreePath,
919
+ adwId,
920
+ diffBase: `origin/${deps.baseBranch}`,
921
+ reviewRequired: result.reviewRequired,
922
+ reviewSummary: result.reviewSummary,
631
923
  });
632
924
  }
633
925
  catch (error) {
@@ -644,6 +936,17 @@ async function runIssue(deps, issue) {
644
936
  cleanupWorktree(deps, { worktree: worktreePath, branch });
645
937
  }
646
938
  }
939
+ /**
940
+ * One issue's full claim -> chain -> PR path, run in the background —
941
+ * `claimNewWork` doesn't await this. `deps.fanout` unset (or `n <= 1`) takes
942
+ * the single-dispatch path exactly as it always has; `watch.fanout.n > 1`
943
+ * fans out via `runBestOf` first.
944
+ */
945
+ async function runIssue(deps, issue) {
946
+ if (!deps.fanout || deps.fanout.n <= 1)
947
+ return runIssueSingle(deps, issue);
948
+ return runIssueFanout(deps, issue, deps.fanout);
949
+ }
647
950
  /**
648
951
  * The spec's own `<prefix>:priority:pN` label, or `null` if it was never
649
952
  * 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.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",