@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,7 +1,24 @@
|
|
|
1
1
|
import type { RecursiveRuntime } from './runtime.ts';
|
|
2
|
+
import { RUN_START_GATE_ID } from './run-start.ts';
|
|
2
3
|
/** The identifiers the workflow uses for its three human gates. */
|
|
3
4
|
export declare const ASK_GATE_IDS: readonly ["tdd-mode", "qa-signoff", "gate-block"];
|
|
4
5
|
export type AskGateId = (typeof ASK_GATE_IDS)[number];
|
|
6
|
+
/**
|
|
7
|
+
* PHASE 0 — THE FOURTH GATE, AND WHY IT IS NOT IN `ASK_GATE_IDS`.
|
|
8
|
+
*
|
|
9
|
+
* The three above are the WORKFLOW's gates, and their membership is asserted as exactly those three.
|
|
10
|
+
* Starting a run is a different kind of decision — it decides whether there is a run at all, and
|
|
11
|
+
* approving it ARMS A GOAL the harness will keep driving — so its data lives in `run-start.ts` with its
|
|
12
|
+
* own contract and its own options. Widening the workflow's gate list must not silently widen what may
|
|
13
|
+
* start a run.
|
|
14
|
+
*/
|
|
15
|
+
export type AskAnyGateId = AskGateId | typeof RUN_START_GATE_ID;
|
|
16
|
+
/** Is this gate id the run-start gate? */
|
|
17
|
+
export declare function isRunStartGate(gateId: string): boolean;
|
|
18
|
+
/** Every gate id `recursive_ask` accepts, workflow gates first. */
|
|
19
|
+
export declare function askGateIds(): string[];
|
|
20
|
+
/** The artifact a gate's answer belongs in — the run-start gate's is fixed to the Phase 0 requirements. */
|
|
21
|
+
export declare function askGateArtifact(gateId: AskAnyGateId): string;
|
|
5
22
|
/** The plugin's own documented bounds — see the module comment on why these are not a claimed mirror. */
|
|
6
23
|
export declare const MAX_HEADER_CHARS = 12;
|
|
7
24
|
export declare const MAX_LABEL_CHARS = 30;
|
|
@@ -41,6 +58,21 @@ export declare class AskValidationError extends Error {
|
|
|
41
58
|
export declare function validateAskQuestion(question: AskQuestion): AskQuestion;
|
|
42
59
|
/** Build the question for a gate, validated. */
|
|
43
60
|
export declare function buildAskQuestion(gateId: AskGateId): AskQuestion;
|
|
61
|
+
/**
|
|
62
|
+
* PHASE 0 — build the question for ANY accepted gate, including the run-start gate.
|
|
63
|
+
*
|
|
64
|
+
* A separate entry point rather than a widened `buildAskQuestion` so the three workflow gates keep the
|
|
65
|
+
* exact signature and behaviour their callers (and `runtime.phaseRules`) already rely on.
|
|
66
|
+
*/
|
|
67
|
+
export declare function buildAskQuestionFor(gateId: AskAnyGateId): AskQuestion;
|
|
68
|
+
/**
|
|
69
|
+
* PHASE 0 — validate an answer to ANY accepted gate.
|
|
70
|
+
*
|
|
71
|
+
* The run-start gate accepts only the labels IT offered, exactly like the other three, and the check is
|
|
72
|
+
* the same `ASK_GATES`-shaped test against its own options. An answer of `maybe` is refused rather than
|
|
73
|
+
* recorded, because a recorded non-answer is the failure mode this whole change exists to prevent.
|
|
74
|
+
*/
|
|
75
|
+
export declare function validateAskAnswerFor(gateId: AskAnyGateId, answer: string): string;
|
|
44
76
|
/**
|
|
45
77
|
* Validate an answer against its gate.
|
|
46
78
|
*
|
|
@@ -84,5 +116,110 @@ export declare function pendingGateFor(artifactFile: string, artifactText: strin
|
|
|
84
116
|
* ⚠ THE WRITE-BACK IS A MARKER LINE, REPLACED IN PLACE when the artifact already carries one. A
|
|
85
117
|
* second `TDD Mode:` line would leave two answers to one question and make "what was decided?"
|
|
86
118
|
* depend on which a reader found first.
|
|
119
|
+
*
|
|
120
|
+
* ⚠ PHASE 0 — `run-start` IS RECORDED BY THE PLUGIN, NEVER BY A BARE MARKER WRITE. See
|
|
121
|
+
* `recordRunStartAnswer`: it is the only path that can arm a run goal, it prefers the blocking human
|
|
122
|
+
* channel, and it fails closed when no person can be reached.
|
|
87
123
|
*/
|
|
88
124
|
export declare function createRecursiveAskTool(recursive: RecursiveRuntime): import("@deepseek-ai/dsh-tools").ToolDefinition;
|
|
125
|
+
/**
|
|
126
|
+
* PHASE 0 — record the answer to the run-start gate, and start the run only if it says so.
|
|
127
|
+
*
|
|
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.
|
|
157
|
+
*/
|
|
158
|
+
export declare function recordRunStartAnswer(recursive: RecursiveRuntime, root: string, runId: string, answer: string | undefined, exec: {
|
|
159
|
+
agent?: unknown;
|
|
160
|
+
signal?: unknown;
|
|
161
|
+
callId?: 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;
|
|
@@ -1,2 +1,25 @@
|
|
|
1
1
|
import type { RecursiveRuntime } from './runtime.ts';
|
|
2
|
+
/**
|
|
3
|
+
* PHASE 0 — SCAFFOLDING IS NOT STARTING, AND THE TOOL SAYS SO AT THE MOMENT IT MATTERS.
|
|
4
|
+
*
|
|
5
|
+
* A spec may legitimately exist before a run does: this tool writes the run directory and every phase
|
|
6
|
+
* document, and it still does. What it must NOT do is start the run, because starting is creating and
|
|
7
|
+
* arming the goal the harness drives autonomous rounds from. That is the owner's rule — *"phase 0
|
|
8
|
+
* requires explicit approval to start a run and goal"* — so the description below names the gate and the
|
|
9
|
+
* result carries `runStartApproval`, which is the pointer a caller needs: the run is inert until
|
|
10
|
+
* `recursive_ask` answers `run-start`.
|
|
11
|
+
*
|
|
12
|
+
* `runStartApproval` is read from the run's own Phase 0 artifact on every call, so it is the TRUE state
|
|
13
|
+
* rather than "this call created something": re-initialising an APPROVED run reports `approved: true`
|
|
14
|
+
* (and the run keeps its goal), which is what a caller re-scaffolding a run it already started needs to
|
|
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.
|
|
24
|
+
*/
|
|
2
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;
|
package/lib/run-id.d.ts
ADDED
|
@@ -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;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/** The gate id `recursive_ask` answers for a run start. Deliberately NOT in ASK_GATE_IDS. */
|
|
2
|
+
export declare const RUN_START_GATE_ID = "run-start";
|
|
3
|
+
/** The Phase 0 artifact the approval is recorded in. */
|
|
4
|
+
export declare const RUN_START_ARTIFACT = "00-requirements.md";
|
|
5
|
+
/** The artifact field the approval reads back from. */
|
|
6
|
+
export declare const RUN_START_MARKER = "Run Start";
|
|
7
|
+
/** The approving label. The ONLY label that starts a run. */
|
|
8
|
+
export declare const RUN_START_APPROVE = "Start run";
|
|
9
|
+
/** The withholding label: the spec stays a spec. */
|
|
10
|
+
export declare const RUN_START_HOLD = "Hold";
|
|
11
|
+
/**
|
|
12
|
+
* WHY THIS GATE IS NOT IN `ASK_GATE_IDS`. Those three are the WORKFLOW's gates — phase-3 test
|
|
13
|
+
* evidence, phase-5 sign-off, resolving a gate block — and their membership is asserted as exactly
|
|
14
|
+
* three. Starting a run is a different kind of decision: it is the one that decides whether there is
|
|
15
|
+
* a run at all. It lives here, with its own contract, so widening the workflow's gate list cannot
|
|
16
|
+
* quietly widen what may start a run.
|
|
17
|
+
*/
|
|
18
|
+
export declare const RUN_START_GATE: {
|
|
19
|
+
readonly id: "run-start";
|
|
20
|
+
readonly header: "Start run";
|
|
21
|
+
readonly question: "Approve phase 0 and start this run? Approving creates an armed goal the harness will keep driving.";
|
|
22
|
+
readonly options: readonly [{
|
|
23
|
+
readonly label: "Start run";
|
|
24
|
+
readonly description: "Record the approval and arm the run goal.";
|
|
25
|
+
}, {
|
|
26
|
+
readonly label: "Hold";
|
|
27
|
+
readonly description: "Leave the spec inert: no run goal, no autonomous rounds.";
|
|
28
|
+
}];
|
|
29
|
+
readonly marker: "Run Start";
|
|
30
|
+
};
|
|
31
|
+
/** The durable line an approval writes. */
|
|
32
|
+
export declare function runStartApprovalLine(): string;
|
|
33
|
+
/** Is a `run-start` answer the approving one? */
|
|
34
|
+
export declare function isRunStartApproval(answer: string): boolean;
|
|
35
|
+
/** Where the Phase 0 requirements artifact lives for a run rooted at `root`. */
|
|
36
|
+
export declare function runStartArtifactPath(root: string, runId: string): string;
|
|
37
|
+
/** The artifact text, or null when the file is absent (a read failure is not an approval). */
|
|
38
|
+
export declare function readRunStartArtifact(root: string, runId: string): string | null;
|
|
39
|
+
/**
|
|
40
|
+
* The approval state of a run, read from its Phase 0 artifact.
|
|
41
|
+
*
|
|
42
|
+
* ⚠ MATCHED ON THE VALUE, NOT ON THE LINE'S PRESENCE. `getMdFieldValue` returns the field's VALUE, so
|
|
43
|
+
* a recorded `- Run Start: Hold` is refused here — a check for "is there a Run Start line?" would read
|
|
44
|
+
* a refusal as consent, which is the one mistake this whole module exists to prevent.
|
|
45
|
+
*/
|
|
46
|
+
export declare function readRunStartApproval(root: string, runId: string): {
|
|
47
|
+
approved: boolean;
|
|
48
|
+
artifact: string;
|
|
49
|
+
reason: string;
|
|
50
|
+
};
|
|
51
|
+
/**
|
|
52
|
+
* The ONE refusal reason the projection returns before approval, exported so every caller branches on
|
|
53
|
+
* the same string instead of re-typing it (a re-typed reason is a caller that silently stops matching).
|
|
54
|
+
*/
|
|
55
|
+
export declare const RUN_START_NOT_APPROVED = "run not started: phase 0 approval has not been granted";
|
package/lib/runtime.d.ts
CHANGED
|
@@ -15,7 +15,7 @@ import { type SubagentsRuntimeLike, type SubagentStartRequestLike, type Subagent
|
|
|
15
15
|
import { type RecursivePhaseState } from './lifecycle.ts';
|
|
16
16
|
import { type EnforcementConfig, type ToolGuardDecision, type ToolExecLike } from './enforcement.ts';
|
|
17
17
|
import { type CreateWorktreeResult, type PromoteBranchResult } from './worktree.ts';
|
|
18
|
-
import { syncRunGoal, type GoalServiceLike } from './goals-projection.ts';
|
|
18
|
+
import { syncRunGoal, type GoalServiceLike, type SyncResult } from './goals-projection.ts';
|
|
19
19
|
import { type TeamRuntimeLike, type AuditToPassResult, type TeamCallerHandle, type TeamTaskViewLike, type AuditRoundOutcome } from './teams-loop.ts';
|
|
20
20
|
import type { ContinuableChildId, ContinuableMessageId } from './delegation.ts';
|
|
21
21
|
declare module '@deepseek-ai/cordis' {
|
|
@@ -39,6 +39,41 @@ export interface LintArtifactResult {
|
|
|
39
39
|
warnings: string[];
|
|
40
40
|
passed: boolean;
|
|
41
41
|
}
|
|
42
|
+
/**
|
|
43
|
+
* PHASE 0 — the structural seam for the host's human-question channel (`ctx.userQuestions`).
|
|
44
|
+
*
|
|
45
|
+
* Declared here as a minimal seam for the same reason as every other harness touchpoint in this plugin:
|
|
46
|
+
* the live `UserQuestionService` satisfies it structurally, so the plugin never imports the host package,
|
|
47
|
+
* and a test can drive the run-start gate with a fake that behaves like the real one. The error case is
|
|
48
|
+
* part of the contract, not an afterthought: the real `ask()` REJECTS (NO_PROVIDER / CALLER_NOT_LIVE /
|
|
49
|
+
* ASK_ABORTED) instead of resolving with something that could be mistaken for an answer, which is what
|
|
50
|
+
* lets `recursive_ask` fail closed rather than invent an approval.
|
|
51
|
+
*/
|
|
52
|
+
export interface UserQuestionsLike {
|
|
53
|
+
ask(request: {
|
|
54
|
+
questions: Array<{
|
|
55
|
+
id: string;
|
|
56
|
+
header?: string;
|
|
57
|
+
question: string;
|
|
58
|
+
options?: Array<{
|
|
59
|
+
label: string;
|
|
60
|
+
description?: string;
|
|
61
|
+
}>;
|
|
62
|
+
}>;
|
|
63
|
+
agent?: unknown;
|
|
64
|
+
signal?: AbortSignal;
|
|
65
|
+
/** Links the card to the tool call that asked, the way plan-mode's exit does. */
|
|
66
|
+
wait?: {
|
|
67
|
+
callId?: unknown;
|
|
68
|
+
};
|
|
69
|
+
}): Promise<{
|
|
70
|
+
answers: Array<{
|
|
71
|
+
id: string;
|
|
72
|
+
selected: string[];
|
|
73
|
+
custom?: string;
|
|
74
|
+
}>;
|
|
75
|
+
}>;
|
|
76
|
+
}
|
|
42
77
|
/**
|
|
43
78
|
* T15 (G): the folded status PLUS the rolling guard-decision evidence. Declared
|
|
44
79
|
* as an intersection rather than by editing RecursiveStatusResult/foldRun — the
|
|
@@ -79,8 +114,20 @@ export declare class RecursiveRuntime extends Service {
|
|
|
79
114
|
subagents?: SubagentsRuntimeLike | null;
|
|
80
115
|
workflow?: WorkflowEngineLike | null;
|
|
81
116
|
});
|
|
82
|
-
/**
|
|
117
|
+
/**
|
|
118
|
+
* T10: the native jobs registry, when the composition mounts one. */
|
|
83
119
|
private readonly jobs;
|
|
120
|
+
/**
|
|
121
|
+
* PHASE 0 — attach the goals service after construction.
|
|
122
|
+
*
|
|
123
|
+
* The composition resolves `goals` with ONE `ctx.get` at apply time and passes it to the constructor,
|
|
124
|
+
* which is fine for a service that is already mounted. This seam exists for the two cases that pattern
|
|
125
|
+
* cannot cover: a composition that mounts `goals` later (the same late-attach reason `attachSubagents`
|
|
126
|
+
* and `attachLlmInventory` exist), and a test that needs the REAL runtime wired to a structural fake —
|
|
127
|
+
* a fake passed through the plugin's Config is dropped, because the Config schema is the settings
|
|
128
|
+
* namespace and strips keys it does not declare.
|
|
129
|
+
*/
|
|
130
|
+
attachGoals(service: GoalServiceLike | null): void;
|
|
84
131
|
/**
|
|
85
132
|
* T23 — write a gate's answer into an artifact as a marker line.
|
|
86
133
|
*
|
|
@@ -160,7 +207,7 @@ export declare class RecursiveRuntime extends Service {
|
|
|
160
207
|
knownProviderNames(): string[];
|
|
161
208
|
private readonly repoRoot;
|
|
162
209
|
private readonly workspaceRegistry;
|
|
163
|
-
private
|
|
210
|
+
private goalsService;
|
|
164
211
|
/**
|
|
165
212
|
* T27 — the hook registry, EXPOSED so a sibling plugin can participate in a run
|
|
166
213
|
* without patching this one:
|
|
@@ -216,10 +263,49 @@ export declare class RecursiveRuntime extends Service {
|
|
|
216
263
|
history?: string;
|
|
217
264
|
lock?: LockArtifactResult;
|
|
218
265
|
}>;
|
|
266
|
+
/**
|
|
267
|
+
* PHASE 0 — read a run's start approval from its own Phase 0 artifact.
|
|
268
|
+
*
|
|
269
|
+
* The approval is a DURABLE line in `.recursive/run/<runId>/00-requirements.md`, not a value held in
|
|
270
|
+
* memory, for the reason every other gate here is durable: a decision that only exists in a session
|
|
271
|
+
* cannot be cited, and cannot survive the session it was made in. Read-only; asking changes nothing.
|
|
272
|
+
*/
|
|
273
|
+
readRunStartApproval(root: string, runId: string): {
|
|
274
|
+
approved: boolean;
|
|
275
|
+
artifact: string;
|
|
276
|
+
reason: string;
|
|
277
|
+
};
|
|
278
|
+
/**
|
|
279
|
+
* PHASE 0 — the harness's blocking human-question channel (`ctx.userQuestions`), when this composition
|
|
280
|
+
* mounts one.
|
|
281
|
+
*
|
|
282
|
+
* ⚠ WHY THE PLUGIN REACHES FOR THIS AT ALL. The other three gates answer through a question card and a
|
|
283
|
+
* relayed label, which is fine for a decision the workflow acts on later. STARTING A RUN is different:
|
|
284
|
+
* the first human turn is the only place the harness can say "arming this goal means autonomous rounds"
|
|
285
|
+
* BEFORE arming it. This channel is the same one plan-mode's exit uses; `ask()` resolves only with a
|
|
286
|
+
* real answer from a real person, and it THROWS when there is no answerer or no live root agent. So the
|
|
287
|
+
* absence of this service cannot be papered over: `recursive_ask` refuses the run-start gate and names
|
|
288
|
+
* the missing channel (RM5502).
|
|
289
|
+
*/
|
|
290
|
+
private userQuestions;
|
|
291
|
+
/** Late-bind the human-question channel when the composition mounts it. */
|
|
292
|
+
attachUserQuestions(service: UserQuestionsLike | null): void;
|
|
293
|
+
/** The human-question channel this composition mounted, or null. */
|
|
294
|
+
get userQuestionsChannel(): UserQuestionsLike | null;
|
|
219
295
|
/**
|
|
220
296
|
* T1 (goals projection): project the run into the native goals service so it is
|
|
221
297
|
* a first-class durable, resumable, blockable object. Best-effort — the run's
|
|
222
298
|
* filesystem state is the source of truth; a goal is the durable projection.
|
|
299
|
+
*
|
|
300
|
+
* ⚠ PHASE 0 — AND IT ARMS NOTHING UNLESS THE RUN WAS STARTED. `create` returns an ARMED goal, and an
|
|
301
|
+
* armed goal is the harness driving autonomous rounds, so this is the one place where "project the
|
|
302
|
+
* state" can quietly equal "start the run". The approval is therefore REQUIRED from the caller and
|
|
303
|
+
* has no default here: a caller that has not resolved the run's approval cannot arm a goal by
|
|
304
|
+
* forgetting to pass one, and `syncRunGoal` refuses every branch that would create one without it.
|
|
305
|
+
*
|
|
306
|
+
* Unapproved is the EXPECTED state for a scaffolded run, so the refusal comes back as a plain
|
|
307
|
+
* `{ ok: false }` carrying {@link RUN_START_NOT_APPROVED}: callers must treat that as normal work,
|
|
308
|
+
* never as a warning (see `armRunGoalIfApproved`, the one caller that arms).
|
|
223
309
|
*/
|
|
224
310
|
projectRunToGoal(agent: {
|
|
225
311
|
session?: {
|
|
@@ -227,15 +313,21 @@ export declare class RecursiveRuntime extends Service {
|
|
|
227
313
|
cwd?: string;
|
|
228
314
|
};
|
|
229
315
|
};
|
|
230
|
-
} | null | undefined, runId: string, state?: Parameters<typeof syncRunGoal>[3]):
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
316
|
+
} | null | undefined, runId: string, state?: Parameters<typeof syncRunGoal>[3], approved?: boolean): SyncResult;
|
|
317
|
+
/**
|
|
318
|
+
* PHASE 0 — the approved-run arm step: read the run's approval from `root` and project the goal only
|
|
319
|
+
* if it is there. This is the phase-progress path (`syncRunGoal` reached on ordinary work), so the
|
|
320
|
+
* unapproved case is deliberately silent: `{ ok: false, reason: RUN_START_NOT_APPROVED }` with no
|
|
321
|
+
* goal, no write and no throw. Read on EVERY call rather than cached, because the approval can arrive
|
|
322
|
+
* mid-session and a cached "not yet" would leave an approved run unable to arm until a plugin reload.
|
|
323
|
+
*/
|
|
324
|
+
armRunGoalIfApproved(agent: {
|
|
325
|
+
session?: {
|
|
326
|
+
header?: {
|
|
327
|
+
cwd?: string;
|
|
328
|
+
};
|
|
329
|
+
};
|
|
330
|
+
} | null | undefined, root: string, runId: string, state?: Parameters<typeof syncRunGoal>[3]): SyncResult;
|
|
239
331
|
/** T1: block the run's goal on a gate-block (durable + UI-visible). */
|
|
240
332
|
blockRunToGoal(agent: {
|
|
241
333
|
session?: {
|
|
@@ -246,31 +338,20 @@ export declare class RecursiveRuntime extends Service {
|
|
|
246
338
|
} | null | undefined, runId: string, reason: {
|
|
247
339
|
code: string;
|
|
248
340
|
message: string;
|
|
249
|
-
}):
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
reason: string;
|
|
257
|
-
};
|
|
258
|
-
/** T1: re-arm the run's goal on a reopen (blocked/paused -> active). */
|
|
341
|
+
}): SyncResult;
|
|
342
|
+
/**
|
|
343
|
+
* T1: re-arm the run's goal on a reopen (blocked/paused -> active). Never starts an unstarted run —
|
|
344
|
+
* REOPEN IS NOT A BACK DOOR TO STARTING A RUN. `approved` is required for the same reason as in
|
|
345
|
+
* `projectRunToGoal`: the phase-0 gate cannot be defaulted open. An approved run's approval outlives
|
|
346
|
+
* a reopen because it is a durable line in the run's own Phase 0 artifact, not a held value.
|
|
347
|
+
*/
|
|
259
348
|
resumeRunToGoal(agent: {
|
|
260
349
|
session?: {
|
|
261
350
|
header?: {
|
|
262
351
|
cwd?: string;
|
|
263
352
|
};
|
|
264
353
|
};
|
|
265
|
-
} | null | undefined, runId: string):
|
|
266
|
-
ok: true;
|
|
267
|
-
phase: import("./goals-projection.ts").GoalPhase;
|
|
268
|
-
ref?: import("./goals-projection.ts").GoalRefLike;
|
|
269
|
-
created?: boolean;
|
|
270
|
-
} | {
|
|
271
|
-
ok: boolean;
|
|
272
|
-
reason: string;
|
|
273
|
-
};
|
|
354
|
+
} | null | undefined, runId: string, approved?: boolean): SyncResult;
|
|
274
355
|
/**
|
|
275
356
|
* Workspace-scoped control-plane root (R1 binding invariant).
|
|
276
357
|
* Resolves the session agent's canonical cwd -> workspace path via the
|
|
@@ -571,6 +652,31 @@ export declare class RecursiveRuntime extends Service {
|
|
|
571
652
|
existing: string[];
|
|
572
653
|
worktree?: CreateWorktreeResult;
|
|
573
654
|
}>;
|
|
655
|
+
/**
|
|
656
|
+
* PHASE 0 — THE APPROVAL ACT: record the human's `Start run` decision and arm the run's goal.
|
|
657
|
+
*
|
|
658
|
+
* ⚠ THE ONLY PATH THAT STARTS A RUN. It exists as one method rather than as "write a line, then
|
|
659
|
+
* project the goal" at the tool, because those two steps must not be separable: an approval recorded
|
|
660
|
+
* without the arm (or an arm without the record) is exactly the half-state that made this defect hard
|
|
661
|
+
* to see. `tests/run-start-approval.spec.ts` drives both halves through this one call.
|
|
662
|
+
*
|
|
663
|
+
* The approval line goes into the run's own Phase 0 artifact, so it is durable, citable, and survives
|
|
664
|
+
* the session — and so a reader of the run can answer "was this run started, and by what?" without the
|
|
665
|
+
* transcript. `answer` is validated against the gate's own labels before it reaches here.
|
|
666
|
+
*/
|
|
667
|
+
approveRunStart(root: string, runId: string, agent?: {
|
|
668
|
+
session?: {
|
|
669
|
+
header?: {
|
|
670
|
+
cwd?: string;
|
|
671
|
+
};
|
|
672
|
+
};
|
|
673
|
+
} | null, answer?: string): {
|
|
674
|
+
ok: boolean;
|
|
675
|
+
reason: string;
|
|
676
|
+
path: string;
|
|
677
|
+
replaced: boolean;
|
|
678
|
+
goal: SyncResult;
|
|
679
|
+
};
|
|
574
680
|
/**
|
|
575
681
|
* Create a linked worktree for a run under the given workspace root. The
|
|
576
682
|
* worktree branch defaults to `recursive/<runId>` and is cut from the given
|
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.
|
|
4
|
+
"version": "0.4.6",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"types": "lib/index.d.ts",
|
|
@@ -185,7 +185,11 @@ async function main() {
|
|
|
185
185
|
complete: (_agent, ref) => { goalCurrent = { ...(goalCurrent ?? { id: ref.id, revision: ref.revision }), phase: 'complete', revision: ref.revision + 1 }; return goalCurrent },
|
|
186
186
|
clear: (_agent, ref) => { goalCurrent = undefined; return { id: ref.id, revision: ref.revision + 1 } },
|
|
187
187
|
}
|
|
188
|
-
|
|
188
|
+
// PHASE 0: scaffolding a run must not start it. Without an approval the projection creates NOTHING —
|
|
189
|
+
// this is the defect the gate closes, so the smoke script proves the refusal as well as the arm.
|
|
190
|
+
const unapproved = syncRunGoal(goalService, {}, '10-smoke', 'active')
|
|
191
|
+
check('PHASE 0 no goal without approval', unapproved.ok === false && goalCurrent === undefined)
|
|
192
|
+
const goalSync = syncRunGoal(goalService, {}, '10-smoke', 'active', true)
|
|
189
193
|
check('T1 run goal armed', goalSync.ok === true && goalCurrent?.objective === 'recursive-run:10-smoke · active')
|
|
190
194
|
const goalBlock = blockRunGoal(goalService, {}, '10-smoke', { code: 'prerequisite-blockers', message: 'monotonic lock-order' })
|
|
191
195
|
check('T1 gate-block blocks the run goal', goalBlock.ok === true && goalCurrent?.phase === 'blocked')
|