@try-works/dsh-recursive-mode 0.4.4 → 0.4.5

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,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,33 @@ 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
+ * ⚠ 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`.
143
+ */
144
+ export declare function recordRunStartAnswer(recursive: RecursiveRuntime, root: string, runId: string, answer: string | undefined, exec: {
145
+ agent?: unknown;
146
+ signal?: unknown;
147
+ callId?: unknown;
148
+ }): Promise<Record<string, unknown>>;
@@ -1,2 +1,17 @@
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
+ */
2
17
  export declare function createRecursiveInitTool(recursive: RecursiveRuntime): import("@deepseek-ai/dsh-tools").ToolDefinition;
@@ -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
- /** T10: the native jobs registry, when the composition mounts one. */
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 readonly goalsService;
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
- ok: true;
232
- phase: import("./goals-projection.ts").GoalPhase;
233
- ref?: import("./goals-projection.ts").GoalRefLike;
234
- created?: boolean;
235
- } | {
236
- ok: boolean;
237
- reason: string;
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
- ok: true;
251
- phase: import("./goals-projection.ts").GoalPhase;
252
- ref?: import("./goals-projection.ts").GoalRefLike;
253
- created?: boolean;
254
- } | {
255
- ok: boolean;
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",
4
+ "version": "0.4.5",
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
- const goalSync = syncRunGoal(goalService, {}, '10-smoke', 'active')
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')
package/src/errors.ts CHANGED
@@ -147,6 +147,18 @@ export const TOOL_ERRORS = {
147
147
 
148
148
  /* 5xxx — the runtime refused an operation it understands. */
149
149
 
150
+ RUN_START_NO_CHANNEL: {
151
+ code: 'RM5502',
152
+ klass: 'runtime',
153
+ problem: 'the run-start gate needs an answer, and this composition mounts no user-questions channel to ask one directly',
154
+ next: 'call recursive_ask with gate: run-start and no answer to surface the question, then retry with answer: ' + '"Start run"',
155
+ },
156
+ RUN_START_UNANSWERED: {
157
+ code: 'RM5503',
158
+ 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',
161
+ },
150
162
  RUNTIME_REFUSED: {
151
163
  code: 'RM5501',
152
164
  klass: 'runtime',
@@ -15,6 +15,7 @@
15
15
  * completed goal may be replaced, per the service contract).
16
16
  */
17
17
  import type { RunState } from './lifecycle.ts'
18
+ import { RUN_START_NOT_APPROVED } from './run-start.ts'
18
19
 
19
20
  /** Native goal phase (mirrors @deepseek-ai/dsh-goal GoalPhase). */
20
21
  export type GoalPhase = 'active' | 'paused' | 'blocked' | 'complete'
@@ -97,8 +98,31 @@ function mutatePhase(service: GoalServiceLike, agent: AgentHandle, ref: GoalRefL
97
98
  * Sync a run's durable goal to the requested phase. Safe: never touches a goal
98
99
  * whose objective is not this run's marker, and never re-creates over a
99
100
  * non-complete foreign goal.
101
+ *
102
+ * ⚠ `approved` IS THE PHASE-0 GATE, and it defaults to the SAFE direction. A goal is not a label:
103
+ * `create` returns an ARMED view and the harness starts driving autonomous goal rounds for the
104
+ * session, so creating one is starting the run. The owner's rule is that phase 0 requires explicit
105
+ * approval, which means the projection must be unable to arm anything on its own — hence a default of
106
+ * `false` and an explicit refusal in EVERY branch that would call `create`, including the two
107
+ * replace-a-completed-goal branches (an unapproved run cannot have reached `complete`, but "cannot
108
+ * happen" is what the single unguarded branch relied on too).
109
+ *
110
+ * ⚠ AND IT IS REACHED ON ORDINARY WORK, so the unapproved path is QUIET AND IDEMPOTENT: no goal is
111
+ * created, nothing is written, no error is thrown, and the run's artifacts are untouched. The caller
112
+ * reads {@link RUN_START_NOT_APPROVED} to tell "this run has not been started yet" apart from a real
113
+ * failure, so a normal phase step never surfaces a warning.
114
+ *
115
+ * `approved` is passed IN rather than read here because this module is pure: it takes the goal service
116
+ * seam and nothing else, and the plugin's own filesystem reads live in the runtime (see
117
+ * `RecursiveRuntime.readRunStartApproval`).
100
118
  */
101
- export function syncRunGoal(service: GoalServiceLike | undefined | null, agent: AgentHandle, runId: string, runState: RunState): SyncResult {
119
+ export function syncRunGoal(
120
+ service: GoalServiceLike | undefined | null,
121
+ agent: AgentHandle,
122
+ runId: string,
123
+ runState: RunState,
124
+ approved = false,
125
+ ): SyncResult {
102
126
  if (!service) return { ok: false, reason: 'no goals service' }
103
127
  const target = RUN_TO_GOAL_PHASE[runState]
104
128
  const current = service.get(agent)
@@ -110,6 +134,7 @@ export function syncRunGoal(service: GoalServiceLike | undefined | null, agent:
110
134
  if (phase === target) return { ok: true, phase: target, ref }
111
135
  // A completed goal is final: the contract allows it to be REPLACED, not resumed.
112
136
  if (phase === 'complete') {
137
+ if (!approved) return { ok: false, reason: RUN_START_NOT_APPROVED }
113
138
  const created = service.create(agent, { objective: goalObjective(runId, runState) })
114
139
  return { ok: true, phase: target, ref: refOf(created), created: true }
115
140
  }
@@ -121,29 +146,45 @@ export function syncRunGoal(service: GoalServiceLike | undefined | null, agent:
121
146
  // cleared or resumed instead. Never clobber a foreign goal.
122
147
  if (current) {
123
148
  if (current.phase === 'complete') {
149
+ if (!approved) return { ok: false, reason: RUN_START_NOT_APPROVED }
124
150
  const created = service.create(agent, { objective: goalObjective(runId, runState) })
125
151
  return { ok: true, phase: target, ref: refOf(created), created: true }
126
152
  }
127
153
  return { ok: false, reason: 'a non-matching active goal exists (foreign goal not touched)' }
128
154
  }
129
155
 
130
- // 3. No current goal -> create and arm.
156
+ // 3. No current goal. This is where the defect lived: scaffolding a run armed it. A run with no
157
+ // phase-0 approval stays goal-less — the spec exists, the run does not.
158
+ if (!approved) return { ok: false, reason: RUN_START_NOT_APPROVED }
131
159
  const created = service.create(agent, { objective: goalObjective(runId, runState) })
132
160
  return { ok: true, phase: target, ref: refOf(created), created: true }
133
161
  }
134
162
 
135
- /** Block the current run goal (used on a gate-block). Never touches a foreign goal. */
163
+ /**
164
+ * Block the current run goal (used on a gate-block). Never touches a foreign goal.
165
+ *
166
+ * ⚠ A RUN THAT WAS NEVER STARTED HAS NO GOAL TO BLOCK, so this reports the unapproved state in the
167
+ * same words as {@link syncRunGoal} rather than "no current goal to block": the caller's question is
168
+ * "why is there no goal", and the answer must not depend on which entry point happened to ask.
169
+ */
136
170
  export function blockRunGoal(service: GoalServiceLike | undefined | null, agent: AgentHandle, runId: string, reason: { code: string; message: string }): SyncResult {
137
171
  if (!service) return { ok: false, reason: 'no goals service' }
138
172
  const current = service.get(agent)
139
- if (!current) return { ok: false, reason: 'no current goal to block' }
173
+ if (!current) return { ok: false, reason: RUN_START_NOT_APPROVED }
140
174
  if (!isRunGoal(current, runId)) return { ok: false, reason: 'current goal is not for this run (foreign goal not touched)' }
141
175
  const ref = refOf(current)
142
176
  const ok = !!service.block(agent, ref, reason)
143
177
  return ok ? { ok: true, phase: 'blocked', ref } : { ok: false, reason: 'goal block failed' }
144
178
  }
145
179
 
146
- /** Bridge a run's blocked goal back to active (used on a reopen). */
147
- export function resumeRunGoal(service: GoalServiceLike | undefined | null, agent: AgentHandle, runId: string): SyncResult {
148
- return syncRunGoal(service, agent, runId, 'active')
180
+ /**
181
+ * Bridge a run's blocked goal back to active (used on a reopen).
182
+ *
183
+ * ⚠ REOPEN IS NOT A BACK DOOR TO STARTING A RUN. It routes through {@link syncRunGoal}, so a reopen of
184
+ * an unapproved run cannot create the goal that init deliberately withheld. An APPROVED run is
185
+ * unaffected: its approval outlives the reopen, because the approval is a durable line in the run's
186
+ * own Phase 0 artifact rather than a value held in memory (verified in `tests/run-start-approval.spec.ts`).
187
+ */
188
+ export function resumeRunGoal(service: GoalServiceLike | undefined | null, agent: AgentHandle, runId: string, approved = false): SyncResult {
189
+ return syncRunGoal(service, agent, runId, 'active', approved)
149
190
  }
package/src/index.ts CHANGED
@@ -4,6 +4,7 @@ import type { ContextFormed } from '@deepseek-ai/dsh-llm'
4
4
  import { existsSync } from 'node:fs'
5
5
  import { join } from 'node:path'
6
6
  import { RecursiveRuntime } from './runtime.ts'
7
+ import type { UserQuestionsLike } from './runtime.ts'
7
8
  import type { JobsRegistryLike } from './jobs-runner.ts'
8
9
  import { planGateForExit } from './plan-gate.ts'
9
10
  import { registerPhaseSkills, type SkillRegistryLike } from './skills-phase.ts'
@@ -237,6 +238,14 @@ export function apply(ctx: Context, config?: RecursiveModeConfig) {
237
238
  ctx.inject(['llm'], (llmCtx: Context) => {
238
239
  recursive.attachLlmInventory((llmCtx.get('llm') as LlmInventoryLike | undefined) ?? null)
239
240
  })
241
+ // ⚠ PHASE 0 — THE HUMAN-QUESTION CHANNEL, resolved the same late-attaching way for the same reason:
242
+ // a composition may mount `userQuestions` after this plugin applies, and the run-start gate asks the
243
+ // person DIRECTLY through it before it arms a goal — which is what keeps a relayed `Start run` from
244
+ // being an approval while a person can actually be asked (see run-start.ts).
245
+ recursive.attachUserQuestions((ctx.get('userQuestions') as UserQuestionsLike | undefined) ?? null)
246
+ ctx.inject(['userQuestions'], (questionsCtx: Context) => {
247
+ recursive.attachUserQuestions((questionsCtx.get('userQuestions') as UserQuestionsLike | undefined) ?? null)
248
+ })
240
249
 
241
250
  // T7 — THE SETTINGS NAMESPACE, APPLIED ON EVERY APPLY. The settings service edits the
242
251
  // Loader entry's config and the Loader RE-APPLIES this plugin, so a toggle in the UI