@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.
@@ -0,0 +1,149 @@
1
+ /**
2
+ * goals-projection.ts (T1, STRENGTHENING-PLAN): project a recursive run into the
3
+ * native `goals` service so the run is a first-class durable, resumable,
4
+ * blockable object — and a gate block is durable + UI-visible rather than a
5
+ * one-line advisory.
6
+ *
7
+ * Pure/structural: this module takes a `GoalServiceLike` seam (the real
8
+ * `ctx.goals` satisfies it structurally) and an opaque `AgentHandle`, and never
9
+ * imports the host `@deepseek-ai/dsh-goal` package. The real service's methods
10
+ * take a live `Agent` and throw if the agent is not the registry's live
11
+ * instance, so callers pass the live agent from a tool/pre-step `exec`.
12
+ *
13
+ * Safety rule: the projection NEVER clobbers a foreign goal. A goal whose
14
+ * objective is not a `recursive-run:<id>` marker is left untouched (only a
15
+ * completed goal may be replaced, per the service contract).
16
+ */
17
+ import type { RunState } from './lifecycle.ts'
18
+
19
+ /** Native goal phase (mirrors @deepseek-ai/dsh-goal GoalPhase). */
20
+ export type GoalPhase = 'active' | 'paused' | 'blocked' | 'complete'
21
+
22
+ /** CSA identity for one exact goal revision. */
23
+ export interface GoalRefLike {
24
+ id: string
25
+ revision: number
26
+ }
27
+
28
+ /** Input resolved by the service when the round cap is omitted. */
29
+ export interface CreateGoalRequestLike {
30
+ objective: string
31
+ maxGoalRounds?: number
32
+ }
33
+
34
+ /** The subset of the goal view the projection reads. */
35
+ export interface GoalViewLike extends GoalRefLike {
36
+ objective?: string
37
+ phase?: GoalPhase
38
+ }
39
+
40
+ /** Opaque handle to the live DSH Agent; the projection never inspects it further. */
41
+ export interface AgentHandle {
42
+ session?: { header?: { cwd?: string } }
43
+ }
44
+
45
+ /** Structural seam for the live `goals` service (real methods return GoalView). */
46
+ export interface GoalServiceLike {
47
+ get(agent: AgentHandle): GoalViewLike | undefined
48
+ create(agent: AgentHandle, req: CreateGoalRequestLike): GoalViewLike
49
+ block(agent: AgentHandle, ref: GoalRefLike, reason: { code: string; message: string }): GoalViewLike
50
+ pause(agent: AgentHandle, ref: GoalRefLike): GoalViewLike
51
+ resume(agent: AgentHandle, ref: GoalRefLike): GoalViewLike
52
+ complete(agent: AgentHandle, ref: GoalRefLike): GoalViewLike
53
+ clear(agent: AgentHandle, ref: GoalRefLike): GoalRefLike
54
+ }
55
+
56
+ /** Outcome of a run→goal sync. */
57
+ export type SyncResult =
58
+ | { ok: true; phase: GoalPhase; ref?: GoalRefLike; created?: boolean }
59
+ | { ok: false; reason: string }
60
+
61
+ /** Marker embedded in the goal objective so a goal can be matched to its run. */
62
+ export function runGoalTag(runId: string): string {
63
+ return 'recursive-run:' + runId
64
+ }
65
+
66
+ /** The durable objective string for a run goal. */
67
+ export function goalObjective(runId: string, runState: RunState = 'active'): string {
68
+ return runGoalTag(runId) + ' · ' + runState
69
+ }
70
+
71
+ /** Map a run state onto the native goal phase it should project to. */
72
+ export const RUN_TO_GOAL_PHASE = {
73
+ new: 'active', active: 'active', paused: 'paused', blocked: 'blocked', complete: 'complete',
74
+ } as const satisfies Record<RunState, GoalPhase>
75
+
76
+ /** Is this goal's objective the marker for `runId`? */
77
+ export function isRunGoal(goal: GoalViewLike | undefined, runId: string): boolean {
78
+ return goal?.objective?.startsWith(runGoalTag(runId)) === true
79
+ }
80
+
81
+ /** Read a live ref (id + revision) for a goal. */
82
+ function refOf(goal: GoalViewLike): GoalRefLike {
83
+ return { id: goal.id, revision: goal.revision }
84
+ }
85
+
86
+ /** Commit a phase mutation; the real service returns a truthy GoalView on success. */
87
+ function mutatePhase(service: GoalServiceLike, agent: AgentHandle, ref: GoalRefLike, target: GoalPhase): boolean {
88
+ switch (target) {
89
+ case 'blocked': return !!service.block(agent, ref, { code: 'run-gate-block', message: 'recursive run gate block' })
90
+ case 'paused': return !!service.pause(agent, ref)
91
+ case 'complete': return !!service.complete(agent, ref)
92
+ case 'active': return !!service.resume(agent, ref)
93
+ }
94
+ }
95
+
96
+ /**
97
+ * Sync a run's durable goal to the requested phase. Safe: never touches a goal
98
+ * whose objective is not this run's marker, and never re-creates over a
99
+ * non-complete foreign goal.
100
+ */
101
+ export function syncRunGoal(service: GoalServiceLike | undefined | null, agent: AgentHandle, runId: string, runState: RunState): SyncResult {
102
+ if (!service) return { ok: false, reason: 'no goals service' }
103
+ const target = RUN_TO_GOAL_PHASE[runState]
104
+ const current = service.get(agent)
105
+
106
+ // 1. Existing goal for this run -> mutate to the requested phase (no-op at target).
107
+ if (current && isRunGoal(current, runId)) {
108
+ const phase = current.phase ?? 'active'
109
+ const ref = refOf(current)
110
+ if (phase === target) return { ok: true, phase: target, ref }
111
+ // A completed goal is final: the contract allows it to be REPLACED, not resumed.
112
+ if (phase === 'complete') {
113
+ const created = service.create(agent, { objective: goalObjective(runId, runState) })
114
+ return { ok: true, phase: target, ref: refOf(created), created: true }
115
+ }
116
+ const ok = mutatePhase(service, agent, ref, target)
117
+ return ok ? { ok: true, phase: target, ref } : { ok: false, reason: 'goal mutation failed' }
118
+ }
119
+
120
+ // 2. A completed goal may be replaced; every other current phase must be
121
+ // cleared or resumed instead. Never clobber a foreign goal.
122
+ if (current) {
123
+ if (current.phase === 'complete') {
124
+ const created = service.create(agent, { objective: goalObjective(runId, runState) })
125
+ return { ok: true, phase: target, ref: refOf(created), created: true }
126
+ }
127
+ return { ok: false, reason: 'a non-matching active goal exists (foreign goal not touched)' }
128
+ }
129
+
130
+ // 3. No current goal -> create and arm.
131
+ const created = service.create(agent, { objective: goalObjective(runId, runState) })
132
+ return { ok: true, phase: target, ref: refOf(created), created: true }
133
+ }
134
+
135
+ /** Block the current run goal (used on a gate-block). Never touches a foreign goal. */
136
+ export function blockRunGoal(service: GoalServiceLike | undefined | null, agent: AgentHandle, runId: string, reason: { code: string; message: string }): SyncResult {
137
+ if (!service) return { ok: false, reason: 'no goals service' }
138
+ const current = service.get(agent)
139
+ if (!current) return { ok: false, reason: 'no current goal to block' }
140
+ if (!isRunGoal(current, runId)) return { ok: false, reason: 'current goal is not for this run (foreign goal not touched)' }
141
+ const ref = refOf(current)
142
+ const ok = !!service.block(agent, ref, reason)
143
+ return ok ? { ok: true, phase: 'blocked', ref } : { ok: false, reason: 'goal block failed' }
144
+ }
145
+
146
+ /** Bridge a run's blocked goal back to active (used on a reopen). */
147
+ export function resumeRunGoal(service: GoalServiceLike | undefined | null, agent: AgentHandle, runId: string): SyncResult {
148
+ return syncRunGoal(service, agent, runId, 'active')
149
+ }
package/src/index.ts CHANGED
@@ -9,10 +9,13 @@ import { createRecursiveLockTool } from './recursive_lock.tool.ts'
9
9
  import { createRecursiveLintTool } from './recursive_lint.tool.ts'
