@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.
@@ -8,6 +8,10 @@ export interface GitContext {
8
8
  baseBranch: string;
9
9
  worktreeBranch: string;
10
10
  baseCommit: string;
11
+ /** True when the init cwd is a linked git worktree (git-dir != git-common-dir). */
12
+ isWorktree: boolean;
13
+ /** Upstream branch with the remote prefix stripped (origin/dev -> dev), null when none/detached. */
14
+ upstreamBranch: string | null;
11
15
  notes: string;
12
16
  }
13
17
  /**
@@ -0,0 +1,7 @@
1
+ import type { RecursiveRuntime } from './runtime.ts';
2
+ /**
3
+ * `recursive_worktree` — create a linked git worktree for a run and/or
4
+ * promote a branch up the dev/stage/main chain. Workspace-scoped: the
5
+ * operations run under the SESSION's control-plane root only.
6
+ */
7
+ export declare function createRecursiveWorktreeTool(recursive: RecursiveRuntime): import("@deepseek-ai/dsh-tools").ToolDefinition;
package/lib/runtime.d.ts CHANGED
@@ -7,6 +7,7 @@ import { type SubagentProviderLike, type RouteDecision, type CapabilityProbe } f
7
7
  import { type SubagentsRuntimeLike, type SubagentStartRequestLike, type SubagentResultLike, type Reference } from './delegation.ts';
8
8
  import { type PhaseTransitionIntent, type SessionEventLike, type RecursivePhaseState, type GateCheckResult } from './lifecycle.ts';
9
9
  import { type EnforcementConfig, type PreStepGateDecision, type ToolGuardDecision, type ToolExecLike } from './enforcement.ts';
