@try-works/dsh-recursive-mode 0.1.18 → 0.2.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.
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
@@ -245,17 +393,43 @@ function getArtifactRequiredSections(fileName, workflowProfile = CURRENT_WORKFLO
245
393
  return headings;
246
394
  }
247
395
  /**
396
+ * LIVE BUG 6 (0.2.1): dedup gate for the agent/pre-step lint-rules reminder.
397
+ * Keyed by (root, runId, phase) so each new run or phase transition gets its own
398
+ * single injection, while re-arming steps in the SAME phase stay silent. Scoped
399
+ * to the apply() effect/fiber lifetime (one instance per mount), matching the
400
+ * repairedRoots dedup used by the scaffold repair above the injection point.
401
+ */
402
+ var ReminderOnceGate = class {
403
+ seen = /* @__PURE__ */ new Set();
404
+ shouldInject(root, runId, phase) {
405
+ const key = root + "\0" + runId + "\0" + phase;
406
+ if (this.seen.has(key)) return false;
407
+ this.seen.add(key);
408
+ return true;
409
+ }
410
+ };
411
+ function phaseRulesFor(fileName, workflowProfile = CURRENT_WORKFLOW_PROFILE$1) {
412
+ return {
413
+ fileName,
414
+ label: fileName.replace(/\.md$/, ""),
415
+ requiredSections: getArtifactRequiredSections(fileName, workflowProfile),
416
+ audited: AUDITED_PHASE_FILES$2.has(fileName),
417
+ tdd: fileName === "03-implementation-summary.md",
418
+ qa: fileName === "05-manual-qa.md"
419
+ };
420
+ }
421
+ /**
248
422
  * Compact lint-rules message for the pre-step injection (R5): this phase's
249
- * required sections + phase-specific gate notes, derived from the canonical
250
- * artifact-template sections. Kept short enough to prepend as a system-style
251
- * reminder without bloating the step context.
423
+ * required sections + phase-specific gate notes, built from phaseRulesFor
424
+ * (single source of truth with the recursive_phase tool). Output is unchanged
425
+ * from the prior inline build so r5-parity.spec.ts stays green.
252
426
  */
