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

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/src/run-start.ts CHANGED
@@ -42,6 +42,8 @@
42
42
  import { readFileSync } from 'node:fs'
43
43
  import { join } from 'node:path'
44
44
  import { getMdFieldValue } from './status.ts'
45
+ import { classifyArtifact, describeEvidence, unfilledEvidence } from './run-spec.ts'
46
+ import { toolError } from './errors.ts'
45
47
 
46
48
  /** The gate id `recursive_ask` answers for a run start. Deliberately NOT in ASK_GATE_IDS. */
47
49
  export const RUN_START_GATE_ID = 'run-start'
@@ -127,3 +129,60 @@ export function readRunStartApproval(root: string, runId: string): { approved: b
127
129
  * the same string instead of re-typing it (a re-typed reason is a caller that silently stops matching).
128
130
  */
129
131
  export const RUN_START_NOT_APPROVED = 'run not started: phase 0 approval has not been granted'
132
+
133
+ /* ============================ THE ORDERING GUARD ============================ */
134
+
135
+ /**
136
+ * PHASE 0 — THE GATE CANNOT BE RAISED BEFORE THERE IS A SPEC TO DECIDE ABOUT.
137
+ *
138
+ * THE DEFECT. `recursive_init` scaffolds Phase 0 as a TEMPLATE, and `recursive_ask gate=run-start` raised
139
+ * "start this run or hold?" over it immediately — while every requirement was still `<short title>`, every
140
+ * acceptance criterion was still `[observable condition 1]`, and nothing put the document in front of the
141
+ * person at all. The owner: *"the card ui for accepting the spec appeared, but i was never shown the spec
142
+ * before that so how could i approve if i havent seen it"*. Approving an unfilled template is not a decision
143
+ * about a spec; there is no spec yet, and a card that asks the question anyway teaches a person to answer
144
+ * without reading.
145
+ *
146
+ * ⚠ WHAT THIS DOES *NOT* TOUCH. It does not weaken the gate's own contract, it does not add a second way to
147
+ * start a run, and it does not make the plugin the decider: it only refuses to ASK. A person's own answer
148
+ * still wins (`recordRunStartAnswer` is unchanged), a spec still creates no goal, and cancellation / abort /
149
+ * timeout are still unrelayable. The check runs BEFORE the question is put to anybody, so no card is shown
150
+ * for a document that cannot be approved meaningfully.
151
+ *
152
+ * ⚠ AND IT IS A CHECK ON THE DOCUMENT, NOT ON THE CALLER. A `runId` that does not resolve is not this
153
+ * refusal's business — the ask path already reports that — so the guard says `ok: true` there and lets the
154
+ * existing route handle it.
155
+ */
156
+ export function runStartSpecGuard(root: string, runId: string): { ok: true } | { ok: false; reason: string } {
157
+ const content = readRunStartArtifact(root, runId)
158
+ if (content === null) {
159
+ // ⚠ A MISSING ARTIFACT IS REFUSED TOO, AND IT IS THE SAME DEFECT. Approving a run that has no Phase 0
160
+ // document is approving something nobody can read — the gate's own question ("approve phase 0 and start
161
+ // this run?") has no referent. The refusal names the path a reader can go and look at.
162
+ return {
163
+ ok: false,
164
+ reason: toolError(
165
+ 'RUN_START_SPEC_UNFILLED',
166
+ 'there is no Phase 0 document to approve: ' + runStartArtifactPath(root, runId) + ' does not exist yet',
167
+ ),
168
+ }
169
+ }
170
+ const verdict = classifyArtifact(content)
171
+ if (verdict.verdict === 'filled') return { ok: true }
172
+ const evidence = unfilledEvidence(verdict)
173
+ const context = verdict.hits.filter((hit) => hit.id !== 'placeholder')
174
+ const contextNote = context.length === 0
175
+ ? ''
176
+ : ' (it also carries ' + String(context.length) + ' unfinished marker(s) of its own, starting at line '
177
+ + String(context[0]?.line ?? 0) + ')'
178
+ // The evidence — line numbers and the placeholder text VERBATIM — travels in the detail: a refusal that
179
+ // asserts "it is a template" without quoting it is a refusal the caller can only take on trust.
180
+ return {
181
+ ok: false,
182
+ reason: toolError(
183
+ 'RUN_START_SPEC_UNFILLED',
184
+ RUN_START_ARTIFACT + ' for run ' + JSON.stringify(runId) + ' still carries the template scaffold'
185
+ + contextNote + ': ' + describeEvidence(evidence),
186
+ ),
187
+ }
188
+ }
package/src/runtime.ts CHANGED
@@ -1099,7 +1099,16 @@ export class RecursiveRuntime extends Service {
1099
1099
  result = continuable.rounds[continuable.rounds.length - 1].result ?? null
1100
1100
  if (!result) error = 'continuable child produced no final result'
1101
1101
  } else {
1102
- error = continuable.reason ?? 'continuable delegation failed'
1102
+ // ⚠ A ROUND THAT SETTLED IS A RESULT, EVEN WHEN IT WAS NOT ACCEPTED — and this branch used to
1103
+ // discard it. The condition above requires `continuable.ok`, so a child that REPORTED and was
1104
+ // refused (`success: false`, or a non-completed stop reason) fell through to here: `result` stayed
1105
+ // null, the action record said "NO SETTLEMENT arrived within the wait", and the child's own stop
1106
+ // reason — the one fact that explains the refusal — was dropped on the floor. It is the same defect
1107
+ // as the parked one, one branch over: an absence asserted where the code had evidence. Keeping the
1108
+ // result is what lets the record say "the delegation returned without acceptance; stop reason error"
1109
+ // instead of blaming a wait that ended perfectly well.
1110
+ result = continuable.rounds[continuable.rounds.length - 1]?.result ?? null
1111
+ if (result === null) error = continuable.reason ?? 'continuable delegation failed'
1103
1112
  }
1104
1113
  } else {
1105
1114
  try {
@@ -1147,7 +1156,14 @@ export class RecursiveRuntime extends Service {
1147
1156
  id: operation,
1148
1157
  act: 'delegate-review',
1149
1158
  at: new Date().toISOString().replace(/\.\d{3}Z$/, 'Z'),
1150
- outcome: evaluation.accepted ? 'accepted' : 'unaccepted',
1159
+ // ⚠ A PARK IS NOT A REFUSAL, and `unaccepted` said it was. This line used to be
1160
+ // `accepted ? 'accepted' : 'unaccepted'`, so a round that had merely not settled yet was indexed
1161
+ // exactly like a delegation that was evaluated and refused — while the round it really was (still in
1162
+ // flight, resume the same child) was nowhere in the run's own operation log. The defect the action
1163
+ // record had, the log had too. The new value is honest on both readings that matter: it is not
1164
+ // `accepted`, so every retry gate still treats the operation as unfinished and retryable — which is
1165
+ // what a parked round is — and it no longer claims the delegation was judged and rejected.
1166
+ outcome: parked ? 'parked' : (evaluation.accepted ? 'accepted' : 'unaccepted'),
1151
1167
  phase: input.phase,
1152
1168
  })
1153
1169
  }
