@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.
@@ -125,24 +125,101 @@ export declare function createRecursiveAskTool(recursive: RecursiveRuntime): imp
125
125
  /**
126
126
  * PHASE 0 — record the answer to the run-start gate, and start the run only if it says so.
127
127
  *
128
- * ⚠ THIS GATE NEVER ACCEPTS A RELAYED ANSWER WHILE A HUMAN CHANNEL IS MOUNTED. That is the rule that makes
129
- * an approval a human act rather than an inference: when `ctx.userQuestions` is present, the question is
130
- * PUT TO THE PERSON and nothing else can settle it — not the caller's own `answer` argument, and not a
131
- * fabrication, because `ask()` resolves only with a real selection. A person's decline is likewise final
132
- * for that call and cannot be overridden by a model that asked for `Start run` in the same breath.
133
- *
134
- * ⚠ AND WHEN NO CHANNEL IS MOUNTED, THE RELAYED ANSWER IS THE ONLY POSSIBLE SOURCE, so it is used — that
135
- * is the same contract the other three gates have always had, and refusing it would leave a composition
136
- * without the channel unable to start any run at all. The question is surfaced first by the ASK branch
137
- * (the card data the host renders), and the model's `answer` is that person's selection coming back.
138
- *
139
- * ⚠ WHAT THE GATE THEREFORE DOES *NOT* CLAIM, stated rather than implied: in a composition with no
140
- * `userQuestions` channel, a plugin cannot verify that a person was really asked, so a model could in
141
- * principle relay a label nobody gave. That is a property of the relay, not of this gate — and it is the
142
- * reason the channel is consulted in preference whenever it exists. See the header of `run-start.ts`.
128
+ * ⚠ THREE OUTCOMES, AND TELLING THEM APART IS THE FIX. The first version of this function collapsed all of
129
+ * them into one `null`: "the channel threw", "the channel resolved with something unrecognisable", and
130
+ * "nobody answered" produced the same refusal, whose text asserted a cause ("so no person was asked") the
131
+ * plugin had already thrown away. A live session paid for that: the call failed after 22.9 s, the operator
132
+ * could not be told why, and the refusal's own advice prescribed the call that had just failed. So:
133
+ *
134
+ * 1. A PERSON WAS REACHED (`unusable`): the channel resolved, so somebody answered, and their answer is
135
+ * not a label this gate offered — a skip, a custom value, several labels at once. That is a DECISION
136
+ * the gate cannot record, and it is final: neither the caller's `answer` nor `relay=true` may replace
137
+ * it. (RM5504)
138
+ * 2. THE CHANNEL FAILED (`unavailable`): no decision came back at all, and the refusal NAMES THE CAUSE
139
+ * from the error the channel threw. Here the run can still be started, because a composition whose
140
+ * channel cannot deliver the question would otherwise be unable to start any run — but only by the
141
+ * caller asking for the relay in so many words (`relay=true`), which the result reports as
142
+ * `source: "relayed"` rather than as a person's own selection. (RM5503)
143
+ * 3. NO CHANNEL IS MOUNTED: the relayed answer is the only possible source, exactly as before. (RM5502
144
+ * when there is no answer either)
145
+ *
146
+ * ⚠ AND A CANCELLED OR CLOSED QUESTION IS NEVER RELAYABLE. `ASK_CANCELLED`, `ASK_ABORTED` and
147
+ * `ASK_TIMED_OUT` are the codes that mean the question was settled from outside this gate — the card was
148
+ * dismissed, the turn was cancelled, or a foreground window ended. The relay is refused for those, so a
149
+ * question the operator stopped cannot be turned into an approval by asking again in the same breath.
150
+ * Every other failure is a composition or capability failure — the question reached nobody — which is the
151
+ * class the relay exists for.
152
+ *
153
+ * ⚠ WHAT THE GATE STILL DOES *NOT* CLAIM: a relayed approval is a relayed approval. The plugin cannot
154
+ * verify that a person gave the label, and it does not pretend otherwise — the result's `source` and
155
+ * `channel` fields say where the decision came from, and a direct selection is preferred whenever the
156
+ * channel can produce one.
143
157
  */
