@try-works/dsh-recursive-mode 0.4.4 → 0.4.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/errors.d.ts +12 -0
- package/lib/goals-projection.d.ts +34 -4
- package/lib/index.js +471 -41
- package/lib/recursive_ask.tool.d.ts +60 -0
- package/lib/recursive_init.tool.d.ts +15 -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 +12 -0
- package/src/goals-projection.ts +48 -7
- package/src/index.ts +9 -0
- package/src/recursive_ask.tool.ts +218 -17
- package/src/recursive_init.tool.ts +16 -1
- package/src/run-start.ts +123 -0
- package/src/runtime.ts +157 -14
|
@@ -22,12 +22,44 @@ import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
|
22
22
|
import type { JsonValue } from '@deepseek-ai/dsh-util-values'
|
|
23
23
|
import type { RecursiveRuntime } from './runtime.ts'
|
|
24
24
|
import { toolError } from './errors.ts'
|
|
25
|
+
import {
|
|
26
|
+
RUN_START_APPROVE,
|
|
27
|
+
RUN_START_ARTIFACT,
|
|
28
|
+
RUN_START_GATE,
|
|
29
|
+
RUN_START_GATE_ID,
|
|
30
|
+
} from './run-start.ts'
|
|
25
31
|
|
|
26
32
|
|
|
27
33
|
/** The identifiers the workflow uses for its three human gates. */
|
|
28
34
|
export const ASK_GATE_IDS = ['tdd-mode', 'qa-signoff', 'gate-block'] as const
|
|
29
35
|
export type AskGateId = (typeof ASK_GATE_IDS)[number]
|
|
30
36
|
|
|
37
|
+
/**
|
|
38
|
+
* PHASE 0 — THE FOURTH GATE, AND WHY IT IS NOT IN `ASK_GATE_IDS`.
|
|
39
|
+
*
|
|
40
|
+
* The three above are the WORKFLOW's gates, and their membership is asserted as exactly those three.
|
|
41
|
+
* Starting a run is a different kind of decision — it decides whether there is a run at all, and
|
|
42
|
+
* approving it ARMS A GOAL the harness will keep driving — so its data lives in `run-start.ts` with its
|
|
43
|
+
* own contract and its own options. Widening the workflow's gate list must not silently widen what may
|
|
44
|
+
* start a run.
|
|
45
|
+
*/
|
|
46
|
+
export type AskAnyGateId = AskGateId | typeof RUN_START_GATE_ID
|
|
47
|
+
|
|
48
|
+
/** Is this gate id the run-start gate? */
|
|
49
|
+
export function isRunStartGate(gateId: string): boolean {
|
|
50
|
+
return gateId === RUN_START_GATE_ID
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Every gate id `recursive_ask` accepts, workflow gates first. */
|
|
54
|
+
export function askGateIds(): string[] {
|
|
55
|
+
return [...ASK_GATE_IDS, RUN_START_GATE_ID]
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The artifact a gate's answer belongs in — the run-start gate's is fixed to the Phase 0 requirements. */
|
|
59
|
+
export function askGateArtifact(gateId: AskAnyGateId): string {
|
|
60
|
+
return isRunStartGate(gateId) ? RUN_START_ARTIFACT : GATE_DEFAULT_ARTIFACT[gateId as AskGateId]
|
|
61
|
+
}
|
|
62
|
+
|
|
31
63
|
/** The plugin's own documented bounds — see the module comment on why these are not a claimed mirror. */
|
|
32
64
|
export const MAX_HEADER_CHARS = 12
|
|
33
65
|
export const MAX_LABEL_CHARS = 30
|
|
@@ -137,6 +169,42 @@ export function buildAskQuestion(gateId: AskGateId): AskQuestion {
|
|
|
137
169
|
})
|
|
138
170
|
}
|
|
139
171
|
|
|
172
|
+
/**
|
|
173
|
+
* PHASE 0 — build the question for ANY accepted gate, including the run-start gate.
|
|
174
|
+
*
|
|
175
|
+
* A separate entry point rather than a widened `buildAskQuestion` so the three workflow gates keep the
|
|
176
|
+
* exact signature and behaviour their callers (and `runtime.phaseRules`) already rely on.
|
|
177
|
+
*/
|
|
178
|
+
export function buildAskQuestionFor(gateId: AskAnyGateId): AskQuestion {
|
|
179
|
+
if (isRunStartGate(gateId)) {
|
|
180
|
+
return validateAskQuestion({
|
|
181
|
+
id: RUN_START_GATE.id,
|
|
182
|
+
header: RUN_START_GATE.header,
|
|
183
|
+
question: RUN_START_GATE.question,
|
|
184
|
+
options: RUN_START_GATE.options.map((option) => ({ ...option })),
|
|
185
|
+
})
|
|
186
|
+
}
|
|
187
|
+
return buildAskQuestion(gateId as AskGateId)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* PHASE 0 — validate an answer to ANY accepted gate.
|
|
192
|
+
*
|
|
193
|
+
* The run-start gate accepts only the labels IT offered, exactly like the other three, and the check is
|
|
194
|
+
* the same `ASK_GATES`-shaped test against its own options. An answer of `maybe` is refused rather than
|
|
195
|
+
* recorded, because a recorded non-answer is the failure mode this whole change exists to prevent.
|
|
196
|
+
*/
|
|
197
|
+
export function validateAskAnswerFor(gateId: AskAnyGateId, answer: string): string {
|
|
198
|
+
if (isRunStartGate(gateId)) {
|
|
199
|
+
const offered = RUN_START_GATE.options.map((option) => option.label) as readonly string[]
|
|
200
|
+
if (!offered.includes(answer)) {
|
|
201
|
+
throw new AskValidationError('answer', 'must be one of ' + offered.join(' | ') + ' (got ' + JSON.stringify(answer) + ')')
|
|
202
|
+
}
|
|
203
|
+
return answer
|
|
204
|
+
}
|
|
205
|
+
return validateAskAnswer(gateId as AskGateId, answer)
|
|
206
|
+
}
|
|
207
|
+
|
|
140
208
|
/**
|
|
141
209
|
* Validate an answer against its gate.
|
|
142
210
|
*
|
|
@@ -218,35 +286,42 @@ export function pendingGateFor(artifactFile: string, artifactText: string | null
|
|
|
218
286
|
* ⚠ THE WRITE-BACK IS A MARKER LINE, REPLACED IN PLACE when the artifact already carries one. A
|
|
219
287
|
* second `TDD Mode:` line would leave two answers to one question and make "what was decided?"
|
|
220
288
|
* depend on which a reader found first.
|
|
289
|
+
*
|
|
290
|
+
* ⚠ PHASE 0 — `run-start` IS RECORDED BY THE PLUGIN, NEVER BY A BARE MARKER WRITE. See
|
|
291
|
+
* `recordRunStartAnswer`: it is the only path that can arm a run goal, it prefers the blocking human
|
|
292
|
+
* channel, and it fails closed when no person can be reached.
|
|
221
293
|
*/
|
|
222
294
|
export function createRecursiveAskTool(recursive: RecursiveRuntime) {
|
|
223
295
|
return defineTool({
|
|
224
296
|
name: 'recursive_ask',
|
|
225
|
-
description: 'Ask
|
|
297
|
+
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.',
|
|
226
298
|
parameters: {
|
|
227
|
-
gate: { type: 'string', description: 'tdd-mode | qa-signoff | gate-block. Required.' },
|
|
299
|
+
gate: { type: 'string', 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.' },
|
|
228
300
|
runId: { type: 'string', description: 'Run id. Required; must resolve inside the current workspace.' },
|
|
229
|
-
artifact: { type: 'string', description: 'Artifact file the answer belongs in. Optional; defaults per gate (gate-block has none, so it is required for that gate).' },
|
|
230
|
-
answer: { type: 'string', description: 'One of the gate\'s option labels. Omit to ASK.' },
|
|
301
|
+
artifact: { type: 'string', 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).' },
|
|
302
|
+
answer: { type: 'string', 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(' | ') + '.' },
|
|
231
303
|
},
|
|
232
304
|
output: {
|
|
233
305
|
schema: { type: 'json' },
|
|
234
306
|
render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 2) }],
|
|
235
307
|
},
|
|
236
308
|
async execute(args: { gate?: string; runId?: string; artifact?: string; answer?: string }, exec) {
|
|
237
|
-
const gateId = (args.gate ?? '').trim() as
|
|
309
|
+
const gateId = (args.gate ?? '').trim() as AskAnyGateId
|
|
238
310
|
const runId = args.runId?.trim() ?? ''
|
|
239
311
|
if (runId === '') return { error: toolError('MISSING_RUN_ID') } as const
|
|
240
|
-
if (!
|
|
241
|
-
return { error: toolError('BAD_ASK_GATE', 'gate must be one of ' +
|
|
312
|
+
if (!askGateIds().includes(gateId)) {
|
|
313
|
+
return { error: toolError('BAD_ASK_GATE', 'gate must be one of ' + askGateIds().join(' | ')) } as const
|
|
242
314
|
}
|
|
243
315
|
const root = await recursive.resolveWorkspaceRoot(exec.agent)
|
|
244
316
|
if (!root) return { error: toolError('NO_WORKSPACE') } as const
|
|
245
317
|
|
|
246
|
-
|
|
318
|
+
// The run-start gate's artifact is FIXED: the Phase 0 requirements document is the run's own
|
|
319
|
+
// phase-0 record, and letting a caller aim the approval somewhere else is how an approval ends up
|
|
320
|
+
// in a file no reader looks at. Every other gate keeps its per-gate default and its override.
|
|
321
|
+
const artifact = (isRunStartGate(gateId) ? RUN_START_ARTIFACT : args.artifact ?? GATE_DEFAULT_ARTIFACT[gateId as AskGateId]).trim()
|
|
247
322
|
let question: AskQuestion
|
|
248
323
|
try {
|
|
249
|
-
question =
|
|
324
|
+
question = buildAskQuestionFor(gateId)
|
|
250
325
|
} catch (err) {
|
|
251
326
|
// The plugin's own gate data failing validation is a defect, so it is reported as one
|
|
252
327
|
// rather than asked: a malformed card would be answered by a person who cannot fix it.
|
|
@@ -254,23 +329,149 @@ export function createRecursiveAskTool(recursive: RecursiveRuntime) {
|
|
|
254
329
|
}
|
|
255
330
|
|
|
256
331
|
// ASK.
|
|
257
|
-
|
|
258
|
-
|
|
332
|
+
//
|
|
333
|
+
// ⚠ PHASE 0 EXCEPTION, AND IT IS THE WHOLE POINT OF THE CHANNEL. For the three workflow gates a
|
|
334
|
+
// question is answered by relaying a label, so "no answer means ask". Starting a run is the decision
|
|
335
|
+
// that creates the armed goal, so when this composition mounts the blocking human channel the
|
|
336
|
+
// question is PUT TO THE PERSON whether or not an answer argument arrived — a caller cannot skip the
|
|
337
|
+
// person by supplying one. Only a composition with no channel falls back to the relayed answer.
|
|
338
|
+
const channelMounted = recursive.userQuestionsChannel !== null
|
|
339
|
+
if (args.answer === undefined && !(isRunStartGate(gateId) && channelMounted)) {
|
|
340
|
+
return { gate: gateId, marker: isRunStartGate(gateId) ? RUN_START_GATE.marker : ASK_GATES[gateId as AskGateId].marker, artifact, question } as unknown as JsonValue
|
|
259
341
|
}
|
|
260
342
|
|
|
261
343
|
// RECORD.
|
|
262
|
-
let answer: string
|
|
263
|
-
|
|
264
|
-
answer =
|
|
265
|
-
}
|
|
266
|
-
|
|
344
|
+
let answer: string | undefined
|
|
345
|
+
if (args.answer === undefined) {
|
|
346
|
+
answer = undefined
|
|
347
|
+
} else {
|
|
348
|
+
try {
|
|
349
|
+
answer = validateAskAnswerFor(gateId, args.answer)
|
|
350
|
+
} catch (err) {
|
|
351
|
+
return { error: toolError('BAD_ASK_ANSWER', err instanceof Error ? err.message : String(err)) } as const
|
|
352
|
+
}
|
|
267
353
|
}
|
|
268
354
|
if (artifact === '') {
|
|
269
355
|
return { error: toolError('MISSING_ASK_ARTIFACT', 'this gate needs an explicit artifact to record into') } as const
|
|
270
356
|
}
|
|
271
|
-
|
|
357
|
+
if (isRunStartGate(gateId)) {
|
|
358
|
+
return recordRunStartAnswer(recursive, root, runId, answer, exec) as unknown as JsonValue
|
|
359
|
+
}
|
|
360
|
+
const marker = answerMarker(gateId as AskGateId, answer as string)
|
|
272
361
|
const written = recursive.recordAskAnswer(root, runId, artifact, marker)
|
|
273
362
|
return { gate: gateId, answer, marker, artifact, path: written.path, replaced: written.replaced } as unknown as JsonValue
|
|
274
363
|
},
|
|
275
364
|
})
|
|
276
365
|
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* PHASE 0 — record the answer to the run-start gate, and start the run only if it says so.
|
|
369
|
+
*
|
|
370
|
+
* ⚠ THIS GATE NEVER ACCEPTS A RELAYED ANSWER WHILE A HUMAN CHANNEL IS MOUNTED. That is the rule that makes
|
|
371
|
+
* an approval a human act rather than an inference: when `ctx.userQuestions` is present, the question is
|
|
372
|
+
* PUT TO THE PERSON and nothing else can settle it — not the caller's own `answer` argument, and not a
|
|
373
|
+
* fabrication, because `ask()` resolves only with a real selection. A person's decline is likewise final
|
|
374
|
+
* for that call and cannot be overridden by a model that asked for `Start run` in the same breath.
|
|
375
|
+
*
|
|
376
|
+
* ⚠ AND WHEN NO CHANNEL IS MOUNTED, THE RELAYED ANSWER IS THE ONLY POSSIBLE SOURCE, so it is used — that
|
|
377
|
+
* is the same contract the other three gates have always had, and refusing it would leave a composition
|
|
378
|
+
* without the channel unable to start any run at all. The question is surfaced first by the ASK branch
|
|
379
|
+
* (the card data the host renders), and the model's `answer` is that person's selection coming back.
|
|
380
|
+
*
|
|
381
|
+
* ⚠ WHAT THE GATE THEREFORE DOES *NOT* CLAIM, stated rather than implied: in a composition with no
|
|
382
|
+
* `userQuestions` channel, a plugin cannot verify that a person was really asked, so a model could in
|
|
383
|
+
* principle relay a label nobody gave. That is a property of the relay, not of this gate — and it is the
|
|
384
|
+
* reason the channel is consulted in preference whenever it exists. See the header of `run-start.ts`.
|
|
385
|
+
*/
|
|
386
|
+
export async function recordRunStartAnswer(
|
|
387
|
+
recursive: RecursiveRuntime,
|
|
388
|
+
root: string,
|
|
389
|
+
runId: string,
|
|
390
|
+
answer: string | undefined,
|
|
391
|
+
exec: { agent?: unknown; signal?: unknown; callId?: unknown },
|
|
392
|
+
): Promise<Record<string, unknown>> {
|
|
393
|
+
// 1. ASK THE PERSON DIRECTLY when this composition mounts the channel. An abort or a dismissal is not
|
|
394
|
+
// consent, so it settles nothing — and, because the channel was available, it does not hand the
|
|
395
|
+
// decision back to the caller either (that is the refusal below).
|
|
396
|
+
const channel = recursive.userQuestionsChannel
|
|
397
|
+
const fromChannel = channel ? await askRunStartDirectly(channel, exec) : null
|
|
398
|
+
if (channel !== null && fromChannel === null) {
|
|
399
|
+
// The channel exists, so a person COULD have been asked and was not: no answerer, no live root agent,
|
|
400
|
+
// a dismissal, an abort, or a selection that is not one of this gate's labels. An approval nobody
|
|
401
|
+
// gave is not recorded, and neither is the caller's argument.
|
|
402
|
+
return { error: toolError('RUN_START_UNANSWERED') }
|
|
403
|
+
}
|
|
404
|
+
const final = fromChannel ?? answer
|
|
405
|
+
if (final === undefined) {
|
|
406
|
+
// No channel and no answer: there is nothing a person said, so nothing is recorded.
|
|
407
|
+
return { error: toolError('RUN_START_NO_CHANNEL') }
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// 2. Validate the decision that is about to become durable. A channel selection has already been
|
|
411
|
+
// filtered to the gate's own labels; a relayed answer has not, and a marker recording an unoffered
|
|
412
|
+
// label would read as a decision while being a transcription error.
|
|
413
|
+
let decided: string
|
|
414
|
+
try {
|
|
415
|
+
decided = validateAskAnswerFor(RUN_START_GATE_ID, final)
|
|
416
|
+
} catch (err) {
|
|
417
|
+
return { error: toolError('BAD_ASK_ANSWER', err instanceof Error ? err.message : String(err)) }
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// 3. Record it, and start the run only for the approving label. A `Hold` is recorded as the decision it
|
|
421
|
+
// is — declaring the run not started belongs in the run's own record — and starts nothing.
|
|
422
|
+
const outcome = recursive.approveRunStart(root, runId, exec.agent as never, decided)
|
|
423
|
+
return {
|
|
424
|
+
gate: RUN_START_GATE_ID,
|
|
425
|
+
answer: decided,
|
|
426
|
+
artifact: RUN_START_ARTIFACT,
|
|
427
|
+
// Where the decision came from matters to a reader of the transcript: a direct answer is the person's
|
|
428
|
+
// own selection; a relayed one came back through the model.
|
|
429
|
+
source: fromChannel === null ? 'relayed' : 'user-questions',
|
|
430
|
+
path: outcome.path,
|
|
431
|
+
replaced: outcome.replaced,
|
|
432
|
+
// `armed` is the answer to "did the harness get a goal to drive?": the run is started only when the
|
|
433
|
+
// marker took AND the projection armed the goal, so both are reported rather than one implying the
|
|
434
|
+
// other.
|
|
435
|
+
armed: outcome.ok && outcome.goal.ok,
|
|
436
|
+
goal: outcome.goal,
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* Ask the run-start question through the blocking channel and return the selection, or null when the
|
|
442
|
+
* person was not reachable (no answerer, no live root agent, a dismissal, an abort).
|
|
443
|
+
*
|
|
444
|
+
* The selection is filtered to the gate's OWN labels before it is returned: a question a UI answered with
|
|
445
|
+
* a free-text custom value must not become an approval just because it arrived on the right channel.
|
|
446
|
+
*/
|
|
447
|
+
async function askRunStartDirectly(
|
|
448
|
+
channel: NonNullable<RecursiveRuntime['userQuestionsChannel']>,
|
|
449
|
+
exec: { agent?: unknown; signal?: unknown; callId?: unknown },
|
|
450
|
+
): Promise<string | null> {
|
|
451
|
+
const known = RUN_START_GATE.options.map((option) => option.label) as readonly string[]
|
|
452
|
+
try {
|
|
453
|
+
// The agent is passed as the LIVE handle the host gave this tool call. The real service validates it
|
|
454
|
+
// against its own registry and rejects when it is not the live root, so a fabricated handle can never
|
|
455
|
+
// produce an answer here.
|
|
456
|
+
const settled = await channel.ask({
|
|
457
|
+
questions: [{
|
|
458
|
+
id: RUN_START_GATE.id,
|
|
459
|
+
header: RUN_START_GATE.header,
|
|
460
|
+
question: RUN_START_GATE.question,
|
|
461
|
+
options: RUN_START_GATE.options.map((option) => ({ ...option })),
|
|
462
|
+
}],
|
|
463
|
+
agent: exec.agent,
|
|
464
|
+
signal: exec.signal,
|
|
465
|
+
// Links the card to this tool call, the way plan-mode's exit does.
|
|
466
|
+
wait: { callId: exec.callId },
|
|
467
|
+
} as never)
|
|
468
|
+
const item = settled.answers.find((entry) => entry.id === RUN_START_GATE.id)
|
|
469
|
+
const selected = item?.selected?.filter((label) => known.includes(label)) ?? []
|
|
470
|
+
if (selected.length !== 1) return null
|
|
471
|
+
return selected[0]
|
|
472
|
+
} catch {
|
|
473
|
+
// Quiet by design: the caller reports the situation, and this function's job is only to say whether a
|
|
474
|
+
// person answered.
|
|
475
|
+
return null
|
|
476
|
+
}
|
|
477
|
+
}
|
|
@@ -3,10 +3,25 @@ import { codeRuntimeRefusal, toolError } from './errors.ts'
|
|
|
3
3
|
import type { JsonValue } from '@deepseek-ai/dsh-util-values'
|
|
4
4
|
import type { RecursiveRuntime } from './runtime.ts'
|
|
5
5
|
|
|
6
|
+
/**
|
|
7
|
+
* PHASE 0 — SCAFFOLDING IS NOT STARTING, AND THE TOOL SAYS SO AT THE MOMENT IT MATTERS.
|
|
8
|
+
*
|
|
9
|
+
* A spec may legitimately exist before a run does: this tool writes the run directory and every phase
|
|
10
|
+
* document, and it still does. What it must NOT do is start the run, because starting is creating and
|
|
11
|
+
* arming the goal the harness drives autonomous rounds from. That is the owner's rule — *"phase 0
|
|
12
|
+
* requires explicit approval to start a run and goal"* — so the description below names the gate and the
|
|
13
|
+
* result carries `runStartApproval`, which is the pointer a caller needs: the run is inert until
|
|
14
|
+
* `recursive_ask` answers `run-start`.
|
|
15
|
+
*
|
|
16
|
+
* `runStartApproval` is read from the run's own Phase 0 artifact on every call, so it is the TRUE state
|
|
17
|
+
* rather than "this call created something": re-initialising an APPROVED run reports `approved: true`
|
|
18
|
+
* (and the run keeps its goal), which is what a caller re-scaffolding a run it already started needs to
|
|
19
|
+
* see.
|
|
20
|
+
*/
|
|
6
21
|
export function createRecursiveInitTool(recursive: RecursiveRuntime) {
|
|
7
22
|
return defineTool({
|
|
8
23
|
name: 'recursive_init',
|
|
9
|
-
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.',
|
|
24
|
+
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.',
|
|
10
25
|
parameters: {
|
|
11
26
|
runId: { type: 'string', description: 'Run id (e.g. 03-something). Required.' },
|
|
12
27
|
createWorktree: { type: 'boolean', description: 'If true, create a linked worktree at .worktrees/<runId>/ and scaffold the run inside it (default: false).' },
|
package/src/run-start.ts
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PHASE 0 — STARTING A RUN IS A HUMAN DECISION, NOT A SIDE EFFECT OF SCAFFOLDING.
|
|
3
|
+
*
|
|
4
|
+
* THE DEFECT THIS CLOSES. `recursive_init` scaffolded a run and the plugin then CREATED AND ARMED a
|
|
5
|
+
* goal for it in the same breath (`syncRunGoal`'s "no current goal -> create and arm" branch). A goal
|
|
6
|
+
* is not a label: `goals.create` returns an ARMED view, and the harness immediately begins driving
|
|
7
|
+
* autonomous goal rounds for the session. So asking for a run spec was enough to start an unattended
|
|
8
|
+
* run — the owner's rule is the opposite: *"creating a spec before a run exists should not create a
|
|
9
|
+
* goal. Phase 0 requires explicit approval to start a run and goal."*
|
|
10
|
+
*
|
|
11
|
+
* WHAT "APPROVAL" IS, EXACTLY. The approving label of the `run-start` gate of `recursive_ask`
|
|
12
|
+
* (`Start run`, as opposed to `Hold`), recorded here as a durable `- Run Start: Start run` line in the
|
|
13
|
+
* run's Phase 0 requirements artifact. Three things make that an explicit human act rather than an
|
|
14
|
+
* inference:
|
|
15
|
+
*
|
|
16
|
+
* 1. NO DEFAULT, AND THE VALUE IS THE DECISION. The line is written by the gate itself into the Phase 0
|
|
17
|
+
* requirements document, and the gate REFUSES an answer that is not one of the labels it offered.
|
|
18
|
+
* An unoffered answer is a transcription error wearing the shape of a decision, and a `Hold` is not
|
|
19
|
+
* an approval in any spelling — see {@link readRunStartApproval}, which matches the approving VALUE
|
|
20
|
+
* and nothing else, so the presence of a `Run Start` line is never on its own consent.
|
|
21
|
+
* 2. IT IS ASKED, NOT ASSUMED. When the composition mounts `ctx.userQuestions` — the harness's own
|
|
22
|
+
* blocking human channel, the same one plan-mode's exit uses — the question is PUT TO THE PERSON and
|
|
23
|
+
* only their selection is recorded; a caller-supplied answer cannot stand in for it, and a channel
|
|
24
|
+
* that cannot reach anyone ends the call without a decision (RM5503). Only a composition with no
|
|
25
|
+
* channel at all falls back to the relayed answer, which is the contract the other three gates have.
|
|
26
|
+
* 3. THE GOAL CANNOT BE CREATED WITHOUT IT. `syncRunGoal` refuses to create a goal for a run whose
|
|
27
|
+
* approval record is absent, in EVERY branch that would create one — not only the "no goal yet"
|
|
28
|
+
* branch. That is the property `tests/run-start-approval.spec.ts` asserts, because a single
|
|
29
|
+
* unguarded branch is exactly how this defect existed in the first place.
|
|
30
|
+
*
|
|
31
|
+
* A SPEC MAY EXIST BEFORE A RUN EXISTS, and this module does not forbid that: the scaffold, the Phase
|
|
32
|
+
* 0 artifacts and every later phase document are all created by `recursive_init` as before. What is
|
|
33
|
+
* withheld is the GOAL — the object that makes the harness drive rounds. A run that is scaffolded and
|
|
34
|
+
* never approved is a spec: readable, editable, lockable, and inert.
|
|
35
|
+
*/
|
|
36
|
+
import { readFileSync } from 'node:fs'
|
|
37
|
+
import { join } from 'node:path'
|
|
38
|
+
import { getMdFieldValue } from './status.ts'
|
|
39
|
+
|
|
40
|
+
/** The gate id `recursive_ask` answers for a run start. Deliberately NOT in ASK_GATE_IDS. */
|
|
41
|
+
export const RUN_START_GATE_ID = 'run-start'
|
|
42
|
+
|
|
43
|
+
/** The Phase 0 artifact the approval is recorded in. */
|
|
44
|
+
export const RUN_START_ARTIFACT = '00-requirements.md'
|
|
45
|
+
|
|
46
|
+
/** The artifact field the approval reads back from. */
|
|
47
|
+
export const RUN_START_MARKER = 'Run Start'
|
|
48
|
+
|
|
49
|
+
/** The approving label. The ONLY label that starts a run. */
|
|
50
|
+
export const RUN_START_APPROVE = 'Start run'
|
|
51
|
+
|
|
52
|
+
/** The withholding label: the spec stays a spec. */
|
|
53
|
+
export const RUN_START_HOLD = 'Hold'
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* WHY THIS GATE IS NOT IN `ASK_GATE_IDS`. Those three are the WORKFLOW's gates — phase-3 test
|
|
57
|
+
* evidence, phase-5 sign-off, resolving a gate block — and their membership is asserted as exactly
|
|
58
|
+
* three. Starting a run is a different kind of decision: it is the one that decides whether there is
|
|
59
|
+
* a run at all. It lives here, with its own contract, so widening the workflow's gate list cannot
|
|
60
|
+
* quietly widen what may start a run.
|
|
61
|
+
*/
|
|
62
|
+
export const RUN_START_GATE = {
|
|
63
|
+
id: RUN_START_GATE_ID,
|
|
64
|
+
header: 'Start run',
|
|
65
|
+
question: 'Approve phase 0 and start this run? Approving creates an armed goal the harness will keep driving.',
|
|
66
|
+
options: [
|
|
67
|
+
{ label: RUN_START_APPROVE, description: 'Record the approval and arm the run goal.' },
|
|
68
|
+
{ label: RUN_START_HOLD, description: 'Leave the spec inert: no run goal, no autonomous rounds.' },
|
|
69
|
+
],
|
|
70
|
+
marker: RUN_START_MARKER,
|
|
71
|
+
} as const
|
|
72
|
+
|
|
73
|
+
/** The durable line an approval writes. */
|
|
74
|
+
export function runStartApprovalLine(): string {
|
|
75
|
+
return '- ' + RUN_START_MARKER + ': ' + RUN_START_APPROVE
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Is a `run-start` answer the approving one? */
|
|
79
|
+
export function isRunStartApproval(answer: string): boolean {
|
|
80
|
+
return answer.trim() === RUN_START_APPROVE
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Where the Phase 0 requirements artifact lives for a run rooted at `root`. */
|
|
84
|
+
export function runStartArtifactPath(root: string, runId: string): string {
|
|
85
|
+
return join(root, '.recursive', 'run', runId, RUN_START_ARTIFACT)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The artifact text, or null when the file is absent (a read failure is not an approval). */
|
|
89
|
+
export function readRunStartArtifact(root: string, runId: string): string | null {
|
|
90
|
+
try {
|
|
91
|
+
return readFileSync(runStartArtifactPath(root, runId), 'utf8')
|
|
92
|
+
} catch {
|
|
93
|
+
return null
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The approval state of a run, read from its Phase 0 artifact.
|
|
99
|
+
*
|
|
100
|
+
* ⚠ MATCHED ON THE VALUE, NOT ON THE LINE'S PRESENCE. `getMdFieldValue` returns the field's VALUE, so
|
|
101
|
+
* a recorded `- Run Start: Hold` is refused here — a check for "is there a Run Start line?" would read
|
|
102
|
+
* a refusal as consent, which is the one mistake this whole module exists to prevent.
|
|
103
|
+
*/
|
|
104
|
+
export function readRunStartApproval(root: string, runId: string): { approved: boolean; artifact: string; reason: string } {
|
|
105
|
+
const content = readRunStartArtifact(root, runId)
|
|
106
|
+
if (content === null) {
|
|
107
|
+
return { approved: false, artifact: RUN_START_ARTIFACT, reason: 'the Phase 0 requirements artifact does not exist yet' }
|
|
108
|
+
}
|
|
109
|
+
const value = getMdFieldValue(content, RUN_START_MARKER)
|
|
110
|
+
if (value === null) {
|
|
111
|
+
return { approved: false, artifact: RUN_START_ARTIFACT, reason: 'no ' + RUN_START_MARKER + ' decision has been recorded' }
|
|
112
|
+
}
|
|
113
|
+
if (!isRunStartApproval(value)) {
|
|
114
|
+
return { approved: false, artifact: RUN_START_ARTIFACT, reason: RUN_START_MARKER + ' is ' + JSON.stringify(value) + ', which does not start a run' }
|
|
115
|
+
}
|
|
116
|
+
return { approved: true, artifact: RUN_START_ARTIFACT, reason: '' }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The ONE refusal reason the projection returns before approval, exported so every caller branches on
|
|
121
|
+
* the same string instead of re-typing it (a re-typed reason is a caller that silently stops matching).
|
|
122
|
+
*/
|
|
123
|
+
export const RUN_START_NOT_APPROVED = 'run not started: phase 0 approval has not been granted'
|