@@ -1158,8 +1174,8 @@ export class RecursiveRuntime extends Service {
1158
1174
  runId: input.runId,
1159
1175
  subagentId: input.childId,
1160
1176
  phase: input.phase,
1161
- // ⚠ FU-17 — the kind is stated in the record. `Status` says accepted or failed; nothing said whether the
1162
- // child PRODUCED the phase's work or JUDGED it, and a reader of a run could not tell the two apart.
1177
+ // ⚠ FU-17 — the kind is stated in the record. `Status` says accepted, failed or parked; nothing said whether
1178
+ // the child PRODUCED the phase's work or JUDGED it, and a reader of a run could not tell the two apart.
1163
1179
  purpose: input.role + (input.kind === 'work' ? ' (work)' : '') + ' for run ' + input.runId,
1164
1180
  executionMode: decision.tier + (input.mode !== 'one-shot' ? ' (continuable)' : ''),
1165
1181
  artifactPath: input.artifactPath,
@@ -1171,32 +1187,70 @@ export class RecursiveRuntime extends Service {
1171
1187
  findings: evaluation.accepted && result?.structured ? [(result.structured as { verdict?: string })?.verdict ?? 'accepted'] : undefined,
1172
1188
  success: evaluation.accepted,
1173
1189
  stopReason: result?.stopReason,
1190
+ // ⚠ A PARKED ROUND IS RECORDED AS PARKED — the whole defect in one field. `success: evaluation.accepted`
1191
+ // is false for a park (correct: nothing was accepted), and `writeActionRecord` reads this flag to state
1192
+ // the third state instead of collapsing it into `failed`.
1193
+ ...(parked ? { parked: true } : {}),
1174
1194
  // ⚠ FU-9 — AND SAY WHICH KIND OF FAILURE, because the record previously could not. `result == null` means
1175
1195
  // the provider never produced anything at all (never started, or returned nothing) — which is what the
1176
1196
  // live record's `Stop Reason: n/a` was quietly telling me — while a present result that failed to be
1177
1197
  // accepted means a child DID run and its work was refused. Different problems, identical artifacts.
1198
+ //
1199
+ // ⚠ AND `parked` IS BRANCHED FIRST. A parked round produced NO result at all — that IS what parking
1200
+ // means — so without this branch first it fell into the `result == null` text below: "the continuable
1201
+ // start was made and NO SETTLEMENT arrived within the wait (the child never reported, or never ran)".
1202
+ // That is the exact false conclusion this fix exists for, and it was reached whatever the record's
1203
+ // Status said. Order is therefore load-bearing here.
1178
1204
  failure: evaluation.accepted
1179
1205
  ? undefined
1180
- : result == null
1181
- // ⚠ THE TWO STATES ARE NOT THE SAME AND THE MESSAGE USED TO CONFLATE THEM. The one-shot path cannot
1182
- // resolve to nothing — the host's `start` returns a run or throws (assertCapabilities, expectProvider)
1183
- // — so a null result on the CONTINUABLE path means the opposite of what I first wrote: the start WAS
1184
- // made and NO SETTLEMENT ARRIVED within the wait. That distinction cost me two rounds of looking at
1185
- // provider names, so the record now states which path a run took and what it was waiting for.
1186
- ? (input.mode !== 'one-shot'
1187
- ? 'the continuable start was made and NO SETTLEMENT arrived within the wait (the child never reported,'
1188
- + ' or never ran); tier ' + decision.tier + ', provider ' + (decision.provider ?? 'none chosen')
1189
- + ', names on offer [' + (this.lastProviderNames.join(', ') || 'none') + ']'
1190
- // ⚠ FU-9 — THE PARENT IDENTITY, because the host refuses a prompt when it cannot resolve the
1191
- // parent session as a live Agent (`subagent/parent-unavailable`, index.ts L429-436), and the tool
1192
- // builds this handle with a CAST (`exec.agent as unknown as SubagentParentHandle`). A cast is not
1193
- // a contract: if the id here is not the one the host looks up, the refusal is real and the
1194
- // classifier's crash has been hiding it. Printing it here costs nothing and settles the question.
1195
- + '; parent id ' + ((input.parent as { id?: string } | undefined)?.id ?? 'none')
1196
- + ', parent session keys [' + (input.parent === undefined ? 'no parent' : Object.keys(input.parent as object).join(', ')) + ']'
1197
- : 'no delegate result was produced by the one-shot path; tier ' + decision.tier
1198
- + ', provider ' + (decision.provider ?? 'none chosen'))
1199
- : 'the delegation returned without acceptance; stop reason ' + (result.stopReason ?? 'none reported'),
1206
+ : parked
1207
+ // ⚠ WHAT IS KNOWN, AND ONLY WHAT IS KNOWN, WITH THE ID THE READER NEEDS TO ACT.
1208
+ //
1209
+ // The text this replaces said "the child never reported, or never ran" — a CONCLUSION drawn from an
1210
+ // ABSENCE, and it was false: a live child went on to complete three review rounds and reply eighteen
1211
+ // minutes later, while the main agent read `Status: failed`, concluded the child was dead, and
1212
+ // obtained its review by other means. So the parked message asserts nothing about the child's state
1213
+ // beyond "no settlement had landed when the wait ended", keeps "may still be working" as the
1214
+ // possibility it is, and NAMES the childId plus the exact next step, because advice to resume is
1215
+ // unactionable without the id. The identity diagnostics stay, because they are what makes a
1216
+ // misconfigured provider readable — but they are diagnostics, not the reason.
1217
+ ? 'no settlement had landed when the wait ended, so this round is PARKED, not failed: nothing was'
1218
+ + ' accepted and nothing was refused, and the child may still be working. The next step is to RESUME'
1219
+ + ' this round, not to re-dispatch it or replace the child: call `recursive_review` again on a later'
1220
+ + ' turn with childId ' + String(continuable?.childId ?? input.childId) + ' (the child the round was'
1221
+ + ' started for, which stays resumable). Diagnostics: tier ' + decision.tier
1222
+ + ', provider ' + (decision.provider ?? 'none chosen')
1223
+ + ', names on offer [' + (this.lastProviderNames.join(', ') || 'none') + ']'
1224
+ // ⚠ FU-9 — THE PARENT IDENTITY, because the host refuses a prompt when it cannot resolve the
1225
+ // parent session as a live Agent (`subagent/parent-unavailable`, index.ts L429-436), and the tool
1226
+ // builds this handle with a CAST (`exec.agent as unknown as SubagentParentHandle`). A cast is not
1227
+ // a contract: if the id here is not the one the host looks up, the refusal is real and the
1228
+ // classifier's crash has been hiding it. Printing it here costs nothing and settles the question.
1229
+ + '; parent id ' + ((input.parent as { id?: string } | undefined)?.id ?? 'none')
1230
+ + ', parent session keys [' + (input.parent === undefined ? 'no parent' : Object.keys(input.parent as object).join(', ')) + ']'
1231
+ : result == null
1232
+ // ⚠ THE TWO STATES ARE NOT THE SAME AND THE MESSAGE USED TO CONFLATE THEM. The one-shot path cannot
1233
+ // resolve to nothing — the host's `start` returns a run or throws (assertCapabilities, expectProvider)
1234
+ // — so a null result on the CONTINUABLE path means the opposite of what I first wrote: the start WAS
1235
+ // made and NO SETTLEMENT ARRIVED within the wait. That distinction cost me two rounds of looking at
1236
+ // provider names, so the record now states which path a run took and what it was waiting for.
1237
+ // (A park is handled above and never reaches this branch; this one is a continuable round that
1238
+ // produced neither a result nor the park signal, which IS a failure to report.)
1239
+ ? (input.mode !== 'one-shot'
1240
+ ? 'the continuable start was made and NO SETTLEMENT arrived within the wait (the child never reported,'
1241
+ + ' or never ran); tier ' + decision.tier + ', provider ' + (decision.provider ?? 'none chosen')
1242
+ + ', names on offer [' + (this.lastProviderNames.join(', ') || 'none') + ']'
1243
+ // ⚠ FU-9 — THE PARENT IDENTITY, because the host refuses a prompt when it cannot resolve the
1244
+ // parent session as a live Agent (`subagent/parent-unavailable`, index.ts L429-436), and the tool
1245
+ // builds this handle with a CAST (`exec.agent as unknown as SubagentParentHandle`). A cast is not
1246
+ // a contract: if the id here is not the one the host looks up, the refusal is real and the
1247
+ // classifier's crash has been hiding it. Printing it here costs nothing and settles the question.
1248
+ + '; parent id ' + ((input.parent as { id?: string } | undefined)?.id ?? 'none')
1249
+ + ', parent session keys [' + (input.parent === undefined ? 'no parent' : Object.keys(input.parent as object).join(', ')) + ']'
1250
+ : 'no delegate result was produced by the one-shot path; tier ' + decision.tier
1251
+ + ', provider ' + (decision.provider ?? 'none chosen'))
1252
+ : 'the delegation returned without acceptance; stop reason ' + (result.stopReason ?? 'none reported')
1253
+ + (result.success === false ? ' (the child itself reported success:false)' : ''),
1200
1254
  })
1201
1255
 
1202
1256
  // T35: report the mode that ACTUALLY ran, not the one that was asked for. A