@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
|
@@ -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
|
-
|
|
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
|
-
//
|
|
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
|
|
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
|
+
}
|