@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.
package/lib/index.js CHANGED
@@ -5462,6 +5462,18 @@ const TOOL_ERRORS = {
5462
5462
  problem: "the run has unresolved delegated work, so this phase cannot lock yet",
5463
5463
  next: "call recursive_status to see the pending delegation, have the child write its reply.md, then lock again"
5464
5464
  },
5465
+ RUN_START_NO_CHANNEL: {
5466
+ code: "RM5502",
5467
+ klass: "runtime",
5468
+ problem: "the run-start gate needs an answer, and this composition mounts no user-questions channel to ask one directly",
5469
+ next: "call recursive_ask with gate: run-start and no answer to surface the question, then retry with answer: \"Start run\""
5470
+ },
5471
+ RUN_START_UNANSWERED: {
5472
+ code: "RM5503",
5473
+ klass: "runtime",
5474
+ problem: "the user-questions channel mounted in this composition refused the run-start question, so no person was asked",
5475
+ 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"
5476
+ },
5465
5477
  RUNTIME_REFUSED: {
5466
5478
  code: "RM5501",
5467
5479
  klass: "runtime",
@@ -6819,6 +6831,123 @@ function extractAndGroup(runner, env, options = {}) {
6819
6831
  };
6820
6832
  }
6821
6833
  //#endregion
6834
+ //#region src/run-start.ts
6835
+ /**
6836
+ * PHASE 0 — STARTING A RUN IS A HUMAN DECISION, NOT A SIDE EFFECT OF SCAFFOLDING.
6837
+ *
6838
+ * THE DEFECT THIS CLOSES. `recursive_init` scaffolded a run and the plugin then CREATED AND ARMED a
6839
+ * goal for it in the same breath (`syncRunGoal`'s "no current goal -> create and arm" branch). A goal
6840
+ * is not a label: `goals.create` returns an ARMED view, and the harness immediately begins driving
6841
+ * autonomous goal rounds for the session. So asking for a run spec was enough to start an unattended
6842
+ * run — the owner's rule is the opposite: *"creating a spec before a run exists should not create a
6843
+ * goal. Phase 0 requires explicit approval to start a run and goal."*
6844
+ *
6845
+ * WHAT "APPROVAL" IS, EXACTLY. The approving label of the `run-start` gate of `recursive_ask`
6846
+ * (`Start run`, as opposed to `Hold`), recorded here as a durable `- Run Start: Start run` line in the
6847
+ * run's Phase 0 requirements artifact. Three things make that an explicit human act rather than an
6848
+ * inference:
6849
+ *
6850
+ * 1. NO DEFAULT, AND THE VALUE IS THE DECISION. The line is written by the gate itself into the Phase 0
6851
+ * requirements document, and the gate REFUSES an answer that is not one of the labels it offered.
6852
+ * An unoffered answer is a transcription error wearing the shape of a decision, and a `Hold` is not
6853
+ * an approval in any spelling — see {@link readRunStartApproval}, which matches the approving VALUE
6854
+ * and nothing else, so the presence of a `Run Start` line is never on its own consent.
6855
+ * 2. IT IS ASKED, NOT ASSUMED. When the composition mounts `ctx.userQuestions` — the harness's own
6856
+ * blocking human channel, the same one plan-mode's exit uses — the question is PUT TO THE PERSON and
6857
+ * only their selection is recorded; a caller-supplied answer cannot stand in for it, and a channel
6858
+ * that cannot reach anyone ends the call without a decision (RM5503). Only a composition with no
6859
+ * channel at all falls back to the relayed answer, which is the contract the other three gates have.
6860
+ * 3. THE GOAL CANNOT BE CREATED WITHOUT IT. `syncRunGoal` refuses to create a goal for a run whose
6861
+ * approval record is absent, in EVERY branch that would create one — not only the "no goal yet"
6862
+ * branch. That is the property `tests/run-start-approval.spec.ts` asserts, because a single
6863
+ * unguarded branch is exactly how this defect existed in the first place.
6864
+ *
6865
+ * A SPEC MAY EXIST BEFORE A RUN EXISTS, and this module does not forbid that: the scaffold, the Phase
6866
+ * 0 artifacts and every later phase document are all created by `recursive_init` as before. What is
6867
+ * withheld is the GOAL — the object that makes the harness drive rounds. A run that is scaffolded and
6868
+ * never approved is a spec: readable, editable, lockable, and inert.
6869
+ */
6870
+ /** The gate id `recursive_ask` answers for a run start. Deliberately NOT in ASK_GATE_IDS. */
6871
+ const RUN_START_GATE_ID = "run-start";
6872
+ /** The Phase 0 artifact the approval is recorded in. */
6873
+ const RUN_START_ARTIFACT = "00-requirements.md";
6874
+ /** The artifact field the approval reads back from. */
6875
+ const RUN_START_MARKER = "Run Start";
6876
+ /** The approving label. The ONLY label that starts a run. */
6877
+ const RUN_START_APPROVE = "Start run";
6878
+ /**
6879
+ * WHY THIS GATE IS NOT IN `ASK_GATE_IDS`. Those three are the WORKFLOW's gates — phase-3 test
6880
+ * evidence, phase-5 sign-off, resolving a gate block — and their membership is asserted as exactly
6881
+ * three. Starting a run is a different kind of decision: it is the one that decides whether there is
6882
+ * a run at all. It lives here, with its own contract, so widening the workflow's gate list cannot
6883
+ * quietly widen what may start a run.
6884
+ */
6885
+ const RUN_START_GATE = {
6886
+ id: RUN_START_GATE_ID,
6887
+ header: "Start run",
6888
+ question: "Approve phase 0 and start this run? Approving creates an armed goal the harness will keep driving.",
6889
+ options: [{
6890
+ label: RUN_START_APPROVE,
6891
+ description: "Record the approval and arm the run goal."
6892
+ }, {
6893
+ label: "Hold",
6894
+ description: "Leave the spec inert: no run goal, no autonomous rounds."
6895
+ }],
6896
+ marker: RUN_START_MARKER
6897
+ };
6898
+ /** Is a `run-start` answer the approving one? */
6899
+ function isRunStartApproval(answer) {
6900
+ return answer.trim() === RUN_START_APPROVE;
6901
+ }
6902
+ /** Where the Phase 0 requirements artifact lives for a run rooted at `root`. */
6903
+ function runStartArtifactPath(root, runId) {
6904
+ return join(root, ".recursive", "run", runId, RUN_START_ARTIFACT);
6905
+ }
6906
+ /** The artifact text, or null when the file is absent (a read failure is not an approval). */
6907
+ function readRunStartArtifact(root, runId) {
6908
+ try {
6909
+ return readFileSync(runStartArtifactPath(root, runId), "utf8");
6910
+ } catch {
6911
+ return null;
6912
+ }
6913
+ }
6914
+ /**
6915
+ * The approval state of a run, read from its Phase 0 artifact.
6916
+ *
6917
+ * ⚠ MATCHED ON THE VALUE, NOT ON THE LINE'S PRESENCE. `getMdFieldValue` returns the field's VALUE, so
6918
+ * a recorded `- Run Start: Hold` is refused here — a check for "is there a Run Start line?" would read
6919
+ * a refusal as consent, which is the one mistake this whole module exists to prevent.
6920
+ */
6921
+ function readRunStartApproval(root, runId) {
6922
+ const content = readRunStartArtifact(root, runId);
6923
+ if (content === null) return {
6924
+ approved: false,
6925
+ artifact: RUN_START_ARTIFACT,
6926
+ reason: "the Phase 0 requirements artifact does not exist yet"
6927
+ };
6928
+ const value = getMdFieldValue(content, RUN_START_MARKER);
6929
+ if (value === null) return {
6930
+ approved: false,
6931
+ artifact: RUN_START_ARTIFACT,
6932
+ reason: "no Run Start decision has been recorded"
6933
+ };
6934
+ if (!isRunStartApproval(value)) return {
6935
+ approved: false,
6936
+ artifact: RUN_START_ARTIFACT,
6937
+ reason: "Run Start is " + JSON.stringify(value) + ", which does not start a run"
6938
+ };
6939
+ return {
6940
+ approved: true,
6941
+ artifact: RUN_START_ARTIFACT,
6942
+ reason: ""
6943
+ };
6944
+ }
6945
+ /**
6946
+ * The ONE refusal reason the projection returns before approval, exported so every caller branches on
6947
+ * the same string instead of re-typing it (a re-typed reason is a caller that silently stops matching).
6948
+ */
6949
+ const RUN_START_NOT_APPROVED = "run not started: phase 0 approval has not been granted";
6950
+ //#endregion
6822
6951
  //#region src/recursive_ask.tool.ts
