@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/git-context.d.ts +53 -0
- package/lib/index.d.ts +2 -0
- package/lib/index.js +756 -26
- package/lib/init-templates.d.ts +4 -0
- package/lib/phase-rules.d.ts +28 -3
- package/lib/recursive_phase.tool.d.ts +9 -0
- package/lib/recursive_worktree.tool.d.ts +7 -0
- package/lib/runtime.d.ts +46 -1
- package/lib/ts-lint.d.ts +8 -0
- package/lib/worktree.d.ts +59 -0
- package/package.json +1 -1
- package/src/commands.ts +46 -0
- package/src/git-context.ts +132 -0
- package/src/index.ts +14 -3
- package/src/init-templates.ts +15 -2
- package/src/phase-rules.ts +49 -6
- package/src/policy.ts +1 -0
- package/src/recursive_init.tool.ts +9 -3
- package/src/recursive_phase.tool.ts +29 -0
- package/src/recursive_worktree.tool.ts +54 -0
- package/src/runtime.ts +75 -6
- package/src/ts-lint.ts +31 -0
- package/src/worktree.ts +229 -0
package/lib/init-templates.d.ts
CHANGED
|
@@ -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
|
/**
|
package/lib/phase-rules.d.ts
CHANGED
|
@@ -25,10 +25,35 @@ export declare const DIFF_BASIS_FIELDS: string[];
|
|
|
25
25
|
* prior-evidence file set).
|
|
26
26
|
*/
|
|
27
27
|
export declare function getArtifactRequiredSections(fileName: string, workflowProfile?: string): string[];
|
|
28
|
+
/**
|
|
29
|
+
* LIVE BUG 6 (0.2.1): dedup gate for the agent/pre-step lint-rules reminder.
|
|
30
|
+
* Keyed by (root, runId, phase) so each new run or phase transition gets its own
|
|
31
|
+
* single injection, while re-arming steps in the SAME phase stay silent. Scoped
|
|
32
|
+
* to the apply() effect/fiber lifetime (one instance per mount), matching the
|
|
33
|
+
* repairedRoots dedup used by the scaffold repair above the injection point.
|
|
34
|
+
*/
|
|
35
|
+
export declare class ReminderOnceGate {
|
|
36
|
+
private seen;
|
|
37
|
+
shouldInject(root: string, runId: string, phase: string): boolean;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Structured, single-source-of-truth rules for one phase (refined LIVE BUG 6
|
|
41
|
+
* design): consumed by BOTH the recursive_phase tool and the once-per-phase
|
|
42
|
+
* pre-step reminder. Derived from the canonical artifact-template sections.
|
|
43
|
+
*/
|
|
44
|
+
export interface PhaseRules {
|
|
45
|
+
fileName: string;
|
|
46
|
+
label: string;
|
|
47
|
+
requiredSections: string[];
|
|
48
|
+
audited: boolean;
|
|
49
|
+
tdd: boolean;
|
|
50
|
+
qa: boolean;
|
|
51
|
+
}
|
|
52
|
+
export declare function phaseRulesFor(fileName: string, workflowProfile?: string): PhaseRules;
|
|
28
53
|
/**
|
|
29
54
|
* Compact lint-rules message for the pre-step injection (R5): this phase's
|
|
30
|
-
* required sections + phase-specific gate notes,
|
|
31
|
-
*
|
|
32
|
-
*
|
|
55
|
+
* required sections + phase-specific gate notes, built from phaseRulesFor
|
|
56
|
+
* (single source of truth with the recursive_phase tool). Output is unchanged
|
|
57
|
+
* from the prior inline build so r5-parity.spec.ts stays green.
|
|
33
58
|
*/
|
|
34
59
|
export declare function phaseLintRulesMessage(fileName: string, workflowProfile?: string): string;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { RecursiveRuntime } from './runtime.ts';
|
|
2
|
+
/**
|
|
3
|
+
* recursive_phase (refined LIVE BUG 6): the canonical on-demand home of the
|
|
4
|
+
* current phase's lint rules + instructions. Reads the same structured source
|
|
5
|
+
* (runtime.phaseRules -> phaseRulesFor) as the once-per-phase pre-step
|
|
6
|
+
* reminder, so the agent can re-ask for the rules without re-injecting them on
|
|
7
|
+
* every step. Returns { error } when no active phase is found.
|
|
8
|
+
*/
|
|
9
|
+
export declare function createRecursivePhaseTool(recursive: RecursiveRuntime): import("@deepseek-ai/dsh-tools").ToolDefinition;
|
|
@@ -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
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import { Service, type Context } from '@deepseek-ai/cordis';
|
|
2
2
|
import type { RecursiveStatusResult } from './types.ts';
|
|
3
3
|
import { type WorkspaceRegistryLike } from './workspace.ts';
|
|
4
|
+
import { type PhaseRules } from './phase-rules.ts';
|
|
4
5
|
import { type ScratchTarget } from './scratch.ts';
|
|
5
6
|
import { type ReviewBundleInput } from './review.ts';
|
|
6
7
|
import { type SubagentProviderLike, type RouteDecision, type CapabilityProbe } from './router.ts';
|
|
7
8
|
import { type SubagentsRuntimeLike, type SubagentStartRequestLike, type SubagentResultLike, type Reference } from './delegation.ts';
|
|
8
9
|
import { type PhaseTransitionIntent, type SessionEventLike, type RecursivePhaseState, type GateCheckResult } from './lifecycle.ts';
|
|
9
10
|
import { type EnforcementConfig, type PreStepGateDecision, type ToolGuardDecision, type ToolExecLike } from './enforcement.ts';
|
|
11
|
+
import { type CreateWorktreeResult, type PromoteBranchResult } from './worktree.ts';
|
|
10
12
|
declare module '@deepseek-ai/cordis' {
|
|
11
13
|
interface Context {
|
|
12
14
|
recursive: RecursiveRuntime;
|
|
@@ -173,6 +175,23 @@ export declare class RecursiveRuntime extends Service {
|
|
|
173
175
|
};
|
|
174
176
|
};
|
|
175
177
|
} | null): Promise<RecursiveStatusResult | null>;
|
|
178
|
+
/**
|
|
179
|
+
* LIVE BUG 6 refined: structured phase rules for the CURRENT phase. Resolves
|
|
180
|
+
* the workspace root (same as status/lock), finds the latest run (or the
|
|
181
|
+
* given runId), advances via getNextLegalPhase, and returns the phase's lint
|
|
182
|
+
* rules + instructions. Returns null when no active phase exists. This is the
|
|
183
|
+
* canonical data source for the recursive_phase tool.
|
|
184
|
+
*/
|
|
185
|
+
phaseRules(runId?: string, agent?: {
|
|
186
|
+
session?: {
|
|
187
|
+
header?: {
|
|
188
|
+
cwd?: string;
|
|
189
|
+
};
|
|
190
|
+
};
|
|
191
|
+
} | null): Promise<(PhaseRules & {
|
|
192
|
+
runId: string;
|
|
193
|
+
phase: string;
|
|
194
|
+
}) | null>;
|
|
176
195
|
/**
|
|
177
196
|
* Scaffold a run directory with FULL per-phase templates (no-op if exists).
|
|
178
197
|
* 00-requirements.md + 00-worktree.md are byte-identical to canonical
|
|
@@ -180,6 +199,12 @@ export declare class RecursiveRuntime extends Service {
|
|
|
180
199
|
* required section (get_artifact_required_sections) + TODO + FAIL gates.
|
|
181
200
|
* Also scaffolds addenda/subagents/router-prompts/evidence dirs. Returns the
|
|
182
201
|
* run dir + created artifacts.
|
|
202
|
+
*
|
|
203
|
+
* When `opts.createWorktree` is true, a linked worktree is first created at
|
|
204
|
+
* `.worktrees/<runId>/` and the run is scaffolded INSIDE it (per the
|
|
205
|
+
* "all subsequent phases execute in worktree context" rule). The worktree
|
|
206
|
+
* branch defaults to `recursive/<runId>` and is cut from `opts.baseBranch`
|
|
207
|
+
* (default: current HEAD branch of the root checkout).
|
|
183
208
|
*/
|
|
184
209
|
initRun(runId: string, agent?: {
|
|
185
210
|
session?: {
|
|
@@ -187,12 +212,32 @@ export declare class RecursiveRuntime extends Service {
|
|
|
187
212
|
cwd?: string;
|
|
188
213
|
};
|
|
189
214
|
};
|
|
190
|
-
} | null
|
|
215
|
+
} | null, opts?: {
|
|
216
|
+
createWorktree?: boolean;
|
|
217
|
+
baseBranch?: string;
|
|
218
|
+
}): Promise<{
|
|
191
219
|
runDir: string;
|
|
192
220
|
runId: string;
|
|
193
221
|
created: string[];
|
|
194
222
|
existing: string[];
|
|
223
|
+
worktree?: CreateWorktreeResult;
|
|
195
224
|
}>;
|
|
225
|
+
/**
|
|
226
|
+
* Create a linked worktree for a run under the given workspace root. The
|
|
227
|
+
* worktree branch defaults to `recursive/<runId>` and is cut from the given
|
|
228
|
+
* base branch (default: the current HEAD branch of the root checkout).
|
|
229
|
+
* Refuses to create over an existing run directory. Workspace-scoped.
|
|
230
|
+
*/
|
|
231
|
+
createRunWorktree(root: string, runId: string, baseBranch?: string): CreateWorktreeResult;
|
|
232
|
+
/**
|
|
233
|
+
* Promote a branch up the dev/stage/main chain (fast-forward). Workspace-scoped.
|
|
234
|
+
*/
|
|
235
|
+
promoteRunBranch(root: string, fromBranch: string, toBranch: string): PromoteBranchResult;
|
|
236
|
+
/**
|
|
237
|
+
* Worktree + branch status for a workspace root: the linked worktrees,
|
|
238
|
+
* which branch each is on, and the current checkout's base/upstream context.
|
|
239
|
+
*/
|
|
240
|
+
worktreeStatus(root: string): Record<string, unknown>;
|
|
196
241
|
/**
|
|
197
242
|
* Lock a DRAFT artifact (or reopen a LOCKED one). Validates prerequisites;
|
|
198
243
|
* 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
|
|
4
|
+
"version": "0.2.1",
|
|
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,8 @@ 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'
|
|
13
|
+
import { createRecursivePhaseTool } from './recursive_phase.tool.ts'
|
|
12
14
|
import { registerRecursiveCommand } from './commands.ts'
|
|
13
15
|
import { evaluateToolGuard } from './enforcement.ts'
|
|
14
16
|
import { renderRecursivePolicy } from './policy.ts'
|
|
@@ -17,7 +19,7 @@ import { snapshotWorkspace } from './snapshot.ts'
|
|
|
17
19
|
import { mountRecursiveRoutesOnce, makeRecursiveRoutes, type RecursiveRouteHost } from './live-route.ts'
|
|
18
20
|
import { enumerateRuns, stageBWorkflowInit } from './bootstrap.ts'
|
|
19
21
|
import { getNextLegalPhase, getLockStatus } from './lock.ts'
|
|
20
|
-
import { phaseLintRulesMessage } from './phase-rules.ts'
|
|
22
|
+
import { phaseLintRulesMessage, ReminderOnceGate } from './phase-rules.ts'
|
|
21
23
|
|
|
22
24
|
export const name = '@try-works/dsh-recursive-mode'
|
|
23
25
|
|
|
@@ -28,6 +30,8 @@ export { createRecursiveLockTool } from './recursive_lock.tool.ts'
|
|
|
28
30
|
export { createRecursiveLintTool } from './recursive_lint.tool.ts'
|
|
29
31
|
export { createRecursiveCloseoutTool } from './recursive_closeout.tool.ts'
|
|
30
32
|
export { createRecursiveScratchTool } from './recursive_scratch.tool.ts'
|
|
33
|
+
export { createRecursiveWorktreeTool } from './recursive_worktree.tool.ts'
|
|
34
|
+
export { createRecursivePhaseTool } from './recursive_phase.tool.ts'
|
|
31
35
|
export * from './status.ts'
|
|
32
36
|
export {
|
|
33
37
|
PHASE_SEQUENCE,
|
|
@@ -99,7 +103,8 @@ export function apply(ctx: Context, config?: { shellOnly?: boolean; repoRoot?: s
|
|
|
99
103
|
const workspaceRegistry = ctx.get('workspaceRegistry') as never
|
|
100
104
|
const recursive = new RecursiveRuntime(ctx, { repoRoot: config?.repoRoot ?? process.cwd(), workspaceRegistry })
|
|
101
105
|
|
|
102
|
-
const repairedRoots = new Set<string>()
|
|
106
|
+
const repairedRoots = new Set<string>()
|
|
107
|
+
const reminderGate = new ReminderOnceGate()
|
|
103
108
|
const disposers = [
|
|
104
109
|
ctx.tools.register(createRecursiveStatusTool(recursive)),
|
|
105
110
|
ctx.tools.register(createRecursiveInitTool(recursive)),
|
|
@@ -107,6 +112,8 @@ export function apply(ctx: Context, config?: { shellOnly?: boolean; repoRoot?: s
|
|
|
107
112
|
ctx.tools.register(createRecursiveLintTool(recursive)),
|
|
108
113
|
ctx.tools.register(createRecursiveCloseoutTool(recursive)),
|
|
109
114
|
ctx.tools.register(createRecursiveScratchTool(recursive)),
|
|
115
|
+
ctx.tools.register(createRecursiveWorktreeTool(recursive)),
|
|
116
|
+
ctx.tools.register(createRecursivePhaseTool(recursive)),
|
|
110
117
|
]
|
|
111
118
|
|
|
112
119
|
// /recursive command (R4): preset-scoped registration, workspace-scoped dispatch.
|
|
@@ -204,7 +211,11 @@ export function apply(ctx: Context, config?: { shellOnly?: boolean; repoRoot?: s
|
|
|
204
211
|
const phasePath = join(runDir, phase)
|
|
205
212
|
const status = existsSync(phasePath) ? getLockStatus(phasePath) : null
|
|
206
213
|
if (status !== 'DRAFT') return { kind: 'enter', messages } as const
|
|
207
|
-
|
|
214
|
+
if (!reminderGate.shouldInject(root, runId, phase)) return { kind: 'enter', messages } as const
|
|
215
|
+
// LIVE BUG 6 (0.2.1): inject the lint-rules reminder AT MOST ONCE PER PHASE.
|
|
216
|
+
// The scaffold repair above is deduped via repairedRoots; the reminder itself was
|
|
217
|
+
// not, so every pre-step while DRAFT re-injected it.
|
|
218
|
+
const reminder = phaseLintRulesMessage(phase)
|
|
208
219
|
return {
|
|
209
220
|
kind: 'enter',
|
|
210
221
|
messages: [...messages, createUserMessage({ content: [{ type: 'text', text: reminder }], source: { kind: 'plugin', plugin: '@try-works/dsh-recursive-mode' } })],
|
package/src/init-templates.ts
CHANGED
|
@@ -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
|
|
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
|
|
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,
|
package/src/phase-rules.ts
CHANGED
|
@@ -240,18 +240,61 @@ export function getArtifactRequiredSections(fileName: string, workflowProfile: s
|
|
|
240
240
|
return headings
|
|
241
241
|
}
|
|
242
242
|
|
|
243
|
+
/**
|
|
244
|
+
* LIVE BUG 6 (0.2.1): dedup gate for the agent/pre-step lint-rules reminder.
|
|
245
|
+
* Keyed by (root, runId, phase) so each new run or phase transition gets its own
|
|
246
|
+
* single injection, while re-arming steps in the SAME phase stay silent. Scoped
|
|
247
|
+
* to the apply() effect/fiber lifetime (one instance per mount), matching the
|
|
248
|
+
* repairedRoots dedup used by the scaffold repair above the injection point.
|
|
249
|
+
*/
|
|
250
|
+
export class ReminderOnceGate {
|
|
251
|
+
private seen = new Set<string>()
|
|
252
|
+
|
|
253
|
+
shouldInject(root: string, runId: string, phase: string): boolean {
|
|
254
|
+
const key = root + '\u0000' + runId + '\u0000' + phase
|
|
255
|
+
if (this.seen.has(key)) return false
|
|
256
|
+
this.seen.add(key)
|
|
257
|
+
return true
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Structured, single-source-of-truth rules for one phase (refined LIVE BUG 6
|
|
263
|
+
* design): consumed by BOTH the recursive_phase tool and the once-per-phase
|
|
264
|
+
* pre-step reminder. Derived from the canonical artifact-template sections.
|
|
265
|
+
*/
|
|
266
|
+
export interface PhaseRules {
|
|
267
|
+
fileName: string
|
|
268
|
+
label: string
|
|
269
|
+
requiredSections: string[]
|
|
270
|
+
audited: boolean
|
|
271
|
+
tdd: boolean
|
|
272
|
+
qa: boolean
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export function phaseRulesFor(fileName: string, workflowProfile: string = CURRENT_WORKFLOW_PROFILE): PhaseRules {
|
|
276
|
+
return {
|
|
277
|
+
fileName,
|
|
278
|
+
label: fileName.replace(/\.md$/, ''),
|
|
279
|
+
requiredSections: getArtifactRequiredSections(fileName, workflowProfile),
|
|
280
|
+
audited: AUDITED_PHASE_FILES.has(fileName),
|
|
281
|
+
tdd: fileName === '03-implementation-summary.md',
|
|
282
|
+
qa: fileName === '05-manual-qa.md',
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
243
286
|
/**
|
|
244
287
|
* Compact lint-rules message for the pre-step injection (R5): this phase's
|
|
245
|
-
* required sections + phase-specific gate notes,
|
|
246
|
-
*
|
|
247
|
-
*
|
|
288
|
+
* required sections + phase-specific gate notes, built from phaseRulesFor
|
|
289
|
+
* (single source of truth with the recursive_phase tool). Output is unchanged
|
|
290
|
+
* from the prior inline build so r5-parity.spec.ts stays green.
|
|
248
291
|
*/
|
|
249
292
|
export function phaseLintRulesMessage(fileName: string, workflowProfile: string = CURRENT_WORKFLOW_PROFILE): string {
|
|
250
|
-
const
|
|
293
|
+
const rules = phaseRulesFor(fileName, workflowProfile)
|
|
251
294
|
const lines = [
|
|
252
295
|
'<system-reminder>',
|
|
253
296
|
'Recursive-mode phase lint rules for THIS phase (' + fileName + '):',
|
|
254
|
-
'Required sections: ' +
|
|
297
|
+
'Required sections: ' + rules.requiredSections.join(' | '),
|
|
255
298
|
'Gates: Coverage: FAIL until all checkboxes pass; Approval: FAIL until user sign-off; lock only via recursive_lock (monotonic).',
|
|
256
299
|
'Audited phases: end with Audit: PASS before setting Coverage/Approval PASS; record Audit Context and Audit Verdict.',
|
|
257
300
|
'TDD (phase 3): declare TDD Mode: strict|pragmatic; strict requires RED + GREEN evidence paths.',
|
|
@@ -259,4 +302,4 @@ export function phaseLintRulesMessage(fileName: string, workflowProfile: string
|
|
|
259
302
|
'</system-reminder>',
|
|
260
303
|
]
|
|
261
304
|
return lines.join('\n')
|
|
262
|
-
}
|
|
305
|
+
}
|
package/src/policy.ts
CHANGED
|
@@ -55,6 +55,7 @@ export function renderRecursivePolicy(context: PolicyContext | null): string {
|
|
|
55
55
|
'- The control-plane root is resolved STRICTLY from this session workspace (never scan other workspaces).',
|
|
56
56
|
'- Scratch is disposable, git-ignored, and never citable as an Input.',
|
|
57
57
|
'- The workflow spec lives at /.recursive/RECURSIVE.md; bootstrap it when missing.',
|
|
58
|
+
'- Call recursive_phase when entering a new phase; the same rules are auto-injected once per phase transition.',
|
|
58
59
|
];
|
|
59
60
|
|
|
60
61
|
// R5: surface the current phase's required sections + gate checklist.
|
|
@@ -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(
|
|
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
|