@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/src/runtime.ts CHANGED
@@ -46,7 +46,16 @@ import { renderRecursivePolicy, type PolicyContext } from './policy.ts'
46
46
  import { snapshotWorkspace } from './snapshot.ts'
47
47
  import { createLinkedWorktree, promoteBranch, listWorktrees, defaultWorktreeBranch, type CreateWorktreeResult, type PromoteBranchResult } from './worktree.ts'
48
48
  import { changedPaths, gitFacts } from './git-context.ts'
49
- import { syncRunGoal, blockRunGoal, resumeRunGoal, type GoalServiceLike } from './goals-projection.ts'
49
+ import { syncRunGoal, blockRunGoal, resumeRunGoal, type GoalServiceLike, type SyncResult } from './goals-projection.ts'
50
+ import {
51
+ RUN_START_APPROVE,
52
+ RUN_START_ARTIFACT,
53
+ RUN_START_GATE_ID,
54
+ RUN_START_MARKER,
55
+ RUN_START_NOT_APPROVED,
56
+ readRunStartApproval,
57
+ runStartArtifactPath,
58
+ } from './run-start.ts'
50
59
  import { auditToPass, renderTaskHistory, type TeamRuntimeLike, type AuditToPassResult, type TeamCallerHandle, type TeamTaskViewLike, type AuditRoundOutcome } from './teams-loop.ts'
51
60
  import type { ContinuableChildId, ContinuableMessageId } from './delegation.ts'
52
61
  import { runChildIds } from './settlement.ts'
@@ -75,6 +84,26 @@ export interface LintArtifactResult {
75
84
  passed: boolean
76
85
  }
77
86
 
87
+ /**
88
+ * PHASE 0 — the structural seam for the host's human-question channel (`ctx.userQuestions`).
89
+ *
90
+ * Declared here as a minimal seam for the same reason as every other harness touchpoint in this plugin:
91
+ * the live `UserQuestionService` satisfies it structurally, so the plugin never imports the host package,
92
+ * and a test can drive the run-start gate with a fake that behaves like the real one. The error case is
93
+ * part of the contract, not an afterthought: the real `ask()` REJECTS (NO_PROVIDER / CALLER_NOT_LIVE /
94
+ * ASK_ABORTED) instead of resolving with something that could be mistaken for an answer, which is what
95
+ * lets `recursive_ask` fail closed rather than invent an approval.
96
+ */
97
+ export interface UserQuestionsLike {
98
+ ask(request: {
99
+ questions: Array<{ id: string; header?: string; question: string; options?: Array<{ label: string; description?: string }> }>
100
+ agent?: unknown
101
+ signal?: AbortSignal
102
+ /** Links the card to the tool call that asked, the way plan-mode's exit does. */
103
+ wait?: { callId?: unknown }
104
+ }): Promise<{ answers: Array<{ id: string; selected: string[]; custom?: string }> }>
105
+ }
106
+
78
107
  /**
79
108
  * T15 (G): the folded status PLUS the rolling guard-decision evidence. Declared
80
109
  * as an intersection rather than by editing RecursiveStatusResult/foldRun — the
@@ -153,9 +182,24 @@ export class RecursiveRuntime extends Service {
153
182
  this.workflow = config.workflow ?? null
154
183
  }
155
184
 
156
- /** T10: the native jobs registry, when the composition mounts one. */
185
+ /**
186
+ * T10: the native jobs registry, when the composition mounts one. */
157
187
  private readonly jobs: JobsRegistryLike | null
158
188
 
