@try-works/dsh-recursive-mode 0.4.5 → 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 +34 -1
- package/lib/index.js +350 -48
- package/lib/recursive_ask.tool.d.ts +93 -16
- package/lib/recursive_closeout.tool.d.ts +10 -0
- package/lib/recursive_init.tool.d.ts +8 -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/package.json +1 -1
- package/src/errors.ts +35 -2
- package/src/recursive_ask.tool.ts +190 -41
- package/src/recursive_closeout.tool.ts +53 -35
- package/src/recursive_init.tool.ts +20 -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 +9 -3
|
@@ -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
CHANGED
|
@@ -20,9 +20,15 @@
|
|
|
20
20
|
* and nothing else, so the presence of a `Run Start` line is never on its own consent.
|
|
21
21
|
* 2. IT IS ASKED, NOT ASSUMED. When the composition mounts `ctx.userQuestions` — the harness's own
|
|
22
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
|
|
24
|
-
*
|
|
25
|
-
*
|
|
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.
|
|
26
32
|
* 3. THE GOAL CANNOT BE CREATED WITHOUT IT. `syncRunGoal` refuses to create a goal for a run whose
|
|
27
33
|
* approval record is absent, in EVERY branch that would create one — not only the "no goal yet"
|
|
28
34
|
* branch. That is the property `tests/run-start-approval.spec.ts` asserts, because a single
|