@try-works/dsh-recursive-mode 0.2.3 → 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/client/doc-viewer.d.ts +40 -0
- package/lib/client/host-api.d.ts +13 -0
- package/lib/client/index.d.ts +1 -0
- package/lib/client.js +813 -58
- package/lib/delegation.d.ts +168 -0
- package/lib/enforcement.d.ts +8 -0
- package/lib/goals-projection.d.ts +92 -0
- package/lib/index.d.ts +1 -0
- package/lib/index.js +915 -68
- package/lib/live-route.d.ts +1 -1
- package/lib/recursive_audit_team.tool.d.ts +2 -0
- package/lib/runtime.d.ts +112 -3
- package/lib/teams-loop.d.ts +160 -0
- package/package.json +1 -1
- package/scripts/test-recursive-mode-smoke.ts +45 -19
- package/src/client/doc-viewer.tsx +306 -0
- package/src/client/host-api.ts +28 -0
- package/src/client/index.ts +1 -0
- package/src/client/inspector.tsx +13 -2
- package/src/client/styles.ts +310 -0
- package/src/delegation.ts +337 -1
- package/src/enforcement.ts +16 -1
- package/src/goals-projection.ts +149 -0
- package/src/index.ts +41 -11
- package/src/live-route.ts +69 -2
- package/src/recursive_audit_team.tool.ts +141 -0
- package/src/runtime.ts +134 -6
- package/src/teams-loop.ts +259 -0
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
|
|
5131
|
-
*
|
|
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
|
|
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)
|
|
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
|
|
@@ -6661,14 +7417,30 @@ function queryOf(req, name) {
|
|
|
6661
7417
|
if (q < 0) return "";
|
|
6662
7418
|
return new URLSearchParams(url.slice(q + 1)).get(name) ?? "";
|
|
6663
7419
|
}
|
|
7420
|
+
/** Phase-doc basename allowlist (the run artifact sequence: single *.md, no subdirs). */
|
|
7421
|
+
const PHASE_DOC_FILES = new Set(RUN_ARTIFACT_SEQUENCE);
|
|
7422
|
+
/** runId/file safety: alphanumerics, dot, underscore, dash only; no '..', no separators. */
|
|
7423
|
+
const DOC_SAFE_RE = /^[A-Za-z0-9._-]+$/;
|
|
7424
|
+
/** Invalid runId or file name (path traversal / subdir / non-phase doc) — reject. */
|
|
7425
|
+
function docTargetError(runId, file) {
|
|
7426
|
+
if (!DOC_SAFE_RE.test(runId) || runId.includes("..")) return "invalid runId";
|
|
7427
|
+
if (!DOC_SAFE_RE.test(file) || file.includes("..") || file.includes("/") || file.includes("\\")) return "invalid file";
|
|
7428
|
+
if (!file.endsWith(".md")) return "invalid file: must be a .md phase doc";
|
|
7429
|
+
if (!PHASE_DOC_FILES.has(file)) return "invalid file: not a phase doc";
|
|
7430
|
+
return null;
|
|
7431
|
+
}
|
|
6664
7432
|
/**
|
|
6665
|
-
*
|
|
6666
|
-
*
|
|
7433
|
+
* The lazy per-phase doc route (0.2.4): GET the raw markdown of one run phase
|
|
7434
|
+
* doc, read ON DEMAND from the filesystem. NOT part of the /state or /events
|
|
7435
|
+
* projection payloads (they stay fold-only). Same browser-marker tripwire as
|
|
7436
|
+
* state/events; the client root is re-validated by the host (never trusted:
|
|
7437
|
+
* an unknown root -> 400), then the resolved doc path is containment-checked
|
|
7438
|
+
* under join(root, '.recursive', 'run', runId).
|
|
6667
7439
|
*/
|
|
6668
|
-
function
|
|
6669
|
-
return
|
|
7440
|
+
function docRoute(host) {
|
|
7441
|
+
return {
|
|
6670
7442
|
kind: "exact",
|
|
6671
|
-
path: "/.recursive/api/
|
|
7443
|
+
path: "/.recursive/api/doc",
|
|
6672
7444
|
handler: async (req, res) => {
|
|
6673
7445
|
if (req.method !== "GET") {
|
|
6674
7446
|
res.writeHead(405);
|
|
@@ -6680,75 +7452,142 @@ function makeRecursiveRoutes(host) {
|
|
|
6680
7452
|
res.end();
|
|
6681
7453
|
return;
|
|
6682
7454
|
}
|
|
6683
|
-
const
|
|
6684
|
-
const
|
|
6685
|
-
const
|
|
6686
|
-
|
|
6687
|
-
|
|
6688
|
-
|
|
6689
|
-
|
|
6690
|
-
|
|
7455
|
+
const root = queryOf(req, "root");
|
|
7456
|
+
const runId = queryOf(req, "runId");
|
|
7457
|
+
const file = queryOf(req, "file");
|
|
7458
|
+
const targetError = docTargetError(runId, file);
|
|
7459
|
+
if (root === "" || targetError !== null) {
|
|
7460
|
+
json(res, 400, {
|
|
7461
|
+
ok: false,
|
|
7462
|
+
error: targetError ?? "missing root"
|
|
6691
7463
|
});
|
|
6692
7464
|
return;
|
|
6693
7465
|
}
|
|
6694
|
-
|
|
6695
|
-
|
|
6696
|
-
|
|
6697
|
-
|
|
6698
|
-
|
|
6699
|
-
|
|
6700
|
-
}, {
|
|
6701
|
-
kind: "exact",
|
|
6702
|
-
path: "/.recursive/api/events",
|
|
6703
|
-
handler: async (req, res) => {
|
|
6704
|
-
if (req.method !== "GET") {
|
|
6705
|
-
res.writeHead(405);
|
|
6706
|
-
res.end();
|
|
7466
|
+
const resolvedRoot = await host.resolveRoot(void 0, root);
|
|
7467
|
+
if (resolvedRoot === null || resolve(resolvedRoot) !== resolve(root)) {
|
|
7468
|
+
json(res, 400, {
|
|
7469
|
+
ok: false,
|
|
7470
|
+
error: "root is not a known workspace"
|
|
7471
|
+
});
|
|
6707
7472
|
return;
|
|
6708
7473
|
}
|
|
6709
|
-
|
|
6710
|
-
|
|
6711
|
-
|
|
7474
|
+
const runBase = resolve(root, ".recursive", "run", runId);
|
|
7475
|
+
const docPath = resolve(runBase, file);
|
|
7476
|
+
if (docPath === runBase || !docPath.startsWith(runBase + sep)) {
|
|
7477
|
+
json(res, 400, {
|
|
7478
|
+
ok: false,
|
|
7479
|
+
error: "doc path escapes the run dir"
|
|
7480
|
+
});
|
|
6712
7481
|
return;
|
|
6713
7482
|
}
|
|
6714
|
-
|
|
6715
|
-
|
|
6716
|
-
|
|
6717
|
-
|
|
6718
|
-
res.writeHead(200, {
|
|
6719
|
-
"content-type": "text/event-stream; charset=utf-8",
|
|
6720
|
-
"cache-control": "no-cache",
|
|
6721
|
-
connection: "keep-alive"
|
|
7483
|
+
if (!existsSync(docPath)) {
|
|
7484
|
+
json(res, 404, {
|
|
7485
|
+
ok: false,
|
|
7486
|
+
error: "phase doc not found"
|
|
6722
7487
|
});
|
|
6723
|
-
res.write("data: {\"root\":null,\"projection\":{},\"revision\":0}\n\n");
|
|
6724
|
-
res.end();
|
|
6725
7488
|
return;
|
|
6726
7489
|
}
|
|
7490
|
+
const text = readFileSync(docPath, "utf8");
|
|
6727
7491
|
res.writeHead(200, {
|
|
6728
|
-
"content-type": "text/
|
|
6729
|
-
"cache-control": "no-
|
|
6730
|
-
connection: "keep-alive"
|
|
7492
|
+
"content-type": "text/markdown; charset=utf-8",
|
|
7493
|
+
"cache-control": "no-store"
|
|
6731
7494
|
});
|
|
6732
|
-
|
|
6733
|
-
|
|
6734
|
-
|
|
7495
|
+
res.end(text);
|
|
7496
|
+
}
|
|
7497
|
+
};
|
|
7498
|
+
}
|
|
7499
|
+
/**
|
|
7500
|
+
* Build the read-only routes. Returns [state, events, doc] in registration order.
|
|
7501
|
+
* @param host - the resolved-root + fs-fold seam (the RecursiveRuntime adapter).
|
|
7502
|
+
*/
|
|
7503
|
+
function makeRecursiveRoutes(host) {
|
|
7504
|
+
return [
|
|
7505
|
+
{
|
|
7506
|
+
kind: "exact",
|
|
7507
|
+
path: "/.recursive/api/state",
|
|
7508
|
+
handler: async (req, res) => {
|
|
7509
|
+
if (req.method !== "GET") {
|
|
7510
|
+
res.writeHead(405);
|
|
7511
|
+
res.end();
|
|
7512
|
+
return;
|
|
7513
|
+
}
|
|
7514
|
+
if (!browserMarker(req)) {
|
|
7515
|
+
res.writeHead(403);
|
|
7516
|
+
res.end();
|
|
7517
|
+
return;
|
|
7518
|
+
}
|
|
7519
|
+
const sessionId = queryOf(req, "sessionId") || void 0;
|
|
7520
|
+
const cwd = queryOf(req, "cwd");
|
|
7521
|
+
const root = sessionId === void 0 && cwd === "" ? null : await host.resolveRoot(sessionId, cwd);
|
|
7522
|
+
if (root === null) {
|
|
7523
|
+
json(res, 200, {
|
|
7524
|
+
root: null,
|
|
7525
|
+
projection: {},
|
|
7526
|
+
revision: 0
|
|
7527
|
+
});
|
|
7528
|
+
return;
|
|
7529
|
+
}
|
|
7530
|
+
json(res, 200, {
|
|
6735
7531
|
root,
|
|
6736
|
-
projection,
|
|
7532
|
+
projection: await host.snapshot(root),
|
|
6737
7533
|
revision: host.revision(root)
|
|
7534
|
+
});
|
|
7535
|
+
}
|
|
7536
|
+
},
|
|
7537
|
+
{
|
|
7538
|
+
kind: "exact",
|
|
7539
|
+
path: "/.recursive/api/events",
|
|
7540
|
+
handler: async (req, res) => {
|
|
7541
|
+
if (req.method !== "GET") {
|
|
7542
|
+
res.writeHead(405);
|
|
7543
|
+
res.end();
|
|
7544
|
+
return;
|
|
7545
|
+
}
|
|
7546
|
+
if (!browserMarker(req)) {
|
|
7547
|
+
res.writeHead(403);
|
|
7548
|
+
res.end();
|
|
7549
|
+
return;
|
|
7550
|
+
}
|
|
7551
|
+
const sessionId = queryOf(req, "sessionId") || void 0;
|
|
7552
|
+
const cwd = queryOf(req, "cwd");
|
|
7553
|
+
const root = sessionId === void 0 && cwd === "" ? null : await host.resolveRoot(sessionId, cwd);
|
|
7554
|
+
if (root === null) {
|
|
7555
|
+
res.writeHead(200, {
|
|
7556
|
+
"content-type": "text/event-stream; charset=utf-8",
|
|
7557
|
+
"cache-control": "no-cache",
|
|
7558
|
+
connection: "keep-alive"
|
|
7559
|
+
});
|
|
7560
|
+
res.write("data: {\"root\":null,\"projection\":{},\"revision\":0}\n\n");
|
|
7561
|
+
res.end();
|
|
7562
|
+
return;
|
|
7563
|
+
}
|
|
7564
|
+
res.writeHead(200, {
|
|
7565
|
+
"content-type": "text/event-stream; charset=utf-8",
|
|
7566
|
+
"cache-control": "no-cache",
|
|
7567
|
+
connection: "keep-alive"
|
|
7568
|
+
});
|
|
7569
|
+
const push = async () => {
|
|
7570
|
+
const projection = await host.snapshot(root);
|
|
7571
|
+
const payload = {
|
|
7572
|
+
root,
|
|
7573
|
+
projection,
|
|
7574
|
+
revision: host.revision(root)
|
|
7575
|
+
};
|
|
7576
|
+
res.write("data: " + JSON.stringify(payload) + "\n\n");
|
|
6738
7577
|
};
|
|
6739
|
-
|
|
6740
|
-
|
|
6741
|
-
|
|
6742
|
-
|
|
6743
|
-
|
|
6744
|
-
|
|
6745
|
-
|
|
6746
|
-
|
|
6747
|
-
|
|
6748
|
-
|
|
6749
|
-
|
|
6750
|
-
|
|
6751
|
-
|
|
7578
|
+
const heartbeat = setInterval(() => {
|
|
7579
|
+
res.write(": ping\n\n");
|
|
7580
|
+
}, HEARTBEAT_MS);
|
|
7581
|
+
const close = () => {
|
|
7582
|
+
clearInterval(heartbeat);
|
|
7583
|
+
};
|
|
7584
|
+
req.once("close", close);
|
|
7585
|
+
res.once("close", close);
|
|
7586
|
+
await push();
|
|
7587
|
+
}
|
|
7588
|
+
},
|
|
7589
|
+
docRoute(host)
|
|
7590
|
+
];
|
|
6752
7591
|
}
|
|
6753
7592
|
/**
|
|
6754
7593
|
* Register the routes at most ONCE per process. WebServer.register throws on a
|
|
@@ -6808,12 +7647,15 @@ function apply(ctx, config) {
|
|
|
6808
7647
|
if (config?.shellOnly) return;
|
|
6809
7648
|
ctx.effect(function* () {
|
|
6810
7649
|
const workspaceRegistry = ctx.get("workspaceRegistry");
|
|
7650
|
+
const goals = ctx.get("goals");
|
|
6811
7651
|
const recursive = new RecursiveRuntime(ctx, {
|
|
6812
7652
|
repoRoot: config?.repoRoot ?? process.cwd(),
|
|
6813
|
-
workspaceRegistry
|
|
7653
|
+
workspaceRegistry,
|
|
7654
|
+
goals
|
|
6814
7655
|
});
|
|
6815
7656
|
const repairedRoots = /* @__PURE__ */ new Set();
|
|
6816
7657
|
const reminderGate = new ReminderOnceGate();
|
|
7658
|
+
const agentTeams = ctx.get("agentTeams");
|
|
6817
7659
|
const disposers = [
|
|
6818
7660
|
ctx.tools.register(createRecursiveStatusTool(recursive)),
|
|
6819
7661
|
ctx.tools.register(createRecursiveInitTool(recursive)),
|
|
@@ -6822,7 +7664,8 @@ function apply(ctx, config) {
|
|
|
6822
7664
|
ctx.tools.register(createRecursiveCloseoutTool(recursive)),
|
|
6823
7665
|
ctx.tools.register(createRecursiveScratchTool(recursive)),
|
|
6824
7666
|
ctx.tools.register(createRecursiveWorktreeTool(recursive)),
|
|
6825
|
-
ctx.tools.register(createRecursivePhaseTool(recursive))
|
|
7667
|
+
ctx.tools.register(createRecursivePhaseTool(recursive)),
|
|
7668
|
+
...agentTeams ? [ctx.tools.register(createRecursiveAuditTeamTool(agentTeams))] : []
|
|
6826
7669
|
];
|
|
6827
7670
|
const commands = ctx.get("commands");
|
|
6828
7671
|
if (commands) disposers.push(registerRecursiveCommand({ commands }, recursive));
|
|
@@ -6849,6 +7692,10 @@ function apply(ctx, config) {
|
|
|
6849
7692
|
const decision = evaluateToolGuard(exec, exec?.agent?.session?.header?.cwd ?? "", "", recursive.enforcementConfig.toolGuards);
|
|
6850
7693
|
if (decision.kind === "allow") return typeof next === "function" ? next() : { kind: "allow" };
|
|
6851
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" };
|
|
6852
7699
|
}));
|
|
6853
7700
|
const agentRuntime = ctx;
|
|
6854
7701
|
if (agentRuntime.on) disposers.push(agentRuntime.on("agent/pre-step", async (payload, next) => {
|
|
@@ -6928,4 +7775,4 @@ function apply(ctx, config) {
|
|
|
6928
7775
|
});
|
|
6929
7776
|
}
|
|
6930
7777
|
//#endregion
|
|
6931
|
-
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 };
|