189
+ /**
190
+ * PHASE 0 — attach the goals service after construction.
191
+ *
192
+ * The composition resolves `goals` with ONE `ctx.get` at apply time and passes it to the constructor,
193
+ * which is fine for a service that is already mounted. This seam exists for the two cases that pattern
194
+ * cannot cover: a composition that mounts `goals` later (the same late-attach reason `attachSubagents`
195
+ * and `attachLlmInventory` exist), and a test that needs the REAL runtime wired to a structural fake —
196
+ * a fake passed through the plugin's Config is dropped, because the Config schema is the settings
197
+ * namespace and strips keys it does not declare.
198
+ */
199
+ attachGoals(service: GoalServiceLike | null): void {
200
+ this.goalsService = service
201
+ }
202
+
159
203
  /**
160
204
  * T23 — write a gate's answer into an artifact as a marker line.
161
205
  *
@@ -303,7 +347,7 @@ export class RecursiveRuntime extends Service {
303
347
 
304
348
  private readonly repoRoot: string
305
349
  private readonly workspaceRegistry: WorkspaceRegistryLike | null
306
- private readonly goalsService: GoalServiceLike | null
350
+ private goalsService: GoalServiceLike | null
307
351
  /**
308
352
  * T27 — the hook registry, EXPOSED so a sibling plugin can participate in a run
309
353
  * without patching this one:
@@ -380,26 +424,89 @@ export class RecursiveRuntime extends Service {
380
424
  return report
381
425
  }
382
426
 
427
+ /**
428
+ * PHASE 0 — read a run's start approval from its own Phase 0 artifact.
429
+ *
430
+ * The approval is a DURABLE line in `.recursive/run/<runId>/00-requirements.md`, not a value held in
431
+ * memory, for the reason every other gate here is durable: a decision that only exists in a session
432
+ * cannot be cited, and cannot survive the session it was made in. Read-only; asking changes nothing.
433
+ */
434
+ readRunStartApproval(root: string, runId: string): { approved: boolean; artifact: string; reason: string } {
435
+ return readRunStartApproval(root, runId)
436
+ }
437
+
438
+ /**
439
+ * PHASE 0 — the harness's blocking human-question channel (`ctx.userQuestions`), when this composition
440
+ * mounts one.
441
+ *
442
+ * ⚠ WHY THE PLUGIN REACHES FOR THIS AT ALL. The other three gates answer through a question card and a
443
+ * relayed label, which is fine for a decision the workflow acts on later. STARTING A RUN is different:
444
+ * the first human turn is the only place the harness can say "arming this goal means autonomous rounds"
445
+ * BEFORE arming it. This channel is the same one plan-mode's exit uses; `ask()` resolves only with a
446
+ * real answer from a real person, and it THROWS when there is no answerer or no live root agent. So the
447
+ * absence of this service cannot be papered over: `recursive_ask` refuses the run-start gate and names
448
+ * the missing channel (RM5502).
449
+ */
450
+ private userQuestions: UserQuestionsLike | null = null
451
+
452
+ /** Late-bind the human-question channel when the composition mounts it. */
453
+ attachUserQuestions(service: UserQuestionsLike | null): void {
454
+ this.userQuestions = service
455
+ }
456
+
457
+ /** The human-question channel this composition mounted, or null. */
458
+ get userQuestionsChannel(): UserQuestionsLike | null {
459
+ return this.userQuestions
460
+ }
461
+
383
462
  /**
384
463
  * T1 (goals projection): project the run into the native goals service so it is
385
464
  * a first-class durable, resumable, blockable object. Best-effort — the run's
386
465
  * filesystem state is the source of truth; a goal is the durable projection.
466
+ *
467
+ * ⚠ PHASE 0 — AND IT ARMS NOTHING UNLESS THE RUN WAS STARTED. `create` returns an ARMED goal, and an
468
+ * armed goal is the harness driving autonomous rounds, so this is the one place where "project the
469
+ * state" can quietly equal "start the run". The approval is therefore REQUIRED from the caller and
470
+ * has no default here: a caller that has not resolved the run's approval cannot arm a goal by
471
+ * forgetting to pass one, and `syncRunGoal` refuses every branch that would create one without it.
472
+ *
473
+ * Unapproved is the EXPECTED state for a scaffolded run, so the refusal comes back as a plain
474
+ * `{ ok: false }` carrying {@link RUN_START_NOT_APPROVED}: callers must treat that as normal work,
475
+ * never as a warning (see `armRunGoalIfApproved`, the one caller that arms).
476
+ */
477
+ projectRunToGoal(agent: { session?: { header?: { cwd?: string } } } | null | undefined, runId: string, state: Parameters<typeof syncRunGoal>[3] = 'active', approved = false): SyncResult {
478
+ if (!agent) return { ok: false, reason: 'no agent' }
479
+ return syncRunGoal(this.goalsService, agent, runId, state, approved)
480
+ }
481
+
482
+ /**
483
+ * PHASE 0 — the approved-run arm step: read the run's approval from `root` and project the goal only
484
+ * if it is there. This is the phase-progress path (`syncRunGoal` reached on ordinary work), so the
485
+ * unapproved case is deliberately silent: `{ ok: false, reason: RUN_START_NOT_APPROVED }` with no
486
+ * goal, no write and no throw. Read on EVERY call rather than cached, because the approval can arrive
487
+ * mid-session and a cached "not yet" would leave an approved run unable to arm until a plugin reload.
387
488
  */
