@try-works/dsh-recursive-mode 0.4.3 → 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
@@ -1490,14 +1490,22 @@ function lockOrderRule(artifact, runDir) {
1490
1490
  /**
1491
1491
  * Locked-artifact write rule: a denial when the target carries
1492
1492
  * `Status: LOCKED`, `null` otherwise. Only a run-tree `*.md` is a candidate —
1493
- * the same admission test the pre-T16 branch used.
1493
+ * the same admission test the pre-T16 branch used, now asked of the RESOLVED
1494
+ * path as well (see the ADMISSION note below).
1495
+ *
1496
+ * A caller with no `worktreeRoot` still gets `null` for every target, absolute
1497
+ * ones included: that is the pre-existing behaviour and it is left alone here —
1498
+ * the guard always carries a root, so nothing that reaches it changes.
1494
1499
  */
1495
1500
  function lockedWriteRule(target, worktreeRoot) {
1496
1501
  if (!target || !worktreeRoot) return null;
1497
1502
  const normalized = target.replace(/\\/g, "/");
1498
- if (!normalized.endsWith(".md") || !normalized.includes("/.recursive/run/")) return null;
1503
+ if (!normalized.endsWith(".md")) return null;
1499
1504
  const abs = resolveFrom(worktreeRoot, normalized);
1500
- if (!abs || getLockStatus(abs) !== "LOCKED") return null;
1505
+ if (!abs) return null;
1506
+ const resolved = abs.replace(/\\/g, "/");
1507
+ if (!normalized.includes("/.recursive/run/") && !resolved.includes("/.recursive/run/")) return null;
1508
+ if (getLockStatus(abs) !== "LOCKED") return null;
1501
1509
  return {
1502
1510
  verdict: "deny",
1503
1511
  detail: normalized + " carries Status: LOCKED (reopen explicitly to edit)"
@@ -2123,7 +2131,7 @@ function resolveFrom(worktreeRoot, target) {
2123
2131
  const normalized = target.replace(/\\/g, "/").trim();
2124
2132
  if (!normalized) return null;
2125
2133
  if (/^[A-Za-z]:\//.test(normalized) || normalized.startsWith("/")) return resolve(normalized);
2126
- return resolve(join(worktreeRoot, normalized.replace(/^\.?\/?/, "")));
2134
+ return resolve(join(worktreeRoot, normalized.replace(/^\.\//, "")));
2127
2135
  }
2128
2136
  /** The tool-target path of a call (same key order enforcement.ts uses). */
2129
2137
  function policyTargetPath(args) {
@@ -5454,6 +5462,18 @@ const TOOL_ERRORS = {
5454
5462
  problem: "the run has unresolved delegated work, so this phase cannot lock yet",
5455
5463
  next: "call recursive_status to see the pending delegation, have the child write its reply.md, then lock again"
5456
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
+ },
5457
5477
  RUNTIME_REFUSED: {
5458
5478
  code: "RM5501",
5459
5479
  klass: "runtime",
@@ -5993,13 +6013,41 @@ function reviewBundleDir(root, runId) {
5993
6013
  * discipline the lock receipts, the closeout receipts and the selection output all follow.
5994
6014
  */
5995
6015
  /** Where the machine-owned counters live — inside the memory plane, but never a shard a person writes. */
5996
- const FEEDBACK_FILE = "memory/.feedback.json";
6016
+ const FEEDBACK_FILE = ".recursive/memory/.feedback.json";
6017
+ /**
6018
+ * WHERE THE COUNTERS LIVED BEFORE the constant above carried its `.recursive/` prefix.
6019
+ *
6020
+ * ⚠ READ, BUT DELIBERATELY NEITHER MOVED NOR DELETED. Read, because evidence a previous run recorded is
6021
+ * not the plugin's to discard, and without the fallback the first settle after the move would start the
6022
+ * new file from an empty book — a counter lost silently, which is the one outcome ruled out. NOT moved,
6023
+ * because a delete is the single action that could re-create the very defect the prefix fixes: a legacy
6024
+ * file that is TRACKED and COMMITTED is absent from the run's diff while it is clean, so removing it
6025
+ * mid-run puts `D memory/.feedback.json` into the diff of every diff-audited phase authored before the
6026
+ * delete, which is the same retro-invalidation. Left alone, an untracked legacy file is in the diff from
6027
+ * the run's first phase (and is therefore accounted for), while a committed one stays invisible.
6028
+ * `readFeedback` prefers {@link FEEDBACK_FILE}, so the legacy counters are folded forward by the next
6029
+ * settle and this file then only sits there; it is machine-owned and disposable, so delete it by hand.
6030
+ */
6031
+ const LEGACY_FEEDBACK_FILE = "memory/.feedback.json";
5997
6032
  /** Where a run records what it was shown. */
5998
6033
  const INJECTIONS_FILE = "memory-injections.json";
5999
- /** Read the counters. A missing or unreadable file is an empty book, never an error. */
6034
+ /**
6035
+ * Read the counters. A missing or unreadable file is an empty book, never an error.
6036
+ *
6037
+ * ⚠ THE LEGACY PATH IS A FALLBACK AND ONLY A FALLBACK: it is consulted when — and only when — there is
6038
+ * no usable file at {@link FEEDBACK_FILE} yet, which is exactly the first run after the path moved. The
6039
+ * two are never merged, because they are two SNAPSHOTS of one counter and adding them would count a run
6040
+ * twice. A file that exists at the current path but does not parse stays an empty book, which is what
6041
+ * this function has always promised: a corrupt sidecar is not an invitation to read a different file.
6042
+ */
6000
6043
  function readFeedback(root, readFile = defaultRead$1) {
6001
- const text = readFile(join(root, FEEDBACK_FILE));
6002
- if (text === null || text.trim() === "") return {};
6044
+ const current = readFile(join(root, FEEDBACK_FILE));
6045
+ if (current !== null && current.trim() !== "") return parseBook(current);
6046
+ const legacy = readFile(join(root, LEGACY_FEEDBACK_FILE));
6047
+ return legacy === null ? {} : parseBook(legacy);
6048
+ }
6049
+ /** One counters file as a book: anything unreadable or unshaped is `{}`, never an error. */
6050
+ function parseBook(text) {
6003
6051
  try {
6004
6052
  const parsed = JSON.parse(text);
6005
6053
  if (typeof parsed !== "object" || parsed === null) return {};
@@ -6075,7 +6123,9 @@ function settleInjections(root, runDir, lockedPhases, write = defaultWrite, read
6075
6123
  counter.applied += 1;
6076
6124
  book[record.source] = counter;
6077
6125
  }
6078
- write(join(root, FEEDBACK_FILE), JSON.stringify(sortBook(book), null, 2) + "\n");
6126
+ const feedbackPath = join(root, FEEDBACK_FILE);
6127
+ mkdirSync(dirname(feedbackPath), { recursive: true });
6128
+ write(feedbackPath, JSON.stringify(sortBook(book), null, 2) + "\n");
6079
6129
  return book;
6080
6130
  }
6081
6131
  /**
@@ -6781,6 +6831,123 @@ function extractAndGroup(runner, env, options = {}) {
6781
6831
  };
6782
6832
  }
6783
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
6784
6951
  //#region src/recursive_ask.tool.ts
6785
6952
  /**
6786
6953
  * T23 — `recursive_ask`: the three human gates as STRUCTURED decisions, not prose.
@@ -6808,6 +6975,14 @@ const ASK_GATE_IDS = [
6808
6975
  "qa-signoff",
6809
6976
  "gate-block"
6810
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
+ }
6811
6986
  const ASK_GATES = {
6812
6987
  "tdd-mode": {
6813
6988
  id: "tdd-mode",
@@ -6901,6 +7076,36 @@ function buildAskQuestion(gateId) {
6901
7076
  });
6902
7077
  }
6903
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
+ /**
6904
7109
  * Validate an answer against its gate.
6905
7110
  *
6906
7111
  * An answer that is not one of the offered labels is REFUSED rather than recorded: a marker saying
@@ -6949,15 +7154,19 @@ function pendingGateFor(artifactFile, artifactText) {
6949
7154
  * ⚠ THE WRITE-BACK IS A MARKER LINE, REPLACED IN PLACE when the artifact already carries one. A
6950
7155
  * second `TDD Mode:` line would leave two answers to one question and make "what was decided?"
6951
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.
6952
7161
  */
6953
7162
  function createRecursiveAskTool(recursive) {
6954
7163
  return defineTool({
6955
7164
  name: "recursive_ask",
6956
- 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.",
6957
7166
  parameters: {
6958
7167
  gate: {
6959
7168
  type: "string",
6960
- 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."
6961
7170
  },
6962
7171
  runId: {
6963
7172
  type: "string",
@@ -6965,11 +7174,11 @@ function createRecursiveAskTool(recursive) {
6965
7174
  },
6966
7175
  artifact: {
6967
7176
  type: "string",
6968
- 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)."
6969
7178
  },
6970
7179
  answer: {
6971
7180
  type: "string",
6972
- 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(" | ") + "."
6973
7182
  }
6974
7183
  },
6975
7184
  output: {
@@ -6983,29 +7192,32 @@ function createRecursiveAskTool(recursive) {
6983
7192
  const gateId = (args.gate ?? "").trim();
6984
7193
  const runId = args.runId?.trim() ?? "";
6985
7194
  if (runId === "") return { error: toolError("MISSING_RUN_ID") };
6986
- 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(" | ")) };
6987
7196
  const root = await recursive.resolveWorkspaceRoot(exec.agent);
6988
7197
  if (!root) return { error: toolError("NO_WORKSPACE") };
6989
- const artifact = (args.artifact ?? GATE_DEFAULT_ARTIFACT[gateId]).trim();
7198
+ const artifact = (isRunStartGate(gateId) ? RUN_START_ARTIFACT : args.artifact ?? GATE_DEFAULT_ARTIFACT[gateId]).trim();
6990
7199
  let question;
6991
7200
  try {
6992
- question = buildAskQuestion(gateId);
7201
+ question = buildAskQuestionFor(gateId);
6993
7202
  } catch (err) {
6994
7203
  return { error: toolError("BAD_ASK_GATE", err instanceof Error ? err.message : String(err)) };
6995
7204
  }
6996
- if (args.answer === void 0) return {
7205
+ const channelMounted = recursive.userQuestionsChannel !== null;
7206
+ if (args.answer === void 0 && !(isRunStartGate(gateId) && channelMounted)) return {
6997
7207
  gate: gateId,
6998
- marker: ASK_GATES[gateId].marker,
7208
+ marker: isRunStartGate(gateId) ? RUN_START_GATE.marker : ASK_GATES[gateId].marker,
6999
7209
  artifact,
7000
7210
  question
7001
7211
  };
7002
7212
  let answer;
7003
- try {
7004
- answer = validateAskAnswer(gateId, args.answer);
7213
+ if (args.answer === void 0) answer = void 0;
7214
+ else try {
7215
+ answer = validateAskAnswerFor(gateId, args.answer);
7005
7216
  } catch (err) {
7006
7217
  return { error: toolError("BAD_ASK_ANSWER", err instanceof Error ? err.message : String(err)) };
7007
7218
  }
7008
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);
7009
7221
  const marker = answerMarker(gateId, answer);
7010
7222
  const written = recursive.recordAskAnswer(root, runId, artifact, marker);
7011
7223
  return {
@@ -7019,6 +7231,76 @@ function createRecursiveAskTool(recursive) {
7019
7231
  }
7020
7232
  });
7021
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
+ }
7022
7304
  //#endregion
7023
7305
  //#region src/lifecycle.ts
7024
7306
  /**
@@ -7234,7 +7516,20 @@ function currentPhaseArtifact(worktreeRoot, runId) {
7234
7516
  best = name;
7235
7517
  }
7236
7518
  }
7237
- return best;
7519
+ let inForce = "";
7520
+ let inForcePhase = Number.POSITIVE_INFINITY;
7521
+ for (const name of names) {
7522
+ if (!name.endsWith(".md")) continue;
7523
+ const phase = phaseNumberForArtifact(name);
7524
+ if (!phase) continue;
7525
+ const value = Number(phase);
7526
+ if (getLockStatus(join(runDir, name)) === "LOCKED") continue;
7527
+ if (value < inForcePhase) {
7528
+ inForcePhase = value;
7529
+ inForce = name;
7530
+ }
7531
+ }
7532
+ return inForce !== "" ? inForce : best;
7238
7533
  }
7239
7534
  function evaluateToolGuard(exec, worktreeRoot, activeRunId, mode = "advisory") {
7240
7535
  const name = exec.name;
@@ -7359,14 +7654,58 @@ function resolveTargetPath(target, worktreeRoot) {
7359
7654
  return abs;
7360
7655
  }
7361
7656
  /**
7657
+ * The ADMISSION test for the tamper path: the resolved absolute path when
7658
+ * `targetPath` names a run-tree `*.md` — a tamper CANDIDATE — and `null`
7659
+ * otherwise. Pure path arithmetic on every branch (no filesystem work), so a
7660
+ * caller may use it as a cheap shape check before paying for `existsSync`.
7661
+ *
7662
+ * ⚠ THE BLIND SPOT THIS FUNCTION EXISTS TO CLOSE. The admission test used to be a
7663
+ * substring test on the target STRING alone, looking for `/.recursive/run/`. A
7664
+ * repo-relative path has no separator before `.recursive`, so
7665
+ * `.recursive/run/<id>/00-requirements.md` — the spelling a model actually types,
7666
+ * and its backslash form — was rejected before ANYTHING was examined, and
7667
+ * tampering with a locked artifact through that spelling was invisible
7668
+ * (measured: `detectTamper` returned a record for the absolute path and `null`
7669
+ * for the relative one, on the same file). The identical defect, in the identical
7670
+ * spelling, was fixed one module over in `policy-globs.ts` `lockedWriteRule`; this
7671
+ * is that fix's shape, reused rather than reinvented.
7672
+ *
7673
+ * So the marker is looked for on the path the target RESOLVES to as well as on
7674
+ * the string as written. The `||` is load-bearing and the string test is KEPT
7675
+ * rather than replaced, because a resolved-only test would SHRINK the admitted
7676
+ * set: an absolute target that literally carries the marker but resolves away
7677
+ * from it (`…/.recursive/run/../…`) was caught before and must stay caught. The
7678
+ * net effect is a strict SUPERSET of the previous behaviour, so no tamper that
7679
+ * was visible before can become invisible.
7680
+ *
7681
+ * EXPORTED because `src/index.ts`'s `fs/observed` listener must apply the SAME
7682
+ * admission test before calling `detectTamper`. That listener used to carry a
7683
+ * hand-copied mirror of this test, and a mirror is exactly what leaves half the
7684
+ * defect behind: widening `detectTamper` alone changes nothing, because the
7685
+ * listener rejects the spelling first. One function cannot disagree with itself.
7686
+ */
7687
+ function tamperCandidatePath(targetPath, worktreeRoot) {
7688
+ const normalized = targetPath.replace(/\\/g, "/");
7689
+ if (!normalized.endsWith(".md")) return null;
7690
+ const abs = resolveTargetPath(normalized, worktreeRoot);
7691
+ if (!abs) return null;
7692
+ const resolved = abs.replace(/\\/g, "/");
7693
+ if (!normalized.includes("/.recursive/run/") && !resolved.includes("/.recursive/run/")) return null;
7694
+ return abs;
7695
+ }
7696
+ /**
7362
7697
  * Layer 8 - fs/observed lock-tamper detection.
7363
7698
  * A locked *.md whose observed version differs from the stored LockHash is
7364
7699
  * a tamper. Returns a tamper reason (or null when clean/not-applicable).
7700
+ *
7701
+ * The admission test lives in `tamperCandidatePath` (above), shared with the
7702
+ * `fs/observed` listener in `src/index.ts` — see the note there for why sharing
7703
+ * it is the point and not a tidiness preference. What this function reports is
7704
+ * unchanged: the same record shape, carrying the target AS WRITTEN.
7365
7705
  */
7366
7706
  function detectTamper(targetPath, worktreeRoot, activeRunId) {
7367
7707
  const normalized = targetPath.replace(/\\/g, "/");
7368
- if (!normalized.endsWith(".md") || !normalized.includes("/.recursive/run/")) return null;
7369
- const abs = resolveTargetPath(normalized, worktreeRoot);
7708
+ const abs = tamperCandidatePath(normalized, worktreeRoot);
7370
7709
  if (!abs || !existsSync(abs)) return null;
7371
7710
  if (getLockStatus(abs) === "STALE_LOCK") return {
7372
7711
  runId: activeRunId,
@@ -8816,8 +9155,25 @@ function mutatePhase(service, agent, ref, target) {
8816
9155
  * Sync a run's durable goal to the requested phase. Safe: never touches a goal
8817
9156
  * whose objective is not this run's marker, and never re-creates over a
8818
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`).
8819
9175
  */
8820
- function syncRunGoal(service, agent, runId, runState) {
9176
+ function syncRunGoal(service, agent, runId, runState, approved = false) {
8821
9177
  if (!service) return {
8822
9178
  ok: false,
8823
9179
  reason: "no goals service"
@@ -8832,12 +9188,18 @@ function syncRunGoal(service, agent, runId, runState) {
8832
9188
  phase: target,
8833
9189
  ref
8834
9190
  };
8835
- if (phase === "complete") return {
8836
- ok: true,
8837
- phase: target,
8838
- ref: refOf(service.create(agent, { objective: goalObjective(runId, runState) })),
8839
- created: true
8840
- };
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
+ }
8841
9203
  return mutatePhase(service, agent, ref, target) ? {
8842
9204
  ok: true,
8843
9205
  phase: target,
@@ -8848,17 +9210,27 @@ function syncRunGoal(service, agent, runId, runState) {
8848
9210
  };
8849
9211
  }
8850
9212
  if (current) {
8851
- if (current.phase === "complete") return {
8852
- ok: true,
8853
- phase: target,
8854
- ref: refOf(service.create(agent, { objective: goalObjective(runId, runState) })),
8855
- created: true
8856
- };
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
+ }
8857
9225
  return {
8858
9226
  ok: false,
8859
9227
  reason: "a non-matching active goal exists (foreign goal not touched)"
8860
9228
  };
8861
9229
  }
9230
+ if (!approved) return {
9231
+ ok: false,
9232
+ reason: RUN_START_NOT_APPROVED
9233
+ };
8862
9234
  return {
8863
9235
  ok: true,
8864
9236
  phase: target,
@@ -8866,7 +9238,13 @@ function syncRunGoal(service, agent, runId, runState) {
8866
9238
  created: true
8867
9239
  };
8868
9240
  }
8869
- /** 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
+ */
8870
9248
  function blockRunGoal(service, agent, runId, reason) {
8871
9249
  if (!service) return {
8872
9250
  ok: false,
@@ -8875,7 +9253,7 @@ function blockRunGoal(service, agent, runId, reason) {
8875
9253
  const current = service.get(agent);
8876
9254
  if (!current) return {
8877
9255
  ok: false,
8878
- reason: "no current goal to block"
9256
+ reason: RUN_START_NOT_APPROVED
8879
9257
  };
8880
9258
  if (!isRunGoal(current, runId)) return {
8881
9259
  ok: false,
@@ -8891,9 +9269,16 @@ function blockRunGoal(service, agent, runId, reason) {
8891
9269
  reason: "goal block failed"
8892
9270
  };
8893
9271
  }
8894
- /** Bridge a run's blocked goal back to active (used on a reopen). */
8895
- function resumeRunGoal(service, agent, runId) {
8896
- 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);
8897
9282
  }
8898
9283
  //#endregion
8899
9284
  //#region src/teams-loop.ts
@@ -9503,9 +9888,23 @@ var RecursiveRuntime = class extends Service {
9503
9888
  this.subagentsSeam = config.subagents ?? null;
9504
9889
  this.workflow = config.workflow ?? null;
9505
9890
  }
9506
- /** T10: the native jobs registry, when the composition mounts one. */
9891
+ /**
9892
+ * T10: the native jobs registry, when the composition mounts one. */
9507
9893
  jobs;
9508
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
+ /**
9509
9908
  * T23 — write a gate's answer into an artifact as a marker line.
9510
9909
  *
9511
9910
  * ⚠ REPLACED IN PLACE when the artifact already carries that gate's marker: two `TDD Mode:` lines
@@ -9697,16 +10096,72 @@ var RecursiveRuntime = class extends Service {
9697
10096
  return report;
9698
10097
  }
9699
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
+ /**
9700
10130
  * T1 (goals projection): project the run into the native goals service so it is
9701
10131
  * a first-class durable, resumable, blockable object. Best-effort — the run's
9702
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).
10143
+ */
10144
+ projectRunToGoal(agent, runId, state = "active", approved = false) {
10145
+ if (!agent) return {
10146
+ ok: false,
10147
+ reason: "no agent"
10148
+ };
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.
9703
10157
  */
9704
- projectRunToGoal(agent, runId, state = "active") {
10158
+ armRunGoalIfApproved(agent, root, runId, state = "active") {
9705
10159
  if (!agent) return {
9706
10160
  ok: false,
9707
10161
  reason: "no agent"
9708
10162
  };
9709
- return syncRunGoal(this.goalsService, agent, runId, state);
10163
+ const approval = this.readRunStartApproval(root, runId);
10164
+ return syncRunGoal(this.goalsService, agent, runId, state, approval.approved);
9710
10165
  }
9711
10166
  /** T1: block the run's goal on a gate-block (durable + UI-visible). */
9712
10167
  blockRunToGoal(agent, runId, reason) {
@@ -9716,13 +10171,18 @@ var RecursiveRuntime = class extends Service {
9716
10171
  };
9717
10172
  return blockRunGoal(this.goalsService, agent, runId, reason);
9718
10173
  }
9719
- /** T1: re-arm the run's goal on a reopen (blocked/paused -> active). */
9720
- 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) {
9721
10181
  if (!agent) return {
9722
10182
  ok: false,
9723
10183
  reason: "no agent"
9724
10184
  };
9725
- return resumeRunGoal(this.goalsService, agent, runId);
10185
+ return resumeRunGoal(this.goalsService, agent, runId, approved);
9726
10186
  }
9727
10187
  /**
9728
10188
  * Workspace-scoped control-plane root (R1 binding invariant).
@@ -10372,15 +10832,61 @@ var RecursiveRuntime = class extends Service {
10372
10832
  runDir,
10373
10833
  runId,
10374
10834
  created,
10375
- existing
10835
+ existing,
10836
+ runStartApproval: {
10837
+ ...this.readRunStartApproval(scaffoldRoot, runId),
10838
+ gate: RUN_START_GATE_ID
10839
+ }
10376
10840
  };
10377
10841
  if (worktree) result.worktree = worktree;
10378
- try {
10379
- this.projectRunToGoal(agent, runId, "active");
10380
- } catch {}
10381
10842
  return result;
10382
10843
  }
10383
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
+ /**
10384
10890
  * Create a linked worktree for a run under the given workspace root. The
10385
10891
  * worktree branch defaults to `recursive/<runId>` and is cut from the given
10386
10892
  * base branch (default: the current HEAD branch of the root checkout).
@@ -10443,6 +10949,8 @@ var RecursiveRuntime = class extends Service {
10443
10949
  }
10444
10950
  const inFlight = pendingWork(runDir);
10445
10951
  if (inFlight.length > 0) throw new Error(toolError("PENDING_WORK", inFlight.map((p) => p.detail).join("; ")));
10952
+ const lint = await this.lintArtifact(runId, artifact, agent);
10953
+ if (!lint.passed) throw new Error("Artifact " + artifact + " does not meet the phase standard, so it was not locked: " + lint.errors.join("; "));
10446
10954
  let content = readFileSync(artifactPath, "utf8");
10447
10955
  const lockedAt = (/* @__PURE__ */ new Date()).toISOString().replace(/\.\d{3}Z$/, "Z");
10448
10956
  content = setOrInsertField(content, "Status", "LOCKED", ["Phase"]);
@@ -10487,7 +10995,7 @@ var RecursiveRuntime = class extends Service {
10487
10995
  const stale = getStaleDownstreamPhases(runDir, artifact);
10488
10996
  for (const entry of stale) invalidateReceipt(runDir, entry.artifact);
10489
10997
  try {
10490
- this.resumeRunToGoal(agent, runId);
10998
+ this.resumeRunToGoal(agent, runId, this.readRunStartApproval(root, runId).approved);
10491
10999
  } catch {}
10492
11000
  return {
10493
11001
  artifact,
@@ -10840,10 +11348,25 @@ function createRecursiveStatusTool(recursive) {
10840
11348
  }
10841
11349
  //#endregion
10842
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
+ */
10843
11366
  function createRecursiveInitTool(recursive) {
10844
11367
  return defineTool({
10845
11368
  name: "recursive_init",
10846
- 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.",
10847
11370
  parameters: {
10848
11371
  runId: {
10849
11372
  type: "string",
@@ -12526,7 +13049,7 @@ function renderExplained(entries) {
12526
13049
  * present-but-empty value would stop the deferral and pin the choice to nothing, which is a different state
12527
13050
  * from "unconfigured" and not one a user can see. Absence is the honest representation of "I have no opinion".
12528
13051
  *
12529
- * 3. **WRITE ATOMICALLY.** A temp file and a rename, the same pattern the preset installer uses. A policy file
13052
+ * 3. **WRITE ATOMICALLY.** A temp file and a rename, the same pattern the preset installer used, before it was retired. A policy file
12530
13053
  * half-written because a process died mid-write would make `loadRouterPolicy` fall back to the built-in
12531
13054
  * self-audit policy — and it does that SILENTLY, on purpose (it never throws). So a torn write here would look
12532
13055
  * exactly like "the user configured nothing", for every role, until someone read the file.
@@ -13590,6 +14113,10 @@ function apply(ctx, config) {
13590
14113
  ctx.inject(["llm"], (llmCtx) => {
13591
14114
  recursive.attachLlmInventory(llmCtx.get("llm") ?? null);
13592
14115
  });
14116
+ recursive.attachUserQuestions(ctx.get("userQuestions") ?? null);
14117
+ ctx.inject(["userQuestions"], (questionsCtx) => {
14118
+ recursive.attachUserQuestions(questionsCtx.get("userQuestions") ?? null);
14119
+ });
13593
14120
  if (config?.enforcement !== void 0) recursive.setEnforcementConfig(config.enforcement);
13594
14121
  const phaseSkills = registerPhaseSkills(ctx.get("skills"), PHASE_SEQUENCE);
13595
14122
  yield () => {
@@ -13614,9 +14141,17 @@ function apply(ctx, config) {
13614
14141
  ctx.tools.register(createRecursiveReviewTool(recursive, subagentsSeam)),
13615
14142
  ctx.tools.register(createRecursiveDelegateTool(recursive, subagentsSeam)),
13616
14143
  ctx.tools.register(createRecursiveAskTool(recursive)),
13617
- ctx.tools.register(createRecursivePreviewTool(recursive)),
13618
- ...agentTeams ? [ctx.tools.register(createRecursiveAuditTeamTool(agentTeams))] : []
14144
+ ctx.tools.register(createRecursivePreviewTool(recursive))
13619
14145
  ];
14146
+ let auditTeamRegistered = agentTeams !== void 0 && agentTeams !== null;
14147
+ if (auditTeamRegistered) disposers.push(ctx.tools.register(createRecursiveAuditTeamTool(agentTeams ?? null)));
14148
+ ctx.inject(["agentTeams"], (teamCtx) => {
14149
+ if (auditTeamRegistered) return;
14150
+ const late = teamCtx.get("agentTeams");
14151
+ if (late === void 0 || late === null) return;
14152
+ auditTeamRegistered = true;
14153
+ ctx.tools.register(createRecursiveAuditTeamTool(late));
14154
+ });
13620
14155
  const commands = ctx.get("commands");
13621
14156
  if (commands) disposers.push(registerRecursiveCommand({ commands }, recursive));
13622
14157
  const systemPrompt = ctx.get("systemPrompt");
@@ -13715,10 +14250,10 @@ function apply(ctx, config) {
13715
14250
  if (observation?.kind !== "present") return;
13716
14251
  const displayPath = target?.displayPath ?? "";
13717
14252
  if (!displayPath) return;
13718
- const normalized = displayPath.replace(/\\/g, "/");
13719
- if (!normalized.endsWith(".md") || !normalized.includes("/.recursive/run/")) return;
13720
14253
  const cwd = actor?.agent?.session?.header?.cwd ?? "";
13721
14254
  if (!cwd) return;
14255
+ const normalized = displayPath.replace(/\\/g, "/");
14256
+ if (!tamperCandidatePath(normalized, cwd)) return;
13722
14257
  const runId = resolveRunDir(cwd)?.runId ?? "";
13723
14258
  const tamper = recursive.detectTamper(normalized, cwd, runId);
13724
14259
  if (!tamper) return;
@@ -13823,4 +14358,4 @@ function apply(ctx, config) {
13823
14358
  });
13824
14359
  }
13825
14360
  //#endregion
13826
- export { Config, DEFAULT_BUDGETS, DEFAULT_ENFORCEMENT, OPTIONAL_PHASES, PHASES, PHASE_POSITIONS, PHASE_SEQUENCE, RECURSIVE_API_PREFIX, RUN_ARTIFACT_SEQUENCE, RUN_STATES, RecursiveRuntime, apply, auditToPass, buildDelegationPrompt, buildReviewBundle, buildWorkSlice, builtInToolPolicy, capabilityProbe, childScratchPath, coerceAskToDecision, contentSha256, contractDigest, coupleGateBlockToGoal, createChildBrief, createHandoff, createRecursiveCloseoutTool, createRecursiveInitTool, createRecursiveLintTool, createRecursiveLockTool, createRecursivePhaseTool, createRecursiveScratchTool, createRecursiveStatusTool, createRecursiveWorktreeTool, currentPhaseArtifact, defaultReviewToolFilter, delegate, delegateContinuable, delegationDecisionBasis, delegationError, detectTamper, discoverRuns, drainContinuableChildren, drainContinuableDescendants, escapeRegExp, evaluateDelegationResult, evaluateToolGuard, foldDiagnostics, foldRun, foldRunCard, getAllStaleReceipts, getArtifactState, getGateStatus, getLatestRunDirectory, getLockStatus, getMdFieldValue, getNextLegalPhase, getPrerequisiteBlockers, getPrerequisites, getStaleDownstreamPhases, getTodoStats, getWorkflowProfile, inject, interruptContinuable, invalidateReceipt, isCoreArtifact, isTaskClaimedBy, loadRouterPolicy, lockHashFromContent, makeRecursiveRoutes, mountRecursiveRoutesOnce, name, normalizeForLockHash, parseReplyVerdict, pendingWork, phaseIndex, phasePosition, probeCapabilities, readReceipt, readRepairFromReply, readRepairFromStructured, readVerdictFromReply, readVerdictFromStructured, receiptPath, referencesFromResult, registerRecursiveSkill, remainingDepthFor, renderPhaseTail, renderRecursivePolicy, renderStableContract, renderTaskHistory, replyPath, resetFoldCache, resolveEnforcementConfig, resolveRole, resolveRunDir, resolveToolPolicyForGuard, reviewBundleDir, reviewOutputSchema, routerPolicyPath, snapshotWorkspace, trimMdValue, validateChain, validateReferences, validateTransition, writeActionRecord, writeReceipt };
14361
+ export { Config, DEFAULT_BUDGETS, DEFAULT_ENFORCEMENT, OPTIONAL_PHASES, PHASES, PHASE_POSITIONS, PHASE_SEQUENCE, RECURSIVE_API_PREFIX, RUN_ARTIFACT_SEQUENCE, RUN_STATES, RecursiveRuntime, apply, auditToPass, buildDelegationPrompt, buildReviewBundle, buildWorkSlice, builtInToolPolicy, capabilityProbe, childScratchPath, coerceAskToDecision, contentSha256, contractDigest, coupleGateBlockToGoal, createChildBrief, createHandoff, createRecursiveCloseoutTool, createRecursiveInitTool, createRecursiveLintTool, createRecursiveLockTool, createRecursivePhaseTool, createRecursiveScratchTool, createRecursiveStatusTool, createRecursiveWorktreeTool, currentPhaseArtifact, defaultReviewToolFilter, delegate, delegateContinuable, delegationDecisionBasis, delegationError, detectTamper, discoverRuns, drainContinuableChildren, drainContinuableDescendants, escapeRegExp, evaluateDelegationResult, evaluateToolGuard, foldDiagnostics, foldRun, foldRunCard, getAllStaleReceipts, getArtifactState, getGateStatus, getLatestRunDirectory, getLockStatus, getMdFieldValue, getNextLegalPhase, getPrerequisiteBlockers, getPrerequisites, getStaleDownstreamPhases, getTodoStats, getWorkflowProfile, inject, interruptContinuable, invalidateReceipt, isCoreArtifact, isTaskClaimedBy, loadRouterPolicy, lockHashFromContent, makeRecursiveRoutes, mountRecursiveRoutesOnce, name, normalizeForLockHash, parseReplyVerdict, pendingWork, phaseIndex, phasePosition, probeCapabilities, readReceipt, readRepairFromReply, readRepairFromStructured, readVerdictFromReply, readVerdictFromStructured, receiptPath, referencesFromResult, registerRecursiveSkill, remainingDepthFor, renderPhaseTail, renderRecursivePolicy, renderStableContract, renderTaskHistory, replyPath, resetFoldCache, resolveEnforcementConfig, resolveRole, resolveRunDir, resolveToolPolicyForGuard, reviewBundleDir, reviewOutputSchema, routerPolicyPath, snapshotWorkspace, tamperCandidatePath, trimMdValue, validateChain, validateReferences, validateTransition, writeActionRecord, writeReceipt };