253
427
  function phaseLintRulesMessage(fileName, workflowProfile = CURRENT_WORKFLOW_PROFILE$1) {
254
- const sections = getArtifactRequiredSections(fileName, workflowProfile);
428
+ const rules = phaseRulesFor(fileName, workflowProfile);
255
429
  return [
256
430
  "<system-reminder>",
257
431
  "Recursive-mode phase lint rules for THIS phase (" + fileName + "):",
258
- "Required sections: " + sections.join(" | "),
432
+ "Required sections: " + rules.requiredSections.join(" | "),
259
433
  "Gates: Coverage: FAIL until all checkboxes pass; Approval: FAIL until user sign-off; lock only via recursive_lock (monotonic).",
260
434
  "Audited phases: end with Audit: PASS before setting Coverage/Approval PASS; record Audit Context and Audit Verdict.",
261
435
  "TDD (phase 3): declare TDD Mode: strict|pragmatic; strict requires RED + GREEN evidence paths.",
@@ -1048,6 +1222,24 @@ function getRunDiffBasis(runDir) {
1048
1222
  notes: getMdFieldValue$1(source, "Diff basis notes") ?? getMdFieldValue$1(source, "Notes")
1049
1223
  };
1050
1224
  }
1225
+ /**
1226
+ * verify_recorded_branches: worktree + branch awareness at lint time. Compares
1227
+ * the branches recorded in 00-worktree.md against live git state. Returns a
1228
+ * list of FAIL messages (empty when consistent). Both the base branch and the
1229
+ * worktree branch must resolve; the worktree branch must match the live HEAD
1230
+ * branch, and it must actually be based on the recorded base branch.
1231
+ */
1232
+ function verifyRecordedBranches(repoRoot, diffBasis, runDir) {
1233
+ const fails = [];
1234
+ const baseBranch = trimMdValue$1(diffBasis.base_branch ?? "");
1235
+ const worktreeBranch = trimMdValue$1(diffBasis.worktree_branch ?? "");
1236
+ if (!baseBranch && !worktreeBranch) return fails;
1237
+ const wtCheck = verifyWorktreeBranch(repoRoot, worktreeBranch || null);
1238
+ if (!wtCheck.ok && wtCheck.reason) fails.push(wtCheck.reason);
1239
+ const baseCheck = verifyBranchBase(repoRoot, baseBranch || null, worktreeBranch || null);
1240
+ if (!baseCheck.ok && baseCheck.reason) fails.push(baseCheck.reason);
1241
+ return fails;
1242
+ }
1051
1243
  /** normalize_diff_basis: validate + compute the executable diff basis. */
1052
1244
  function normalizeDiffBasis(repoRoot, diffBasis) {
1053
1245
  const baselineType = normalizeBaselineType(diffBasis.baseline_type);
@@ -2612,6 +2804,11 @@ function lintRun(repoRoot, runId) {
2612
2804
  let diffBasisError = null;
2613
2805
  if (STRICT_WORKFLOW_PROFILES$1.has(workflowProfile)) {
2614
2806
  const diffBasis = getRunDiffBasis(runDir);
2807
+ const branchFails = verifyRecordedBranches(root, diffBasis, runDir);
2808
+ for (const message of branchFails) {
2809
+ totalFail += 1;
2810
+ writeIssue("FAIL", runDir, message);
2811
+ }
2615
2812
  if (diffBasis.baseline_reference || diffBasis.normalized_baseline) {
2616
2813
  const [rawChanged, gitError] = getGitChangedFiles(root, diffBasis);
2617
2814
  if (gitError) {
@@ -2751,12 +2948,9 @@ function detectGitContext(repoRoot) {
2751
2948
  context: {},
2752
2949
  error: "Unable to resolve HEAD commit for Phase 0 diff basis prefill: git rev-parse returned no output"
2753
2950
  };
2754
- const branch = run([
2755
- "symbolic-ref",
2756
- "--quiet",
2757
- "--short",
2758
- "HEAD"
2759
- ]) || "(detached HEAD)";
2951
+ const facts = gitFacts(repoRoot);
2952
+ const branch = facts.branch ?? "(detached HEAD)";
2953
+ const baseBranch = resolveBaseBranch(facts) ?? branch;
2760
2954
  return {
2761
2955
  context: {
2762
2956
  baselineType: "local commit",
@@ -2765,9 +2959,11 @@ function detectGitContext(repoRoot) {
2765
2959
  normalizedBaseline: headSha,
2766
2960
  normalizedComparison: "working-tree",
2767
2961
  normalizedDiffCommand: "git diff --name-only " + headSha,
2768
- baseBranch: branch,
2962
+ baseBranch,
2769
2963
  worktreeBranch: branch,
2770
2964
  baseCommit: headSha,
2965
+ isWorktree: facts.isWorktree,
2966
+ upstreamBranch: facts.upstreamBranch,
2771
2967
  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
2968
  },
2773
2969
  error: null
@@ -4671,7 +4867,8 @@ function renderRecursivePolicy(context) {
4671
4867
  "- Phase 3 lock requires TDD evidence (strict) or rationale (pragmatic); Phase 5 requires QA sign-off for human/hybrid modes.",
4672
4868
  "- The control-plane root is resolved STRICTLY from this session workspace (never scan other workspaces).",
4673
4869
  "- Scratch is disposable, git-ignored, and never citable as an Input.",
4674
- "- The workflow spec lives at /.recursive/RECURSIVE.md; bootstrap it when missing."
4870
+ "- The workflow spec lives at /.recursive/RECURSIVE.md; bootstrap it when missing.",
4871
+ "- Call recursive_phase when entering a new phase; the same rules are auto-injected once per phase transition."
4675
4872
  ];
4676
4873
  if (currentFile) {
4677
4874
  const sections = getArtifactRequiredSections(currentFile, CURRENT_WORKFLOW_PROFILE$1);
@@ -4687,6 +4884,272 @@ function renderRecursivePolicy(context) {
4687
4884
  return lines.join("\n");
4688
4885
  }
4689
4886
  //#endregion
4887
+ //#region src/worktree.ts
4888
+ /**
4889
+ * worktree.ts — drive linked-worktree creation and branch promotion.
4890
+ *
4891
+ * Worktree + branch awareness, operational half (R?): after detection
4892
+ * (git-context.ts) and lint validation (ts-lint.ts), this module performs the
4893
+ * git operations that actually realize the dev/stage/main worktree workflow:
4894
+ *
4895
+ * - `createLinkedWorktree`: create a linked worktree at
4896
+ * `<repoRoot>/.worktrees/<runId>/` on a worktree branch based on a base
4897
+ * branch (default: the upstream/promotion source branch).
4898
+ * - `promoteBranch`: fast-forward one branch into a target promotion stage
4899
+ * (feature -> dev -> stage -> main). The promotion is verified to be a
4900
+ * fast-forward first (the target must be an ancestor of the source) and
4901
+ * then applied where it is safe:
4902
+ * - if the target branch is checked out in a worktree, `git merge
4903
+ * --ff-only` runs THERE (safe: moves the branch and its working tree
4904
+ * forward together, never a merge commit);
4905
+ * - otherwise the branch ref is updated directly.
4906
+ * It never rewrites history and never creates a merge commit.
4907
+ *
4908
+ * Both operations are explicit, workspace-scoped, and total: they return
4909
+ * { ok, ... } rather than corrupting git state. No operation ever touches a
4910
+ * branch other than the explicitly requested one.
4911
+ */
4912
+ /** Run git; throw on failure (callers catch to turn into a result). */
4913
+ function gitThrow(repoRoot, ...args) {
4914
+ return execFileSync("git", args, {
4915
+ cwd: repoRoot,
4916
+ encoding: "utf8",
4917
+ stdio: [
4918
+ "ignore",
4919
+ "pipe",
4920
+ "pipe"
4921
+ ]
4922
+ }).trim();
4923
+ }
4924
+ /** Resolve the current branch of a repo dir (short name or null when detached). */
4925
+ function currentBranch(repoRoot) {
4926
+ try {
4927
+ return gitThrow(repoRoot, "symbolic-ref", "--quiet", "--short", "HEAD");
4928
+ } catch {
4929
+ return null;
4930
+ }
4931
+ }
4932
+ /** Default worktree branch name for a run. */
4933
+ function defaultWorktreeBranch(runId) {
4934
+ return "recursive/" + runId;
4935
+ }
4936
+ /** List linked worktrees (path + branch) for a repo root. */
4937
+ function listWorktrees(repoRoot) {
4938
+ let out;
4939
+ try {
4940
+ out = gitThrow(repoRoot, "worktree", "list", "--porcelain");
4941
+ } catch {
4942
+ return [];
4943
+ }
4944
+ const result = [];
4945
+ let current = null;
4946
+ for (const line of out.split(/\r?\n/)) {
4947
+ const trimmed = line.trim();
4948
+ if (!trimmed) {
4949
+ if (current) {
4950
+ result.push(current);
4951
+ current = null;
4952
+ }
4953
+ continue;
4954
+ }
4955
+ if (trimmed.startsWith("worktree ")) current = {
4956
+ path: trimmed.slice(9),
4957
+ branch: null,
4958
+ detached: false
4959
+ };
4960
+ else if (trimmed.startsWith("branch refs/heads/") && current) current.branch = trimmed.slice(18);
4961
+ else if (trimmed === "detached" && current) current.detached = true;
4962
+ }
4963
+ if (current) result.push(current);
4964
+ return result;
4965
+ }
4966
+ /**
4967
+ * Find the worktree path where `branch` is currently checked out, or null.
4968
+ * Parses `git worktree list --porcelain` (block per worktree with a
4969
+ * `branch refs/heads/<name>` line).
4970
+ */
4971
+ function findWorktreeForBranch(repoRoot, branch) {
4972
+ let out;
4973
+ try {
4974
+ out = gitThrow(repoRoot, "worktree", "list", "--porcelain");
4975
+ } catch {
4976
+ return null;
4977
+ }
4978
+ let currentPath = null;
4979
+ for (const line of out.split(/\r?\n/)) {
4980
+ const trimmed = line.trim();
4981
+ if (!trimmed) {
4982
+ currentPath = null;
4983
+ continue;
4984
+ }
4985
+ if (trimmed.startsWith("worktree ")) currentPath = trimmed.slice(9);
4986
+ else if (trimmed.startsWith("branch refs/heads/")) {
4987
+ if (trimmed.slice(18) === branch && currentPath) return currentPath;
4988
+ }
4989
+ }
4990
+ return null;
4991
+ }
4992
+ /**
4993
+ * Create a linked worktree at `.worktrees/<runId>/` for a run. The worktree
4994
+ * branch is cut from `baseBranch` (default: the current HEAD branch). Returns
4995
+ * the created worktree dir + branch. No-op-safe: if the worktree dir already
4996
+ * exists, returns ok with the existing dir. Refuses to create a worktree when
4997
+ * the run directory already exists (prevents clobbering an in-progress run).
4998
+ */
4999
+ function createLinkedWorktree(opts) {
5000
+ const { repoRoot, runId } = opts;
5001
+ const baseBranch = opts.baseBranch ?? currentBranch(repoRoot) ?? "main";
5002
+ const worktreeBranch = opts.worktreeBranch ?? defaultWorktreeBranch(runId);
5003
+ const worktreeDir = join(repoRoot, ".worktrees", runId);
5004
+ if (existsSync(worktreeDir)) return {
5005
+ ok: true,
5006
+ worktreeDir,
5007
+ worktreeBranch,
5008
+ baseBranch,
5009
+ error: void 0
5010
+ };
5011
+ const runDir = join(repoRoot, ".recursive", "run", runId);
5012
+ if (existsSync(runDir)) return {
5013
+ ok: false,
5014
+ worktreeDir,
5015
+ worktreeBranch,
5016
+ baseBranch,
5017
+ error: "run directory already exists: " + runDir + " (refusing to create a worktree for an existing run)"
5018
+ };
5019
+ try {
5020
+ gitThrow(repoRoot, "rev-parse", "--verify", baseBranch + "^{commit}");
5021
+ } catch {
5022
+ return {
5023
+ ok: false,
5024
+ worktreeDir,
5025
+ worktreeBranch,
5026
+ baseBranch,
5027
+ error: "base branch does not resolve: " + baseBranch
5028
+ };
5029
+ }
5030
+ try {
5031
+ gitThrow(repoRoot, "worktree", "add", "-b", worktreeBranch, worktreeDir, baseBranch);
5032
+ } catch (err) {
5033
+ return {
5034
+ ok: false,
5035
+ worktreeDir,
5036
+ worktreeBranch,
5037
+ baseBranch,
5038
+ error: "git worktree add failed: " + err.message
5039
+ };
5040
+ }
5041
+ return {
5042
+ ok: true,
5043
+ worktreeDir,
5044
+ worktreeBranch,
5045
+ baseBranch
5046
+ };
5047
+ }
5048
+ /**
5049
+ * Fast-forward `toBranch` to `fromBranch` (promotion up the dev/stage/main
5050
+ * chain). Returns ok:false when the promotion is not a pure fast-forward
5051
+ * (would require a merge commit) or when the from/to branches are missing.
5052
+ */
5053
+ function promoteBranch(opts) {
5054
+ const { repoRoot, fromBranch, toBranch } = opts;
5055
+ if (fromBranch === toBranch) return {
5056
+ ok: false,
5057
+ fromBranch,
5058
+ toBranch,
5059
+ action: "fast-forward",
5060
+ error: "from and to branches are identical: " + fromBranch
5061
+ };
5062
+ let fromSha = null;
5063
+ try {
5064
+ fromSha = gitThrow(repoRoot, "rev-parse", "--verify", fromBranch + "^{commit}");
5065
+ } catch {}
5066
+ if (!fromSha) return {
5067
+ ok: false,
5068
+ fromBranch,
5069
+ toBranch,
5070
+ action: "fast-forward",
5071
+ error: "from branch does not resolve: " + fromBranch
5072
+ };
5073
+ let toSha = null;
5074
+ try {
5075
+ toSha = gitThrow(repoRoot, "rev-parse", "--verify", toBranch + "^{commit}");
5076
+ } catch {}
5077
+ if (!toSha) try {
5078
+ gitThrow(repoRoot, "branch", toBranch, fromSha);
5079
+ return {
5080
+ ok: true,
5081
+ fromBranch,
5082
+ toBranch,
5083
+ action: "created"
5084
+ };
5085
+ } catch (err) {
5086
+ return {
5087
+ ok: false,
5088
+ fromBranch,
5089
+ toBranch,
5090
+ action: "created",
5091
+ error: "git branch failed: " + err.message
5092
+ };
5093
+ }
5094
+ try {
5095
+ execFileSync("git", [
5096
+ "-C",
5097
+ repoRoot,
5098
+ "merge-base",
5099
+ "--is-ancestor",
5100
+ toBranch,
5101
+ fromBranch
5102
+ ], { stdio: [
5103
+ "ignore",
5104
+ "pipe",
5105
+ "pipe"
5106
+ ] });
5107
+ } catch {
5108
+ return {
5109
+ ok: false,
5110
+ fromBranch,
5111
+ toBranch,
5112
+ action: "fast-forward",
5113
+ error: "promotion is not a fast-forward: " + toBranch + " is not an ancestor of " + fromBranch
5114
+ };
5115
+ }
5116
+ const checkedOutIn = findWorktreeForBranch(repoRoot, toBranch);
5117
+ if (checkedOutIn) try {
5118
+ gitThrow(checkedOutIn, "merge", "--ff-only", fromBranch);
5119
+ return {
5120
+ ok: true,
5121
+ fromBranch,
5122
+ toBranch,
5123
+ action: "fast-forward"
5124
+ };
5125
+ } catch (err) {
5126
+ return {
5127
+ ok: false,
5128
+ fromBranch,
5129
+ toBranch,
5130
+ action: "fast-forward",
5131
+ error: "fast-forward merge in " + checkedOutIn + " failed: " + err.message
5132
+ };
5133
+ }
5134
+ try {
5135
+ gitThrow(repoRoot, "update-ref", "refs/heads/" + toBranch, fromSha);
5136
+ return {
5137
+ ok: true,
5138
+ fromBranch,
5139
+ toBranch,
5140
+ action: "fast-forward"
5141
+ };
5142
+ } catch (err) {
5143
+ return {
5144
+ ok: false,
5145
+ fromBranch,
5146
+ toBranch,
5147
+ action: "fast-forward",
5148
+ error: "git update-ref failed: " + err.message
5149
+ };
5150
+ }
5151
+ }
5152
+ //#endregion
4690
5153
  //#region src/runtime.ts
4691
5154
  var RecursiveRuntime = class extends Service {
4692
5155
  /** Recursive-mode runtime service. Owns run-state reads + lock/init/lint operations. */
@@ -4947,17 +5410,54 @@ var RecursiveRuntime = class extends Service {
4947
5410
  return foldRun(resolved.runDir, resolved.runId);
4948
5411
  }
4949
5412
  /**
5413
+ * LIVE BUG 6 refined: structured phase rules for the CURRENT phase. Resolves
5414
+ * the workspace root (same as status/lock), finds the latest run (or the
5415
+ * given runId), advances via getNextLegalPhase, and returns the phase's lint
5416
+ * rules + instructions. Returns null when no active phase exists. This is the
5417
+ * canonical data source for the recursive_phase tool.
5418
+ */
5419
+ async phaseRules(runId, agent) {
5420
+ const root = await this.resolveRootFor(agent);
5421
+ if (!root) return null;
5422
+ const resolved = resolveRunDir(root, runId);
5423
+ if (!resolved) return null;
5424
+ const phase = getNextLegalPhase(resolved.runDir);
5425
+ if (!phase) return null;
5426
+ return {
5427
+ runId: resolved.runId,
5428
+ phase,
5429
+ ...phaseRulesFor(phase)
5430
+ };
5431
+ }
5432
+ /**
4950
5433
  * Scaffold a run directory with FULL per-phase templates (no-op if exists).
4951
5434
  * 00-requirements.md + 00-worktree.md are byte-identical to canonical
4952
5435
  * recursive-init.py (incl. git-context prefill); later phases carry every
4953
5436
  * required section (get_artifact_required_sections) + TODO + FAIL gates.
4954
5437
  * Also scaffolds addenda/subagents/router-prompts/evidence dirs. Returns the
4955
5438
  * run dir + created artifacts.
5439
+ *
5440
+ * When `opts.createWorktree` is true, a linked worktree is first created at
5441
+ * `.worktrees/<runId>/` and the run is scaffolded INSIDE it (per the
5442
+ * "all subsequent phases execute in worktree context" rule). The worktree
5443
+ * branch defaults to `recursive/<runId>` and is cut from `opts.baseBranch`
5444
+ * (default: current HEAD branch of the root checkout).
4956
5445
  */
4957
- async initRun(runId, agent) {
5446
+ async initRun(runId, agent, opts) {
4958
5447
  const root = await this.resolveRootFor(agent);
4959
5448
  if (!root) throw new Error("cannot resolve workspace control-plane root for this session");
4960
- const runDir = join(root, ".recursive", "run", runId);
5449
+ let scaffoldRoot = root;
5450
+ let worktree;
5451
+ if (opts?.createWorktree) {
5452
+ worktree = createLinkedWorktree({
5453
+ repoRoot: root,
5454
+ runId,
5455
+ baseBranch: opts.baseBranch
5456
+ });
5457
+ if (!worktree.ok) throw new Error(worktree.error ?? "worktree create failed");
5458
+ scaffoldRoot = worktree.worktreeDir;
5459
+ }
5460
+ const runDir = join(scaffoldRoot, ".recursive", "run", runId);
4961
5461
  mkdirSync(runDir, { recursive: true });
4962
5462
  const created = [];
4963
5463
  const existing = [];
@@ -4970,8 +5470,8 @@ var RecursiveRuntime = class extends Service {
4970
5470
  mkdirSync(p, { recursive: true });
4971
5471
  created.push(dir + "/");
4972
5472
  }
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)]];
5473
+ const { context: gitContext, error: prefillError } = detectGitContext(scaffoldRoot);
5474
+ const phase0 = [["00-requirements.md", requirementsContent(runId, "feature", "")], ["00-worktree.md", worktreeContent(runId, scaffoldRoot, gitContext, prefillError)]];
4975
5475
  for (const [file, content] of phase0) {
4976
5476
  const path = join(runDir, file);
4977
5477
  if (existsSync(path)) {
@@ -5001,12 +5501,52 @@ var RecursiveRuntime = class extends Service {
5001
5501
  writeFileSync(path, laterPhaseContent(runId, file), "utf8");
5002
5502
  created.push(file);
5003
5503
  }
5004
- return {
5504
+ const result = {
5005
5505
  runDir,
5006
5506
  runId,
5007
5507
  created,
5008
5508
  existing
5009
5509
  };
5510
+ if (worktree) result.worktree = worktree;
5511
+ return result;
5512
+ }
5513
+ /**
5514
+ * Create a linked worktree for a run under the given workspace root. The
5515
+ * worktree branch defaults to `recursive/<runId>` and is cut from the given
5516
+ * base branch (default: the current HEAD branch of the root checkout).
5517
+ * Refuses to create over an existing run directory. Workspace-scoped.
5518
+ */
5519
+ createRunWorktree(root, runId, baseBranch) {
5520
+ return createLinkedWorktree({
5521
+ repoRoot: root,
5522
+ runId,
5523
+ baseBranch
5524
+ });
5525
+ }
5526
+ /**
5527
+ * Promote a branch up the dev/stage/main chain (fast-forward). Workspace-scoped.
5528
+ */
5529
+ promoteRunBranch(root, fromBranch, toBranch) {
5530
+ return promoteBranch({
5531
+ repoRoot: root,
5532
+ fromBranch,
5533
+ toBranch
5534
+ });
5535
+ }
5536
+ /**
5537
+ * Worktree + branch status for a workspace root: the linked worktrees,
5538
+ * which branch each is on, and the current checkout's base/upstream context.
5539
+ */
5540
+ worktreeStatus(root) {
5541
+ const facts = gitFacts(root);
5542
+ const worktrees = listWorktrees(root);
5543
+ return {
5544
+ root,
5545
+ isWorktree: facts.isWorktree,
5546
+ branch: facts.branch,
5547
+ upstreamBranch: facts.upstreamBranch,
5548
+ worktrees
5549
+ };
5010
5550
  }
5011
5551
  /**
5012
5552
  * Lock a DRAFT artifact (or reopen a LOCKED one). Validates prerequisites;
@@ -5232,11 +5772,21 @@ function createRecursiveStatusTool(recursive) {
5232
5772
  function createRecursiveInitTool(recursive) {
5233
5773
  return defineTool({
5234
5774
  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
- } },
5775
+ 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.",
5776
+ parameters: {
5777
+ runId: {
5778
+ type: "string",
5779
+ description: "Run id (e.g. 03-something). Required."
5780
+ },
5781
+ createWorktree: {
5782
+ type: "boolean",
5783
+ description: "If true, create a linked worktree at .worktrees/<runId>/ and scaffold the run inside it (default: false)."
5784
+ },
5785
+ baseBranch: {
5786
+ type: "string",
5787
+ description: "Base branch the worktree branch is cut from (default: current HEAD branch). Only used when createWorktree is true."
5788
+ }
5789
+ },
5240
5790
  output: {
5241
5791
  schema: { type: "json" },
5242
5792
  render: (_args, value) => [{
@@ -5247,7 +5797,10 @@ function createRecursiveInitTool(recursive) {
5247
5797
  async execute(args, exec) {
5248
5798
  if (!args.runId || args.runId.trim() === "") return { error: "runId is required" };
5249
5799
  try {
5250
- return await recursive.initRun(args.runId.trim(), exec.agent);
5800
+ return await recursive.initRun(args.runId.trim(), exec.agent, {
5801
+ createWorktree: args.createWorktree === true,
5802
+ baseBranch: args.baseBranch?.trim() || void 0
5803
+ });
5251
5804
  } catch (err) {
5252
5805
  return { error: err instanceof Error ? err.message : String(err) };
5253
5806
  }
@@ -5411,6 +5964,94 @@ function createRecursiveScratchTool(recursive) {
5411
5964
  });
5412
5965
  }
5413
5966
  //#endregion
5967
+ //#region src/recursive_worktree.tool.ts
5968
+ /**
5969
+ * `recursive_worktree` — create a linked git worktree for a run and/or
5970
+ * promote a branch up the dev/stage/main chain. Workspace-scoped: the
5971
+ * operations run under the SESSION's control-plane root only.
5972
+ */
5973
+ function createRecursiveWorktreeTool(recursive) {
5974
+ return defineTool({
5975
+ name: "recursive_worktree",
5976
+ 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.",
5977
+ parameters: {
5978
+ runId: {
5979
+ type: "string",
5980
+ description: "Run id the worktree is created for (e.g. 03-something). Required for create."
5981
+ },
5982
+ action: {
5983
+ type: "string",
5984
+ description: "create | promote | status. Default: create."
5985
+ },
5986
+ fromBranch: {
5987
+ type: "string",
5988
+ description: "Source branch for a promote action (the branch holding the new commits)."
5989
+ },
5990
+ toBranch: {
5991
+ type: "string",
5992
+ description: "Promotion target branch for a promote action (feature -> dev -> stage -> main)."
5993
+ },
5994
+ baseBranch: {
5995
+ type: "string",
5996
+ description: "Base branch the worktree branch is cut from (default: current HEAD branch)."
5997
+ }
5998
+ },
5999
+ output: {
6000
+ schema: { type: "json" },
6001
+ render: (_args, value) => [{
6002
+ type: "text",
6003
+ text: JSON.stringify(value, null, 2)
6004
+ }]
6005
+ },
6006
+ async execute(args, exec) {
6007
+ const action = args.action ?? "create";
6008
+ const root = await recursive.resolveRootFor(exec.agent);
6009
+ if (!root) return { error: "session is not attached to a registered workspace (cannot resolve control-plane root)" };
6010
+ if (action === "create") {
6011
+ if (!args.runId || args.runId.trim() === "") return { error: "runId is required for create" };
6012
+ return recursive.createRunWorktree(root, args.runId.trim(), args.baseBranch?.trim() || void 0);
6013
+ }
6014
+ if (action === "promote") {
6015
+ if (!args.fromBranch || !args.toBranch) return { error: "fromBranch and toBranch are required for promote" };
6016
+ return recursive.promoteRunBranch(root, args.fromBranch.trim(), args.toBranch.trim());
6017
+ }
6018
+ if (action === "status") return recursive.worktreeStatus(root);
6019
+ return { error: "action must be create | promote | status" };
6020
+ }
6021
+ });
6022
+ }
6023
+ //#endregion
6024
+ //#region src/recursive_phase.tool.ts
6025
+ /**
6026
+ * recursive_phase (refined LIVE BUG 6): the canonical on-demand home of the
6027
+ * current phase's lint rules + instructions. Reads the same structured source
6028
+ * (runtime.phaseRules -> phaseRulesFor) as the once-per-phase pre-step
6029
+ * reminder, so the agent can re-ask for the rules without re-injecting them on
6030
+ * every step. Returns { error } when no active phase is found.
6031
+ */
6032
+ function createRecursivePhaseTool(recursive) {
6033
+ return defineTool({
6034
+ name: "recursive_phase",
6035
+ description: "Return the lint rules + instructions for the current recursive-mode phase (required sections, gates, TDD/QA notes). Call once when entering a new phase; the same rules are also auto-injected once per phase transition.",
6036
+ parameters: { runId: {
6037
+ type: "string",
6038
+ description: "Optional run id (defaults to the latest run by mtime)"
6039
+ } },
6040
+ output: {
6041
+ schema: { type: "json" },
6042
+ render: (_args, value) => [{
6043
+ type: "text",
6044
+ text: JSON.stringify(value, null, 2)
6045
+ }]
6046
+ },
6047
+ async execute(args, exec) {
6048
+ const result = await recursive.phaseRules(args.runId, exec.agent);
6049
+ if (!result) return { error: "no recursive phase found" };
6050
+ return result;
6051
+ }
6052
+ });
6053
+ }
6054
+ //#endregion
5414
6055
  //#region src/bootstrap.ts
5415
6056
  /**
5416
6057
  * Idempotent scaffold installer (R3). TS port of install-recursive-mode.py's
@@ -5851,6 +6492,46 @@ function executeRecursiveCommand(root, rawInput) {
5851
6492
  text: "closeout scaffolded for " + runId + " phase " + phase
5852
6493
  };
5853
6494
  }
6495
+ case "worktree": {
6496
+ const parts = arg.trim().split(/\s+/);
6497
+ const op = parts[0] ?? "";
6498
+ if (op === "status") return {
6499
+ kind: "success",
6500
+ text: "worktree status for workspace " + root
6501
+ };
6502
+ if (op === "create") {
6503
+ const runId = parts[1] ?? "";
6504
+ if (!runId) return {
6505
+ kind: "error",
6506
+ text: "worktree create requires a run id"
6507
+ };
6508
+ const runDir = join(runRoot, runId);
6509
+ if (existsSync(runDir)) return {
6510
+ kind: "error",
6511
+ text: "Run already exists in this workspace: " + runId
6512
+ };
6513
+ return {
6514
+ kind: "success",
6515
+ text: "worktree created for run " + runId + " at .worktrees/" + runId
6516
+ };
6517
+ }
6518
+ if (op === "promote") {
6519
+ const fromBranch = parts[1] ?? "";
6520
+ const toBranch = parts[2] ?? "";
6521
+ if (!fromBranch || !toBranch) return {
6522
+ kind: "error",
6523
+ text: "worktree promote requires <from> <to>"
6524
+ };
6525
+ return {
6526
+ kind: "success",
6527
+ text: "promoted " + fromBranch + " -> " + toBranch
6528
+ };
6529
+ }
6530
+ return {
6531
+ kind: "error",
6532
+ text: "worktree requires create|promote|status"
6533
+ };
6534
+ }
5854
6535
  case "scratch": {
5855
6536
  const runId = arg.trim();
5856
6537
  if (!runId) return {
@@ -5916,6 +6597,48 @@ function registerRecursiveCommand(ctx, recursive) {
5916
6597
  };
5917
6598
  }
5918
6599
  }
6600
+ if (verb === "worktree" && arg) {
6601
+ const parts = arg.trim().split(/\s+/);
6602
+ const op = parts[0] ?? "";
6603
+ if (op === "create") {
6604
+ const runId = parts[1] ?? "";
6605
+ const baseBranch = arg.match(/--base\s+(\S+)/)?.[1];
6606
+ if (!runId) return {
6607
+ kind: "error",
6608
+ text: "worktree create requires a run id"
6609
+ };
6610
+ const result = recursive.createRunWorktree(root, runId, baseBranch);
6611
+ if (!result.ok) return {
6612
+ kind: "error",
6613
+ text: result.error ?? "worktree create failed"
6614
+ };
6615
+ return {
6616
+ kind: "success",
6617
+ text: "worktree created: " + JSON.stringify(result)
6618
+ };
6619
+ }
6620
+ if (op === "promote") {
6621
+ const fromBranch = parts[1] ?? "";
6622
+ const toBranch = parts[2] ?? "";
6623
+ if (!fromBranch || !toBranch) return {
6624
+ kind: "error",
6625
+ text: "worktree promote requires <from> <to>"
6626
+ };
6627
+ const result = recursive.promoteRunBranch(root, fromBranch, toBranch);
6628
+ if (!result.ok) return {
6629
+ kind: "error",
6630
+ text: result.error ?? "promote failed"
6631
+ };
6632
+ return {
6633
+ kind: "success",
6634
+ text: "promoted: " + JSON.stringify(result)
6635
+ };
6636
+ }
6637
+ if (op === "status") return {
6638
+ kind: "success",
6639
+ text: "worktree status: " + JSON.stringify(recursive.worktreeStatus(root))
6640
+ };
6641
+ }
5919
6642
  return executeRecursiveCommand(root, rawInput);
5920
6643
  }
5921
6644
  });
@@ -6200,13 +6923,16 @@ function apply(ctx, config) {
6200
6923
  workspaceRegistry
6201
6924
  });
6202
6925
  const repairedRoots = /* @__PURE__ */ new Set();
6926
+ const reminderGate = new ReminderOnceGate();
6203
6927
  const disposers = [
6204
6928
  ctx.tools.register(createRecursiveStatusTool(recursive)),
6205
6929
  ctx.tools.register(createRecursiveInitTool(recursive)),
6206
6930
  ctx.tools.register(createRecursiveLockTool(recursive)),
6207
6931
  ctx.tools.register(createRecursiveLintTool(recursive)),
6208
6932
  ctx.tools.register(createRecursiveCloseoutTool(recursive)),
6209
- ctx.tools.register(createRecursiveScratchTool(recursive))
6933
+ ctx.tools.register(createRecursiveScratchTool(recursive)),
6934
+ ctx.tools.register(createRecursiveWorktreeTool(recursive)),
6935
+ ctx.tools.register(createRecursivePhaseTool(recursive))
6210
6936
  ];
6211
6937
  const commands = ctx.get("commands");
6212
6938
  if (commands) disposers.push(registerRecursiveCommand({ commands }, recursive));
@@ -6277,6 +7003,10 @@ function apply(ctx, config) {
6277
7003
  kind: "enter",
6278
7004
  messages
6279
7005
  };
7006
+ if (!reminderGate.shouldInject(root, runId, phase)) return {
7007
+ kind: "enter",
7008
+ messages
7009
+ };
6280
7010
  const reminder = phaseLintRulesMessage(phase);
6281
7011
  return {
6282
7012
  kind: "enter",
@@ -6308,4 +7038,4 @@ function apply(ctx, config) {
6308
7038
  });
6309
7039
  }
6310
7040
  //#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 };
7041
+ 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, createRecursivePhaseTool, 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 };