144
158
  export declare function recordRunStartAnswer(recursive: RecursiveRuntime, root: string, runId: string, answer: string | undefined, exec: {
145
159
  agent?: unknown;
146
160
  signal?: unknown;
147
161
  callId?: unknown;
148
- }): Promise<Record<string, unknown>>;
162
+ }, relay?: boolean): Promise<Record<string, unknown>>;
163
+ /**
164
+ * PHASE 0 — WHAT THE BLOCKING CHANNEL ACTUALLY DID.
165
+ *
166
+ * ⚠ THIS TYPE EXISTS BECAUSE ITS ABSENCE WAS THE DEFECT. The first version returned a bare `null` from a
167
+ * `catch {}` for every failure and for every unrecognisable selection, so "the channel threw NO_PROVIDER",
168
+ * "the caller is not the live root agent", "the person skipped the question" and "the person typed a
169
+ * custom value" were ONE value. The refusal built from it then asserted the one cause it could not know.
170
+ * An outcome carries the cause, the raw message, and whether the failure is the class a relay may answer.
171
+ */
172
+ export type RunStartChannelOutcome =
173
+ /** The person answered, and their selection is exactly one of the gate's own labels. */
174
+ {
175
+ kind: 'answered';
176
+ answer: string;
177
+ }
178
+ /** The channel RESOLVED, so a person was reached — but the answer is not a label this gate offered. */
179
+ | {
180
+ kind: 'unusable';
181
+ detail: string;
182
+ }
183
+ /** The channel THREW: no decision came back, and the cause is named rather than discarded. */
184
+ | {
185
+ kind: 'unavailable';
186
+ cause: string;
187
+ detail: string;
188
+ relayable: boolean;
189
+ };
190
+ /**
191
+ * ⚠ THE CODES THAT MEAN THE QUESTION WAS CANCELLED OR CLOSED rather than never delivered: the person
192
+ * dismissed the card, their turn was cancelled, or a foreground window ended. A caller may not convert any
193
+ * of those into an approval by asking for the relay in the same breath. Every other failure means the
194
+ * question reached nobody — a composition or capability failure, which is the class the relay exists for.
195
+ */
196
+ export declare const NON_RELAYABLE_CHANNEL_CODES: readonly ["ASK_CANCELLED", "ASK_ABORTED", "ASK_TIMED_OUT"];
197
+ /**
198
+ * Name the failure of one `ask()` call, without inventing anything about it.
199
+ *
200
+ * The cause is the error's own `code` when it has one (the harness's `UserQuestionError` carries
201
+ * `NO_PROVIDER`, `CALLER_NOT_LIVE`, `DELEGATED_CALLER`, `ASK_ABORTED`, …), else its `name`, else its
202
+ * JavaScript type. `detail` keeps the message verbatim so a reader sees the channel's own words rather
203
+ * than this plugin's paraphrase — the paraphrase is exactly how the previous version came to assert a
204
+ * cause nobody had.
205
+ */
206
+ export declare function classifyChannelFailure(err: unknown): {
207
+ cause: string;
208
+ detail: string;
209
+ relayable: boolean;
210
+ };
211
+ /**
212
+ * Ask the run-start question through the blocking channel and report WHAT HAPPENED.
213
+ *
214
+ * ⚠ THE CATCH IS THE POINT. It used to be `catch { return null }` — a blocking human question whose
215
+ * failure cause was erased at the exact moment the cause was the only thing worth knowing. Every path out
216
+ * of this function now says which path it was.
217
+ *
218
+ * The selection is filtered to the gate's OWN labels: a question a UI answered with a free-text custom
219
+ * value must not become an approval just because it arrived on the right channel.
220
+ */
221
+ export declare function askRunStartDirectly(channel: NonNullable<RecursiveRuntime['userQuestionsChannel']>, exec: {
222
+ agent?: unknown;
223
+ signal?: unknown;
224
+ callId?: unknown;
225
+ }): Promise<RunStartChannelOutcome>;
@@ -4,5 +4,15 @@ import type { RecursiveRuntime } from './runtime.ts';
4
4
  * SESSION's workspace only (R1 workspace-scoping invariant). The run is resolved
5
5
  * via the session agent's cwd -> workspace registry; a runId outside the current
6
6
  * workspace is rejected.
