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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -5390,12 +5390,30 @@ const TOOL_ERRORS = {
5390
5390
  problem: "this gate has no default artifact, so one must be named",
5391
5391
  next: "pass artifact: <file> so the answer has somewhere durable to land"
5392
5392
  },
5393
+ RELAY_ONLY_FOR_RUN_START: {
5394
+ code: "RM1150",
5395
+ klass: "input",
5396
+ problem: "relay applies only to the run-start gate, which is the only gate that asks the user-questions channel",
5397
+ next: "drop relay for this gate and answer it with one of its own labels, or call recursive_ask with gate: run-start when the decision is whether to start the run"
5398
+ },
5393
5399
  MISSING_RUN_ID: {
5394
5400
  code: "RM1101",
5395
5401
  klass: "input",
5396
5402
  problem: "runId is required",
5397
5403
  next: "call recursive_status with no runId to see the latest run id in this workspace"
5398
5404
  },
5405
+ /**
5406
+ * A run id is the NAME of the run directory and is joined onto the run layer
5407
+ * as one path segment, so a path-shaped id is refused before any directory is
5408
+ * created. See `run-id.ts` for the rule and for why it is not the runtime that
5409
+ * learns to accept a path.
5410
+ */
5411
+ BAD_RUN_ID: {
5412
+ code: "RM1107",
5413
+ klass: "input",
5414
+ problem: "runId is not a single directory name",
5415
+ next: "pass the run directory name such as 03-something, then call recursive_init again"
5416
+ },
5399
5417
  MISSING_ARTIFACT: {
5400
5418
  code: "RM1102",
5401
5419
  klass: "input",
@@ -5468,11 +5486,26 @@ const TOOL_ERRORS = {
5468
5486
  problem: "the run-start gate needs an answer, and this composition mounts no user-questions channel to ask one directly",
5469
5487
  next: "call recursive_ask with gate: run-start and no answer to surface the question, then retry with answer: \"Start run\""
5470
5488
  },
5489
+ /**
5490
+ * ⚠ RM5503 USED TO LIE. Its text asserted "so no person was asked" for EVERY cause, because the caller
5491
+ * that produced it had already thrown the cause away — and a live session showed the cost: the gate
5492
+ * failed 22.9 s into the call with a reason nobody could see, and its `Next:` clause prescribed the very
5493
+ * call that had just failed, so no route to start a run remained. The problem statement now claims only
5494
+ * what the gate knows (no decision came back), and the cause travels in the `detail` the caller
5495
+ * supplies. `RUN_START_ANSWER_UNUSABLE` carries the one case this entry must NOT cover: a person was
5496
+ * reached and their answer was not an approval.
5497
+ */
5471
5498
  RUN_START_UNANSWERED: {
5472
5499
  code: "RM5503",
5473
5500
  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"
5501
+ problem: "the run-start question reached no decision: the mounted user-questions channel failed before a person answered it",
5502
+ next: "read the cause named in the detail, fix it and call recursive_ask again, or - when this composition cannot deliver the question at all - call recursive_ask with answer: \"Start run\" and relay=true to record the person's explicit approval as a relayed one"
5503
+ },
5504
+ RUN_START_ANSWER_UNUSABLE: {
5505
+ code: "RM5504",
5506
+ klass: "runtime",
5507
+ problem: "a person was asked to start this run and their answer was not one of the labels the run-start gate offered",
5508
+ next: "call recursive_ask again and have the person choose exactly \"Start run\" or \"Hold\"; a skipped or custom answer is not an approval, and no relayed answer can replace a decision the person made"
5476
5509
  },
5477
5510
  RUNTIME_REFUSED: {
5478
5511
  code: "RM5501",
@@ -6854,9 +6887,15 @@ function extractAndGroup(runner, env, options = {}) {
6854
6887
  * and nothing else, so the presence of a `Run Start` line is never on its own consent.
6855
6888
  * 2. IT IS ASKED, NOT ASSUMED. When the composition mounts `ctx.userQuestions` — the harness's own
6856
6889
  * 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.
6890
+ * only their selection is recorded; a caller-supplied answer cannot stand in for it. A channel that
6891
+ * RESOLVES with an answer the gate does not recognise is a person's decision the gate cannot record
6892
+ * and it ends the call (RM5504). A channel that FAILS ends the call too (RM5503), naming the cause the
6893
+ * channel threw — and there the caller may take the relayed route deliberately, with `relay=true`,
6894
+ * which the result reports as `source: "relayed"` rather than as a person's own selection, so a
6895
+ * composition whose channel cannot deliver the question can still start a run. A failure that means
6896
+ * the question was cancelled, aborted, or timed out is never relayable. Only a composition with no
6897
+ * channel at all falls back to the relayed answer unconditionally, which is the contract the other
6898
+ * three gates have.
6860
6899
  * 3. THE GOAL CANNOT BE CREATED WITHOUT IT. `syncRunGoal` refuses to create a goal for a run whose
6861
6900
  * approval record is absent, in EVERY branch that would create one — not only the "no goal yet"
6862
6901
  * branch. That is the property `tests/run-start-approval.spec.ts` asserts, because a single
@@ -7162,7 +7201,7 @@ function pendingGateFor(artifactFile, artifactText) {
7162
7201
  function createRecursiveAskTool(recursive) {
7163
7202
  return defineTool({
7164
7203
  name: "recursive_ask",
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.",
7204
+ 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. For run-start the mounted human channel is asked first and its own selection wins; when that channel cannot deliver the question, the refusal names the cause and `relay=true` with an explicit `answer` records the person's relayed approval. One ask per step.",
7166
7205
  parameters: {
7167
7206
  gate: {
7168
7207
  type: "string",
@@ -7179,6 +7218,10 @@ function createRecursiveAskTool(recursive) {
7179
7218
  answer: {
7180
7219
  type: "string",
7181
7220
  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(" | ") + "."
7221
+ },
7222
+ relay: {
7223
+ type: "boolean",
7224
+ description: "run-start only, and only after the person has approved in this conversation. Set relay=true when the mounted user-questions channel cannot deliver the run-start question: `answer` then stands in for the channel's selection and the result reports source: \"relayed\" instead of a direct selection. It is refused when the channel reports the question was cancelled, aborted, or timed out, and it is not needed when the person answers the card."
7182
7225
  }
7183
7226
  },
7184
7227
  output: {
@@ -7193,6 +7236,7 @@ function createRecursiveAskTool(recursive) {
7193
7236
  const runId = args.runId?.trim() ?? "";
7194
7237
  if (runId === "") return { error: toolError("MISSING_RUN_ID") };
7195
7238
  if (!askGateIds().includes(gateId)) return { error: toolError("BAD_ASK_GATE", "gate must be one of " + askGateIds().join(" | ")) };
7239
+ if (args.relay === true && !isRunStartGate(gateId)) return { error: toolError("RELAY_ONLY_FOR_RUN_START", "gate is " + gateId) };
7196
7240
  const root = await recursive.resolveWorkspaceRoot(exec.agent);
7197
7241
  if (!root) return { error: toolError("NO_WORKSPACE") };
7198
7242
  const artifact = (isRunStartGate(gateId) ? RUN_START_ARTIFACT : args.artifact ?? GATE_DEFAULT_ARTIFACT[gateId]).trim();
@@ -7217,7 +7261,7 @@ function createRecursiveAskTool(recursive) {
7217
7261
  return { error: toolError("BAD_ASK_ANSWER", err instanceof Error ? err.message : String(err)) };
7218
7262
  }
7219
7263
  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);
7264
+ if (isRunStartGate(gateId)) return recordRunStartAnswer(recursive, root, runId, answer, exec, args.relay === true);
7221
7265
  const marker = answerMarker(gateId, answer);
7222
7266
  const written = recursive.recordAskAnswer(root, runId, artifact, marker);
7223
7267
  return {
@@ -7234,28 +7278,83 @@ function createRecursiveAskTool(recursive) {
7234
7278
  /**
7235
7279
  * PHASE 0 — record the answer to the run-start gate, and start the run only if it says so.
7236
7280
  *
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) {
7281
+ * ⚠ THREE OUTCOMES, AND TELLING THEM APART IS THE FIX. The first version of this function collapsed all of
7282
+ * them into one `null`: "the channel threw", "the channel resolved with something unrecognisable", and
7283
+ * "nobody answered" produced the same refusal, whose text asserted a cause ("so no person was asked") the
7284
+ * plugin had already thrown away. A live session paid for that: the call failed after 22.9 s, the operator
7285
+ * could not be told why, and the refusal's own advice prescribed the call that had just failed. So:
7286
+ *
7287
+ * 1. A PERSON WAS REACHED (`unusable`): the channel resolved, so somebody answered, and their answer is
7288
+ * not a label this gate offered — a skip, a custom value, several labels at once. That is a DECISION
7289
+ * the gate cannot record, and it is final: neither the caller's `answer` nor `relay=true` may replace
7290
+ * it. (RM5504)
7291
+ * 2. THE CHANNEL FAILED (`unavailable`): no decision came back at all, and the refusal NAMES THE CAUSE
7292
+ * from the error the channel threw. Here the run can still be started, because a composition whose
7293
+ * channel cannot deliver the question would otherwise be unable to start any run — but only by the
7294
+ * caller asking for the relay in so many words (`relay=true`), which the result reports as
7295
+ * `source: "relayed"` rather than as a person's own selection. (RM5503)
7296
+ * 3. NO CHANNEL IS MOUNTED: the relayed answer is the only possible source, exactly as before. (RM5502
7297
+ * when there is no answer either)
7298
+ *
7299
+ * ⚠ AND A CANCELLED OR CLOSED QUESTION IS NEVER RELAYABLE. `ASK_CANCELLED`, `ASK_ABORTED` and
7300
+ * `ASK_TIMED_OUT` are the codes that mean the question was settled from outside this gate — the card was
7301
+ * dismissed, the turn was cancelled, or a foreground window ended. The relay is refused for those, so a
7302
+ * question the operator stopped cannot be turned into an approval by asking again in the same breath.
7303
+ * Every other failure is a composition or capability failure — the question reached nobody — which is the
7304
+ * class the relay exists for.
7305
+ *
7306
+ * ⚠ WHAT THE GATE STILL DOES *NOT* CLAIM: a relayed approval is a relayed approval. The plugin cannot
7307
+ * verify that a person gave the label, and it does not pretend otherwise — the result's `source` and
7308
+ * `channel` fields say where the decision came from, and a direct selection is preferred whenever the
7309
+ * channel can produce one.
7310
+ */
7311
+ async function recordRunStartAnswer(recursive, root, runId, answer, exec, relay = false) {
7312
+ const question = buildAskQuestionFor(RUN_START_GATE_ID);
7254
7313
  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") };
7314
+ const channelOutcome = channel ? await askRunStartDirectly(channel, exec) : null;
7315
+ if (channelOutcome !== null && channelOutcome.kind === "unusable") return {
7316
+ error: toolError("RUN_START_ANSWER_UNUSABLE", channelOutcome.detail),
7317
+ gate: RUN_START_GATE_ID,
7318
+ runId,
7319
+ artifact: RUN_START_ARTIFACT,
7320
+ question
7321
+ };
7322
+ if (channelOutcome !== null && channelOutcome.kind === "unavailable") {
7323
+ const blocked = !relay ? "the caller did not ask for the relay" : "the channel reports the question was cancelled, aborted, or timed out, so it is not relayable";
7324
+ if (!relay || !channelOutcome.relayable) return {
7325
+ error: toolError("RUN_START_UNANSWERED", channelOutcome.detail + " (" + blocked + ")"),
7326
+ gate: RUN_START_GATE_ID,
7327
+ runId,
7328
+ artifact: RUN_START_ARTIFACT,
7329
+ question,
7330
+ channel: {
7331
+ outcome: "unavailable",
7332
+ cause: channelOutcome.cause,
7333
+ relayable: channelOutcome.relayable
7334
+ }
7335
+ };
7336
+ if (answer === void 0) return {
7337
+ error: toolError("RUN_START_UNANSWERED", channelOutcome.detail + " (the relay was authorised but no answer was supplied, so there is no decision to record)"),
7338
+ gate: RUN_START_GATE_ID,
7339
+ runId,
7340
+ artifact: RUN_START_ARTIFACT,
7341
+ question,
7342
+ channel: {
7343
+ outcome: "unavailable",
7344
+ cause: channelOutcome.cause,
7345
+ relayable: channelOutcome.relayable
7346
+ }
7347
+ };
7348
+ }
7349
+ const fromChannel = channelOutcome !== null && channelOutcome.kind === "answered" ? channelOutcome.answer : null;
7257
7350
  const final = fromChannel ?? answer;
7258
- if (final === void 0) return { error: toolError("RUN_START_NO_CHANNEL") };
7351
+ if (final === void 0) return {
7352
+ error: toolError("RUN_START_NO_CHANNEL"),
7353
+ gate: RUN_START_GATE_ID,
7354
+ runId,
7355
+ artifact: RUN_START_ARTIFACT,
7356
+ question
7357
+ };
7259
7358
  let decided;
7260
7359
  try {
7261
7360
  decided = validateAskAnswerFor(RUN_START_GATE_ID, final);
@@ -7268,6 +7367,12 @@ async function recordRunStartAnswer(recursive, root, runId, answer, exec) {
7268
7367
  answer: decided,
7269
7368
  artifact: RUN_START_ARTIFACT,
7270
7369
  source: fromChannel === null ? "relayed" : "user-questions",
7370
+ ...channelOutcome !== null && channelOutcome.kind === "unavailable" ? { channel: {
7371
+ outcome: "unavailable",
7372
+ cause: channelOutcome.cause,
7373
+ relayable: channelOutcome.relayable,
7374
+ relayed: true
7375
+ } } : {},
7271
7376
  path: outcome.path,
7272
7377
  replaced: outcome.replaced,
7273
7378
  armed: outcome.ok && outcome.goal.ok,
@@ -7275,16 +7380,66 @@ async function recordRunStartAnswer(recursive, root, runId, answer, exec) {
7275
7380
  };
7276
7381
  }
7277
7382
  /**
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).
7383
+ * ⚠ THE CODES THAT MEAN THE QUESTION WAS CANCELLED OR CLOSED rather than never delivered: the person
7384
+ * dismissed the card, their turn was cancelled, or a foreground window ended. A caller may not convert any
7385
+ * of those into an approval by asking for the relay in the same breath. Every other failure means the
7386
+ * question reached nobody — a composition or capability failure, which is the class the relay exists for.
7387
+ */
7388
+ const NON_RELAYABLE_CHANNEL_CODES = [
7389
+ "ASK_CANCELLED",
7390
+ "ASK_ABORTED",
7391
+ "ASK_TIMED_OUT"
7392
+ ];
7393
+ /**
7394
+ * Name the failure of one `ask()` call, without inventing anything about it.
7395
+ *
7396
+ * The cause is the error's own `code` when it has one (the harness's `UserQuestionError` carries
7397
+ * `NO_PROVIDER`, `CALLER_NOT_LIVE`, `DELEGATED_CALLER`, `ASK_ABORTED`, …), else its `name`, else its
7398
+ * JavaScript type. `detail` keeps the message verbatim so a reader sees the channel's own words rather
7399
+ * than this plugin's paraphrase — the paraphrase is exactly how the previous version came to assert a
7400
+ * cause nobody had.
7401
+ */
7402
+ function classifyChannelFailure(err) {
7403
+ const code = err?.code;
7404
+ const name = err instanceof Error ? err.name : typeof err;
7405
+ const message = err instanceof Error ? err.message : String(err);
7406
+ const hasCode = typeof code === "string" && code.trim() !== "";
7407
+ const cause = hasCode ? code : name;
7408
+ const relayable = !(hasCode && NON_RELAYABLE_CHANNEL_CODES.includes(code));
7409
+ return {
7410
+ cause,
7411
+ detail: "channel threw " + name + "[" + cause + "]: " + message,
7412
+ relayable
7413
+ };
7414
+ }
7415
+ /** Describe an answer that arrived but is not a decision this gate can record. */
7416
+ function describeUnusableAnswer(item) {
7417
+ const offered = RUN_START_GATE.options.map((option) => option.label);
7418
+ const list = (values) => JSON.stringify(values.join(" | "));
7419
+ if (item === void 0) return "the channel resolved with no answer for question " + JSON.stringify(RUN_START_GATE.id) + " at all";
7420
+ const raw = item.selected ?? [];
7421
+ const custom = item.custom?.trim() ?? "";
7422
+ if (raw.length === 0 && custom === "") return "the person skipped the question, and a skip is not an approval";
7423
+ if (raw.length === 0) return "the person answered " + JSON.stringify(custom) + " as free text rather than one of " + list(offered);
7424
+ const recognised = raw.filter((label) => offered.includes(label));
7425
+ if (recognised.length === 0) return "the person selected " + list(raw) + ", and none of those name a label this gate offered (" + offered.join(" | ") + ")";
7426
+ if (recognised.length === raw.length) return "the person selected " + list(raw) + ", and an approval is exactly one of " + list(offered);
7427
+ return "the person selected " + list(raw) + ", of which only " + list(recognised) + " name this gate's labels " + list(offered);
7428
+ }
7429
+ /**
7430
+ * Ask the run-start question through the blocking channel and report WHAT HAPPENED.
7280
7431
  *
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.
7432
+ * ⚠ THE CATCH IS THE POINT. It used to be `catch { return null }` — a blocking human question whose
7433
+ * failure cause was erased at the exact moment the cause was the only thing worth knowing. Every path out
7434
+ * of this function now says which path it was.
7435
+ *
7436
+ * The selection is filtered to the gate's OWN labels: a question a UI answered with a free-text custom
7437
+ * value must not become an approval just because it arrived on the right channel.
7283
7438
  */
7284
7439
  async function askRunStartDirectly(channel, exec) {
7285
7440
  const known = RUN_START_GATE.options.map((option) => option.label);
7286
7441
  try {
7287
- const selected = (await channel.ask({
7442
+ const item = (await channel.ask({
7288
7443
  questions: [{
7289
7444
  id: RUN_START_GATE.id,
7290
7445
  header: RUN_START_GATE.header,
@@ -7294,11 +7449,21 @@ async function askRunStartDirectly(channel, exec) {
7294
7449
  agent: exec.agent,
7295
7450
  signal: exec.signal,
7296
7451
  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;
7452
+ })).answers.find((entry) => entry.id === RUN_START_GATE.id);
7453
+ const selected = item?.selected?.filter((label) => known.includes(label)) ?? [];
7454
+ if (selected.length !== 1) return {
7455
+ kind: "unusable",
7456
+ detail: describeUnusableAnswer(item)
7457
+ };
7458
+ return {
7459
+ kind: "answered",
7460
+ answer: selected[0]
7461
+ };
7462
+ } catch (err) {
7463
+ return {
7464
+ kind: "unavailable",
7465
+ ...classifyChannelFailure(err)
7466
+ };
7302
7467
  }
7303
7468
  }
7304
7469
  //#endregion
@@ -11347,6 +11512,75 @@ function createRecursiveStatusTool(recursive) {
11347
11512
  });
11348
11513
  }
11349
11514
  //#endregion
11515
+ //#region src/run-id.ts
11516
+ /**
11517
+ * A RUN ID IS A NAME, NOT A PATH.
11518
+ *
11519
+ * WHY THIS MODULE EXISTS. Every consumer of a run id JOINS it onto a directory
11520
+ * that already carries the meaning "the run layer":
11521
+ *
11522
+ * join(root, '.recursive', 'run', runId) // runtime.ts, run.ts, handoff.ts, scratch.ts
11523
+ * join(repoRoot, '.worktrees', runId) // worktree.ts (a linked worktree)
11524
+ * 'recursive/' + runId // worktree.ts (the run's git branch)
11525
+ *
11526
+ * `join` is a PATH operation: absolute paths, drive specifiers and `..` segments
11527
+ * are all legal input to it, and each one silently changes what the call means.
11528
+ * A caller who passes `E:\tmp\rm-live-diagnostics\01-calculator-lib` is asking
11529
+ * for a run "on another drive"; what they get is a `mkdir` of
11530
+ *
11531
+ * <workspace>\.recursive\run\E:\tmp\rm-live-diagnostics\01-calculator-lib
11532
+ *
11533
+ * which is not drive-qualified at all — on POSIX and Windows alike the colon is
11534
+ * just another character in a relative component. The result is a bogus nested
11535
+ * folder INSIDE the workspace, created before anything can refuse it, surfacing
11536
+ * far away as an ENOENT-shaped runtime failure (RM5501) with the operator's
11537
+ * filesystem already dirty.
11538
+ *
11539
+ * SO THE RULE IS ENFORCED WHERE THE NAME ENTERS, and NOT by teaching the runtime
11540
+ * to accept a path. The joins in `runtime.ts` are CORRECT for a name; what was
11541
+ * missing was a gate on the name. Do not "fix" this back: a run on another drive
11542
+ * or in a worktree is reached through the session's control-plane root
11543
+ * (`recursive_worktree`, `00-worktree.md`) — the run layer is never relocated by
11544
+ * smuggling a path into the id.
11545
+ *
11546
+ * The charset below is deliberately the SAME one the read path already uses
11547
+ * (`live-route.ts` `DOC_SAFE_RE`) so a name this gate accepts is a name that
11548
+ * route can serve.
11549
+ */
11550
+ /**
11551
+ * The accepted shape, as prose that can be embedded in a model-facing parameter
11552
+ * description and in a refusal detail, so the rule is stated once.
11553
+ */
11554
+ const RUN_ID_RULE = "letters, digits, dot, underscore or dash only, no leading or trailing dot, no path separator, no drive specifier and no \"..\" segment";
11555
+ /** Directory-name charset — the read path's `DOC_SAFE_RE`, verbatim. */
11556
+ const RUN_ID_CHARS = /^[A-Za-z0-9._-]+$/;
11557
+ /**
11558
+ * Why a run id is refused, or `null` when it is a usable NAME.
11559
+ *
11560
+ * The returned string is the SPECIFIC problem (which rule the id broke), with no
11561
+ * trailing punctuation and no sentence of its own, so a caller can hand it to
11562
+ * `toolError('BAD_RUN_ID', …)` as the detail. `RUN_ID_RULE` states the shape.
11563
+ *
11564
+ * The order of the checks is part of the message quality: a Windows absolute
11565
+ * path is reported as a drive-qualified path (what the caller passed) rather
11566
+ * than as a separator complaint (what that path is made of).
11567
+ */
11568
+ function runIdProblem(raw) {
11569
+ if (raw === "") return "runId is empty";
11570
+ if (raw.length > 100) return "runId is " + raw.length + " characters, over the 100 allowed";
11571
+ if (/^[A-Za-z]:/.test(raw)) return "runId is a Windows drive-qualified path, starting with \"" + raw.slice(0, 2) + "\"";
11572
+ if (raw.includes("/") || raw.includes("\\")) return "runId contains the path separator \"" + (raw.includes("/") ? "/" : "\\") + "\"";
11573
+ if (raw.includes(":")) return "runId contains a colon (\":\"), which is a drive and stream separator on Windows";
11574
+ if (raw.includes("..")) return "runId contains a \"..\" segment, which escapes the run directory";
11575
+ if (raw.startsWith(".")) return "runId starts with \".\", which makes it a hidden name or a relative path segment";
11576
+ if (raw.endsWith(".")) return "runId ends with \".\"";
11577
+ if (!RUN_ID_CHARS.test(raw)) {
11578
+ if (/\s/.test(raw)) return "runId contains a space or other whitespace character inside the name";
11579
+ return "runId contains a character outside the allowed set";
11580
+ }
11581
+ return null;
11582
+ }
11583
+ //#endregion
11350
11584
  //#region src/recursive_init.tool.ts
11351
11585
  /**
11352
11586
  * PHASE 0 — SCAFFOLDING IS NOT STARTING, AND THE TOOL SAYS SO AT THE MOMENT IT MATTERS.
@@ -11362,15 +11596,23 @@ function createRecursiveStatusTool(recursive) {
11362
11596
  * rather than "this call created something": re-initialising an APPROVED run reports `approved: true`
11363
11597
  * (and the run keeps its goal), which is what a caller re-scaffolding a run it already started needs to
11364
11598
  * see.
11599
+ *
11600
+ * AND A RUN ID IS A NAME, NOT A PATH. This is the boundary where the name enters, so it is the boundary
11601
+ * that refuses a path-shaped one — loudly, and before `initRun` can mkdir anything. The check lives here
11602
+ * (through the shared `run-id.ts` rule) rather than in `runtime.ts` because `initRun` is not the only
11603
+ * caller and because the runtime's `join(root, '.recursive', 'run', runId)` is CORRECT for a name; what
11604
+ * was missing was a gate on the name. Teaching the runtime to accept a path would silently relocate the
11605
+ * run layer instead of rejecting the call. See `run-id.ts` for the rule, the evidence behind it, and the
11606
+ * "do not fix this back" note.
11365
11607
  */
11366
11608
  function createRecursiveInitTool(recursive) {
11367
11609
  return defineTool({
11368
11610
  name: "recursive_init",
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.",
11611
+ 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. The runId is the NAME of the run directory under .recursive/run/ and is never a path (see the parameter description): a path-shaped runId is refused before anything is written.",
11370
11612
  parameters: {
11371
11613
  runId: {
11372
11614
  type: "string",
11373
- description: "Run id (e.g. 03-something). Required."
11615
+ description: "Run id — the NAME of the run directory under .recursive/run/ (e.g. 03-something, 01-calculator-lib), never a path: " + RUN_ID_RULE + ". A run on another drive or inside a worktree is reached with recursive_worktree, not by passing a path here. Required."
11374
11616
  },
11375
11617
  createWorktree: {
11376
11618
  type: "boolean",
@@ -11389,9 +11631,12 @@ function createRecursiveInitTool(recursive) {
11389
11631
  }]
11390
11632
  },
11391
11633
  async execute(args, exec) {
11392
- if (!args.runId || args.runId.trim() === "") return { error: toolError("MISSING_RUN_ID") };
11634
+ const runId = args.runId?.trim() ?? "";
11635
+ if (runId === "") return { error: toolError("MISSING_RUN_ID") };
11636
+ const problem = runIdProblem(runId);
11637
+ if (problem !== null) return { error: toolError("BAD_RUN_ID", problem + " - run ids allow letters, digits, dot, underscore or dash only, no leading or trailing dot, no path separator, no drive specifier and no \"..\" segment - e.g. 01-calculator-lib, fixture-run") };
11393
11638
  try {
11394
- return await recursive.initRun(args.runId.trim(), exec.agent, {
11639
+ return await recursive.initRun(runId, exec.agent, {
11395
11640
  createWorktree: args.createWorktree === true,
11396
11641
  baseBranch: args.baseBranch?.trim() || void 0
11397
11642
  });
@@ -11605,6 +11850,16 @@ function createRecursiveLintTool(recursive) {
11605
11850
  * SESSION's workspace only (R1 workspace-scoping invariant). The run is resolved
11606
11851
  * via the session agent's cwd -> workspace registry; a runId outside the current
11607
11852
  * workspace is rejected.
11853
+ *
11854
+ * A RUN ID IS A NAME, NOT A PATH HERE TOO, and "outside the current workspace is rejected" is NOT enough
11855
+ * on its own: `closeoutRun` joins the id onto the run layer and then writes a receipt under it, and its
11856
+ * scoping check is `runDir.startsWith(runRoot)` — a string prefix test, not containment. A `..\` segment
11857
+ * that lands on a SIBLING of the run layer passes that check whenever the sibling's name begins with
11858
+ * `run`, so a path-shaped id can still receive a write. MEASURED pre-fix: `..\run-away` and `../run-away`
11859
+ * were accepted and reached the report; the other shapes below were stopped only by the sibling not
11860
+ * existing, which is the operator's filesystem deciding, not the tool. The rule is `run-id.ts`; this
11861
+ * boundary is where the name enters, so this is where it is refused, with `recursive_init`'s refusal shape
11862
+ * — `BAD_RUN_ID` (RM1107), same detail sentence.
11608
11863
  */
11609
11864
  function createRecursiveCloseoutTool(recursive) {
11610
11865
  return defineTool({
@@ -11617,7 +11872,7 @@ function createRecursiveCloseoutTool(recursive) {
11617
11872
  },
11618
11873
  runId: {
11619
11874
  type: "string",
11620
- description: "Run id (e.g. 03-something). Required. Must resolve inside the current workspace."
11875
+ description: "Run id — the NAME of the run directory under .recursive/run/ (e.g. 03-something), never a path: " + RUN_ID_RULE + ". Required. Must resolve inside the current workspace."
11621
11876
  }
11622
11877
  },
11623
11878
  output: {
@@ -11629,9 +11884,12 @@ function createRecursiveCloseoutTool(recursive) {
11629
11884
  },
11630
11885
  async execute(args, exec) {
11631
11886
  if (!args.phase || !args.runId || args.runId.trim() === "") return { error: toolError("MISSING_PHASE_AND_RUN") };
11887
+ const runId = args.runId.trim();
11888
+ const problem = runIdProblem(runId);
11889
+ if (problem !== null) return { error: toolError("BAD_RUN_ID", problem + " - run ids allow letters, digits, dot, underscore or dash only, no leading or trailing dot, no path separator, no drive specifier and no \"..\" segment - e.g. 01-calculator-lib, fixture-run") };
11632
11890
  const root = await recursive.resolveWorkspaceRoot(exec.agent);
11633
11891
  if (!root) return { error: toolError("NO_WORKSPACE") };
11634
- return await recursive.closeoutRun(root, args.runId.trim(), args.phase.trim(), exec.agent);
11892
+ return await recursive.closeoutRun(root, runId, args.phase.trim(), exec.agent);
11635
11893
  }
11636
11894
  });
11637
11895
  }
@@ -11641,6 +11899,16 @@ function createRecursiveCloseoutTool(recursive) {
11641
11899
  * `recursive_scratch` — read/write/append the run-scoped disposable scratchpad
11642
11900
  * (R5) under the CURRENT session workspace only (R1). Scratch is git-ignored
11643
11901
  * and never citable as an Input.
11902
+ *
11903
+ * A RUN ID IS A NAME, NOT A PATH HERE TOO. `scratchRun` joins it onto the run layer, and it then WRITES
11904
+ * (write/append) into the directory it landed on, so a path-shaped id does not merely fail to find a run:
11905
+ * with a `..\` segment it can find and write into a SIBLING of the run layer, because the runtime's
11906
+ * workspace-scoping check is `runDir.startsWith(runRoot)` — a string prefix test, not containment — and a
11907
+ * sibling directory whose name begins with `run` passes it. MEASURED pre-fix: `..\run-away` was accepted
11908
+ * and `scratchRun` wrote `scratch.md` into `<workspace>\.recursive\run-away\scratch\`. The rule is
11909
+ * `run-id.ts`; the gate sits here, at the boundary where the name enters, rather than in `scratchRun`, for
11910
+ * the same reason `recursive_init`'s does. Refusal shape is `recursive_init`'s, `BAD_RUN_ID` (RM1107),
11911
+ * composed identically: one rule, one message.
11644
11912
  */
11645
11913
  function createRecursiveScratchTool(recursive) {
11646
11914
  return defineTool({
@@ -11653,7 +11921,7 @@ function createRecursiveScratchTool(recursive) {
11653
11921
  },
11654
11922
  runId: {
11655
11923
  type: "string",
11656
- description: "Run id (e.g. 03-something). Required; must resolve inside the current workspace."
11924
+ description: "Run id — the NAME of the run directory under .recursive/run/ (e.g. 03-something), never a path: " + RUN_ID_RULE + ". Required; must resolve inside the current workspace."
11657
11925
  },
11658
11926
  target: {
11659
11927
  type: "string",
@@ -11676,6 +11944,8 @@ function createRecursiveScratchTool(recursive) {
11676
11944
  const runId = args.runId?.trim() ?? "";
11677
11945
  const target = args.target ?? "";
11678
11946
  if (!action || !runId || !target) return { error: toolError("MISSING_SCRATCH_ARGS") };
11947
+ const problem = runIdProblem(runId);
11948
+ if (problem !== null) return { error: toolError("BAD_RUN_ID", problem + " - run ids allow letters, digits, dot, underscore or dash only, no leading or trailing dot, no path separator, no drive specifier and no \"..\" segment - e.g. 01-calculator-lib, fixture-run") };
11679
11949
  if (target !== "md" && target !== "ts") return { error: toolError("BAD_TARGET") };
11680
11950
  const root = await recursive.resolveWorkspaceRoot(exec.agent);
11681
11951
  if (!root) return { error: toolError("NO_WORKSPACE") };
@@ -11689,6 +11959,22 @@ function createRecursiveScratchTool(recursive) {
11689
11959
  * `recursive_worktree` — create a linked git worktree for a run and/or
11690
11960
  * promote a branch up the dev/stage/main chain. Workspace-scoped: the
11691
11961
  * operations run under the SESSION's control-plane root only.
11962
+ *
11963
+ * A RUN ID IS A NAME, NOT A PATH HERE TOO, and this is the worst place to be without the rule: a `create`
11964
+ * builds TWO things out of the id — the linked worktree directory `.worktrees/<runId>` AND the git branch
11965
+ * `recursive/<runId>` (git accepts '/' inside a ref) — so a path-shaped id used to leave a worktree and a
11966
+ * ref behind, not just a folder. MEASURED pre-fix, per id, against a fresh repo: `nested/child-run`
11967
+ * returned ok:true and created BOTH `.worktrees/nested/child-run` and
11968
+ * `refs/heads/recursive/nested/child-run`, while the shapes git itself refuses as ref syntax
11969
+ * (`recursive//tmp/x`, `recursive/C:…`, `.hidden-run`, a trailing space) failed the worktree add and
11970
+ * created neither. The rule is `run-id.ts` and is not restated here; the gate sits at this boundary, ahead
11971
+ * of `createRunWorktree`, so the refusal no longer depends on git happening to dislike the ref name.
11972
+ *
11973
+ * The refusal is `BAD_RUN_ID` (RM1107), composed exactly as `recursive_init` composes it — same code, same
11974
+ * detail, same sentence. One message for one rule is what keeps a caller from having to learn a second
11975
+ * vocabulary for the same defect, and the shared remedy it carries ("call recursive_init again") is right
11976
+ * for this tool as well: a `create` for a run that does not exist yet is exactly what `recursive_init`
11977
+ * with `createWorktree: true` does.
11692
11978
  */
11693
11979
  function createRecursiveWorktreeTool(recursive) {
11694
11980
  return defineTool({
@@ -11697,7 +11983,7 @@ function createRecursiveWorktreeTool(recursive) {
11697
11983
  parameters: {
11698
11984
  runId: {
11699
11985
  type: "string",
11700
- description: "Run id the worktree is created for (e.g. 03-something). Required for create."
11986
+ description: "Run id the worktree is created for — the NAME of the run (e.g. 03-something), never a path: " + RUN_ID_RULE + ". A path-shaped id is refused for create. Required for create."
11701
11987
  },
11702
11988
  action: {
11703
11989
  type: "string",
@@ -11729,7 +12015,10 @@ function createRecursiveWorktreeTool(recursive) {
11729
12015
  if (!root) return { error: toolError("NO_WORKSPACE") };
11730
12016
  if (action === "create") {
11731
12017
  if (!args.runId || args.runId.trim() === "") return { error: toolError("MISSING_CREATE_RUN_ID") };
11732
- return recursive.createRunWorktree(root, args.runId.trim(), args.baseBranch?.trim() || void 0);
12018
+ const runId = args.runId.trim();
12019
+ const problem = runIdProblem(runId);
12020
+ if (problem !== null) return { error: toolError("BAD_RUN_ID", problem + " - run ids allow letters, digits, dot, underscore or dash only, no leading or trailing dot, no path separator, no drive specifier and no \"..\" segment - e.g. 01-calculator-lib, fixture-run") };
12021
+ return recursive.createRunWorktree(root, runId, args.baseBranch?.trim() || void 0);
11733
12022
  }
11734
12023
  if (action === "promote") {
11735
12024
  if (!args.fromBranch || !args.toBranch) return { error: toolError("MISSING_PROMOTE_BRANCHES") };
@@ -11748,6 +12037,14 @@ function createRecursiveWorktreeTool(recursive) {
11748
12037
  * (runtime.phaseRules -> phaseRulesFor) as the once-per-phase pre-step
11749
12038
  * reminder, so the agent can re-ask for the rules without re-injecting them on
11750
12039
  * every step. Returns { error } when no active phase is found.
12040
+ *
12041
+ * A RUN ID IS A NAME, NOT A PATH HERE TOO, INCLUDING WHEN IT IS OMITTED. Omitted and path-shaped are
12042
+ * different cases and must stay different: omitted means "the latest run by mtime", which `resolveRunDir`
12043
+ * answers by DISCOVERY rather than by joining anything, and that case is untouched below. A path-shaped id,
12044
+ * by contrast, is joined by `phaseRules` -> `resolveRunDir` and then read, and `recordInjection` WRITES
12045
+ * `memory-injections.json` under whatever directory it resolved to — with no scoping check at all on this
12046
+ * path. So the gate fires only on an id that was actually supplied, and `run-id.ts` owns the rule. The
12047
+ * refusal is `recursive_init`'s, `BAD_RUN_ID` (RM1107), composed identically.
11751
12048
  */
11752
12049
  function createRecursivePhaseTool(recursive) {
11753
12050
  return defineTool({
@@ -11755,7 +12052,7 @@ function createRecursivePhaseTool(recursive) {
11755
12052
  description: "Return the lint rules + instructions for the current recursive-mode phase (required sections, gates, TDD/QA notes). Call once when entering a new phase; the same rules are also auto-injected once per phase transition.",
11756
12053
  parameters: { runId: {
11757
12054
  type: "string",
11758
- description: "Optional run id (defaults to the latest run by mtime)"
12055
+ description: "Optional run id — the NAME of the run directory under .recursive/run/ (e.g. 03-something), never a path: " + RUN_ID_RULE + ". Omit it for the latest run by mtime."
11759
12056
  } },
11760
12057
  output: {
11761
12058
  schema: { type: "json" },
@@ -11765,7 +12062,12 @@ function createRecursivePhaseTool(recursive) {
11765
12062
  }]
11766
12063
  },
11767
12064
  async execute(args, exec) {
11768
- const result = await recursive.phaseRules(args.runId, exec.agent);
12065
+ const runId = args.runId?.trim();
12066
+ if (runId !== void 0) {
12067
+ const problem = runId === "" ? "runId is empty" : runIdProblem(runId);
12068
+ if (problem !== null) return { error: toolError("BAD_RUN_ID", problem + " - run ids allow letters, digits, dot, underscore or dash only, no leading or trailing dot, no path separator, no drive specifier and no \"..\" segment - e.g. 01-calculator-lib, fixture-run") };
12069
+ }
12070
+ const result = await recursive.phaseRules(runId, exec.agent);
11769
12071
  if (!result) return { error: toolError("NO_PHASE") };
11770
12072
  return result;
11771
12073
  }