10
10
  import { createRecursiveCloseoutTool } from './recursive_closeout.tool.ts'
11
11
  import { createRecursiveScratchTool } from './recursive_scratch.tool.ts'
12
- import { createRecursiveWorktreeTool } from './recursive_worktree.tool.ts'
12
+ import { createRecursiveWorktreeTool } from './recursive_worktree.tool.ts'
13
13
  import { createRecursivePhaseTool } from './recursive_phase.tool.ts'
14
+ import { createRecursiveAuditTeamTool } from './recursive_audit_team.tool.ts'
14
15
  import { registerRecursiveCommand } from './commands.ts'
15
- import { evaluateToolGuard } from './enforcement.ts'
16
+ import { evaluateToolGuard, coerceAskToDecision } from './enforcement.ts'
17
+ import type { GoalServiceLike } from './goals-projection.ts'
18
+ import type { TeamRuntimeLike } from './teams-loop.ts'
16
19
  import { renderRecursivePolicy } from './policy.ts'
17
20
  import { fsPolicyIntent } from './fs-intent.ts'
18
21
  import { snapshotWorkspace } from './snapshot.ts'
@@ -30,7 +33,7 @@ export { createRecursiveLockTool } from './recursive_lock.tool.ts'
30
33
  export { createRecursiveLintTool } from './recursive_lint.tool.ts'