7
+ *
8
+ * A RUN ID IS A NAME, NOT A PATH HERE TOO, and "outside the current workspace is rejected" is NOT enough
9
+ * on its own: `closeoutRun` joins the id onto the run layer and then writes a receipt under it, and its
10
+ * scoping check is `runDir.startsWith(runRoot)` — a string prefix test, not containment. A `..\` segment
11
+ * that lands on a SIBLING of the run layer passes that check whenever the sibling's name begins with
12
+ * `run`, so a path-shaped id can still receive a write. MEASURED pre-fix: `..\run-away` and `../run-away`
13
+ * were accepted and reached the report; the other shapes below were stopped only by the sibling not
14
+ * existing, which is the operator's filesystem deciding, not the tool. The rule is `run-id.ts`; this
15
+ * boundary is where the name enters, so this is where it is refused, with `recursive_init`'s refusal shape
16
+ * — `BAD_RUN_ID` (RM1107), same detail sentence.
7
17
  */
8
18
  export declare function createRecursiveCloseoutTool(recursive: RecursiveRuntime): import("@deepseek-ai/dsh-tools").ToolDefinition;
@@ -13,5 +13,13 @@ import type { RecursiveRuntime } from './runtime.ts';
13
13
  * rather than "this call created something": re-initialising an APPROVED run reports `approved: true`
14
14
  * (and the run keeps its goal), which is what a caller re-scaffolding a run it already started needs to
15
15
  * see.
16
+ *
17
+ * AND A RUN ID IS A NAME, NOT A PATH. This is the boundary where the name enters, so it is the boundary
18
+ * that refuses a path-shaped one — loudly, and before `initRun` can mkdir anything. The check lives here
19
+ * (through the shared `run-id.ts` rule) rather than in `runtime.ts` because `initRun` is not the only
20
+ * caller and because the runtime's `join(root, '.recursive', 'run', runId)` is CORRECT for a name; what
21
+ * was missing was a gate on the name. Teaching the runtime to accept a path would silently relocate the
22
+ * run layer instead of rejecting the call. See `run-id.ts` for the rule, the evidence behind it, and the
23
+ * "do not fix this back" note.
16
24
  */
17
25
  export declare function createRecursiveInitTool(recursive: RecursiveRuntime): import("@deepseek-ai/dsh-tools").ToolDefinition;
@@ -5,5 +5,13 @@ import type { RecursiveRuntime } from './runtime.ts';
5
5
  * (runtime.phaseRules -> phaseRulesFor) as the once-per-phase pre-step
6
6
  * reminder, so the agent can re-ask for the rules without re-injecting them on
7
7
  * every step. Returns { error } when no active phase is found.
8
+ *
9
+ * A RUN ID IS A NAME, NOT A PATH HERE TOO, INCLUDING WHEN IT IS OMITTED. Omitted and path-shaped are
10
+ * different cases and must stay different: omitted means "the latest run by mtime", which `resolveRunDir`
11
+ * answers by DISCOVERY rather than by joining anything, and that case is untouched below. A path-shaped id,
12
+ * by contrast, is joined by `phaseRules` -> `resolveRunDir` and then read, and `recordInjection` WRITES
13
+ * `memory-injections.json` under whatever directory it resolved to — with no scoping check at all on this
14
+ * path. So the gate fires only on an id that was actually supplied, and `run-id.ts` owns the rule. The
15
+ * refusal is `recursive_init`'s, `BAD_RUN_ID` (RM1107), composed identically.
8
16
  */
9
17
  export declare function createRecursivePhaseTool(recursive: RecursiveRuntime): import("@deepseek-ai/dsh-tools").ToolDefinition;
@@ -3,5 +3,15 @@ import type { RecursiveRuntime } from './runtime.ts';
3
3
  * `recursive_scratch` — read/write/append the run-scoped disposable scratchpad
4
4
  * (R5) under the CURRENT session workspace only (R1). Scratch is git-ignored
5
5
  * and never citable as an Input.
6
+ *
7
+ * A RUN ID IS A NAME, NOT A PATH HERE TOO. `scratchRun` joins it onto the run layer, and it then WRITES
8
+ * (write/append) into the directory it landed on, so a path-shaped id does not merely fail to find a run:
9
+ * with a `..\` segment it can find and write into a SIBLING of the run layer, because the runtime's
10
+ * workspace-scoping check is `runDir.startsWith(runRoot)` — a string prefix test, not containment — and a
11
+ * sibling directory whose name begins with `run` passes it. MEASURED pre-fix: `..\run-away` was accepted
12
+ * and `scratchRun` wrote `scratch.md` into `<workspace>\.recursive\run-away\scratch\`. The rule is
13
+ * `run-id.ts`; the gate sits here, at the boundary where the name enters, rather than in `scratchRun`, for
14
+ * the same reason `recursive_init`'s does. Refusal shape is `recursive_init`'s, `BAD_RUN_ID` (RM1107),
15
+ * composed identically: one rule, one message.
6
16
  */
7
17
  export declare function createRecursiveScratchTool(recursive: RecursiveRuntime): import("@deepseek-ai/dsh-tools").ToolDefinition;
@@ -3,5 +3,21 @@ import type { RecursiveRuntime } from './runtime.ts';
3
3
  * `recursive_worktree` — create a linked git worktree for a run and/or
4
4
  * promote a branch up the dev/stage/main chain. Workspace-scoped: the
5
5
  * operations run under the SESSION's control-plane root only.
6
+ *
7
+ * A RUN ID IS A NAME, NOT A PATH HERE TOO, and this is the worst place to be without the rule: a `create`
8
+ * builds TWO things out of the id — the linked worktree directory `.worktrees/<runId>` AND the git branch
9
+ * `recursive/<runId>` (git accepts '/' inside a ref) — so a path-shaped id used to leave a worktree and a
10
+ * ref behind, not just a folder. MEASURED pre-fix, per id, against a fresh repo: `nested/child-run`
11
+ * returned ok:true and created BOTH `.worktrees/nested/child-run` and
12
+ * `refs/heads/recursive/nested/child-run`, while the shapes git itself refuses as ref syntax
13
+ * (`recursive//tmp/x`, `recursive/C:…`, `.hidden-run`, a trailing space) failed the worktree add and
14
+ * created neither. The rule is `run-id.ts` and is not restated here; the gate sits at this boundary, ahead
15
+ * of `createRunWorktree`, so the refusal no longer depends on git happening to dislike the ref name.
16
+ *
17
+ * The refusal is `BAD_RUN_ID` (RM1107), composed exactly as `recursive_init` composes it — same code, same
18
+ * detail, same sentence. One message for one rule is what keeps a caller from having to learn a second
19
+ * vocabulary for the same defect, and the shared remedy it carries ("call recursive_init again") is right
20
+ * for this tool as well: a `create` for a run that does not exist yet is exactly what `recursive_init`
21
+ * with `createWorktree: true` does.
6
22
  */
