@try-works/dsh-recursive-mode 0.3.1 → 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.
Files changed (117) hide show
  1. package/README.md +959 -0
  2. package/lib/client.js +9 -2
  3. package/lib/closeout-report.d.ts +113 -0
  4. package/lib/closeout-standards.d.ts +35 -0
  5. package/lib/closeout.d.ts +12 -0
  6. package/lib/commands.d.ts +1 -1
  7. package/lib/config.d.ts +202 -0
  8. package/lib/delegation.d.ts +123 -3
  9. package/lib/enforcement.d.ts +90 -1
  10. package/lib/errors.d.ts +168 -0
  11. package/lib/git-context.d.ts +17 -0
  12. package/lib/guard-log.d.ts +39 -0
  13. package/lib/handoff.d.ts +29 -0
  14. package/lib/hooks.d.ts +103 -0
  15. package/lib/identity.d.ts +61 -0
  16. package/lib/index.d.ts +32 -12
  17. package/lib/index.js +9819 -3858
  18. package/lib/job-log.d.ts +34 -0
  19. package/lib/jobs-runner.d.ts +105 -0
  20. package/lib/json-safe.d.ts +33 -0
  21. package/lib/lock.d.ts +42 -0
  22. package/lib/memory-feedback.d.ts +52 -0
  23. package/lib/memory-select.d.ts +78 -0
  24. package/lib/memory.d.ts +137 -0
  25. package/lib/model-inventory.d.ts +106 -0
  26. package/lib/phase-graph.d.ts +111 -0
  27. package/lib/phase-rules.d.ts +67 -8
  28. package/lib/plan-gate.d.ts +68 -0
  29. package/lib/policy-globs.d.ts +222 -0
  30. package/lib/policy-write.d.ts +42 -0
  31. package/lib/policy.d.ts +39 -0
  32. package/lib/recursive_ask.tool.d.ts +88 -0
  33. package/lib/recursive_closeout.tool.d.ts +1 -1
  34. package/lib/recursive_delegate.tool.d.ts +22 -0
  35. package/lib/recursive_preview.tool.d.ts +48 -0
  36. package/lib/recursive_review.tool.d.ts +28 -0
  37. package/lib/result-cap.d.ts +70 -0
  38. package/lib/review-round.d.ts +82 -0
  39. package/lib/review.d.ts +9 -0
  40. package/lib/role-route.d.ts +122 -0
  41. package/lib/router.d.ts +90 -5
  42. package/lib/runtime.d.ts +252 -12
  43. package/lib/settlement.d.ts +132 -0
  44. package/lib/skills-phase.d.ts +71 -0
  45. package/lib/status.d.ts +53 -1
  46. package/lib/teams-loop.d.ts +91 -2
  47. package/lib/training.d.ts +211 -0
  48. package/lib/ts-lint.d.ts +15 -0
  49. package/lib/types.d.ts +48 -0
  50. package/lib/workflow-audit.d.ts +207 -0
  51. package/package.json +29 -30
  52. package/preset/recursive.patch.yml +312 -0
  53. package/scripts/e2e-run.mjs +51 -0
  54. package/scripts/link-dsh.mjs +233 -0
  55. package/scripts/live/fake-llm.mjs +150 -0
  56. package/scripts/live-session-plugin.mjs +179 -0
  57. package/scripts/live-session-stock.mjs +106 -0
  58. package/scripts/live-session.mjs +139 -0
  59. package/src/client/derive.ts +18 -2
  60. package/src/closeout-report.ts +274 -0
  61. package/src/closeout-standards.ts +102 -0
  62. package/src/closeout.ts +39 -2
  63. package/src/commands.ts +116 -4
  64. package/src/config.ts +113 -0
  65. package/src/delegation.ts +336 -18
  66. package/src/enforcement.ts +262 -72
  67. package/src/errors.ts +197 -0
  68. package/src/git-context.ts +33 -2
  69. package/src/guard-log.ts +134 -0
  70. package/src/handoff.ts +62 -0
  71. package/src/hooks.ts +316 -0
  72. package/src/identity.ts +230 -0
  73. package/src/index.ts +385 -20
  74. package/src/job-log.ts +112 -0
  75. package/src/jobs-runner.ts +222 -0
  76. package/src/json-safe.ts +75 -0
  77. package/src/lock.ts +153 -16
  78. package/src/memory-feedback.ts +185 -0
  79. package/src/memory-select.ts +187 -0
  80. package/src/memory.ts +309 -0
  81. package/src/model-inventory.ts +196 -0
  82. package/src/phase-graph.ts +191 -0
  83. package/src/phase-rules.ts +236 -0
  84. package/src/plan-gate.ts +111 -0
  85. package/src/policy-globs.ts +636 -0
  86. package/src/policy-write.ts +210 -0
  87. package/src/policy.ts +70 -5
  88. package/src/recursive_ask.tool.ts +276 -0
  89. package/src/recursive_audit_team.tool.ts +7 -3
  90. package/src/recursive_closeout.tool.ts +36 -35
  91. package/src/recursive_delegate.tool.ts +194 -0
  92. package/src/recursive_init.tool.ts +4 -3
  93. package/src/recursive_lint.tool.ts +81 -6
  94. package/src/recursive_lock.tool.ts +21 -4
  95. package/src/recursive_phase.tool.ts +3 -2
  96. package/src/recursive_preview.tool.ts +142 -0
  97. package/src/recursive_review.tool.ts +190 -0
  98. package/src/recursive_scratch.tool.ts +5 -4
  99. package/src/recursive_status.tool.ts +3 -2
  100. package/src/recursive_worktree.tool.ts +6 -5
  101. package/src/result-cap.ts +130 -0
  102. package/src/review-round.ts +335 -0
  103. package/src/review.ts +17 -3
  104. package/src/role-route.ts +230 -0
  105. package/src/router.ts +128 -2
  106. package/src/runtime.ts +968 -39
  107. package/src/settlement.ts +355 -0
  108. package/src/skills-phase.ts +143 -0
  109. package/src/snapshot.ts +39 -8
  110. package/src/status.ts +209 -4
  111. package/src/teams-loop.ts +223 -9
  112. package/src/training.ts +565 -0
  113. package/src/ts-lint.ts +38 -4
  114. package/src/types.ts +51 -0
  115. package/src/workflow-audit.ts +288 -0
  116. package/scripts/install-preset.cmd +0 -7
  117. package/scripts/install-preset.js +0 -101
