@try-works/dsh-recursive-mode 0.4.4 → 0.4.6
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/README.md +15 -4
- package/lib/errors.d.ts +45 -0
- package/lib/goals-projection.d.ts +34 -4
- package/lib/index.js +783 -51
- package/lib/recursive_ask.tool.d.ts +137 -0
- package/lib/recursive_closeout.tool.d.ts +10 -0
- package/lib/recursive_init.tool.d.ts +23 -0
- package/lib/recursive_phase.tool.d.ts +8 -0
- package/lib/recursive_scratch.tool.d.ts +10 -0
- package/lib/recursive_worktree.tool.d.ts +16 -0
- package/lib/run-id.d.ts +62 -0
- package/lib/run-start.d.ts +55 -0
- package/lib/runtime.d.ts +137 -31
- package/package.json +1 -1
- package/scripts/test-recursive-mode-smoke.ts +5 -1
- package/src/errors.ts +45 -0
- package/src/goals-projection.ts +48 -7
- package/src/index.ts +9 -0
- package/src/recursive_ask.tool.ts +368 -18
- package/src/recursive_closeout.tool.ts +53 -35
- package/src/recursive_init.tool.ts +35 -4
- package/src/recursive_phase.tool.ts +22 -2
- package/src/recursive_scratch.tool.ts +17 -1
- package/src/recursive_worktree.tool.ts +27 -2
- package/src/run-id.ts +100 -0
- package/src/run-start.ts +129 -0
- package/src/runtime.ts +157 -14
|
@@ -1,36 +1,54 @@
|
|
|
1
|
-
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
2
|
-
import { toolError } from './errors.ts'
|
|
3
|
-
import
|
|
4
|
-
import type {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* workspace
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
},
|
|
35
|
-
|
|
1
|
+
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
2
|
+
import { toolError } from './errors.ts'
|
|
3
|
+
import { RUN_ID_EXAMPLES, RUN_ID_RULE, runIdProblem } from './run-id.ts'
|
|
4
|
+
import type { JsonValue } from '@deepseek-ai/dsh-util-values'
|
|
5
|
+
import type { RecursiveRuntime } from './runtime.ts'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* `recursive_closeout` — scaffold a Phase 0-8 closeout receipt under the
|
|
9
|
+
* SESSION's workspace only (R1 workspace-scoping invariant). The run is resolved
|
|
10
|
+
* via the session agent's cwd -> workspace registry; a runId outside the current
|
|
11
|
+
* workspace is rejected.
|
|
12
|
+
*
|
|
13
|
+
* A RUN ID IS A NAME, NOT A PATH HERE TOO, and "outside the current workspace is rejected" is NOT enough
|
|
14
|
+
* on its own: `closeoutRun` joins the id onto the run layer and then writes a receipt under it, and its
|
|
15
|
+
* scoping check is `runDir.startsWith(runRoot)` — a string prefix test, not containment. A `..\` segment
|
|
16
|
+
* that lands on a SIBLING of the run layer passes that check whenever the sibling's name begins with
|
|
17
|
+
* `run`, so a path-shaped id can still receive a write. MEASURED pre-fix: `..\run-away` and `../run-away`
|
|
18
|
+
* were accepted and reached the report; the other shapes below were stopped only by the sibling not
|
|
19
|
+
* existing, which is the operator's filesystem deciding, not the tool. The rule is `run-id.ts`; this
|
|
20
|
+
* boundary is where the name enters, so this is where it is refused, with `recursive_init`'s refusal shape
|
|
21
|
+
* — `BAD_RUN_ID` (RM1107), same detail sentence.
|
|
22
|
+
*/
|
|
23
|
+
export function createRecursiveCloseoutTool(recursive: RecursiveRuntime) {
|
|
24
|
+
return defineTool({
|
|
25
|
+
name: 'recursive_closeout',
|
|
26
|
+
description: 'REPORT what a closeout phase artifact is missing (Phase 4-8): it reads the artifact, lists the required sections and gates that are absent, and records a closeout receipt of its own. It NEVER writes the phase document. For a run in the CURRENT session workspace only. Workspace-scoped: refuses runIds outside the session\'s workspace. Delegates to the RecursiveRuntime service.',
|
|
27
|
+
parameters: {
|
|
28
|
+
phase: { type: 'string', description: 'Closeout phase to report on: 04, 05, 06, 07 or 08 (the same phase keys recursive_status prints). Phase 08 additionally fires the training trigger when it has been closed out before.' },
|
|
29
|
+
runId: { type: 'string', description: 'Run id — the NAME of the run directory under .recursive/run/ (e.g. 03-something), never a path: ' + RUN_ID_RULE + '. Required. Must resolve inside the current workspace.' },
|
|
30
|
+
},
|
|
31
|
+
output: {
|
|
32
|
+
schema: { type: 'json' },
|
|
33
|
+
render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 2) }],
|
|
34
|
+
},
|
|
35
|
+
async execute(args: { phase?: string; runId?: string }, exec) {
|
|
36
|
+
if (!args.phase || !args.runId || args.runId.trim() === '') {
|
|
37
|
+
return { error: toolError('MISSING_PHASE_AND_RUN') } as const
|
|
38
|
+
}
|
|
39
|
+
const runId = args.runId.trim()
|
|
40
|
+
// BEFORE `closeoutRun`, which joins the id onto the run layer and writes the closeout receipt under
|
|
41
|
+
// whatever directory it resolved to.
|
|
42
|
+
const problem = runIdProblem(runId)
|
|
43
|
+
if (problem !== null) {
|
|
44
|
+
return { error: toolError('BAD_RUN_ID', problem + ' - run ids allow ' + RUN_ID_RULE + ' - e.g. ' + RUN_ID_EXAMPLES) } as const
|
|
45
|
+
}
|
|
46
|
+
const root = await recursive.resolveWorkspaceRoot(exec.agent)
|
|
47
|
+
if (!root) {
|
|
48
|
+
return { error: toolError('NO_WORKSPACE') } as const
|
|
49
|
+
}
|
|
50
|
+
const result = await recursive.closeoutRun(root, runId, args.phase.trim(), exec.agent as { session?: { header?: { cwd?: string } } } | null)
|
|
51
|
+
return result as unknown as JsonValue
|
|
52
|
+
},
|
|
53
|
+
})
|
|
36
54
|
}
|
|
@@ -1,14 +1,38 @@
|
|
|
1
1
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
2
2
|
import { codeRuntimeRefusal, toolError } from './errors.ts'
|
|
3
|
+
import { RUN_ID_EXAMPLES, RUN_ID_RULE, runIdProblem } from './run-id.ts'
|
|
3
4
|
import type { JsonValue } from '@deepseek-ai/dsh-util-values'
|
|
4
5
|
import type { RecursiveRuntime } from './runtime.ts'
|
|
5
6
|
|
|
7
|
+
/**
|
|
8
|
+
* PHASE 0 — SCAFFOLDING IS NOT STARTING, AND THE TOOL SAYS SO AT THE MOMENT IT MATTERS.
|
|
9
|
+
*
|
|
10
|
+
* A spec may legitimately exist before a run does: this tool writes the run directory and every phase
|
|
11
|
+
* document, and it still does. What it must NOT do is start the run, because starting is creating and
|
|
12
|
+
* arming the goal the harness drives autonomous rounds from. That is the owner's rule — *"phase 0
|
|
13
|
+
* requires explicit approval to start a run and goal"* — so the description below names the gate and the
|
|
14
|
+
* result carries `runStartApproval`, which is the pointer a caller needs: the run is inert until
|
|
15
|
+
* `recursive_ask` answers `run-start`.
|
|
16
|
+
*
|
|
17
|
+
* `runStartApproval` is read from the run's own Phase 0 artifact on every call, so it is the TRUE state
|
|
18
|
+
* rather than "this call created something": re-initialising an APPROVED run reports `approved: true`
|
|
19
|
+
* (and the run keeps its goal), which is what a caller re-scaffolding a run it already started needs to
|
|
20
|
+
* see.
|
|
21
|
+
*
|
|
22
|
+
* AND A RUN ID IS A NAME, NOT A PATH. This is the boundary where the name enters, so it is the boundary
|
|
23
|
+
* that refuses a path-shaped one — loudly, and before `initRun` can mkdir anything. The check lives here
|
|
24
|
+
* (through the shared `run-id.ts` rule) rather than in `runtime.ts` because `initRun` is not the only
|
|
25
|
+
* caller and because the runtime's `join(root, '.recursive', 'run', runId)` is CORRECT for a name; what
|
|
26
|
+
* was missing was a gate on the name. Teaching the runtime to accept a path would silently relocate the
|
|
27
|
+
* run layer instead of rejecting the call. See `run-id.ts` for the rule, the evidence behind it, and the
|
|
28
|
+
* "do not fix this back" note.
|
|
29
|
+
*/
|
|
6
30
|
export function createRecursiveInitTool(recursive: RecursiveRuntime) {
|
|
7
31
|
return defineTool({
|
|
8
32
|
name: 'recursive_init',
|
|
9
|
-
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.',
|
|
33
|
+
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. THIS DOES NOT START THE RUN: no goal exists until the user approves phase 0 through recursive_ask gate=run-start, so the result carries runStartApproval — read it and ask. The runId is the NAME of the run directory under .recursive/run/ and is never a path (see the parameter description): a path-shaped runId is refused before anything is written.',
|
|
10
34
|
parameters: {
|
|
11
|
-
runId: { type: 'string', description: 'Run id (e.g. 03-something). Required.' },
|
|
35
|
+
runId: { type: 'string', description: 'Run id — the NAME of the run directory under .recursive/run/ (e.g. 03-something, 01-calculator-lib), never a path: ' + RUN_ID_RULE + '. A run on another drive or inside a worktree is reached with recursive_worktree, not by passing a path here. Required.' },
|
|
12
36
|
createWorktree: { type: 'boolean', description: 'If true, create a linked worktree at .worktrees/<runId>/ and scaffold the run inside it (default: false).' },
|
|
13
37
|
baseBranch: { type: 'string', description: 'Base branch the worktree branch is cut from (default: current HEAD branch). Only used when createWorktree is true.' },
|
|
14
38
|
},
|
|
@@ -17,12 +41,19 @@ export function createRecursiveInitTool(recursive: RecursiveRuntime) {
|
|
|
17
41
|
render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 2) }],
|
|
18
42
|
},
|
|
19
43
|
async execute(args: { runId?: string; createWorktree?: boolean; baseBranch?: string }, exec) {
|
|
20
|
-
|
|
44
|
+
const runId = args.runId?.trim() ?? ''
|
|
45
|
+
if (runId === '') {
|
|
21
46
|
return { error: toolError('MISSING_RUN_ID') } as const
|
|
22
47
|
}
|
|
48
|
+
// BEFORE `initRun`: that call starts with `mkdirSync(runDir, { recursive: true })`, so a
|
|
49
|
+
// path-shaped id that reaches it has already made the operator-visible mess it was refused for.
|
|
50
|
+
const problem = runIdProblem(runId)
|
|
51
|
+
if (problem !== null) {
|
|
52
|
+
return { error: toolError('BAD_RUN_ID', problem + ' - run ids allow ' + RUN_ID_RULE + ' - e.g. ' + RUN_ID_EXAMPLES) } as const
|
|
53
|
+
}
|
|
23
54
|
try {
|
|
24
55
|
const result = await recursive.initRun(
|
|
25
|
-
|
|
56
|
+
runId,
|
|
26
57
|
exec.agent as { session?: { header?: { cwd?: string } } } | null,
|
|
27
58
|
{ createWorktree: args.createWorktree === true, baseBranch: args.baseBranch?.trim() || undefined },
|
|
28
59
|
)
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
2
2
|
import { toolError } from './errors.ts'
|
|
3
|
+
import { RUN_ID_EXAMPLES, RUN_ID_RULE, runIdProblem } from './run-id.ts'
|
|
3
4
|
import type { JsonValue } from '@deepseek-ai/dsh-util-values'
|
|
4
5
|
import type { RecursiveRuntime } from './runtime.ts'
|
|
5
6
|
|
|
@@ -9,20 +10,39 @@ import type { RecursiveRuntime } from './runtime.ts'
|
|
|
9
10
|
* (runtime.phaseRules -> phaseRulesFor) as the once-per-phase pre-step
|
|
10
11
|
* reminder, so the agent can re-ask for the rules without re-injecting them on
|
|
11
12
|
* every step. Returns { error } when no active phase is found.
|
|
13
|
+
*
|
|
14
|
+
* A RUN ID IS A NAME, NOT A PATH HERE TOO, INCLUDING WHEN IT IS OMITTED. Omitted and path-shaped are
|
|
15
|
+
* different cases and must stay different: omitted means "the latest run by mtime", which `resolveRunDir`
|
|
16
|
+
* answers by DISCOVERY rather than by joining anything, and that case is untouched below. A path-shaped id,
|
|
17
|
+
* by contrast, is joined by `phaseRules` -> `resolveRunDir` and then read, and `recordInjection` WRITES
|
|
18
|
+
* `memory-injections.json` under whatever directory it resolved to — with no scoping check at all on this
|
|
19
|
+
* path. So the gate fires only on an id that was actually supplied, and `run-id.ts` owns the rule. The
|
|
20
|
+
* refusal is `recursive_init`'s, `BAD_RUN_ID` (RM1107), composed identically.
|
|
12
21
|
*/
|
|
13
22
|
export function createRecursivePhaseTool(recursive: RecursiveRuntime) {
|
|
14
23
|
return defineTool({
|
|
15
24
|
name: 'recursive_phase',
|
|
16
25
|
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.',
|
|
17
26
|
parameters: {
|
|
18
|
-
runId: { type: 'string', description: 'Optional run id (
|
|
27
|
+
runId: { type: 'string', description: 'Optional run id — the NAME of the run directory under .recursive/run/ (e.g. 03-something), never a path: ' + RUN_ID_RULE + '. Omit it for the latest run by mtime.' },
|
|
19
28
|
},
|
|
20
29
|
output: {
|
|
21
30
|
schema: { type: 'json' },
|
|
22
31
|
render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 2) }],
|
|
23
32
|
},
|
|
24
33
|
async execute(args: { runId?: string }, exec) {
|
|
25
|
-
|
|
34
|
+
// ABSENT IS NOT INVALID. No runId at all keeps its documented meaning — the latest run by mtime,
|
|
35
|
+
// resolved by discovery in `resolveRunDir` — so the gate below runs only when a name was supplied,
|
|
36
|
+
// while an EMPTY one is refused rather than silently read as "latest": a caller that passed `""` did
|
|
37
|
+
// not ask for discovery, and `resolveRunDir` would otherwise interpret their mistyped id as one.
|
|
38
|
+
const runId = args.runId?.trim()
|
|
39
|
+
if (runId !== undefined) {
|
|
40
|
+
const problem = runId === '' ? 'runId is empty' : runIdProblem(runId)
|
|
41
|
+
if (problem !== null) {
|
|
42
|
+
return { error: toolError('BAD_RUN_ID', problem + ' - run ids allow ' + RUN_ID_RULE + ' - e.g. ' + RUN_ID_EXAMPLES) } as const
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
const result = await recursive.phaseRules(runId, exec.agent as { session?: { header?: { cwd?: string } } } | null)
|
|
26
46
|
if (!result) return { error: toolError('NO_PHASE') } as const
|
|
27
47
|
return result as unknown as JsonValue
|
|
28
48
|
},
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
2
2
|
import { toolError } from './errors.ts'
|
|
3
|
+
import { RUN_ID_EXAMPLES, RUN_ID_RULE, runIdProblem } from './run-id.ts'
|
|
3
4
|
import type { JsonValue } from '@deepseek-ai/dsh-util-values'
|
|
4
5
|
import type { RecursiveRuntime } from './runtime.ts'
|
|
5
6
|
|
|
@@ -7,6 +8,16 @@ import type { RecursiveRuntime } from './runtime.ts'
|
|
|
7
8
|
* `recursive_scratch` — read/write/append the run-scoped disposable scratchpad
|
|
8
9
|
* (R5) under the CURRENT session workspace only (R1). Scratch is git-ignored
|
|
9
10
|
* and never citable as an Input.
|
|
11
|
+
*
|
|
12
|
+
* A RUN ID IS A NAME, NOT A PATH HERE TOO. `scratchRun` joins it onto the run layer, and it then WRITES
|
|
13
|
+
* (write/append) into the directory it landed on, so a path-shaped id does not merely fail to find a run:
|
|
14
|
+
* with a `..\` segment it can find and write into a SIBLING of the run layer, because the runtime's
|
|
15
|
+
* workspace-scoping check is `runDir.startsWith(runRoot)` — a string prefix test, not containment — and a
|
|
16
|
+
* sibling directory whose name begins with `run` passes it. MEASURED pre-fix: `..\run-away` was accepted
|
|
17
|
+
* and `scratchRun` wrote `scratch.md` into `<workspace>\.recursive\run-away\scratch\`. The rule is
|
|
18
|
+
* `run-id.ts`; the gate sits here, at the boundary where the name enters, rather than in `scratchRun`, for
|
|
19
|
+
* the same reason `recursive_init`'s does. Refusal shape is `recursive_init`'s, `BAD_RUN_ID` (RM1107),
|
|
20
|
+
* composed identically: one rule, one message.
|
|
10
21
|
*/
|
|
11
22
|
export function createRecursiveScratchTool(recursive: RecursiveRuntime) {
|
|
12
23
|
return defineTool({
|
|
@@ -14,7 +25,7 @@ export function createRecursiveScratchTool(recursive: RecursiveRuntime) {
|
|
|
14
25
|
description: 'Read, write, or append the run-scoped disposable scratchpad (scratch/scratch.md or scratch/scratch.ts) for a run in the CURRENT session workspace. Workspace-scoped; scratch is git-ignored and never citable as an Input.',
|
|
15
26
|
parameters: {
|
|
16
27
|
action: { type: 'string', description: 'read | write | append. Required.' },
|
|
17
|
-
runId: { type: 'string', description: 'Run id (e.g. 03-something). Required; must resolve inside the current workspace.' },
|
|
28
|
+
runId: { type: 'string', description: 'Run id — the NAME of the run directory under .recursive/run/ (e.g. 03-something), never a path: ' + RUN_ID_RULE + '. Required; must resolve inside the current workspace.' },
|
|
18
29
|
target: { type: 'string', description: 'md | ts. Required.' },
|
|
19
30
|
content: { type: 'string', description: 'Content for write/append. Optional for read.' },
|
|
20
31
|
},
|
|
@@ -29,6 +40,11 @@ export function createRecursiveScratchTool(recursive: RecursiveRuntime) {
|
|
|
29
40
|
if (!action || !runId || !target) {
|
|
30
41
|
return { error: toolError('MISSING_SCRATCH_ARGS') } as const
|
|
31
42
|
}
|
|
43
|
+
// BEFORE `scratchRun`, which joins the id onto the run layer and then writes through it.
|
|
44
|
+
const problem = runIdProblem(runId)
|
|
45
|
+
if (problem !== null) {
|
|
46
|
+
return { error: toolError('BAD_RUN_ID', problem + ' - run ids allow ' + RUN_ID_RULE + ' - e.g. ' + RUN_ID_EXAMPLES) } as const
|
|
47
|
+
}
|
|
32
48
|
if (target !== 'md' && target !== 'ts') {
|
|
33
49
|
return { error: toolError('BAD_TARGET') } as const
|
|
34
50
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
2
2
|
import { toolError } from './errors.ts'
|
|
3
|
+
import { RUN_ID_EXAMPLES, RUN_ID_RULE, runIdProblem } from './run-id.ts'
|
|
3
4
|
import type { JsonValue } from '@deepseek-ai/dsh-util-values'
|
|
4
5
|
import type { RecursiveRuntime } from './runtime.ts'
|
|
5
6
|
|
|
@@ -7,13 +8,29 @@ import type { RecursiveRuntime } from './runtime.ts'
|
|
|
7
8
|
* `recursive_worktree` — create a linked git worktree for a run and/or
|
|
8
9
|
* promote a branch up the dev/stage/main chain. Workspace-scoped: the
|
|
9
10
|
* operations run under the SESSION's control-plane root only.
|
|
11
|
+
*
|
|
12
|
+
* A RUN ID IS A NAME, NOT A PATH HERE TOO, and this is the worst place to be without the rule: a `create`
|
|
13
|
+
* builds TWO things out of the id — the linked worktree directory `.worktrees/<runId>` AND the git branch
|
|
14
|
+
* `recursive/<runId>` (git accepts '/' inside a ref) — so a path-shaped id used to leave a worktree and a
|
|
15
|
+
* ref behind, not just a folder. MEASURED pre-fix, per id, against a fresh repo: `nested/child-run`
|
|
16
|
+
* returned ok:true and created BOTH `.worktrees/nested/child-run` and
|
|
17
|
+
* `refs/heads/recursive/nested/child-run`, while the shapes git itself refuses as ref syntax
|
|
18
|
+
* (`recursive//tmp/x`, `recursive/C:…`, `.hidden-run`, a trailing space) failed the worktree add and
|
|
19
|
+
* created neither. The rule is `run-id.ts` and is not restated here; the gate sits at this boundary, ahead
|
|
20
|
+
* of `createRunWorktree`, so the refusal no longer depends on git happening to dislike the ref name.
|
|
21
|
+
*
|
|
22
|
+
* The refusal is `BAD_RUN_ID` (RM1107), composed exactly as `recursive_init` composes it — same code, same
|
|
23
|
+
* detail, same sentence. One message for one rule is what keeps a caller from having to learn a second
|
|
24
|
+
* vocabulary for the same defect, and the shared remedy it carries ("call recursive_init again") is right
|
|
25
|
+
* for this tool as well: a `create` for a run that does not exist yet is exactly what `recursive_init`
|
|
26
|
+
* with `createWorktree: true` does.
|
|
10
27
|
*/
|
|
11
28
|
export function createRecursiveWorktreeTool(recursive: RecursiveRuntime) {
|
|
12
29
|
return defineTool({
|
|
13
30
|
name: 'recursive_worktree',
|
|
14
31
|
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.',
|
|
15
32
|
parameters: {
|
|
16
|
-
runId: { type: 'string', description: 'Run id the worktree is created for (e.g. 03-something). Required for create.' },
|
|
33
|
+
runId: { type: 'string', description: 'Run id the worktree is created for — the NAME of the run (e.g. 03-something), never a path: ' + RUN_ID_RULE + '. A path-shaped id is refused for create. Required for create.' },
|
|
17
34
|
action: { type: 'string', description: 'create | promote | status. Default: create.' },
|
|
18
35
|
fromBranch: { type: 'string', description: 'Source branch for a promote action (the branch holding the new commits).' },
|
|
19
36
|
toBranch: { type: 'string', description: 'Promotion target branch for a promote action (feature -> dev -> stage -> main).' },
|
|
@@ -35,7 +52,15 @@ export function createRecursiveWorktreeTool(recursive: RecursiveRuntime) {
|
|
|
35
52
|
if (!args.runId || args.runId.trim() === '') {
|
|
36
53
|
return { error: toolError('MISSING_CREATE_RUN_ID') } as const
|
|
37
54
|
}
|
|
38
|
-
const
|
|
55
|
+
const runId = args.runId.trim()
|
|
56
|
+
// BEFORE `createRunWorktree`: that call builds `.worktrees/<runId>` and cuts the branch
|
|
57
|
+
// `recursive/<runId>`, so a path-shaped id that reaches it has already created both. The gate is
|
|
58
|
+
// on the name; `run-id.ts` says why the join itself is not the thing to change.
|
|
59
|
+
const problem = runIdProblem(runId)
|
|
60
|
+
if (problem !== null) {
|
|
61
|
+
return { error: toolError('BAD_RUN_ID', problem + ' - run ids allow ' + RUN_ID_RULE + ' - e.g. ' + RUN_ID_EXAMPLES) } as const
|
|
62
|
+
}
|
|
63
|
+
const result = recursive.createRunWorktree(root, runId, args.baseBranch?.trim() || undefined)
|
|
39
64
|
return result as unknown as JsonValue
|
|
40
65
|
}
|
|
41
66
|
if (action === 'promote') {
|
package/src/run-id.ts
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A RUN ID IS A NAME, NOT A PATH.
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS MODULE EXISTS. Every consumer of a run id JOINS it onto a directory
|
|
5
|
+
* that already carries the meaning "the run layer":
|
|
6
|
+
*
|
|
7
|
+
* join(root, '.recursive', 'run', runId) // runtime.ts, run.ts, handoff.ts, scratch.ts
|
|
8
|
+
* join(repoRoot, '.worktrees', runId) // worktree.ts (a linked worktree)
|
|
9
|
+
* 'recursive/' + runId // worktree.ts (the run's git branch)
|
|
10
|
+
*
|
|
11
|
+
* `join` is a PATH operation: absolute paths, drive specifiers and `..` segments
|
|
12
|
+
* are all legal input to it, and each one silently changes what the call means.
|
|
13
|
+
* A caller who passes `E:\tmp\rm-live-diagnostics\01-calculator-lib` is asking
|
|
14
|
+
* for a run "on another drive"; what they get is a `mkdir` of
|
|
15
|
+
*
|
|
16
|
+
* <workspace>\.recursive\run\E:\tmp\rm-live-diagnostics\01-calculator-lib
|
|
17
|
+
*
|
|
18
|
+
* which is not drive-qualified at all — on POSIX and Windows alike the colon is
|
|
19
|
+
* just another character in a relative component. The result is a bogus nested
|
|
20
|
+
* folder INSIDE the workspace, created before anything can refuse it, surfacing
|
|
21
|
+
* far away as an ENOENT-shaped runtime failure (RM5501) with the operator's
|
|
22
|
+
* filesystem already dirty.
|
|
23
|
+
*
|
|
24
|
+
* SO THE RULE IS ENFORCED WHERE THE NAME ENTERS, and NOT by teaching the runtime
|
|
25
|
+
* to accept a path. The joins in `runtime.ts` are CORRECT for a name; what was
|
|
26
|
+
* missing was a gate on the name. Do not "fix" this back: a run on another drive
|
|
27
|
+
* or in a worktree is reached through the session's control-plane root
|
|
28
|
+
* (`recursive_worktree`, `00-worktree.md`) — the run layer is never relocated by
|
|
29
|
+
* smuggling a path into the id.
|
|
30
|
+
*
|
|
31
|
+
* The charset below is deliberately the SAME one the read path already uses
|
|
32
|
+
* (`live-route.ts` `DOC_SAFE_RE`) so a name this gate accepts is a name that
|
|
33
|
+
* route can serve.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The accepted shape, as prose that can be embedded in a model-facing parameter
|
|
38
|
+
* description and in a refusal detail, so the rule is stated once.
|
|
39
|
+
*/
|
|
40
|
+
export const RUN_ID_RULE = 'letters, digits, dot, underscore or dash only, no leading or trailing dot, no path separator, no drive specifier and no ".." segment'
|
|
41
|
+
|
|
42
|
+
/** Two ids in the shapes the scaffold convention actually produces. */
|
|
43
|
+
export const RUN_ID_EXAMPLES = '01-calculator-lib, fixture-run'
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Longest run id accepted. Directory-name components cap at 255 bytes on NTFS
|
|
47
|
+
* and ext4; a run id also becomes a git ref component (`recursive/<runId>`) and
|
|
48
|
+
* a prefix of every lock/receipt filename inside the run, so the ceiling is set
|
|
49
|
+
* well below the filesystem limit rather than at it.
|
|
50
|
+
*/
|
|
51
|
+
export const RUN_ID_MAX_LENGTH = 100
|
|
52
|
+
|
|
53
|
+
/** Directory-name charset — the read path's `DOC_SAFE_RE`, verbatim. */
|
|
54
|
+
const RUN_ID_CHARS = /^[A-Za-z0-9._-]+$/
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Why a run id is refused, or `null` when it is a usable NAME.
|
|
58
|
+
*
|
|
59
|
+
* The returned string is the SPECIFIC problem (which rule the id broke), with no
|
|
60
|
+
* trailing punctuation and no sentence of its own, so a caller can hand it to
|
|
61
|
+
* `toolError('BAD_RUN_ID', …)` as the detail. `RUN_ID_RULE` states the shape.
|
|
62
|
+
*
|
|
63
|
+
* The order of the checks is part of the message quality: a Windows absolute
|
|
64
|
+
* path is reported as a drive-qualified path (what the caller passed) rather
|
|
65
|
+
* than as a separator complaint (what that path is made of).
|
|
66
|
+
*/
|
|
67
|
+
export function runIdProblem(raw: string): string | null {
|
|
68
|
+
if (raw === '') return 'runId is empty'
|
|
69
|
+
if (raw.length > RUN_ID_MAX_LENGTH) return 'runId is ' + raw.length + ' characters, over the ' + RUN_ID_MAX_LENGTH + ' allowed'
|
|
70
|
+
// A Windows drive-QUALIFIED path (`E:\x`, and the drive-relative `E:x` too).
|
|
71
|
+
if (/^[A-Za-z]:/.test(raw)) return 'runId is a Windows drive-qualified path, starting with "' + raw.slice(0, 2) + '"'
|
|
72
|
+
// Path separators: an absolute POSIX path, a UNC path, or any nested path.
|
|
73
|
+
if (raw.includes('/') || raw.includes('\\')) {
|
|
74
|
+
return 'runId contains the path separator "' + (raw.includes('/') ? '/' : '\\') + '"'
|
|
75
|
+
}
|
|
76
|
+
// A colon that is not a drive prefix is still unmappable on Windows (NTFS
|
|
77
|
+
// alternate data streams), and the run id is a Windows directory name.
|
|
78
|
+
if (raw.includes(':')) return 'runId contains a colon (":"), which is a drive and stream separator on Windows'
|
|
79
|
+
// `..` makes the join resolve to the PARENT of the run layer.
|
|
80
|
+
if (raw.includes('..')) return 'runId contains a ".." segment, which escapes the run directory'
|
|
81
|
+
// `.` and `..` are the parent/current directory segments, and a trailing dot is
|
|
82
|
+
// stripped by the Win32 path parser — `03-foo.` and `03-foo` would then be two
|
|
83
|
+
// names for one directory.
|
|
84
|
+
if (raw.startsWith('.')) return 'runId starts with ".", which makes it a hidden name or a relative path segment'
|
|
85
|
+
if (raw.endsWith('.')) return 'runId ends with "."'
|
|
86
|
+
if (!RUN_ID_CHARS.test(raw)) {
|
|
87
|
+
// Whitespace inside the name is called out separately because the caller can
|
|
88
|
+
// see the id they typed and cannot see why it is refused: the tool trims the
|
|
89
|
+
// ENDS (so `" 03-x "` already names `03-x`), but an interior space is not
|
|
90
|
+
// normalized anywhere and would create a directory the caller cannot retype.
|
|
91
|
+
if (/\s/.test(raw)) return 'runId contains a space or other whitespace character inside the name'
|
|
92
|
+
return 'runId contains a character outside the allowed set'
|
|
93
|
+
}
|
|
94
|
+
return null
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** True when `raw` is a usable run NAME. Convenience for callers that only branch. */
|
|
98
|
+
export function isValidRunId(raw: string): boolean {
|
|
99
|
+
return runIdProblem(raw) === null
|
|
100
|
+
}
|
package/src/run-start.ts
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PHASE 0 — STARTING A RUN IS A HUMAN DECISION, NOT A SIDE EFFECT OF SCAFFOLDING.
|
|
3
|
+
*
|
|
4
|
+
* THE DEFECT THIS CLOSES. `recursive_init` scaffolded a run and the plugin then CREATED AND ARMED a
|
|
5
|
+
* goal for it in the same breath (`syncRunGoal`'s "no current goal -> create and arm" branch). A goal
|
|
6
|
+
* is not a label: `goals.create` returns an ARMED view, and the harness immediately begins driving
|
|
7
|
+
* autonomous goal rounds for the session. So asking for a run spec was enough to start an unattended
|
|
8
|
+
* run — the owner's rule is the opposite: *"creating a spec before a run exists should not create a
|
|
9
|
+
* goal. Phase 0 requires explicit approval to start a run and goal."*
|
|
10
|
+
*
|
|
11
|
+
* WHAT "APPROVAL" IS, EXACTLY. The approving label of the `run-start` gate of `recursive_ask`
|
|
12
|
+
* (`Start run`, as opposed to `Hold`), recorded here as a durable `- Run Start: Start run` line in the
|
|
13
|
+
* run's Phase 0 requirements artifact. Three things make that an explicit human act rather than an
|
|
14
|
+
* inference:
|
|
15
|
+
*
|
|
16
|
+
* 1. NO DEFAULT, AND THE VALUE IS THE DECISION. The line is written by the gate itself into the Phase 0
|
|
17
|
+
* requirements document, and the gate REFUSES an answer that is not one of the labels it offered.
|
|
18
|
+
* An unoffered answer is a transcription error wearing the shape of a decision, and a `Hold` is not
|
|
19
|
+
* an approval in any spelling — see {@link readRunStartApproval}, which matches the approving VALUE
|
|
20
|
+
* and nothing else, so the presence of a `Run Start` line is never on its own consent.
|
|
21
|
+
* 2. IT IS ASKED, NOT ASSUMED. When the composition mounts `ctx.userQuestions` — the harness's own
|
|
22
|
+
* blocking human channel, the same one plan-mode's exit uses — the question is PUT TO THE PERSON and
|
|
23
|
+
* only their selection is recorded; a caller-supplied answer cannot stand in for it. A channel that
|
|
24
|
+
* RESOLVES with an answer the gate does not recognise is a person's decision the gate cannot record
|
|
25
|
+
* and it ends the call (RM5504). A channel that FAILS ends the call too (RM5503), naming the cause the
|
|
26
|
+
* channel threw — and there the caller may take the relayed route deliberately, with `relay=true`,
|
|
27
|
+
* which the result reports as `source: "relayed"` rather than as a person's own selection, so a
|
|
28
|
+
* composition whose channel cannot deliver the question can still start a run. A failure that means
|
|
29
|
+
* the question was cancelled, aborted, or timed out is never relayable. Only a composition with no
|
|
30
|
+
* channel at all falls back to the relayed answer unconditionally, which is the contract the other
|
|
31
|
+
* three gates have.
|
|
32
|
+
* 3. THE GOAL CANNOT BE CREATED WITHOUT IT. `syncRunGoal` refuses to create a goal for a run whose
|
|
33
|
+
* approval record is absent, in EVERY branch that would create one — not only the "no goal yet"
|
|
34
|
+
* branch. That is the property `tests/run-start-approval.spec.ts` asserts, because a single
|
|
35
|
+
* unguarded branch is exactly how this defect existed in the first place.
|
|
36
|
+
*
|
|
37
|
+
* A SPEC MAY EXIST BEFORE A RUN EXISTS, and this module does not forbid that: the scaffold, the Phase
|
|
38
|
+
* 0 artifacts and every later phase document are all created by `recursive_init` as before. What is
|
|
39
|
+
* withheld is the GOAL — the object that makes the harness drive rounds. A run that is scaffolded and
|
|
40
|
+
* never approved is a spec: readable, editable, lockable, and inert.
|
|
41
|
+
*/
|
|
42
|
+
import { readFileSync } from 'node:fs'
|
|
43
|
+
import { join } from 'node:path'
|
|
44
|
+
import { getMdFieldValue } from './status.ts'
|
|
45
|
+
|
|
46
|
+
/** The gate id `recursive_ask` answers for a run start. Deliberately NOT in ASK_GATE_IDS. */
|
|
47
|
+
export const RUN_START_GATE_ID = 'run-start'
|
|
48
|
+
|
|
49
|
+
/** The Phase 0 artifact the approval is recorded in. */
|
|
50
|
+
export const RUN_START_ARTIFACT = '00-requirements.md'
|
|
51
|
+
|
|
52
|
+
/** The artifact field the approval reads back from. */
|
|
53
|
+
export const RUN_START_MARKER = 'Run Start'
|
|
54
|
+
|
|
55
|
+
/** The approving label. The ONLY label that starts a run. */
|
|
56
|
+
export const RUN_START_APPROVE = 'Start run'
|
|
57
|
+
|
|
58
|
+
/** The withholding label: the spec stays a spec. */
|
|
59
|
+
export const RUN_START_HOLD = 'Hold'
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* WHY THIS GATE IS NOT IN `ASK_GATE_IDS`. Those three are the WORKFLOW's gates — phase-3 test
|
|
63
|
+
* evidence, phase-5 sign-off, resolving a gate block — and their membership is asserted as exactly
|
|
64
|
+
* three. Starting a run is a different kind of decision: it is the one that decides whether there is
|
|
65
|
+
* a run at all. It lives here, with its own contract, so widening the workflow's gate list cannot
|
|
66
|
+
* quietly widen what may start a run.
|
|
67
|
+
*/
|
|
68
|
+
export const RUN_START_GATE = {
|
|
69
|
+
id: RUN_START_GATE_ID,
|
|
70
|
+
header: 'Start run',
|
|
71
|
+
question: 'Approve phase 0 and start this run? Approving creates an armed goal the harness will keep driving.',
|
|
72
|
+
options: [
|
|
73
|
+
{ label: RUN_START_APPROVE, description: 'Record the approval and arm the run goal.' },
|
|
74
|
+
{ label: RUN_START_HOLD, description: 'Leave the spec inert: no run goal, no autonomous rounds.' },
|
|
75
|
+
],
|
|
76
|
+
marker: RUN_START_MARKER,
|
|
77
|
+
} as const
|
|
78
|
+
|
|
79
|
+
/** The durable line an approval writes. */
|
|
80
|
+
export function runStartApprovalLine(): string {
|
|
81
|
+
return '- ' + RUN_START_MARKER + ': ' + RUN_START_APPROVE
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Is a `run-start` answer the approving one? */
|
|
85
|
+
export function isRunStartApproval(answer: string): boolean {
|
|
86
|
+
return answer.trim() === RUN_START_APPROVE
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Where the Phase 0 requirements artifact lives for a run rooted at `root`. */
|
|
90
|
+
export function runStartArtifactPath(root: string, runId: string): string {
|
|
91
|
+
return join(root, '.recursive', 'run', runId, RUN_START_ARTIFACT)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The artifact text, or null when the file is absent (a read failure is not an approval). */
|
|
95
|
+
export function readRunStartArtifact(root: string, runId: string): string | null {
|
|
96
|
+
try {
|
|
97
|
+
return readFileSync(runStartArtifactPath(root, runId), 'utf8')
|
|
98
|
+
} catch {
|
|
99
|
+
return null
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The approval state of a run, read from its Phase 0 artifact.
|
|
105
|
+
*
|
|
106
|
+
* ⚠ MATCHED ON THE VALUE, NOT ON THE LINE'S PRESENCE. `getMdFieldValue` returns the field's VALUE, so
|
|
107
|
+
* a recorded `- Run Start: Hold` is refused here — a check for "is there a Run Start line?" would read
|
|
108
|
+
* a refusal as consent, which is the one mistake this whole module exists to prevent.
|
|
109
|
+
*/
|
|
110
|
+
export function readRunStartApproval(root: string, runId: string): { approved: boolean; artifact: string; reason: string } {
|
|
111
|
+
const content = readRunStartArtifact(root, runId)
|
|
112
|
+
if (content === null) {
|
|
113
|
+
return { approved: false, artifact: RUN_START_ARTIFACT, reason: 'the Phase 0 requirements artifact does not exist yet' }
|
|
114
|
+
}
|
|
115
|
+
const value = getMdFieldValue(content, RUN_START_MARKER)
|
|
116
|
+
if (value === null) {
|
|
117
|
+
return { approved: false, artifact: RUN_START_ARTIFACT, reason: 'no ' + RUN_START_MARKER + ' decision has been recorded' }
|
|
118
|
+
}
|
|
119
|
+
if (!isRunStartApproval(value)) {
|
|
120
|
+
return { approved: false, artifact: RUN_START_ARTIFACT, reason: RUN_START_MARKER + ' is ' + JSON.stringify(value) + ', which does not start a run' }
|
|
121
|
+
}
|
|
122
|
+
return { approved: true, artifact: RUN_START_ARTIFACT, reason: '' }
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* The ONE refusal reason the projection returns before approval, exported so every caller branches on
|
|
127
|
+
* the same string instead of re-typing it (a re-typed reason is a caller that silently stops matching).
|
|
128
|
+
*/
|
|
129
|
+
export const RUN_START_NOT_APPROVED = 'run not started: phase 0 approval has not been granted'
|