@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.
@@ -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 result = recursive.createRunWorktree(root, args.runId.trim(), args.baseBranch?.trim() || undefined)
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, and a channel
24
- * that cannot reach anyone ends the call without a decision (RM5503). Only a composition with no
25
- * channel at all falls back to the relayed answer, which is the contract the other three gates have.
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