31
34
  export { createRecursiveCloseoutTool } from './recursive_closeout.tool.ts'
32
35
  export { createRecursiveScratchTool } from './recursive_scratch.tool.ts'
33
- export { createRecursiveWorktreeTool } from './recursive_worktree.tool.ts'
36
+ export { createRecursiveWorktreeTool } from './recursive_worktree.tool.ts'
34
37
  export { createRecursivePhaseTool } from './recursive_phase.tool.ts'
35
38
  export * from './status.ts'
36
39
  export {
@@ -70,6 +73,7 @@ export * from './enforcement.ts'
70
73
  export * from './policy.ts'
71
74
  export * from './snapshot.ts'
72
75
  export * from './live-route.ts'
76
+ export * from './teams-loop.ts'
73
77
 
74
78
  /**
75
79
  * Bundle plugin entry. The Loader activates this row once `tools` is available
@@ -101,10 +105,26 @@ export function apply(ctx: Context, config?: { shellOnly?: boolean; repoRoot?: s
101
105
  // property access requires inject and would fail boot when undeclared.
102
106
  // Resolve the control-plane root strictly from the session agent's cwd.
103
107
  const workspaceRegistry = ctx.get('workspaceRegistry') as never
104
- const recursive = new RecursiveRuntime(ctx, { repoRoot: config?.repoRoot ?? process.cwd(), workspaceRegistry })
108
+ // T1 (goals projection): the goals service is on the host plane; it resolves
109
+ // from inside the recursive-realm via inheritance (same as workspaceRegistry).
110
+ // SAFETY: the goals service is an optional host service (could be absent); the
111
+ // run projection treats null as "no goal backing" and never throws.
112
+ const goals = ctx.get('goals') as GoalServiceLike | null
113
+ const recursive = new RecursiveRuntime(ctx, { repoRoot: config?.repoRoot ?? process.cwd(), workspaceRegistry, goals })
105
114
 
106
- const repairedRoots = new Set<string>()
115
+ const repairedRoots = new Set<string>()
107
116
  const reminderGate = new ReminderOnceGate()
117
+ // T3 (agentTeams task loop): wire the live ctx.agentTeams service (optional —
118
+ // absent in compositions without the experimental agent-team row) into the
119
+ // turn-driven task-board tool. The whole-loop driver (auditToPass) is also
120
+ // exported for callers with a settlement observer.
121
+ // SAFETY: ctx.get returns the live service as an opaque value; the single
122
+ // boundary cast asserts it satisfies the TeamRuntimeLike structural seam
123
+ // (createTask/updateTask plus optional wait/interrupt/board reads). The
124
+ // live service's real Agent parameter is a superset of TeamCallerHandle, so
125
+ // the seam passes the exact live Agent the tool extracts from exec.agent.
126
+ const agentTeams = ctx.get('agentTeams') as TeamRuntimeLike | undefined
127
+
108
128
  const disposers = [
109
129
  ctx.tools.register(createRecursiveStatusTool(recursive)),
110
130
  ctx.tools.register(createRecursiveInitTool(recursive)),
@@ -112,8 +132,9 @@ export function apply(ctx: Context, config?: { shellOnly?: boolean; repoRoot?: s
112
132
  ctx.tools.register(createRecursiveLintTool(recursive)),
113
133
  ctx.tools.register(createRecursiveCloseoutTool(recursive)),
114
134
  ctx.tools.register(createRecursiveScratchTool(recursive)),
115
- ctx.tools.register(createRecursiveWorktreeTool(recursive)),
135
+ ctx.tools.register(createRecursiveWorktreeTool(recursive)),
116
136
  ctx.tools.register(createRecursivePhaseTool(recursive)),
137
+ ...(agentTeams ? [ctx.tools.register(createRecursiveAuditTeamTool(agentTeams))] : []),
117
138
  ]
118
139
 
119
140
  // /recursive command (R4): preset-scoped registration, workspace-scoped dispatch.
@@ -167,7 +188,16 @@ export function apply(ctx: Context, config?: { shellOnly?: boolean; repoRoot?: s
167
188
  const decision = evaluateToolGuard(exec as never, root, '', recursive.enforcementConfig.toolGuards)
168
189
  if (decision.kind === 'allow') return typeof next === 'function' ? next() : { kind: 'allow' }
169
190
  if (decision.kind === 'deny') return decision
170
- // ask -> in advisory we let it through with a warn (the approval seam is Phase D)
191
+ // T6 (approval ask→policy bridge): an `ask` must never be a silent
192
+ // allow. Strict coerces to deny; advisory allows but carries a warn that
193
+ // the caller logs below. The approval seam is the follow-on (Phase D).
194
+ const coerced = coerceAskToDecision(decision, recursive.enforcementConfig.toolGuards)
195
+ if (coerced.kind === 'deny') return coerced
196
+ if (coerced.kind === 'allow' && coerced.warn) {
197
+ // Package-tagged host logging; never a silent pass under approval=never.
198
+ console.warn('[recursive] tool guard (advisory): ' + coerced.warn + ' — allowing')
199
+ }
200
+ return typeof next === 'function' ? next() : { kind: 'allow' }
171
201
  }))
172
202
  }
173
203
 
@@ -212,10 +242,10 @@ export function apply(ctx: Context, config?: { shellOnly?: boolean; repoRoot?: s
212
242
  const phasePath = join(runDir, phase)
213
243
  const status = existsSync(phasePath) ? getLockStatus(phasePath) : null
214
244
  if (status !== 'DRAFT') return { kind: 'enter', messages } as const
215
- if (!reminderGate.shouldInject(root, runId, phase)) return { kind: 'enter', messages } as const
216
- // LIVE BUG 6 (0.2.1): inject the lint-rules reminder AT MOST ONCE PER PHASE.
217
- // The scaffold repair above is deduped via repairedRoots; the reminder itself was
218
- // not, so every pre-step while DRAFT re-injected it.
245
+ if (!reminderGate.shouldInject(root, runId, phase)) return { kind: 'enter', messages } as const
246
+ // LIVE BUG 6 (0.2.1): inject the lint-rules reminder AT MOST ONCE PER PHASE.
247
+ // The scaffold repair above is deduped via repairedRoots; the reminder itself was
248
+ // not, so every pre-step while DRAFT re-injected it.
219
249
  const reminder = phaseLintRulesMessage(phase)
220
250
  return {
221
251
  kind: 'enter',
package/src/live-route.ts CHANGED
@@ -17,7 +17,10 @@
17
17
  * would be both wrong and a crash).
18
18
  */
19
19
  import type { IncomingMessage, ServerResponse } from 'node:http'
20
+ import { existsSync, readFileSync } from 'node:fs'
21
+ import { join, resolve, sep } from 'node:path'
20
22
  import type { RecursiveProjection } from './types.ts'
23
+ import { RUN_ARTIFACT_SEQUENCE } from './status.ts'
21
24
 
22
25
  /** API prefix the board/strip fetch. */
23
26
  export const RECURSIVE_API_PREFIX = '/.recursive/api'
@@ -65,8 +68,72 @@ function queryOf(req: IncomingMessage, name: string): string {
65
68
  return new URLSearchParams(url.slice(q + 1)).get(name) ?? ''
66
69
  }
67
70
 
71
+ /** Phase-doc basename allowlist (the run artifact sequence: single *.md, no subdirs). */
72
+ const PHASE_DOC_FILES = new Set(RUN_ARTIFACT_SEQUENCE)
73
+
74
+ /** runId/file safety: alphanumerics, dot, underscore, dash only; no '..', no separators. */
75
+ const DOC_SAFE_RE = /^[A-Za-z0-9._-]+$/
76
+
77
+ /** Invalid runId or file name (path traversal / subdir / non-phase doc) — reject. */
78
+ function docTargetError(runId: string, file: string): string | null {
79
+ if (!DOC_SAFE_RE.test(runId) || runId.includes('..')) return 'invalid runId'
80
+ if (!DOC_SAFE_RE.test(file) || file.includes('..') || file.includes('/') || file.includes('\\')) return 'invalid file'
81
+ if (!file.endsWith('.md')) return 'invalid file: must be a .md phase doc'
82
+ if (!PHASE_DOC_FILES.has(file)) return 'invalid file: not a phase doc'
83
+ return null
84
+ }
85
+
86
+ /**
87
+ * The lazy per-phase doc route (0.2.4): GET the raw markdown of one run phase
88
+ * doc, read ON DEMAND from the filesystem. NOT part of the /state or /events
89
+ * projection payloads (they stay fold-only). Same browser-marker tripwire as
90
+ * state/events; the client root is re-validated by the host (never trusted:
91
+ * an unknown root -> 400), then the resolved doc path is containment-checked
92
+ * under join(root, '.recursive', 'run', runId).
93
+ */
94
+ function docRoute(host: RecursiveRouteHost) {
95
+ return {
96
+ kind: 'exact' as const,
97
+ path: RECURSIVE_API_PREFIX + '/doc',
98
+ handler: async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
99
+ if (req.method !== 'GET') { res.writeHead(405); res.end(); return }
100
+ if (!browserMarker(req)) { res.writeHead(403); res.end(); return }
101
+ const root = queryOf(req, 'root')
102
+ const runId = queryOf(req, 'runId')
103
+ const file = queryOf(req, 'file')
104
+ const targetError = docTargetError(runId, file)
105
+ if (root === '' || targetError !== null) {
106
+ json(res, 400, { ok: false, error: targetError ?? 'missing root' })
107
+ return
108
+ }
109
+ // Root validation: the client passes back what the host already resolved.
110
+ // Re-resolve via the host and require a canonical match — never accept an
111
+ // arbitrary path (registry know-it or headless cwd pass-through).
112
+ const resolvedRoot = await host.resolveRoot(undefined, root)
113
+ if (resolvedRoot === null || resolve(resolvedRoot) !== resolve(root)) {
114
+ json(res, 400, { ok: false, error: 'root is not a known workspace' })
115
+ return
116
+ }
117
+ // Containment: the doc must stay under join(root, .recursive, run, runId).
118
+ const runBase = resolve(root, '.recursive', 'run', runId)
119
+ const docPath = resolve(runBase, file)
120
+ if (docPath === runBase || !docPath.startsWith(runBase + sep)) {
121
+ json(res, 400, { ok: false, error: 'doc path escapes the run dir' })
122
+ return
123
+ }
124
+ if (!existsSync(docPath)) {
125
+ json(res, 404, { ok: false, error: 'phase doc not found' })
126
+ return
127
+ }
128
+ const text = readFileSync(docPath, 'utf8')
129
+ res.writeHead(200, { 'content-type': 'text/markdown; charset=utf-8', 'cache-control': 'no-store' })
130
+ res.end(text)
131
+ },
132
+ }
133
+ }
134
+
68
135
  /**
69
- * Build the two read-only routes. Returns [state, events] in registration order.
136
+ * Build the read-only routes. Returns [state, events, doc] in registration order.
70
137
  * @param host - the resolved-root + fs-fold seam (the RecursiveRuntime adapter).
71
138
  */
72
139
  export function makeRecursiveRoutes(host: RecursiveRouteHost): readonly { kind: 'exact'; path: string; handler: (req: IncomingMessage, res: ServerResponse) => void | Promise<void> }[] {
@@ -109,7 +176,7 @@ export function makeRecursiveRoutes(host: RecursiveRouteHost): readonly { kind:
109
176
  await push()
110
177
  },
111
178
  }
112
- return [state, events]
179
+ return [state, events, docRoute(host)]
113
180
  }
114
181
 
115
182
  /**
@@ -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
+ }