@try-works/dsh-recursive-mode 0.2.4 → 0.3.0

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/index.js CHANGED
@@ -4316,6 +4316,218 @@ async function delegate(input) {
4316
4316
  throw delegationError(message, "DELEGATION_FAILED");
4317
4317
  }
4318
4318
  }
4319
+ /** Read the verdict from a review-schema structured result (pure). */
4320
+ function readVerdictFromStructured(result) {
4321
+ const verdict = result.structured?.verdict;
4322
+ if (verdict === "APPROVE" || verdict === "REVISE" || verdict === "REJECT") return verdict;
4323
+ return "APPROVE";
4324
+ }
4325
+ /** Read the repair instruction from a review-schema structured result (pure). */
4326
+ function readRepairFromStructured(result) {
4327
+ const findings = result.structured?.findings;
4328
+ const titles = Array.isArray(findings) ? findings.map((f) => f.title ?? "").filter(Boolean) : [];
4329
+ if (titles.length > 0) return "Address the findings: " + titles.join("; ");
4330
+ return "REVISE: address the review findings and re-submit.";
4331
+ }
4332
+ /**
4333
+ * Run a multi-round delegated task on ONE durable continuable child:
4334
+ * 1. `startContinuable` (initial prompt) — `start()` is never called.
4335
+ * 2. `awaitRoundResult` observes the child's settlement for that round.
4336
+ * 3. On REVISE: `followup` delivers the repair instruction to the SAME child.
4337
+ * 4. On APPROVE/REJECT: finish (accepted only when the verdict is APPROVE and
4338
+ * the result evaluates as accepted).
4339
+ *
4340
+ * `awaitRoundResult(childId, messageId)` is the ONLY parent-side observation
4341
+ * seam: in live usage it waits for the child's settlement notice (the child's
4342
+ * `reportFrom` lands in the parent's inbox); in tests it is a fake queue.
4343
+ */
4344
+ async function delegateContinuable(input) {
4345
+ const { subagents, provider, label, prompt, parent, toolFilter, maxDepth } = input;
4346
+ const maxRounds = input.maxRounds ?? 3;
4347
+ const readVerdict = input.readVerdict ?? readVerdictFromStructured;
4348
+ const readRepair = input.readRepair ?? readRepairFromStructured;
4349
+ const startContinuable = subagents?.startContinuable;
4350
+ const followup = subagents?.followup;
4351
+ if (!(startContinuable !== void 0 && followup !== void 0 && input.awaitRoundResult !== void 0) || subagents === void 0 || parent === void 0) try {
4352
+ const oneShot = await delegate({
4353
+ subagents,
4354
+ provider,
4355
+ request: {
4356
+ prompt: [{
4357
+ type: "text",
4358
+ text: prompt
4359
+ }],
4360
+ label,
4361
+ toolFilter,
4362
+ maxDepth,
4363
+ parent
4364
+ }
4365
+ });
4366
+ const verdict = readVerdict(oneShot);
4367
+ return {
4368
+ ok: true,
4369
+ rounds: [{
4370
+ text: prompt,
4371
+ result: oneShot
4372
+ }],
4373
+ accepted: verdict === "APPROVE" && evaluateDelegationResult(oneShot).accepted,
4374
+ fellBackToOneShot: true
4375
+ };
4376
+ } catch (err) {
4377
+ return {
4378
+ ok: false,
4379
+ reason: err instanceof Error ? err.message : String(err),
4380
+ rounds: [],
4381
+ accepted: false,
4382
+ fellBackToOneShot: true
4383
+ };
4384
+ }
4385
+ const messageIds = [];
4386
+ const rounds = [];
4387
+ let childId;
4388
+ const request = {
4389
+ prompt: [{
4390
+ type: "text",
4391
+ text: prompt
4392
+ }],
4393
+ parent
4394
+ };
4395
+ if (toolFilter !== void 0) request.toolFilter = toolFilter;
4396
+ if (maxDepth !== void 0) request.maxDepth = maxDepth;
4397
+ const spec = {
4398
+ provider,
4399
+ label,
4400
+ request
4401
+ };
4402
+ if (input.childId !== void 0) spec.childId = input.childId;
4403
+ try {
4404
+ const started = await startContinuable(spec);
4405
+ childId = started.childId;
4406
+ messageIds.push(started.messageId);
4407
+ rounds.push({ text: prompt });
4408
+ for (let round = 0; round < maxRounds; round += 1) {
4409
+ const current = rounds[round];
4410
+ const observed = await input.awaitRoundResult(childId, messageIds[messageIds.length - 1]);
4411
+ if (observed === null) return {
4412
+ ok: false,
4413
+ reason: "continuable child produced no settlement for round " + (round + 1),
4414
+ childId,
4415
+ messageIds,
4416
+ rounds,
4417
+ accepted: false
4418
+ };
4419
+ current.result = observed;
4420
+ const verdict = readVerdict(observed);
4421
+ if (verdict !== "REVISE") {
4422
+ const accepted = verdict === "APPROVE" && evaluateDelegationResult(observed).accepted;
4423
+ return {
4424
+ ok: accepted,
4425
+ reason: accepted ? "delegation completed" : "delegation stopped with verdict " + verdict,
4426
+ childId,
4427
+ messageIds,
4428
+ rounds,
4429
+ accepted
4430
+ };
4431
+ }
4432
+ const repair = readRepair(observed);
4433
+ if (!repair) return {
4434
+ ok: false,
4435
+ reason: "REVISE verdict without a repair instruction",
4436
+ childId,
4437
+ messageIds,
4438
+ rounds,
4439
+ accepted: false
4440
+ };
4441
+ const followupId = await followup(parent, childId, [{
4442
+ type: "text",
4443
+ text: repair
4444
+ }], { source: {
4445
+ kind: "coordinator",
4446
+ form: "relay",
4447
+ senderSessionId: parent.id ?? ""
4448
+ } });
4449
+ messageIds.push(followupId);
4450
+ current.revise = true;
4451
+ current.repair = repair;
4452
+ rounds.push({ text: repair });
4453
+ }
4454
+ return {
4455
+ ok: false,
4456
+ reason: "max rounds reached without an APPROVE",
4457
+ childId,
4458
+ messageIds,
4459
+ rounds,
4460
+ accepted: false
4461
+ };
4462
+ } catch (err) {
4463
+ return {
4464
+ ok: false,
4465
+ reason: err instanceof Error ? err.message : String(err),
4466
+ childId,
4467
+ messageIds,
4468
+ rounds,
4469
+ accepted: false
4470
+ };
4471
+ }
4472
+ }
4473
+ /**
4474
+ * T4 kill switch: interrupt one live continuable child's current turn. Admission
4475
+ * is synchronous, the effect asynchronous, and the child's pending inbox is
4476
+ * preserved (keepInbox semantics) — a followup later resumes the parked queue.
4477
+ */
4478
+ function interruptContinuable(subagents, childId, parentSessionId) {
4479
+ const interrupt = subagents?.interrupt;
4480
+ if (interrupt === void 0) return {
4481
+ ok: false,
4482
+ reason: "no continuable interrupt seam"
4483
+ };
4484
+ try {
4485
+ interrupt(childId, {
4486
+ kind: "user",
4487
+ parentSessionId
4488
+ });
4489
+ return { ok: true };
4490
+ } catch (err) {
4491
+ return {
4492
+ ok: false,
4493
+ reason: err instanceof Error ? err.message : String(err)
4494
+ };
4495
+ }
4496
+ }
4497
+ /**
4498
+ * T4 closeout: release one continuable child (host drains its Activation and
4499
+ * disposes its handle). No-op when the seam lacks the method (one-shot hosts).
4500
+ */
4501
+ async function drainContinuableChildren(subagents, parent, childIds) {
4502
+ if (subagents?.drainContinuableChildren === void 0 || childIds.length === 0) return { ok: true };
4503
+ try {
4504
+ await subagents.drainContinuableChildren(parent, childIds);
4505
+ return { ok: true };
4506
+ } catch (err) {
4507
+ return {
4508
+ ok: false,
4509
+ reason: err instanceof Error ? err.message : String(err)
4510
+ };
4511
+ }
4512
+ }
4513
+ /**
4514
+ * T4 closeout (host teardown path): release every continuable descendant below
4515
+ * the given live parents (mirrors the live `drainContinuableDescendants`).
4516
+ * No-op when the seam lacks the method; the host owns this at session teardown.
4517
+ */
4518
+ async function drainContinuableDescendants(subagents, parents) {
4519
+ const drain = subagents?.drainContinuableDescendants;
4520
+ if (drain === void 0 || parents.length === 0) return { ok: true };
4521
+ try {
4522
+ await drain(parents);
4523
+ return { ok: true };
4524
+ } catch (err) {
4525
+ return {
4526
+ ok: false,
4527
+ reason: err instanceof Error ? err.message : String(err)
4528
+ };
4529
+ }
4530
+ }
4319
4531
  function norm(repoRelative) {
4320
4532
  return repoRelative.replace(/\\/g, "/").replace(/^\/+/, "");
4321
4533
  }