388
- projectRunToGoal(agent: { session?: { header?: { cwd?: string } } } | null | undefined, runId: string, state: Parameters<typeof syncRunGoal>[3] = 'active') {
489
+ armRunGoalIfApproved(agent: { session?: { header?: { cwd?: string } } } | null | undefined, root: string, runId: string, state: Parameters<typeof syncRunGoal>[3] = 'active'): SyncResult {
389
490
  if (!agent) return { ok: false, reason: 'no agent' }
390
- return syncRunGoal(this.goalsService, agent, runId, state)
491
+ const approval = this.readRunStartApproval(root, runId)
492
+ return syncRunGoal(this.goalsService, agent, runId, state, approval.approved)
391
493
  }
392
494
 
393
495
  /** T1: block the run's goal on a gate-block (durable + UI-visible). */
394
- blockRunToGoal(agent: { session?: { header?: { cwd?: string } } } | null | undefined, runId: string, reason: { code: string; message: string }) {
496
+ blockRunToGoal(agent: { session?: { header?: { cwd?: string } } } | null | undefined, runId: string, reason: { code: string; message: string }): SyncResult {
395
497
  if (!agent) return { ok: false, reason: 'no agent' }
396
498
  return blockRunGoal(this.goalsService, agent, runId, reason)
397
499
  }
398
500
 
399
- /** T1: re-arm the run's goal on a reopen (blocked/paused -> active). */
400
- resumeRunToGoal(agent: { session?: { header?: { cwd?: string } } } | null | undefined, runId: string) {
501
+ /**
502
+ * T1: re-arm the run's goal on a reopen (blocked/paused -> active). Never starts an unstarted run —
503
+ * REOPEN IS NOT A BACK DOOR TO STARTING A RUN. `approved` is required for the same reason as in
504
+ * `projectRunToGoal`: the phase-0 gate cannot be defaulted open. An approved run's approval outlives
505
+ * a reopen because it is a durable line in the run's own Phase 0 artifact, not a held value.
506
+ */
507
+ resumeRunToGoal(agent: { session?: { header?: { cwd?: string } } } | null | undefined, runId: string, approved = false): SyncResult {
401
508
  if (!agent) return { ok: false, reason: 'no agent' }
402
- return resumeRunGoal(this.goalsService, agent, runId)
509
+ return resumeRunGoal(this.goalsService, agent, runId, approved)
403
510
  }
404
511
 
405
512
  /**
@@ -1394,14 +1501,48 @@ export class RecursiveRuntime extends Service {
1394
1501
  created.push(file)
1395
1502
  }
1396
1503
 
1397
- const result: { runDir: string; runId: string; created: string[]; existing: string[]; worktree?: CreateWorktreeResult } = { runDir, runId, created, existing }
1504
+ const result: { runDir: string; runId: string; created: string[]; existing: string[]; worktree?: CreateWorktreeResult; runStartApproval: { approved: boolean; artifact: string; reason: string; gate: string } } = { runDir, runId, created, existing, runStartApproval: { ...this.readRunStartApproval(scaffoldRoot, runId), gate: RUN_START_GATE_ID } }
1398
1505
  if (worktree) result.worktree = worktree
1399
- // T1 (goals projection): arm a durable run goal for the driving session.
1400
- // Best-effort — never fails a run init if the goals service is absent/odd.
1401
- try { this.projectRunToGoal(agent, runId, 'active') } catch { /* goal projection is best-effort */ }
1506
+ // ⚠ PHASE 0 — SCAFFOLDING A RUN MUST NOT START IT. This used to arm a durable run goal right here,
1507
+ // and arming is what makes the harness drive autonomous rounds: asking for a run spec was enough to
1508
+ // start an unattended run. The rule is that phase 0 requires EXPLICIT approval to start a run and
1509
+ // goal, so init now ONLY SCAFFOLDS and REPORTS what is owed. `result.runStartApproval` is that
1510
+ // report, and it is the pointer the model needs: the run stays inert until `recursive_ask` answers
1511
+ // the `run-start` gate (which re-reads the approval and arms the goal through `armRunGoalIfApproved`).
1512
+ // The spec is not forbidden — the whole run directory was just written. What is withheld is the goal.
1402
1513
  return result
1403
1514
  }
1404
1515
 
1516
+ /**
1517
+ * PHASE 0 — THE APPROVAL ACT: record the human's `Start run` decision and arm the run's goal.
1518
+ *
1519
+ * ⚠ THE ONLY PATH THAT STARTS A RUN. It exists as one method rather than as "write a line, then
1520
+ * project the goal" at the tool, because those two steps must not be separable: an approval recorded
1521
+ * without the arm (or an arm without the record) is exactly the half-state that made this defect hard
1522
+ * to see. `tests/run-start-approval.spec.ts` drives both halves through this one call.
1523
+ *
1524
+ * The approval line goes into the run's own Phase 0 artifact, so it is durable, citable, and survives
1525
+ * the session — and so a reader of the run can answer "was this run started, and by what?" without the
1526
+ * transcript. `answer` is validated against the gate's own labels before it reaches here.
1527
+ */
1528
+ approveRunStart(root: string, runId: string, agent?: { session?: { header?: { cwd?: string } } } | null, answer: string = RUN_START_APPROVE): { ok: boolean; reason: string; path: string; replaced: boolean; goal: SyncResult } {
1529
+ if (root.trim() === '' || runId.trim() === '') {
1530
+ return { ok: false, reason: 'a run start needs a workspace root and a run id', path: '', replaced: false, goal: { ok: false, reason: 'no run to start' } }
1531
+ }
1532
+ const path = runStartArtifactPath(root, runId)
1533
+ // The record is written through the same in-place marker write every other gate uses, so a changed
1534
+ // mind REPLACES its line instead of leaving two answers to one question.
1535
+ const written = this.recordAskAnswer(root, runId, RUN_START_ARTIFACT, '- ' + RUN_START_MARKER + ': ' + answer)
1536
+ const approval = this.readRunStartApproval(root, runId)
1537
+ if (!approval.approved) {
1538
+ // A `Hold` (or anything else) is recorded as the decision it is and STARTS NOTHING. The goal is
1539
+ // not merely paused: an unstarted run has no goal at all (see goals-projection.ts branch 3).
1540
+ return { ok: false, reason: approval.reason, path: written.path, replaced: written.replaced, goal: { ok: false, reason: RUN_START_NOT_APPROVED } }
1541
+ }
1542
+ const goal = this.armRunGoalIfApproved(agent, root, runId, 'active')
1543
+ return { ok: true, reason: '', path: written.path, replaced: written.replaced, goal }
1544
+ }
1545
+
1405
1546
  /**
1406
1547
  * Create a linked worktree for a run under the given workspace root. The
1407
1548
  * worktree branch defaults to `recursive/<runId>` and is cut from the given
@@ -1574,7 +1715,9 @@ export class RecursiveRuntime extends Service {
1574
1715
  for (const entry of stale) invalidateReceipt(runDir, entry.artifact)
1575
1716
  // B2: reopen reverts to DRAFT — the live fs route folds the reverted state.
1576
1717
  // T1 (goals projection): re-arm the durable run goal (reopen un-blocks).
1577
- try { this.resumeRunToGoal(agent, runId) } catch { /* best-effort */ }
1718
+ // ⚠ PHASE 0: only for a run that WAS started — the approval is read from the run's own Phase 0
1719
+ // artifact here, so reopening an unapproved run cannot arm the goal init deliberately withheld.
1720
+ try { this.resumeRunToGoal(agent, runId, this.readRunStartApproval(root, runId).approved) } catch { /* best-effort */ }
1578
1721
  return {
1579
1722
  artifact,
1580
1723
  runId,