7
23
  export declare function createRecursiveWorktreeTool(recursive: RecursiveRuntime): import("@deepseek-ai/dsh-tools").ToolDefinition;
@@ -0,0 +1,62 @@
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
+ * The accepted shape, as prose that can be embedded in a model-facing parameter
37
+ * description and in a refusal detail, so the rule is stated once.
38
+ */
39
+ export declare 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";
40
+ /** Two ids in the shapes the scaffold convention actually produces. */
41
+ export declare const RUN_ID_EXAMPLES = "01-calculator-lib, fixture-run";
42
+ /**
43
+ * Longest run id accepted. Directory-name components cap at 255 bytes on NTFS
44
+ * and ext4; a run id also becomes a git ref component (`recursive/<runId>`) and
45
+ * a prefix of every lock/receipt filename inside the run, so the ceiling is set
46
+ * well below the filesystem limit rather than at it.
47
+ */
48
+ export declare const RUN_ID_MAX_LENGTH = 100;
49
+ /**
50
+ * Why a run id is refused, or `null` when it is a usable NAME.
51
+ *
52
+ * The returned string is the SPECIFIC problem (which rule the id broke), with no
53
+ * trailing punctuation and no sentence of its own, so a caller can hand it to
54
+ * `toolError('BAD_RUN_ID', …)` as the detail. `RUN_ID_RULE` states the shape.
55
+ *
56
+ * The order of the checks is part of the message quality: a Windows absolute
57
+ * path is reported as a drive-qualified path (what the caller passed) rather
58
+ * than as a separator complaint (what that path is made of).
59
+ */
60
+ export declare function runIdProblem(raw: string): string | null;
61
+ /** True when `raw` is a usable run NAME. Convenience for callers that only branch. */
62
+ export declare function isValidRunId(raw: string): boolean;
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 + 13 recursive_* tools (recursive_status, recursive_init, recursive_lock, recursive_lint, recursive_closeout, recursive_scratch, recursive_worktree, recursive_phase, recursive_audit_team, recursive_review, recursive_delegate, recursive_ask, recursive_preview)",
4
- "version": "0.4.5",
4
+ "version": "0.4.6",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "types": "lib/index.d.ts",
package/src/errors.ts CHANGED
@@ -65,12 +65,30 @@ export const TOOL_ERRORS = {
65
65
  problem: 'this gate has no default artifact, so one must be named',
66
66
  next: 'pass artifact: <file> so the answer has somewhere durable to land',
67
67
  },