@@ -4690,6 +4902,23 @@ function evaluateToolGuard(exec, worktreeRoot, activeRunId, mode = "advisory") {
4690
4902
  }
4691
4903
  return { kind: "allow" };
4692
4904
  }
4905
+ /**
4906
+ * T6 (approval ask→policy bridge): an `ask` decision must never be a silent
4907
+ * allow. Under `strict` it coerces to `deny`; under `advisory` it stays `allow`
4908
+ * but flags a `warn` so the caller never lets it through unlogged. Non-ask
4909
+ * decisions pass through unchanged.
4910
+ */
4911
+ function coerceAskToDecision(decision, mode = "advisory") {
4912
+ if (decision.kind !== "ask") return decision;
4913
+ if (mode === "strict") return {
4914
+ kind: "deny",
4915
+ reason: decision.reason ?? "ask under strict enforcement denies"
4916
+ };
4917
+ return {
4918
+ kind: "allow",
4919
+ warn: decision.reason ?? "ask under advisory enforcement allows"
4920
+ };
4921
+ }
4693
4922
  /** Resolve a tool-target path to an absolute path under the worktree root. */
4694
4923
  function resolveTargetPath(target, worktreeRoot) {
4695
4924
  const normalized = target.replace(/\\/g, "/");
@@ -5055,6 +5284,291 @@ function promoteBranch(opts) {
5055
5284
  }
5056
5285
  }
5057
5286
  //#endregion
