@try-works/dsh-recursive-mode 0.1.18 → 0.2.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/lib/index.js CHANGED
@@ -6,6 +6,154 @@ import { createHash } from "node:crypto";
6
6
  import { execFileSync } from "node:child_process";
7
7
  import { defineTool } from "@deepseek-ai/dsh-tools";
8
8
  import { fileURLToPath } from "node:url";
9
+ //#region src/git-context.ts
10
+ /**
11
+ * git-context.ts — live git facts used for worktree + branch awareness (R?).
12
+ *
13
+ * Worktree/branch awareness goal: the plugin must know WHICH checkout and
14
+ * WHICH branch a run is executing in, so Phase 0 records an honest base-vs-
15
+ * worktree branch split and lint fails when the recorded context no longer
16
+ * matches live git state.
17
+ *
18
+ * Detection primitives:
19
+ * - `isLinkedWorktree` — a directory is a linked git worktree when its private
20
+ * git dir (`git rev-parse --git-dir`) differs from the common git dir
21
+ * (`git rev-parse --git-common-dir`). The main checkout has both equal.
22
+ * - `upstreamBranch` — the current branch's upstream target with the remote
23
+ * prefix stripped (origin/main -> main). Used to infer the promotion source
24
+ * branch for dev/stage/main workflows.
25
+ *
26
+ * Every accessor is total: on missing git or non-git dirs it returns null /
27
+ * false, never throws. Callers defer rather than fail hard.
28
+ */
29
+ /** Run git in a repo dir; return trimmed stdout or null on any failure. */
30
+ function gitRun(repoRoot, ...args) {
31
+ try {
32
+ return execFileSync("git", args, {
33
+ cwd: repoRoot,
34
+ encoding: "utf8",
35
+ stdio: [
36
+ "ignore",
37
+ "pipe",
38
+ "ignore"
39
+ ]
40
+ }).trim();
41
+ } catch {
42
+ return null;
43
+ }
44
+ }
45
+ /** True when a non-equal git-dir vs git-common-dir marks a linked worktree. */
46
+ function isLinkedWorktree(gitDir, commonDir) {
47
+ if (!gitDir || !commonDir) return false;
48
+ return gitDir !== commonDir;
49
+ }
50
+ /** Strip a remote ref prefix (origin/main -> main, remotes/origin/main -> main). */
51
+ function stripRemotePrefix(ref) {
52
+ if (!ref) return null;
53
+ const short = ref.trim().replace(/^refs\/remotes\//, "").replace(/^[^/]+\//, "");
54
+ return short === "" ? null : short;
55
+ }
56
+ /**
57
+ * Gather normalized git facts about `repoRoot`. Total: any git failure
58
+ * degrades to neutral values (false / null), never throws.
59
+ */
60
+ function gitFacts(repoRoot) {
61
+ const gitDir = gitRun(repoRoot, "rev-parse", "--git-dir");
62
+ const commonDir = gitRun(repoRoot, "rev-parse", "--git-common-dir");
63
+ const headSha = gitRun(repoRoot, "rev-parse", "--verify", "HEAD^{commit}");
64
+ const branch = gitRun(repoRoot, "symbolic-ref", "--quiet", "--short", "HEAD");
65
+ const toplevel = gitRun(repoRoot, "rev-parse", "--show-toplevel");
66
+ const upstreamRef = gitRun(repoRoot, "rev-parse", "--abbrev-ref", "@{upstream}");
67
+ return {
68
+ headSha,
69
+ branch: branch || null,
70
+ detached: !branch,
71
+ isWorktree: isLinkedWorktree(gitDir, commonDir),
72
+ gitDir,
73
+ commonDir,
74
+ toplevel,
75
+ upstreamBranch: branch ? stripRemotePrefix(upstreamRef) : null
76
+ };
77
+ }
78
+ /**
79
+ * Resolve the effective base branch for a checkout. In a linked worktree this
80
+ * is the branch the work is based on — best inferred from the upstream target
81
+ * (dev/stage/main promotion source) when one is configured; otherwise fall
82
+ * back to the current branch. Returns null only when no branch exists.
83
+ */
84
+ function resolveBaseBranch(facts) {
85
+ if (facts.branch && facts.upstreamBranch && facts.upstreamBranch !== facts.branch) return facts.upstreamBranch;
86
+ return facts.branch;
87
+ }
88
+ /**
89
+ * Verify a worktree branch is (still) based on the recorded base branch.
90
+ * Returns { ok, reason }. Fails when the base branch no longer exists, or the
91
+ * worktree branch no longer contains it (merge-base --is-ancestor fails).
92
+ * A missing/unresolvable branch degrades to ok:false with a reason; a
93
+ * non-git dir degrades to ok:true (defer — nothing to verify).
94
+ */
95
+ function verifyBranchBase(repoRoot, baseBranch, worktreeBranch) {
96
+ if (!baseBranch || !worktreeBranch) return {
97
+ ok: true,
98
+ reason: null
99
+ };
100
+ if (!gitRun(repoRoot, "rev-parse", "--git-dir")) return {
101
+ ok: true,
102
+ reason: null
103
+ };
104
+ if (!gitRun(repoRoot, "rev-parse", "--verify", `${baseBranch}^{commit}`)) return {
105
+ ok: false,
106
+ reason: `recorded base branch '${baseBranch}' does not resolve in this checkout`
107
+ };
108
+ if (!gitRun(repoRoot, "rev-parse", "--verify", `${worktreeBranch}^{commit}`)) return {
109
+ ok: false,
110
+ reason: `recorded worktree branch '${worktreeBranch}' does not resolve in this checkout`
111
+ };
112
+ try {
113
+ execFileSync("git", [
114
+ "-C",
115
+ repoRoot,
116
+ "merge-base",
117
+ "--is-ancestor",
118
+ baseBranch,
119
+ worktreeBranch
120
+ ], { stdio: [
121
+ "ignore",
122
+ "pipe",
123
+ "pipe"
124
+ ] });
125
+ return {
126
+ ok: true,
127
+ reason: null
128
+ };
129
+ } catch {
130
+ return {
131
+ ok: false,
132
+ reason: `worktree branch '${worktreeBranch}' is not based on recorded base branch '${baseBranch}'`
133
+ };
134
+ }
135
+ }
136
+ /** Verify the recorded worktree branch matches the live HEAD branch. */
137
+ function verifyWorktreeBranch(repoRoot, recordedBranch) {
138
+ if (!recordedBranch) return {
139
+ ok: true,
140
+ reason: null
141
+ };
142
+ const live = gitRun(repoRoot, "symbolic-ref", "--quiet", "--short", "HEAD");
143
+ if (!live) return {
144
+ ok: false,
145
+ reason: "HEAD is detached; expected branch " + recordedBranch
146
+ };
147
+ if (live !== recordedBranch) return {
148
+ ok: false,
149
+ reason: `checkout is on branch '${live}' but 00-worktree.md records '${recordedBranch}'`
150
+ };
151
+ return {
152
+ ok: true,
153
+ reason: null
154
+ };
155
+ }
156
+ //#endregion
9
157
  //#region src/phase-rules.ts
10
158
  /**
11
159
  * Phase rules (R5): canonical parity port of lint-recursive-run.py's
@@ -1048,6 +1196,24 @@ function getRunDiffBasis(runDir) {
1048
1196
  notes: getMdFieldValue$1(source, "Diff basis notes") ?? getMdFieldValue$1(source, "Notes")
1049
1197
  };
1050
1198
  }
1199
+ /**
1200
+ * verify_recorded_branches: worktree + branch awareness at lint time. Compares
1201
+ * the branches recorded in 00-worktree.md against live git state. Returns a
1202
+ * list of FAIL messages (empty when consistent). Both the base branch and the
1203
+ * worktree branch must resolve; the worktree branch must match the live HEAD
1204
+ * branch, and it must actually be based on the recorded base branch.
1205
+ */
1206
+ function verifyRecordedBranches(repoRoot, diffBasis, runDir) {
1207
+ const fails = [];
1208
+ const baseBranch = trimMdValue$1(diffBasis.base_branch ?? "");
1209
+ const worktreeBranch = trimMdValue$1(diffBasis.worktree_branch ?? "");
1210
+ if (!baseBranch && !worktreeBranch) return fails;
1211
+ const wtCheck = verifyWorktreeBranch(repoRoot, worktreeBranch || null);
1212
+ if (!wtCheck.ok && wtCheck.reason) fails.push(wtCheck.reason);
1213
+ const baseCheck = verifyBranchBase(repoRoot, baseBranch || null, worktreeBranch || null);
1214
+ if (!baseCheck.ok && baseCheck.reason) fails.push(baseCheck.reason);
1215
+ return fails;
1216
+ }
1051
1217
  /** normalize_diff_basis: validate + compute the executable diff basis. */
1052
1218
  function normalizeDiffBasis(repoRoot, diffBasis) {
1053
1219
  const baselineType = normalizeBaselineType(diffBasis.baseline_type);
@@ -2612,6 +2778,11 @@ function lintRun(repoRoot, runId) {
2612
2778
  let diffBasisError = null;
2613
2779
  if (STRICT_WORKFLOW_PROFILES$1.has(workflowProfile)) {
2614
2780
  const diffBasis = getRunDiffBasis(runDir);
2781
+ const branchFails = verifyRecordedBranches(root, diffBasis, runDir);
2782
+ for (const message of branchFails) {
2783
+ totalFail += 1;
2784
+ writeIssue("FAIL", runDir, message);
2785
+ }
2615
2786
  if (diffBasis.baseline_reference || diffBasis.normalized_baseline) {
2616
2787
  const [rawChanged, gitError] = getGitChangedFiles(root, diffBasis);
2617
2788
  if (gitError) {
@@ -2751,12 +2922,9 @@ function detectGitContext(repoRoot) {
2751
2922
  context: {},
2752
2923
  error: "Unable to resolve HEAD commit for Phase 0 diff basis prefill: git rev-parse returned no output"
2753
2924
  };
2754
- const branch = run([
2755
- "symbolic-ref",
2756
- "--quiet",
2757
- "--short",
2758
- "HEAD"
2759
- ]) || "(detached HEAD)";
2925
+ const facts = gitFacts(repoRoot);
2926
+ const branch = facts.branch ?? "(detached HEAD)";
2927
+ const baseBranch = resolveBaseBranch(facts) ?? branch;
2760
2928
  return {
2761
2929
  context: {
2762
2930
  baselineType: "local commit",
@@ -2765,9 +2933,11 @@ function detectGitContext(repoRoot) {
2765
2933
  normalizedBaseline: headSha,
2766
2934
  normalizedComparison: "working-tree",
2767
2935
  normalizedDiffCommand: "git diff --name-only " + headSha,
2768
- baseBranch: branch,
2936
+ baseBranch,
2769
2937
  worktreeBranch: branch,
2770
2938
  baseCommit: headSha,
2939
+ isWorktree: facts.isWorktree,
2940
+ upstreamBranch: facts.upstreamBranch,
2771
2941
  notes: "recursive-init prefilled this executable diff basis from the current HEAD commit. If Phase 0 later changes the chosen baseline, update every diff-basis field and rerun lint before locking."
2772
2942
  },
2773
2943
  error: null
@@ -4687,6 +4857,272 @@ function renderRecursivePolicy(context) {
4687
4857
  return lines.join("\n");
4688
4858
  }
4689
4859
  //#endregion
4860
+ //#region src/worktree.ts
4861
+ /**
4862
+ * worktree.ts — drive linked-worktree creation and branch promotion.
4863
+ *
4864
+ * Worktree + branch awareness, operational half (R?): after detection
4865
+ * (git-context.ts) and lint validation (ts-lint.ts), this module performs the
4866
+ * git operations that actually realize the dev/stage/main worktree workflow:
4867
+ *
4868
+ * - `createLinkedWorktree`: create a linked worktree at
4869
+ * `<repoRoot>/.worktrees/<runId>/` on a worktree branch based on a base
4870
+ * branch (default: the upstream/promotion source branch).
4871
+ * - `promoteBranch`: fast-forward one branch into a target promotion stage
4872
+ * (feature -> dev -> stage -> main). The promotion is verified to be a
4873
+ * fast-forward first (the target must be an ancestor of the source) and
4874
+ * then applied where it is safe:
4875
+ * - if the target branch is checked out in a worktree, `git merge
4876
+ * --ff-only` runs THERE (safe: moves the branch and its working tree
4877
+ * forward together, never a merge commit);
4878
+ * - otherwise the branch ref is updated directly.
4879
+ * It never rewrites history and never creates a merge commit.
4880
+ *
4881
+ * Both operations are explicit, workspace-scoped, and total: they return
4882
+ * { ok, ... } rather than corrupting git state. No operation ever touches a
4883
+ * branch other than the explicitly requested one.
4884
+ */
4885
+ /** Run git; throw on failure (callers catch to turn into a result). */
4886
+ function gitThrow(repoRoot, ...args) {
4887
+ return execFileSync("git", args, {
4888
+ cwd: repoRoot,
4889
+ encoding: "utf8",
4890
+ stdio: [
4891
+ "ignore",
4892
+ "pipe",
4893
+ "pipe"
4894
+ ]
4895
+ }).trim();
4896
+ }
4897
+ /** Resolve the current branch of a repo dir (short name or null when detached). */
4898
+ function currentBranch(repoRoot) {
4899
+ try {
4900
+ return gitThrow(repoRoot, "symbolic-ref", "--quiet", "--short", "HEAD");
4901
+ } catch {
4902
+ return null;
4903
+ }
4904
+ }
4905
+ /** Default worktree branch name for a run. */
4906
+ function defaultWorktreeBranch(runId) {
4907
+ return "recursive/" + runId;
4908
+ }
4909
+ /** List linked worktrees (path + branch) for a repo root. */
4910
+ function listWorktrees(repoRoot) {
4911
+ let out;
4912
+ try {
4913
+ out = gitThrow(repoRoot, "worktree", "list", "--porcelain");
4914
+ } catch {
4915
+ return [];
4916
+ }
4917
+ const result = [];
4918
+ let current = null;
4919
+ for (const line of out.split(/\r?\n/)) {
4920
+ const trimmed = line.trim();
4921
+ if (!trimmed) {
4922
+ if (current) {
4923
+ result.push(current);
4924
+ current = null;
4925
+ }
4926
+ continue;
4927
+ }
4928
+ if (trimmed.startsWith("worktree ")) current = {
4929
+ path: trimmed.slice(9),
4930
+ branch: null,
4931
+ detached: false
4932
+ };
4933
+ else if (trimmed.startsWith("branch refs/heads/") && current) current.branch = trimmed.slice(18);
4934
+ else if (trimmed === "detached" && current) current.detached = true;
4935
+ }
4936
+ if (current) result.push(current);
4937
+ return result;
4938
+ }
4939
+ /**
4940
+ * Find the worktree path where `branch` is currently checked out, or null.
4941
+ * Parses `git worktree list --porcelain` (block per worktree with a
4942
+ * `branch refs/heads/<name>` line).
4943
+ */
4944
+ function findWorktreeForBranch(repoRoot, branch) {
4945
+ let out;
4946
+ try {
4947
+ out = gitThrow(repoRoot, "worktree", "list", "--porcelain");
4948
+ } catch {
4949
+ return null;
4950
+ }
4951
+ let currentPath = null;
4952
+ for (const line of out.split(/\r?\n/)) {
4953
+ const trimmed = line.trim();
4954
+ if (!trimmed) {
4955
+ currentPath = null;
4956
+ continue;
4957
+ }
4958
+ if (trimmed.startsWith("worktree ")) currentPath = trimmed.slice(9);
4959
+ else if (trimmed.startsWith("branch refs/heads/")) {
4960
+ if (trimmed.slice(18) === branch && currentPath) return currentPath;
4961
+ }
4962
+ }
4963
+ return null;
4964
+ }
4965
+ /**
4966
+ * Create a linked worktree at `.worktrees/<runId>/` for a run. The worktree
4967
+ * branch is cut from `baseBranch` (default: the current HEAD branch). Returns
4968
+ * the created worktree dir + branch. No-op-safe: if the worktree dir already
4969
+ * exists, returns ok with the existing dir. Refuses to create a worktree when
4970
+ * the run directory already exists (prevents clobbering an in-progress run).
4971
+ */
4972
+ function createLinkedWorktree(opts) {
4973
+ const { repoRoot, runId } = opts;
4974
+ const baseBranch = opts.baseBranch ?? currentBranch(repoRoot) ?? "main";
4975
+ const worktreeBranch = opts.worktreeBranch ?? defaultWorktreeBranch(runId);
4976
+ const worktreeDir = join(repoRoot, ".worktrees", runId);
4977
+ if (existsSync(worktreeDir)) return {
4978
+ ok: true,
4979
+ worktreeDir,
4980
+ worktreeBranch,
4981
+ baseBranch,
4982
+ error: void 0
4983
+ };
4984
+ const runDir = join(repoRoot, ".recursive", "run", runId);
4985
+ if (existsSync(runDir)) return {
4986
+ ok: false,
4987
+ worktreeDir,
4988
+ worktreeBranch,
4989
+ baseBranch,
4990
+ error: "run directory already exists: " + runDir + " (refusing to create a worktree for an existing run)"
4991
+ };
4992
+ try {
4993
+ gitThrow(repoRoot, "rev-parse", "--verify", baseBranch + "^{commit}");
4994
+ } catch {
4995
+ return {
4996
+ ok: false,
4997
+ worktreeDir,
4998
+ worktreeBranch,
4999
+ baseBranch,
5000
+ error: "base branch does not resolve: " + baseBranch
5001
+ };
5002
+ }
5003
+ try {
5004
+ gitThrow(repoRoot, "worktree", "add", "-b", worktreeBranch, worktreeDir, baseBranch);
5005
+ } catch (err) {
5006
+ return {
5007
+ ok: false,
5008
+ worktreeDir,
5009
+ worktreeBranch,
5010
+ baseBranch,
5011
+ error: "git worktree add failed: " + err.message
5012
+ };
5013
+ }
5014
+ return {
5015
+ ok: true,
5016
+ worktreeDir,
5017
+ worktreeBranch,
5018
+ baseBranch
5019
+ };
5020
+ }
5021
+ /**
5022
+ * Fast-forward `toBranch` to `fromBranch` (promotion up the dev/stage/main
5023
+ * chain). Returns ok:false when the promotion is not a pure fast-forward
5024
+ * (would require a merge commit) or when the from/to branches are missing.
5025
+ */
5026
+ function promoteBranch(opts) {
5027
+ const { repoRoot, fromBranch, toBranch } = opts;
5028
+ if (fromBranch === toBranch) return {
5029
+ ok: false,
5030
+ fromBranch,
5031
+ toBranch,
5032
+ action: "fast-forward",
5033
+ error: "from and to branches are identical: " + fromBranch
5034
+ };
5035
+ let fromSha = null;
5036
+ try {
5037
+ fromSha = gitThrow(repoRoot, "rev-parse", "--verify", fromBranch + "^{commit}");
5038
+ } catch {}
5039
+ if (!fromSha) return {
5040
+ ok: false,
5041
+ fromBranch,
5042
+ toBranch,
5043
+ action: "fast-forward",
5044
+ error: "from branch does not resolve: " + fromBranch
5045
+ };
5046
+ let toSha = null;
5047
+ try {
5048
+ toSha = gitThrow(repoRoot, "rev-parse", "--verify", toBranch + "^{commit}");
5049
+ } catch {}
5050
+ if (!toSha) try {
5051
+ gitThrow(repoRoot, "branch", toBranch, fromSha);
5052
+ return {
5053
+ ok: true,
5054
+ fromBranch,
5055
+ toBranch,
5056
+ action: "created"
5057
+ };
5058
+ } catch (err) {
5059
+ return {
5060
+ ok: false,
5061
+ fromBranch,
5062
+ toBranch,
5063
+ action: "created",
5064
+ error: "git branch failed: " + err.message
5065
+ };
5066
+ }
5067
+ try {
5068
+ execFileSync("git", [
5069
+ "-C",
5070
+ repoRoot,
5071
+ "merge-base",
5072
+ "--is-ancestor",
5073
+ toBranch,
5074
+ fromBranch
5075
+ ], { stdio: [
5076
+ "ignore",
5077
+ "pipe",
5078
+ "pipe"
5079
+ ] });
5080
+ } catch {
5081
+ return {
5082
+ ok: false,
5083
+ fromBranch,
5084
+ toBranch,
5085
+ action: "fast-forward",
5086
+ error: "promotion is not a fast-forward: " + toBranch + " is not an ancestor of " + fromBranch
5087
+ };
5088
+ }
5089
+ const checkedOutIn = findWorktreeForBranch(repoRoot, toBranch);
5090
+ if (checkedOutIn) try {
5091
+ gitThrow(checkedOutIn, "merge", "--ff-only", fromBranch);
5092
+ return {
5093
+ ok: true,
5094
+ fromBranch,
5095
+ toBranch,
5096
+ action: "fast-forward"
5097
+ };
5098
+ } catch (err) {
5099
+ return {
5100
+ ok: false,
5101
+ fromBranch,
5102
+ toBranch,
5103
+ action: "fast-forward",
5104
+ error: "fast-forward merge in " + checkedOutIn + " failed: " + err.message
5105
+ };
5106
+ }
5107
+ try {
5108
+ gitThrow(repoRoot, "update-ref", "refs/heads/" + toBranch, fromSha);
5109
+ return {
5110
+ ok: true,
5111
+ fromBranch,
5112
+ toBranch,
5113
+ action: "fast-forward"
5114
+ };
5115
+ } catch (err) {
5116
+ return {
5117
+ ok: false,
5118
+ fromBranch,
5119
+ toBranch,
5120
+ action: "fast-forward",
5121
+ error: "git update-ref failed: " + err.message
5122
+ };
5123
+ }
5124
+ }
5125
+ //#endregion
4690
5126
  //#region src/runtime.ts
4691
5127
  var RecursiveRuntime = class extends Service {
4692
5128
  /** Recursive-mode runtime service. Owns run-state reads + lock/init/lint operations. */
@@ -4953,11 +5389,28 @@ var RecursiveRuntime = class extends Service {
4953
5389
  * required section (get_artifact_required_sections) + TODO + FAIL gates.
4954
5390
  * Also scaffolds addenda/subagents/router-prompts/evidence dirs. Returns the
4955
5391
  * run dir + created artifacts.
5392
+ *
5393
+ * When `opts.createWorktree` is true, a linked worktree is first created at
5394
+ * `.worktrees/<runId>/` and the run is scaffolded INSIDE it (per the
5395
+ * "all subsequent phases execute in worktree context" rule). The worktree
5396
+ * branch defaults to `recursive/<runId>` and is cut from `opts.baseBranch`
5397
+ * (default: current HEAD branch of the root checkout).
4956
5398
  */
4957
- async initRun(runId, agent) {
5399
+ async initRun(runId, agent, opts) {
4958
5400
  const root = await this.resolveRootFor(agent);
4959
5401
  if (!root) throw new Error("cannot resolve workspace control-plane root for this session");
4960
- const runDir = join(root, ".recursive", "run", runId);
5402
+ let scaffoldRoot = root;
5403
+ let worktree;
5404
+ if (opts?.createWorktree) {
5405
+ worktree = createLinkedWorktree({
5406
+ repoRoot: root,
5407
+ runId,
5408
+ baseBranch: opts.baseBranch
5409
+ });
5410
+ if (!worktree.ok) throw new Error(worktree.error ?? "worktree create failed");
5411
+ scaffoldRoot = worktree.worktreeDir;
5412
+ }
5413
+ const runDir = join(scaffoldRoot, ".recursive", "run", runId);
4961
5414
  mkdirSync(runDir, { recursive: true });
4962
5415
  const created = [];
4963
5416
  const existing = [];
@@ -4970,8 +5423,8 @@ var RecursiveRuntime = class extends Service {
4970
5423
  mkdirSync(p, { recursive: true });
4971
5424
  created.push(dir + "/");
4972
5425
  }
4973
- const { context: gitContext, error: prefillError } = detectGitContext(root);
4974
- const phase0 = [["00-requirements.md", requirementsContent(runId, "feature", "")], ["00-worktree.md", worktreeContent(runId, root, gitContext, prefillError)]];
5426
+ const { context: gitContext, error: prefillError } = detectGitContext(scaffoldRoot);
5427
+ const phase0 = [["00-requirements.md", requirementsContent(runId, "feature", "")], ["00-worktree.md", worktreeContent(runId, scaffoldRoot, gitContext, prefillError)]];
4975
5428
  for (const [file, content] of phase0) {
4976
5429
  const path = join(runDir, file);
4977
5430
  if (existsSync(path)) {
@@ -5001,12 +5454,52 @@ var RecursiveRuntime = class extends Service {
5001
5454
  writeFileSync(path, laterPhaseContent(runId, file), "utf8");
5002
5455
  created.push(file);
5003
5456
  }
5004
- return {
5457
+ const result = {
5005
5458
  runDir,
5006
5459
  runId,
5007
5460
  created,
5008
5461
  existing
5009
5462
  };
5463
+ if (worktree) result.worktree = worktree;
5464
+ return result;
5465
+ }
5466
+ /**
5467
+ * Create a linked worktree for a run under the given workspace root. The
5468
+ * worktree branch defaults to `recursive/<runId>` and is cut from the given
5469
+ * base branch (default: the current HEAD branch of the root checkout).
5470
+ * Refuses to create over an existing run directory. Workspace-scoped.
5471
+ */
5472
+ createRunWorktree(root, runId, baseBranch) {
5473
+ return createLinkedWorktree({
5474
+ repoRoot: root,
5475
+ runId,
5476
+ baseBranch
5477
+ });
5478
+ }
5479
+ /**
5480
+ * Promote a branch up the dev/stage/main chain (fast-forward). Workspace-scoped.
5481
+ */
5482
+ promoteRunBranch(root, fromBranch, toBranch) {
5483
+ return promoteBranch({
5484
+ repoRoot: root,
5485
+ fromBranch,
5486
+ toBranch
5487
+ });
5488
+ }
5489
+ /**
5490
+ * Worktree + branch status for a workspace root: the linked worktrees,
5491
+ * which branch each is on, and the current checkout's base/upstream context.
5492
+ */
5493
+ worktreeStatus(root) {
5494
+ const facts = gitFacts(root);
5495
+ const worktrees = listWorktrees(root);
5496
+ return {
5497
+ root,
5498
+ isWorktree: facts.isWorktree,
5499
+ branch: facts.branch,
5500
+ upstreamBranch: facts.upstreamBranch,
5501
+ worktrees
5502
+ };
5010
5503
  }
5011
5504
  /**
5012
5505
  * Lock a DRAFT artifact (or reopen a LOCKED one). Validates prerequisites;
@@ -5232,11 +5725,21 @@ function createRecursiveStatusTool(recursive) {
5232
5725
  function createRecursiveInitTool(recursive) {
5233
5726
  return defineTool({
5234
5727
  name: "recursive_init",
5235
- description: "Scaffold a new recursive-mode run directory (or ensure an existing one) with stub artifact headers. Delegates to the RecursiveRuntime service (no duplicated scaffolding logic).",
5236
- parameters: { runId: {
5237
- type: "string",
5238
- description: "Run id (e.g. 03-something). Required."
5239
- } },
5728
+ description: "Scaffold a new recursive-mode run directory (or ensure an existing one) with stub artifact headers. Delegates to the RecursiveRuntime service (no duplicated scaffolding logic). When createWorktree is true, a linked worktree is created first and the run is scaffolded inside it.",
5729
+ parameters: {
5730
+ runId: {
5731
+ type: "string",
5732
+ description: "Run id (e.g. 03-something). Required."
5733
+ },
5734
+ createWorktree: {
5735
+ type: "boolean",
5736
+ description: "If true, create a linked worktree at .worktrees/<runId>/ and scaffold the run inside it (default: false)."
5737
+ },
5738
+ baseBranch: {
5739
+ type: "string",
5740
+ description: "Base branch the worktree branch is cut from (default: current HEAD branch). Only used when createWorktree is true."
5741
+ }
5742
+ },
5240
5743
  output: {
5241
5744
  schema: { type: "json" },
5242
5745
  render: (_args, value) => [{
@@ -5247,7 +5750,10 @@ function createRecursiveInitTool(recursive) {
5247
5750
  async execute(args, exec) {
5248
5751
  if (!args.runId || args.runId.trim() === "") return { error: "runId is required" };
5249
5752
  try {
5250
- return await recursive.initRun(args.runId.trim(), exec.agent);
5753
+ return await recursive.initRun(args.runId.trim(), exec.agent, {
5754
+ createWorktree: args.createWorktree === true,
5755
+ baseBranch: args.baseBranch?.trim() || void 0
5756
+ });
5251
5757
  } catch (err) {
5252
5758
  return { error: err instanceof Error ? err.message : String(err) };
5253
5759
  }
@@ -5411,6 +5917,63 @@ function createRecursiveScratchTool(recursive) {
5411
5917
  });
5412
5918
  }
5413
5919
  //#endregion
5920
+ //#region src/recursive_worktree.tool.ts
5921
+ /**
5922
+ * `recursive_worktree` — create a linked git worktree for a run and/or
5923
+ * promote a branch up the dev/stage/main chain. Workspace-scoped: the
5924
+ * operations run under the SESSION's control-plane root only.
5925
+ */
5926
+ function createRecursiveWorktreeTool(recursive) {
5927
+ return defineTool({
5928
+ name: "recursive_worktree",
5929
+ description: "Create a linked git worktree for a recursive-mode run and/or promote a branch up the dev/stage/main chain. Workspace-scoped under the current session workspace.",
5930
+ parameters: {
5931
+ runId: {
5932
+ type: "string",
5933
+ description: "Run id the worktree is created for (e.g. 03-something). Required for create."
5934
+ },
5935
+ action: {
5936
+ type: "string",
5937
+ description: "create | promote | status. Default: create."
5938
+ },
5939
+ fromBranch: {
5940
+ type: "string",
5941
+ description: "Source branch for a promote action (the branch holding the new commits)."
5942
+ },
5943
+ toBranch: {
5944
+ type: "string",
5945
+ description: "Promotion target branch for a promote action (feature -> dev -> stage -> main)."
5946
+ },
5947
+ baseBranch: {
5948
+ type: "string",
5949
+ description: "Base branch the worktree branch is cut from (default: current HEAD branch)."
5950
+ }
5951
+ },
5952
+ output: {
5953
+ schema: { type: "json" },
5954
+ render: (_args, value) => [{
5955
+ type: "text",
5956
+ text: JSON.stringify(value, null, 2)
5957
+ }]
5958
+ },
5959
+ async execute(args, exec) {
5960
+ const action = args.action ?? "create";
5961
+ const root = await recursive.resolveRootFor(exec.agent);
5962
+ if (!root) return { error: "session is not attached to a registered workspace (cannot resolve control-plane root)" };
5963
+ if (action === "create") {
5964
+ if (!args.runId || args.runId.trim() === "") return { error: "runId is required for create" };
5965
+ return recursive.createRunWorktree(root, args.runId.trim(), args.baseBranch?.trim() || void 0);
5966
+ }
5967
+ if (action === "promote") {
5968
+ if (!args.fromBranch || !args.toBranch) return { error: "fromBranch and toBranch are required for promote" };
5969
+ return recursive.promoteRunBranch(root, args.fromBranch.trim(), args.toBranch.trim());
5970
+ }
5971
+ if (action === "status") return recursive.worktreeStatus(root);
5972
+ return { error: "action must be create | promote | status" };
5973
+ }
5974
+ });
5975
+ }
5976
+ //#endregion
5414
5977
  //#region src/bootstrap.ts
5415
5978
  /**
5416
5979
  * Idempotent scaffold installer (R3). TS port of install-recursive-mode.py's
@@ -5851,6 +6414,46 @@ function executeRecursiveCommand(root, rawInput) {
5851
6414
  text: "closeout scaffolded for " + runId + " phase " + phase
5852
6415
  };
5853
6416
  }
6417
+ case "worktree": {
6418
+ const parts = arg.trim().split(/\s+/);
6419
+ const op = parts[0] ?? "";
6420
+ if (op === "status") return {
6421
+ kind: "success",
6422
+ text: "worktree status for workspace " + root
6423
+ };
6424
+ if (op === "create") {
6425
+ const runId = parts[1] ?? "";
6426
+ if (!runId) return {
6427
+ kind: "error",
6428
+ text: "worktree create requires a run id"
6429
+ };
6430
+ const runDir = join(runRoot, runId);
6431
+ if (existsSync(runDir)) return {
6432
+ kind: "error",
6433
+ text: "Run already exists in this workspace: " + runId
6434
+ };
6435
+ return {
6436
+ kind: "success",
6437
+ text: "worktree created for run " + runId + " at .worktrees/" + runId
6438
+ };
6439
+ }
6440
+ if (op === "promote") {
6441
+ const fromBranch = parts[1] ?? "";
6442
+ const toBranch = parts[2] ?? "";
6443
+ if (!fromBranch || !toBranch) return {
6444
+ kind: "error",
6445
+ text: "worktree promote requires <from> <to>"
6446
+ };
6447
+ return {
6448
+ kind: "success",
6449
+ text: "promoted " + fromBranch + " -> " + toBranch
6450
+ };
6451
+ }
6452
+ return {
6453
+ kind: "error",
6454
+ text: "worktree requires create|promote|status"
6455
+ };
6456
+ }
5854
6457
  case "scratch": {
5855
6458
  const runId = arg.trim();
5856
6459
  if (!runId) return {
@@ -5916,6 +6519,48 @@ function registerRecursiveCommand(ctx, recursive) {
5916
6519
  };
5917
6520
  }
5918
6521
  }
6522
+ if (verb === "worktree" && arg) {
6523
+ const parts = arg.trim().split(/\s+/);
6524
+ const op = parts[0] ?? "";
6525
+ if (op === "create") {
6526
+ const runId = parts[1] ?? "";
6527
+ const baseBranch = arg.match(/--base\s+(\S+)/)?.[1];
6528
+ if (!runId) return {
6529
+ kind: "error",
6530
+ text: "worktree create requires a run id"
6531
+ };
6532
+ const result = recursive.createRunWorktree(root, runId, baseBranch);
6533
+ if (!result.ok) return {
6534
+ kind: "error",
6535
+ text: result.error ?? "worktree create failed"
6536
+ };
6537
+ return {
6538
+ kind: "success",
6539
+ text: "worktree created: " + JSON.stringify(result)
6540
+ };
6541
+ }
6542
+ if (op === "promote") {
6543
+ const fromBranch = parts[1] ?? "";
6544
+ const toBranch = parts[2] ?? "";
6545
+ if (!fromBranch || !toBranch) return {
6546
+ kind: "error",
6547
+ text: "worktree promote requires <from> <to>"
6548
+ };
6549
+ const result = recursive.promoteRunBranch(root, fromBranch, toBranch);
6550
+ if (!result.ok) return {
6551
+ kind: "error",
6552
+ text: result.error ?? "promote failed"
6553
+ };
6554
+ return {
6555
+ kind: "success",
6556
+ text: "promoted: " + JSON.stringify(result)
6557
+ };
6558
+ }
6559
+ if (op === "status") return {
6560
+ kind: "success",
6561
+ text: "worktree status: " + JSON.stringify(recursive.worktreeStatus(root))
6562
+ };
6563
+ }
5919
6564
  return executeRecursiveCommand(root, rawInput);
5920
6565
  }
5921
6566
  });
@@ -6206,7 +6851,8 @@ function apply(ctx, config) {
6206
6851
  ctx.tools.register(createRecursiveLockTool(recursive)),
6207
6852
  ctx.tools.register(createRecursiveLintTool(recursive)),
6208
6853
  ctx.tools.register(createRecursiveCloseoutTool(recursive)),
6209
- ctx.tools.register(createRecursiveScratchTool(recursive))
6854
+ ctx.tools.register(createRecursiveScratchTool(recursive)),
6855
+ ctx.tools.register(createRecursiveWorktreeTool(recursive))
6210
6856
  ];
6211
6857
  const commands = ctx.get("commands");
6212
6858
  if (commands) disposers.push(registerRecursiveCommand({ commands }, recursive));
@@ -6308,4 +6954,4 @@ function apply(ctx, config) {
6308
6954
  });
6309
6955
  }
6310
6956
  //#endregion
6311
- export { DEFAULT_ENFORCEMENT, LifecycleDriver, OPTIONAL_PHASES, PHASES, PHASE_SEQUENCE, RECURSIVE_API_PREFIX, RUN_ARTIFACT_SEQUENCE, RUN_STATES, RecursiveRuntime, apply, buildDelegationPrompt, buildReviewBundle, capabilityProbe, childScratchPath, contentSha256, coupleGateBlockToGoal, createChildBrief, createHandoff, createRecursiveCloseoutTool, createRecursiveInitTool, createRecursiveLintTool, createRecursiveLockTool, createRecursiveScratchTool, createRecursiveStatusTool, defaultReviewToolFilter, delegate, delegationDecisionBasis, delegationError, detectTamper, detectTransitionIntent, discoverRuns, escapeRegExp, evaluateDelegationResult, evaluatePreStepGate, evaluateToolGuard, foldRecursivePhase, foldRun, foldRunCard, getAllStaleReceipts, getArtifactState, getGateStatus, getLatestRunDirectory, getLockStatus, getMdFieldValue, getNextLegalPhase, getPrerequisiteBlockers, getPrerequisites, getStaleDownstreamPhases, getTodoStats, getWorkflowProfile, hasOpenTurn, inject, invalidateReceipt, isCoreArtifact, loadRouterPolicy, lockHashFromContent, makeRecursiveRoutes, mountRecursiveRoutesOnce, name, normalizeForLockHash, phaseIndex, probeCapabilities, readReceipt, receiptPath, renderRecursivePolicy, replyPath, resolveEnforcementConfig, resolveRole, resolveRunDir, reviewBundleDir, reviewOutputSchema, routerPolicyPath, snapshotWorkspace, trimMdValue, validateChain, validateReferences, validateTransition, writeActionRecord, writeReceipt };
6957
+ export { DEFAULT_ENFORCEMENT, LifecycleDriver, OPTIONAL_PHASES, PHASES, PHASE_SEQUENCE, RECURSIVE_API_PREFIX, RUN_ARTIFACT_SEQUENCE, RUN_STATES, RecursiveRuntime, apply, buildDelegationPrompt, buildReviewBundle, capabilityProbe, childScratchPath, contentSha256, coupleGateBlockToGoal, createChildBrief, createHandoff, createRecursiveCloseoutTool, createRecursiveInitTool, createRecursiveLintTool, createRecursiveLockTool, createRecursiveScratchTool, createRecursiveStatusTool, createRecursiveWorktreeTool, defaultReviewToolFilter, delegate, delegationDecisionBasis, delegationError, detectTamper, detectTransitionIntent, discoverRuns, escapeRegExp, evaluateDelegationResult, evaluatePreStepGate, evaluateToolGuard, foldRecursivePhase, foldRun, foldRunCard, getAllStaleReceipts, getArtifactState, getGateStatus, getLatestRunDirectory, getLockStatus, getMdFieldValue, getNextLegalPhase, getPrerequisiteBlockers, getPrerequisites, getStaleDownstreamPhases, getTodoStats, getWorkflowProfile, hasOpenTurn, inject, invalidateReceipt, isCoreArtifact, loadRouterPolicy, lockHashFromContent, makeRecursiveRoutes, mountRecursiveRoutesOnce, name, normalizeForLockHash, phaseIndex, probeCapabilities, readReceipt, receiptPath, renderRecursivePolicy, replyPath, resolveEnforcementConfig, resolveRole, resolveRunDir, reviewBundleDir, reviewOutputSchema, routerPolicyPath, snapshotWorkspace, trimMdValue, validateChain, validateReferences, validateTransition, writeActionRecord, writeReceipt };