68
+ RELAY_ONLY_FOR_RUN_START: {
69
+ code: 'RM1150',
70
+ klass: 'input',
71
+ problem: 'relay applies only to the run-start gate, which is the only gate that asks the user-questions channel',
72
+ next: 'drop relay for this gate and answer it with one of its own labels, or call recursive_ask with gate: run-start when the decision is whether to start the run',
73
+ },
68
74
  MISSING_RUN_ID: {
69
75
  code: 'RM1101',
70
76
  klass: 'input',
71
77
  problem: 'runId is required',
72
78
  next: 'call recursive_status with no runId to see the latest run id in this workspace',
73
79
  },
80
+ /**
81
+ * A run id is the NAME of the run directory and is joined onto the run layer
82
+ * as one path segment, so a path-shaped id is refused before any directory is
83
+ * created. See `run-id.ts` for the rule and for why it is not the runtime that
84
+ * learns to accept a path.
85
+ */
86
+ BAD_RUN_ID: {
87
+ code: 'RM1107',
88
+ klass: 'input',
89
+ problem: 'runId is not a single directory name',
90
+ next: 'pass the run directory name such as 03-something, then call recursive_init again',
91
+ },
74
92
  MISSING_ARTIFACT: {
75
93
  code: 'RM1102',
76
94
  klass: 'input',
@@ -153,11 +171,26 @@ export const TOOL_ERRORS = {
153
171
  problem: 'the run-start gate needs an answer, and this composition mounts no user-questions channel to ask one directly',
154
172
  next: 'call recursive_ask with gate: run-start and no answer to surface the question, then retry with answer: ' + '"Start run"',
155
173
  },
174
+ /**
175
+ * ⚠ RM5503 USED TO LIE. Its text asserted "so no person was asked" for EVERY cause, because the caller
176
+ * that produced it had already thrown the cause away — and a live session showed the cost: the gate
177
+ * failed 22.9 s into the call with a reason nobody could see, and its `Next:` clause prescribed the very
178
+ * call that had just failed, so no route to start a run remained. The problem statement now claims only
179
+ * what the gate knows (no decision came back), and the cause travels in the `detail` the caller
180
+ * supplies. `RUN_START_ANSWER_UNUSABLE` carries the one case this entry must NOT cover: a person was
181
+ * reached and their answer was not an approval.
182
+ */
156
183
  RUN_START_UNANSWERED: {
157
184
  code: 'RM5503',
158
185
  klass: 'runtime',
159
- problem: 'the user-questions channel mounted in this composition refused the run-start question, so no person was asked',
160
- next: 'use recursive_ask without an answer to surface the question, and retry it with answer: ' + '"Start run" once the user has approved the run start',
186
+ problem: 'the run-start question reached no decision: the mounted user-questions channel failed before a person answered it',
187
+ next: 'read the cause named in the detail, fix it and call recursive_ask again, or - when this composition cannot deliver the question at all - call recursive_ask with answer: ' + '"Start run"' + ' and relay=true to record the person\'s explicit approval as a relayed one',
188
+ },
189
+ RUN_START_ANSWER_UNUSABLE: {
190
+ code: 'RM5504',
191
+ klass: 'runtime',
192
+ problem: 'a person was asked to start this run and their answer was not one of the labels the run-start gate offered',
193
+ next: 'call recursive_ask again and have the person choose exactly "Start run" or "Hold"; a skipped or custom answer is not an approval, and no relayed answer can replace a decision the person made',
161
194
  },
162
195
  RUNTIME_REFUSED: {
163
196
  code: 'RM5501',