@try-works/dsh-recursive-mode 0.2.4 → 0.3.1
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/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 +2 -0
- package/lib/index.js +863 -12
- package/lib/recursive_audit_team.tool.d.ts +2 -0
- package/lib/runtime.d.ts +112 -3
- package/lib/skills.d.ts +70 -0
- package/lib/teams-loop.d.ts +160 -0
- package/package.json +3 -2
- package/scripts/test-recursive-mode-smoke.ts +45 -19
- package/skills/recursive-mode/SKILL.md +66 -0
- package/src/delegation.ts +337 -1
- package/src/enforcement.ts +16 -1
- package/src/goals-projection.ts +149 -0
- package/src/index.ts +50 -11
- package/src/recursive_audit_team.tool.ts +141 -0
- package/src/runtime.ts +134 -6
- package/src/skills.ts +151 -0
- package/src/teams-loop.ts +259 -0
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
2
|
+
import type { JsonValue } from '@deepseek-ai/dsh-tools'
|
|
3
|
+
import type {
|
|
4
|
+
TeamRuntimeLike,
|
|
5
|
+
TeamCallerHandle,
|
|
6
|
+
TeamTaskActionLike,
|
|
7
|
+
TeamTaskViewLike,
|
|
8
|
+
CreateTeamTaskRequestLike,
|
|
9
|
+
UpdateTeamTaskRequestLike,
|
|
10
|
+
} from './teams-loop.ts'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* `recursive_audit_team` — the T3 entry path: a turn-driven adapter over the
|
|
14
|
+
* live `agentTeams` task board. ONE call advances the audit→repair→re-audit
|
|
15
|
+
* state machine by one transition, so the agent drives the loop across turns
|
|
16
|
+
* as continuable-child settlement notices arrive in its inbox.
|
|
17
|
+
*
|
|
18
|
+
* Why one step per call: the live subagents service has no public parent-side
|
|
19
|
+
* "await settlement" promise — a continuable child's verdict arrives as a
|
|
20
|
+
* `subagent-settled` message on a LATER turn. The pure whole-loop driver
|
|
21
|
+
* (`auditToPass` in teams-loop.ts) models the full state machine and is the
|
|
22
|
+
* tested reference; this tool is its honest turn-driven shell.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/** Project one live task view to an owned, lossless-JSON-safe record. */
|
|
26
|
+
function taskViewToJson(view: TeamTaskViewLike): JsonValue {
|
|
27
|
+
// Read only leaf fields; never pass the live service object into the model
|
|
28
|
+
// context (the tool boundary is where a live view becomes owned JSON).
|
|
29
|
+
return {
|
|
30
|
+
id: view.id,
|
|
31
|
+
revision: view.revision,
|
|
32
|
+
subject: view.subject,
|
|
33
|
+
description: view.description,
|
|
34
|
+
status: view.status,
|
|
35
|
+
blockedBy: [...view.blockedBy],
|
|
36
|
+
writeScopes: [...view.writeScopes],
|
|
37
|
+
ownerName: view.ownerName ?? null,
|
|
38
|
+
ready: view.ready,
|
|
39
|
+
writeScopeWarnings: [...view.writeScopeWarnings],
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Wrap a non-JSON-pure value in the standard error envelope. */
|
|
44
|
+
function errorJson(message: string): JsonValue {
|
|
45
|
+
return { error: message }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function createRecursiveAuditTeamTool(teams: TeamRuntimeLike | null) {
|
|
49
|
+
return defineTool({
|
|
50
|
+
name: 'recursive_audit_team',
|
|
51
|
+
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.',
|
|
52
|
+
parameters: {
|
|
53
|
+
action: { type: 'string', description: 'create | claim | edit | complete | release | interrupt | get | list. Required.' },
|
|
54
|
+
taskId: { type: 'string', description: 'Task id for claim/edit/complete/release/interrupt/get.' },
|
|
55
|
+
expectedRevision: { type: 'number', description: 'CAS revision for claim/edit/complete/release.' },
|
|
56
|
+
subject: { type: 'string', description: 'Task subject (create).' },
|
|
57
|
+
description: { type: 'string', description: 'Task description (create) or appended repair note (edit).' },
|
|
58
|
+
blockedBy: { type: 'array', items: { type: 'string' }, description: 'Task blockers (create).' },
|
|
59
|
+
writeScopes: { type: 'array', items: { type: 'string' }, description: 'Advisory write scopes (create).' },
|
|
60
|
+
targetName: { type: 'string', description: 'Teammate name to interrupt (interrupt).' },
|
|
61
|
+
},
|
|
62
|
+
output: {
|
|
63
|
+
schema: { type: 'json' },
|
|
64
|
+
render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 2) }],
|
|
65
|
+
},
|
|
66
|
+
async execute(args: {
|
|
67
|
+
action?: string
|
|
68
|
+
taskId?: string
|
|
69
|
+
expectedRevision?: number
|
|
70
|
+
subject?: string
|
|
71
|
+
description?: string
|
|
72
|
+
blockedBy?: string[]
|
|
73
|
+
writeScopes?: string[]
|
|
74
|
+
targetName?: string
|
|
75
|
+
}, exec) {
|
|
76
|
+
const action = args.action ?? ''
|
|
77
|
+
if (!teams) return errorJson('agentTeams service is not available in this composition')
|
|
78
|
+
// SAFETY: exec.agent is the live Team member Agent (a superset of the
|
|
79
|
+
// named opaque handle); the seam reads only id/session identity and never
|
|
80
|
+
// serializes it. When no agent owns the call, an empty handle degrades to
|
|
81
|
+
// an anonymous caller (the service admits/denies by its own authority).
|
|
82
|
+
const caller: TeamCallerHandle = exec.agent ?? {}
|
|
83
|
+
|
|
84
|
+
try {
|
|
85
|
+
switch (action) {
|
|
86
|
+
case 'create': {
|
|
87
|
+
if (!args.subject || !args.description) return errorJson('create requires subject and description')
|
|
88
|
+
const createRequest: CreateTeamTaskRequestLike = {
|
|
89
|
+
subject: args.subject,
|
|
90
|
+
description: args.description,
|
|
91
|
+
}
|
|
92
|
+
if (args.blockedBy !== undefined) createRequest.blockedBy = args.blockedBy
|
|
93
|
+
if (args.writeScopes !== undefined) createRequest.writeScopes = args.writeScopes
|
|
94
|
+
const view = await teams.createTask(caller, createRequest)
|
|
95
|
+
return taskViewToJson(view)
|
|
96
|
+
}
|
|
97
|
+
case 'claim':
|
|
98
|
+
case 'edit':
|
|
99
|
+
case 'complete':
|
|
100
|
+
case 'release': {
|
|
101
|
+
if (!args.taskId || args.expectedRevision === undefined) {
|
|
102
|
+
return errorJson(action + ' requires taskId and expectedRevision')
|
|
103
|
+
}
|
|
104
|
+
// SAFETY: this case arm is reachable only for action 'claim' | 'edit' |
|
|
105
|
+
// 'complete' | 'release' (the switch discriminates on the same string),
|
|
106
|
+
// each a member of TeamTaskActionLike's subset; the cast re-asserts that
|
|
107
|
+
// narrowing across the four fall-through labels without widening scope.
|
|
108
|
+
const updateAction = action as TeamTaskActionLike
|
|
109
|
+
const updateRequest: UpdateTeamTaskRequestLike = {
|
|
110
|
+
taskId: args.taskId,
|
|
111
|
+
expectedRevision: args.expectedRevision,
|
|
112
|
+
action: updateAction,
|
|
113
|
+
}
|
|
114
|
+
if (action === 'edit' && args.description !== undefined) updateRequest.description = args.description
|
|
115
|
+
const view = await teams.updateTask(caller, updateRequest)
|
|
116
|
+
return taskViewToJson(view)
|
|
117
|
+
}
|
|
118
|
+
case 'interrupt': {
|
|
119
|
+
if (!args.targetName) return errorJson('interrupt requires targetName')
|
|
120
|
+
if (!teams.interrupt) return errorJson('no interrupt seam')
|
|
121
|
+
const outcome = teams.interrupt(caller, args.targetName)
|
|
122
|
+
return { previousStatus: outcome.previousStatus }
|
|
123
|
+
}
|
|
124
|
+
case 'get': {
|
|
125
|
+
if (!args.taskId) return errorJson('get requires taskId')
|
|
126
|
+
if (!teams.getTask) return errorJson('no getTask seam')
|
|
127
|
+
return taskViewToJson(teams.getTask(caller, args.taskId))
|
|
128
|
+
}
|
|
129
|
+
case 'list': {
|
|
130
|
+
if (!teams.listTasks) return errorJson('no listTasks seam')
|
|
131
|
+
return teams.listTasks(caller).map(taskViewToJson)
|
|
132
|
+
}
|
|
133
|
+
default:
|
|
134
|
+
return errorJson('unsupported action: ' + action + ' (create|claim|edit|complete|release|interrupt|get|list)')
|
|
135
|
+
}
|
|
136
|
+
} catch (err) {
|
|
137
|
+
return errorJson(err instanceof Error ? err.message : String(err))
|
|
138
|
+
}
|
|
139
|
+
},
|
|
140
|
+
})
|
|
141
|
+
}
|
package/src/runtime.ts
CHANGED
|
@@ -21,7 +21,7 @@ import { readScratch, writeScratch, appendScratch, type ScratchTarget } from './
|
|
|
21
21
|
import { buildReviewBundle, type ReviewBundleInput } from './review.ts'
|
|
22
22
|
import { createHandoff, createChildBrief, replyPath, childScratchPath, buildDelegationPrompt, type HandoffInput, type ChildBriefInput } from './handoff.ts'
|
|
23
23
|
import { loadRouterPolicy, routerPolicyPath, resolveRole, capabilityProbe, delegationDecisionBasis, type RouterPolicy, type SubagentProviderLike, type RouteDecision, type CapabilityProbe } from './router.ts'
|
|
24
|
-
import { delegate, validateReferences, writeActionRecord, evaluateDelegationResult, reviewOutputSchema, defaultReviewToolFilter, type SubagentsRuntimeLike, type SubagentStartRequestLike, type SubagentResultLike, type Reference, type ActionRecordInput } from './delegation.ts'
|
|
24
|
+
import { delegate, delegateContinuable, validateReferences, writeActionRecord, evaluateDelegationResult, reviewOutputSchema, defaultReviewToolFilter, type SubagentsRuntimeLike, type SubagentStartRequestLike, type SubagentResultLike, type Reference, type ActionRecordInput, type ContinuableDelegationLike, type SubagentParentHandle } from './delegation.ts'
|
|
25
25
|
import { validateTransition, coupleGateBlockToGoal, type PhaseTransitionIntent, type RecursivePhaseState, type GateCheckResult } from './lifecycle.ts'
|
|
26
26
|
import { resolveEnforcementConfig, DEFAULT_ENFORCEMENT, evaluateToolGuard, detectTamper, type EnforcementConfig, type ToolGuardDecision, type ToolExecLike } from './enforcement.ts'
|
|
27
27
|
import type { Session } from '@deepseek-ai/dsh-session'
|
|
@@ -29,6 +29,9 @@ import { renderRecursivePolicy, type PolicyContext } from './policy.ts'
|
|
|
29
29
|
import { snapshotWorkspace } from './snapshot.ts'
|
|
30
30
|
import { createLinkedWorktree, promoteBranch, listWorktrees, defaultWorktreeBranch, type CreateWorktreeResult, type PromoteBranchResult } from './worktree.ts'
|
|
31
31
|
import { gitFacts } from './git-context.ts'
|
|
32
|
+
import { syncRunGoal, blockRunGoal, resumeRunGoal, type GoalServiceLike } from './goals-projection.ts'
|
|
33
|
+
import { auditToPass, renderTaskHistory, type TeamRuntimeLike, type AuditToPassResult, type TeamCallerHandle, type TeamTaskViewLike, type AuditRoundOutcome } from './teams-loop.ts'
|
|
34
|
+
import type { ContinuableChildId, ContinuableMessageId } from './delegation.ts'
|
|
32
35
|
|
|
33
36
|
declare module '@deepseek-ai/cordis' {
|
|
34
37
|
interface Context {
|
|
@@ -70,16 +73,86 @@ const ARTIFACT_STUB = {
|
|
|
70
73
|
export class RecursiveRuntime extends Service {
|
|
71
74
|
/** Recursive-mode runtime service. Owns run-state reads + lock/init/lint operations. */
|
|
72
75
|
|
|
73
|
-
constructor(ctx: Context, config: { repoRoot?: string; workspaceRegistry?: WorkspaceRegistryLike } = {}) {
|
|
76
|
+
constructor(ctx: Context, config: { repoRoot?: string; workspaceRegistry?: WorkspaceRegistryLike; goals?: GoalServiceLike | null } = {}) {
|
|
74
77
|
super(ctx, 'recursive')
|
|
75
78
|
this.repoRoot = config.repoRoot ?? process.cwd()
|
|
76
79
|
this.workspaceRegistry = config.workspaceRegistry ?? null
|
|
80
|
+
this.goalsService = config.goals ?? null
|
|
77
81
|
}
|
|
78
82
|
|
|
79
83
|
private readonly repoRoot: string
|
|
80
84
|
private readonly workspaceRegistry: WorkspaceRegistryLike | null
|
|
85
|
+
private readonly goalsService: GoalServiceLike | null
|
|
81
86
|
private _enforcementConfig: EnforcementConfig | null = null
|
|
82
87
|
|
|
88
|
+
/**
|
|
89
|
+
* T3 (agentTeams task loop): run the audit→repair→re-audit state machine on
|
|
90
|
+
* ONE durable team task. The `teams` seam (live `ctx.agentTeams`) is injected
|
|
91
|
+
* per-call so the loop stays unit-testable; `runAuditRound` is the caller's
|
|
92
|
+
* round executor (live usage wires T4's continuable delegation). Locking the
|
|
93
|
+
* phase artifact is `lockPhase` — the loop NEVER locks before an APPROVE.
|
|
94
|
+
*/
|
|
95
|
+
async auditToPass(input: {
|
|
96
|
+
teams: TeamRuntimeLike
|
|
97
|
+
caller: TeamCallerHandle
|
|
98
|
+
root: string
|
|
99
|
+
runId: string
|
|
100
|
+
phase: string
|
|
101
|
+
artifact: string
|
|
102
|
+
agent?: { session?: { header?: { cwd?: string } } } | null
|
|
103
|
+
runAuditRound: (round: number, task: TeamTaskViewLike) => Promise<AuditRoundOutcome>
|
|
104
|
+
blockedBy?: readonly string[]
|
|
105
|
+
writeScopes?: readonly string[]
|
|
106
|
+
reviewerName?: string
|
|
107
|
+
maxRounds?: number
|
|
108
|
+
waitTimeoutMs?: number
|
|
109
|
+
}): Promise<AuditToPassResult & { history?: string; lock?: LockArtifactResult }> {
|
|
110
|
+
const { teams, caller, runId, phase, artifact, runAuditRound, agent } = input
|
|
111
|
+
let lockResult: LockArtifactResult | undefined
|
|
112
|
+
const lockPhase = async () => { lockResult = await this.lockArtifact(runId, artifact, false, agent) }
|
|
113
|
+
const result = await auditToPass({
|
|
114
|
+
teams,
|
|
115
|
+
caller,
|
|
116
|
+
runId,
|
|
117
|
+
phase,
|
|
118
|
+
blockedBy: input.blockedBy,
|
|
119
|
+
writeScopes: input.writeScopes,
|
|
120
|
+
reviewerName: input.reviewerName,
|
|
121
|
+
maxRounds: input.maxRounds,
|
|
122
|
+
waitTimeoutMs: input.waitTimeoutMs,
|
|
123
|
+
runAuditRound,
|
|
124
|
+
lockPhase,
|
|
125
|
+
})
|
|
126
|
+
const report: AuditToPassResult & { history?: string; lock?: LockArtifactResult } = {
|
|
127
|
+
...result,
|
|
128
|
+
history: renderTaskHistory(result.taskView, result.rounds),
|
|
129
|
+
}
|
|
130
|
+
if (lockResult !== undefined) report.lock = lockResult
|
|
131
|
+
return report
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* T1 (goals projection): project the run into the native goals service so it is
|
|
136
|
+
* a first-class durable, resumable, blockable object. Best-effort — the run's
|
|
137
|
+
* filesystem state is the source of truth; a goal is the durable projection.
|
|
138
|
+
*/
|
|
139
|
+
projectRunToGoal(agent: { session?: { header?: { cwd?: string } } } | null | undefined, runId: string, state: Parameters<typeof syncRunGoal>[3] = 'active') {
|
|
140
|
+
if (!agent) return { ok: false, reason: 'no agent' }
|
|
141
|
+
return syncRunGoal(this.goalsService, agent, runId, state)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** T1: block the run's goal on a gate-block (durable + UI-visible). */
|
|
145
|
+
blockRunToGoal(agent: { session?: { header?: { cwd?: string } } } | null | undefined, runId: string, reason: { code: string; message: string }) {
|
|
146
|
+
if (!agent) return { ok: false, reason: 'no agent' }
|
|
147
|
+
return blockRunGoal(this.goalsService, agent, runId, reason)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** T1: re-arm the run's goal on a reopen (blocked/paused -> active). */
|
|
151
|
+
resumeRunToGoal(agent: { session?: { header?: { cwd?: string } } } | null | undefined, runId: string) {
|
|
152
|
+
if (!agent) return { ok: false, reason: 'no agent' }
|
|
153
|
+
return resumeRunGoal(this.goalsService, agent, runId)
|
|
154
|
+
}
|
|
155
|
+
|
|
83
156
|
/**
|
|
84
157
|
* Workspace-scoped control-plane root (R1 binding invariant).
|
|
85
158
|
* Resolves the session agent's canonical cwd -> workspace path via the
|
|
@@ -138,8 +211,16 @@ export class RecursiveRuntime extends Service {
|
|
|
138
211
|
/**
|
|
139
212
|
* Phase B (native delegation): build a review bundle (R1) + file-backed
|
|
140
213
|
* handoff docs (R2), resolve the role via the router policy (R3), and call
|
|
141
|
-
* ctx.subagents
|
|
142
|
-
*
|
|
214
|
+
* ctx.subagents with the full request (R4). Workspace-scoped: every path
|
|
215
|
+
* resolves under the session's control-plane root.
|
|
216
|
+
*
|
|
217
|
+
* `mode: 'continuable'` (T4) runs the audit→repair→re-audit loop on ONE
|
|
218
|
+
* durable continuable child (startContinuable → followup with the repair
|
|
219
|
+
* instruction → settle) and drains the child on closeout. It requires an
|
|
220
|
+
* `awaitRoundResult` observer (the parent-side settlement seam) AND the exact
|
|
221
|
+
* live `parent` Agent (continuable followup is object-identity authority);
|
|
222
|
+
* when either is absent it falls back to one-shot `delegate()` with a flag —
|
|
223
|
+
* never silently. One-shot `start()` is never called on the continuable path.
|
|
143
224
|
*/
|
|
144
225
|
async delegateReview(input: {
|
|
145
226
|
root: string
|
|
@@ -160,6 +241,11 @@ export class RecursiveRuntime extends Service {
|
|
|
160
241
|
subagents?: SubagentsRuntimeLike
|
|
161
242
|
maxDepth?: number
|
|
162
243
|
toolFilter?: unknown
|
|
244
|
+
mode?: 'one-shot' | 'continuable'
|
|
245
|
+
awaitRoundResult?: (childId: ContinuableChildId, messageId: ContinuableMessageId) => Promise<SubagentResultLike | null>
|
|
246
|
+
maxRounds?: number
|
|
247
|
+
/** T4: the exact live direct-parent Agent (object-identity authority). */
|
|
248
|
+
parent?: SubagentParentHandle
|
|
163
249
|
}) {
|
|
164
250
|
const policy = loadRouterPolicy(input.policyPath ?? routerPolicyPath(input.root))
|
|
165
251
|
const providers = input.providers ?? {}
|
|
@@ -213,7 +299,9 @@ export class RecursiveRuntime extends Service {
|
|
|
213
299
|
briefPath,
|
|
214
300
|
})
|
|
215
301
|
|
|
216
|
-
// R4: plugin-driven delegation with the full request shape.
|
|
302
|
+
// R4: plugin-driven delegation with the full request shape. `parent` is the
|
|
303
|
+
// exact live direct-parent Agent (object-identity authority in the live
|
|
304
|
+
// subagent service); absent it, the live start() rejects the request.
|
|
217
305
|
const request: SubagentStartRequestLike = {
|
|
218
306
|
prompt: [{ type: 'text', text: prompt }],
|
|
219
307
|
label: input.delegationId + '/' + input.childId,
|
|
@@ -221,12 +309,38 @@ export class RecursiveRuntime extends Service {
|
|
|
221
309
|
toolFilter: input.toolFilter ?? defaultReviewToolFilter(),
|
|
222
310
|
maxDepth: input.maxDepth ?? 2,
|
|
223
311
|
}
|
|
312
|
+
if (input.parent !== undefined) request.parent = input.parent
|
|
224
313
|
|
|
225
314
|
let result: SubagentResultLike | null = null
|
|
226
315
|
let error: string | null = null
|
|
316
|
+
let continuable: ContinuableDelegationLike | null = null
|
|
227
317
|
if (decision.tier === 'native' || decision.tier === 'external-cli') {
|
|
228
318
|
if (!input.subagents) {
|
|
229
319
|
error = 'no ctx.subagents runtime available (self-audit fallback)'
|
|
320
|
+
} else if (input.mode === 'continuable') {
|
|
321
|
+
// T4: one durable child carries every round; start() is never called.
|
|
322
|
+
continuable = await delegateContinuable({
|
|
323
|
+
subagents: input.subagents,
|
|
324
|
+
provider: decision.provider as string,
|
|
325
|
+
label: input.delegationId + '/' + input.childId,
|
|
326
|
+
prompt,
|
|
327
|
+
childId: input.childId,
|
|
328
|
+
maxDepth: input.maxDepth ?? 2,
|
|
329
|
+
toolFilter: input.toolFilter ?? defaultReviewToolFilter(),
|
|
330
|
+
maxRounds: input.maxRounds ?? 3,
|
|
331
|
+
awaitRoundResult: input.awaitRoundResult,
|
|
332
|
+
parent: input.parent,
|
|
333
|
+
})
|
|
334
|
+
if (continuable.fellBackToOneShot) {
|
|
335
|
+
// The seam has no continuable capability — keep the one-shot result.
|
|
336
|
+
result = continuable.rounds[0]?.result ?? null
|
|
337
|
+
if (!result) error = 'continuable fallback produced no result'
|
|
338
|
+
} else if (continuable.ok && continuable.rounds.length > 0) {
|
|
339
|
+
result = continuable.rounds[continuable.rounds.length - 1].result ?? null
|
|
340
|
+
if (!result) error = 'continuable child produced no final result'
|
|
341
|
+
} else {
|
|
342
|
+
error = continuable.reason ?? 'continuable delegation failed'
|
|
343
|
+
}
|
|
230
344
|
} else {
|
|
231
345
|
try {
|
|
232
346
|
result = await delegate({
|
|
@@ -251,7 +365,7 @@ export class RecursiveRuntime extends Service {
|
|
|
251
365
|
subagentId: input.childId,
|
|
252
366
|
phase: input.phase,
|
|
253
367
|
purpose: input.role + ' for run ' + input.runId,
|
|
254
|
-
executionMode: decision.tier,
|
|
368
|
+
executionMode: decision.tier + (input.mode === 'continuable' ? ' (continuable)' : ''),
|
|
255
369
|
artifactPath: input.artifactPath,
|
|
256
370
|
upstreamArtifacts: input.upstreamArtifacts,
|
|
257
371
|
reviewBundle: bundle.repoRelativePath,
|
|
@@ -263,6 +377,10 @@ export class RecursiveRuntime extends Service {
|
|
|
263
377
|
stopReason: result?.stopReason,
|
|
264
378
|
})
|
|
265
379
|
|
|
380
|
+
// T4: the durable child id is reported for the caller (a tool/closeout that
|
|
381
|
+
// holds the live parent Agent may drain it explicitly); the HOST owns the
|
|
382
|
+
// teardown drain (drainContinuableDescendants) at session close — this loop
|
|
383
|
+
// never forces a drain with a wrong authority credential (childId ≠ parent).
|
|
266
384
|
return {
|
|
267
385
|
decision,
|
|
268
386
|
probe,
|
|
@@ -277,6 +395,7 @@ export class RecursiveRuntime extends Service {
|
|
|
277
395
|
evaluation,
|
|
278
396
|
actionRecordPath,
|
|
279
397
|
error,
|
|
398
|
+
continuable: continuable ? { rounds: continuable.rounds, childId: continuable.childId, fellBackToOneShot: continuable.fellBackToOneShot } : null,
|
|
280
399
|
}
|
|
281
400
|
}
|
|
282
401
|
|
|
@@ -431,6 +550,9 @@ export class RecursiveRuntime extends Service {
|
|
|
431
550
|
|
|
432
551
|
const result: { runDir: string; runId: string; created: string[]; existing: string[]; worktree?: CreateWorktreeResult } = { runDir, runId, created, existing }
|
|
433
552
|
if (worktree) result.worktree = worktree
|
|
553
|
+
// T1 (goals projection): arm a durable run goal for the driving session.
|
|
554
|
+
// Best-effort — never fails a run init if the goals service is absent/odd.
|
|
555
|
+
try { this.projectRunToGoal(agent, runId, 'active') } catch { /* goal projection is best-effort */ }
|
|
434
556
|
return result
|
|
435
557
|
}
|
|
436
558
|
|
|
@@ -486,6 +608,10 @@ export class RecursiveRuntime extends Service {
|
|
|
486
608
|
// durable commit below is what the live fs route folds. No phase-intent event.
|
|
487
609
|
const blockers = getPrerequisiteBlockers(runDir, artifact)
|
|
488
610
|
if (blockers.length > 0) {
|
|
611
|
+
// T1 (goals projection): a gate-block becomes a durable, UI-visible goal
|
|
612
|
+
// block rather than a one-line advisory. Best-effort before the throw.
|
|
613
|
+
const message = 'monotonic lock-order: ' + blockers.map(b => b.artifact + ' (' + b.status + ')').join(', ')
|
|
614
|
+
try { this.blockRunToGoal(agent, runId, { code: 'prerequisite-blockers', message }) } catch { /* best-effort */ }
|
|
489
615
|
throw new Error('Prerequisite blockers: ' + blockers.map(b => b.artifact + ' (' + b.status + ')').join(', '))
|
|
490
616
|
}
|
|
491
617
|
let content = readFileSync(artifactPath, 'utf8')
|
|
@@ -521,6 +647,8 @@ export class RecursiveRuntime extends Service {
|
|
|
521
647
|
const stale = getStaleDownstreamPhases(runDir, artifact)
|
|
522
648
|
for (const entry of stale) invalidateReceipt(runDir, entry.artifact)
|
|
523
649
|
// B2: reopen reverts to DRAFT — the live fs route folds the reverted state.
|
|
650
|
+
// T1 (goals projection): re-arm the durable run goal (reopen un-blocks).
|
|
651
|
+
try { this.resumeRunToGoal(agent, runId) } catch { /* best-effort */ }
|
|
524
652
|
return {
|
|
525
653
|
artifact,
|
|
526
654
|
runId,
|
package/src/skills.ts
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Packaged `recursive-mode` skill (dsh plugin standard).
|
|
3
|
+
*
|
|
4
|
+
* Ships the workflow's operating contract as a bundled skill through
|
|
5
|
+
* `ctx.skills.registerProvider(...)` — the same shape as the shipped
|
|
6
|
+
* `dsh-skill-badge` provider: a `bundled` candidate at `BUNDLED_SKILL_RANK`
|
|
7
|
+
* (600), body read from the package's shipped `skills/recursive-mode/SKILL.md`
|
|
8
|
+
* (never an inlined TS string literal), with a directory resource base so the
|
|
9
|
+
* skill can resolve its own assets. Skills are optional instructions, not
|
|
10
|
+
* session events: this emits nothing and never appends a recursive/* event.
|
|
11
|
+
*
|
|
12
|
+
* Optionality: `skills` is a host-plane registry; when the composition has
|
|
13
|
+
* none, this is a no-op (returns undefined) rather than failing boot.
|
|
14
|
+
*/
|
|
15
|
+
import { readFileSync } from 'node:fs'
|
|
16
|
+
import { dirname, join } from 'node:path'
|
|
17
|
+
import { fileURLToPath } from 'node:url'
|
|
18
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
19
|
+
|
|
20
|
+
/** Package root: <package>/src/.. — skills/ sits next to src/. */
|
|
21
|
+
const MODULE_DIR = dirname(fileURLToPath(import.meta.url))
|
|
22
|
+
const PACKAGE_ROOT = join(MODULE_DIR, '..')
|
|
23
|
+
|
|
24
|
+
/** Precedence rank for packaged/bundled skills (mirrors `BUNDLED_SKILL_RANK`). */
|
|
25
|
+
const BUNDLED_SKILL_RANK = 600
|
|
26
|
+
const SKILL_NAME = 'recursive-mode'
|
|
27
|
+
const PROVIDER_NAME = 'recursive-mode'
|
|
28
|
+
const SKILL_BODY_PATH = join(PACKAGE_ROOT, 'skills', SKILL_NAME, 'SKILL.md')
|
|
29
|
+
const SKILL_RESOURCE_BASE = join(PACKAGE_ROOT, 'skills', SKILL_NAME)
|
|
30
|
+
const SKILL_DESCRIPTION =
|
|
31
|
+
'Drive the recursive-mode workflow: an audit-gated, phase-disciplined loop that carries one requirement from AS-IS through TO-BE plan, implementation, test, and manual QA to locked control-plane state and durable memory. Use whenever the user asks to implement, resume, or advance a recursive run, or when a repo carries a /.recursive/ control plane.'
|
|
32
|
+
|
|
33
|
+
/** Invocation policy: both the model catalog and user `/name` surfaces may load it. */
|
|
34
|
+
const INVOCATION = { modelInvocable: true, userInvocable: true } as const
|
|
35
|
+
|
|
36
|
+
/** One skill contribution, mirrored from the `dsh-skill` registry contract. */
|
|
37
|
+
export interface SkillInvocationPolicyLike {
|
|
38
|
+
readonly modelInvocable: boolean
|
|
39
|
+
readonly userInvocable: boolean
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Provider-owned base for relative resource resolution. */
|
|
43
|
+
export type SkillResourceBaseLike =
|
|
44
|
+
| { readonly kind: 'directory'; readonly path: string }
|
|
45
|
+
| { readonly kind: 'url'; readonly url: string }
|
|
46
|
+
| { readonly kind: 'opaque'; readonly description: string }
|
|
47
|
+
|
|
48
|
+
/** Invocation-neutral summary fields shared by candidates and definitions. */
|
|
49
|
+
export interface SkillSummaryLike {
|
|
50
|
+
readonly name: string
|
|
51
|
+
readonly description: string
|
|
52
|
+
readonly whenToUse?: string
|
|
53
|
+
readonly invocation: SkillInvocationPolicyLike
|
|
54
|
+
readonly source: string
|
|
55
|
+
readonly provider: string
|
|
56
|
+
readonly resourceBase?: SkillResourceBaseLike
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Provider catalog entry: a summary plus rank and an opaque locator. */
|
|
60
|
+
export interface SkillCandidateLike extends SkillSummaryLike {
|
|
61
|
+
readonly rank: number
|
|
62
|
+
readonly locator: unknown
|
|
63
|
+
readonly path?: string
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Complete loaded skill: a summary plus the markdown instruction body. */
|
|
67
|
+
export interface SkillDefinitionLike extends SkillSummaryLike {
|
|
68
|
+
readonly content: string
|
|
69
|
+
readonly path?: string
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Lookup options passed to provider `list`/`get`. */
|
|
73
|
+
export interface SkillLookupOptionsLike {
|
|
74
|
+
readonly cwd?: string | undefined
|
|
75
|
+
readonly signal?: AbortSignal | undefined
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Registration-scoped control borrowed by one provider. */
|
|
79
|
+
export interface SkillProviderControlLike {
|
|
80
|
+
readonly signal: AbortSignal
|
|
81
|
+
readonly invalidate: () => void
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** One source of skills, mirrored from the `dsh-skill` SkillProvider contract. */
|
|
85
|
+
export interface SkillProviderLike {
|
|
86
|
+
readonly name: string
|
|
87
|
+
readonly list: (options: SkillLookupOptionsLike) => Promise<readonly SkillCandidateLike[] | { readonly candidates: readonly SkillCandidateLike[]; readonly complete: boolean }>
|
|
88
|
+
readonly get: (candidate: SkillCandidateLike, options: SkillLookupOptionsLike) => Promise<SkillDefinitionLike | undefined>
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Minimal host-realm contract for ctx.skills (the seam we call). */
|
|
92
|
+
export interface SkillsRuntimeLike {
|
|
93
|
+
registerProvider(create: (control: SkillProviderControlLike) => SkillProviderLike): () => void
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The bundled candidate, stable across every `list()` call. */
|
|
97
|
+
function candidate(): SkillCandidateLike {
|
|
98
|
+
return {
|
|
99
|
+
name: SKILL_NAME,
|
|
100
|
+
description: SKILL_DESCRIPTION,
|
|
101
|
+
invocation: INVOCATION,
|
|
102
|
+
provider: PROVIDER_NAME,
|
|
103
|
+
source: 'bundled',
|
|
104
|
+
resourceBase: { kind: 'directory', path: SKILL_RESOURCE_BASE },
|
|
105
|
+
rank: BUNDLED_SKILL_RANK,
|
|
106
|
+
locator: SKILL_BODY_PATH,
|
|
107
|
+
path: SKILL_BODY_PATH,
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Read the shipped operating-contract body (fail loud if the package lost it). */
|
|
112
|
+
function readBody(): string {
|
|
113
|
+
return readFileSync(SKILL_BODY_PATH, 'utf8').replace(/\r\n/g, '\n').replace(/\r/g, '\n')
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** The one bundled provider this plugin contributes. */
|
|
117
|
+
function provider(): SkillProviderLike {
|
|
118
|
+
return {
|
|
119
|
+
name: PROVIDER_NAME,
|
|
120
|
+
list: () => Promise.resolve([candidate()]),
|
|
121
|
+
get: async () => ({
|
|
122
|
+
name: SKILL_NAME,
|
|
123
|
+
description: SKILL_DESCRIPTION,
|
|
124
|
+
invocation: INVOCATION,
|
|
125
|
+
provider: PROVIDER_NAME,
|
|
126
|
+
source: 'bundled',
|
|
127
|
+
resourceBase: { kind: 'directory', path: SKILL_RESOURCE_BASE },
|
|
128
|
+
content: readBody(),
|
|
129
|
+
path: SKILL_BODY_PATH,
|
|
130
|
+
}),
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Register the packaged `recursive-mode` skill into the host `skills` registry.
|
|
136
|
+
*
|
|
137
|
+
* Reads `ctx.get('skills')` optionally (the registry is host-plane; a
|
|
138
|
+
* composition without it is valid). On success returns the exact disposer that
|
|
139
|
+
* unregisters the provider; on absence returns undefined (a no-op, never a boot
|
|
140
|
+
* failure).
|
|
141
|
+
*/
|
|
142
|
+
export function registerRecursiveSkill(ctx: Context): (() => void) | undefined {
|
|
143
|
+
const skills = ctx.get('skills')
|
|
144
|
+
if (skills === undefined || skills === null) return undefined
|
|
145
|
+
// SAFETY: the single boundary cast asserts the live ctx.skills satisfies the
|
|
146
|
+
// structural SkillsRuntimeLike seam (registerProvider). The live registry's
|
|
147
|
+
// method is a superset of this seam; the provider we return is a plain owned
|
|
148
|
+
// object read through its own leaf fields, never serialized.
|
|
149
|
+
const registry = skills as SkillsRuntimeLike
|
|
150
|
+
return registry.registerProvider(() => provider())
|
|
151
|
+
}
|