@try-works/dsh-recursive-mode 0.3.1 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +32 -12
- package/lib/index.js +9819 -3858
- 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/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 +29 -30
- package/preset/recursive/agent.cordis.yml +2 -2
- 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/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 +385 -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/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
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `recursive_review` — T36: the reachable caller that makes a failed review become a
|
|
3
|
+
* repair.
|
|
4
|
+
*
|
|
5
|
+
* WHY A TOOL, AND WHY IT IS TURN-SHAPED. A tool call is the agent's only interface,
|
|
6
|
+
* so the operator's requirement — "the main agent can message a child whose work
|
|
7
|
+
* failed review and tell it to re-do or repair" — needs a tool or it is not real.
|
|
8
|
+
* It is deliberately ONE TRANSITION PER CALL, like `recursive_audit_team`: the
|
|
9
|
+
* harness has no parent-side await-settlement promise, so a review cannot be one
|
|
10
|
+
* call that waits for a verdict that arrives on a later turn. Call it, get either an
|
|
11
|
+
* outcome or "still running", and call it again after the child settles — the
|
|
12
|
+
* durable review state carries the child id between those calls.
|
|
13
|
+
*
|
|
14
|
+
* DELEGATION IS CONTINUABLE BY DEFAULT (T35), which is what makes the repair
|
|
15
|
+
* possible at all: a one-shot child is NOT resumable, so a one-shot review could
|
|
16
|
+
* only ever be a complaint. When the continuable seam is unavailable the outcome says
|
|
17
|
+
* so explicitly (`unavailable`) rather than reporting a success.
|
|
18
|
+
*/
|
|
19
|
+
import { randomUUID } from 'node:crypto'
|
|
20
|
+
import { readFileSync } from 'node:fs'
|
|
21
|
+
import { join } from 'node:path'
|
|
22
|
+
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
23
|
+
import type { JsonValue } from '@deepseek-ai/dsh-util-values'
|
|
24
|
+
import { toLosslessJson } from './json-safe.ts'
|
|
25
|
+
import type { RecursiveRuntime } from './runtime.ts'
|
|
26
|
+
import { codeRuntimeRefusal, toolError } from './errors.ts'
|
|
27
|
+
import { replyPath } from './handoff.ts'
|
|
28
|
+
import { advanceReview } from './review-round.ts'
|
|
29
|
+
import { settlementRoundObserver } from './settlement.ts'
|
|
30
|
+
import type { ContinuableDelegationLike, SubagentParentHandle, SubagentsRuntimeLike } from './delegation.ts'
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The reply contract, stated for the child AND relied on by the fail-closed reader:
|
|
34
|
+
* `verdict` decides the round, and `findings[].title` is the ONLY text that can
|
|
35
|
+
* become a repair instruction, so a child cannot inject instructions.
|
|
36
|
+
*/
|
|
37
|
+
const REPLY_CONTRACT =
|
|
38
|
+
'Write reply.md with a single JSON object: {"verdict":"APPROVE"|"REVISE"|"REJECT",'
|
|
39
|
+
+ '"findings":[{"severity":"HIGH"|"MEDIUM"|"LOW","title":"...","detail":"..."}]}. '
|
|
40
|
+
+ 'State the verdict explicitly: a reply that does not state one is read as REVISE, never as approval.'
|
|
41
|
+
|
|
42
|
+
export function createRecursiveReviewTool(recursive: RecursiveRuntime, subagents?: SubagentsRuntimeLike) {
|
|
43
|
+
return defineTool({
|
|
44
|
+
name: 'recursive_review',
|
|
45
|
+
description:
|
|
46
|
+
'Run or resume an INDEPENDENT review of the current phase artifact with a durable continuable subagent. '
|
|
47
|
+
+ 'Call it once per turn: it either reports an outcome (approved | rejected | unavailable) or that the '
|
|
48
|
+
+ 'review is still running. When a round comes back REVISE the SAME child is sent the repair instruction, '
|
|
49
|
+
+ 'so call this again after the re-submission settles. Never locks anything itself.',
|
|
50
|
+
parameters: {
|
|
51
|
+
runId: { type: 'string', description: 'Optional run id (defaults to the latest run by mtime).' },
|
|
52
|
+
phase: { type: 'string', description: 'Optional phase key to review (e.g. 03). Defaults to the run\'s current phase.' },
|
|
53
|
+
role: { type: 'string', description: 'Reviewer role to route (default: code-reviewer). Must be enabled in the router policy.' },
|
|
54
|
+
},
|
|
55
|
+
output: {
|
|
56
|
+
schema: { type: 'json' },
|
|
57
|
+
render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 2) }],
|
|
58
|
+
},
|
|
59
|
+
async execute(args: { runId?: string; phase?: string; role?: string }, exec) {
|
|
60
|
+
try {
|
|
61
|
+
const root = await recursive.resolveWorkspaceRoot(exec.agent)
|
|
62
|
+
if (!root) return { error: toolError('NO_WORKSPACE') } as const
|
|
63
|
+
|
|
64
|
+
const status = await recursive.status(args.runId, exec.agent)
|
|
65
|
+
if (!status) return { error: toolError('NO_RUN') } as const
|
|
66
|
+
const runId = status.runId
|
|
67
|
+
|
|
68
|
+
// The phase defaults to the run's current phase: the one a review is actually
|
|
69
|
+
// about, because that is the artifact whose lock the review gates.
|
|
70
|
+
const phase = args.phase?.trim() || status.currentPhase?.key || ''
|
|
71
|
+
if (phase === '') return { error: toolError('NO_PHASE') } as const
|
|
72
|
+
const row = status.phases.find((p) => p.key === phase)
|
|
73
|
+
if (row === undefined || !row.exists) {
|
|
74
|
+
return { error: toolError('NO_PHASE', 'phase ' + phase + ' has no artifact to review yet') } as const
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const role = args.role?.trim() || 'code-reviewer'
|
|
78
|
+
const delegationId = phase + '-review'
|
|
79
|
+
const runDir = join(root, '.recursive', 'run', runId)
|
|
80
|
+
const artifactPath = join(runDir, row.file)
|
|
81
|
+
const parent = exec.agent as unknown as SubagentParentHandle
|
|
82
|
+
|
|
83
|
+
const outcome = await advanceReview({
|
|
84
|
+
runDir,
|
|
85
|
+
delegationId,
|
|
86
|
+
phase,
|
|
87
|
+
role,
|
|
88
|
+
delegate: async ({ resumeChild }) => {
|
|
89
|
+
// A resumed round keeps the child it was given; a fresh round reserves an
|
|
90
|
+
// id up front so the brief/prompt paths and the session agree from the
|
|
91
|
+
// first call (`ctx.agents.create` accepts a reserved id).
|
|
92
|
+
const childId = resumeChild ?? randomUUID()
|
|
93
|
+
const review = await recursive.delegateReview({
|
|
94
|
+
root,
|
|
95
|
+
runId,
|
|
96
|
+
phase,
|
|
97
|
+
role,
|
|
98
|
+
delegationId,
|
|
99
|
+
childId,
|
|
100
|
+
artifactPath,
|
|
101
|
+
upstreamArtifacts: [],
|
|
102
|
+
auditQuestions: [
|
|
103
|
+
'Does the artifact satisfy every required section for phase ' + phase + '?',
|
|
104
|
+
'Are the gate fields (Coverage/Approval/Audit/TDD Compliance) truthful and supported by evidence?',
|
|
105
|
+
'Do the artifact and the upstream artifacts it cites agree with each other?',
|
|
106
|
+
],
|
|
107
|
+
requiredOutput: REPLY_CONTRACT,
|
|
108
|
+
subagents,
|
|
109
|
+
parent,
|
|
110
|
+
mode: 'continuable',
|
|
111
|
+
awaitRoundResult: settlementRoundObserver(runDir),
|
|
112
|
+
})
|
|
113
|
+
return toContinuable(review)
|
|
114
|
+
},
|
|
115
|
+
readReply: (childId) => readReplyText(root, runId, delegationId, childId),
|
|
116
|
+
// The loop can stop believing a prose reply was approved; the driver
|
|
117
|
+
// disagrees, so it must be able to deliver the repair itself.
|
|
118
|
+
sendRepair: async (childId, instruction) => {
|
|
119
|
+
const followup = subagents?.followup
|
|
120
|
+
if (followup === undefined) throw new Error('no continuable followup seam')
|
|
121
|
+
await followup(parent, childId, [{ type: 'text', text: instruction }], {
|
|
122
|
+
source: { kind: 'coordinator', form: 'relay', senderSessionId: parent?.id ?? '' },
|
|
123
|
+
})
|
|
124
|
+
},
|
|
125
|
+
})
|
|
126
|
+
// ⚠ FU-9 — A CAST IS NOT A CONTRACT, and this line proved it live. It used to be
|
|
127
|
+
// `return outcome as unknown as JsonValue`, and a real session answered with
|
|
128
|
+
// Error: tool "recursive_review" returned invalid output: value is not lossless JSON
|
|
129
|
+
// ToolOutputError, code INVALID_TOOL_OUTPUT
|
|
130
|
+
// because the outcome carries per-round `result` objects from the host — live Agents and seams, none of
|
|
131
|
+
// which survive JSON.stringify. The projection keeps every datum a caller reads (status, rounds, the
|
|
132
|
+
// round text, the message) and turns what has no JSON form into something that does, so the host can
|
|
133
|
+
// always read the result rather than refusing it and reporting nothing about the work.
|
|
134
|
+
const projected = toLosslessJson(outcome)
|
|
135
|
+
return (projected ?? { error: codeRuntimeRefusal('the review produced no JSON-representable result') }) as JsonValue
|
|
136
|
+
} catch (err) {
|
|
137
|
+
return { error: codeRuntimeRefusal(err instanceof Error ? err.message : String(err)) } as const
|
|
138
|
+
}
|
|
139
|
+
},
|
|
140
|
+
})
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** The child's reply file, or '' when it has not written one (an empty reply is not an approval). */
|
|
144
|
+
export function readReplyText(root: string, runId: string, delegationId: string, childId: string): string {
|
|
145
|
+
try {
|
|
146
|
+
return readFileSync(replyPath({ root, runId, delegationId, childId }), 'utf8')
|
|
147
|
+
} catch {
|
|
148
|
+
return ''
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Project `delegateReview`'s result onto the loop shape the driver reads.
|
|
154
|
+
*
|
|
155
|
+
* The mapping is where the honest failure modes live: no continuable result at all
|
|
156
|
+
* (self-audit, or the delegation never ran) becomes `fellBackToOneShot`, which the
|
|
157
|
+
* driver reports as `unavailable` — the review happened without the repair path, and
|
|
158
|
+
* saying so beats reporting a success that cannot be acted on.
|
|
159
|
+
*/
|
|
160
|
+
export function toContinuable(review: {
|
|
161
|
+
continuable: { rounds: unknown[]; childId?: string; fellBackToOneShot?: boolean; parked?: boolean; ok?: boolean; reason?: string } | null
|
|
162
|
+
evaluation?: { accepted?: boolean }
|
|
163
|
+
error?: string | null
|
|
164
|
+
parked?: boolean
|
|
165
|
+
}): ContinuableDelegationLike {
|
|
166
|
+
if (review.continuable === null) {
|
|
167
|
+
return {
|
|
168
|
+
ok: false,
|
|
169
|
+
reason: review.error ?? 'no continuable delegation ran',
|
|
170
|
+
rounds: [],
|
|
171
|
+
accepted: false,
|
|
172
|
+
fellBackToOneShot: true,
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return {
|
|
176
|
+
// ⚠ FU-9 — THE DELEGATION'S OWN `ok` AND `reason`, NOT A HARDCODED `true`. This adapter forwarded `rounds`,
|
|
177
|
+
// `childId`, `accepted` and `parked` while dropping `ok` and `reason`, so a delegation that returned
|
|
178
|
+
// `{ ok: false, reason: '...' }` arrived at the review driver as `{ ok: true, rounds: [] }` — and the driver
|
|
179
|
+
// then printed "the round ended without an approval", naming none of the three branches that can produce
|
|
180
|
+
// it. Two rounds of live runs went into recovering a sentence this object already carried.
|
|
181
|
+
ok: review.continuable.ok ?? true,
|
|
182
|
+
...(review.continuable.reason === undefined ? {} : { reason: review.continuable.reason }),
|
|
183
|
+
rounds: review.continuable.rounds as ContinuableDelegationLike['rounds'],
|
|
184
|
+
...(review.continuable.childId === undefined ? {} : { childId: review.continuable.childId }),
|
|
185
|
+
accepted: review.evaluation?.accepted === true,
|
|
186
|
+
...(review.continuable.parked === true ? { parked: true } : {}),
|
|
187
|
+
// ⚠ AND THE FALLBACK FLAG, which decides whether the driver reports "no continuable repair path" at all.
|
|
188
|
+
...(review.continuable.fellBackToOneShot === true ? { fellBackToOneShot: true } : {}),
|
|
189
|
+
}
|
|
190
|
+
}
|
|
@@ -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
|
/**
|
|
@@ -26,14 +27,14 @@ export function createRecursiveScratchTool(recursive: RecursiveRuntime) {
|
|
|
26
27
|
const runId = args.runId?.trim() ?? ''
|
|
27
28
|
const target = args.target ?? ''
|
|
28
29
|
if (!action || !runId || !target) {
|
|
29
|
-
return { error: '
|
|
30
|
+
return { error: toolError('MISSING_SCRATCH_ARGS') } as const
|
|
30
31
|
}
|
|
31
32
|
if (target !== 'md' && target !== 'ts') {
|
|
32
|
-
return { error: '
|
|
33
|
+
return { error: toolError('BAD_TARGET') } as const
|
|
33
34
|
}
|
|
34
35
|
const root = await recursive.resolveWorkspaceRoot(exec.agent)
|
|
35
36
|
if (!root) {
|
|
36
|
-
return { error: '
|
|
37
|
+
return { error: toolError('NO_WORKSPACE') } as const
|
|
37
38
|
}
|
|
38
39
|
const result = recursive.scratchRun(root, runId, action, target as 'md' | 'ts', args.content)
|
|
39
40
|
return result as unknown as JsonValue
|
|
@@ -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
|
export function createRecursiveStatusTool(recursive: RecursiveRuntime) {
|
|
@@ -17,7 +18,7 @@ export function createRecursiveStatusTool(recursive: RecursiveRuntime) {
|
|
|
17
18
|
// B3: per-call workspace root via exec.agent.session.header.cwd (registry
|
|
18
19
|
// first, cwd fallback) — never the host checkout process.cwd().
|
|
19
20
|
const result = await recursive.status(args.runId, exec.agent as { session?: { header?: { cwd?: string } } } | null)
|
|
20
|
-
if (!result) return { error: '
|
|
21
|
+
if (!result) return { error: toolError('NO_RUN') } as const
|
|
21
22
|
return result as unknown as JsonValue
|
|
22
23
|
},
|
|
23
24
|
})
|
|
@@ -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
|
/**
|
|
@@ -28,18 +29,18 @@ export function createRecursiveWorktreeTool(recursive: RecursiveRuntime) {
|
|
|
28
29
|
// so tests and headless callers work without a live session header.
|
|
29
30
|
const root = await recursive.resolveRootFor(exec.agent as { session?: { header?: { cwd?: string } } } | null)
|
|
30
31
|
if (!root) {
|
|
31
|
-
return { error: '
|
|
32
|
+
return { error: toolError('NO_WORKSPACE') } as const
|
|
32
33
|
}
|
|
33
34
|
if (action === 'create') {
|
|
34
35
|
if (!args.runId || args.runId.trim() === '') {
|
|
35
|
-
return { error: '
|
|
36
|
+
return { error: toolError('MISSING_CREATE_RUN_ID') } as const
|
|
36
37
|
}
|
|
37
38
|
const result = recursive.createRunWorktree(root, args.runId.trim(), args.baseBranch?.trim() || undefined)
|
|
38
39
|
return result as unknown as JsonValue
|
|
39
40
|
}
|
|
40
41
|
if (action === 'promote') {
|
|
41
42
|
if (!args.fromBranch || !args.toBranch) {
|
|
42
|
-
return { error: '
|
|
43
|
+
return { error: toolError('MISSING_PROMOTE_BRANCHES') } as const
|
|
43
44
|
}
|
|
44
45
|
const result = recursive.promoteRunBranch(root, args.fromBranch.trim(), args.toBranch.trim())
|
|
45
46
|
return result as unknown as JsonValue
|
|
@@ -48,7 +49,7 @@ export function createRecursiveWorktreeTool(recursive: RecursiveRuntime) {
|
|
|
48
49
|
const result = recursive.worktreeStatus(root)
|
|
49
50
|
return result as unknown as JsonValue
|
|
50
51
|
}
|
|
51
|
-
return { error: '
|
|
52
|
+
return { error: toolError('BAD_ACTION') } as const
|
|
52
53
|
},
|
|
53
54
|
})
|
|
54
55
|
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* T24 — bounded tool results.
|
|
3
|
+
*
|
|
4
|
+
* A linter over a 126 KB port can emit hundreds of findings, and `recursive_lint`
|
|
5
|
+
* returned `errors[]`/`warnings[]` unbounded, so a single tool result could grow
|
|
6
|
+
* without limit. Every result this plugin returns must be BOUNDED, and when a
|
|
7
|
+
* bound bites it must say so in a way the reader can act on:
|
|
8
|
+
*
|
|
9
|
+
* - WHAT was removed (which list, how many),
|
|
10
|
+
* - what is SHOWN, and
|
|
11
|
+
* - HOW to see the rest.
|
|
12
|
+
*
|
|
13
|
+
* A silent truncation is worse than no cap at all: the reader cannot tell a
|
|
14
|
+
* complete result from a clipped one. Hence {@link ElisionMeta} accompanies every
|
|
15
|
+
* truncation, and the caller surfaces it next to the findings.
|
|
16
|
+
*
|
|
17
|
+
* The "how to see the rest" route is deliberately ITERATIVE rather than a
|
|
18
|
+
* bypass argument: the cap is a hard bound, so telling the caller to pass
|
|
19
|
+
* `mode: 'full'` when `mode: 'full'` is already the capped mode would be a lie.
|
|
20
|
+
* Fixing the shown findings and re-running genuinely reveals the next batch,
|
|
21
|
+
* which is also the order the workflow wants them fixed in.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/** Findings kept by `mode: 'full'`. */
|
|
25
|
+
export const MAX_FINDINGS_FULL = 200
|
|
26
|
+
|
|
27
|
+
/** Findings kept by `mode: 'summary'` — enough to orient, not enough to drown a turn. */
|
|
28
|
+
export const MAX_FINDINGS_SUMMARY = 10
|
|
29
|
+
|
|
30
|
+
export interface ElisionMeta {
|
|
31
|
+
/** Which list was clipped. */
|
|
32
|
+
kind: 'errors' | 'warnings'
|
|
33
|
+
/** Findings the source produced. */
|
|
34
|
+
total: number
|
|
35
|
+
/** Findings actually returned. */
|
|
36
|
+
shown: number
|
|
37
|
+
/** `total - shown`; stated so a consumer need not compute it. */
|
|
38
|
+
omitted: number
|
|
39
|
+
/** One self-sufficient sentence: what was removed and how to see it. */
|
|
40
|
+
hint: string
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface ElideResult {
|
|
44
|
+
kept: string[]
|
|
45
|
+
/** Present only when the list was clipped. */
|
|
46
|
+
meta: ElisionMeta | null
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The payload shape the byte budget knows how to trim. */
|
|
50
|
+
export interface CappedPayload {
|
|
51
|
+
errors: string[]
|
|
52
|
+
warnings: string[]
|
|
53
|
+
elided: ElisionMeta[]
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Serialized size of a payload, measured the way the byte budget is enforced. */
|
|
57
|
+
export function payloadBytes(value: unknown): number {
|
|
58
|
+
try {
|
|
59
|
+
return Buffer.byteLength(JSON.stringify(value) ?? '', 'utf8')
|
|
60
|
+
} catch {
|
|
61
|
+
return 0
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* T28 — cap a payload by BYTES, which T24's count cap cannot do.
|
|
67
|
+
*
|
|
68
|
+
* `MAX_FINDINGS_FULL` bounds HOW MANY findings come back; it cannot see SIZE, so a
|
|
69
|
+
* handful of enormous findings still passes. This trims the longer list repeatedly
|
|
70
|
+
* until the payload fits, and reports every trim through the same `ElisionMeta` T24
|
|
71
|
+
* established — a silent truncation is worse than no cap, because the reader cannot
|
|
72
|
+
* tell a complete result from a clipped one.
|
|
73
|
+
*
|
|
74
|
+
* Terminates: each pass at least halves the longer list, and an empty pair of lists
|
|
75
|
+
* ends the loop. A payload that still exceeds the budget once both lists are empty is
|
|
76
|
+
* returned as-is — the alternative would be deleting fields the caller needs.
|
|
77
|
+
*/
|
|
78
|
+
export function capPayloadBytes<T extends CappedPayload>(payload: T, maxBytes: number): T {
|
|
79
|
+
let current = payload
|
|
80
|
+
let bytes = payloadBytes(current)
|
|
81
|
+
if (bytes <= maxBytes) return current
|
|
82
|
+
|
|
83
|
+
for (let attempt = 0; attempt < 16 && bytes > maxBytes; attempt += 1) {
|
|
84
|
+
const { errors, warnings } = current
|
|
85
|
+
if (errors.length === 0 && warnings.length === 0) break
|
|
86
|
+
const trimErrors = errors.length >= warnings.length
|
|
87
|
+
const from = trimErrors ? errors : warnings
|
|
88
|
+
const keep = Math.floor(from.length / 2)
|
|
89
|
+
current = {
|
|
90
|
+
...current,
|
|
91
|
+
errors: trimErrors ? from.slice(0, keep) : errors,
|
|
92
|
+
warnings: trimErrors ? warnings : from.slice(0, keep),
|
|
93
|
+
elided: [...current.elided, {
|
|
94
|
+
kind: trimErrors ? 'errors' : 'warnings',
|
|
95
|
+
total: from.length,
|
|
96
|
+
shown: keep,
|
|
97
|
+
omitted: from.length - keep,
|
|
98
|
+
hint: 'trimmed to fit the configured result byte budget (maxResultBytes); '
|
|
99
|
+
+ 'fix the findings shown and re-run to see the next batch',
|
|
100
|
+
}],
|
|
101
|
+
}
|
|
102
|
+
bytes = payloadBytes(current)
|
|
103
|
+
}
|
|
104
|
+
return current
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Keep at most `max` findings. Clipping is reported, never silent.
|
|
109
|
+
* A `max` of 0 or less is treated as "keep nothing" but still reports honestly.
|
|
110
|
+
*/
|
|
111
|
+
export function elideFindings(kind: 'errors' | 'warnings', findings: readonly string[], max: number): ElideResult {
|
|
112
|
+
const list = Array.isArray(findings) ? findings : []
|
|
113
|
+
const bound = Number.isFinite(max) ? Math.max(0, Math.floor(max)) : list.length
|
|
114
|
+
if (list.length <= bound) return { kept: [...list], meta: null }
|
|
115
|
+
const kept = list.slice(0, bound)
|
|
116
|
+
const omitted = list.length - kept.length
|
|
117
|
+
return {
|
|
118
|
+
kept,
|
|
119
|
+
meta: {
|
|
120
|
+
kind,
|
|
121
|
+
total: list.length,
|
|
122
|
+
shown: kept.length,
|
|
123
|
+
omitted,
|
|
124
|
+
hint:
|
|
125
|
+
'showing ' + kept.length + ' of ' + list.length + ' ' + kind + '; ' + omitted +
|
|
126
|
+
' omitted by the result cap - fix the shown findings and re-run to reveal the rest' +
|
|
127
|
+
', or narrow the result with the artifact argument',
|
|
128
|
+
},
|
|
129
|
+
}
|
|
130
|
+
}
|