@@ -1,35 +1,36 @@
1
- import { defineTool } from '@deepseek-ai/dsh-tools'
2
- import type { JsonValue } from '@deepseek-ai/dsh-tools'
3
- import type { RecursiveRuntime } from './runtime.ts'
4
-
5
- /**
6
- * `recursive_closeout` — scaffold a Phase 4/5/6/7/8 closeout receipt under the
7
- * SESSION's workspace only (R1 workspace-scoping invariant). The run is resolved
8
- * via the session agent's cwd -> workspace registry; a runId outside the current
9
- * workspace is rejected.
10
- */
11
- export function createRecursiveCloseoutTool(recursive: RecursiveRuntime) {
12
- return defineTool({
13
- name: 'recursive_closeout',
14
- description: 'Scaffold a closeout receipt stub (Phase 4/5/6/7/8 delta receipt) for a run in the CURRENT session workspace only. Workspace-scoped: refuses runIds outside the session\'s workspace. Delegates to the RecursiveRuntime service.',
15
- parameters: {
16
- phase: { type: 'string', description: 'Closeout phase to scaffold: 04, 05, 06, 07, or 08 (e.g. \'06\' for 06-decisions-update.md). Required.' },
17
- runId: { type: 'string', description: 'Run id (e.g. 03-something). Required. Must resolve inside the current workspace.' },
18
- },
19
- output: {
20
- schema: { type: 'json' },
21
- render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 2) }],
22
- },
23
- async execute(args: { phase?: string; runId?: string }, exec) {
24
- if (!args.phase || !args.runId || args.runId.trim() === '') {
25
- return { error: 'phase and runId are required' } as const
26
- }
27
- const root = await recursive.resolveWorkspaceRoot(exec.agent)
28
- if (!root) {
29
- return { error: 'session is not attached to a registered workspace (cannot resolve control-plane root)' } as const
30
- }
31
- const result = recursive.closeoutRun(root, args.runId.trim(), args.phase.trim())
32
- return result as unknown as JsonValue
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 type { JsonValue } from '@deepseek-ai/dsh-tools'
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: 'runId is required' } as const
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-tools'
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: 'runId is required' } as const
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
- return result as unknown as JsonValue
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 type { JsonValue } from '@deepseek-ai/dsh-tools'
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: 'runId is required' } as const
22
+ return { error: toolError('MISSING_RUN_ID') } as const
21
23
  }
22
24
  if (!args.artifact || args.artifact.trim() === '') {
23
- return { error: 'artifact is required' } as const
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
- return { error: err instanceof Error ? err.message : String(err) } as const
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 type { JsonValue } from '@deepseek-ai/dsh-tools'
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: 'no recursive phase found' } as const
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
+ }