@try-works/dsh-recursive-mode 0.4.5 → 0.4.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -4
- package/lib/errors.d.ts +34 -1
- package/lib/index.js +350 -48
- package/lib/recursive_ask.tool.d.ts +93 -16
- package/lib/recursive_closeout.tool.d.ts +10 -0
- package/lib/recursive_init.tool.d.ts +8 -0
- package/lib/recursive_phase.tool.d.ts +8 -0
- package/lib/recursive_scratch.tool.d.ts +10 -0
- package/lib/recursive_worktree.tool.d.ts +16 -0
- package/lib/run-id.d.ts +62 -0
- package/package.json +1 -1
- package/src/errors.ts +35 -2
- package/src/recursive_ask.tool.ts +190 -41
- package/src/recursive_closeout.tool.ts +53 -35
- package/src/recursive_init.tool.ts +20 -4
- package/src/recursive_phase.tool.ts +22 -2
- package/src/recursive_scratch.tool.ts +17 -1
- package/src/recursive_worktree.tool.ts +27 -2
- package/src/run-id.ts +100 -0
- package/src/run-start.ts +9 -3
|
@@ -294,24 +294,31 @@ export function pendingGateFor(artifactFile: string, artifactText: string | null
|
|
|
294
294
|
export function createRecursiveAskTool(recursive: RecursiveRuntime) {
|
|
295
295
|
return defineTool({
|
|
296
296
|
name: 'recursive_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.',
|
|
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. 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.',
|
|
298
298
|
parameters: {
|
|
299
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.' },
|
|
300
300
|
runId: { type: 'string', description: 'Run id. Required; must resolve inside the current workspace.' },
|
|
301
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
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(' | ') + '.' },
|
|
303
|
+
relay: { type: 'boolean', 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.' },
|
|
303
304
|
},
|
|
304
305
|
output: {
|
|
305
306
|
schema: { type: 'json' },
|
|
306
307
|
render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 2) }],
|
|
307
308
|
},
|
|
308
|
-
async execute(args: { gate?: string; runId?: string; artifact?: string; answer?: string }, exec) {
|
|
309
|
+
async execute(args: { gate?: string; runId?: string; artifact?: string; answer?: string; relay?: boolean }, exec) {
|
|
309
310
|
const gateId = (args.gate ?? '').trim() as AskAnyGateId
|
|
310
311
|
const runId = args.runId?.trim() ?? ''
|
|
311
312
|
if (runId === '') return { error: toolError('MISSING_RUN_ID') } as const
|
|
312
313
|
if (!askGateIds().includes(gateId)) {
|
|
313
314
|
return { error: toolError('BAD_ASK_GATE', 'gate must be one of ' + askGateIds().join(' | ')) } as const
|
|
314
315
|
}
|
|
316
|
+
// ⚠ `relay` IS RUN-START ONLY, AND SAYING SO IS THE POINT. The other three gates never consult the
|
|
317
|
+
// channel, so accepting the flag there would report a fallback that did not happen — the same class
|
|
318
|
+
// of false claim this tool was fixed for.
|
|
319
|
+
if (args.relay === true && !isRunStartGate(gateId)) {
|
|
320
|
+
return { error: toolError('RELAY_ONLY_FOR_RUN_START', 'gate is ' + gateId) } as const
|
|
321
|
+
}
|
|
315
322
|
const root = await recursive.resolveWorkspaceRoot(exec.agent)
|
|
316
323
|
if (!root) return { error: toolError('NO_WORKSPACE') } as const
|
|
317
324
|
|
|
@@ -334,7 +341,9 @@ export function createRecursiveAskTool(recursive: RecursiveRuntime) {
|
|
|
334
341
|
// question is answered by relaying a label, so "no answer means ask". Starting a run is the decision
|
|
335
342
|
// that creates the armed goal, so when this composition mounts the blocking human channel the
|
|
336
343
|
// 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
|
|
344
|
+
// person by supplying one. Only a composition with no channel falls back to the relayed answer, and
|
|
345
|
+
// a channel that FAILED is a third case: it is reported with its cause, and the relayed answer is
|
|
346
|
+
// taken only when the caller asks for the relay in so many words (see `recordRunStartAnswer`).
|
|
338
347
|
const channelMounted = recursive.userQuestionsChannel !== null
|
|
339
348
|
if (args.answer === undefined && !(isRunStartGate(gateId) && channelMounted)) {
|
|
340
349
|
return { gate: gateId, marker: isRunStartGate(gateId) ? RUN_START_GATE.marker : ASK_GATES[gateId as AskGateId].marker, artifact, question } as unknown as JsonValue
|
|
@@ -355,7 +364,7 @@ export function createRecursiveAskTool(recursive: RecursiveRuntime) {
|
|
|
355
364
|
return { error: toolError('MISSING_ASK_ARTIFACT', 'this gate needs an explicit artifact to record into') } as const
|
|
356
365
|
}
|
|
357
366
|
if (isRunStartGate(gateId)) {
|
|
358
|
-
return recordRunStartAnswer(recursive, root, runId, answer, exec) as unknown as JsonValue
|
|
367
|
+
return recordRunStartAnswer(recursive, root, runId, answer, exec, args.relay === true) as unknown as JsonValue
|
|
359
368
|
}
|
|
360
369
|
const marker = answerMarker(gateId as AskGateId, answer as string)
|
|
361
370
|
const written = recursive.recordAskAnswer(root, runId, artifact, marker)
|
|
@@ -367,21 +376,35 @@ export function createRecursiveAskTool(recursive: RecursiveRuntime) {
|
|
|
367
376
|
/**
|
|
368
377
|
* PHASE 0 — record the answer to the run-start gate, and start the run only if it says so.
|
|
369
378
|
*
|
|
370
|
-
* ⚠
|
|
371
|
-
*
|
|
372
|
-
*
|
|
373
|
-
*
|
|
374
|
-
*
|
|
379
|
+
* ⚠ THREE OUTCOMES, AND TELLING THEM APART IS THE FIX. The first version of this function collapsed all of
|
|
380
|
+
* them into one `null`: "the channel threw", "the channel resolved with something unrecognisable", and
|
|
381
|
+
* "nobody answered" produced the same refusal, whose text asserted a cause ("so no person was asked") the
|
|
382
|
+
* plugin had already thrown away. A live session paid for that: the call failed after 22.9 s, the operator
|
|
383
|
+
* could not be told why, and the refusal's own advice prescribed the call that had just failed. So:
|
|
384
|
+
*
|
|
385
|
+
* 1. A PERSON WAS REACHED (`unusable`): the channel resolved, so somebody answered, and their answer is
|
|
386
|
+
* not a label this gate offered — a skip, a custom value, several labels at once. That is a DECISION
|
|
387
|
+
* the gate cannot record, and it is final: neither the caller's `answer` nor `relay=true` may replace
|
|
388
|
+
* it. (RM5504)
|
|
389
|
+
* 2. THE CHANNEL FAILED (`unavailable`): no decision came back at all, and the refusal NAMES THE CAUSE
|
|
390
|
+
* from the error the channel threw. Here the run can still be started, because a composition whose
|
|
391
|
+
* channel cannot deliver the question would otherwise be unable to start any run — but only by the
|
|
392
|
+
* caller asking for the relay in so many words (`relay=true`), which the result reports as
|
|
393
|
+
* `source: "relayed"` rather than as a person's own selection. (RM5503)
|
|
394
|
+
* 3. NO CHANNEL IS MOUNTED: the relayed answer is the only possible source, exactly as before. (RM5502
|
|
395
|
+
* when there is no answer either)
|
|
375
396
|
*
|
|
376
|
-
* ⚠ AND
|
|
377
|
-
*
|
|
378
|
-
*
|
|
379
|
-
*
|
|
397
|
+
* ⚠ AND A CANCELLED OR CLOSED QUESTION IS NEVER RELAYABLE. `ASK_CANCELLED`, `ASK_ABORTED` and
|
|
398
|
+
* `ASK_TIMED_OUT` are the codes that mean the question was settled from outside this gate — the card was
|
|
399
|
+
* dismissed, the turn was cancelled, or a foreground window ended. The relay is refused for those, so a
|
|
400
|
+
* question the operator stopped cannot be turned into an approval by asking again in the same breath.
|
|
401
|
+
* Every other failure is a composition or capability failure — the question reached nobody — which is the
|
|
402
|
+
* class the relay exists for.
|
|
380
403
|
*
|
|
381
|
-
* ⚠ WHAT THE GATE
|
|
382
|
-
*
|
|
383
|
-
*
|
|
384
|
-
*
|
|
404
|
+
* ⚠ WHAT THE GATE STILL DOES *NOT* CLAIM: a relayed approval is a relayed approval. The plugin cannot
|
|
405
|
+
* verify that a person gave the label, and it does not pretend otherwise — the result's `source` and
|
|
406
|
+
* `channel` fields say where the decision came from, and a direct selection is preferred whenever the
|
|
407
|
+
* channel can produce one.
|
|
385
408
|
*/
|
|
386
409
|
export async function recordRunStartAnswer(
|
|
387
410
|
recursive: RecursiveRuntime,
|
|
@@ -389,22 +412,72 @@ export async function recordRunStartAnswer(
|
|
|
389
412
|
runId: string,
|
|
390
413
|
answer: string | undefined,
|
|
391
414
|
exec: { agent?: unknown; signal?: unknown; callId?: unknown },
|
|
415
|
+
relay = false,
|
|
392
416
|
): Promise<Record<string, unknown>> {
|
|
393
|
-
//
|
|
394
|
-
//
|
|
395
|
-
|
|
417
|
+
// The question travels in every refusal: a composition whose channel cannot render a card can still put
|
|
418
|
+
// the exact decision to the person in the transcript, which is what makes the failure recoverable.
|
|
419
|
+
const question = buildAskQuestionFor(RUN_START_GATE_ID)
|
|
396
420
|
const channel = recursive.userQuestionsChannel
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
421
|
+
|
|
422
|
+
// 1. ASK THE PERSON DIRECTLY when this composition mounts the channel.
|
|
423
|
+
const channelOutcome: RunStartChannelOutcome | null = channel ? await askRunStartDirectly(channel, exec) : null
|
|
424
|
+
|
|
425
|
+
if (channelOutcome !== null && channelOutcome.kind === 'unusable') {
|
|
426
|
+
// A person WAS asked. Their answer is not an approval this gate can record, and nothing the caller
|
|
427
|
+
// supplies can stand in for it.
|
|
428
|
+
return {
|
|
429
|
+
error: toolError('RUN_START_ANSWER_UNUSABLE', channelOutcome.detail),
|
|
430
|
+
gate: RUN_START_GATE_ID,
|
|
431
|
+
runId,
|
|
432
|
+
artifact: RUN_START_ARTIFACT,
|
|
433
|
+
question,
|
|
434
|
+
}
|
|
403
435
|
}
|
|
436
|
+
|
|
437
|
+
if (channelOutcome !== null && channelOutcome.kind === 'unavailable') {
|
|
438
|
+
const blocked = !relay
|
|
439
|
+
? 'the caller did not ask for the relay'
|
|
440
|
+
: 'the channel reports the question was cancelled, aborted, or timed out, so it is not relayable'
|
|
441
|
+
if (!relay || !channelOutcome.relayable) {
|
|
442
|
+
return {
|
|
443
|
+
error: toolError('RUN_START_UNANSWERED', channelOutcome.detail + ' (' + blocked + ')'),
|
|
444
|
+
gate: RUN_START_GATE_ID,
|
|
445
|
+
runId,
|
|
446
|
+
artifact: RUN_START_ARTIFACT,
|
|
447
|
+
question,
|
|
448
|
+
// The diagnosis, as data: a model can quote the cause, and a test can assert on it rather than on
|
|
449
|
+
// the prose of the sentence above.
|
|
450
|
+
channel: { outcome: 'unavailable', cause: channelOutcome.cause, relayable: channelOutcome.relayable },
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
if (answer === undefined) {
|
|
454
|
+
// ⚠ THE RELAY NEEDS SOMETHING TO RELAY, AND THIS IS NOT RM5502. A channel IS mounted here, so the
|
|
455
|
+
// "this composition mounts no user-questions channel" sentence would be false — reachable by asking
|
|
456
|
+
// for the relay without supplying the answer it relays.
|
|
457
|
+
return {
|
|
458
|
+
error: toolError('RUN_START_UNANSWERED', channelOutcome.detail + ' (the relay was authorised but no answer was supplied, so there is no decision to record)'),
|
|
459
|
+
gate: RUN_START_GATE_ID,
|
|
460
|
+
runId,
|
|
461
|
+
artifact: RUN_START_ARTIFACT,
|
|
462
|
+
question,
|
|
463
|
+
channel: { outcome: 'unavailable', cause: channelOutcome.cause, relayable: channelOutcome.relayable },
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
const fromChannel = channelOutcome !== null && channelOutcome.kind === 'answered' ? channelOutcome.answer : null
|
|
404
469
|
const final = fromChannel ?? answer
|
|
405
470
|
if (final === undefined) {
|
|
406
|
-
// No channel and no answer
|
|
407
|
-
|
|
471
|
+
// No channel is mounted and no answer was supplied — the only state left here, because an `answered`
|
|
472
|
+
// outcome sets `final`, an `unusable` one returned above, and an `unavailable` one either returned
|
|
473
|
+
// above or carried an answer through the relay. RM5502 says exactly this, and nothing is recorded.
|
|
474
|
+
return {
|
|
475
|
+
error: toolError('RUN_START_NO_CHANNEL'),
|
|
476
|
+
gate: RUN_START_GATE_ID,
|
|
477
|
+
runId,
|
|
478
|
+
artifact: RUN_START_ARTIFACT,
|
|
479
|
+
question,
|
|
480
|
+
}
|
|
408
481
|
}
|
|
409
482
|
|
|
410
483
|
// 2. Validate the decision that is about to become durable. A channel selection has already been
|
|
@@ -425,8 +498,12 @@ export async function recordRunStartAnswer(
|
|
|
425
498
|
answer: decided,
|
|
426
499
|
artifact: RUN_START_ARTIFACT,
|
|
427
500
|
// 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.
|
|
501
|
+
// own selection; a relayed one came back through the model. When the relay answered a FAILED channel,
|
|
502
|
+
// the failure travels with the result, so a relayed approval never reads as a direct selection.
|
|
429
503
|
source: fromChannel === null ? 'relayed' : 'user-questions',
|
|
504
|
+
...channelOutcome !== null && channelOutcome.kind === 'unavailable'
|
|
505
|
+
? { channel: { outcome: 'unavailable', cause: channelOutcome.cause, relayable: channelOutcome.relayable, relayed: true } }
|
|
506
|
+
: {},
|
|
430
507
|
path: outcome.path,
|
|
431
508
|
replaced: outcome.replaced,
|
|
432
509
|
// `armed` is the answer to "did the harness get a goal to drive?": the run is started only when the
|
|
@@ -438,16 +515,90 @@ export async function recordRunStartAnswer(
|
|
|
438
515
|
}
|
|
439
516
|
|
|
440
517
|
/**
|
|
441
|
-
*
|
|
442
|
-
*
|
|
518
|
+
* PHASE 0 — WHAT THE BLOCKING CHANNEL ACTUALLY DID.
|
|
519
|
+
*
|
|
520
|
+
* ⚠ THIS TYPE EXISTS BECAUSE ITS ABSENCE WAS THE DEFECT. The first version returned a bare `null` from a
|
|
521
|
+
* `catch {}` for every failure and for every unrecognisable selection, so "the channel threw NO_PROVIDER",
|
|
522
|
+
* "the caller is not the live root agent", "the person skipped the question" and "the person typed a
|
|
523
|
+
* custom value" were ONE value. The refusal built from it then asserted the one cause it could not know.
|
|
524
|
+
* An outcome carries the cause, the raw message, and whether the failure is the class a relay may answer.
|
|
525
|
+
*/
|
|
526
|
+
export type RunStartChannelOutcome =
|
|
527
|
+
/** The person answered, and their selection is exactly one of the gate's own labels. */
|
|
528
|
+
| { kind: 'answered'; answer: string }
|
|
529
|
+
/** The channel RESOLVED, so a person was reached — but the answer is not a label this gate offered. */
|
|
530
|
+
| { kind: 'unusable'; detail: string }
|
|
531
|
+
/** The channel THREW: no decision came back, and the cause is named rather than discarded. */
|
|
532
|
+
| { kind: 'unavailable'; cause: string; detail: string; relayable: boolean }
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* ⚠ THE CODES THAT MEAN THE QUESTION WAS CANCELLED OR CLOSED rather than never delivered: the person
|
|
536
|
+
* dismissed the card, their turn was cancelled, or a foreground window ended. A caller may not convert any
|
|
537
|
+
* of those into an approval by asking for the relay in the same breath. Every other failure means the
|
|
538
|
+
* question reached nobody — a composition or capability failure, which is the class the relay exists for.
|
|
539
|
+
*/
|
|
540
|
+
export const NON_RELAYABLE_CHANNEL_CODES = ['ASK_CANCELLED', 'ASK_ABORTED', 'ASK_TIMED_OUT'] as const
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* Name the failure of one `ask()` call, without inventing anything about it.
|
|
544
|
+
*
|
|
545
|
+
* The cause is the error's own `code` when it has one (the harness's `UserQuestionError` carries
|
|
546
|
+
* `NO_PROVIDER`, `CALLER_NOT_LIVE`, `DELEGATED_CALLER`, `ASK_ABORTED`, …), else its `name`, else its
|
|
547
|
+
* JavaScript type. `detail` keeps the message verbatim so a reader sees the channel's own words rather
|
|
548
|
+
* than this plugin's paraphrase — the paraphrase is exactly how the previous version came to assert a
|
|
549
|
+
* cause nobody had.
|
|
550
|
+
*/
|
|
551
|
+
export function classifyChannelFailure(err: unknown): { cause: string; detail: string; relayable: boolean } {
|
|
552
|
+
const code = (err as { code?: unknown } | null | undefined)?.code
|
|
553
|
+
const name = err instanceof Error ? err.name : typeof err
|
|
554
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
555
|
+
const hasCode = typeof code === 'string' && code.trim() !== ''
|
|
556
|
+
const cause = hasCode ? (code as string) : name
|
|
557
|
+
const relayable = !(hasCode && (NON_RELAYABLE_CHANNEL_CODES as readonly string[]).includes(code as string))
|
|
558
|
+
return { cause, detail: 'channel threw ' + name + '[' + cause + ']: ' + message, relayable }
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/** Describe an answer that arrived but is not a decision this gate can record. */
|
|
562
|
+
function describeUnusableAnswer(item: { selected: string[]; custom?: string } | undefined): string {
|
|
563
|
+
const offered: readonly string[] = RUN_START_GATE.options.map((option) => option.label)
|
|
564
|
+
const list = (values: readonly string[]): string => JSON.stringify(values.join(' | '))
|
|
565
|
+
if (item === undefined) {
|
|
566
|
+
return 'the channel resolved with no answer for question ' + JSON.stringify(RUN_START_GATE.id) + ' at all'
|
|
567
|
+
}
|
|
568
|
+
const raw = item.selected ?? []
|
|
569
|
+
const custom = item.custom?.trim() ?? ''
|
|
570
|
+
if (raw.length === 0 && custom === '') {
|
|
571
|
+
return 'the person skipped the question, and a skip is not an approval'
|
|
572
|
+
}
|
|
573
|
+
if (raw.length === 0) {
|
|
574
|
+
return 'the person answered ' + JSON.stringify(custom) + ' as free text rather than one of ' + list(offered)
|
|
575
|
+
}
|
|
576
|
+
// ⚠ THE SUBSET MATTERS. A UI can return a label the gate never offered, so "not exactly one of mine" is
|
|
577
|
+
// not the same statement as "the person chose something I do not know" — and the refusal says which.
|
|
578
|
+
const recognised = raw.filter((label) => offered.includes(label))
|
|
579
|
+
if (recognised.length === 0) {
|
|
580
|
+
return 'the person selected ' + list(raw) + ', and none of those name a label this gate offered (' + offered.join(' | ') + ')'
|
|
581
|
+
}
|
|
582
|
+
if (recognised.length === raw.length) {
|
|
583
|
+
return 'the person selected ' + list(raw) + ', and an approval is exactly one of ' + list(offered)
|
|
584
|
+
}
|
|
585
|
+
return 'the person selected ' + list(raw) + ', of which only ' + list(recognised) + ' name this gate\'s labels ' + list(offered)
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/**
|
|
589
|
+
* Ask the run-start question through the blocking channel and report WHAT HAPPENED.
|
|
443
590
|
*
|
|
444
|
-
*
|
|
445
|
-
*
|
|
591
|
+
* ⚠ THE CATCH IS THE POINT. It used to be `catch { return null }` — a blocking human question whose
|
|
592
|
+
* failure cause was erased at the exact moment the cause was the only thing worth knowing. Every path out
|
|
593
|
+
* of this function now says which path it was.
|
|
594
|
+
*
|
|
595
|
+
* The selection is filtered to the gate's OWN labels: a question a UI answered with a free-text custom
|
|
596
|
+
* value must not become an approval just because it arrived on the right channel.
|
|
446
597
|
*/
|
|
447
|
-
async function askRunStartDirectly(
|
|
598
|
+
export async function askRunStartDirectly(
|
|
448
599
|
channel: NonNullable<RecursiveRuntime['userQuestionsChannel']>,
|
|
449
600
|
exec: { agent?: unknown; signal?: unknown; callId?: unknown },
|
|
450
|
-
): Promise<
|
|
601
|
+
): Promise<RunStartChannelOutcome> {
|
|
451
602
|
const known = RUN_START_GATE.options.map((option) => option.label) as readonly string[]
|
|
452
603
|
try {
|
|
453
604
|
// The agent is passed as the LIVE handle the host gave this tool call. The real service validates it
|
|
@@ -467,11 +618,9 @@ async function askRunStartDirectly(
|
|
|
467
618
|
} as never)
|
|
468
619
|
const item = settled.answers.find((entry) => entry.id === RUN_START_GATE.id)
|
|
469
620
|
const selected = item?.selected?.filter((label) => known.includes(label)) ?? []
|
|
470
|
-
if (selected.length !== 1) return
|
|
471
|
-
return selected[0]
|
|
472
|
-
} catch {
|
|
473
|
-
|
|
474
|
-
// person answered.
|
|
475
|
-
return null
|
|
621
|
+
if (selected.length !== 1) return { kind: 'unusable', detail: describeUnusableAnswer(item) }
|
|
622
|
+
return { kind: 'answered', answer: selected[0] as string }
|
|
623
|
+
} catch (err) {
|
|
624
|
+
return { kind: 'unavailable', ...classifyChannelFailure(err) }
|
|
476
625
|
}
|
|
477
626
|
}
|
|
@@ -1,36 +1,54 @@
|
|
|
1
|
-
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
2
|
-
import { toolError } from './errors.ts'
|
|
3
|
-
import
|
|
4
|
-
import type {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* workspace
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
},
|
|
35
|
-
|
|
1
|
+
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
2
|
+
import { toolError } from './errors.ts'
|
|
3
|
+
import { RUN_ID_EXAMPLES, RUN_ID_RULE, runIdProblem } from './run-id.ts'
|
|
4
|
+
import type { JsonValue } from '@deepseek-ai/dsh-util-values'
|
|
5
|
+
import type { RecursiveRuntime } from './runtime.ts'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* `recursive_closeout` — scaffold a Phase 0-8 closeout receipt under the
|
|
9
|
+
* SESSION's workspace only (R1 workspace-scoping invariant). The run is resolved
|
|
10
|
+
* via the session agent's cwd -> workspace registry; a runId outside the current
|
|
11
|
+
* workspace is rejected.
|
|
12
|
+
*
|
|
13
|
+
* A RUN ID IS A NAME, NOT A PATH HERE TOO, and "outside the current workspace is rejected" is NOT enough
|
|
14
|
+
* on its own: `closeoutRun` joins the id onto the run layer and then writes a receipt under it, and its
|
|
15
|
+
* scoping check is `runDir.startsWith(runRoot)` — a string prefix test, not containment. A `..\` segment
|
|
16
|
+
* that lands on a SIBLING of the run layer passes that check whenever the sibling's name begins with
|
|
17
|
+
* `run`, so a path-shaped id can still receive a write. MEASURED pre-fix: `..\run-away` and `../run-away`
|
|
18
|
+
* were accepted and reached the report; the other shapes below were stopped only by the sibling not
|
|
19
|
+
* existing, which is the operator's filesystem deciding, not the tool. The rule is `run-id.ts`; this
|
|
20
|
+
* boundary is where the name enters, so this is where it is refused, with `recursive_init`'s refusal shape
|
|
21
|
+
* — `BAD_RUN_ID` (RM1107), same detail sentence.
|
|
22
|
+
*/
|
|
23
|
+
export function createRecursiveCloseoutTool(recursive: RecursiveRuntime) {
|
|
24
|
+
return defineTool({
|
|
25
|
+
name: 'recursive_closeout',
|
|
26
|
+
description: 'REPORT what a closeout phase artifact is missing (Phase 4-8): it reads the artifact, lists the required sections and gates that are absent, and records a closeout receipt of its own. It NEVER writes the phase document. For a run in the CURRENT session workspace only. Workspace-scoped: refuses runIds outside the session\'s workspace. Delegates to the RecursiveRuntime service.',
|
|
27
|
+
parameters: {
|
|
28
|
+
phase: { type: 'string', description: 'Closeout phase to report on: 04, 05, 06, 07 or 08 (the same phase keys recursive_status prints). Phase 08 additionally fires the training trigger when it has been closed out before.' },
|
|
29
|
+
runId: { type: 'string', 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.' },
|
|
30
|
+
},
|
|
31
|
+
output: {
|
|
32
|
+
schema: { type: 'json' },
|
|
33
|
+
render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 2) }],
|
|
34
|
+
},
|
|
35
|
+
async execute(args: { phase?: string; runId?: string }, exec) {
|
|
36
|
+
if (!args.phase || !args.runId || args.runId.trim() === '') {
|
|
37
|
+
return { error: toolError('MISSING_PHASE_AND_RUN') } as const
|
|
38
|
+
}
|
|
39
|
+
const runId = args.runId.trim()
|
|
40
|
+
// BEFORE `closeoutRun`, which joins the id onto the run layer and writes the closeout receipt under
|
|
41
|
+
// whatever directory it resolved to.
|
|
42
|
+
const problem = runIdProblem(runId)
|
|
43
|
+
if (problem !== null) {
|
|
44
|
+
return { error: toolError('BAD_RUN_ID', problem + ' - run ids allow ' + RUN_ID_RULE + ' - e.g. ' + RUN_ID_EXAMPLES) } as const
|
|
45
|
+
}
|
|
46
|
+
const root = await recursive.resolveWorkspaceRoot(exec.agent)
|
|
47
|
+
if (!root) {
|
|
48
|
+
return { error: toolError('NO_WORKSPACE') } as const
|
|
49
|
+
}
|
|
50
|
+
const result = await recursive.closeoutRun(root, runId, args.phase.trim(), exec.agent as { session?: { header?: { cwd?: string } } } | null)
|
|
51
|
+
return result as unknown as JsonValue
|
|
52
|
+
},
|
|
53
|
+
})
|
|
36
54
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
2
2
|
import { codeRuntimeRefusal, toolError } from './errors.ts'
|
|
3
|
+
import { RUN_ID_EXAMPLES, RUN_ID_RULE, runIdProblem } from './run-id.ts'
|
|
3
4
|
import type { JsonValue } from '@deepseek-ai/dsh-util-values'
|
|
4
5
|
import type { RecursiveRuntime } from './runtime.ts'
|
|
5
6
|
|
|
@@ -17,13 +18,21 @@ import type { RecursiveRuntime } from './runtime.ts'
|
|
|
17
18
|
* rather than "this call created something": re-initialising an APPROVED run reports `approved: true`
|
|
18
19
|
* (and the run keeps its goal), which is what a caller re-scaffolding a run it already started needs to
|
|
19
20
|
* see.
|
|
21
|
+
*
|
|
22
|
+
* AND A RUN ID IS A NAME, NOT A PATH. This is the boundary where the name enters, so it is the boundary
|
|
23
|
+
* that refuses a path-shaped one — loudly, and before `initRun` can mkdir anything. The check lives here
|
|
24
|
+
* (through the shared `run-id.ts` rule) rather than in `runtime.ts` because `initRun` is not the only
|
|
25
|
+
* caller and because the runtime's `join(root, '.recursive', 'run', runId)` is CORRECT for a name; what
|
|
26
|
+
* was missing was a gate on the name. Teaching the runtime to accept a path would silently relocate the
|
|
27
|
+
* run layer instead of rejecting the call. See `run-id.ts` for the rule, the evidence behind it, and the
|
|
28
|
+
* "do not fix this back" note.
|
|
20
29
|
*/
|
|
21
30
|
export function createRecursiveInitTool(recursive: RecursiveRuntime) {
|
|
22
31
|
return defineTool({
|
|
23
32
|
name: 'recursive_init',
|
|
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.',
|
|
33
|
+
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.',
|
|
25
34
|
parameters: {
|
|
26
|
-
runId: { type: 'string', description: 'Run id (e.g. 03-something). Required.' },
|
|
35
|
+
runId: { type: 'string', 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.' },
|
|
27
36
|
createWorktree: { type: 'boolean', description: 'If true, create a linked worktree at .worktrees/<runId>/ and scaffold the run inside it (default: false).' },
|
|
28
37
|
baseBranch: { type: 'string', description: 'Base branch the worktree branch is cut from (default: current HEAD branch). Only used when createWorktree is true.' },
|
|
29
38
|
},
|
|
@@ -32,12 +41,19 @@ export function createRecursiveInitTool(recursive: RecursiveRuntime) {
|
|
|
32
41
|
render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 2) }],
|
|
33
42
|
},
|
|
34
43
|
async execute(args: { runId?: string; createWorktree?: boolean; baseBranch?: string }, exec) {
|
|
35
|
-
|
|
44
|
+
const runId = args.runId?.trim() ?? ''
|
|
45
|
+
if (runId === '') {
|
|
36
46
|
return { error: toolError('MISSING_RUN_ID') } as const
|
|
37
47
|
}
|
|
48
|
+
// BEFORE `initRun`: that call starts with `mkdirSync(runDir, { recursive: true })`, so a
|
|
49
|
+
// path-shaped id that reaches it has already made the operator-visible mess it was refused for.
|
|
50
|
+
const problem = runIdProblem(runId)
|
|
51
|
+
if (problem !== null) {
|
|
52
|
+
return { error: toolError('BAD_RUN_ID', problem + ' - run ids allow ' + RUN_ID_RULE + ' - e.g. ' + RUN_ID_EXAMPLES) } as const
|
|
53
|
+
}
|
|
38
54
|
try {
|
|
39
55
|
const result = await recursive.initRun(
|
|
40
|
-
|
|
56
|
+
runId,
|
|
41
57
|
exec.agent as { session?: { header?: { cwd?: string } } } | null,
|
|
42
58
|
{ createWorktree: args.createWorktree === true, baseBranch: args.baseBranch?.trim() || undefined },
|
|
43
59
|
)
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
2
2
|
import { toolError } from './errors.ts'
|
|
3
|
+
import { RUN_ID_EXAMPLES, RUN_ID_RULE, runIdProblem } from './run-id.ts'
|
|
3
4
|
import type { JsonValue } from '@deepseek-ai/dsh-util-values'
|
|
4
5
|
import type { RecursiveRuntime } from './runtime.ts'
|
|
5
6
|
|
|
@@ -9,20 +10,39 @@ import type { RecursiveRuntime } from './runtime.ts'
|
|
|
9
10
|
* (runtime.phaseRules -> phaseRulesFor) as the once-per-phase pre-step
|
|
10
11
|
* reminder, so the agent can re-ask for the rules without re-injecting them on
|
|
11
12
|
* every step. Returns { error } when no active phase is found.
|
|
13
|
+
*
|
|
14
|
+
* A RUN ID IS A NAME, NOT A PATH HERE TOO, INCLUDING WHEN IT IS OMITTED. Omitted and path-shaped are
|
|
15
|
+
* different cases and must stay different: omitted means "the latest run by mtime", which `resolveRunDir`
|
|
16
|
+
* answers by DISCOVERY rather than by joining anything, and that case is untouched below. A path-shaped id,
|
|
17
|
+
* by contrast, is joined by `phaseRules` -> `resolveRunDir` and then read, and `recordInjection` WRITES
|
|
18
|
+
* `memory-injections.json` under whatever directory it resolved to — with no scoping check at all on this
|
|
19
|
+
* path. So the gate fires only on an id that was actually supplied, and `run-id.ts` owns the rule. The
|
|
20
|
+
* refusal is `recursive_init`'s, `BAD_RUN_ID` (RM1107), composed identically.
|
|
12
21
|
*/
|
|
13
22
|
export function createRecursivePhaseTool(recursive: RecursiveRuntime) {
|
|
14
23
|
return defineTool({
|
|
15
24
|
name: 'recursive_phase',
|
|
16
25
|
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.',
|
|
17
26
|
parameters: {
|
|
18
|
-
runId: { type: 'string', description: 'Optional run id (
|
|
27
|
+
runId: { type: 'string', 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.' },
|
|
19
28
|
},
|
|
20
29
|
output: {
|
|
21
30
|
schema: { type: 'json' },
|
|
22
31
|
render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 2) }],
|
|
23
32
|
},
|
|
24
33
|
async execute(args: { runId?: string }, exec) {
|
|
25
|
-
|
|
34
|
+
// ABSENT IS NOT INVALID. No runId at all keeps its documented meaning — the latest run by mtime,
|
|
35
|
+
// resolved by discovery in `resolveRunDir` — so the gate below runs only when a name was supplied,
|
|
36
|
+
// while an EMPTY one is refused rather than silently read as "latest": a caller that passed `""` did
|
|
37
|
+
// not ask for discovery, and `resolveRunDir` would otherwise interpret their mistyped id as one.
|
|
38
|
+
const runId = args.runId?.trim()
|
|
39
|
+
if (runId !== undefined) {
|
|
40
|
+
const problem = runId === '' ? 'runId is empty' : runIdProblem(runId)
|
|
41
|
+
if (problem !== null) {
|
|
42
|
+
return { error: toolError('BAD_RUN_ID', problem + ' - run ids allow ' + RUN_ID_RULE + ' - e.g. ' + RUN_ID_EXAMPLES) } as const
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
const result = await recursive.phaseRules(runId, exec.agent as { session?: { header?: { cwd?: string } } } | null)
|
|
26
46
|
if (!result) return { error: toolError('NO_PHASE') } as const
|
|
27
47
|
return result as unknown as JsonValue
|
|
28
48
|
},
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
2
2
|
import { toolError } from './errors.ts'
|
|
3
|
+
import { RUN_ID_EXAMPLES, RUN_ID_RULE, runIdProblem } from './run-id.ts'
|
|
3
4
|
import type { JsonValue } from '@deepseek-ai/dsh-util-values'
|
|
4
5
|
import type { RecursiveRuntime } from './runtime.ts'
|
|
5
6
|
|
|
@@ -7,6 +8,16 @@ import type { RecursiveRuntime } from './runtime.ts'
|
|
|
7
8
|
* `recursive_scratch` — read/write/append the run-scoped disposable scratchpad
|
|
8
9
|
* (R5) under the CURRENT session workspace only (R1). Scratch is git-ignored
|
|
9
10
|
* and never citable as an Input.
|
|
11
|
+
*
|
|
12
|
+
* A RUN ID IS A NAME, NOT A PATH HERE TOO. `scratchRun` joins it onto the run layer, and it then WRITES
|
|
13
|
+
* (write/append) into the directory it landed on, so a path-shaped id does not merely fail to find a run:
|
|
14
|
+
* with a `..\` segment it can find and write into a SIBLING of the run layer, because the runtime's
|
|
15
|
+
* workspace-scoping check is `runDir.startsWith(runRoot)` — a string prefix test, not containment — and a
|
|
16
|
+
* sibling directory whose name begins with `run` passes it. MEASURED pre-fix: `..\run-away` was accepted
|
|
17
|
+
* and `scratchRun` wrote `scratch.md` into `<workspace>\.recursive\run-away\scratch\`. The rule is
|
|
18
|
+
* `run-id.ts`; the gate sits here, at the boundary where the name enters, rather than in `scratchRun`, for
|
|
19
|
+
* the same reason `recursive_init`'s does. Refusal shape is `recursive_init`'s, `BAD_RUN_ID` (RM1107),
|
|
20
|
+
* composed identically: one rule, one message.
|
|
10
21
|
*/
|
|
11
22
|
export function createRecursiveScratchTool(recursive: RecursiveRuntime) {
|
|
12
23
|
return defineTool({
|
|
@@ -14,7 +25,7 @@ export function createRecursiveScratchTool(recursive: RecursiveRuntime) {
|
|
|
14
25
|
description: 'Read, write, or append the run-scoped disposable scratchpad (scratch/scratch.md or scratch/scratch.ts) for a run in the CURRENT session workspace. Workspace-scoped; scratch is git-ignored and never citable as an Input.',
|
|
15
26
|
parameters: {
|
|
16
27
|
action: { type: 'string', description: 'read | write | append. Required.' },
|
|
17
|
-
runId: { type: 'string', description: 'Run id (e.g. 03-something). Required; must resolve inside the current workspace.' },
|
|
28
|
+
runId: { type: 'string', 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.' },
|
|
18
29
|
target: { type: 'string', description: 'md | ts. Required.' },
|
|
19
30
|
content: { type: 'string', description: 'Content for write/append. Optional for read.' },
|
|
20
31
|
},
|
|
@@ -29,6 +40,11 @@ export function createRecursiveScratchTool(recursive: RecursiveRuntime) {
|
|
|
29
40
|
if (!action || !runId || !target) {
|
|
30
41
|
return { error: toolError('MISSING_SCRATCH_ARGS') } as const
|
|
31
42
|
}
|
|
43
|
+
// BEFORE `scratchRun`, which joins the id onto the run layer and then writes through it.
|
|
44
|
+
const problem = runIdProblem(runId)
|
|
45
|
+
if (problem !== null) {
|
|
46
|
+
return { error: toolError('BAD_RUN_ID', problem + ' - run ids allow ' + RUN_ID_RULE + ' - e.g. ' + RUN_ID_EXAMPLES) } as const
|
|
47
|
+
}
|
|
32
48
|
if (target !== 'md' && target !== 'ts') {
|
|
33
49
|
return { error: toolError('BAD_TARGET') } as const
|
|
34
50
|
}
|