5287
+ //#region src/goals-projection.ts
5288
+ /** Marker embedded in the goal objective so a goal can be matched to its run. */
5289
+ function runGoalTag(runId) {
5290
+ return "recursive-run:" + runId;
5291
+ }
5292
+ /** The durable objective string for a run goal. */
5293
+ function goalObjective(runId, runState = "active") {
5294
+ return runGoalTag(runId) + " · " + runState;
5295
+ }
5296
+ /** Map a run state onto the native goal phase it should project to. */
5297
+ const RUN_TO_GOAL_PHASE = {
5298
+ new: "active",
5299
+ active: "active",
5300
+ paused: "paused",
5301
+ blocked: "blocked",
5302
+ complete: "complete"
5303
+ };
5304
+ /** Is this goal's objective the marker for `runId`? */
5305
+ function isRunGoal(goal, runId) {
5306
+ return goal?.objective?.startsWith(runGoalTag(runId)) === true;
5307
+ }
5308
+ /** Read a live ref (id + revision) for a goal. */
5309
+ function refOf(goal) {
5310
+ return {
5311
+ id: goal.id,
5312
+ revision: goal.revision
5313
+ };
5314
+ }
5315
+ /** Commit a phase mutation; the real service returns a truthy GoalView on success. */
5316
+ function mutatePhase(service, agent, ref, target) {
5317
+ switch (target) {
5318
+ case "blocked": return !!service.block(agent, ref, {
5319
+ code: "run-gate-block",
5320
+ message: "recursive run gate block"
5321
+ });
5322
+ case "paused": return !!service.pause(agent, ref);
5323
+ case "complete": return !!service.complete(agent, ref);
5324
+ case "active": return !!service.resume(agent, ref);
5325
+ }
5326
+ }
5327
+ /**
5328
+ * Sync a run's durable goal to the requested phase. Safe: never touches a goal
5329
+ * whose objective is not this run's marker, and never re-creates over a
5330
+ * non-complete foreign goal.
5331
+ */
5332
+ function syncRunGoal(service, agent, runId, runState) {
5333
+ if (!service) return {
5334
+ ok: false,
5335
+ reason: "no goals service"
5336
+ };
5337
+ const target = RUN_TO_GOAL_PHASE[runState];
5338
+ const current = service.get(agent);
5339
+ if (current && isRunGoal(current, runId)) {
5340
+ const phase = current.phase ?? "active";
5341
+ const ref = refOf(current);
5342
+ if (phase === target) return {
5343
+ ok: true,
5344
+ phase: target,
5345
+ ref
5346
+ };
5347
+ if (phase === "complete") return {
5348
+ ok: true,
5349
+ phase: target,
5350
+ ref: refOf(service.create(agent, { objective: goalObjective(runId, runState) })),
5351
+ created: true
5352
+ };
5353
+ return mutatePhase(service, agent, ref, target) ? {
5354
+ ok: true,
5355
+ phase: target,
5356
+ ref
5357
+ } : {
5358
+ ok: false,
5359
+ reason: "goal mutation failed"
5360
+ };
5361
+ }
5362
+ if (current) {
5363
+ if (current.phase === "complete") return {
5364
+ ok: true,
5365
+ phase: target,
5366
+ ref: refOf(service.create(agent, { objective: goalObjective(runId, runState) })),
5367
+ created: true
5368
+ };
5369
+ return {
5370
+ ok: false,
5371
+ reason: "a non-matching active goal exists (foreign goal not touched)"
5372
+ };
5373
+ }
5374
+ return {
5375
+ ok: true,
5376
+ phase: target,
5377
+ ref: refOf(service.create(agent, { objective: goalObjective(runId, runState) })),
5378
+ created: true
5379
+ };
5380
+ }
5381
+ /** Block the current run goal (used on a gate-block). Never touches a foreign goal. */
5382
+ function blockRunGoal(service, agent, runId, reason) {
5383
+ if (!service) return {
5384
+ ok: false,
5385
+ reason: "no goals service"
5386
+ };
5387
+ const current = service.get(agent);
5388
+ if (!current) return {
5389
+ ok: false,
5390
+ reason: "no current goal to block"
5391
+ };
5392
+ if (!isRunGoal(current, runId)) return {
5393
+ ok: false,
5394
+ reason: "current goal is not for this run (foreign goal not touched)"
5395
+ };
5396
+ const ref = refOf(current);
5397
+ return !!service.block(agent, ref, reason) ? {
5398
+ ok: true,
5399
+ phase: "blocked",
5400
+ ref
5401
+ } : {
5402
+ ok: false,
5403
+ reason: "goal block failed"
5404
+ };
5405
+ }
5406
+ /** Bridge a run's blocked goal back to active (used on a reopen). */
5407
+ function resumeRunGoal(service, agent, runId) {
5408
+ return syncRunGoal(service, agent, runId, "active");
5409
+ }
5410
+ //#endregion
5411
+ //#region src/teams-loop.ts
5412
+ /** Whether a view is the loop's expected task (guards CAS against foreign ids). */
5413
+ function isSameTask(task, id) {
5414
+ return task.id === id;
5415
+ }
5416
+ /**
5417
+ * Render a per-phase task history (board-facing; pure). One line per round plus
5418
+ * the final task status — no live data, no mutation.
5419
+ */
5420
+ function renderTaskHistory(task, rounds) {
5421
+ const lines = [];
5422
+ if (task !== void 0) lines.push("task " + task.id + " (" + task.status + ", rev " + task.revision + "): " + task.subject);
5423
+ for (const round of rounds) {
5424
+ const repair = round.repair ? " — " + round.repair : "";
5425
+ lines.push("round " + round.round + ": " + round.verdict + " (task rev " + round.taskRevision + ")" + repair);
5426
+ }
5427
+ return lines.join("\n");
5428
+ }
5429
+ /**
5430
+ * T3 driver: audit the phase until it passes, on ONE durable team task.
5431
+ *
5432
+ * Transition trail (the fake records exactly this order):
5433
+ * createTask(pending) → claim(in_progress) → waitForChange → audit round
5434
+ * → REVISE: updateTask(edit, repair) → waitForChange → re-audit SAME task
5435
+ * → APPROVE: updateTask(complete) → lockPhase()
5436
+ * → REJECT / cap / stuck: updateTask(release) + interrupt, NO lock.
5437
+ */
5438
+ async function auditToPass(input) {
5439
+ const { teams, caller, runId, phase, runAuditRound, lockPhase } = input;
5440
+ const maxRounds = input.maxRounds ?? 3;
5441
+ const waitTimeoutMs = input.waitTimeoutMs ?? 3e4;
5442
+ const waitForChange = teams.waitForChange;
5443
+ const interrupt = teams.interrupt;
5444
+ const rounds = [];
5445
+ const createRequest = {
5446
+ subject: "Audit to pass: " + runId + " " + phase,
5447
+ description: "drive " + phase + " through draft → audit → repair → re-audit → pass → lock for run " + runId
5448
+ };
5449
+ if (input.blockedBy !== void 0) createRequest.blockedBy = input.blockedBy;
5450
+ if (input.writeScopes !== void 0) createRequest.writeScopes = input.writeScopes;
5451
+ const task = await teams.createTask(caller, createRequest);
5452
+ const claim = await teams.updateTask(caller, {
5453
+ taskId: task.id,
5454
+ expectedRevision: task.revision,
5455
+ action: "claim"
5456
+ });
5457
+ if (!isSameTask(claim, task.id)) return {
5458
+ ok: false,
5459
+ reason: "claim returned a foreign task",
5460
+ taskId: task.id,
5461
+ rounds,
5462
+ locked: false,
5463
+ taskView: claim
5464
+ };
5465
+ let current = claim;
5466
+ rounds.push({
5467
+ round: 0,
5468
+ verdict: "REVISE",
5469
+ taskRevision: current.revision
5470
+ });
5471
+ try {
5472
+ for (let round = 1; round <= maxRounds; round += 1) {
5473
+ if (waitForChange !== void 0) await waitForChange(caller, waitTimeoutMs, void 0);
5474
+ const outcome = await runAuditRound(round, current);
5475
+ if (!isSameTask(current, task.id)) return {
5476
+ ok: false,
5477
+ reason: "round observed a foreign task",
5478
+ taskId: task.id,
5479
+ rounds,
5480
+ locked: false,
5481
+ taskView: current
5482
+ };
5483
+ if (outcome.verdict === "APPROVE") {
5484
+ const completed = await teams.updateTask(caller, {
5485
+ taskId: task.id,
5486
+ expectedRevision: current.revision,
5487
+ action: "complete"
5488
+ });
5489
+ await lockPhase();
5490
+ rounds.push({
5491
+ round,
5492
+ verdict: "APPROVE",
5493
+ taskRevision: completed.revision
5494
+ });
5495
+ return {
5496
+ ok: outcome.accepted,
5497
+ reason: outcome.accepted ? "audit passed and phase locked" : "verdict APPROVE but delegation not accepted",
5498
+ taskId: task.id,
5499
+ rounds,
5500
+ locked: true,
5501
+ taskView: completed
5502
+ };
5503
+ }
5504
+ if (outcome.verdict === "REJECT") {
5505
+ const released = await teams.updateTask(caller, {
5506
+ taskId: task.id,
5507
+ expectedRevision: current.revision,
5508
+ action: "release"
5509
+ });
5510
+ rounds.push({
5511
+ round,
5512
+ verdict: "REJECT",
5513
+ taskRevision: released.revision
5514
+ });
5515
+ return {
5516
+ ok: false,
5517
+ reason: "audit rejected at round " + round,
5518
+ taskId: task.id,
5519
+ rounds,
5520
+ locked: false,
5521
+ taskView: released
5522
+ };
5523
+ }
5524
+ const repair = outcome.repair ?? "REVISE: address the review findings and re-submit.";
5525
+ const edited = await teams.updateTask(caller, {
5526
+ taskId: task.id,
5527
+ expectedRevision: current.revision,
5528
+ action: "edit",
5529
+ description: current.description + "\nround " + round + " repair: " + repair
5530
+ });
5531
+ current = edited;
5532
+ rounds.push({
5533
+ round,
5534
+ verdict: "REVISE",
5535
+ repair,
5536
+ taskRevision: edited.revision
5537
+ });
5538
+ }
5539
+ const released = await teams.updateTask(caller, {
5540
+ taskId: task.id,
5541
+ expectedRevision: current.revision,
5542
+ action: "release"
5543
+ });
5544
+ return {
5545
+ ok: false,
5546
+ reason: "max rounds reached without an APPROVE",
5547
+ taskId: task.id,
5548
+ rounds,
5549
+ locked: false,
5550
+ taskView: released
5551
+ };
5552
+ } catch (err) {
5553
+ const message = err instanceof Error ? err.message : String(err);
5554
+ if (interrupt !== void 0) try {
5555
+ interrupt(caller, input.reviewerName ?? "auditor");
5556
+ } catch {}
5557
+ return {
5558
+ ok: false,
5559
+ reason: message,
5560
+ taskId: task.id,
5561
+ rounds,
5562
+ locked: false,
5563
+ taskView: current
5564
+ };
5565
+ }
5566
+ }
5567
+ /** Whether a task view is currently claimed by the named owner (board-facing). */
5568
+ function isTaskClaimedBy(task, ownerName) {
5569
+ return task.ownerName === ownerName && task.status === "in_progress";
5570
+ }
5571
+ //#endregion
5058
5572
  //#region src/runtime.ts
