@try-works/dsh-recursive-mode 0.4.4 → 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 +45 -0
- package/lib/goals-projection.d.ts +34 -4
- package/lib/index.js +783 -51
- package/lib/recursive_ask.tool.d.ts +137 -0
- package/lib/recursive_closeout.tool.d.ts +10 -0
- package/lib/recursive_init.tool.d.ts +23 -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-start.d.ts +55 -0
- package/lib/runtime.d.ts +137 -31
- package/package.json +1 -1
- package/scripts/test-recursive-mode-smoke.ts +5 -1
- package/src/errors.ts +45 -0
- package/src/goals-projection.ts +48 -7
- package/src/index.ts +9 -0
- package/src/recursive_ask.tool.ts +368 -18
- package/src/recursive_closeout.tool.ts +53 -35
- package/src/recursive_init.tool.ts +35 -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 +129 -0
- package/src/runtime.ts +157 -14
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,6 +5480,33 @@ 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
|
+
RUN_START_NO_CHANNEL: {
|
|
5484
|
+
code: "RM5502",
|
|
5485
|
+
klass: "runtime",
|
|
5486
|
+
problem: "the run-start gate needs an answer, and this composition mounts no user-questions channel to ask one directly",
|
|
5487
|
+
next: "call recursive_ask with gate: run-start and no answer to surface the question, then retry with answer: \"Start run\""
|
|
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
|
+
*/
|
|
5498
|
+
RUN_START_UNANSWERED: {
|
|
5499
|
+
code: "RM5503",
|
|
5500
|
+
klass: "runtime",
|
|
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"
|
|
5509
|
+
},
|
|
5465
5510
|
RUNTIME_REFUSED: {
|
|
5466
5511
|
code: "RM5501",
|
|
5467
5512
|
klass: "runtime",
|
|
@@ -6819,6 +6864,129 @@ function extractAndGroup(runner, env, options = {}) {
|
|
|
6819
6864
|
};
|
|
6820
6865
|
}
|
|
6821
6866
|
//#endregion
|
|
6867
|
+
//#region src/run-start.ts
|
|
6868
|
+
/**
|
|
6869
|
+
* PHASE 0 — STARTING A RUN IS A HUMAN DECISION, NOT A SIDE EFFECT OF SCAFFOLDING.
|
|
6870
|
+
*
|
|
6871
|
+
* THE DEFECT THIS CLOSES. `recursive_init` scaffolded a run and the plugin then CREATED AND ARMED a
|
|
6872
|
+
* goal for it in the same breath (`syncRunGoal`'s "no current goal -> create and arm" branch). A goal
|
|
6873
|
+
* is not a label: `goals.create` returns an ARMED view, and the harness immediately begins driving
|
|
6874
|
+
* autonomous goal rounds for the session. So asking for a run spec was enough to start an unattended
|
|
6875
|
+
* run — the owner's rule is the opposite: *"creating a spec before a run exists should not create a
|
|
6876
|
+
* goal. Phase 0 requires explicit approval to start a run and goal."*
|
|
6877
|
+
*
|
|
6878
|
+
* WHAT "APPROVAL" IS, EXACTLY. The approving label of the `run-start` gate of `recursive_ask`
|
|
6879
|
+
* (`Start run`, as opposed to `Hold`), recorded here as a durable `- Run Start: Start run` line in the
|
|
6880
|
+
* run's Phase 0 requirements artifact. Three things make that an explicit human act rather than an
|
|
6881
|
+
* inference:
|
|
6882
|
+
*
|
|
6883
|
+
* 1. NO DEFAULT, AND THE VALUE IS THE DECISION. The line is written by the gate itself into the Phase 0
|
|
6884
|
+
* requirements document, and the gate REFUSES an answer that is not one of the labels it offered.
|
|
6885
|
+
* An unoffered answer is a transcription error wearing the shape of a decision, and a `Hold` is not
|
|
6886
|
+
* an approval in any spelling — see {@link readRunStartApproval}, which matches the approving VALUE
|
|
6887
|
+
* and nothing else, so the presence of a `Run Start` line is never on its own consent.
|
|
6888
|
+
* 2. IT IS ASKED, NOT ASSUMED. When the composition mounts `ctx.userQuestions` — the harness's own
|
|
6889
|
+
* blocking human channel, the same one plan-mode's exit uses — the question is PUT TO THE PERSON and
|
|
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.
|
|
6899
|
+
* 3. THE GOAL CANNOT BE CREATED WITHOUT IT. `syncRunGoal` refuses to create a goal for a run whose
|
|
6900
|
+
* approval record is absent, in EVERY branch that would create one — not only the "no goal yet"
|
|
6901
|
+
* branch. That is the property `tests/run-start-approval.spec.ts` asserts, because a single
|
|
6902
|
+
* unguarded branch is exactly how this defect existed in the first place.
|
|
6903
|
+
*
|
|
6904
|
+
* A SPEC MAY EXIST BEFORE A RUN EXISTS, and this module does not forbid that: the scaffold, the Phase
|
|
6905
|
+
* 0 artifacts and every later phase document are all created by `recursive_init` as before. What is
|
|
6906
|
+
* withheld is the GOAL — the object that makes the harness drive rounds. A run that is scaffolded and
|
|
6907
|
+
* never approved is a spec: readable, editable, lockable, and inert.
|
|
6908
|
+
*/
|
|
6909
|
+
/** The gate id `recursive_ask` answers for a run start. Deliberately NOT in ASK_GATE_IDS. */
|
|
6910
|
+
const RUN_START_GATE_ID = "run-start";
|
|
6911
|
+
/** The Phase 0 artifact the approval is recorded in. */
|
|
6912
|
+
const RUN_START_ARTIFACT = "00-requirements.md";
|
|
6913
|
+
/** The artifact field the approval reads back from. */
|
|
6914
|
+
const RUN_START_MARKER = "Run Start";
|
|
6915
|
+
/** The approving label. The ONLY label that starts a run. */
|
|
6916
|
+
const RUN_START_APPROVE = "Start run";
|
|
6917
|
+
/**
|
|
6918
|
+
* WHY THIS GATE IS NOT IN `ASK_GATE_IDS`. Those three are the WORKFLOW's gates — phase-3 test
|
|
6919
|
+
* evidence, phase-5 sign-off, resolving a gate block — and their membership is asserted as exactly
|
|
6920
|
+
* three. Starting a run is a different kind of decision: it is the one that decides whether there is
|
|
6921
|
+
* a run at all. It lives here, with its own contract, so widening the workflow's gate list cannot
|
|
6922
|
+
* quietly widen what may start a run.
|
|
6923
|
+
*/
|
|
6924
|
+
const RUN_START_GATE = {
|
|
6925
|
+
id: RUN_START_GATE_ID,
|
|
6926
|
+
header: "Start run",
|
|
6927
|
+
question: "Approve phase 0 and start this run? Approving creates an armed goal the harness will keep driving.",
|
|
6928
|
+
options: [{
|
|
6929
|
+
label: RUN_START_APPROVE,
|
|
6930
|
+
description: "Record the approval and arm the run goal."
|
|
6931
|
+
}, {
|
|
6932
|
+
label: "Hold",
|
|
6933
|
+
description: "Leave the spec inert: no run goal, no autonomous rounds."
|
|
6934
|
+
}],
|
|
6935
|
+
marker: RUN_START_MARKER
|
|
6936
|
+
};
|
|
6937
|
+
/** Is a `run-start` answer the approving one? */
|
|
6938
|
+
function isRunStartApproval(answer) {
|
|
6939
|
+
return answer.trim() === RUN_START_APPROVE;
|
|
6940
|
+
}
|
|
6941
|
+
/** Where the Phase 0 requirements artifact lives for a run rooted at `root`. */
|
|
6942
|
+
function runStartArtifactPath(root, runId) {
|
|
6943
|
+
return join(root, ".recursive", "run", runId, RUN_START_ARTIFACT);
|
|
6944
|
+
}
|
|
6945
|
+
/** The artifact text, or null when the file is absent (a read failure is not an approval). */
|
|
6946
|
+
function readRunStartArtifact(root, runId) {
|
|
6947
|
+
try {
|
|
6948
|
+
return readFileSync(runStartArtifactPath(root, runId), "utf8");
|
|
6949
|
+
} catch {
|
|
6950
|
+
return null;
|
|
6951
|
+
}
|
|
6952
|
+
}
|
|
6953
|
+
/**
|
|
6954
|
+
* The approval state of a run, read from its Phase 0 artifact.
|
|
6955
|
+
*
|
|
6956
|
+
* ⚠ MATCHED ON THE VALUE, NOT ON THE LINE'S PRESENCE. `getMdFieldValue` returns the field's VALUE, so
|
|
6957
|
+
* a recorded `- Run Start: Hold` is refused here — a check for "is there a Run Start line?" would read
|
|
6958
|
+
* a refusal as consent, which is the one mistake this whole module exists to prevent.
|
|
6959
|
+
*/
|
|
6960
|
+
function readRunStartApproval(root, runId) {
|
|
6961
|
+
const content = readRunStartArtifact(root, runId);
|
|
6962
|
+
if (content === null) return {
|
|
6963
|
+
approved: false,
|
|
6964
|
+
artifact: RUN_START_ARTIFACT,
|
|
6965
|
+
reason: "the Phase 0 requirements artifact does not exist yet"
|
|
6966
|
+
};
|
|
6967
|
+
const value = getMdFieldValue(content, RUN_START_MARKER);
|
|
6968
|
+
if (value === null) return {
|
|
6969
|
+
approved: false,
|
|
6970
|
+
artifact: RUN_START_ARTIFACT,
|
|
6971
|
+
reason: "no Run Start decision has been recorded"
|
|
6972
|
+
};
|
|
6973
|
+
if (!isRunStartApproval(value)) return {
|
|
6974
|
+
approved: false,
|
|
6975
|
+
artifact: RUN_START_ARTIFACT,
|
|
6976
|
+
reason: "Run Start is " + JSON.stringify(value) + ", which does not start a run"
|
|
6977
|
+
};
|
|
6978
|
+
return {
|
|
6979
|
+
approved: true,
|
|
6980
|
+
artifact: RUN_START_ARTIFACT,
|
|
6981
|
+
reason: ""
|
|
6982
|
+
};
|
|
6983
|
+
}
|
|
6984
|
+
/**
|
|
6985
|
+
* The ONE refusal reason the projection returns before approval, exported so every caller branches on
|
|
6986
|
+
* the same string instead of re-typing it (a re-typed reason is a caller that silently stops matching).
|
|
6987
|
+
*/
|
|
6988
|
+
const RUN_START_NOT_APPROVED = "run not started: phase 0 approval has not been granted";
|
|
6989
|
+
//#endregion
|
|
6822
6990
|
//#region src/recursive_ask.tool.ts
|
|
6823
6991
|
/**
|
|
6824
6992
|
* T23 — `recursive_ask`: the three human gates as STRUCTURED decisions, not prose.
|
|
@@ -6846,6 +7014,14 @@ const ASK_GATE_IDS = [
|
|
|
6846
7014
|
"qa-signoff",
|
|
6847
7015
|
"gate-block"
|
|
6848
7016
|
];
|
|
7017
|
+
/** Is this gate id the run-start gate? */
|
|
7018
|
+
function isRunStartGate(gateId) {
|
|
7019
|
+
return gateId === RUN_START_GATE_ID;
|
|
7020
|
+
}
|
|
7021
|
+
/** Every gate id `recursive_ask` accepts, workflow gates first. */
|
|
7022
|
+
function askGateIds() {
|
|
7023
|
+
return [...ASK_GATE_IDS, RUN_START_GATE_ID];
|
|
7024
|
+
}
|
|
6849
7025
|
const ASK_GATES = {
|
|
6850
7026
|
"tdd-mode": {
|
|
6851
7027
|
id: "tdd-mode",
|
|
@@ -6939,6 +7115,36 @@ function buildAskQuestion(gateId) {
|
|
|
6939
7115
|
});
|
|
6940
7116
|
}
|
|
6941
7117
|
/**
|
|
7118
|
+
* PHASE 0 — build the question for ANY accepted gate, including the run-start gate.
|
|
7119
|
+
*
|
|
7120
|
+
* A separate entry point rather than a widened `buildAskQuestion` so the three workflow gates keep the
|
|
7121
|
+
* exact signature and behaviour their callers (and `runtime.phaseRules`) already rely on.
|
|
7122
|
+
*/
|
|
7123
|
+
function buildAskQuestionFor(gateId) {
|
|
7124
|
+
if (isRunStartGate(gateId)) return validateAskQuestion({
|
|
7125
|
+
id: RUN_START_GATE.id,
|
|
7126
|
+
header: RUN_START_GATE.header,
|
|
7127
|
+
question: RUN_START_GATE.question,
|
|
7128
|
+
options: RUN_START_GATE.options.map((option) => ({ ...option }))
|
|
7129
|
+
});
|
|
7130
|
+
return buildAskQuestion(gateId);
|
|
7131
|
+
}
|
|
7132
|
+
/**
|
|
7133
|
+
* PHASE 0 — validate an answer to ANY accepted gate.
|
|
7134
|
+
*
|
|
7135
|
+
* The run-start gate accepts only the labels IT offered, exactly like the other three, and the check is
|
|
7136
|
+
* the same `ASK_GATES`-shaped test against its own options. An answer of `maybe` is refused rather than
|
|
7137
|
+
* recorded, because a recorded non-answer is the failure mode this whole change exists to prevent.
|
|
7138
|
+
*/
|
|
7139
|
+
function validateAskAnswerFor(gateId, answer) {
|
|
7140
|
+
if (isRunStartGate(gateId)) {
|
|
7141
|
+
const offered = RUN_START_GATE.options.map((option) => option.label);
|
|
7142
|
+
if (!offered.includes(answer)) throw new AskValidationError("answer", "must be one of " + offered.join(" | ") + " (got " + JSON.stringify(answer) + ")");
|
|
7143
|
+
return answer;
|
|
7144
|
+
}
|
|
7145
|
+
return validateAskAnswer(gateId, answer);
|
|
7146
|
+
}
|
|
7147
|
+
/**
|
|
6942
7148
|
* Validate an answer against its gate.
|
|
6943
7149
|
*
|
|
6944
7150
|
* An answer that is not one of the offered labels is REFUSED rather than recorded: a marker saying
|
|
@@ -6987,15 +7193,19 @@ function pendingGateFor(artifactFile, artifactText) {
|
|
|
6987
7193
|
* ⚠ THE WRITE-BACK IS A MARKER LINE, REPLACED IN PLACE when the artifact already carries one. A
|
|
6988
7194
|
* second `TDD Mode:` line would leave two answers to one question and make "what was decided?"
|
|
6989
7195
|
* depend on which a reader found first.
|
|
7196
|
+
*
|
|
7197
|
+
* ⚠ PHASE 0 — `run-start` IS RECORDED BY THE PLUGIN, NEVER BY A BARE MARKER WRITE. See
|
|
7198
|
+
* `recordRunStartAnswer`: it is the only path that can arm a run goal, it prefers the blocking human
|
|
7199
|
+
* channel, and it fails closed when no person can be reached.
|
|
6990
7200
|
*/
|
|
6991
7201
|
function createRecursiveAskTool(recursive) {
|
|
6992
7202
|
return defineTool({
|
|
6993
7203
|
name: "recursive_ask",
|
|
6994
|
-
description: "Ask
|
|
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.",
|
|
6995
7205
|
parameters: {
|
|
6996
7206
|
gate: {
|
|
6997
7207
|
type: "string",
|
|
6998
|
-
description: "tdd-mode | qa-signoff | gate-block. Required."
|
|
7208
|
+
description: "tdd-mode | qa-signoff | gate-block | run-start. Required. `run-start` is phase 0: approving it records the approval and arms the run goal, which is what makes the harness drive rounds."
|
|
6999
7209
|
},
|
|
7000
7210
|
runId: {
|
|
7001
7211
|
type: "string",
|
|
@@ -7003,11 +7213,15 @@ function createRecursiveAskTool(recursive) {
|
|
|
7003
7213
|
},
|
|
7004
7214
|
artifact: {
|
|
7005
7215
|
type: "string",
|
|
7006
|
-
description: "Artifact file the answer belongs in. Optional; defaults per gate (gate-block has none, so it is required for that gate)."
|
|
7216
|
+
description: "Artifact file the answer belongs in. Optional; defaults per gate (gate-block has none, so it is required for that gate; run-start is always recorded in 00-requirements.md)."
|
|
7007
7217
|
},
|
|
7008
7218
|
answer: {
|
|
7009
7219
|
type: "string",
|
|
7010
|
-
description: "One of the gate's option labels. Omit to ASK."
|
|
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."
|
|
7011
7225
|
}
|
|
7012
7226
|
},
|
|
7013
7227
|
output: {
|
|
@@ -7021,29 +7235,33 @@ function createRecursiveAskTool(recursive) {
|
|
|
7021
7235
|
const gateId = (args.gate ?? "").trim();
|
|
7022
7236
|
const runId = args.runId?.trim() ?? "";
|
|
7023
7237
|
if (runId === "") return { error: toolError("MISSING_RUN_ID") };
|
|
7024
|
-
if (!
|
|
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) };
|
|
7025
7240
|
const root = await recursive.resolveWorkspaceRoot(exec.agent);
|
|
7026
7241
|
if (!root) return { error: toolError("NO_WORKSPACE") };
|
|
7027
|
-
const artifact = (args.artifact ?? GATE_DEFAULT_ARTIFACT[gateId]).trim();
|
|
7242
|
+
const artifact = (isRunStartGate(gateId) ? RUN_START_ARTIFACT : args.artifact ?? GATE_DEFAULT_ARTIFACT[gateId]).trim();
|
|
7028
7243
|
let question;
|
|
7029
7244
|
try {
|
|
7030
|
-
question =
|
|
7245
|
+
question = buildAskQuestionFor(gateId);
|
|
7031
7246
|
} catch (err) {
|
|
7032
7247
|
return { error: toolError("BAD_ASK_GATE", err instanceof Error ? err.message : String(err)) };
|
|
7033
7248
|
}
|
|
7034
|
-
|
|
7249
|
+
const channelMounted = recursive.userQuestionsChannel !== null;
|
|
7250
|
+
if (args.answer === void 0 && !(isRunStartGate(gateId) && channelMounted)) return {
|
|
7035
7251
|
gate: gateId,
|
|
7036
|
-
marker: ASK_GATES[gateId].marker,
|
|
7252
|
+
marker: isRunStartGate(gateId) ? RUN_START_GATE.marker : ASK_GATES[gateId].marker,
|
|
7037
7253
|
artifact,
|
|
7038
7254
|
question
|
|
7039
7255
|
};
|
|
7040
7256
|
let answer;
|
|
7041
|
-
|
|
7042
|
-
|
|
7257
|
+
if (args.answer === void 0) answer = void 0;
|
|
7258
|
+
else try {
|
|
7259
|
+
answer = validateAskAnswerFor(gateId, args.answer);
|
|
7043
7260
|
} catch (err) {
|
|
7044
7261
|
return { error: toolError("BAD_ASK_ANSWER", err instanceof Error ? err.message : String(err)) };
|
|
7045
7262
|
}
|
|
7046
7263
|
if (artifact === "") return { error: toolError("MISSING_ASK_ARTIFACT", "this gate needs an explicit artifact to record into") };
|
|
7264
|
+
if (isRunStartGate(gateId)) return recordRunStartAnswer(recursive, root, runId, answer, exec, args.relay === true);
|
|
7047
7265
|
const marker = answerMarker(gateId, answer);
|
|
7048
7266
|
const written = recursive.recordAskAnswer(root, runId, artifact, marker);
|
|
7049
7267
|
return {
|
|
@@ -7057,6 +7275,197 @@ function createRecursiveAskTool(recursive) {
|
|
|
7057
7275
|
}
|
|
7058
7276
|
});
|
|
7059
7277
|
}
|
|
7278
|
+
/**
|
|
7279
|
+
* PHASE 0 — record the answer to the run-start gate, and start the run only if it says so.
|
|
7280
|
+
*
|
|
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);
|
|
7313
|
+
const channel = recursive.userQuestionsChannel;
|
|
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;
|
|
7350
|
+
const final = fromChannel ?? answer;
|
|
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
|
+
};
|
|
7358
|
+
let decided;
|
|
7359
|
+
try {
|
|
7360
|
+
decided = validateAskAnswerFor(RUN_START_GATE_ID, final);
|
|
7361
|
+
} catch (err) {
|
|
7362
|
+
return { error: toolError("BAD_ASK_ANSWER", err instanceof Error ? err.message : String(err)) };
|
|
7363
|
+
}
|
|
7364
|
+
const outcome = recursive.approveRunStart(root, runId, exec.agent, decided);
|
|
7365
|
+
return {
|
|
7366
|
+
gate: RUN_START_GATE_ID,
|
|
7367
|
+
answer: decided,
|
|
7368
|
+
artifact: RUN_START_ARTIFACT,
|
|
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
|
+
} } : {},
|
|
7376
|
+
path: outcome.path,
|
|
7377
|
+
replaced: outcome.replaced,
|
|
7378
|
+
armed: outcome.ok && outcome.goal.ok,
|
|
7379
|
+
goal: outcome.goal
|
|
7380
|
+
};
|
|
7381
|
+
}
|
|
7382
|
+
/**
|
|
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.
|
|
7431
|
+
*
|
|
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.
|
|
7438
|
+
*/
|
|
7439
|
+
async function askRunStartDirectly(channel, exec) {
|
|
7440
|
+
const known = RUN_START_GATE.options.map((option) => option.label);
|
|
7441
|
+
try {
|
|
7442
|
+
const item = (await channel.ask({
|
|
7443
|
+
questions: [{
|
|
7444
|
+
id: RUN_START_GATE.id,
|
|
7445
|
+
header: RUN_START_GATE.header,
|
|
7446
|
+
question: RUN_START_GATE.question,
|
|
7447
|
+
options: RUN_START_GATE.options.map((option) => ({ ...option }))
|
|
7448
|
+
}],
|
|
7449
|
+
agent: exec.agent,
|
|
7450
|
+
signal: exec.signal,
|
|
7451
|
+
wait: { callId: exec.callId }
|
|
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
|
+
};
|
|
7467
|
+
}
|
|
7468
|
+
}
|
|
7060
7469
|
//#endregion
|
|
7061
7470
|
//#region src/lifecycle.ts
|
|
7062
7471
|
/**
|
|
@@ -8911,8 +9320,25 @@ function mutatePhase(service, agent, ref, target) {
|
|
|
8911
9320
|
* Sync a run's durable goal to the requested phase. Safe: never touches a goal
|
|
8912
9321
|
* whose objective is not this run's marker, and never re-creates over a
|
|
8913
9322
|
* non-complete foreign goal.
|
|
9323
|
+
*
|
|
9324
|
+
* ⚠ `approved` IS THE PHASE-0 GATE, and it defaults to the SAFE direction. A goal is not a label:
|
|
9325
|
+
* `create` returns an ARMED view and the harness starts driving autonomous goal rounds for the
|
|
9326
|
+
* session, so creating one is starting the run. The owner's rule is that phase 0 requires explicit
|
|
9327
|
+
* approval, which means the projection must be unable to arm anything on its own — hence a default of
|
|
9328
|
+
* `false` and an explicit refusal in EVERY branch that would call `create`, including the two
|
|
9329
|
+
* replace-a-completed-goal branches (an unapproved run cannot have reached `complete`, but "cannot
|
|
9330
|
+
* happen" is what the single unguarded branch relied on too).
|
|
9331
|
+
*
|
|
9332
|
+
* ⚠ AND IT IS REACHED ON ORDINARY WORK, so the unapproved path is QUIET AND IDEMPOTENT: no goal is
|
|
9333
|
+
* created, nothing is written, no error is thrown, and the run's artifacts are untouched. The caller
|
|
9334
|
+
* reads {@link RUN_START_NOT_APPROVED} to tell "this run has not been started yet" apart from a real
|
|
9335
|
+
* failure, so a normal phase step never surfaces a warning.
|
|
9336
|
+
*
|
|
9337
|
+
* `approved` is passed IN rather than read here because this module is pure: it takes the goal service
|
|
9338
|
+
* seam and nothing else, and the plugin's own filesystem reads live in the runtime (see
|
|
9339
|
+
* `RecursiveRuntime.readRunStartApproval`).
|
|
8914
9340
|
*/
|
|
8915
|
-
function syncRunGoal(service, agent, runId, runState) {
|
|
9341
|
+
function syncRunGoal(service, agent, runId, runState, approved = false) {
|
|
8916
9342
|
if (!service) return {
|
|
8917
9343
|
ok: false,
|
|
8918
9344
|
reason: "no goals service"
|
|
@@ -8927,12 +9353,18 @@ function syncRunGoal(service, agent, runId, runState) {
|
|
|
8927
9353
|
phase: target,
|
|
8928
9354
|
ref
|
|
8929
9355
|
};
|
|
8930
|
-
if (phase === "complete")
|
|
8931
|
-
|
|
8932
|
-
|
|
8933
|
-
|
|
8934
|
-
|
|
8935
|
-
|
|
9356
|
+
if (phase === "complete") {
|
|
9357
|
+
if (!approved) return {
|
|
9358
|
+
ok: false,
|
|
9359
|
+
reason: RUN_START_NOT_APPROVED
|
|
9360
|
+
};
|
|
9361
|
+
return {
|
|
9362
|
+
ok: true,
|
|
9363
|
+
phase: target,
|
|
9364
|
+
ref: refOf(service.create(agent, { objective: goalObjective(runId, runState) })),
|
|
9365
|
+
created: true
|
|
9366
|
+
};
|
|
9367
|
+
}
|
|
8936
9368
|
return mutatePhase(service, agent, ref, target) ? {
|
|
8937
9369
|
ok: true,
|
|
8938
9370
|
phase: target,
|
|
@@ -8943,17 +9375,27 @@ function syncRunGoal(service, agent, runId, runState) {
|
|
|
8943
9375
|
};
|
|
8944
9376
|
}
|
|
8945
9377
|
if (current) {
|
|
8946
|
-
if (current.phase === "complete")
|
|
8947
|
-
|
|
8948
|
-
|
|
8949
|
-
|
|
8950
|
-
|
|
8951
|
-
|
|
9378
|
+
if (current.phase === "complete") {
|
|
9379
|
+
if (!approved) return {
|
|
9380
|
+
ok: false,
|
|
9381
|
+
reason: RUN_START_NOT_APPROVED
|
|
9382
|
+
};
|
|
9383
|
+
return {
|
|
9384
|
+
ok: true,
|
|
9385
|
+
phase: target,
|
|
9386
|
+
ref: refOf(service.create(agent, { objective: goalObjective(runId, runState) })),
|
|
9387
|
+
created: true
|
|
9388
|
+
};
|
|
9389
|
+
}
|
|
8952
9390
|
return {
|
|
8953
9391
|
ok: false,
|
|
8954
9392
|
reason: "a non-matching active goal exists (foreign goal not touched)"
|
|
8955
9393
|
};
|
|
8956
9394
|
}
|
|
9395
|
+
if (!approved) return {
|
|
9396
|
+
ok: false,
|
|
9397
|
+
reason: RUN_START_NOT_APPROVED
|
|
9398
|
+
};
|
|
8957
9399
|
return {
|
|
8958
9400
|
ok: true,
|
|
8959
9401
|
phase: target,
|
|
@@ -8961,7 +9403,13 @@ function syncRunGoal(service, agent, runId, runState) {
|
|
|
8961
9403
|
created: true
|
|
8962
9404
|
};
|
|
8963
9405
|
}
|
|
8964
|
-
/**
|
|
9406
|
+
/**
|
|
9407
|
+
* Block the current run goal (used on a gate-block). Never touches a foreign goal.
|
|
9408
|
+
*
|
|
9409
|
+
* ⚠ A RUN THAT WAS NEVER STARTED HAS NO GOAL TO BLOCK, so this reports the unapproved state in the
|
|
9410
|
+
* same words as {@link syncRunGoal} rather than "no current goal to block": the caller's question is
|
|
9411
|
+
* "why is there no goal", and the answer must not depend on which entry point happened to ask.
|
|
9412
|
+
*/
|
|
8965
9413
|
function blockRunGoal(service, agent, runId, reason) {
|
|
8966
9414
|
if (!service) return {
|
|
8967
9415
|
ok: false,
|
|
@@ -8970,7 +9418,7 @@ function blockRunGoal(service, agent, runId, reason) {
|
|
|
8970
9418
|
const current = service.get(agent);
|
|
8971
9419
|
if (!current) return {
|
|
8972
9420
|
ok: false,
|
|
8973
|
-
reason:
|
|
9421
|
+
reason: RUN_START_NOT_APPROVED
|
|
8974
9422
|
};
|
|
8975
9423
|
if (!isRunGoal(current, runId)) return {
|
|
8976
9424
|
ok: false,
|
|
@@ -8986,9 +9434,16 @@ function blockRunGoal(service, agent, runId, reason) {
|
|
|
8986
9434
|
reason: "goal block failed"
|
|
8987
9435
|
};
|
|
8988
9436
|
}
|
|
8989
|
-
/**
|
|
8990
|
-
|
|
8991
|
-
|
|
9437
|
+
/**
|
|
9438
|
+
* Bridge a run's blocked goal back to active (used on a reopen).
|
|
9439
|
+
*
|
|
9440
|
+
* ⚠ REOPEN IS NOT A BACK DOOR TO STARTING A RUN. It routes through {@link syncRunGoal}, so a reopen of
|
|
9441
|
+
* an unapproved run cannot create the goal that init deliberately withheld. An APPROVED run is
|
|
9442
|
+
* unaffected: its approval outlives the reopen, because the approval is a durable line in the run's
|
|
9443
|
+
* own Phase 0 artifact rather than a value held in memory (verified in `tests/run-start-approval.spec.ts`).
|
|
9444
|
+
*/
|
|
9445
|
+
function resumeRunGoal(service, agent, runId, approved = false) {
|
|
9446
|
+
return syncRunGoal(service, agent, runId, "active", approved);
|
|
8992
9447
|
}
|
|
8993
9448
|
//#endregion
|
|
8994
9449
|
//#region src/teams-loop.ts
|
|
@@ -9598,9 +10053,23 @@ var RecursiveRuntime = class extends Service {
|
|
|
9598
10053
|
this.subagentsSeam = config.subagents ?? null;
|
|
9599
10054
|
this.workflow = config.workflow ?? null;
|
|
9600
10055
|
}
|
|
9601
|
-
/**
|
|
10056
|
+
/**
|
|
10057
|
+
* T10: the native jobs registry, when the composition mounts one. */
|
|
9602
10058
|
jobs;
|
|
9603
10059
|
/**
|
|
10060
|
+
* PHASE 0 — attach the goals service after construction.
|
|
10061
|
+
*
|
|
10062
|
+
* The composition resolves `goals` with ONE `ctx.get` at apply time and passes it to the constructor,
|
|
10063
|
+
* which is fine for a service that is already mounted. This seam exists for the two cases that pattern
|
|
10064
|
+
* cannot cover: a composition that mounts `goals` later (the same late-attach reason `attachSubagents`
|
|
10065
|
+
* and `attachLlmInventory` exist), and a test that needs the REAL runtime wired to a structural fake —
|
|
10066
|
+
* a fake passed through the plugin's Config is dropped, because the Config schema is the settings
|
|
10067
|
+
* namespace and strips keys it does not declare.
|
|
10068
|
+
*/
|
|
10069
|
+
attachGoals(service) {
|
|
10070
|
+
this.goalsService = service;
|
|
10071
|
+
}
|
|
10072
|
+
/**
|
|
9604
10073
|
* T23 — write a gate's answer into an artifact as a marker line.
|
|
9605
10074
|
*
|
|
9606
10075
|
* ⚠ REPLACED IN PLACE when the artifact already carries that gate's marker: two `TDD Mode:` lines
|
|
@@ -9792,16 +10261,72 @@ var RecursiveRuntime = class extends Service {
|
|
|
9792
10261
|
return report;
|
|
9793
10262
|
}
|
|
9794
10263
|
/**
|
|
10264
|
+
* PHASE 0 — read a run's start approval from its own Phase 0 artifact.
|
|
10265
|
+
*
|
|
10266
|
+
* The approval is a DURABLE line in `.recursive/run/<runId>/00-requirements.md`, not a value held in
|
|
10267
|
+
* memory, for the reason every other gate here is durable: a decision that only exists in a session
|
|
10268
|
+
* cannot be cited, and cannot survive the session it was made in. Read-only; asking changes nothing.
|
|
10269
|
+
*/
|
|
10270
|
+
readRunStartApproval(root, runId) {
|
|
10271
|
+
return readRunStartApproval(root, runId);
|
|
10272
|
+
}
|
|
10273
|
+
/**
|
|
10274
|
+
* PHASE 0 — the harness's blocking human-question channel (`ctx.userQuestions`), when this composition
|
|
10275
|
+
* mounts one.
|
|
10276
|
+
*
|
|
10277
|
+
* ⚠ WHY THE PLUGIN REACHES FOR THIS AT ALL. The other three gates answer through a question card and a
|
|
10278
|
+
* relayed label, which is fine for a decision the workflow acts on later. STARTING A RUN is different:
|
|
10279
|
+
* the first human turn is the only place the harness can say "arming this goal means autonomous rounds"
|
|
10280
|
+
* BEFORE arming it. This channel is the same one plan-mode's exit uses; `ask()` resolves only with a
|
|
10281
|
+
* real answer from a real person, and it THROWS when there is no answerer or no live root agent. So the
|
|
10282
|
+
* absence of this service cannot be papered over: `recursive_ask` refuses the run-start gate and names
|
|
10283
|
+
* the missing channel (RM5502).
|
|
10284
|
+
*/
|
|
10285
|
+
userQuestions = null;
|
|
10286
|
+
/** Late-bind the human-question channel when the composition mounts it. */
|
|
10287
|
+
attachUserQuestions(service) {
|
|
10288
|
+
this.userQuestions = service;
|
|
10289
|
+
}
|
|
10290
|
+
/** The human-question channel this composition mounted, or null. */
|
|
10291
|
+
get userQuestionsChannel() {
|
|
10292
|
+
return this.userQuestions;
|
|
10293
|
+
}
|
|
10294
|
+
/**
|
|
9795
10295
|
* T1 (goals projection): project the run into the native goals service so it is
|
|
9796
10296
|
* a first-class durable, resumable, blockable object. Best-effort — the run's
|
|
9797
10297
|
* filesystem state is the source of truth; a goal is the durable projection.
|
|
10298
|
+
*
|
|
10299
|
+
* ⚠ PHASE 0 — AND IT ARMS NOTHING UNLESS THE RUN WAS STARTED. `create` returns an ARMED goal, and an
|
|
10300
|
+
* armed goal is the harness driving autonomous rounds, so this is the one place where "project the
|
|
10301
|
+
* state" can quietly equal "start the run". The approval is therefore REQUIRED from the caller and
|
|
10302
|
+
* has no default here: a caller that has not resolved the run's approval cannot arm a goal by
|
|
10303
|
+
* forgetting to pass one, and `syncRunGoal` refuses every branch that would create one without it.
|
|
10304
|
+
*
|
|
10305
|
+
* Unapproved is the EXPECTED state for a scaffolded run, so the refusal comes back as a plain
|
|
10306
|
+
* `{ ok: false }` carrying {@link RUN_START_NOT_APPROVED}: callers must treat that as normal work,
|
|
10307
|
+
* never as a warning (see `armRunGoalIfApproved`, the one caller that arms).
|
|
10308
|
+
*/
|
|
10309
|
+
projectRunToGoal(agent, runId, state = "active", approved = false) {
|
|
10310
|
+
if (!agent) return {
|
|
10311
|
+
ok: false,
|
|
10312
|
+
reason: "no agent"
|
|
10313
|
+
};
|
|
10314
|
+
return syncRunGoal(this.goalsService, agent, runId, state, approved);
|
|
10315
|
+
}
|
|
10316
|
+
/**
|
|
10317
|
+
* PHASE 0 — the approved-run arm step: read the run's approval from `root` and project the goal only
|
|
10318
|
+
* if it is there. This is the phase-progress path (`syncRunGoal` reached on ordinary work), so the
|
|
10319
|
+
* unapproved case is deliberately silent: `{ ok: false, reason: RUN_START_NOT_APPROVED }` with no
|
|
10320
|
+
* goal, no write and no throw. Read on EVERY call rather than cached, because the approval can arrive
|
|
10321
|
+
* mid-session and a cached "not yet" would leave an approved run unable to arm until a plugin reload.
|
|
9798
10322
|
*/
|
|
9799
|
-
|
|
10323
|
+
armRunGoalIfApproved(agent, root, runId, state = "active") {
|
|
9800
10324
|
if (!agent) return {
|
|
9801
10325
|
ok: false,
|
|
9802
10326
|
reason: "no agent"
|
|
9803
10327
|
};
|
|
9804
|
-
|
|
10328
|
+
const approval = this.readRunStartApproval(root, runId);
|
|
10329
|
+
return syncRunGoal(this.goalsService, agent, runId, state, approval.approved);
|
|
9805
10330
|
}
|
|
9806
10331
|
/** T1: block the run's goal on a gate-block (durable + UI-visible). */
|
|
9807
10332
|
blockRunToGoal(agent, runId, reason) {
|
|
@@ -9811,13 +10336,18 @@ var RecursiveRuntime = class extends Service {
|
|
|
9811
10336
|
};
|
|
9812
10337
|
return blockRunGoal(this.goalsService, agent, runId, reason);
|
|
9813
10338
|
}
|
|
9814
|
-
/**
|
|
9815
|
-
|
|
10339
|
+
/**
|
|
10340
|
+
* T1: re-arm the run's goal on a reopen (blocked/paused -> active). Never starts an unstarted run —
|
|
10341
|
+
* REOPEN IS NOT A BACK DOOR TO STARTING A RUN. `approved` is required for the same reason as in
|
|
10342
|
+
* `projectRunToGoal`: the phase-0 gate cannot be defaulted open. An approved run's approval outlives
|
|
10343
|
+
* a reopen because it is a durable line in the run's own Phase 0 artifact, not a held value.
|
|
10344
|
+
*/
|
|
10345
|
+
resumeRunToGoal(agent, runId, approved = false) {
|
|
9816
10346
|
if (!agent) return {
|
|
9817
10347
|
ok: false,
|
|
9818
10348
|
reason: "no agent"
|
|
9819
10349
|
};
|
|
9820
|
-
return resumeRunGoal(this.goalsService, agent, runId);
|
|
10350
|
+
return resumeRunGoal(this.goalsService, agent, runId, approved);
|
|
9821
10351
|
}
|
|
9822
10352
|
/**
|
|
9823
10353
|
* Workspace-scoped control-plane root (R1 binding invariant).
|
|
@@ -10467,15 +10997,61 @@ var RecursiveRuntime = class extends Service {
|
|
|
10467
10997
|
runDir,
|
|
10468
10998
|
runId,
|
|
10469
10999
|
created,
|
|
10470
|
-
existing
|
|
11000
|
+
existing,
|
|
11001
|
+
runStartApproval: {
|
|
11002
|
+
...this.readRunStartApproval(scaffoldRoot, runId),
|
|
11003
|
+
gate: RUN_START_GATE_ID
|
|
11004
|
+
}
|
|
10471
11005
|
};
|
|
10472
11006
|
if (worktree) result.worktree = worktree;
|
|
10473
|
-
try {
|
|
10474
|
-
this.projectRunToGoal(agent, runId, "active");
|
|
10475
|
-
} catch {}
|
|
10476
11007
|
return result;
|
|
10477
11008
|
}
|
|
10478
11009
|
/**
|
|
11010
|
+
* PHASE 0 — THE APPROVAL ACT: record the human's `Start run` decision and arm the run's goal.
|
|
11011
|
+
*
|
|
11012
|
+
* ⚠ THE ONLY PATH THAT STARTS A RUN. It exists as one method rather than as "write a line, then
|
|
11013
|
+
* project the goal" at the tool, because those two steps must not be separable: an approval recorded
|
|
11014
|
+
* without the arm (or an arm without the record) is exactly the half-state that made this defect hard
|
|
11015
|
+
* to see. `tests/run-start-approval.spec.ts` drives both halves through this one call.
|
|
11016
|
+
*
|
|
11017
|
+
* The approval line goes into the run's own Phase 0 artifact, so it is durable, citable, and survives
|
|
11018
|
+
* the session — and so a reader of the run can answer "was this run started, and by what?" without the
|
|
11019
|
+
* transcript. `answer` is validated against the gate's own labels before it reaches here.
|
|
11020
|
+
*/
|
|
11021
|
+
approveRunStart(root, runId, agent, answer = RUN_START_APPROVE) {
|
|
11022
|
+
if (root.trim() === "" || runId.trim() === "") return {
|
|
11023
|
+
ok: false,
|
|
11024
|
+
reason: "a run start needs a workspace root and a run id",
|
|
11025
|
+
path: "",
|
|
11026
|
+
replaced: false,
|
|
11027
|
+
goal: {
|
|
11028
|
+
ok: false,
|
|
11029
|
+
reason: "no run to start"
|
|
11030
|
+
}
|
|
11031
|
+
};
|
|
11032
|
+
runStartArtifactPath(root, runId);
|
|
11033
|
+
const written = this.recordAskAnswer(root, runId, RUN_START_ARTIFACT, "- Run Start: " + answer);
|
|
11034
|
+
const approval = this.readRunStartApproval(root, runId);
|
|
11035
|
+
if (!approval.approved) return {
|
|
11036
|
+
ok: false,
|
|
11037
|
+
reason: approval.reason,
|
|
11038
|
+
path: written.path,
|
|
11039
|
+
replaced: written.replaced,
|
|
11040
|
+
goal: {
|
|
11041
|
+
ok: false,
|
|
11042
|
+
reason: RUN_START_NOT_APPROVED
|
|
11043
|
+
}
|
|
11044
|
+
};
|
|
11045
|
+
const goal = this.armRunGoalIfApproved(agent, root, runId, "active");
|
|
11046
|
+
return {
|
|
11047
|
+
ok: true,
|
|
11048
|
+
reason: "",
|
|
11049
|
+
path: written.path,
|
|
11050
|
+
replaced: written.replaced,
|
|
11051
|
+
goal
|
|
11052
|
+
};
|
|
11053
|
+
}
|
|
11054
|
+
/**
|
|
10479
11055
|
* Create a linked worktree for a run under the given workspace root. The
|
|
10480
11056
|
* worktree branch defaults to `recursive/<runId>` and is cut from the given
|
|
10481
11057
|
* base branch (default: the current HEAD branch of the root checkout).
|
|
@@ -10584,7 +11160,7 @@ var RecursiveRuntime = class extends Service {
|
|
|
10584
11160
|
const stale = getStaleDownstreamPhases(runDir, artifact);
|
|
10585
11161
|
for (const entry of stale) invalidateReceipt(runDir, entry.artifact);
|
|
10586
11162
|
try {
|
|
10587
|
-
this.resumeRunToGoal(agent, runId);
|
|
11163
|
+
this.resumeRunToGoal(agent, runId, this.readRunStartApproval(root, runId).approved);
|
|
10588
11164
|
} catch {}
|
|
10589
11165
|
return {
|
|
10590
11166
|
artifact,
|
|
@@ -10936,15 +11512,107 @@ function createRecursiveStatusTool(recursive) {
|
|
|
10936
11512
|
});
|
|
10937
11513
|
}
|
|
10938
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
|
|
10939
11584
|
//#region src/recursive_init.tool.ts
|
|
11585
|
+
/**
|
|
11586
|
+
* PHASE 0 — SCAFFOLDING IS NOT STARTING, AND THE TOOL SAYS SO AT THE MOMENT IT MATTERS.
|
|
11587
|
+
*
|
|
11588
|
+
* A spec may legitimately exist before a run does: this tool writes the run directory and every phase
|
|
11589
|
+
* document, and it still does. What it must NOT do is start the run, because starting is creating and
|
|
11590
|
+
* arming the goal the harness drives autonomous rounds from. That is the owner's rule — *"phase 0
|
|
11591
|
+
* requires explicit approval to start a run and goal"* — so the description below names the gate and the
|
|
11592
|
+
* result carries `runStartApproval`, which is the pointer a caller needs: the run is inert until
|
|
11593
|
+
* `recursive_ask` answers `run-start`.
|
|
11594
|
+
*
|
|
11595
|
+
* `runStartApproval` is read from the run's own Phase 0 artifact on every call, so it is the TRUE state
|
|
11596
|
+
* rather than "this call created something": re-initialising an APPROVED run reports `approved: true`
|
|
11597
|
+
* (and the run keeps its goal), which is what a caller re-scaffolding a run it already started needs to
|
|
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.
|
|
11607
|
+
*/
|
|
10940
11608
|
function createRecursiveInitTool(recursive) {
|
|
10941
11609
|
return defineTool({
|
|
10942
11610
|
name: "recursive_init",
|
|
10943
|
-
description: "Scaffold a new recursive-mode run directory (or ensure an existing one) with stub artifact headers. Delegates to the RecursiveRuntime service (no duplicated scaffolding logic). When createWorktree is true, a linked worktree is created first and the run is scaffolded inside it.",
|
|
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.",
|
|
10944
11612
|
parameters: {
|
|
10945
11613
|
runId: {
|
|
10946
11614
|
type: "string",
|
|
10947
|
-
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."
|
|
10948
11616
|
},
|
|
10949
11617
|
createWorktree: {
|
|
10950
11618
|
type: "boolean",
|
|
@@ -10963,9 +11631,12 @@ function createRecursiveInitTool(recursive) {
|
|
|
10963
11631
|
}]
|
|
10964
11632
|
},
|
|
10965
11633
|
async execute(args, exec) {
|
|
10966
|
-
|
|
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") };
|
|
10967
11638
|
try {
|
|
10968
|
-
return await recursive.initRun(
|
|
11639
|
+
return await recursive.initRun(runId, exec.agent, {
|
|
10969
11640
|
createWorktree: args.createWorktree === true,
|
|
10970
11641
|
baseBranch: args.baseBranch?.trim() || void 0
|
|
10971
11642
|
});
|
|
@@ -11179,6 +11850,16 @@ function createRecursiveLintTool(recursive) {
|
|
|
11179
11850
|
* SESSION's workspace only (R1 workspace-scoping invariant). The run is resolved
|
|
11180
11851
|
* via the session agent's cwd -> workspace registry; a runId outside the current
|
|
11181
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.
|
|
11182
11863
|
*/
|
|
11183
11864
|
function createRecursiveCloseoutTool(recursive) {
|
|
11184
11865
|
return defineTool({
|
|
@@ -11191,7 +11872,7 @@ function createRecursiveCloseoutTool(recursive) {
|
|
|
11191
11872
|
},
|
|
11192
11873
|
runId: {
|
|
11193
11874
|
type: "string",
|
|
11194
|
-
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."
|
|
11195
11876
|
}
|
|
11196
11877
|
},
|
|
11197
11878
|
output: {
|
|
@@ -11203,9 +11884,12 @@ function createRecursiveCloseoutTool(recursive) {
|
|
|
11203
11884
|
},
|
|
11204
11885
|
async execute(args, exec) {
|
|
11205
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") };
|
|
11206
11890
|
const root = await recursive.resolveWorkspaceRoot(exec.agent);
|
|
11207
11891
|
if (!root) return { error: toolError("NO_WORKSPACE") };
|
|
11208
|
-
return await recursive.closeoutRun(root,
|
|
11892
|
+
return await recursive.closeoutRun(root, runId, args.phase.trim(), exec.agent);
|
|
11209
11893
|
}
|
|
11210
11894
|
});
|
|
11211
11895
|
}
|
|
@@ -11215,6 +11899,16 @@ function createRecursiveCloseoutTool(recursive) {
|
|
|
11215
11899
|
* `recursive_scratch` — read/write/append the run-scoped disposable scratchpad
|
|
11216
11900
|
* (R5) under the CURRENT session workspace only (R1). Scratch is git-ignored
|
|
11217
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.
|
|
11218
11912
|
*/
|
|
11219
11913
|
function createRecursiveScratchTool(recursive) {
|
|
11220
11914
|
return defineTool({
|
|
@@ -11227,7 +11921,7 @@ function createRecursiveScratchTool(recursive) {
|
|
|
11227
11921
|
},
|
|
11228
11922
|
runId: {
|
|
11229
11923
|
type: "string",
|
|
11230
|
-
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."
|
|
11231
11925
|
},
|
|
11232
11926
|
target: {
|
|
11233
11927
|
type: "string",
|
|
@@ -11250,6 +11944,8 @@ function createRecursiveScratchTool(recursive) {
|
|
|
11250
11944
|
const runId = args.runId?.trim() ?? "";
|
|
11251
11945
|
const target = args.target ?? "";
|
|
11252
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") };
|
|
11253
11949
|
if (target !== "md" && target !== "ts") return { error: toolError("BAD_TARGET") };
|
|
11254
11950
|
const root = await recursive.resolveWorkspaceRoot(exec.agent);
|
|
11255
11951
|
if (!root) return { error: toolError("NO_WORKSPACE") };
|
|
@@ -11263,6 +11959,22 @@ function createRecursiveScratchTool(recursive) {
|
|
|
11263
11959
|
* `recursive_worktree` — create a linked git worktree for a run and/or
|
|
11264
11960
|
* promote a branch up the dev/stage/main chain. Workspace-scoped: the
|
|
11265
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.
|
|
11266
11978
|
*/
|
|
11267
11979
|
function createRecursiveWorktreeTool(recursive) {
|
|
11268
11980
|
return defineTool({
|
|
@@ -11271,7 +11983,7 @@ function createRecursiveWorktreeTool(recursive) {
|
|
|
11271
11983
|
parameters: {
|
|
11272
11984
|
runId: {
|
|
11273
11985
|
type: "string",
|
|
11274
|
-
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."
|
|
11275
11987
|
},
|
|
11276
11988
|
action: {
|
|
11277
11989
|
type: "string",
|
|
@@ -11303,7 +12015,10 @@ function createRecursiveWorktreeTool(recursive) {
|
|
|
11303
12015
|
if (!root) return { error: toolError("NO_WORKSPACE") };
|
|
11304
12016
|
if (action === "create") {
|
|
11305
12017
|
if (!args.runId || args.runId.trim() === "") return { error: toolError("MISSING_CREATE_RUN_ID") };
|
|
11306
|
-
|
|
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);
|
|
11307
12022
|
}
|
|
11308
12023
|
if (action === "promote") {
|
|
11309
12024
|
if (!args.fromBranch || !args.toBranch) return { error: toolError("MISSING_PROMOTE_BRANCHES") };
|
|
@@ -11322,6 +12037,14 @@ function createRecursiveWorktreeTool(recursive) {
|
|
|
11322
12037
|
* (runtime.phaseRules -> phaseRulesFor) as the once-per-phase pre-step
|
|
11323
12038
|
* reminder, so the agent can re-ask for the rules without re-injecting them on
|
|
11324
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.
|
|
11325
12048
|
*/
|
|
11326
12049
|
function createRecursivePhaseTool(recursive) {
|
|
11327
12050
|
return defineTool({
|
|
@@ -11329,7 +12052,7 @@ function createRecursivePhaseTool(recursive) {
|
|
|
11329
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.",
|
|
11330
12053
|
parameters: { runId: {
|
|
11331
12054
|
type: "string",
|
|
11332
|
-
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."
|
|
11333
12056
|
} },
|
|
11334
12057
|
output: {
|
|
11335
12058
|
schema: { type: "json" },
|
|
@@ -11339,7 +12062,12 @@ function createRecursivePhaseTool(recursive) {
|
|
|
11339
12062
|
}]
|
|
11340
12063
|
},
|
|
11341
12064
|
async execute(args, exec) {
|
|
11342
|
-
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);
|
|
11343
12071
|
if (!result) return { error: toolError("NO_PHASE") };
|
|
11344
12072
|
return result;
|
|
11345
12073
|
}
|
|
@@ -13687,6 +14415,10 @@ function apply(ctx, config) {
|
|
|
13687
14415
|
ctx.inject(["llm"], (llmCtx) => {
|
|
13688
14416
|
recursive.attachLlmInventory(llmCtx.get("llm") ?? null);
|
|
13689
14417
|
});
|
|
14418
|
+
recursive.attachUserQuestions(ctx.get("userQuestions") ?? null);
|
|
14419
|
+
ctx.inject(["userQuestions"], (questionsCtx) => {
|
|
14420
|
+
recursive.attachUserQuestions(questionsCtx.get("userQuestions") ?? null);
|
|
14421
|
+
});
|
|
13690
14422
|
if (config?.enforcement !== void 0) recursive.setEnforcementConfig(config.enforcement);
|
|
13691
14423
|
const phaseSkills = registerPhaseSkills(ctx.get("skills"), PHASE_SEQUENCE);
|
|
13692
14424
|
yield () => {
|