@try-works/dsh-recursive-mode 0.4.5 → 0.4.7
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/client/contract.d.ts +10 -0
- package/lib/client/doc-viewer.d.ts +50 -0
- package/lib/client/spec-sheet-view.d.ts +237 -0
- package/lib/client/spec-sheet.d.ts +103 -0
- package/lib/client/use-live.d.ts +17 -1
- package/lib/client.js +988 -14
- package/lib/errors.d.ts +47 -1
- package/lib/index.js +483 -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/lib/run-spec.d.ts +78 -0
- package/lib/run-start.d.ts +27 -0
- package/package.json +1 -1
- package/src/client/contract.ts +10 -0
- package/src/client/doc-viewer.tsx +131 -12
- package/src/client/slots.ts +12 -0
- package/src/client/spec-sheet-view.ts +325 -0
- package/src/client/spec-sheet.tsx +409 -0
- package/src/client/styles.ts +298 -1
- package/src/client/use-live.ts +23 -2
- package/src/errors.ts +48 -2
- package/src/recursive_ask.tool.ts +217 -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-spec.ts +148 -0
- package/src/run-start.ts +68 -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",
|
|
@@ -5462,17 +5480,45 @@ const TOOL_ERRORS = {
|
|
|
5462
5480
|
problem: "the run has unresolved delegated work, so this phase cannot lock yet",
|
|
5463
5481
|
next: "call recursive_status to see the pending delegation, have the child write its reply.md, then lock again"
|
|
5464
5482
|
},
|
|
5483
|
+
/**
|
|
5484
|
+
* THE ORDERING DEFECT, AS A CODE. `recursive_ask gate=run-start` used to raise "start this run or hold?"
|
|
5485
|
+
* over a Phase 0 document that was still the scaffold `recursive_init` wrote — placeholder requirements,
|
|
5486
|
+
* unchecked lists, `FAIL` gates — and nothing put that document in front of the person either. The owner:
|
|
5487
|
+
* *"i was never shown the spec before that so how could i approve if i havent seen it"*. Approving an
|
|
5488
|
+
* unfilled template is not a decision about a spec, so the gate refuses to be raised until there is one.
|
|
5489
|
+
*/
|
|
5490
|
+
RUN_START_SPEC_UNFILLED: {
|
|
5491
|
+
code: "RM4404",
|
|
5492
|
+
klass: "state",
|
|
5493
|
+
problem: "the Phase 0 requirements document is still the unfilled template, so there is no run spec for a person to approve",
|
|
5494
|
+
next: "fill the requirements document in (define the requirement ids and their acceptance criteria, and complete the TODO list) and then call recursive_ask with gate: run-start again"
|
|
5495
|
+
},
|
|
5465
5496
|
RUN_START_NO_CHANNEL: {
|
|
5466
5497
|
code: "RM5502",
|
|
5467
5498
|
klass: "runtime",
|
|
5468
5499
|
problem: "the run-start gate needs an answer, and this composition mounts no user-questions channel to ask one directly",
|
|
5469
5500
|
next: "call recursive_ask with gate: run-start and no answer to surface the question, then retry with answer: \"Start run\""
|
|
5470
5501
|
},
|
|
5502
|
+
/**
|
|
5503
|
+
* ⚠ RM5503 USED TO LIE. Its text asserted "so no person was asked" for EVERY cause, because the caller
|
|
5504
|
+
* that produced it had already thrown the cause away — and a live session showed the cost: the gate
|
|
5505
|
+
* failed 22.9 s into the call with a reason nobody could see, and its `Next:` clause prescribed the very
|
|
5506
|
+
* call that had just failed, so no route to start a run remained. The problem statement now claims only
|
|
5507
|
+
* what the gate knows (no decision came back), and the cause travels in the `detail` the caller
|
|
5508
|
+
* supplies. `RUN_START_ANSWER_UNUSABLE` carries the one case this entry must NOT cover: a person was
|
|
5509
|
+
* reached and their answer was not an approval.
|
|
5510
|
+
*/
|
|
5471
5511
|
RUN_START_UNANSWERED: {
|
|
5472
5512
|
code: "RM5503",
|
|
5473
5513
|
klass: "runtime",
|
|
5474
|
-
problem: "the
|
|
5475
|
-
next: "
|
|
5514
|
+
problem: "the run-start question reached no decision: the mounted user-questions channel failed before a person answered it",
|
|
5515
|
+
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"
|
|
5516
|
+
},
|
|
5517
|
+
RUN_START_ANSWER_UNUSABLE: {
|
|
5518
|
+
code: "RM5504",
|
|
5519
|
+
klass: "runtime",
|
|
5520
|
+
problem: "a person was asked to start this run and their answer was not one of the labels the run-start gate offered",
|
|
5521
|
+
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
5522
|
},
|
|
5477
5523
|
RUNTIME_REFUSED: {
|
|
5478
5524
|
code: "RM5501",
|
|
@@ -6831,6 +6877,79 @@ function extractAndGroup(runner, env, options = {}) {
|
|
|
6831
6877
|
};
|
|
6832
6878
|
}
|
|
6833
6879
|
//#endregion
|
|
6880
|
+
//#region src/run-spec.ts
|
|
6881
|
+
/** The named evidence classes, so a reader can tell a placeholder from an unmet gate. */
|
|
6882
|
+
const ARTIFACT_MARKER_IDS = {
|
|
6883
|
+
placeholder: "placeholder",
|
|
6884
|
+
uncheckedTodo: "unchecked-todo",
|
|
6885
|
+
failedGate: "failed-gate"
|
|
6886
|
+
};
|
|
6887
|
+
/** Every marker, in the order they are reported for a single line. */
|
|
6888
|
+
const MARKER_PATTERNS = [
|
|
6889
|
+
{
|
|
6890
|
+
id: ARTIFACT_MARKER_IDS.placeholder,
|
|
6891
|
+
re: /(^\s*\.\.\.\s*$)|(\[[^[\]\n<>]{2,120}\])|(<[^<>\n]{2,120}>)/
|
|
6892
|
+
},
|
|
6893
|
+
{
|
|
6894
|
+
id: ARTIFACT_MARKER_IDS.uncheckedTodo,
|
|
6895
|
+
re: /^\s*[-*]\s*\[ \]/
|
|
6896
|
+
},
|
|
6897
|
+
{
|
|
6898
|
+
id: ARTIFACT_MARKER_IDS.failedGate,
|
|
6899
|
+
re: /^\s*(Coverage|Approval):\s*FAIL\b/i
|
|
6900
|
+
}
|
|
6901
|
+
];
|
|
6902
|
+
/**
|
|
6903
|
+
* Which marker a single line carries, or null.
|
|
6904
|
+
*
|
|
6905
|
+
* Exported because the client prints the marker NAMES beside the quoted lines, and a second classifier that
|
|
6906
|
+
* re-derived them would be a second answer to the same question.
|
|
6907
|
+
*/
|
|
6908
|
+
function markerIdsOnLine(line) {
|
|
6909
|
+
const ids = [];
|
|
6910
|
+
for (const pattern of MARKER_PATTERNS) if (pattern.re.test(line)) ids.push(pattern.id);
|
|
6911
|
+
return ids;
|
|
6912
|
+
}
|
|
6913
|
+
/**
|
|
6914
|
+
* Classify one artifact's text.
|
|
6915
|
+
*
|
|
6916
|
+
* A verdict of `unfilled` means the document still carries the template's own placeholder text — the
|
|
6917
|
+
* evidence travels with it, line by line, so the refusal (and the client notice) can name what is missing
|
|
6918
|
+
* instead of asserting a state the reader cannot check.
|
|
6919
|
+
*/
|
|
6920
|
+
function classifyArtifact(text) {
|
|
6921
|
+
const lines = text.split(/\r?\n/);
|
|
6922
|
+
const hits = [];
|
|
6923
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
6924
|
+
const line = lines[i];
|
|
6925
|
+
for (const id of markerIdsOnLine(line)) hits.push({
|
|
6926
|
+
id,
|
|
6927
|
+
line: i + 1,
|
|
6928
|
+
text: line.trim()
|
|
6929
|
+
});
|
|
6930
|
+
}
|
|
6931
|
+
return {
|
|
6932
|
+
verdict: hits.some((hit) => hit.id === ARTIFACT_MARKER_IDS.placeholder) ? "unfilled" : "filled",
|
|
6933
|
+
hits
|
|
6934
|
+
};
|
|
6935
|
+
}
|
|
6936
|
+
/** The unfilled evidence only (what a refusal names). */
|
|
6937
|
+
function unfilledEvidence(result) {
|
|
6938
|
+
return result.hits.filter((hit) => hit.id === ARTIFACT_MARKER_IDS.placeholder);
|
|
6939
|
+
}
|
|
6940
|
+
/**
|
|
6941
|
+
* One line naming what is missing, for a refusal sentence.
|
|
6942
|
+
*
|
|
6943
|
+
* The line number and the text are both quoted: "line 12: <short title>" is a thing a reader can go and
|
|
6944
|
+
* look at, while "the requirements are not filled in" is an assertion they would have to take on trust.
|
|
6945
|
+
*/
|
|
6946
|
+
function describeEvidence(hits, limit = 3) {
|
|
6947
|
+
const shown = hits.slice(0, limit).map((hit) => "line " + String(hit.line) + ": " + hit.text);
|
|
6948
|
+
const rest = hits.length - shown.length;
|
|
6949
|
+
const suffix = rest > 0 ? " (and " + String(rest) + " more)" : "";
|
|
6950
|
+
return shown.join(" | ") + suffix;
|
|
6951
|
+
}
|
|
6952
|
+
//#endregion
|
|
6834
6953
|
//#region src/run-start.ts
|
|
6835
6954
|
/**
|
|
6836
6955
|
* PHASE 0 — STARTING A RUN IS A HUMAN DECISION, NOT A SIDE EFFECT OF SCAFFOLDING.
|
|
@@ -6854,9 +6973,15 @@ function extractAndGroup(runner, env, options = {}) {
|
|
|
6854
6973
|
* and nothing else, so the presence of a `Run Start` line is never on its own consent.
|
|
6855
6974
|
* 2. IT IS ASKED, NOT ASSUMED. When the composition mounts `ctx.userQuestions` — the harness's own
|
|
6856
6975
|
* 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
|
-
*
|
|
6976
|
+
* only their selection is recorded; a caller-supplied answer cannot stand in for it. A channel that
|
|
6977
|
+
* RESOLVES with an answer the gate does not recognise is a person's decision the gate cannot record
|
|
6978
|
+
* and it ends the call (RM5504). A channel that FAILS ends the call too (RM5503), naming the cause the
|
|
6979
|
+
* channel threw — and there the caller may take the relayed route deliberately, with `relay=true`,
|
|
6980
|
+
* which the result reports as `source: "relayed"` rather than as a person's own selection, so a
|
|
6981
|
+
* composition whose channel cannot deliver the question can still start a run. A failure that means
|
|
6982
|
+
* the question was cancelled, aborted, or timed out is never relayable. Only a composition with no
|
|
6983
|
+
* channel at all falls back to the relayed answer unconditionally, which is the contract the other
|
|
6984
|
+
* three gates have.
|
|
6860
6985
|
* 3. THE GOAL CANNOT BE CREATED WITHOUT IT. `syncRunGoal` refuses to create a goal for a run whose
|
|
6861
6986
|
* approval record is absent, in EVERY branch that would create one — not only the "no goal yet"
|
|
6862
6987
|
* branch. That is the property `tests/run-start-approval.spec.ts` asserts, because a single
|
|
@@ -6947,6 +7072,43 @@ function readRunStartApproval(root, runId) {
|
|
|
6947
7072
|
* the same string instead of re-typing it (a re-typed reason is a caller that silently stops matching).
|
|
6948
7073
|
*/
|
|
6949
7074
|
const RUN_START_NOT_APPROVED = "run not started: phase 0 approval has not been granted";
|
|
7075
|
+
/**
|
|
7076
|
+
* PHASE 0 — THE GATE CANNOT BE RAISED BEFORE THERE IS A SPEC TO DECIDE ABOUT.
|
|
7077
|
+
*
|
|
7078
|
+
* THE DEFECT. `recursive_init` scaffolds Phase 0 as a TEMPLATE, and `recursive_ask gate=run-start` raised
|
|
7079
|
+
* "start this run or hold?" over it immediately — while every requirement was still `<short title>`, every
|
|
7080
|
+
* acceptance criterion was still `[observable condition 1]`, and nothing put the document in front of the
|
|
7081
|
+
* person at all. The owner: *"the card ui for accepting the spec appeared, but i was never shown the spec
|
|
7082
|
+
* before that so how could i approve if i havent seen it"*. Approving an unfilled template is not a decision
|
|
7083
|
+
* about a spec; there is no spec yet, and a card that asks the question anyway teaches a person to answer
|
|
7084
|
+
* without reading.
|
|
7085
|
+
*
|
|
7086
|
+
* ⚠ WHAT THIS DOES *NOT* TOUCH. It does not weaken the gate's own contract, it does not add a second way to
|
|
7087
|
+
* start a run, and it does not make the plugin the decider: it only refuses to ASK. A person's own answer
|
|
7088
|
+
* still wins (`recordRunStartAnswer` is unchanged), a spec still creates no goal, and cancellation / abort /
|
|
7089
|
+
* timeout are still unrelayable. The check runs BEFORE the question is put to anybody, so no card is shown
|
|
7090
|
+
* for a document that cannot be approved meaningfully.
|
|
7091
|
+
*
|
|
7092
|
+
* ⚠ AND IT IS A CHECK ON THE DOCUMENT, NOT ON THE CALLER. A `runId` that does not resolve is not this
|
|
7093
|
+
* refusal's business — the ask path already reports that — so the guard says `ok: true` there and lets the
|
|
7094
|
+
* existing route handle it.
|
|
7095
|
+
*/
|
|
7096
|
+
function runStartSpecGuard(root, runId) {
|
|
7097
|
+
const content = readRunStartArtifact(root, runId);
|
|
7098
|
+
if (content === null) return {
|
|
7099
|
+
ok: false,
|
|
7100
|
+
reason: toolError("RUN_START_SPEC_UNFILLED", "there is no Phase 0 document to approve: " + runStartArtifactPath(root, runId) + " does not exist yet")
|
|
7101
|
+
};
|
|
7102
|
+
const verdict = classifyArtifact(content);
|
|
7103
|
+
if (verdict.verdict === "filled") return { ok: true };
|
|
7104
|
+
const evidence = unfilledEvidence(verdict);
|
|
7105
|
+
const context = verdict.hits.filter((hit) => hit.id !== "placeholder");
|
|
7106
|
+
const contextNote = context.length === 0 ? "" : " (it also carries " + String(context.length) + " unfinished marker(s) of its own, starting at line " + String(context[0]?.line ?? 0) + ")";
|
|
7107
|
+
return {
|
|
7108
|
+
ok: false,
|
|
7109
|
+
reason: toolError("RUN_START_SPEC_UNFILLED", "00-requirements.md for run " + JSON.stringify(runId) + " still carries the template scaffold" + contextNote + ": " + describeEvidence(evidence))
|
|
7110
|
+
};
|
|
7111
|
+
}
|
|
6950
7112
|
//#endregion
|
|
6951
7113
|
//#region src/recursive_ask.tool.ts
|
|
6952
7114
|
/**
|
|
@@ -7162,7 +7324,7 @@ function pendingGateFor(artifactFile, artifactText) {
|
|
|
7162
7324
|
function createRecursiveAskTool(recursive) {
|
|
7163
7325
|
return defineTool({
|
|
7164
7326
|
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.",
|
|
7327
|
+
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. The run-start gate is REFUSED while the Phase 0 requirements document is still the unfilled template — the refusal quotes the placeholder lines, and there is nothing to approve until they are written. One ask per step.",
|
|
7166
7328
|
parameters: {
|
|
7167
7329
|
gate: {
|
|
7168
7330
|
type: "string",
|
|
@@ -7179,6 +7341,10 @@ function createRecursiveAskTool(recursive) {
|
|
|
7179
7341
|
answer: {
|
|
7180
7342
|
type: "string",
|
|
7181
7343
|
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(" | ") + "."
|
|
7344
|
+
},
|
|
7345
|
+
relay: {
|
|
7346
|
+
type: "boolean",
|
|
7347
|
+
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
7348
|
}
|
|
7183
7349
|
},
|
|
7184
7350
|
output: {
|
|
@@ -7193,9 +7359,20 @@ function createRecursiveAskTool(recursive) {
|
|
|
7193
7359
|
const runId = args.runId?.trim() ?? "";
|
|
7194
7360
|
if (runId === "") return { error: toolError("MISSING_RUN_ID") };
|
|
7195
7361
|
if (!askGateIds().includes(gateId)) return { error: toolError("BAD_ASK_GATE", "gate must be one of " + askGateIds().join(" | ")) };
|
|
7362
|
+
if (args.relay === true && !isRunStartGate(gateId)) return { error: toolError("RELAY_ONLY_FOR_RUN_START", "gate is " + gateId) };
|
|
7196
7363
|
const root = await recursive.resolveWorkspaceRoot(exec.agent);
|
|
7197
7364
|
if (!root) return { error: toolError("NO_WORKSPACE") };
|
|
7198
7365
|
const artifact = (isRunStartGate(gateId) ? RUN_START_ARTIFACT : args.artifact ?? GATE_DEFAULT_ARTIFACT[gateId]).trim();
|
|
7366
|
+
if (isRunStartGate(gateId)) {
|
|
7367
|
+
const guard = runStartSpecGuard(root, runId);
|
|
7368
|
+
if (!guard.ok) return {
|
|
7369
|
+
error: guard.reason,
|
|
7370
|
+
gate: RUN_START_GATE_ID,
|
|
7371
|
+
runId,
|
|
7372
|
+
artifact: RUN_START_ARTIFACT,
|
|
7373
|
+
question: buildAskQuestionFor(RUN_START_GATE_ID)
|
|
7374
|
+
};
|
|
7375
|
+
}
|
|
7199
7376
|
let question;
|
|
7200
7377
|
try {
|
|
7201
7378
|
question = buildAskQuestionFor(gateId);
|
|
@@ -7217,7 +7394,7 @@ function createRecursiveAskTool(recursive) {
|
|
|
7217
7394
|
return { error: toolError("BAD_ASK_ANSWER", err instanceof Error ? err.message : String(err)) };
|
|
7218
7395
|
}
|
|
7219
7396
|
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);
|
|
7397
|
+
if (isRunStartGate(gateId)) return recordRunStartAnswer(recursive, root, runId, answer, exec, args.relay === true);
|
|
7221
7398
|
const marker = answerMarker(gateId, answer);
|
|
7222
7399
|
const written = recursive.recordAskAnswer(root, runId, artifact, marker);
|
|
7223
7400
|
return {
|
|
@@ -7234,28 +7411,83 @@ function createRecursiveAskTool(recursive) {
|
|
|
7234
7411
|
/**
|
|
7235
7412
|
* PHASE 0 — record the answer to the run-start gate, and start the run only if it says so.
|
|
7236
7413
|
*
|
|
7237
|
-
* ⚠
|
|
7238
|
-
*
|
|
7239
|
-
*
|
|
7240
|
-
*
|
|
7241
|
-
*
|
|
7242
|
-
*
|
|
7243
|
-
*
|
|
7244
|
-
*
|
|
7245
|
-
*
|
|
7246
|
-
* (
|
|
7247
|
-
*
|
|
7248
|
-
*
|
|
7249
|
-
*
|
|
7250
|
-
*
|
|
7251
|
-
*
|
|
7252
|
-
|
|
7253
|
-
|
|
7414
|
+
* ⚠ THREE OUTCOMES, AND TELLING THEM APART IS THE FIX. The first version of this function collapsed all of
|
|
7415
|
+
* them into one `null`: "the channel threw", "the channel resolved with something unrecognisable", and
|
|
7416
|
+
* "nobody answered" produced the same refusal, whose text asserted a cause ("so no person was asked") the
|
|
7417
|
+
* plugin had already thrown away. A live session paid for that: the call failed after 22.9 s, the operator
|
|
7418
|
+
* could not be told why, and the refusal's own advice prescribed the call that had just failed. So:
|
|
7419
|
+
*
|
|
7420
|
+
* 1. A PERSON WAS REACHED (`unusable`): the channel resolved, so somebody answered, and their answer is
|
|
7421
|
+
* not a label this gate offered — a skip, a custom value, several labels at once. That is a DECISION
|
|
7422
|
+
* the gate cannot record, and it is final: neither the caller's `answer` nor `relay=true` may replace
|
|
7423
|
+
* it. (RM5504)
|
|
7424
|
+
* 2. THE CHANNEL FAILED (`unavailable`): no decision came back at all, and the refusal NAMES THE CAUSE
|
|
7425
|
+
* from the error the channel threw. Here the run can still be started, because a composition whose
|
|
7426
|
+
* channel cannot deliver the question would otherwise be unable to start any run — but only by the
|
|
7427
|
+
* caller asking for the relay in so many words (`relay=true`), which the result reports as
|
|
7428
|
+
* `source: "relayed"` rather than as a person's own selection. (RM5503)
|
|
7429
|
+
* 3. NO CHANNEL IS MOUNTED: the relayed answer is the only possible source, exactly as before. (RM5502
|
|
7430
|
+
* when there is no answer either)
|
|
7431
|
+
*
|
|
7432
|
+
* ⚠ AND A CANCELLED OR CLOSED QUESTION IS NEVER RELAYABLE. `ASK_CANCELLED`, `ASK_ABORTED` and
|
|
7433
|
+
* `ASK_TIMED_OUT` are the codes that mean the question was settled from outside this gate — the card was
|
|
7434
|
+
* dismissed, the turn was cancelled, or a foreground window ended. The relay is refused for those, so a
|
|
7435
|
+
* question the operator stopped cannot be turned into an approval by asking again in the same breath.
|
|
7436
|
+
* Every other failure is a composition or capability failure — the question reached nobody — which is the
|
|
7437
|
+
* class the relay exists for.
|
|
7438
|
+
*
|
|
7439
|
+
* ⚠ WHAT THE GATE STILL DOES *NOT* CLAIM: a relayed approval is a relayed approval. The plugin cannot
|
|
7440
|
+
* verify that a person gave the label, and it does not pretend otherwise — the result's `source` and
|
|
7441
|
+
* `channel` fields say where the decision came from, and a direct selection is preferred whenever the
|
|
7442
|
+
* channel can produce one.
|
|
7443
|
+
*/
|
|
7444
|
+
async function recordRunStartAnswer(recursive, root, runId, answer, exec, relay = false) {
|
|
7445
|
+
const question = buildAskQuestionFor(RUN_START_GATE_ID);
|
|
7254
7446
|
const channel = recursive.userQuestionsChannel;
|
|
7255
|
-
const
|
|
7256
|
-
if (
|
|
7447
|
+
const channelOutcome = channel ? await askRunStartDirectly(channel, exec) : null;
|
|
7448
|
+
if (channelOutcome !== null && channelOutcome.kind === "unusable") return {
|
|
7449
|
+
error: toolError("RUN_START_ANSWER_UNUSABLE", channelOutcome.detail),
|
|
7450
|
+
gate: RUN_START_GATE_ID,
|
|
7451
|
+
runId,
|
|
7452
|
+
artifact: RUN_START_ARTIFACT,
|
|
7453
|
+
question
|
|
7454
|
+
};
|
|
7455
|
+
if (channelOutcome !== null && channelOutcome.kind === "unavailable") {
|
|
7456
|
+
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";
|
|
7457
|
+
if (!relay || !channelOutcome.relayable) return {
|
|
7458
|
+
error: toolError("RUN_START_UNANSWERED", channelOutcome.detail + " (" + blocked + ")"),
|
|
7459
|
+
gate: RUN_START_GATE_ID,
|
|
7460
|
+
runId,
|
|
7461
|
+
artifact: RUN_START_ARTIFACT,
|
|
7462
|
+
question,
|
|
7463
|
+
channel: {
|
|
7464
|
+
outcome: "unavailable",
|
|
7465
|
+
cause: channelOutcome.cause,
|
|
7466
|
+
relayable: channelOutcome.relayable
|
|
7467
|
+
}
|
|
7468
|
+
};
|
|
7469
|
+
if (answer === void 0) return {
|
|
7470
|
+
error: toolError("RUN_START_UNANSWERED", channelOutcome.detail + " (the relay was authorised but no answer was supplied, so there is no decision to record)"),
|
|
7471
|
+
gate: RUN_START_GATE_ID,
|
|
7472
|
+
runId,
|
|
7473
|
+
artifact: RUN_START_ARTIFACT,
|
|
7474
|
+
question,
|
|
7475
|
+
channel: {
|
|
7476
|
+
outcome: "unavailable",
|
|
7477
|
+
cause: channelOutcome.cause,
|
|
7478
|
+
relayable: channelOutcome.relayable
|
|
7479
|
+
}
|
|
7480
|
+
};
|
|
7481
|
+
}
|
|
7482
|
+
const fromChannel = channelOutcome !== null && channelOutcome.kind === "answered" ? channelOutcome.answer : null;
|
|
7257
7483
|
const final = fromChannel ?? answer;
|
|
7258
|
-
if (final === void 0) return {
|
|
7484
|
+
if (final === void 0) return {
|
|
7485
|
+
error: toolError("RUN_START_NO_CHANNEL"),
|
|
7486
|
+
gate: RUN_START_GATE_ID,
|
|
7487
|
+
runId,
|
|
7488
|
+
artifact: RUN_START_ARTIFACT,
|
|
7489
|
+
question
|
|
7490
|
+
};
|
|
7259
7491
|
let decided;
|
|
7260
7492
|
try {
|
|
7261
7493
|
decided = validateAskAnswerFor(RUN_START_GATE_ID, final);
|
|
@@ -7268,6 +7500,12 @@ async function recordRunStartAnswer(recursive, root, runId, answer, exec) {
|
|
|
7268
7500
|
answer: decided,
|
|
7269
7501
|
artifact: RUN_START_ARTIFACT,
|
|
7270
7502
|
source: fromChannel === null ? "relayed" : "user-questions",
|
|
7503
|
+
...channelOutcome !== null && channelOutcome.kind === "unavailable" ? { channel: {
|
|
7504
|
+
outcome: "unavailable",
|
|
7505
|
+
cause: channelOutcome.cause,
|
|
7506
|
+
relayable: channelOutcome.relayable,
|
|
7507
|
+
relayed: true
|
|
7508
|
+
} } : {},
|
|
7271
7509
|
path: outcome.path,
|
|
7272
7510
|
replaced: outcome.replaced,
|
|
7273
7511
|
armed: outcome.ok && outcome.goal.ok,
|
|
@@ -7275,16 +7513,66 @@ async function recordRunStartAnswer(recursive, root, runId, answer, exec) {
|
|
|
7275
7513
|
};
|
|
7276
7514
|
}
|
|
7277
7515
|
/**
|
|
7278
|
-
*
|
|
7279
|
-
*
|
|
7516
|
+
* ⚠ THE CODES THAT MEAN THE QUESTION WAS CANCELLED OR CLOSED rather than never delivered: the person
|
|
7517
|
+
* dismissed the card, their turn was cancelled, or a foreground window ended. A caller may not convert any
|
|
7518
|
+
* of those into an approval by asking for the relay in the same breath. Every other failure means the
|
|
7519
|
+
* question reached nobody — a composition or capability failure, which is the class the relay exists for.
|
|
7520
|
+
*/
|
|
7521
|
+
const NON_RELAYABLE_CHANNEL_CODES = [
|
|
7522
|
+
"ASK_CANCELLED",
|
|
7523
|
+
"ASK_ABORTED",
|
|
7524
|
+
"ASK_TIMED_OUT"
|
|
7525
|
+
];
|
|
7526
|
+
/**
|
|
7527
|
+
* Name the failure of one `ask()` call, without inventing anything about it.
|
|
7528
|
+
*
|
|
7529
|
+
* The cause is the error's own `code` when it has one (the harness's `UserQuestionError` carries
|
|
7530
|
+
* `NO_PROVIDER`, `CALLER_NOT_LIVE`, `DELEGATED_CALLER`, `ASK_ABORTED`, …), else its `name`, else its
|
|
7531
|
+
* JavaScript type. `detail` keeps the message verbatim so a reader sees the channel's own words rather
|
|
7532
|
+
* than this plugin's paraphrase — the paraphrase is exactly how the previous version came to assert a
|
|
7533
|
+
* cause nobody had.
|
|
7534
|
+
*/
|
|
7535
|
+
function classifyChannelFailure(err) {
|
|
7536
|
+
const code = err?.code;
|
|
7537
|
+
const name = err instanceof Error ? err.name : typeof err;
|
|
7538
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
7539
|
+
const hasCode = typeof code === "string" && code.trim() !== "";
|
|
7540
|
+
const cause = hasCode ? code : name;
|
|
7541
|
+
const relayable = !(hasCode && NON_RELAYABLE_CHANNEL_CODES.includes(code));
|
|
7542
|
+
return {
|
|
7543
|
+
cause,
|
|
7544
|
+
detail: "channel threw " + name + "[" + cause + "]: " + message,
|
|
7545
|
+
relayable
|
|
7546
|
+
};
|
|
7547
|
+
}
|
|
7548
|
+
/** Describe an answer that arrived but is not a decision this gate can record. */
|
|
7549
|
+
function describeUnusableAnswer(item) {
|
|
7550
|
+
const offered = RUN_START_GATE.options.map((option) => option.label);
|
|
7551
|
+
const list = (values) => JSON.stringify(values.join(" | "));
|
|
7552
|
+
if (item === void 0) return "the channel resolved with no answer for question " + JSON.stringify(RUN_START_GATE.id) + " at all";
|
|
7553
|
+
const raw = item.selected ?? [];
|
|
7554
|
+
const custom = item.custom?.trim() ?? "";
|
|
7555
|
+
if (raw.length === 0 && custom === "") return "the person skipped the question, and a skip is not an approval";
|
|
7556
|
+
if (raw.length === 0) return "the person answered " + JSON.stringify(custom) + " as free text rather than one of " + list(offered);
|
|
7557
|
+
const recognised = raw.filter((label) => offered.includes(label));
|
|
7558
|
+
if (recognised.length === 0) return "the person selected " + list(raw) + ", and none of those name a label this gate offered (" + offered.join(" | ") + ")";
|
|
7559
|
+
if (recognised.length === raw.length) return "the person selected " + list(raw) + ", and an approval is exactly one of " + list(offered);
|
|
7560
|
+
return "the person selected " + list(raw) + ", of which only " + list(recognised) + " name this gate's labels " + list(offered);
|
|
7561
|
+
}
|
|
7562
|
+
/**
|
|
7563
|
+
* Ask the run-start question through the blocking channel and report WHAT HAPPENED.
|
|
7280
7564
|
*
|
|
7281
|
-
*
|
|
7282
|
-
*
|
|
7565
|
+
* ⚠ THE CATCH IS THE POINT. It used to be `catch { return null }` — a blocking human question whose
|
|
7566
|
+
* failure cause was erased at the exact moment the cause was the only thing worth knowing. Every path out
|
|
7567
|
+
* of this function now says which path it was.
|
|
7568
|
+
*
|
|
7569
|
+
* The selection is filtered to the gate's OWN labels: a question a UI answered with a free-text custom
|
|
7570
|
+
* value must not become an approval just because it arrived on the right channel.
|
|
7283
7571
|
*/
|
|
7284
7572
|
async function askRunStartDirectly(channel, exec) {
|
|
7285
7573
|
const known = RUN_START_GATE.options.map((option) => option.label);
|
|
7286
7574
|
try {
|
|
7287
|
-
const
|
|
7575
|
+
const item = (await channel.ask({
|
|
7288
7576
|
questions: [{
|
|
7289
7577
|
id: RUN_START_GATE.id,
|
|
7290
7578
|
header: RUN_START_GATE.header,
|
|
@@ -7294,11 +7582,21 @@ async function askRunStartDirectly(channel, exec) {
|
|
|
7294
7582
|
agent: exec.agent,
|
|
7295
7583
|
signal: exec.signal,
|
|
7296
7584
|
wait: { callId: exec.callId }
|
|
7297
|
-
})).answers.find((entry) => entry.id === RUN_START_GATE.id)
|
|
7298
|
-
|
|
7299
|
-
return
|
|
7300
|
-
|
|
7301
|
-
|
|
7585
|
+
})).answers.find((entry) => entry.id === RUN_START_GATE.id);
|
|
7586
|
+
const selected = item?.selected?.filter((label) => known.includes(label)) ?? [];
|
|
7587
|
+
if (selected.length !== 1) return {
|
|
7588
|
+
kind: "unusable",
|
|
7589
|
+
detail: describeUnusableAnswer(item)
|
|
7590
|
+
};
|
|
7591
|
+
return {
|
|
7592
|
+
kind: "answered",
|
|
7593
|
+
answer: selected[0]
|
|
7594
|
+
};
|
|
7595
|
+
} catch (err) {
|
|
7596
|
+
return {
|
|
7597
|
+
kind: "unavailable",
|
|
7598
|
+
...classifyChannelFailure(err)
|
|
7599
|
+
};
|
|
7302
7600
|
}
|
|
7303
7601
|
}
|
|
7304
7602
|
//#endregion
|
|
@@ -11347,6 +11645,75 @@ function createRecursiveStatusTool(recursive) {
|
|
|
11347
11645
|
});
|
|
11348
11646
|
}
|
|
11349
11647
|
//#endregion
|
|
11648
|
+
//#region src/run-id.ts
|
|
11649
|
+
/**
|
|
11650
|
+
* A RUN ID IS A NAME, NOT A PATH.
|
|
11651
|
+
*
|
|
11652
|
+
* WHY THIS MODULE EXISTS. Every consumer of a run id JOINS it onto a directory
|
|
11653
|
+
* that already carries the meaning "the run layer":
|
|
11654
|
+
*
|
|
11655
|
+
* join(root, '.recursive', 'run', runId) // runtime.ts, run.ts, handoff.ts, scratch.ts
|
|
11656
|
+
* join(repoRoot, '.worktrees', runId) // worktree.ts (a linked worktree)
|
|
11657
|
+
* 'recursive/' + runId // worktree.ts (the run's git branch)
|
|
11658
|
+
*
|
|
11659
|
+
* `join` is a PATH operation: absolute paths, drive specifiers and `..` segments
|
|
11660
|
+
* are all legal input to it, and each one silently changes what the call means.
|
|
11661
|
+
* A caller who passes `E:\tmp\rm-live-diagnostics\01-calculator-lib` is asking
|
|
11662
|
+
* for a run "on another drive"; what they get is a `mkdir` of
|
|
11663
|
+
*
|
|
11664
|
+
* <workspace>\.recursive\run\E:\tmp\rm-live-diagnostics\01-calculator-lib
|
|
11665
|
+
*
|
|
11666
|
+
* which is not drive-qualified at all — on POSIX and Windows alike the colon is
|
|
11667
|
+
* just another character in a relative component. The result is a bogus nested
|
|
11668
|
+
* folder INSIDE the workspace, created before anything can refuse it, surfacing
|
|
11669
|
+
* far away as an ENOENT-shaped runtime failure (RM5501) with the operator's
|
|
11670
|
+
* filesystem already dirty.
|
|
11671
|
+
*
|
|
11672
|
+
* SO THE RULE IS ENFORCED WHERE THE NAME ENTERS, and NOT by teaching the runtime
|
|
11673
|
+
* to accept a path. The joins in `runtime.ts` are CORRECT for a name; what was
|
|
11674
|
+
* missing was a gate on the name. Do not "fix" this back: a run on another drive
|
|
11675
|
+
* or in a worktree is reached through the session's control-plane root
|
|
11676
|
+
* (`recursive_worktree`, `00-worktree.md`) — the run layer is never relocated by
|
|
11677
|
+
* smuggling a path into the id.
|
|
11678
|
+
*
|
|
11679
|
+
* The charset below is deliberately the SAME one the read path already uses
|
|
11680
|
+
* (`live-route.ts` `DOC_SAFE_RE`) so a name this gate accepts is a name that
|
|
11681
|
+
* route can serve.
|
|
11682
|
+
*/
|
|
11683
|
+
/**
|
|
11684
|
+
* The accepted shape, as prose that can be embedded in a model-facing parameter
|
|
11685
|
+
* description and in a refusal detail, so the rule is stated once.
|
|
11686
|
+
*/
|
|
11687
|
+
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";
|
|
11688
|
+
/** Directory-name charset — the read path's `DOC_SAFE_RE`, verbatim. */
|
|
11689
|
+
const RUN_ID_CHARS = /^[A-Za-z0-9._-]+$/;
|
|
11690
|
+
/**
|
|
11691
|
+
* Why a run id is refused, or `null` when it is a usable NAME.
|
|
11692
|
+
*
|
|
11693
|
+
* The returned string is the SPECIFIC problem (which rule the id broke), with no
|
|
11694
|
+
* trailing punctuation and no sentence of its own, so a caller can hand it to
|
|
11695
|
+
* `toolError('BAD_RUN_ID', …)` as the detail. `RUN_ID_RULE` states the shape.
|
|
11696
|
+
*
|
|
11697
|
+
* The order of the checks is part of the message quality: a Windows absolute
|
|
11698
|
+
* path is reported as a drive-qualified path (what the caller passed) rather
|
|
11699
|
+
* than as a separator complaint (what that path is made of).
|
|
11700
|
+
*/
|
|
11701
|
+
function runIdProblem(raw) {
|
|
11702
|
+
if (raw === "") return "runId is empty";
|
|
11703
|
+
if (raw.length > 100) return "runId is " + raw.length + " characters, over the 100 allowed";
|
|
11704
|
+
if (/^[A-Za-z]:/.test(raw)) return "runId is a Windows drive-qualified path, starting with \"" + raw.slice(0, 2) + "\"";
|
|
11705
|
+
if (raw.includes("/") || raw.includes("\\")) return "runId contains the path separator \"" + (raw.includes("/") ? "/" : "\\") + "\"";
|
|
11706
|
+
if (raw.includes(":")) return "runId contains a colon (\":\"), which is a drive and stream separator on Windows";
|
|
11707
|
+
if (raw.includes("..")) return "runId contains a \"..\" segment, which escapes the run directory";
|
|
11708
|
+
if (raw.startsWith(".")) return "runId starts with \".\", which makes it a hidden name or a relative path segment";
|
|
11709
|
+
if (raw.endsWith(".")) return "runId ends with \".\"";
|
|
11710
|
+
if (!RUN_ID_CHARS.test(raw)) {
|
|
11711
|
+
if (/\s/.test(raw)) return "runId contains a space or other whitespace character inside the name";
|
|
11712
|
+
return "runId contains a character outside the allowed set";
|
|
11713
|
+
}
|
|
11714
|
+
return null;
|
|
11715
|
+
}
|
|
11716
|
+
//#endregion
|
|
11350
11717
|
//#region src/recursive_init.tool.ts
|
|
11351
11718
|
/**
|
|
11352
11719
|
* PHASE 0 — SCAFFOLDING IS NOT STARTING, AND THE TOOL SAYS SO AT THE MOMENT IT MATTERS.
|
|
@@ -11362,15 +11729,23 @@ function createRecursiveStatusTool(recursive) {
|
|
|
11362
11729
|
* rather than "this call created something": re-initialising an APPROVED run reports `approved: true`
|
|
11363
11730
|
* (and the run keeps its goal), which is what a caller re-scaffolding a run it already started needs to
|
|
11364
11731
|
* see.
|
|
11732
|
+
*
|
|
11733
|
+
* AND A RUN ID IS A NAME, NOT A PATH. This is the boundary where the name enters, so it is the boundary
|
|
11734
|
+
* that refuses a path-shaped one — loudly, and before `initRun` can mkdir anything. The check lives here
|
|
11735
|
+
* (through the shared `run-id.ts` rule) rather than in `runtime.ts` because `initRun` is not the only
|
|
11736
|
+
* caller and because the runtime's `join(root, '.recursive', 'run', runId)` is CORRECT for a name; what
|
|
11737
|
+
* was missing was a gate on the name. Teaching the runtime to accept a path would silently relocate the
|
|
11738
|
+
* run layer instead of rejecting the call. See `run-id.ts` for the rule, the evidence behind it, and the
|
|
11739
|
+
* "do not fix this back" note.
|
|
11365
11740
|
*/
|
|
11366
11741
|
function createRecursiveInitTool(recursive) {
|
|
11367
11742
|
return defineTool({
|
|
11368
11743
|
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.",
|
|
11744
|
+
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
11745
|
parameters: {
|
|
11371
11746
|
runId: {
|
|
11372
11747
|
type: "string",
|
|
11373
|
-
description: "Run id (e.g. 03-something). Required."
|
|
11748
|
+
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
11749
|
},
|
|
11375
11750
|
createWorktree: {
|
|
11376
11751
|
type: "boolean",
|
|
@@ -11389,9 +11764,12 @@ function createRecursiveInitTool(recursive) {
|
|
|
11389
11764
|
}]
|
|
11390
11765
|
},
|
|
11391
11766
|
async execute(args, exec) {
|
|
11392
|
-
|
|
11767
|
+
const runId = args.runId?.trim() ?? "";
|
|
11768
|
+
if (runId === "") return { error: toolError("MISSING_RUN_ID") };
|
|
11769
|
+
const problem = runIdProblem(runId);
|
|
11770
|
+
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
11771
|
try {
|
|
11394
|
-
return await recursive.initRun(
|
|
11772
|
+
return await recursive.initRun(runId, exec.agent, {
|
|
11395
11773
|
createWorktree: args.createWorktree === true,
|
|
11396
11774
|
baseBranch: args.baseBranch?.trim() || void 0
|
|
11397
11775
|
});
|
|
@@ -11605,6 +11983,16 @@ function createRecursiveLintTool(recursive) {
|
|
|
11605
11983
|
* SESSION's workspace only (R1 workspace-scoping invariant). The run is resolved
|
|
11606
11984
|
* via the session agent's cwd -> workspace registry; a runId outside the current
|
|
11607
11985
|
* workspace is rejected.
|
|
11986
|
+
*
|
|
11987
|
+
* A RUN ID IS A NAME, NOT A PATH HERE TOO, and "outside the current workspace is rejected" is NOT enough
|
|
11988
|
+
* on its own: `closeoutRun` joins the id onto the run layer and then writes a receipt under it, and its
|
|
11989
|
+
* scoping check is `runDir.startsWith(runRoot)` — a string prefix test, not containment. A `..\` segment
|
|
11990
|
+
* that lands on a SIBLING of the run layer passes that check whenever the sibling's name begins with
|
|
11991
|
+
* `run`, so a path-shaped id can still receive a write. MEASURED pre-fix: `..\run-away` and `../run-away`
|
|
11992
|
+
* were accepted and reached the report; the other shapes below were stopped only by the sibling not
|
|
11993
|
+
* existing, which is the operator's filesystem deciding, not the tool. The rule is `run-id.ts`; this
|
|
11994
|
+
* boundary is where the name enters, so this is where it is refused, with `recursive_init`'s refusal shape
|
|
11995
|
+
* — `BAD_RUN_ID` (RM1107), same detail sentence.
|
|
11608
11996
|
*/
|
|
11609
11997
|
function createRecursiveCloseoutTool(recursive) {
|
|
11610
11998
|
return defineTool({
|
|
@@ -11617,7 +12005,7 @@ function createRecursiveCloseoutTool(recursive) {
|
|
|
11617
12005
|
},
|
|
11618
12006
|
runId: {
|
|
11619
12007
|
type: "string",
|
|
11620
|
-
description: "Run id (e.g. 03-something). Required. Must resolve inside the current workspace."
|
|
12008
|
+
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
12009
|
}
|
|
11622
12010
|
},
|
|
11623
12011
|
output: {
|
|
@@ -11629,9 +12017,12 @@ function createRecursiveCloseoutTool(recursive) {
|
|
|
11629
12017
|
},
|
|
11630
12018
|
async execute(args, exec) {
|
|
11631
12019
|
if (!args.phase || !args.runId || args.runId.trim() === "") return { error: toolError("MISSING_PHASE_AND_RUN") };
|
|
12020
|
+
const runId = args.runId.trim();
|
|
12021
|
+
const problem = runIdProblem(runId);
|
|
12022
|
+
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
12023
|
const root = await recursive.resolveWorkspaceRoot(exec.agent);
|
|
11633
12024
|
if (!root) return { error: toolError("NO_WORKSPACE") };
|
|
11634
|
-
return await recursive.closeoutRun(root,
|
|
12025
|
+
return await recursive.closeoutRun(root, runId, args.phase.trim(), exec.agent);
|
|
11635
12026
|
}
|
|
11636
12027
|
});
|
|
11637
12028
|
}
|
|
@@ -11641,6 +12032,16 @@ function createRecursiveCloseoutTool(recursive) {
|
|
|
11641
12032
|
* `recursive_scratch` — read/write/append the run-scoped disposable scratchpad
|
|
11642
12033
|
* (R5) under the CURRENT session workspace only (R1). Scratch is git-ignored
|
|
11643
12034
|
* and never citable as an Input.
|
|
12035
|
+
*
|
|
12036
|
+
* A RUN ID IS A NAME, NOT A PATH HERE TOO. `scratchRun` joins it onto the run layer, and it then WRITES
|
|
12037
|
+
* (write/append) into the directory it landed on, so a path-shaped id does not merely fail to find a run:
|
|
12038
|
+
* with a `..\` segment it can find and write into a SIBLING of the run layer, because the runtime's
|
|
12039
|
+
* workspace-scoping check is `runDir.startsWith(runRoot)` — a string prefix test, not containment — and a
|
|
12040
|
+
* sibling directory whose name begins with `run` passes it. MEASURED pre-fix: `..\run-away` was accepted
|
|
12041
|
+
* and `scratchRun` wrote `scratch.md` into `<workspace>\.recursive\run-away\scratch\`. The rule is
|
|
12042
|
+
* `run-id.ts`; the gate sits here, at the boundary where the name enters, rather than in `scratchRun`, for
|
|
12043
|
+
* the same reason `recursive_init`'s does. Refusal shape is `recursive_init`'s, `BAD_RUN_ID` (RM1107),
|
|
12044
|
+
* composed identically: one rule, one message.
|
|
11644
12045
|
*/
|
|
11645
12046
|
function createRecursiveScratchTool(recursive) {
|
|
11646
12047
|
return defineTool({
|
|
@@ -11653,7 +12054,7 @@ function createRecursiveScratchTool(recursive) {
|
|
|
11653
12054
|
},
|
|
11654
12055
|
runId: {
|
|
11655
12056
|
type: "string",
|
|
11656
|
-
description: "Run id (e.g. 03-something). Required; must resolve inside the current workspace."
|
|
12057
|
+
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
12058
|
},
|
|
11658
12059
|
target: {
|
|
11659
12060
|
type: "string",
|
|
@@ -11676,6 +12077,8 @@ function createRecursiveScratchTool(recursive) {
|
|
|
11676
12077
|
const runId = args.runId?.trim() ?? "";
|
|
11677
12078
|
const target = args.target ?? "";
|
|
11678
12079
|
if (!action || !runId || !target) return { error: toolError("MISSING_SCRATCH_ARGS") };
|
|
12080
|
+
const problem = runIdProblem(runId);
|
|
12081
|
+
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
12082
|
if (target !== "md" && target !== "ts") return { error: toolError("BAD_TARGET") };
|
|
11680
12083
|
const root = await recursive.resolveWorkspaceRoot(exec.agent);
|
|
11681
12084
|
if (!root) return { error: toolError("NO_WORKSPACE") };
|
|
@@ -11689,6 +12092,22 @@ function createRecursiveScratchTool(recursive) {
|
|
|
11689
12092
|
* `recursive_worktree` — create a linked git worktree for a run and/or
|
|
11690
12093
|
* promote a branch up the dev/stage/main chain. Workspace-scoped: the
|
|
11691
12094
|
* operations run under the SESSION's control-plane root only.
|
|
12095
|
+
*
|
|
12096
|
+
* A RUN ID IS A NAME, NOT A PATH HERE TOO, and this is the worst place to be without the rule: a `create`
|
|
12097
|
+
* builds TWO things out of the id — the linked worktree directory `.worktrees/<runId>` AND the git branch
|
|
12098
|
+
* `recursive/<runId>` (git accepts '/' inside a ref) — so a path-shaped id used to leave a worktree and a
|
|
12099
|
+
* ref behind, not just a folder. MEASURED pre-fix, per id, against a fresh repo: `nested/child-run`
|
|
12100
|
+
* returned ok:true and created BOTH `.worktrees/nested/child-run` and
|
|
12101
|
+
* `refs/heads/recursive/nested/child-run`, while the shapes git itself refuses as ref syntax
|
|
12102
|
+
* (`recursive//tmp/x`, `recursive/C:…`, `.hidden-run`, a trailing space) failed the worktree add and
|
|
12103
|
+
* created neither. The rule is `run-id.ts` and is not restated here; the gate sits at this boundary, ahead
|
|
12104
|
+
* of `createRunWorktree`, so the refusal no longer depends on git happening to dislike the ref name.
|
|
12105
|
+
*
|
|
12106
|
+
* The refusal is `BAD_RUN_ID` (RM1107), composed exactly as `recursive_init` composes it — same code, same
|
|
12107
|
+
* detail, same sentence. One message for one rule is what keeps a caller from having to learn a second
|
|
12108
|
+
* vocabulary for the same defect, and the shared remedy it carries ("call recursive_init again") is right
|
|
12109
|
+
* for this tool as well: a `create` for a run that does not exist yet is exactly what `recursive_init`
|
|
12110
|
+
* with `createWorktree: true` does.
|
|
11692
12111
|
*/
|
|
11693
12112
|
function createRecursiveWorktreeTool(recursive) {
|
|
11694
12113
|
return defineTool({
|
|
@@ -11697,7 +12116,7 @@ function createRecursiveWorktreeTool(recursive) {
|
|
|
11697
12116
|
parameters: {
|
|
11698
12117
|
runId: {
|
|
11699
12118
|
type: "string",
|
|
11700
|
-
description: "Run id the worktree is created for (e.g. 03-something). Required for create."
|
|
12119
|
+
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
12120
|
},
|
|
11702
12121
|
action: {
|
|
11703
12122
|
type: "string",
|
|
@@ -11729,7 +12148,10 @@ function createRecursiveWorktreeTool(recursive) {
|
|
|
11729
12148
|
if (!root) return { error: toolError("NO_WORKSPACE") };
|
|
11730
12149
|
if (action === "create") {
|
|
11731
12150
|
if (!args.runId || args.runId.trim() === "") return { error: toolError("MISSING_CREATE_RUN_ID") };
|
|
11732
|
-
|
|
12151
|
+
const runId = args.runId.trim();
|
|
12152
|
+
const problem = runIdProblem(runId);
|
|
12153
|
+
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") };
|
|
12154
|
+
return recursive.createRunWorktree(root, runId, args.baseBranch?.trim() || void 0);
|
|
11733
12155
|
}
|
|
11734
12156
|
if (action === "promote") {
|
|
11735
12157
|
if (!args.fromBranch || !args.toBranch) return { error: toolError("MISSING_PROMOTE_BRANCHES") };
|
|
@@ -11748,6 +12170,14 @@ function createRecursiveWorktreeTool(recursive) {
|
|
|
11748
12170
|
* (runtime.phaseRules -> phaseRulesFor) as the once-per-phase pre-step
|
|
11749
12171
|
* reminder, so the agent can re-ask for the rules without re-injecting them on
|
|
11750
12172
|
* every step. Returns { error } when no active phase is found.
|
|
12173
|
+
*
|
|
12174
|
+
* A RUN ID IS A NAME, NOT A PATH HERE TOO, INCLUDING WHEN IT IS OMITTED. Omitted and path-shaped are
|
|
12175
|
+
* different cases and must stay different: omitted means "the latest run by mtime", which `resolveRunDir`
|
|
12176
|
+
* answers by DISCOVERY rather than by joining anything, and that case is untouched below. A path-shaped id,
|
|
12177
|
+
* by contrast, is joined by `phaseRules` -> `resolveRunDir` and then read, and `recordInjection` WRITES
|
|
12178
|
+
* `memory-injections.json` under whatever directory it resolved to — with no scoping check at all on this
|
|
12179
|
+
* path. So the gate fires only on an id that was actually supplied, and `run-id.ts` owns the rule. The
|
|
12180
|
+
* refusal is `recursive_init`'s, `BAD_RUN_ID` (RM1107), composed identically.
|
|
11751
12181
|
*/
|
|
11752
12182
|
function createRecursivePhaseTool(recursive) {
|
|
11753
12183
|
return defineTool({
|
|
@@ -11755,7 +12185,7 @@ function createRecursivePhaseTool(recursive) {
|
|
|
11755
12185
|
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
12186
|
parameters: { runId: {
|
|
11757
12187
|
type: "string",
|
|
11758
|
-
description: "Optional run id (
|
|
12188
|
+
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
12189
|
} },
|
|
11760
12190
|
output: {
|
|
11761
12191
|
schema: { type: "json" },
|
|
@@ -11765,7 +12195,12 @@ function createRecursivePhaseTool(recursive) {
|
|
|
11765
12195
|
}]
|
|
11766
12196
|
},
|
|
11767
12197
|
async execute(args, exec) {
|
|
11768
|
-
const
|
|
12198
|
+
const runId = args.runId?.trim();
|
|
12199
|
+
if (runId !== void 0) {
|
|
12200
|
+
const problem = runId === "" ? "runId is empty" : runIdProblem(runId);
|
|
12201
|
+
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") };
|
|
12202
|
+
}
|
|
12203
|
+
const result = await recursive.phaseRules(runId, exec.agent);
|
|
11769
12204
|
if (!result) return { error: toolError("NO_PHASE") };
|
|
11770
12205
|
return result;
|
|
11771
12206
|
}
|