10
+ import { type CreateWorktreeResult, type PromoteBranchResult } from './worktree.ts';
10
11
  declare module '@deepseek-ai/cordis' {
11
12
  interface Context {
12
13
  recursive: RecursiveRuntime;
@@ -180,6 +181,12 @@ export declare class RecursiveRuntime extends Service {
180
181
  * required section (get_artifact_required_sections) + TODO + FAIL gates.
181
182
  * Also scaffolds addenda/subagents/router-prompts/evidence dirs. Returns the
182
183
  * run dir + created artifacts.
184
+ *
185
+ * When `opts.createWorktree` is true, a linked worktree is first created at
186
+ * `.worktrees/<runId>/` and the run is scaffolded INSIDE it (per the
187
+ * "all subsequent phases execute in worktree context" rule). The worktree
188
+ * branch defaults to `recursive/<runId>` and is cut from `opts.baseBranch`
189
+ * (default: current HEAD branch of the root checkout).
183
190
  */
184
191
  initRun(runId: string, agent?: {
185
192
  session?: {
@@ -187,12 +194,32 @@ export declare class RecursiveRuntime extends Service {
187
194
  cwd?: string;
188
195
  };
189
196
  };
190
- } | null): Promise<{
197
+ } | null, opts?: {
198
+ createWorktree?: boolean;
199
+ baseBranch?: string;
200
+ }): Promise<{
191
201
  runDir: string;
192
202
  runId: string;
193
203
  created: string[];
194
204
  existing: string[];
205
+ worktree?: CreateWorktreeResult;
195
206
  }>;
207
+ /**
208
+ * Create a linked worktree for a run under the given workspace root. The
209
+ * worktree branch defaults to `recursive/<runId>` and is cut from the given
210
+ * base branch (default: the current HEAD branch of the root checkout).
211
+ * Refuses to create over an existing run directory. Workspace-scoped.
212
+ */
213
+ createRunWorktree(root: string, runId: string, baseBranch?: string): CreateWorktreeResult;
214
+ /**
215
+ * Promote a branch up the dev/stage/main chain (fast-forward). Workspace-scoped.
216
+ */
217
+ promoteRunBranch(root: string, fromBranch: string, toBranch: string): PromoteBranchResult;
218
+ /**
219
+ * Worktree + branch status for a workspace root: the linked worktrees,
220
+ * which branch each is on, and the current checkout's base/upstream context.
221
+ */
222
+ worktreeStatus(root: string): Record<string, unknown>;
196
223
  /**
197
224
  * Lock a DRAFT artifact (or reopen a LOCKED one). Validates prerequisites;
198
225
  * writes Status/LockedAt/LockHash + receipt. Returns the lock result.
package/lib/ts-lint.d.ts CHANGED
@@ -105,6 +105,14 @@ export declare function normalizeComparisonReference(value: string | null): stri
105
105
  export declare function parseDiffBasisSource(content: string): string;
106
106
  /** get_run_diff_basis: read diff-basis fields from 00-worktree.md. */
107
107
  export declare function getRunDiffBasis(runDir: string): Record<string, string | null>;
108
+ /**
109
+ * verify_recorded_branches: worktree + branch awareness at lint time. Compares
110
+ * the branches recorded in 00-worktree.md against live git state. Returns a
111
+ * list of FAIL messages (empty when consistent). Both the base branch and the
112
+ * worktree branch must resolve; the worktree branch must match the live HEAD
113
+ * branch, and it must actually be based on the recorded base branch.
114
+ */
115
+ export declare function verifyRecordedBranches(repoRoot: string, diffBasis: Record<string, string | null>, runDir: string): string[];
108
116
  /** normalize_diff_basis: validate + compute the executable diff basis. */
109
117
  export declare function normalizeDiffBasis(repoRoot: string, diffBasis: Record<string, string | null>): [Record<string, string> | null, string | null];
110
118
  /** get_git_changed_files: git diff --name-only + untracked (--relative). */
@@ -0,0 +1,59 @@
1
+ export interface CreateWorktreeOptions {
2
+ repoRoot: string;
3
+ runId: string;
4
+ /** Base branch the worktree branch is cut from (default: current HEAD branch). */
5
+ baseBranch?: string;
6
+ /** Worktree branch name (default: `recursive/<runId>`). */
7
+ worktreeBranch?: string;
8
+ }
9
+ export interface CreateWorktreeResult {
10
+ ok: boolean;
11
+ worktreeDir: string;
12
+ worktreeBranch: string;
13
+ baseBranch: string;
14
+ error?: string;
15
+ }
16
+ export interface PromoteBranchOptions {
17
+ repoRoot: string;
18
+ /** Branch being promoted (the source of the new commits). */
19
+ fromBranch: string;
20
+ /** Promotion target branch (feature -> dev -> stage -> main). */
21
+ toBranch: string;
22
+ }
23
+ export interface PromoteBranchResult {
24
+ ok: boolean;
25
+ fromBranch: string;
26
+ toBranch: string;
27
+ action: 'fast-forward' | 'created';
28
+ error?: string;
29
+ }
30
+ /** Default worktree branch name for a run. */
31
+ export declare function defaultWorktreeBranch(runId: string): string;
32
+ /** One row of `git worktree list --porcelain` (path + branch when attached). */
33
+ export interface WorktreeInfo {
34
+ path: string;
35
+ branch: string | null;
36
+ detached: boolean;
37
+ }
38
+ /** List linked worktrees (path + branch) for a repo root. */
39
+ export declare function listWorktrees(repoRoot: string): WorktreeInfo[];
40
+ /**
41
+ * Find the worktree path where `branch` is currently checked out, or null.
42
+ * Parses `git worktree list --porcelain` (block per worktree with a
43
+ * `branch refs/heads/<name>` line).
44
+ */
45
+ export declare function findWorktreeForBranch(repoRoot: string, branch: string): string | null;
46
+ /**
47
+ * Create a linked worktree at `.worktrees/<runId>/` for a run. The worktree
48
+ * branch is cut from `baseBranch` (default: the current HEAD branch). Returns
49
+ * the created worktree dir + branch. No-op-safe: if the worktree dir already
50
+ * exists, returns ok with the existing dir. Refuses to create a worktree when
51
+ * the run directory already exists (prevents clobbering an in-progress run).
52
+ */
53
+ export declare function createLinkedWorktree(opts: CreateWorktreeOptions): CreateWorktreeResult;
54
+ /**
55
+ * Fast-forward `toBranch` to `fromBranch` (promotion up the dev/stage/main
56
+ * chain). Returns ok:false when the promotion is not a pure fast-forward
57
+ * (would require a merge commit) or when the from/to branches are missing.
58
+ */
59
+ export declare function promoteBranch(opts: PromoteBranchOptions): PromoteBranchResult;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@try-works/dsh-recursive-mode",
3
3
  "description": "recursive-mode workflow as a DeepSeek Harness bundle: RecursiveRuntime service + recursive_status tool",
4
- "version": "0.1.18",
4
+ "version": "0.2.0",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "types": "lib/index.d.ts",
package/src/commands.ts CHANGED
@@ -85,6 +85,28 @@ export function executeRecursiveCommand(root: string, rawInput: string): Recursi
85
85
  if (!existsSync(runDir)) return { kind: 'error', text: 'Run not found in current workspace: ' + runId }
86
86
  return { kind: 'success', text: 'closeout scaffolded for ' + runId + ' phase ' + phase }
87
87
  }
88
+ case 'worktree': {
89
+ // /recursive worktree create <runId> [--base <branch>]
90
+ // /recursive worktree promote <from> <to>
91
+ // /recursive worktree status
92
+ const parts = arg.trim().split(/\s+/)
93
+ const op = parts[0] ?? ''
94
+ if (op === 'status') return { kind: 'success', text: 'worktree status for workspace ' + root }
95
+ if (op === 'create') {
96
+ const runId = parts[1] ?? ''
97
+ if (!runId) return { kind: 'error', text: 'worktree create requires a run id' }
98
+ const runDir = join(runRoot, runId)
99
+ if (existsSync(runDir)) return { kind: 'error', text: 'Run already exists in this workspace: ' + runId }
100
+ return { kind: 'success', text: 'worktree created for run ' + runId + ' at .worktrees/' + runId }
101
+ }
102
+ if (op === 'promote') {
103
+ const fromBranch = parts[1] ?? ''
104
+ const toBranch = parts[2] ?? ''
105
+ if (!fromBranch || !toBranch) return { kind: 'error', text: 'worktree promote requires <from> <to>' }
106
+ return { kind: 'success', text: 'promoted ' + fromBranch + ' -> ' + toBranch }
107
+ }
108
+ return { kind: 'error', text: 'worktree requires create|promote|status' }
109
+ }
88
110
  case 'scratch': {
89
111
  const runId = arg.trim()
90
112
  if (!runId) return { kind: 'error', text: 'scratch requires a run id' }
@@ -147,6 +169,30 @@ export function registerRecursiveCommand(
147
169
  return { kind: 'success', text: 'closeout scaffolded: ' + JSON.stringify(result) }
148
170
  }
149
171
  }
172
+ if (verb === 'worktree' && arg) {
173
+ const parts = arg.trim().split(/\s+/)
174
+ const op = parts[0] ?? ''
175
+ if (op === 'create') {
176
+ const runId = parts[1] ?? ''
177
+ const baseMatch = arg.match(/--base\s+(\S+)/)
178
+ const baseBranch = baseMatch?.[1]
179
+ if (!runId) return { kind: 'error', text: 'worktree create requires a run id' }
180
+ const result = recursive.createRunWorktree(root, runId, baseBranch)
181
+ if (!result.ok) return { kind: 'error', text: result.error ?? 'worktree create failed' }
182
+ return { kind: 'success', text: 'worktree created: ' + JSON.stringify(result) }
183
+ }
184
+ if (op === 'promote') {
185
+ const fromBranch = parts[1] ?? ''
186
+ const toBranch = parts[2] ?? ''
187
+ if (!fromBranch || !toBranch) return { kind: 'error', text: 'worktree promote requires <from> <to>' }
188
+ const result = recursive.promoteRunBranch(root, fromBranch, toBranch)
189
+ if (!result.ok) return { kind: 'error', text: result.error ?? 'promote failed' }
190
+ return { kind: 'success', text: 'promoted: ' + JSON.stringify(result) }
191
+ }
192
+ if (op === 'status') {
193
+ return { kind: 'success', text: 'worktree status: ' + JSON.stringify(recursive.worktreeStatus(root)) }
194
+ }
195
+ }
150
196
  return executeRecursiveCommand(root, rawInput)
151
197
  },