6823
6952
  /**
6824
6953
  * T23 — `recursive_ask`: the three human gates as STRUCTURED decisions, not prose.
@@ -6846,6 +6975,14 @@ const ASK_GATE_IDS = [
6846
6975
  "qa-signoff",
6847
6976
  "gate-block"
6848
6977
  ];
6978
+ /** Is this gate id the run-start gate? */
6979
+ function isRunStartGate(gateId) {
6980
+ return gateId === RUN_START_GATE_ID;
6981
+ }
6982
+ /** Every gate id `recursive_ask` accepts, workflow gates first. */
6983
+ function askGateIds() {
6984
+ return [...ASK_GATE_IDS, RUN_START_GATE_ID];
6985
+ }
6849
6986
  const ASK_GATES = {
6850
6987
  "tdd-mode": {
6851
6988
  id: "tdd-mode",
@@ -6939,6 +7076,36 @@ function buildAskQuestion(gateId) {
6939
7076
  });
6940
7077
  }
6941
7078
  /**
7079
+ * PHASE 0 — build the question for ANY accepted gate, including the run-start gate.
7080
+ *
7081
+ * A separate entry point rather than a widened `buildAskQuestion` so the three workflow gates keep the
7082
+ * exact signature and behaviour their callers (and `runtime.phaseRules`) already rely on.
7083
+ */
7084
+ function buildAskQuestionFor(gateId) {
7085
+ if (isRunStartGate(gateId)) return validateAskQuestion({
7086
+ id: RUN_START_GATE.id,
7087
+ header: RUN_START_GATE.header,
7088
+ question: RUN_START_GATE.question,
7089
+ options: RUN_START_GATE.options.map((option) => ({ ...option }))
7090
+ });
7091
+ return buildAskQuestion(gateId);
7092
+ }
7093
+ /**
7094
+ * PHASE 0 — validate an answer to ANY accepted gate.
7095
+ *
7096
+ * The run-start gate accepts only the labels IT offered, exactly like the other three, and the check is
7097
+ * the same `ASK_GATES`-shaped test against its own options. An answer of `maybe` is refused rather than
7098
+ * recorded, because a recorded non-answer is the failure mode this whole change exists to prevent.
7099
+ */
7100
+ function validateAskAnswerFor(gateId, answer) {
7101
+ if (isRunStartGate(gateId)) {
7102
+ const offered = RUN_START_GATE.options.map((option) => option.label);
7103
+ if (!offered.includes(answer)) throw new AskValidationError("answer", "must be one of " + offered.join(" | ") + " (got " + JSON.stringify(answer) + ")");
7104
+ return answer;
7105
+ }
7106
+ return validateAskAnswer(gateId, answer);
7107
+ }
7108
+ /**
6942
7109
  * Validate an answer against its gate.
6943
7110
  *
6944
7111
  * An answer that is not one of the offered labels is REFUSED rather than recorded: a marker saying
@@ -6987,15 +7154,19 @@ function pendingGateFor(artifactFile, artifactText) {
6987
7154
  * ⚠ THE WRITE-BACK IS A MARKER LINE, REPLACED IN PLACE when the artifact already carries one. A
6988
7155
  * second `TDD Mode:` line would leave two answers to one question and make "what was decided?"
6989
7156
  * depend on which a reader found first.
7157
+ *
7158
+ * ⚠ PHASE 0 — `run-start` IS RECORDED BY THE PLUGIN, NEVER BY A BARE MARKER WRITE. See
7159
+ * `recordRunStartAnswer`: it is the only path that can arm a run goal, it prefers the blocking human
7160
+ * channel, and it fails closed when no person can be reached.
6990
7161
  */
