@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/README.md +15 -4
- package/lib/errors.d.ts +34 -1
- package/lib/index.js +350 -48
- package/lib/recursive_ask.tool.d.ts +93 -16
- package/lib/recursive_closeout.tool.d.ts +10 -0
- package/lib/recursive_init.tool.d.ts +8 -0
- package/lib/recursive_phase.tool.d.ts +8 -0
- package/lib/recursive_scratch.tool.d.ts +10 -0
- package/lib/recursive_worktree.tool.d.ts +16 -0
- package/lib/run-id.d.ts +62 -0
- package/package.json +1 -1
- package/src/errors.ts +35 -2
- package/src/recursive_ask.tool.ts +190 -41
- package/src/recursive_closeout.tool.ts +53 -35
- package/src/recursive_init.tool.ts +20 -4
- package/src/recursive_phase.tool.ts +22 -2
- package/src/recursive_scratch.tool.ts +17 -1
- package/src/recursive_worktree.tool.ts +27 -2
- package/src/run-id.ts +100 -0
- package/src/run-start.ts +9 -3
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
|
|
5475
|
-
next: "
|
|
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
|
|
6858
|
-
*
|
|
6859
|
-
*
|
|
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
|
-
* ⚠
|
|
7238
|
-
*
|
|
7239
|
-
*
|
|
7240
|
-
*
|
|
7241
|
-
*
|
|
7242
|
-
*
|
|
7243
|
-
*
|
|
7244
|
-
*
|
|
7245
|
-
*
|
|
7246
|
-
* (
|
|
7247
|
-
*
|
|
7248
|
-
*
|
|
7249
|
-
*
|
|
7250
|
-
*
|
|
7251
|
-
*
|
|
7252
|
-
|
|
7253
|
-
|
|
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
|
|
7256
|
-
if (
|
|
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 {
|
|
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
|
-
*
|
|
7279
|
-
*
|
|
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
|
-
*
|
|
7282
|
-
*
|
|
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
|
|
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)
|
|
7298
|
-
|
|
7299
|
-
return
|
|
7300
|
-
|
|
7301
|
-
|
|
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
|
-
|
|
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(
|
|
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,
|
|
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
|
-
|
|
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 (
|
|
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
|
|
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
|
}
|