152
198
  })
@@ -0,0 +1,132 @@
1
+ /**
2
+ * git-context.ts — live git facts used for worktree + branch awareness (R?).
3
+ *
4
+ * Worktree/branch awareness goal: the plugin must know WHICH checkout and
5
+ * WHICH branch a run is executing in, so Phase 0 records an honest base-vs-
6
+ * worktree branch split and lint fails when the recorded context no longer
7
+ * matches live git state.
8
+ *
9
+ * Detection primitives:
10
+ * - `isLinkedWorktree` — a directory is a linked git worktree when its private
11
+ * git dir (`git rev-parse --git-dir`) differs from the common git dir
12
+ * (`git rev-parse --git-common-dir`). The main checkout has both equal.
13
+ * - `upstreamBranch` — the current branch's upstream target with the remote
14
+ * prefix stripped (origin/main -> main). Used to infer the promotion source
15
+ * branch for dev/stage/main workflows.
16
+ *
17
+ * Every accessor is total: on missing git or non-git dirs it returns null /
18
+ * false, never throws. Callers defer rather than fail hard.
19
+ */
20
+ import { execFileSync } from 'node:child_process'
21
+
22
+ /** Normalized git facts about one checkout directory. */
23
+ export interface GitRepoFacts {
24
+ /** SHA of HEAD^{commit}, or null when unresolvable (empty/non-git repo). */
25
+ headSha: string | null
26
+ /** Current branch short name via `git symbolic-ref --short HEAD`, null when detached. */
27
+ branch: string | null
28
+ /** True when HEAD is detached (no symbolic ref). */
29
+ detached: boolean
30
+ /** True when the cwd is a linked worktree (git-dir != git-common-dir). */
31
+ isWorktree: boolean
32
+ /** `git rev-parse --git-dir` output (may be relative). */
33
+ gitDir: string | null
34
+ /** `git rev-parse --git-common-dir` output (may be relative). */
35
+ commonDir: string | null
36
+ /** `git rev-parse --show-toplevel` output, or null. */
37
+ toplevel: string | null
38
+ /** Upstream branch with remote prefix stripped (origin/main -> main), null when none/detached. */
39
+ upstreamBranch: string | null
40
+ }
41
+
42
+ /** Run git in a repo dir; return trimmed stdout or null on any failure. */
43
+ export function gitRun(repoRoot: string, ...args: string[]): string | null {
44
+ try {
45
+ return execFileSync('git', args, { cwd: repoRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim()
46
+ } catch {
47
+ return null
48
+ }
49
+ }
50
+
51
+ /** True when a non-equal git-dir vs git-common-dir marks a linked worktree. */
52
+ export function isLinkedWorktree(gitDir: string | null, commonDir: string | null): boolean {
53
+ if (!gitDir || !commonDir) return false
54
+ return gitDir !== commonDir
55
+ }
56
+
57
+ /** Strip a remote ref prefix (origin/main -> main, remotes/origin/main -> main). */
58
+ export function stripRemotePrefix(ref: string | null): string | null {
59
+ if (!ref) return null
60
+ const trimmed = ref.trim()
61
+ const short = trimmed.replace(/^refs\/remotes\//, '').replace(/^[^/]+\//, '')
62
+ return short === '' ? null : short
63
+ }
64
+
65
+ /**
66
+ * Gather normalized git facts about `repoRoot`. Total: any git failure
67
+ * degrades to neutral values (false / null), never throws.
68
+ */
69
+ export function gitFacts(repoRoot: string): GitRepoFacts {
70
+ const gitDir = gitRun(repoRoot, 'rev-parse', '--git-dir')
71
+ const commonDir = gitRun(repoRoot, 'rev-parse', '--git-common-dir')
72
+ const headSha = gitRun(repoRoot, 'rev-parse', '--verify', 'HEAD^{commit}')
73
+ const branch = gitRun(repoRoot, 'symbolic-ref', '--quiet', '--short', 'HEAD')
74
+ const toplevel = gitRun(repoRoot, 'rev-parse', '--show-toplevel')
75
+ const upstreamRef = gitRun(repoRoot, 'rev-parse', '--abbrev-ref', '@{upstream}')
76
+ return {
77
+ headSha,
78
+ branch: branch || null,
79
+ detached: !branch,
80
+ isWorktree: isLinkedWorktree(gitDir, commonDir),
81
+ gitDir,
82
+ commonDir,
83
+ toplevel,
84
+ upstreamBranch: branch ? stripRemotePrefix(upstreamRef) : null,
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Resolve the effective base branch for a checkout. In a linked worktree this
90
+ * is the branch the work is based on — best inferred from the upstream target
91
+ * (dev/stage/main promotion source) when one is configured; otherwise fall
92
+ * back to the current branch. Returns null only when no branch exists.
93
+ */
94
+ export function resolveBaseBranch(facts: GitRepoFacts): string | null {
95
+ if (facts.branch && facts.upstreamBranch && facts.upstreamBranch !== facts.branch) {
96
+ return facts.upstreamBranch
97
+ }
98
+ return facts.branch
99
+ }
100
+
101
+ /**
102
+ * Verify a worktree branch is (still) based on the recorded base branch.
103
+ * Returns { ok, reason }. Fails when the base branch no longer exists, or the
104
+ * worktree branch no longer contains it (merge-base --is-ancestor fails).
105
+ * A missing/unresolvable branch degrades to ok:false with a reason; a
106
+ * non-git dir degrades to ok:true (defer — nothing to verify).
107
+ */
108
+ export function verifyBranchBase(repoRoot: string, baseBranch: string | null, worktreeBranch: string | null): { ok: boolean; reason: string | null } {
109
+ if (!baseBranch || !worktreeBranch) return { ok: true, reason: null }
110
+ const gitDir = gitRun(repoRoot, 'rev-parse', '--git-dir')
111
+ if (!gitDir) return { ok: true, reason: null }
112
+ const baseSha = gitRun(repoRoot, 'rev-parse', '--verify', `${baseBranch}^{commit}`)
113
+ if (!baseSha) return { ok: false, reason: `recorded base branch '${baseBranch}' does not resolve in this checkout` }
114
+ const wtSha = gitRun(repoRoot, 'rev-parse', '--verify', `${worktreeBranch}^{commit}`)
115
+ if (!wtSha) return { ok: false, reason: `recorded worktree branch '${worktreeBranch}' does not resolve in this checkout` }
116
+ // merge-base --is-ancestor <base> <worktree> -> exit 0 means base is an ancestor.
117
+ try {
118
+ execFileSync('git', ['-C', repoRoot, 'merge-base', '--is-ancestor', baseBranch, worktreeBranch], { stdio: ['ignore', 'pipe', 'pipe'] })
119
+ return { ok: true, reason: null }
120
+ } catch {
121
+ return { ok: false, reason: `worktree branch '${worktreeBranch}' is not based on recorded base branch '${baseBranch}'` }
122
+ }
123
+ }
124
+
125
+ /** Verify the recorded worktree branch matches the live HEAD branch. */
126
+ export function verifyWorktreeBranch(repoRoot: string, recordedBranch: string | null): { ok: boolean; reason: string | null } {
127
+ if (!recordedBranch) return { ok: true, reason: null }
128
+ const live = gitRun(repoRoot, 'symbolic-ref', '--quiet', '--short', 'HEAD')
129
+ if (!live) return { ok: false, reason: 'HEAD is detached; expected branch ' + recordedBranch }
130
+ if (live !== recordedBranch) return { ok: false, reason: `checkout is on branch '${live}' but 00-worktree.md records '${recordedBranch}'` }
131
+ return { ok: true, reason: null }
132
+ }
package/src/index.ts CHANGED
@@ -9,6 +9,7 @@ import { createRecursiveLockTool } from './recursive_lock.tool.ts'
9
9
  import { createRecursiveLintTool } from './recursive_lint.tool.ts'
10
10
  import { createRecursiveCloseoutTool } from './recursive_closeout.tool.ts'
11
11
  import { createRecursiveScratchTool } from './recursive_scratch.tool.ts'
12
+ import { createRecursiveWorktreeTool } from './recursive_worktree.tool.ts'
12
13
  import { registerRecursiveCommand } from './commands.ts'
13
14
  import { evaluateToolGuard } from './enforcement.ts'
14
15
  import { renderRecursivePolicy } from './policy.ts'
@@ -28,6 +29,7 @@ export { createRecursiveLockTool } from './recursive_lock.tool.ts'
28
29
  export { createRecursiveLintTool } from './recursive_lint.tool.ts'
29
30
  export { createRecursiveCloseoutTool } from './recursive_closeout.tool.ts'
30
31
  export { createRecursiveScratchTool } from './recursive_scratch.tool.ts'
32
+ export { createRecursiveWorktreeTool } from './recursive_worktree.tool.ts'
31
33
  export * from './status.ts'
32
34
  export {
33
35
  PHASE_SEQUENCE,
@@ -107,6 +109,7 @@ export function apply(ctx: Context, config?: { shellOnly?: boolean; repoRoot?: s
107
109
  ctx.tools.register(createRecursiveLintTool(recursive)),
108
110
  ctx.tools.register(createRecursiveCloseoutTool(recursive)),
109
111
  ctx.tools.register(createRecursiveScratchTool(recursive)),
112
+ ctx.tools.register(createRecursiveWorktreeTool(recursive)),
110
113
  ]
111
114
 
112
115
  // /recursive command (R4): preset-scoped registration, workspace-scoped dispatch.
@@ -12,6 +12,7 @@
12
12
  import { execFileSync } from 'node:child_process'
13
13
  import { join } from 'node:path'
14
14
  import { getArtifactRequiredSections } from './phase-rules.ts'
15
+ import { gitFacts, resolveBaseBranch, type GitRepoFacts } from './git-context.ts'
15
16
 
16
17
  export interface GitContext {
17
18
  baselineType: string
@@ -23,6 +24,10 @@ export interface GitContext {
23
24
  baseBranch: string
24
25
  worktreeBranch: string
25
26
  baseCommit: string
27
+ /** True when the init cwd is a linked git worktree (git-dir != git-common-dir). */
28
+ isWorktree: boolean
29
+ /** Upstream branch with the remote prefix stripped (origin/dev -> dev), null when none/detached. */
30
+ upstreamBranch: string | null
26
31
  notes: string
27
32
  }
28
33
 
@@ -42,7 +47,13 @@ export function detectGitContext(repoRoot: string): { context: Partial<GitContex
42
47
  if (!headSha) {
43
48
  return { context: {}, error: 'Unable to resolve HEAD commit for Phase 0 diff basis prefill: git rev-parse returned no output' }
44
49
  }
45
- const branch = run(['symbolic-ref', '--quiet', '--short', 'HEAD']) || '(detached HEAD)'
50
+ const facts: GitRepoFacts = gitFacts(repoRoot)
51
+ const branch = facts.branch ?? '(detached HEAD)'
52
+ // Distinct base branch: in a linked worktree the work is based on the branch
53
+ // whose upstream target is the promotion source (dev/stage/main). When no
54
+ // upstream is configured, fall back to the current branch so the recorded
55
+ // base is always a resolvable ref.
56
+ const baseBranch = resolveBaseBranch(facts) ?? branch
46
57
  const diffCommand = 'git diff --name-only ' + headSha
47
58
  return {
48
59
  context: {
@@ -52,9 +63,11 @@ export function detectGitContext(repoRoot: string): { context: Partial<GitContex
52
63
  normalizedBaseline: headSha,
53
64
  normalizedComparison: 'working-tree',
54
65
  normalizedDiffCommand: diffCommand,
55
- baseBranch: branch,
66
+ baseBranch,
56
67
  worktreeBranch: branch,
57
68
  baseCommit: headSha,
69
+ isWorktree: facts.isWorktree,
70
+ upstreamBranch: facts.upstreamBranch,
58
71
  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.',
59
72
  },
60
73
  error: null,
@@ -5,20 +5,26 @@ import type { RecursiveRuntime } from './runtime.ts'
5
5
  export function createRecursiveInitTool(recursive: RecursiveRuntime) {
6
6
  return defineTool({
7
7
  name: 'recursive_init',
8
- 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).',
8
+ 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.',
9
9
  parameters: {
10
10
  runId: { type: 'string', description: 'Run id (e.g. 03-something). Required.' },
11
+ createWorktree: { type: 'boolean', description: 'If true, create a linked worktree at .worktrees/<runId>/ and scaffold the run inside it (default: false).' },
12
+ baseBranch: { type: 'string', description: 'Base branch the worktree branch is cut from (default: current HEAD branch). Only used when createWorktree is true.' },
11
13
  },
12
14
  output: {
13
15
  schema: { type: 'json' },
14
16
  render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 2) }],
15
17
  },
16
- async execute(args: { runId?: string }, exec) {
18
+ async execute(args: { runId?: string; createWorktree?: boolean; baseBranch?: string }, exec) {
17
19
  if (!args.runId || args.runId.trim() === '') {
18
20
  return { error: 'runId is required' } as const
19
21
  }
20
22
  try {
21
- const result = await recursive.initRun(args.runId.trim(), exec.agent as { session?: { header?: { cwd?: string } } } | null)
23
+ const result = await recursive.initRun(
24
+ args.runId.trim(),
25
+ exec.agent as { session?: { header?: { cwd?: string } } } | null,
26
+ { createWorktree: args.createWorktree === true, baseBranch: args.baseBranch?.trim() || undefined },
27
+ )
22
28
  return result as unknown as JsonValue
23
29
  } catch (err) {
24
30
  return { error: err instanceof Error ? err.message : String(err) } as const
@@ -0,0 +1,54 @@
1
+ import { defineTool } from '@deepseek-ai/dsh-tools'
2
+ import type { JsonValue } from '@deepseek-ai/dsh-tools'
3
+ import type { RecursiveRuntime } from './runtime.ts'
4
+
5
+ /**
6
+ * `recursive_worktree` — create a linked git worktree for a run and/or
7
+ * promote a branch up the dev/stage/main chain. Workspace-scoped: the
8
+ * operations run under the SESSION's control-plane root only.
9
+ */
10
+ export function createRecursiveWorktreeTool(recursive: RecursiveRuntime) {
11
+ return defineTool({
12
+ name: 'recursive_worktree',
13
+ 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.',
14
+ parameters: {
15
+ runId: { type: 'string', description: 'Run id the worktree is created for (e.g. 03-something). Required for create.' },
16
+ action: { type: 'string', description: 'create | promote | status. Default: create.' },
17
+ fromBranch: { type: 'string', description: 'Source branch for a promote action (the branch holding the new commits).' },
18
+ toBranch: { type: 'string', description: 'Promotion target branch for a promote action (feature -> dev -> stage -> main).' },
19
+ baseBranch: { type: 'string', description: 'Base branch the worktree branch is cut from (default: current HEAD branch).' },
20
+ },
21
+ output: {
22
+ schema: { type: 'json' },
23
+ render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 2) }],
24
+ },
25
+ async execute(args: { action?: string; runId?: string; fromBranch?: string; toBranch?: string; baseBranch?: string }, exec) {
26
+ const action = args.action ?? 'create'
27
+ // Resolve the control-plane root: agent cwd first, repoRoot fallback (B4),
28
+ // so tests and headless callers work without a live session header.
29
+ const root = await recursive.resolveRootFor(exec.agent as { session?: { header?: { cwd?: string } } } | null)
30
+ if (!root) {
31
+ return { error: 'session is not attached to a registered workspace (cannot resolve control-plane root)' } as const
32
+ }
33
+ if (action === 'create') {
34
+ if (!args.runId || args.runId.trim() === '') {
35
+ return { error: 'runId is required for create' } as const
36
+ }
37
+ const result = recursive.createRunWorktree(root, args.runId.trim(), args.baseBranch?.trim() || undefined)
38
+ return result as unknown as JsonValue
39
+ }
40
+ if (action === 'promote') {
41
+ if (!args.fromBranch || !args.toBranch) {
42
+ return { error: 'fromBranch and toBranch are required for promote' } as const
43
+ }
44
+ const result = recursive.promoteRunBranch(root, args.fromBranch.trim(), args.toBranch.trim())
45
+ return result as unknown as JsonValue
46
+ }
47
+ if (action === 'status') {
48
+ const result = recursive.worktreeStatus(root)
49
+ return result as unknown as JsonValue
50
+ }
51
+ return { error: 'action must be create | promote | status' } as const
52
+ },
53
+ })
54
+ }
package/src/runtime.ts CHANGED
@@ -26,6 +26,8 @@ import { resolveEnforcementConfig, DEFAULT_ENFORCEMENT, evaluatePreStepGate, eva
26
26
  import type { Session } from '@deepseek-ai/dsh-session'
27
27
  import { renderRecursivePolicy, type PolicyContext } from './policy.ts'
28
28
  import { snapshotWorkspace } from './snapshot.ts'
29
+ import { createLinkedWorktree, promoteBranch, listWorktrees, defaultWorktreeBranch, type CreateWorktreeResult, type PromoteBranchResult } from './worktree.ts'
30
+ import { gitFacts } from './git-context.ts'
29
31
 
30
32
  declare module '@deepseek-ai/cordis' {
31
33
  interface Context {
@@ -343,11 +345,24 @@ export class RecursiveRuntime extends Service {
343
345
  * required section (get_artifact_required_sections) + TODO + FAIL gates.
344
346
  * Also scaffolds addenda/subagents/router-prompts/evidence dirs. Returns the
345
347
  * run dir + created artifacts.
348
+ *
349
+ * When `opts.createWorktree` is true, a linked worktree is first created at
350
+ * `.worktrees/<runId>/` and the run is scaffolded INSIDE it (per the
351
+ * "all subsequent phases execute in worktree context" rule). The worktree
352
+ * branch defaults to `recursive/<runId>` and is cut from `opts.baseBranch`
353
+ * (default: current HEAD branch of the root checkout).
346
354
  */
347
- async initRun(runId: string, agent?: { session?: { header?: { cwd?: string } } } | null): Promise<{ runDir: string; runId: string; created: string[]; existing: string[] }> {
355
+ async initRun(runId: string, agent?: { session?: { header?: { cwd?: string } } } | null, opts?: { createWorktree?: boolean; baseBranch?: string }): Promise<{ runDir: string; runId: string; created: string[]; existing: string[]; worktree?: CreateWorktreeResult }> {
348
356
  const root = await this.resolveRootFor(agent)
349
357
  if (!root) throw new Error('cannot resolve workspace control-plane root for this session')
350
- const runDir = join(root, '.recursive', 'run', runId)
358
+ let scaffoldRoot = root
359
+ let worktree: CreateWorktreeResult | undefined
360
+ if (opts?.createWorktree) {
361
+ worktree = createLinkedWorktree({ repoRoot: root, runId, baseBranch: opts.baseBranch })
362
+ if (!worktree.ok) throw new Error(worktree.error ?? 'worktree create failed')
363
+ scaffoldRoot = worktree.worktreeDir
364
+ }
365
+ const runDir = join(scaffoldRoot, '.recursive', 'run', runId)
351
366
  mkdirSync(runDir, { recursive: true })
352
367
  const created: string[] = []
353
368
  const existing: string[] = []
@@ -360,13 +375,14 @@ export class RecursiveRuntime extends Service {
360
375
  created.push(dir + '/')
361
376
  }
362
377
 
363
- // Git context for the Phase 0 diff-basis prefill (canonical parity).
364
- const { context: gitContext, error: prefillError } = detectGitContext(root)
378
+ // Git context for the Phase 0 diff-basis prefill (canonical parity). When a
379
+ // worktree was created, the git context + Phase 0 record the WORKTREE.
380
+ const { context: gitContext, error: prefillError } = detectGitContext(scaffoldRoot)
365
381
 
366
382
  // Phase 0 templates: byte-identical to canonical recursive-init.py.
367
383
  const phase0: Array<[string, string]> = [
368
384
  ['00-requirements.md', requirementsContent(runId, 'feature', '')],
369
- ['00-worktree.md', worktreeContent(runId, root, gitContext, prefillError)],
385
+ ['00-worktree.md', worktreeContent(runId, scaffoldRoot, gitContext, prefillError)],
370
386
  ]
371
387
  for (const [file, content] of phase0) {
372
388
  const path = join(runDir, file)
@@ -395,7 +411,42 @@ export class RecursiveRuntime extends Service {
395
411
  created.push(file)
396
412
  }
397
413
 
398
- return { runDir, runId, created, existing }
414
+ const result: { runDir: string; runId: string; created: string[]; existing: string[]; worktree?: CreateWorktreeResult } = { runDir, runId, created, existing }
415
+ if (worktree) result.worktree = worktree
416
+ return result
417
+ }
418
+
419
+ /**
420
+ * Create a linked worktree for a run under the given workspace root. The
421
+ * worktree branch defaults to `recursive/<runId>` and is cut from the given
422
+ * base branch (default: the current HEAD branch of the root checkout).
423
+ * Refuses to create over an existing run directory. Workspace-scoped.
424
+ */
425
+ createRunWorktree(root: string, runId: string, baseBranch?: string): CreateWorktreeResult {
426
+ return createLinkedWorktree({ repoRoot: root, runId, baseBranch })
427
+ }
428
+
429
+ /**
430
+ * Promote a branch up the dev/stage/main chain (fast-forward). Workspace-scoped.
431
+ */
432
+ promoteRunBranch(root: string, fromBranch: string, toBranch: string): PromoteBranchResult {
433
+ return promoteBranch({ repoRoot: root, fromBranch, toBranch })
434
+ }
435
+
436
+ /**
437
+ * Worktree + branch status for a workspace root: the linked worktrees,
438
+ * which branch each is on, and the current checkout's base/upstream context.
439
+ */
440
+ worktreeStatus(root: string): Record<string, unknown> {
441
+ const facts = gitFacts(root)
442
+ const worktrees = listWorktrees(root)
443
+ return {
444
+ root,
445
+ isWorktree: facts.isWorktree,
446
+ branch: facts.branch,
447
+ upstreamBranch: facts.upstreamBranch,
448
+ worktrees,
449
+ }
399
450
  }
400
451
 
401
452
  /**