5059
5573
  var RecursiveRuntime = class extends Service {
5060
5574
  /** Recursive-mode runtime service. Owns run-state reads + lock/init/lint operations. */
@@ -5062,11 +5576,74 @@ var RecursiveRuntime = class extends Service {
5062
5576
  super(ctx, "recursive");
5063
5577
  this.repoRoot = config.repoRoot ?? process.cwd();
5064
5578
  this.workspaceRegistry = config.workspaceRegistry ?? null;
5579
+ this.goalsService = config.goals ?? null;
5065
5580
  }
5066
5581
  repoRoot;
5067
5582
  workspaceRegistry;
5583
+ goalsService;
5068
5584
  _enforcementConfig = null;
5069
5585
  /**
5586
+ * T3 (agentTeams task loop): run the audit→repair→re-audit state machine on
5587
+ * ONE durable team task. The `teams` seam (live `ctx.agentTeams`) is injected
5588
+ * per-call so the loop stays unit-testable; `runAuditRound` is the caller's
5589
+ * round executor (live usage wires T4's continuable delegation). Locking the
5590
+ * phase artifact is `lockPhase` — the loop NEVER locks before an APPROVE.
5591
+ */
5592
+ async auditToPass(input) {
5593
+ const { teams, caller, runId, phase, artifact, runAuditRound, agent } = input;
5594
+ let lockResult;
5595
+ const lockPhase = async () => {
5596
+ lockResult = await this.lockArtifact(runId, artifact, false, agent);
5597
+ };
5598
+ const result = await auditToPass({
5599
+ teams,
5600
+ caller,
5601
+ runId,
5602
+ phase,
5603
+ blockedBy: input.blockedBy,
5604
+ writeScopes: input.writeScopes,
5605
+ reviewerName: input.reviewerName,
5606
+ maxRounds: input.maxRounds,
5607
+ waitTimeoutMs: input.waitTimeoutMs,
5608
+ runAuditRound,
5609
+ lockPhase
5610
+ });
5611
+ const report = {
5612
+ ...result,
5613
+ history: renderTaskHistory(result.taskView, result.rounds)
5614
+ };
5615
+ if (lockResult !== void 0) report.lock = lockResult;
5616
+ return report;
5617
+ }
5618
+ /**
5619
+ * T1 (goals projection): project the run into the native goals service so it is
5620
+ * a first-class durable, resumable, blockable object. Best-effort — the run's
5621
+ * filesystem state is the source of truth; a goal is the durable projection.
5622
+ */
5623
+ projectRunToGoal(agent, runId, state = "active") {
5624
+ if (!agent) return {
5625
+ ok: false,
5626
+ reason: "no agent"
5627
+ };
5628
+ return syncRunGoal(this.goalsService, agent, runId, state);
5629
+ }
5630
+ /** T1: block the run's goal on a gate-block (durable + UI-visible). */
5631
+ blockRunToGoal(agent, runId, reason) {
5632
+ if (!agent) return {
5633
+ ok: false,
5634
+ reason: "no agent"
5635
+ };
5636
+ return blockRunGoal(this.goalsService, agent, runId, reason);
5637
+ }
5638
+ /** T1: re-arm the run's goal on a reopen (blocked/paused -> active). */
5639
+ resumeRunToGoal(agent, runId) {
5640
+ if (!agent) return {
5641
+ ok: false,
5642
+ reason: "no agent"
5643
+ };
5644
+ return resumeRunGoal(this.goalsService, agent, runId);
5645
+ }
5646
+ /**
5070
5647
  * Workspace-scoped control-plane root (R1 binding invariant).
5071
5648
  * Resolves the session agent's canonical cwd -> workspace path via the
5072
5649
  * registry; NEVER scans list(). Returns null when unavailable (defer).
@@ -5127,8 +5704,16 @@ var RecursiveRuntime = class extends Service {
5127
5704
  /**
5128
5705
  * Phase B (native delegation): build a review bundle (R1) + file-backed
5129
5706
  * handoff docs (R2), resolve the role via the router policy (R3), and call
5130
- * ctx.subagents.start() with the full request (R4). Workspace-scoped: every
5131
- * path resolves under the session's control-plane root.
5707
+ * ctx.subagents with the full request (R4). Workspace-scoped: every path
5708
+ * resolves under the session's control-plane root.
5709
+ *
5710
+ * `mode: 'continuable'` (T4) runs the audit→repair→re-audit loop on ONE
5711
+ * durable continuable child (startContinuable → followup with the repair
5712
+ * instruction → settle) and drains the child on closeout. It requires an
5713
+ * `awaitRoundResult` observer (the parent-side settlement seam) AND the exact
5714
+ * live `parent` Agent (continuable followup is object-identity authority);
5715
+ * when either is absent it falls back to one-shot `delegate()` with a flag —
5716
+ * never silently. One-shot `start()` is never called on the continuable path.
5132
5717
  */
5133
5718
  async delegateReview(input) {
5134
5719
  const policy = loadRouterPolicy(input.policyPath ?? routerPolicyPath(input.root));
@@ -5194,11 +5779,33 @@ var RecursiveRuntime = class extends Service {
5194
5779
  toolFilter: input.toolFilter ?? defaultReviewToolFilter(),
5195
5780
  maxDepth: input.maxDepth ?? 2
5196
5781
  };
5782
+ if (input.parent !== void 0) request.parent = input.parent;
5197
5783
  let result = null;
5198
5784
  let error = null;
5785
+ let continuable = null;
5199
5786
  if (decision.tier === "native" || decision.tier === "external-cli") {
5200
5787
  if (!input.subagents) error = "no ctx.subagents runtime available (self-audit fallback)";
5201
- else try {
5788
+ else if (input.mode === "continuable") {
5789
+ continuable = await delegateContinuable({
5790
+ subagents: input.subagents,
5791
+ provider: decision.provider,
5792
+ label: input.delegationId + "/" + input.childId,
5793
+ prompt,
5794
+ childId: input.childId,
5795
+ maxDepth: input.maxDepth ?? 2,
5796
+ toolFilter: input.toolFilter ?? defaultReviewToolFilter(),
5797
+ maxRounds: input.maxRounds ?? 3,
5798
+ awaitRoundResult: input.awaitRoundResult,
5799
+ parent: input.parent
5800
+ });
5801
+ if (continuable.fellBackToOneShot) {
5802
+ result = continuable.rounds[0]?.result ?? null;
5803
+ if (!result) error = "continuable fallback produced no result";
5804
+ } else if (continuable.ok && continuable.rounds.length > 0) {
5805
+ result = continuable.rounds[continuable.rounds.length - 1].result ?? null;
5806
+ if (!result) error = "continuable child produced no final result";
5807
+ } else error = continuable.reason ?? "continuable delegation failed";
5808
+ } else try {
5202
5809
  result = await delegate({
5203
5810
  subagents: input.subagents,
5204
5811
  provider: decision.provider,
@@ -5218,7 +5825,7 @@ var RecursiveRuntime = class extends Service {
5218
5825
  subagentId: input.childId,
5219
5826
  phase: input.phase,
5220
5827
  purpose: input.role + " for run " + input.runId,
5221
- executionMode: decision.tier,
5828
+ executionMode: decision.tier + (input.mode === "continuable" ? " (continuable)" : ""),
5222
5829
  artifactPath: input.artifactPath,
5223
5830
  upstreamArtifacts: input.upstreamArtifacts,
5224
5831
  reviewBundle: bundle.repoRelativePath,
@@ -5251,7 +5858,12 @@ var RecursiveRuntime = class extends Service {
5251
5858
  result,
5252
5859
  evaluation,
5253
5860
  actionRecordPath,
5254
- error
5861
+ error,
5862
+ continuable: continuable ? {
5863
+ rounds: continuable.rounds,
5864
+ childId: continuable.childId,
5865
+ fellBackToOneShot: continuable.fellBackToOneShot
5866
+ } : null
5255
5867
  };
5256
5868
  }
5257
5869
  /** R6: validate a child's claimed references against actual files. */
@@ -5413,6 +6025,9 @@ var RecursiveRuntime = class extends Service {
5413
6025
  existing
5414
6026
  };
5415
6027
  if (worktree) result.worktree = worktree;
6028
+ try {
6029
+ this.projectRunToGoal(agent, runId, "active");
6030
+ } catch {}
5416
6031
  return result;
5417
6032
  }
5418
6033
  /**
@@ -5466,7 +6081,16 @@ var RecursiveRuntime = class extends Service {
5466
6081
  if (!existsSync(artifactPath)) throw new Error("Artifact not found: " + artifact);
5467
6082
  if (getLockStatus(artifactPath) === "LOCKED") throw new Error("Artifact already LOCKED: " + artifact);
5468
6083
  const blockers = getPrerequisiteBlockers(runDir, artifact);
5469
- if (blockers.length > 0) throw new Error("Prerequisite blockers: " + blockers.map((b) => b.artifact + " (" + b.status + ")").join(", "));
6084
+ if (blockers.length > 0) {
6085
+ const message = "monotonic lock-order: " + blockers.map((b) => b.artifact + " (" + b.status + ")").join(", ");
6086
+ try {
6087
+ this.blockRunToGoal(agent, runId, {
6088
+ code: "prerequisite-blockers",
6089
+ message
6090
+ });
6091
+ } catch {}
6092
+ throw new Error("Prerequisite blockers: " + blockers.map((b) => b.artifact + " (" + b.status + ")").join(", "));
6093
+ }
5470
6094
  let content = readFileSync(artifactPath, "utf8");
5471
6095
  const lockedAt = (/* @__PURE__ */ new Date()).toISOString().replace(/\.\d{3}Z$/, "Z");
5472
6096
  content = setOrInsertField(content, "Status", "LOCKED", ["Phase"]);
@@ -5495,6 +6119,9 @@ var RecursiveRuntime = class extends Service {
5495
6119
  const hadReceipt = invalidateReceipt(runDir, artifact);
5496
6120
  const stale = getStaleDownstreamPhases(runDir, artifact);
5497
6121
  for (const entry of stale) invalidateReceipt(runDir, entry.artifact);
6122
+ try {
6123
+ this.resumeRunToGoal(agent, runId);
6124
+ } catch {}
5498
6125
  return {
5499
6126
  artifact,
5500
6127
  runId,
@@ -5942,6 +6569,135 @@ function createRecursivePhaseTool(recursive) {
5942
6569
  });
5943
6570
  }
5944
6571
  //#endregion
6572
+ //#region src/recursive_audit_team.tool.ts
6573
+ /**
6574
+ * `recursive_audit_team` — the T3 entry path: a turn-driven adapter over the
6575
+ * live `agentTeams` task board. ONE call advances the audit→repair→re-audit
6576
+ * state machine by one transition, so the agent drives the loop across turns
6577
+ * as continuable-child settlement notices arrive in its inbox.
6578
+ *
6579
+ * Why one step per call: the live subagents service has no public parent-side
6580
+ * "await settlement" promise — a continuable child's verdict arrives as a
6581
+ * `subagent-settled` message on a LATER turn. The pure whole-loop driver
6582
+ * (`auditToPass` in teams-loop.ts) models the full state machine and is the
6583
+ * tested reference; this tool is its honest turn-driven shell.
6584
+ */
6585
+ /** Project one live task view to an owned, lossless-JSON-safe record. */
6586
+ function taskViewToJson(view) {
6587
+ return {
6588
+ id: view.id,
6589
+ revision: view.revision,
6590
+ subject: view.subject,
6591
+ description: view.description,
6592
+ status: view.status,
6593
+ blockedBy: [...view.blockedBy],
6594
+ writeScopes: [...view.writeScopes],
6595
+ ownerName: view.ownerName ?? null,
6596
+ ready: view.ready,
6597
+ writeScopeWarnings: [...view.writeScopeWarnings]
6598
+ };
6599
+ }
6600
+ /** Wrap a non-JSON-pure value in the standard error envelope. */
6601
+ function errorJson(message) {
6602
+ return { error: message };
6603
+ }
6604
+ function createRecursiveAuditTeamTool(teams) {
6605
+ return defineTool({
6606
+ name: "recursive_audit_team",
6607
+ description: "Advance one agentTeams Task-board transition for the recursive audit loop (create → claim → edit(REVISE) → complete(APPROVE) → release/interrupt). Drive one step per turn as continuable-child settlement notices arrive; complete the task (and lock the phase) ONLY after an APPROVE verdict.",
6608
+ parameters: {
6609
+ action: {
6610
+ type: "string",
6611
+ description: "create | claim | edit | complete | release | interrupt | get | list. Required."
6612
+ },
6613
+ taskId: {
6614
+ type: "string",
6615
+ description: "Task id for claim/edit/complete/release/interrupt/get."
6616
+ },
6617
+ expectedRevision: {
6618
+ type: "number",
6619
+ description: "CAS revision for claim/edit/complete/release."
6620
+ },
6621
+ subject: {
6622
+ type: "string",
6623
+ description: "Task subject (create)."
6624
+ },
6625
+ description: {
6626
+ type: "string",
6627
+ description: "Task description (create) or appended repair note (edit)."
6628
+ },
6629
+ blockedBy: {
6630
+ type: "array",
6631
+ items: { type: "string" },
6632
+ description: "Task blockers (create)."
6633
+ },
6634
+ writeScopes: {
6635
+ type: "array",
6636
+ items: { type: "string" },
6637
+ description: "Advisory write scopes (create)."
6638
+ },
6639
+ targetName: {
6640
+ type: "string",
6641
+ description: "Teammate name to interrupt (interrupt)."
6642
+ }
6643
+ },
6644
+ output: {
6645
+ schema: { type: "json" },
6646
+ render: (_args, value) => [{
6647
+ type: "text",
6648
+ text: JSON.stringify(value, null, 2)
6649
+ }]
6650
+ },
6651
+ async execute(args, exec) {
6652
+ const action = args.action ?? "";
6653
+ if (!teams) return errorJson("agentTeams service is not available in this composition");
6654
+ const caller = exec.agent ?? {};
6655
+ try {
6656
+ switch (action) {
6657
+ case "create": {
6658
+ if (!args.subject || !args.description) return errorJson("create requires subject and description");
6659
+ const createRequest = {
6660
+ subject: args.subject,
6661
+ description: args.description
6662
+ };
6663
+ if (args.blockedBy !== void 0) createRequest.blockedBy = args.blockedBy;
6664
+ if (args.writeScopes !== void 0) createRequest.writeScopes = args.writeScopes;
6665
+ return taskViewToJson(await teams.createTask(caller, createRequest));
6666
+ }
6667
+ case "claim":
6668
+ case "edit":
6669
+ case "complete":
6670
+ case "release": {
6671
+ if (!args.taskId || args.expectedRevision === void 0) return errorJson(action + " requires taskId and expectedRevision");
6672
+ const updateAction = action;
6673
+ const updateRequest = {
6674
+ taskId: args.taskId,
6675
+ expectedRevision: args.expectedRevision,
6676
+ action: updateAction
6677
+ };
6678
+ if (action === "edit" && args.description !== void 0) updateRequest.description = args.description;
6679
+ return taskViewToJson(await teams.updateTask(caller, updateRequest));
6680
+ }
6681
+ case "interrupt":
6682
+ if (!args.targetName) return errorJson("interrupt requires targetName");
6683
+ if (!teams.interrupt) return errorJson("no interrupt seam");
6684
+ return { previousStatus: teams.interrupt(caller, args.targetName).previousStatus };
6685
+ case "get":
6686
+ if (!args.taskId) return errorJson("get requires taskId");
6687
+ if (!teams.getTask) return errorJson("no getTask seam");
6688
+ return taskViewToJson(teams.getTask(caller, args.taskId));
6689
+ case "list":
6690
+ if (!teams.listTasks) return errorJson("no listTasks seam");
6691
+ return teams.listTasks(caller).map(taskViewToJson);
6692
+ default: return errorJson("unsupported action: " + action + " (create|claim|edit|complete|release|interrupt|get|list)");
6693
+ }
6694
+ } catch (err) {
6695
+ return errorJson(err instanceof Error ? err.message : String(err));
6696
+ }
6697
+ }
6698
+ });
6699
+ }
6700
+ //#endregion
5945
6701
  //#region src/bootstrap.ts
5946
6702
  /**
5947
6703
  * Idempotent scaffold installer (R3). TS port of install-recursive-mode.py's
@@ -6891,12 +7647,15 @@ function apply(ctx, config) {
6891
7647
  if (config?.shellOnly) return;
6892
7648
  ctx.effect(function* () {
6893
7649
  const workspaceRegistry = ctx.get("workspaceRegistry");
7650
+ const goals = ctx.get("goals");
6894
7651
  const recursive = new RecursiveRuntime(ctx, {
6895
7652
  repoRoot: config?.repoRoot ?? process.cwd(),
6896
- workspaceRegistry
7653
+ workspaceRegistry,
7654
+ goals
6897
7655
  });
6898
7656
  const repairedRoots = /* @__PURE__ */ new Set();
6899
7657
  const reminderGate = new ReminderOnceGate();
7658
+ const agentTeams = ctx.get("agentTeams");
6900
7659
  const disposers = [
6901
7660
  ctx.tools.register(createRecursiveStatusTool(recursive)),
6902
7661
  ctx.tools.register(createRecursiveInitTool(recursive)),
@@ -6905,7 +7664,8 @@ function apply(ctx, config) {
6905
7664
  ctx.tools.register(createRecursiveCloseoutTool(recursive)),
6906
7665
  ctx.tools.register(createRecursiveScratchTool(recursive)),
6907
7666
  ctx.tools.register(createRecursiveWorktreeTool(recursive)),
6908
- ctx.tools.register(createRecursivePhaseTool(recursive))
7667
+ ctx.tools.register(createRecursivePhaseTool(recursive)),
7668
+ ...agentTeams ? [ctx.tools.register(createRecursiveAuditTeamTool(agentTeams))] : []
6909
7669
  ];
6910
7670
  const commands = ctx.get("commands");
6911
7671
  if (commands) disposers.push(registerRecursiveCommand({ commands }, recursive));
@@ -6932,6 +7692,10 @@ function apply(ctx, config) {
6932
7692
  const decision = evaluateToolGuard(exec, exec?.agent?.session?.header?.cwd ?? "", "", recursive.enforcementConfig.toolGuards);
6933
7693
  if (decision.kind === "allow") return typeof next === "function" ? next() : { kind: "allow" };
6934
7694
  if (decision.kind === "deny") return decision;
7695
+ const coerced = coerceAskToDecision(decision, recursive.enforcementConfig.toolGuards);
7696
+ if (coerced.kind === "deny") return coerced;
7697
+ if (coerced.kind === "allow" && coerced.warn) console.warn("[recursive] tool guard (advisory): " + coerced.warn + " — allowing");
7698
+ return typeof next === "function" ? next() : { kind: "allow" };
6935
7699
  }));
6936
7700
  const agentRuntime = ctx;
6937
7701
  if (agentRuntime.on) disposers.push(agentRuntime.on("agent/pre-step", async (payload, next) => {
@@ -7011,4 +7775,4 @@ function apply(ctx, config) {
7011
7775
  });
7012
7776
  }
7013
7777
  //#endregion
7014
- export { DEFAULT_ENFORCEMENT, OPTIONAL_PHASES, PHASES, PHASE_SEQUENCE, RECURSIVE_API_PREFIX, RUN_ARTIFACT_SEQUENCE, RUN_STATES, RecursiveRuntime, apply, buildDelegationPrompt, buildReviewBundle, capabilityProbe, childScratchPath, contentSha256, coupleGateBlockToGoal, createChildBrief, createHandoff, createRecursiveCloseoutTool, createRecursiveInitTool, createRecursiveLintTool, createRecursiveLockTool, createRecursivePhaseTool, createRecursiveScratchTool, createRecursiveStatusTool, createRecursiveWorktreeTool, defaultReviewToolFilter, delegate, delegationDecisionBasis, delegationError, detectTamper, discoverRuns, escapeRegExp, evaluateDelegationResult, evaluateToolGuard, foldRun, foldRunCard, getAllStaleReceipts, getArtifactState, getGateStatus, getLatestRunDirectory, getLockStatus, getMdFieldValue, getNextLegalPhase, getPrerequisiteBlockers, getPrerequisites, getStaleDownstreamPhases, getTodoStats, getWorkflowProfile, inject, invalidateReceipt, isCoreArtifact, loadRouterPolicy, lockHashFromContent, makeRecursiveRoutes, mountRecursiveRoutesOnce, name, normalizeForLockHash, phaseIndex, probeCapabilities, readReceipt, receiptPath, renderRecursivePolicy, replyPath, resolveEnforcementConfig, resolveRole, resolveRunDir, reviewBundleDir, reviewOutputSchema, routerPolicyPath, snapshotWorkspace, trimMdValue, validateChain, validateReferences, validateTransition, writeActionRecord, writeReceipt };
7778
+ export { DEFAULT_ENFORCEMENT, OPTIONAL_PHASES, PHASES, PHASE_SEQUENCE, RECURSIVE_API_PREFIX, RUN_ARTIFACT_SEQUENCE, RUN_STATES, RecursiveRuntime, apply, auditToPass, buildDelegationPrompt, buildReviewBundle, capabilityProbe, childScratchPath, coerceAskToDecision, contentSha256, coupleGateBlockToGoal, createChildBrief, createHandoff, createRecursiveCloseoutTool, createRecursiveInitTool, createRecursiveLintTool, createRecursiveLockTool, createRecursivePhaseTool, createRecursiveScratchTool, createRecursiveStatusTool, createRecursiveWorktreeTool, defaultReviewToolFilter, delegate, delegateContinuable, delegationDecisionBasis, delegationError, detectTamper, discoverRuns, drainContinuableChildren, drainContinuableDescendants, escapeRegExp, evaluateDelegationResult, evaluateToolGuard, foldRun, foldRunCard, getAllStaleReceipts, getArtifactState, getGateStatus, getLatestRunDirectory, getLockStatus, getMdFieldValue, getNextLegalPhase, getPrerequisiteBlockers, getPrerequisites, getStaleDownstreamPhases, getTodoStats, getWorkflowProfile, inject, interruptContinuable, invalidateReceipt, isCoreArtifact, isTaskClaimedBy, loadRouterPolicy, lockHashFromContent, makeRecursiveRoutes, mountRecursiveRoutesOnce, name, normalizeForLockHash, phaseIndex, probeCapabilities, readReceipt, readRepairFromStructured, readVerdictFromStructured, receiptPath, renderRecursivePolicy, renderTaskHistory, replyPath, resolveEnforcementConfig, resolveRole, resolveRunDir, reviewBundleDir, reviewOutputSchema, routerPolicyPath, snapshotWorkspace, trimMdValue, validateChain, validateReferences, validateTransition, writeActionRecord, writeReceipt };