6991
7162
  function createRecursiveAskTool(recursive) {
6992
7163
  return defineTool({
6993
7164
  name: "recursive_ask",
6994
- description: "Ask one of the three human gates as a structured decision (tdd-mode, qa-signoff, gate-block), or record the answer. Call without `answer` to ask; call with it to write the answer into the artifact as a durable marker. One ask per step.",
7165
+ description: "Ask a human gate as a structured decision (tdd-mode, qa-signoff, gate-block), or ASK TO START A RUN (run-start: nothing runs, and no goal exists, until this gate is approved). Call without `answer` to ask; call with it to write the answer into the artifact as a durable marker. One ask per step.",
6995
7166
  parameters: {
6996
7167
  gate: {
6997
7168
  type: "string",
6998
- description: "tdd-mode | qa-signoff | gate-block. Required."
7169
+ description: "tdd-mode | qa-signoff | gate-block | run-start. Required. `run-start` is phase 0: approving it records the approval and arms the run goal, which is what makes the harness drive rounds."
6999
7170
  },
7000
7171
  runId: {
7001
7172
  type: "string",
@@ -7003,11 +7174,11 @@ function createRecursiveAskTool(recursive) {
7003
7174
  },
7004
7175
  artifact: {
7005
7176
  type: "string",
7006
- description: "Artifact file the answer belongs in. Optional; defaults per gate (gate-block has none, so it is required for that gate)."
7177
+ description: "Artifact file the answer belongs in. Optional; defaults per gate (gate-block has none, so it is required for that gate; run-start is always recorded in 00-requirements.md)."
7007
7178
  },
7008
7179
  answer: {
7009
7180
  type: "string",
7010
- description: "One of the gate's option labels. Omit to ASK."
7181
+ description: "One of the gate's option labels. Omit to ASK. For run-start, the labels are: " + RUN_START_GATE.options.map((option) => option.label).join(" | ") + "."
7011
7182
  }
7012
7183
  },
7013
7184
  output: {
@@ -7021,29 +7192,32 @@ function createRecursiveAskTool(recursive) {
7021
7192
  const gateId = (args.gate ?? "").trim();
7022
7193
  const runId = args.runId?.trim() ?? "";
7023
7194
  if (runId === "") return { error: toolError("MISSING_RUN_ID") };
7024
- if (!ASK_GATE_IDS.includes(gateId)) return { error: toolError("BAD_ASK_GATE", "gate must be one of " + ASK_GATE_IDS.join(" | ")) };
7195
+ if (!askGateIds().includes(gateId)) return { error: toolError("BAD_ASK_GATE", "gate must be one of " + askGateIds().join(" | ")) };
7025
7196
  const root = await recursive.resolveWorkspaceRoot(exec.agent);
7026
7197
  if (!root) return { error: toolError("NO_WORKSPACE") };
7027
- const artifact = (args.artifact ?? GATE_DEFAULT_ARTIFACT[gateId]).trim();
7198
+ const artifact = (isRunStartGate(gateId) ? RUN_START_ARTIFACT : args.artifact ?? GATE_DEFAULT_ARTIFACT[gateId]).trim();
7028
7199
  let question;
7029
7200
  try {
7030
- question = buildAskQuestion(gateId);
7201
+ question = buildAskQuestionFor(gateId);
7031
7202
  } catch (err) {
7032
7203
  return { error: toolError("BAD_ASK_GATE", err instanceof Error ? err.message : String(err)) };
7033
7204
  }
7034
- if (args.answer === void 0) return {
7205
+ const channelMounted = recursive.userQuestionsChannel !== null;
7206
+ if (args.answer === void 0 && !(isRunStartGate(gateId) && channelMounted)) return {
7035
7207
  gate: gateId,
7036
- marker: ASK_GATES[gateId].marker,
7208
+ marker: isRunStartGate(gateId) ? RUN_START_GATE.marker : ASK_GATES[gateId].marker,
7037
7209
  artifact,
7038
7210
  question
7039
7211
  };
7040
7212
  let answer;
7041
- try {
7042
- answer = validateAskAnswer(gateId, args.answer);
7213
+ if (args.answer === void 0) answer = void 0;
7214
+ else try {
7215
+ answer = validateAskAnswerFor(gateId, args.answer);
7043
7216
  } catch (err) {
7044
7217
  return { error: toolError("BAD_ASK_ANSWER", err instanceof Error ? err.message : String(err)) };
7045
7218
  }
7046
7219
  if (artifact === "") return { error: toolError("MISSING_ASK_ARTIFACT", "this gate needs an explicit artifact to record into") };
7220
+ if (isRunStartGate(gateId)) return recordRunStartAnswer(recursive, root, runId, answer, exec);
7047
7221
  const marker = answerMarker(gateId, answer);
7048
7222
  const written = recursive.recordAskAnswer(root, runId, artifact, marker);
7049
7223
  return {
@@ -7057,6 +7231,76 @@ function createRecursiveAskTool(recursive) {
7057
7231
  }
7058
7232
  });
7059
7233
  }
7234
+ /**
7235
+ * PHASE 0 — record the answer to the run-start gate, and start the run only if it says so.
7236
+ *
7237
+ * ⚠ THIS GATE NEVER ACCEPTS A RELAYED ANSWER WHILE A HUMAN CHANNEL IS MOUNTED. That is the rule that makes
7238
+ * an approval a human act rather than an inference: when `ctx.userQuestions` is present, the question is
7239
+ * PUT TO THE PERSON and nothing else can settle it — not the caller's own `answer` argument, and not a
7240
+ * fabrication, because `ask()` resolves only with a real selection. A person's decline is likewise final
7241
+ * for that call and cannot be overridden by a model that asked for `Start run` in the same breath.
7242
+ *
7243
+ * ⚠ AND WHEN NO CHANNEL IS MOUNTED, THE RELAYED ANSWER IS THE ONLY POSSIBLE SOURCE, so it is used — that
7244
+ * is the same contract the other three gates have always had, and refusing it would leave a composition
7245
+ * without the channel unable to start any run at all. The question is surfaced first by the ASK branch
7246
+ * (the card data the host renders), and the model's `answer` is that person's selection coming back.
7247
+ *
7248
+ * ⚠ WHAT THE GATE THEREFORE DOES *NOT* CLAIM, stated rather than implied: in a composition with no
7249
+ * `userQuestions` channel, a plugin cannot verify that a person was really asked, so a model could in
7250
+ * principle relay a label nobody gave. That is a property of the relay, not of this gate — and it is the
7251
+ * reason the channel is consulted in preference whenever it exists. See the header of `run-start.ts`.
7252
+ */
7253
+ async function recordRunStartAnswer(recursive, root, runId, answer, exec) {
7254
+ const channel = recursive.userQuestionsChannel;
7255
+ const fromChannel = channel ? await askRunStartDirectly(channel, exec) : null;
7256
+ if (channel !== null && fromChannel === null) return { error: toolError("RUN_START_UNANSWERED") };
7257
+ const final = fromChannel ?? answer;
7258
+ if (final === void 0) return { error: toolError("RUN_START_NO_CHANNEL") };
7259
+ let decided;
7260
+ try {
7261
+ decided = validateAskAnswerFor(RUN_START_GATE_ID, final);
7262
+ } catch (err) {
7263
+ return { error: toolError("BAD_ASK_ANSWER", err instanceof Error ? err.message : String(err)) };
7264
+ }
7265
+ const outcome = recursive.approveRunStart(root, runId, exec.agent, decided);
7266
+ return {
7267
+ gate: RUN_START_GATE_ID,
7268
+ answer: decided,
7269
+ artifact: RUN_START_ARTIFACT,
7270
+ source: fromChannel === null ? "relayed" : "user-questions",
7271
+ path: outcome.path,
7272
+ replaced: outcome.replaced,
7273
+ armed: outcome.ok && outcome.goal.ok,
7274
+ goal: outcome.goal
7275
+ };
7276
+ }
7277
+ /**
7278
+ * Ask the run-start question through the blocking channel and return the selection, or null when the
7279
+ * person was not reachable (no answerer, no live root agent, a dismissal, an abort).
7280
+ *
7281
+ * The selection is filtered to the gate's OWN labels before it is returned: a question a UI answered with
7282
+ * a free-text custom value must not become an approval just because it arrived on the right channel.
7283
+ */
7284
+ async function askRunStartDirectly(channel, exec) {
7285
+ const known = RUN_START_GATE.options.map((option) => option.label);
7286
+ try {
7287
+ const selected = (await channel.ask({
7288
+ questions: [{
7289
+ id: RUN_START_GATE.id,
7290
+ header: RUN_START_GATE.header,
7291
+ question: RUN_START_GATE.question,
7292
+ options: RUN_START_GATE.options.map((option) => ({ ...option }))
7293
+ }],
7294
+ agent: exec.agent,
7295
+ signal: exec.signal,
7296
+ wait: { callId: exec.callId }
7297
+ })).answers.find((entry) => entry.id === RUN_START_GATE.id)?.selected?.filter((label) => known.includes(label)) ?? [];
7298
+ if (selected.length !== 1) return null;
7299
+ return selected[0];
7300
+ } catch {
7301
+ return null;
7302
+ }
7303
+ }
7060
7304
  //#endregion
7061
7305
  //#region src/lifecycle.ts
7062
7306
  /**
@@ -8911,8 +9155,25 @@ function mutatePhase(service, agent, ref, target) {
8911
9155
  * Sync a run's durable goal to the requested phase. Safe: never touches a goal
8912
9156
  * whose objective is not this run's marker, and never re-creates over a
8913
9157
  * non-complete foreign goal.
9158
+ *
9159
+ * ⚠ `approved` IS THE PHASE-0 GATE, and it defaults to the SAFE direction. A goal is not a label:
9160
+ * `create` returns an ARMED view and the harness starts driving autonomous goal rounds for the
9161
+ * session, so creating one is starting the run. The owner's rule is that phase 0 requires explicit
9162
+ * approval, which means the projection must be unable to arm anything on its own — hence a default of
9163
+ * `false` and an explicit refusal in EVERY branch that would call `create`, including the two
9164
+ * replace-a-completed-goal branches (an unapproved run cannot have reached `complete`, but "cannot
9165
+ * happen" is what the single unguarded branch relied on too).
9166
+ *
9167
+ * ⚠ AND IT IS REACHED ON ORDINARY WORK, so the unapproved path is QUIET AND IDEMPOTENT: no goal is
9168
+ * created, nothing is written, no error is thrown, and the run's artifacts are untouched. The caller
9169
+ * reads {@link RUN_START_NOT_APPROVED} to tell "this run has not been started yet" apart from a real
9170
+ * failure, so a normal phase step never surfaces a warning.
9171
+ *
9172
+ * `approved` is passed IN rather than read here because this module is pure: it takes the goal service
9173
+ * seam and nothing else, and the plugin's own filesystem reads live in the runtime (see
9174
+ * `RecursiveRuntime.readRunStartApproval`).
8914
9175
  */
8915
- function syncRunGoal(service, agent, runId, runState) {
9176
+ function syncRunGoal(service, agent, runId, runState, approved = false) {
8916
9177
  if (!service) return {
8917
9178
  ok: false,
8918
9179
  reason: "no goals service"
@@ -8927,12 +9188,18 @@ function syncRunGoal(service, agent, runId, runState) {
8927
9188
  phase: target,
8928
9189
  ref
8929
9190
  };
8930
- if (phase === "complete") return {
8931
- ok: true,
8932
- phase: target,
8933
- ref: refOf(service.create(agent, { objective: goalObjective(runId, runState) })),
8934
- created: true
8935
- };
9191
+ if (phase === "complete") {
9192
+ if (!approved) return {
9193
+ ok: false,
9194
+ reason: RUN_START_NOT_APPROVED
9195
+ };
9196
+ return {
9197
+ ok: true,
9198
+ phase: target,
9199
+ ref: refOf(service.create(agent, { objective: goalObjective(runId, runState) })),
9200
+ created: true
9201
+ };
9202
+ }
8936
9203
  return mutatePhase(service, agent, ref, target) ? {
8937
9204
  ok: true,
8938
9205
  phase: target,
@@ -8943,17 +9210,27 @@ function syncRunGoal(service, agent, runId, runState) {
8943
9210
  };
8944
9211
  }
8945
9212
  if (current) {
8946
- if (current.phase === "complete") return {
8947
- ok: true,
8948
- phase: target,
8949
- ref: refOf(service.create(agent, { objective: goalObjective(runId, runState) })),
8950
- created: true
8951
- };
9213
+ if (current.phase === "complete") {
9214
+ if (!approved) return {
9215
+ ok: false,
9216
+ reason: RUN_START_NOT_APPROVED
9217
+ };
9218
+ return {
9219
+ ok: true,
9220
+ phase: target,
9221
+ ref: refOf(service.create(agent, { objective: goalObjective(runId, runState) })),
9222
+ created: true
9223
+ };
9224
+ }
8952
9225
  return {
8953
9226
  ok: false,
8954
9227
  reason: "a non-matching active goal exists (foreign goal not touched)"
8955
9228
  };
8956
9229
  }
9230
+ if (!approved) return {
9231
+ ok: false,
9232
+ reason: RUN_START_NOT_APPROVED
9233
+ };
8957
9234
  return {
8958
9235
  ok: true,
8959
9236
  phase: target,
@@ -8961,7 +9238,13 @@ function syncRunGoal(service, agent, runId, runState) {
8961
9238
  created: true
8962
9239
  };
8963
9240
  }
8964
- /** Block the current run goal (used on a gate-block). Never touches a foreign goal. */
9241
+ /**
9242
+ * Block the current run goal (used on a gate-block). Never touches a foreign goal.
9243
+ *
9244
+ * ⚠ A RUN THAT WAS NEVER STARTED HAS NO GOAL TO BLOCK, so this reports the unapproved state in the
9245
+ * same words as {@link syncRunGoal} rather than "no current goal to block": the caller's question is
9246
+ * "why is there no goal", and the answer must not depend on which entry point happened to ask.
9247
+ */
8965
9248
  function blockRunGoal(service, agent, runId, reason) {
8966
9249
  if (!service) return {
8967
9250
  ok: false,
@@ -8970,7 +9253,7 @@ function blockRunGoal(service, agent, runId, reason) {
8970
9253
  const current = service.get(agent);
8971
9254
  if (!current) return {
8972
9255
  ok: false,
8973
- reason: "no current goal to block"
9256
+ reason: RUN_START_NOT_APPROVED
8974
9257
  };
8975
9258
  if (!isRunGoal(current, runId)) return {
8976
9259
  ok: false,
@@ -8986,9 +9269,16 @@ function blockRunGoal(service, agent, runId, reason) {
8986
9269
  reason: "goal block failed"
8987
9270
  };
8988
9271
  }
8989
- /** Bridge a run's blocked goal back to active (used on a reopen). */
8990
- function resumeRunGoal(service, agent, runId) {
8991
- return syncRunGoal(service, agent, runId, "active");
9272
+ /**
9273
+ * Bridge a run's blocked goal back to active (used on a reopen).
9274
+ *
9275
+ * ⚠ REOPEN IS NOT A BACK DOOR TO STARTING A RUN. It routes through {@link syncRunGoal}, so a reopen of
9276
+ * an unapproved run cannot create the goal that init deliberately withheld. An APPROVED run is
9277
+ * unaffected: its approval outlives the reopen, because the approval is a durable line in the run's
9278
+ * own Phase 0 artifact rather than a value held in memory (verified in `tests/run-start-approval.spec.ts`).
9279
+ */
9280
+ function resumeRunGoal(service, agent, runId, approved = false) {
9281
+ return syncRunGoal(service, agent, runId, "active", approved);
8992
9282
  }
8993
9283
  //#endregion
8994
9284
  //#region src/teams-loop.ts
@@ -9598,9 +9888,23 @@ var RecursiveRuntime = class extends Service {
9598
9888
  this.subagentsSeam = config.subagents ?? null;
9599
9889
  this.workflow = config.workflow ?? null;
9600
9890
  }
9601
- /** T10: the native jobs registry, when the composition mounts one. */
9891
+ /**
9892
+ * T10: the native jobs registry, when the composition mounts one. */
9602
9893
  jobs;
9603
9894
  /**
9895
+ * PHASE 0 — attach the goals service after construction.
9896
+ *
9897
+ * The composition resolves `goals` with ONE `ctx.get` at apply time and passes it to the constructor,
9898
+ * which is fine for a service that is already mounted. This seam exists for the two cases that pattern
9899
+ * cannot cover: a composition that mounts `goals` later (the same late-attach reason `attachSubagents`
9900
+ * and `attachLlmInventory` exist), and a test that needs the REAL runtime wired to a structural fake —
9901
+ * a fake passed through the plugin's Config is dropped, because the Config schema is the settings
9902
+ * namespace and strips keys it does not declare.
9903
+ */
9904
+ attachGoals(service) {
9905
+ this.goalsService = service;
9906
+ }
9907
+ /**
9604
9908
  * T23 — write a gate's answer into an artifact as a marker line.
9605
9909
  *
9606
9910
  * ⚠ REPLACED IN PLACE when the artifact already carries that gate's marker: two `TDD Mode:` lines
@@ -9792,16 +10096,72 @@ var RecursiveRuntime = class extends Service {
9792
10096
  return report;
9793
10097
  }
9794
10098
  /**
10099
+ * PHASE 0 — read a run's start approval from its own Phase 0 artifact.
10100
+ *
10101
+ * The approval is a DURABLE line in `.recursive/run/<runId>/00-requirements.md`, not a value held in
10102
+ * memory, for the reason every other gate here is durable: a decision that only exists in a session
10103
+ * cannot be cited, and cannot survive the session it was made in. Read-only; asking changes nothing.
10104
+ */
10105
+ readRunStartApproval(root, runId) {
10106
+ return readRunStartApproval(root, runId);
10107
+ }
10108
+ /**
10109
+ * PHASE 0 — the harness's blocking human-question channel (`ctx.userQuestions`), when this composition
10110
+ * mounts one.
10111
+ *
10112
+ * ⚠ WHY THE PLUGIN REACHES FOR THIS AT ALL. The other three gates answer through a question card and a
10113
+ * relayed label, which is fine for a decision the workflow acts on later. STARTING A RUN is different:
10114
+ * the first human turn is the only place the harness can say "arming this goal means autonomous rounds"
10115
+ * BEFORE arming it. This channel is the same one plan-mode's exit uses; `ask()` resolves only with a
10116
+ * real answer from a real person, and it THROWS when there is no answerer or no live root agent. So the
10117
+ * absence of this service cannot be papered over: `recursive_ask` refuses the run-start gate and names
10118
+ * the missing channel (RM5502).
10119
+ */
10120
+ userQuestions = null;
10121
+ /** Late-bind the human-question channel when the composition mounts it. */
10122
+ attachUserQuestions(service) {
10123
+ this.userQuestions = service;
10124
+ }
10125
+ /** The human-question channel this composition mounted, or null. */
10126
+ get userQuestionsChannel() {
10127
+ return this.userQuestions;
10128
+ }
10129
+ /**
9795
10130
  * T1 (goals projection): project the run into the native goals service so it is
9796
10131
  * a first-class durable, resumable, blockable object. Best-effort — the run's
9797
10132
  * filesystem state is the source of truth; a goal is the durable projection.
10133
+ *
10134
+ * ⚠ PHASE 0 — AND IT ARMS NOTHING UNLESS THE RUN WAS STARTED. `create` returns an ARMED goal, and an
10135
+ * armed goal is the harness driving autonomous rounds, so this is the one place where "project the
10136
+ * state" can quietly equal "start the run". The approval is therefore REQUIRED from the caller and
10137
+ * has no default here: a caller that has not resolved the run's approval cannot arm a goal by
10138
+ * forgetting to pass one, and `syncRunGoal` refuses every branch that would create one without it.
10139
+ *
10140
+ * Unapproved is the EXPECTED state for a scaffolded run, so the refusal comes back as a plain
10141
+ * `{ ok: false }` carrying {@link RUN_START_NOT_APPROVED}: callers must treat that as normal work,
10142
+ * never as a warning (see `armRunGoalIfApproved`, the one caller that arms).
9798
10143
  */
9799
- projectRunToGoal(agent, runId, state = "active") {
10144
+ projectRunToGoal(agent, runId, state = "active", approved = false) {
9800
10145
  if (!agent) return {
9801
10146
  ok: false,
9802
10147
  reason: "no agent"
9803
10148
  };
9804
- return syncRunGoal(this.goalsService, agent, runId, state);
10149
+ return syncRunGoal(this.goalsService, agent, runId, state, approved);
10150
+ }
10151
+ /**
10152
+ * PHASE 0 — the approved-run arm step: read the run's approval from `root` and project the goal only
10153
+ * if it is there. This is the phase-progress path (`syncRunGoal` reached on ordinary work), so the
10154
+ * unapproved case is deliberately silent: `{ ok: false, reason: RUN_START_NOT_APPROVED }` with no
10155
+ * goal, no write and no throw. Read on EVERY call rather than cached, because the approval can arrive
10156
+ * mid-session and a cached "not yet" would leave an approved run unable to arm until a plugin reload.
10157
+ */
10158
+ armRunGoalIfApproved(agent, root, runId, state = "active") {
10159
+ if (!agent) return {
10160
+ ok: false,
10161
+ reason: "no agent"
10162
+ };
10163
+ const approval = this.readRunStartApproval(root, runId);
10164
+ return syncRunGoal(this.goalsService, agent, runId, state, approval.approved);
9805
10165
  }
9806
10166
  /** T1: block the run's goal on a gate-block (durable + UI-visible). */
9807
10167
  blockRunToGoal(agent, runId, reason) {
@@ -9811,13 +10171,18 @@ var RecursiveRuntime = class extends Service {
9811
10171
  };
9812
10172
  return blockRunGoal(this.goalsService, agent, runId, reason);
9813
10173
  }
9814
- /** T1: re-arm the run's goal on a reopen (blocked/paused -> active). */
9815
- resumeRunToGoal(agent, runId) {
10174
+ /**
10175
+ * T1: re-arm the run's goal on a reopen (blocked/paused -> active). Never starts an unstarted run —
10176
+ * REOPEN IS NOT A BACK DOOR TO STARTING A RUN. `approved` is required for the same reason as in
10177
+ * `projectRunToGoal`: the phase-0 gate cannot be defaulted open. An approved run's approval outlives
10178
+ * a reopen because it is a durable line in the run's own Phase 0 artifact, not a held value.
10179
+ */
10180
+ resumeRunToGoal(agent, runId, approved = false) {
9816
10181
  if (!agent) return {
9817
10182
  ok: false,
9818
10183
  reason: "no agent"
9819
10184
  };
9820
- return resumeRunGoal(this.goalsService, agent, runId);
10185
+ return resumeRunGoal(this.goalsService, agent, runId, approved);
9821
10186
  }
9822
10187
  /**
9823
10188
  * Workspace-scoped control-plane root (R1 binding invariant).
@@ -10467,15 +10832,61 @@ var RecursiveRuntime = class extends Service {
10467
10832
  runDir,
10468
10833
  runId,
10469
10834
  created,
10470
- existing
10835
+ existing,
10836
+ runStartApproval: {
10837
+ ...this.readRunStartApproval(scaffoldRoot, runId),
10838
+ gate: RUN_START_GATE_ID
10839
+ }
10471
10840
  };
10472
10841
  if (worktree) result.worktree = worktree;
10473
- try {
10474
- this.projectRunToGoal(agent, runId, "active");
10475
- } catch {}
10476
10842
  return result;
10477
10843
  }
10478
10844
  /**
10845
+ * PHASE 0 — THE APPROVAL ACT: record the human's `Start run` decision and arm the run's goal.
10846
+ *
10847
+ * ⚠ THE ONLY PATH THAT STARTS A RUN. It exists as one method rather than as "write a line, then
10848
+ * project the goal" at the tool, because those two steps must not be separable: an approval recorded
10849
+ * without the arm (or an arm without the record) is exactly the half-state that made this defect hard
10850
+ * to see. `tests/run-start-approval.spec.ts` drives both halves through this one call.
10851
+ *
10852
+ * The approval line goes into the run's own Phase 0 artifact, so it is durable, citable, and survives
10853
+ * the session — and so a reader of the run can answer "was this run started, and by what?" without the
10854
+ * transcript. `answer` is validated against the gate's own labels before it reaches here.
10855
+ */
10856
+ approveRunStart(root, runId, agent, answer = RUN_START_APPROVE) {
10857
+ if (root.trim() === "" || runId.trim() === "") return {
10858
+ ok: false,
10859
+ reason: "a run start needs a workspace root and a run id",
10860
+ path: "",
10861
+ replaced: false,
10862
+ goal: {
10863
+ ok: false,
10864
+ reason: "no run to start"
10865
+ }
10866
+ };
10867
+ runStartArtifactPath(root, runId);
10868
+ const written = this.recordAskAnswer(root, runId, RUN_START_ARTIFACT, "- Run Start: " + answer);
10869
+ const approval = this.readRunStartApproval(root, runId);
10870
+ if (!approval.approved) return {
10871
+ ok: false,
10872
+ reason: approval.reason,
10873
+ path: written.path,
10874
+ replaced: written.replaced,
10875
+ goal: {
10876
+ ok: false,
10877
+ reason: RUN_START_NOT_APPROVED
10878
+ }
10879
+ };
10880
+ const goal = this.armRunGoalIfApproved(agent, root, runId, "active");
10881
+ return {
10882
+ ok: true,
10883
+ reason: "",
10884
+ path: written.path,
10885
+ replaced: written.replaced,
10886
+ goal
10887
+ };
10888
+ }
10889
+ /**
10479
10890
  * Create a linked worktree for a run under the given workspace root. The
10480
10891
  * worktree branch defaults to `recursive/<runId>` and is cut from the given
10481
10892
  * base branch (default: the current HEAD branch of the root checkout).
@@ -10584,7 +10995,7 @@ var RecursiveRuntime = class extends Service {
10584
10995
  const stale = getStaleDownstreamPhases(runDir, artifact);
10585
10996
  for (const entry of stale) invalidateReceipt(runDir, entry.artifact);
10586
10997
  try {
10587
- this.resumeRunToGoal(agent, runId);
10998
+ this.resumeRunToGoal(agent, runId, this.readRunStartApproval(root, runId).approved);
10588
10999
  } catch {}
10589
11000
  return {
10590
11001
  artifact,
@@ -10937,10 +11348,25 @@ function createRecursiveStatusTool(recursive) {
10937
11348
  }
10938
11349
  //#endregion
10939
11350
  //#region src/recursive_init.tool.ts
11351
+ /**
11352
+ * PHASE 0 — SCAFFOLDING IS NOT STARTING, AND THE TOOL SAYS SO AT THE MOMENT IT MATTERS.
11353
+ *
11354
+ * A spec may legitimately exist before a run does: this tool writes the run directory and every phase
11355
+ * document, and it still does. What it must NOT do is start the run, because starting is creating and
11356
+ * arming the goal the harness drives autonomous rounds from. That is the owner's rule — *"phase 0
11357
+ * requires explicit approval to start a run and goal"* — so the description below names the gate and the
11358
+ * result carries `runStartApproval`, which is the pointer a caller needs: the run is inert until
11359
+ * `recursive_ask` answers `run-start`.
11360
+ *
11361
+ * `runStartApproval` is read from the run's own Phase 0 artifact on every call, so it is the TRUE state
11362
+ * rather than "this call created something": re-initialising an APPROVED run reports `approved: true`
11363
+ * (and the run keeps its goal), which is what a caller re-scaffolding a run it already started needs to
11364
+ * see.
11365
+ */
10940
11366
  function createRecursiveInitTool(recursive) {
10941
11367
  return defineTool({
10942
11368
  name: "recursive_init",
10943
- description: "Scaffold a new recursive-mode run directory (or ensure an existing one) with stub artifact headers. Delegates to the RecursiveRuntime service (no duplicated scaffolding logic). When createWorktree is true, a linked worktree is created first and the run is scaffolded inside it.",
11369
+ description: "Scaffold a new recursive-mode run directory (or ensure an existing one) with stub artifact headers. Delegates to the RecursiveRuntime service (no duplicated scaffolding logic). When createWorktree is true, a linked worktree is created first and the run is scaffolded inside it. THIS DOES NOT START THE RUN: no goal exists until the user approves phase 0 through recursive_ask gate=run-start, so the result carries runStartApproval — read it and ask.",
10944
11370
  parameters: {
10945
11371
  runId: {
10946
11372
  type: "string",
@@ -13687,6 +14113,10 @@ function apply(ctx, config) {
13687
14113
  ctx.inject(["llm"], (llmCtx) => {
13688
14114
  recursive.attachLlmInventory(llmCtx.get("llm") ?? null);
13689
14115
  });
14116
+ recursive.attachUserQuestions(ctx.get("userQuestions") ?? null);
14117
+ ctx.inject(["userQuestions"], (questionsCtx) => {
14118
+ recursive.attachUserQuestions(questionsCtx.get("userQuestions") ?? null);
14119
+ });
13690
14120
  if (config?.enforcement !== void 0) recursive.setEnforcementConfig(config.enforcement);
13691
14121
  const phaseSkills = registerPhaseSkills(ctx.get("skills"), PHASE_SEQUENCE);
13692
14122
  yield () => {