@try-works/dsh-recursive-mode 0.3.0 → 0.4.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/README.md +959 -0
- package/lib/client.js +9 -2
- package/lib/closeout-report.d.ts +113 -0
- package/lib/closeout-standards.d.ts +35 -0
- package/lib/closeout.d.ts +12 -0
- package/lib/commands.d.ts +1 -1
- package/lib/config.d.ts +202 -0
- package/lib/delegation.d.ts +123 -3
- package/lib/enforcement.d.ts +90 -1
- package/lib/errors.d.ts +168 -0
- package/lib/git-context.d.ts +17 -0
- package/lib/guard-log.d.ts +39 -0
- package/lib/handoff.d.ts +29 -0
- package/lib/hooks.d.ts +103 -0
- package/lib/identity.d.ts +61 -0
- package/lib/index.d.ts +33 -12
- package/lib/index.js +10017 -3969
- package/lib/job-log.d.ts +34 -0
- package/lib/jobs-runner.d.ts +105 -0
- package/lib/json-safe.d.ts +33 -0
- package/lib/lock.d.ts +42 -0
- package/lib/memory-feedback.d.ts +52 -0
- package/lib/memory-select.d.ts +78 -0
- package/lib/memory.d.ts +137 -0
- package/lib/model-inventory.d.ts +106 -0
- package/lib/phase-graph.d.ts +111 -0
- package/lib/phase-rules.d.ts +67 -8
- package/lib/plan-gate.d.ts +68 -0
- package/lib/policy-globs.d.ts +222 -0
- package/lib/policy-write.d.ts +42 -0
- package/lib/policy.d.ts +39 -0
- package/lib/recursive_ask.tool.d.ts +88 -0
- package/lib/recursive_closeout.tool.d.ts +1 -1
- package/lib/recursive_delegate.tool.d.ts +22 -0
- package/lib/recursive_preview.tool.d.ts +48 -0
- package/lib/recursive_review.tool.d.ts +28 -0
- package/lib/result-cap.d.ts +70 -0
- package/lib/review-round.d.ts +82 -0
- package/lib/review.d.ts +9 -0
- package/lib/role-route.d.ts +122 -0
- package/lib/router.d.ts +90 -5
- package/lib/runtime.d.ts +252 -12
- package/lib/settlement.d.ts +132 -0
- package/lib/skills-phase.d.ts +71 -0
- package/lib/skills.d.ts +70 -0
- package/lib/status.d.ts +53 -1
- package/lib/teams-loop.d.ts +91 -2
- package/lib/training.d.ts +211 -0
- package/lib/ts-lint.d.ts +15 -0
- package/lib/types.d.ts +48 -0
- package/lib/workflow-audit.d.ts +207 -0
- package/package.json +31 -31
- package/preset/recursive.patch.yml +312 -0
- package/scripts/e2e-run.mjs +51 -0
- package/scripts/link-dsh.mjs +233 -0
- package/scripts/live/fake-llm.mjs +150 -0
- package/scripts/live-session-plugin.mjs +179 -0
- package/scripts/live-session-stock.mjs +106 -0
- package/scripts/live-session.mjs +139 -0
- package/skills/recursive-mode/SKILL.md +66 -0
- package/src/client/derive.ts +18 -2
- package/src/closeout-report.ts +274 -0
- package/src/closeout-standards.ts +102 -0
- package/src/closeout.ts +39 -2
- package/src/commands.ts +116 -4
- package/src/config.ts +113 -0
- package/src/delegation.ts +336 -18
- package/src/enforcement.ts +262 -72
- package/src/errors.ts +197 -0
- package/src/git-context.ts +33 -2
- package/src/guard-log.ts +134 -0
- package/src/handoff.ts +62 -0
- package/src/hooks.ts +316 -0
- package/src/identity.ts +230 -0
- package/src/index.ts +394 -20
- package/src/job-log.ts +112 -0
- package/src/jobs-runner.ts +222 -0
- package/src/json-safe.ts +75 -0
- package/src/lock.ts +153 -16
- package/src/memory-feedback.ts +185 -0
- package/src/memory-select.ts +187 -0
- package/src/memory.ts +309 -0
- package/src/model-inventory.ts +196 -0
- package/src/phase-graph.ts +191 -0
- package/src/phase-rules.ts +236 -0
- package/src/plan-gate.ts +111 -0
- package/src/policy-globs.ts +636 -0
- package/src/policy-write.ts +210 -0
- package/src/policy.ts +70 -5
- package/src/recursive_ask.tool.ts +276 -0
- package/src/recursive_audit_team.tool.ts +7 -3
- package/src/recursive_closeout.tool.ts +36 -35
- package/src/recursive_delegate.tool.ts +194 -0
- package/src/recursive_init.tool.ts +4 -3
- package/src/recursive_lint.tool.ts +81 -6
- package/src/recursive_lock.tool.ts +21 -4
- package/src/recursive_phase.tool.ts +3 -2
- package/src/recursive_preview.tool.ts +142 -0
- package/src/recursive_review.tool.ts +190 -0
- package/src/recursive_scratch.tool.ts +5 -4
- package/src/recursive_status.tool.ts +3 -2
- package/src/recursive_worktree.tool.ts +6 -5
- package/src/result-cap.ts +130 -0
- package/src/review-round.ts +335 -0
- package/src/review.ts +17 -3
- package/src/role-route.ts +230 -0
- package/src/router.ts +128 -2
- package/src/runtime.ts +968 -39
- package/src/settlement.ts +355 -0
- package/src/skills-phase.ts +143 -0
- package/src/skills.ts +151 -0
- package/src/snapshot.ts +39 -8
- package/src/status.ts +209 -4
- package/src/teams-loop.ts +223 -9
- package/src/training.ts +565 -0
- package/src/ts-lint.ts +38 -4
- package/src/types.ts +51 -0
- package/src/workflow-audit.ts +288 -0
- package/scripts/install-preset.cmd +0 -7
- package/scripts/install-preset.js +0 -101
|
@@ -1,35 +1,36 @@
|
|
|
1
|
-
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
2
|
-
import
|
|
3
|
-
import type {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* workspace
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
}
|
|
1
|
+
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
2
|
+
import { toolError } from './errors.ts'
|
|
3
|
+
import type { JsonValue } from '@deepseek-ai/dsh-util-values'
|
|
4
|
+
import type { RecursiveRuntime } from './runtime.ts'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* `recursive_closeout` — scaffold a Phase 0-8 closeout receipt under the
|
|
8
|
+
* SESSION's workspace only (R1 workspace-scoping invariant). The run is resolved
|
|
9
|
+
* via the session agent's cwd -> workspace registry; a runId outside the current
|
|
10
|
+
* workspace is rejected.
|
|
11
|
+
*/
|
|
12
|
+
export function createRecursiveCloseoutTool(recursive: RecursiveRuntime) {
|
|
13
|
+
return defineTool({
|
|
14
|
+
name: 'recursive_closeout',
|
|
15
|
+
description: 'REPORT what a closeout phase artifact is missing (Phase 4-8): it reads the artifact, lists the required sections and gates that are absent, and records a closeout receipt of its own. It NEVER writes the phase document. For a run in the CURRENT session workspace only. Workspace-scoped: refuses runIds outside the session\'s workspace. Delegates to the RecursiveRuntime service.',
|
|
16
|
+
parameters: {
|
|
17
|
+
phase: { type: 'string', description: 'Closeout phase to report on: 04, 05, 06, 07 or 08 (the same phase keys recursive_status prints). Phase 08 additionally fires the training trigger when it has been closed out before.' },
|
|
18
|
+
runId: { type: 'string', description: 'Run id (e.g. 03-something). Required. Must resolve inside the current workspace.' },
|
|
19
|
+
},
|
|
20
|
+
output: {
|
|
21
|
+
schema: { type: 'json' },
|
|
22
|
+
render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 2) }],
|
|
23
|
+
},
|
|
24
|
+
async execute(args: { phase?: string; runId?: string }, exec) {
|
|
25
|
+
if (!args.phase || !args.runId || args.runId.trim() === '') {
|
|
26
|
+
return { error: toolError('MISSING_PHASE_AND_RUN') } as const
|
|
27
|
+
}
|
|
28
|
+
const root = await recursive.resolveWorkspaceRoot(exec.agent)
|
|
29
|
+
if (!root) {
|
|
30
|
+
return { error: toolError('NO_WORKSPACE') } as const
|
|
31
|
+
}
|
|
32
|
+
const result = await recursive.closeoutRun(root, args.runId.trim(), args.phase.trim(), exec.agent as { session?: { header?: { cwd?: string } } } | null)
|
|
33
|
+
return result as unknown as JsonValue
|
|
34
|
+
},
|
|
35
|
+
})
|
|
36
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `recursive_delegate` — FU-17: the reachable caller that lets the main agent hand a phase's ACTUAL WORK to a
|
|
3
|
+
* child, read what came back, and send feedback to the SAME child so it repairs with its working set intact.
|
|
4
|
+
*
|
|
5
|
+
* ⚠ WHY THIS IS NOT `recursive_review`, and the difference is the whole point. A review JUDGES an artifact that
|
|
6
|
+
* exists. Work PRODUCES it. Three consequences follow, and each of them is a defect if ignored:
|
|
7
|
+
*
|
|
8
|
+
* 1. THIS TOOL DOES NOT REQUIRE THE ARTIFACT TO EXIST. Phase 01 as-is is delegated BEFORE its artifact does —
|
|
9
|
+
* the work is what creates it — so requiring the file would make the tool refuse exactly the case it is for.
|
|
10
|
+
* 2. THE CHILD IS BRIEFED ON THE STANDARD, NOT ON ANTI-PATTERNS. `buildWorkSlice` composes the required
|
|
11
|
+
* sections from the phase rules, so the child is told what its output will be linted against. A worker that
|
|
12
|
+
* has never seen the gate cannot meet it.
|
|
13
|
+
* 3. THE CHILD IS GIVEN A WRITE-CAPABLE FILTER. `delegateReview` defaults to
|
|
14
|
+
* `{ allow: ['fs_read', 'grep', 'glob'] }` — correct for a reviewer, fatal for a worker, and SILENT either
|
|
15
|
+
* way: a worker with a read-only filter reports a perfectly healthy delegation while being unable to write a
|
|
16
|
+
* single file. See `WORK_TOOL_FILTER` for why the override is a deny list.
|
|
17
|
+
*
|
|
18
|
+
* ⚠ THE PARENT KEEPS THE DECISION. This tool starts work and reports what came back; it never writes the phase
|
|
19
|
+
* artifact itself and never locks anything. The main agent reads the reply, verifies it, and either accepts it —
|
|
20
|
+
* recording that in `## Subagent Contribution Verification` — or calls this tool again with `feedback`, which
|
|
21
|
+
* goes to the same child. That division is what the phase contract is built on.
|
|
22
|
+
*
|
|
23
|
+
* ⚠ ONE TRANSITION PER CALL, like `recursive_review` and `recursive_audit_team`: the harness has no parent-side
|
|
24
|
+
* await-settlement promise, so this cannot be one call that waits for a deliverable that arrives on a later turn.
|
|
25
|
+
* The durable state carries the child id between calls.
|
|
26
|
+
*/
|
|
27
|
+
import { randomUUID } from 'node:crypto'
|
|
28
|
+
import { join } from 'node:path'
|
|
29
|
+
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
30
|
+
import type { JsonValue } from '@deepseek-ai/dsh-util-values'
|
|
31
|
+
import { toLosslessJson } from './json-safe.ts'
|
|
32
|
+
import type { RecursiveRuntime } from './runtime.ts'
|
|
33
|
+
import { codeRuntimeRefusal, toolError } from './errors.ts'
|
|
34
|
+
import { buildWorkSlice } from './handoff.ts'
|
|
35
|
+
import { advanceReview } from './review-round.ts'
|
|
36
|
+
import { settlementRoundObserver } from './settlement.ts'
|
|
37
|
+
import { CURRENT_WORKFLOW_PROFILE, getArtifactRequiredSections } from './phase-rules.ts'
|
|
38
|
+
import type { SubagentParentHandle, SubagentsRuntimeLike } from './delegation.ts'
|
|
39
|
+
import { readReplyText, toContinuable } from './recursive_review.tool.ts'
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* ⚠ FU-17 — THE WORK TOOL FILTER, AND WHY IT IS A DENY LIST RATHER THAN AN ALLOW LIST.
|
|
43
|
+
*
|
|
44
|
+
* `delegateReview` applies `input.toolFilter ?? defaultReviewToolFilter()` and that default is
|
|
45
|
+
* `{ allow: ['fs_read', 'grep', 'glob'] }`. So a work delegation that passes nothing receives a child that
|
|
46
|
+
* cannot write — and nothing about the outcome would say so.
|
|
47
|
+
*
|
|
48
|
+
* The obvious fix is an allow list of the writing tools, and it is the wrong one: it has to name EVERY tool a
|
|
49
|
+
* worker might need (`bash`, `pwsh`, `str_replace_editor`, `session_search`, `session_event_read`, `skill`,
|
|
50
|
+
* `load_workspace_dependencies`, `present`, …) and it SILENTLY REMOVES capability for each name forgotten.
|
|
51
|
+
* That is the same defect class this project has already fixed ten times: a surface that cannot express what
|
|
52
|
+
* the system can do, failing quietly.
|
|
53
|
+
*
|
|
54
|
+
* `deny` states the actual posture instead: a delegated worker may use everything EXCEPT spawning its own
|
|
55
|
+
* children and driving the team board. That is what the workflow wants — no recursive fan-out from a worker and
|
|
56
|
+
* no contention over the audit board — and it cannot lose a tool by omission. The host accepts `allow` and/or
|
|
57
|
+
* `deny` (`subagent/src/descriptor.ts`), throwing only when neither is declared.
|
|
58
|
+
*/
|
|
59
|
+
export const WORK_TOOL_FILTER: unknown = {
|
|
60
|
+
deny: ['spawn_teammate', 'team_task_create', 'team_task_update', 'interrupt_agent'],
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function createRecursiveDelegateTool(recursive: RecursiveRuntime, subagents?: SubagentsRuntimeLike) {
|
|
64
|
+
return defineTool({
|
|
65
|
+
name: 'recursive_delegate',
|
|
66
|
+
description:
|
|
67
|
+
'Delegate the WORK of a phase to a durable continuable subagent: it produces the content and writes its '
|
|
68
|
+
+ 'submission to reply.md, and YOU remain the judge. Unlike recursive_review this does NOT require the '
|
|
69
|
+
+ 'artifact to exist — delegating the work for a phase whose artifact is empty or absent is the normal '
|
|
70
|
+
+ 'case. It reports `submitted` when the child delivered, `reviewing` while it is still working, and '
|
|
71
|
+
+ '`unavailable` when no continuable child could be used. When the work is not good enough, call this tool '
|
|
72
|
+
+ 'again with `feedback`: it is delivered to the SAME child, which repairs with its context intact. It '
|
|
73
|
+
+ 'never writes the artifact and never locks anything.',
|
|
74
|
+
parameters: {
|
|
75
|
+
runId: { type: 'string', description: 'Optional run id (defaults to the latest run by mtime).' },
|
|
76
|
+
phase: { type: 'string', description: 'Phase key whose work is being delegated (e.g. 03). Defaults to the run\'s current phase.' },
|
|
77
|
+
role: { type: 'string', description: 'Worker role to route (default: analyst). Must be enabled in the router policy, or the router falls back to self-audit and says so.' },
|
|
78
|
+
instruction: { type: 'string', description: 'What the child must produce, in your words. Required to start work; omit it when only resuming or sending feedback.' },
|
|
79
|
+
feedback: { type: 'string', description: 'What is wrong with the submitted work. Delivered to the SAME child, which repairs it.' },
|
|
80
|
+
},
|
|
81
|
+
output: {
|
|
82
|
+
schema: { type: 'json' },
|
|
83
|
+
render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 2) }],
|
|
84
|
+
},
|
|
85
|
+
async execute(
|
|
86
|
+
args: { runId?: string; phase?: string; role?: string; instruction?: string; feedback?: string },
|
|
87
|
+
exec,
|
|
88
|
+
) {
|
|
89
|
+
try {
|
|
90
|
+
const root = await recursive.resolveWorkspaceRoot(exec.agent)
|
|
91
|
+
if (!root) return { error: toolError('NO_WORKSPACE') } as const
|
|
92
|
+
|
|
93
|
+
const status = await recursive.status(args.runId, exec.agent)
|
|
94
|
+
if (!status) return { error: toolError('NO_RUN') } as const
|
|
95
|
+
const runId = status.runId
|
|
96
|
+
|
|
97
|
+
const phase = args.phase?.trim() || status.currentPhase?.key || ''
|
|
98
|
+
if (phase === '') return { error: toolError('NO_PHASE') } as const
|
|
99
|
+
const row = status.phases.find((p) => p.key === phase)
|
|
100
|
+
// ⚠ NO `row.exists` CHECK — and that is deliberate, not an omission. A review needs an artifact to judge;
|
|
101
|
+
// work is often what CREATES one. The FILE NAME is what this tool needs, and the status row carries it
|
|
102
|
+
// whether or not the file is on disk yet.
|
|
103
|
+
if (row === undefined) {
|
|
104
|
+
return { error: toolError('NO_PHASE', 'unknown phase ' + phase + ' for this run') } as const
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const running = args.feedback === undefined || args.feedback.trim() === ''
|
|
108
|
+
// ⚠ NO REFUSAL FOR A MISSING INSTRUCTION, and this is a design choice rather than laziness. The brief
|
|
109
|
+
// already carries the real requirement — the phase's required sections, composed from the phase rules —
|
|
110
|
+
// so a caller with nothing extra to say still gets a child that knows what to produce. Inventing a new
|
|
111
|
+
// refusal code would also mean a new registry entry (`TOOL_ERRORS`, whose codes are grouped and asserted
|
|
112
|
+
// unique), for a case that is better served by a default than by an error.
|
|
113
|
+
const instruction = (args.instruction ?? '').trim() !== ''
|
|
114
|
+
? (args.instruction ?? '').trim()
|
|
115
|
+
: 'Produce the content this phase\'s artifact requires, following the required sections in your brief.'
|
|
116
|
+
void running
|
|
117
|
+
|
|
118
|
+
const role = args.role?.trim() || 'analyst'
|
|
119
|
+
const delegationId = phase + '-work'
|
|
120
|
+
const runDir = join(root, '.recursive', 'run', runId)
|
|
121
|
+
const artifactPath = join(runDir, row.file)
|
|
122
|
+
const parent = exec.agent as unknown as SubagentParentHandle
|
|
123
|
+
|
|
124
|
+
const outcome = await advanceReview({
|
|
125
|
+
runDir,
|
|
126
|
+
delegationId,
|
|
127
|
+
phase,
|
|
128
|
+
role,
|
|
129
|
+
// ⚠ FU-17 — THE KIND IS WHAT SEPARATES A DELIVERABLE FROM A VERDICT. A work round must not have an
|
|
130
|
+
// APPROVE read out of prose that was never a verdict.
|
|
131
|
+
kind: 'work',
|
|
132
|
+
// The main agent's feedback, when it has any: delivered to the SAME child by the driver.
|
|
133
|
+
...(args.feedback === undefined ? {} : { instruction: args.feedback }),
|
|
134
|
+
delegate: async ({ resumeChild }) => {
|
|
135
|
+
// A resumed round keeps the child it was given; a fresh round reserves an id up front so the
|
|
136
|
+
// brief/prompt paths and the session agree from the first call (`ctx.agents.create` accepts a
|
|
137
|
+
// reserved id).
|
|
138
|
+
const childId = resumeChild ?? randomUUID()
|
|
139
|
+
// ⚠ FU-18 — AND THE RUNTIME IS TOLD IT IS A CONTINUATION. Without this the delegation carries the
|
|
140
|
+
// same operation identity as the round that already ran, the T19 guard sees an accepted operation
|
|
141
|
+
// against an unchanged artifact and REFUSES — which is precisely the call that starts a repair.
|
|
142
|
+
const continuing = resumeChild !== undefined
|
|
143
|
+
const work = await recursive.delegateReview({
|
|
144
|
+
root,
|
|
145
|
+
runId,
|
|
146
|
+
phase,
|
|
147
|
+
role,
|
|
148
|
+
delegationId,
|
|
149
|
+
childId,
|
|
150
|
+
artifactPath,
|
|
151
|
+
upstreamArtifacts: [],
|
|
152
|
+
auditQuestions: [],
|
|
153
|
+
requiredOutput: 'Write your submission to reply.md, then call the report tool citing reply.md.',
|
|
154
|
+
// ⚠ THE BRIEF IS THE PHASE STANDARD, composed from the same source the linter reads. Building a
|
|
155
|
+
// second copy here is how a brief and a linter drift apart. On a RESUME the child already has its
|
|
156
|
+
// brief, so the slice is omitted rather than rewritten.
|
|
157
|
+
...(resumeChild === undefined
|
|
158
|
+
? {
|
|
159
|
+
slice: buildWorkSlice({
|
|
160
|
+
instruction,
|
|
161
|
+
artifactFile: row.file,
|
|
162
|
+
phase,
|
|
163
|
+
requiredSections: getArtifactRequiredSections(row.file, CURRENT_WORKFLOW_PROFILE),
|
|
164
|
+
}),
|
|
165
|
+
}
|
|
166
|
+
: {}),
|
|
167
|
+
// ⚠ AND THE CHILD MAY WRITE. Without this the worker is a reviewer with a read-only filter.
|
|
168
|
+
toolFilter: WORK_TOOL_FILTER,
|
|
169
|
+
subagents,
|
|
170
|
+
parent,
|
|
171
|
+
mode: 'continuable',
|
|
172
|
+
kind: 'work',
|
|
173
|
+
continuing,
|
|
174
|
+
awaitRoundResult: settlementRoundObserver(runDir),
|
|
175
|
+
})
|
|
176
|
+
return toContinuable(work)
|
|
177
|
+
},
|
|
178
|
+
readReply: (childId) => readReplyText(root, runId, delegationId, childId),
|
|
179
|
+
sendRepair: async (childId, repair) => {
|
|
180
|
+
const followup = subagents?.followup
|
|
181
|
+
if (followup === undefined) throw new Error('no continuable followup seam')
|
|
182
|
+
await followup(parent, childId, [{ type: 'text', text: repair }], {
|
|
183
|
+
source: { kind: 'coordinator', form: 'relay', senderSessionId: parent?.id ?? '' },
|
|
184
|
+
})
|
|
185
|
+
},
|
|
186
|
+
})
|
|
187
|
+
const projected = toLosslessJson(outcome)
|
|
188
|
+
return (projected ?? { error: codeRuntimeRefusal('the delegation produced no JSON-representable result') }) as JsonValue
|
|
189
|
+
} catch (err) {
|
|
190
|
+
return { error: codeRuntimeRefusal(err instanceof Error ? err.message : String(err)) } as const
|
|
191
|
+
}
|
|
192
|
+
},
|
|
193
|
+
})
|
|
194
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
2
|
-
import
|
|
2
|
+
import { codeRuntimeRefusal, toolError } from './errors.ts'
|
|
3
|
+
import type { JsonValue } from '@deepseek-ai/dsh-util-values'
|
|
3
4
|
import type { RecursiveRuntime } from './runtime.ts'
|
|
4
5
|
|
|
5
6
|
export function createRecursiveInitTool(recursive: RecursiveRuntime) {
|
|
@@ -17,7 +18,7 @@ export function createRecursiveInitTool(recursive: RecursiveRuntime) {
|
|
|
17
18
|
},
|
|
18
19
|
async execute(args: { runId?: string; createWorktree?: boolean; baseBranch?: string }, exec) {
|
|
19
20
|
if (!args.runId || args.runId.trim() === '') {
|
|
20
|
-
return { error: '
|
|
21
|
+
return { error: toolError('MISSING_RUN_ID') } as const
|
|
21
22
|
}
|
|
22
23
|
try {
|
|
23
24
|
const result = await recursive.initRun(
|
|
@@ -27,7 +28,7 @@ export function createRecursiveInitTool(recursive: RecursiveRuntime) {
|
|
|
27
28
|
)
|
|
28
29
|
return result as unknown as JsonValue
|
|
29
30
|
} catch (err) {
|
|
30
|
-
return { error: err instanceof Error ? err.message : String(err) } as const
|
|
31
|
+
return { error: codeRuntimeRefusal(err instanceof Error ? err.message : String(err)) } as const
|
|
31
32
|
}
|
|
32
33
|
},
|
|
33
34
|
})
|
|
@@ -1,28 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* T24 — bounded, coded results and refusals for `recursive_lint`.
|
|
3
|
+
*
|
|
4
|
+
* Two contracts are enforced here:
|
|
5
|
+
*
|
|
6
|
+
* 1. THE RESULT IS BOUNDED. `errors`/`warnings` are clipped to a named cap and
|
|
7
|
+
* every clip is reported through `elided[]` (`src/result-cap.ts`), so a
|
|
8
|
+
* reader can always tell a complete result from a clipped one. `mode`
|
|
9
|
+
* chooses the budget: `full` (default, up to MAX_FINDINGS_FULL per list) or
|
|
10
|
+
* `summary` (a few findings plus the true totals, for a turn that only needs
|
|
11
|
+
* to know whether the artifact passes).
|
|
12
|
+
*
|
|
13
|
+
* 2. EVERY REFUSAL IS CODED AND ROUTED. Errors come from `src/errors.ts`, so a
|
|
14
|
+
* failure is one greppable sentence naming the call that resolves it. A
|
|
15
|
+
* runtime refusal is WRAPPED rather than passed through raw: a bare thrown
|
|
16
|
+
* message carries no stable code for a consumer to branch on, and the
|
|
17
|
+
* wrapper preserves the original sentence as its detail.
|
|
18
|
+
*/
|
|
1
19
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
2
|
-
import type { JsonValue } from '@deepseek-ai/dsh-
|
|
20
|
+
import type { JsonValue } from '@deepseek-ai/dsh-util-values'
|
|
3
21
|
import type { RecursiveRuntime } from './runtime.ts'
|
|
22
|
+
import { codeRuntimeRefusal, toolError } from './errors.ts'
|
|
23
|
+
import { DEFAULT_BUDGETS } from './enforcement.ts'
|
|
24
|
+
import { MAX_FINDINGS_FULL, MAX_FINDINGS_SUMMARY, elideFindings, capPayloadBytes, type ElisionMeta } from './result-cap.ts'
|
|
25
|
+
|
|
26
|
+
type LintMode = 'summary' | 'full'
|
|
27
|
+
|
|
28
|
+
/** The wire shape this tool returns (bounded by construction). */
|
|
29
|
+
interface CappedLintResult {
|
|
30
|
+
artifact: string
|
|
31
|
+
runId: string
|
|
32
|
+
passed: boolean
|
|
33
|
+
mode: LintMode
|
|
34
|
+
/** Totals BEFORE clipping — a reader must be able to see the real size. */
|
|
35
|
+
failCount: number
|
|
36
|
+
warnCount: number
|
|
37
|
+
errors: string[]
|
|
38
|
+
warnings: string[]
|
|
39
|
+
/** Empty when nothing was clipped; otherwise one marker per clipped list. */
|
|
40
|
+
elided: ElisionMeta[]
|
|
41
|
+
}
|
|
4
42
|
|
|
5
43
|
export function createRecursiveLintTool(recursive: RecursiveRuntime) {
|
|
6
44
|
return defineTool({
|
|
7
45
|
name: 'recursive_lint',
|
|
8
|
-
description: 'Lint a recursive-mode run artifact for phase-specific issues (gates, TODO, traceability, diff audit). Delegates to the RecursiveRuntime service.',
|
|
46
|
+
description: 'Lint a recursive-mode run artifact for phase-specific issues (gates, TODO, traceability, diff audit). Delegates to the RecursiveRuntime service. The result is bounded: when findings are clipped, `elided` says how many and how to see the rest.',
|
|
9
47
|
parameters: {
|
|
10
48
|
runId: { type: 'string', description: 'Run id. Required.' },
|
|
11
49
|
artifact: { type: 'string', description: 'Optional artifact file name; defaults to the current phase.' },
|
|
50
|
+
mode: { type: 'string', description: "Result budget: 'full' (default, up to 200 findings per list) or 'summary' (a few findings plus the true totals)." },
|
|
12
51
|
},
|
|
13
52
|
output: {
|
|
14
53
|
schema: { type: 'json' },
|
|
15
54
|
render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 2) }],
|
|
16
55
|
},
|
|
17
|
-
async execute(args: { runId?: string; artifact?: string }, exec) {
|
|
56
|
+
async execute(args: { runId?: string; artifact?: string; mode?: string }, exec) {
|
|
18
57
|
if (!args.runId || args.runId.trim() === '') {
|
|
19
|
-
return { error: '
|
|
58
|
+
return { error: toolError('MISSING_RUN_ID') } as const
|
|
20
59
|
}
|
|
60
|
+
const mode: LintMode = args.mode === 'summary' ? 'summary' : 'full'
|
|
61
|
+
const cap = mode === 'summary' ? MAX_FINDINGS_SUMMARY : MAX_FINDINGS_FULL
|
|
21
62
|
try {
|
|
22
63
|
const result = await recursive.lintArtifact(args.runId.trim(), args.artifact?.trim(), exec.agent as { session?: { header?: { cwd?: string } } } | null)
|
|
23
|
-
|
|
64
|
+
const raw = result as unknown as { artifact?: string; runId?: string; passed?: boolean; errors?: string[]; warnings?: string[] }
|
|
65
|
+
const sourceErrors = raw.errors ?? []
|
|
66
|
+
const sourceWarnings = raw.warnings ?? []
|
|
67
|
+
const errors = elideFindings('errors', sourceErrors, cap)
|
|
68
|
+
const warnings = elideFindings('warnings', sourceWarnings, cap)
|
|
69
|
+
const elided: ElisionMeta[] = []
|
|
70
|
+
if (errors.meta) elided.push(errors.meta)
|
|
71
|
+
if (warnings.meta) elided.push(warnings.meta)
|
|
72
|
+
const bounded: CappedLintResult = {
|
|
73
|
+
artifact: String(raw.artifact ?? args.artifact ?? ''),
|
|
74
|
+
runId: String(raw.runId ?? args.runId.trim()),
|
|
75
|
+
passed: Boolean(raw.passed),
|
|
76
|
+
mode,
|
|
77
|
+
// Totals, never the clipped length: a reader must see the real size.
|
|
78
|
+
failCount: sourceErrors.length,
|
|
79
|
+
warnCount: sourceWarnings.length,
|
|
80
|
+
errors: errors.kept,
|
|
81
|
+
warnings: warnings.kept,
|
|
82
|
+
elided,
|
|
83
|
+
}
|
|
84
|
+
// T28: the BYTE budget, applied after the count budget. T24's cap bounds how
|
|
85
|
+
// MANY findings come back and cannot see size, so one enormous finding passes
|
|
86
|
+
// it; this bounds the serialized payload. Both report through `elided[]`, so a
|
|
87
|
+
// reader can always tell a complete result from a clipped one.
|
|
88
|
+
// T28: the byte budget comes from the config, so it is reviewable and
|
|
89
|
+
// configurable rather than another hardcoded constant.
|
|
90
|
+
//
|
|
91
|
+
// Read DEFENSIVELY: this tool only needs `lintArtifact`, and a caller that
|
|
92
|
+
// supplies a narrower runtime (a test stub, a host that mounts the tool
|
|
93
|
+
// without the full service) must still get a bounded result. An unavailable
|
|
94
|
+
// config falls back to the documented default instead of throwing — the cap
|
|
95
|
+
// is a safety bound, and a safety bound that can be disabled by an absent
|
|
96
|
+
// config is not one.
|
|
97
|
+
const maxResultBytes = recursive.enforcementConfig?.budgets?.maxResultBytes ?? DEFAULT_BUDGETS.maxResultBytes
|
|
98
|
+
return capPayloadBytes(bounded, maxResultBytes) as unknown as JsonValue
|
|
24
99
|
} catch (err) {
|
|
25
|
-
return { error: err instanceof Error ? err.message : String(err) } as const
|
|
100
|
+
return { error: codeRuntimeRefusal(err instanceof Error ? err.message : String(err)) } as const
|
|
26
101
|
}
|
|
27
102
|
},
|
|
28
103
|
})
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
2
|
-
import
|
|
2
|
+
import { codeRuntimeRefusal, toolError } from './errors.ts'
|
|
3
|
+
import { buildAskQuestion } from './recursive_ask.tool.ts'
|
|
4
|
+
import type { JsonValue } from '@deepseek-ai/dsh-util-values'
|
|
3
5
|
import type { RecursiveRuntime } from './runtime.ts'
|
|
4
6
|
|
|
5
7
|
export function createRecursiveLockTool(recursive: RecursiveRuntime) {
|
|
@@ -17,16 +19,31 @@ export function createRecursiveLockTool(recursive: RecursiveRuntime) {
|
|
|
17
19
|
},
|
|
18
20
|
async execute(args: { runId?: string; artifact?: string; reopen?: boolean }, exec) {
|
|
19
21
|
if (!args.runId || args.runId.trim() === '') {
|
|
20
|
-
return { error: '
|
|
22
|
+
return { error: toolError('MISSING_RUN_ID') } as const
|
|
21
23
|
}
|
|
22
24
|
if (!args.artifact || args.artifact.trim() === '') {
|
|
23
|
-
return { error: '
|
|
25
|
+
return { error: toolError('MISSING_ARTIFACT') } as const
|
|
24
26
|
}
|
|
25
27
|
try {
|
|
26
28
|
const result = await recursive.lockArtifact(args.runId.trim(), args.artifact.trim(), args.reopen === true, exec.agent as { session?: { header?: { cwd?: string } } } | null)
|
|
27
29
|
return result as unknown as JsonValue
|
|
28
30
|
} catch (err) {
|
|
29
|
-
|
|
31
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
32
|
+
const refusal = codeRuntimeRefusal(message)
|
|
33
|
+
// FU-7 — THE GATE-BLOCK CALL POINT, and this is the moment it belongs to: a lock was REFUSED, so
|
|
34
|
+
// the run is blocked and a person has to choose how to unblock it. Returning only the sentence
|
|
35
|
+
// makes the caller parse prose to find the decision; the options are what turn it into a choice.
|
|
36
|
+
//
|
|
37
|
+
// ⚠ IT IS ATTACHED TO THE ORDERING REFUSAL SPECIFICALLY (`Prerequisite blockers:`), because that
|
|
38
|
+
// is the one a human resolves. A missing run id or an already-locked artifact is a caller mistake
|
|
39
|
+
// with a mechanical fix, and offering "reopen / abandon the run" for those would be noise.
|
|
40
|
+
if (message.startsWith('Prerequisite blockers:')) {
|
|
41
|
+
return {
|
|
42
|
+
error: refusal,
|
|
43
|
+
ask: { gate: 'gate-block', ...buildAskQuestion('gate-block'), artifact: args.artifact ?? '', blocked: message },
|
|
44
|
+
} as unknown as JsonValue
|
|
45
|
+
}
|
|
46
|
+
return { error: refusal } as const
|
|
30
47
|
}
|
|
31
48
|
},
|
|
32
49
|
})
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
2
|
-
import
|
|
2
|
+
import { toolError } from './errors.ts'
|
|
3
|
+
import type { JsonValue } from '@deepseek-ai/dsh-util-values'
|
|
3
4
|
import type { RecursiveRuntime } from './runtime.ts'
|
|
4
5
|
|
|
5
6
|
/**
|
|
@@ -22,7 +23,7 @@ export function createRecursivePhaseTool(recursive: RecursiveRuntime) {
|
|
|
22
23
|
},
|
|
23
24
|
async execute(args: { runId?: string }, exec) {
|
|
24
25
|
const result = await recursive.phaseRules(args.runId, exec.agent as { session?: { header?: { cwd?: string } } } | null)
|
|
25
|
-
if (!result) return { error: '
|
|
26
|
+
if (!result) return { error: toolError('NO_PHASE') } as const
|
|
26
27
|
return result as unknown as JsonValue
|
|
27
28
|
},
|
|
28
29
|
})
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* T26 — `recursive_preview`: see the enforcement contract BEFORE it fires.
|
|
3
|
+
*
|
|
4
|
+
* WHY. There is no way to see what the policy section will render, which rule a tool call will match,
|
|
5
|
+
* or what the next legal transition requires — until it happens. That makes *enforce, don't just
|
|
6
|
+
* describe* unverifiable by the person it is enforced on: a rule nobody can inspect in advance is
|
|
7
|
+
* indistinguishable from a rule that does not exist.
|
|
8
|
+
*
|
|
9
|
+
* ⚠ IT MAKES NO MODEL CALL, and that is a property rather than a happy accident: every part of this
|
|
10
|
+
* module is filesystem reads plus the SAME pure functions the enforcement path uses
|
|
11
|
+
* (`renderStableContract` / `renderPhaseTail` / `contractDigest` from T22, `getNextLegalPhase` from
|
|
12
|
+
* the lock chain, `phaseRulesFor` from the lint rules, `evaluateToolGuard` from the guard). A preview
|
|
13
|
+
* that asked a model what would happen would be a DESCRIPTION of enforcement, which is exactly what
|
|
14
|
+
* the item exists to replace.
|
|
15
|
+
*
|
|
16
|
+
* ⚠ AND IT REUSES THE ENFORCEMENT PATH RATHER THAN RESTATING IT. The probe calls `evaluateToolGuard`
|
|
17
|
+
* — the same function the real `pre-execute` listener calls — so the rule it names is the rule that
|
|
18
|
+
* WOULD fire, not a second opinion that can drift from it.
|
|
19
|
+
*/
|
|
20
|
+
import { existsSync } from 'node:fs'
|
|
21
|
+
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
22
|
+
import type { JsonValue } from '@deepseek-ai/dsh-util-values'
|
|
23
|
+
import { toolError } from './errors.ts'
|
|
24
|
+
import { evaluateToolGuard } from './enforcement.ts'
|
|
25
|
+
import { getNextLegalPhase } from './lock.ts'
|
|
26
|
+
import { phaseRulesFor } from './phase-rules.ts'
|
|
27
|
+
import { renderStableContract, renderPhaseTail, contractDigest } from './policy.ts'
|
|
28
|
+
import type { EnforcementConfig } from './enforcement.ts'
|
|
29
|
+
import type { RecursiveRuntime } from './runtime.ts'
|
|
30
|
+
|
|
31
|
+
/** A tool call the caller wants to preview the guard's decision for. */
|
|
32
|
+
export interface PreviewProbe {
|
|
33
|
+
name: string
|
|
34
|
+
arguments?: Record<string, unknown>
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface PreviewResult {
|
|
38
|
+
/** T22: the byte-identical prefix, the per-phase tail, and the local identifier. */
|
|
39
|
+
policy: { stable: string; tail: string; digest: string }
|
|
40
|
+
/** What the phase being worked on requires, from the same rules the linter enforces. */
|
|
41
|
+
phase: { file: string; requiredSections: string[]; audited: boolean; tdd: boolean; qa: boolean } | null
|
|
42
|
+
/** The next legal transition, or null when nothing is pending. */
|
|
43
|
+
next: { phase: string; requiredSections: string[] } | null
|
|
44
|
+
/** What the guard WOULD decide for a probe call — the rule that would fire, by name. */
|
|
45
|
+
probe: { kind: string; rule: string; reason?: string } | null
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Build the preview. Pure apart from the run-directory reads it is given.
|
|
50
|
+
*
|
|
51
|
+
* `next` is `null` for a completed run rather than a fabricated phase: "nothing is pending" is a fact
|
|
52
|
+
* a reader needs, and inventing the last phase as "next" would be the opposite of a preview.
|
|
53
|
+
*/
|
|
54
|
+
export function buildPreview(input: {
|
|
55
|
+
root: string
|
|
56
|
+
runId: string
|
|
57
|
+
config: EnforcementConfig
|
|
58
|
+
probe?: PreviewProbe
|
|
59
|
+
}): PreviewResult {
|
|
60
|
+
const nextPhase = getNextLegalPhase(input.root + '/.recursive/run/' + input.runId) ?? null
|
|
61
|
+
const rules = nextPhase === null ? null : phaseRulesFor(nextPhase)
|
|
62
|
+
|
|
63
|
+
const probe = input.probe === undefined
|
|
64
|
+
? null
|
|
65
|
+
: (() => {
|
|
66
|
+
const decision = evaluateToolGuard(
|
|
67
|
+
{ name: input.probe.name, arguments: input.probe.arguments ?? {} } as never,
|
|
68
|
+
input.root,
|
|
69
|
+
input.runId,
|
|
70
|
+
input.config.toolGuards,
|
|
71
|
+
)
|
|
72
|
+
const asRecord = decision as unknown as { kind: string; rule?: string; reason?: string }
|
|
73
|
+
return {
|
|
74
|
+
kind: asRecord.kind,
|
|
75
|
+
// A decision with no rule is reported as `none` rather than omitted, so "no rule matched"
|
|
76
|
+
// and "the preview forgot to look" cannot be confused.
|
|
77
|
+
rule: asRecord.rule ?? 'none',
|
|
78
|
+
...(asRecord.reason === undefined ? {} : { reason: asRecord.reason }),
|
|
79
|
+
}
|
|
80
|
+
})()
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
policy: {
|
|
84
|
+
stable: renderStableContract(input.config),
|
|
85
|
+
tail: renderPhaseTail(null),
|
|
86
|
+
digest: contractDigest(input.config),
|
|
87
|
+
},
|
|
88
|
+
phase: rules === null
|
|
89
|
+
? null
|
|
90
|
+
: {
|
|
91
|
+
file: rules.fileName,
|
|
92
|
+
requiredSections: [...rules.requiredSections],
|
|
93
|
+
audited: rules.audited,
|
|
94
|
+
tdd: rules.tdd,
|
|
95
|
+
qa: rules.qa,
|
|
96
|
+
},
|
|
97
|
+
next: nextPhase === null || rules === null
|
|
98
|
+
? null
|
|
99
|
+
: { phase: nextPhase, requiredSections: [...rules.requiredSections] },
|
|
100
|
+
probe,
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** `recursive_preview` — the read-only view, registered as a tool so it is one call away. */
|
|
105
|
+
export function createRecursivePreviewTool(recursive: RecursiveRuntime) {
|
|
106
|
+
return defineTool({
|
|
107
|
+
name: 'recursive_preview',
|
|
108
|
+
description: 'Show what the recursive-mode enforcement contract will do BEFORE it fires: the rendered policy prefix and its digest, the current phase\'s required sections and gates, the next legal transition, and the guard rule a probe tool call would match. Read-only: it reads the run directory and the same pure functions the enforcement path uses, and makes no model call.',
|
|
109
|
+
parameters: {
|
|
110
|
+
runId: { type: 'string', description: 'Run id. Required; must resolve inside the current workspace.' },
|
|
111
|
+
probeTool: { type: 'string', description: 'Optional tool name to preview the guard decision for (e.g. recursive_lock).' },
|
|
112
|
+
probeArguments: { type: 'string', description: 'Optional JSON object of arguments for the probe call.' },
|
|
113
|
+
},
|
|
114
|
+
output: {
|
|
115
|
+
schema: { type: 'json' },
|
|
116
|
+
render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 2) }],
|
|
117
|
+
},
|
|
118
|
+
async execute(args: { runId?: string; probeTool?: string; probeArguments?: string }, exec) {
|
|
119
|
+
const runId = args.runId?.trim() ?? ''
|
|
120
|
+
if (runId === '') return { error: toolError('MISSING_RUN_ID') } as const
|
|
121
|
+
const root = await recursive.resolveWorkspaceRoot(exec.agent)
|
|
122
|
+
if (!root) return { error: toolError('NO_WORKSPACE') } as const
|
|
123
|
+
if (!existsSync(root + '/.recursive/run/' + runId)) {
|
|
124
|
+
return { error: toolError('MISSING_RUN_ID', 'no run directory for ' + runId) } as const
|
|
125
|
+
}
|
|
126
|
+
let probe: PreviewProbe | undefined
|
|
127
|
+
if (args.probeTool !== undefined && args.probeTool.trim() !== '') {
|
|
128
|
+
let parsed: Record<string, unknown> = {}
|
|
129
|
+
if (args.probeArguments !== undefined && args.probeArguments.trim() !== '') {
|
|
130
|
+
try {
|
|
131
|
+
parsed = JSON.parse(args.probeArguments) as Record<string, unknown>
|
|
132
|
+
} catch {
|
|
133
|
+
return { error: toolError('BAD_PROBE_ARGUMENTS') } as const
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
probe = { name: args.probeTool.trim(), arguments: parsed }
|
|
137
|
+
}
|
|
138
|
+
const preview = buildPreview({ root, runId, config: recursive.enforcementConfig, ...(probe === undefined ? {} : { probe }) })
|
|
139
|
+
return preview as unknown as JsonValue
|
|
140
|
+
},
|
|
141
|
+
})
|
|
142
|
+
}
|