@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.
@@ -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.start() with the full request (R4). Workspace-scoped: every
142
- * path resolves under the session's control-plane root.
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
+ }