@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.
- 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.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,210 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ⚠ FU-19 — WRITING THE USER'S PROVIDER/MODEL CHOICES BACK TO THE POLICY FILE.
|
|
3
|
+
*
|
|
4
|
+
* `recursive-router.json` is the plugin's own declarative config, and `bootstrap.ts` already writes it once when a
|
|
5
|
+
* workspace is scaffolded. This module is the second writer: it changes ONE level of ONE thing and leaves every
|
|
6
|
+
* other byte of the file alone.
|
|
7
|
+
*
|
|
8
|
+
* ## Three rules, each of which exists because the opposite would be a defect
|
|
9
|
+
*
|
|
10
|
+
* 1. **PRESERVE WHAT WE DO NOT UNDERSTAND.** The file is read as `Record<string, unknown>` and mutated in place,
|
|
11
|
+
* never rebuilt from our types. A policy carrying a field this version has never heard of — a hand-written
|
|
12
|
+
* `cli_overrides`, a future schema addition — keeps it. Rebuilding from the schema would silently delete a
|
|
13
|
+
* user's configuration the first time they used a verb, which is the worst kind of data loss because it looks
|
|
14
|
+
* like the verb worked.
|
|
15
|
+
*
|
|
16
|
+
* 2. **`clear` MEANS ABSENCE, NOT EMPTINESS.** The schema's own rule is that an absent field DEFERS to the next
|
|
17
|
+
* level of precedence. So clearing must DELETE the key rather than set it to null or to an empty string: a
|
|
18
|
+
* present-but-empty value would stop the deferral and pin the choice to nothing, which is a different state
|
|
19
|
+
* from "unconfigured" and not one a user can see. Absence is the honest representation of "I have no opinion".
|
|
20
|
+
*
|
|
21
|
+
* 3. **WRITE ATOMICALLY.** A temp file and a rename, the same pattern the preset installer uses. A policy file
|
|
22
|
+
* half-written because a process died mid-write would make `loadRouterPolicy` fall back to the built-in
|
|
23
|
+
* self-audit policy — and it does that SILENTLY, on purpose (it never throws). So a torn write here would look
|
|
24
|
+
* exactly like "the user configured nothing", for every role, until someone read the file.
|
|
25
|
+
*/
|
|
26
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
|
|
27
|
+
import { dirname, join } from 'node:path'
|
|
28
|
+
import { routerPolicyPath } from './router.ts'
|
|
29
|
+
|
|
30
|
+
/** Which level a write targets. The narrowest matching level wins at resolution time. */
|
|
31
|
+
export type SelectionScope = 'general' | 'phase' | 'role'
|
|
32
|
+
|
|
33
|
+
/** What to write. Every field is optional; omitting one leaves that field alone. */
|
|
34
|
+
export interface SelectionPatch {
|
|
35
|
+
scope: SelectionScope
|
|
36
|
+
/** Required for `phase` and `role`. */
|
|
37
|
+
phase?: string
|
|
38
|
+
/** Required for `role`. */
|
|
39
|
+
role?: string
|
|
40
|
+
/** The SUBAGENT provider — who creates the child. */
|
|
41
|
+
provider?: string
|
|
42
|
+
/** The model id. */
|
|
43
|
+
model?: string
|
|
44
|
+
/** The LLM provider — who SERVES the model. Not the same thing as `provider` above. */
|
|
45
|
+
modelProvider?: string
|
|
46
|
+
/** Remove the named fields instead of setting them. With no field names, removes the whole level. */
|
|
47
|
+
clear?: boolean
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface SelectionWriteResult {
|
|
51
|
+
path: string
|
|
52
|
+
/** One sentence, naming the level, what changed, and the rule that history is untouched. */
|
|
53
|
+
message: string
|
|
54
|
+
/** Whether anything actually changed — a no-op write still rewrites the file, but says it was already so. */
|
|
55
|
+
changed: boolean
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const SCOPED_FIELDS = ['provider', 'model', 'modelProvider'] as const
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* A selector name, cleaned of the quotes a shell-typed argument often carries. `--role ""` must mean "no role"
|
|
62
|
+
* rather than a route literally named two quote characters — a defect this writer's own spec caught when the verb
|
|
63
|
+
* was wired to it.
|
|
64
|
+
*/
|
|
65
|
+
function cleanName(value: string | undefined): string {
|
|
66
|
+
return (value ?? '').replace(/^["']|["']$/g, '').trim()
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function asRecord(value: unknown): Record<string, unknown> | null {
|
|
70
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : null
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Where a scope's patch lives inside the policy object, creating the container only when a write needs it. */
|
|
74
|
+
function containerFor(policy: Record<string, unknown>, patch: SelectionPatch): Record<string, unknown> | null {
|
|
75
|
+
if (patch.scope === 'general') {
|
|
76
|
+
return asRecord(policy.defaults) ?? null
|
|
77
|
+
}
|
|
78
|
+
if (patch.scope === 'phase') {
|
|
79
|
+
const key = cleanName(patch.phase)
|
|
80
|
+
if (key === '') return null
|
|
81
|
+
const routes = asRecord(policy.phase_routes) ?? {}
|
|
82
|
+
const existing = asRecord(routes[key]) ?? {}
|
|
83
|
+
routes[key] = existing
|
|
84
|
+
policy.phase_routes = routes
|
|
85
|
+
return existing
|
|
86
|
+
}
|
|
87
|
+
const role = cleanName(patch.role)
|
|
88
|
+
if (role === '') return null
|
|
89
|
+
const routes = asRecord(policy.role_routes) ?? {}
|
|
90
|
+
const existing = asRecord(routes[role]) ?? {}
|
|
91
|
+
routes[role] = existing
|
|
92
|
+
policy.role_routes = routes
|
|
93
|
+
return existing
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Apply one patch to a policy object IN PLACE. Returns whether anything changed, or a refusal reason. */
|
|
97
|
+
export function applySelectionPatch(
|
|
98
|
+
policy: Record<string, unknown>,
|
|
99
|
+
patch: SelectionPatch,
|
|
100
|
+
): { changed: boolean } | { refused: string } {
|
|
101
|
+
if (patch.scope === 'phase' && cleanName(patch.phase) === '') {
|
|
102
|
+
return { refused: 'a phase-scoped choice needs a phase, e.g. --phase 03' }
|
|
103
|
+
}
|
|
104
|
+
if (patch.scope === 'role' && cleanName(patch.role) === '') {
|
|
105
|
+
return { refused: 'a role-scoped choice needs a role, e.g. --role code-reviewer' }
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// A role route carries `model` at its own top level (that is where the pre-FU-19 schema put it), so the fields
|
|
109
|
+
// are written there and NOT nested under a `subagent` object the way the general default is.
|
|
110
|
+
const target = patch.scope === 'general'
|
|
111
|
+
? (() => {
|
|
112
|
+
const defaults = asRecord(policy.defaults) ?? {}
|
|
113
|
+
policy.defaults = defaults
|
|
114
|
+
const subagent = asRecord(defaults.subagent) ?? {}
|
|
115
|
+
defaults.subagent = subagent
|
|
116
|
+
return subagent
|
|
117
|
+
})()
|
|
118
|
+
: containerFor(policy, patch)
|
|
119
|
+
if (target === null) return { refused: 'the policy target could not be created' }
|
|
120
|
+
|
|
121
|
+
const names = SCOPED_FIELDS.filter((field) => patch[field] !== undefined)
|
|
122
|
+
let changed = false
|
|
123
|
+
|
|
124
|
+
if (patch.clear === true) {
|
|
125
|
+
// Clear the named fields, or the whole level when none were named. DELETE, never blank — see rule 2 above.
|
|
126
|
+
const removals = names.length > 0 ? names : [...SCOPED_FIELDS]
|
|
127
|
+
for (const field of removals) {
|
|
128
|
+
if (field in target) {
|
|
129
|
+
delete target[field]
|
|
130
|
+
changed = true
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
// And drop a now-empty general container, so "cleared" leaves no residue that could shadow a lower level.
|
|
134
|
+
if (patch.scope === 'general') {
|
|
135
|
+
const defaults = asRecord(policy.defaults)
|
|
136
|
+
const subagent = defaults === null ? null : asRecord(defaults.subagent)
|
|
137
|
+
if (subagent !== null && Object.keys(subagent).length === 0) {
|
|
138
|
+
delete defaults!.subagent
|
|
139
|
+
changed = true
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return { changed }
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
for (const field of names) {
|
|
146
|
+
const value = patch[field]
|
|
147
|
+
const next = typeof value === 'string' && value.trim() !== '' ? value.trim() : null
|
|
148
|
+
if (next === null) {
|
|
149
|
+
// An empty string is not a choice: treat it as "clear this field" rather than storing a blank that would
|
|
150
|
+
// stop the deferral to the next level.
|
|
151
|
+
if (field in target) {
|
|
152
|
+
delete target[field]
|
|
153
|
+
changed = true
|
|
154
|
+
}
|
|
155
|
+
continue
|
|
156
|
+
}
|
|
157
|
+
if (target[field] !== next) {
|
|
158
|
+
target[field] = next
|
|
159
|
+
changed = true
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return { changed }
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Write a selection patch to a workspace's policy file.
|
|
167
|
+
*
|
|
168
|
+
* Returns a refusal instead of throwing when the patch is incomplete, and a message that always states the rule
|
|
169
|
+
* the objective names: this applies to future delegations and never rewrites a run's recorded history.
|
|
170
|
+
*/
|
|
171
|
+
export function writePolicySelection(
|
|
172
|
+
root: string,
|
|
173
|
+
patch: SelectionPatch,
|
|
174
|
+
options: { path?: string } = {},
|
|
175
|
+
): SelectionWriteResult | { refused: string } {
|
|
176
|
+
const path = options.path ?? routerPolicyPath(root)
|
|
177
|
+
let policy: Record<string, unknown> = {}
|
|
178
|
+
if (existsSync(path)) {
|
|
179
|
+
try {
|
|
180
|
+
policy = asRecord(JSON.parse(readFileSync(path, 'utf8'))) ?? {}
|
|
181
|
+
} catch {
|
|
182
|
+
return { refused: 'the policy file at ' + path + ' is not readable JSON, so it was left untouched rather than overwritten' }
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const applied = applySelectionPatch(policy, patch)
|
|
187
|
+
if ('refused' in applied) return applied
|
|
188
|
+
|
|
189
|
+
const level = patch.scope === 'general'
|
|
190
|
+
? 'the general subagent default'
|
|
191
|
+
: patch.scope === 'phase'
|
|
192
|
+
? 'the phase ' + (patch.phase ?? '') + ' override'
|
|
193
|
+
: 'the ' + (patch.role ?? '') + ' role route'
|
|
194
|
+
|
|
195
|
+
try {
|
|
196
|
+
mkdirSync(dirname(path), { recursive: true })
|
|
197
|
+
const tmp = join(dirname(path), '.recursive-router.json.tmp')
|
|
198
|
+
writeFileSync(tmp, JSON.stringify(policy, null, 2) + '\n', 'utf8')
|
|
199
|
+
renameSync(tmp, path)
|
|
200
|
+
} catch (err) {
|
|
201
|
+
return { refused: 'the policy could not be written: ' + (err instanceof Error ? err.message : String(err)) }
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return {
|
|
205
|
+
path,
|
|
206
|
+
changed: applied.changed,
|
|
207
|
+
message: (applied.changed ? 'updated ' : 'already as requested: ') + level
|
|
208
|
+
+ '. This applies to FUTURE delegations only — it never rewrites a run\'s recorded history.',
|
|
209
|
+
}
|
|
210
|
+
}
|
package/src/policy.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
* machine gates enforce (no prompt/gate contradiction).
|
|
8
8
|
*/
|
|
9
9
|
import { existsSync } from 'node:fs'
|
|
10
|
+
import { createHash } from 'node:crypto'
|
|
10
11
|
import { join } from 'node:path'
|
|
11
12
|
import { PHASE_SEQUENCE, getLockStatus } from './lock.ts'
|
|
12
13
|
import { foldRun } from './status.ts'
|
|
@@ -43,12 +44,17 @@ export function renderRecursivePolicy(context: PolicyContext | null): string {
|
|
|
43
44
|
}
|
|
44
45
|
|
|
45
46
|
const strictness = (gate: EnforcementConfig[keyof EnforcementConfig]) => gate
|
|
47
|
+
// T22 — THE STABLE PREFIX COMES FIRST, then the per-phase tail.
|
|
48
|
+
//
|
|
49
|
+
// ⚠ The four lines that used to open this array were the phase-independent contract, rendered
|
|
50
|
+
// inline; they now come from `renderStableContract`, so the contract has ONE definition and the
|
|
51
|
+
// prefix is byte-identical for the whole run — which is the precondition for any provider-side
|
|
52
|
+
// caching, whatever the provider does with it. The current phase and its next artifact move to
|
|
53
|
+
// `renderPhaseTail`, because they are precisely what changes between phases; a prefix that carried
|
|
54
|
+
// them would be stable in name only.
|
|
46
55
|
const lines = [
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
'- Next required artifact: ' + (nextRequired || 'none - run complete'),
|
|
50
|
-
'- Lock chain: phases lock monotonically (' + PHASE_SEQUENCE.join(' -> ') + ').',
|
|
51
|
-
'- Gates in force: pre-step ' + strictness(config.preStep) + ', tool guards ' + strictness(config.toolGuards) + ', tamper ' + strictness(config.tamper) + '.',
|
|
56
|
+
renderStableContract(config),
|
|
57
|
+
renderPhaseTail(status?.currentPhase ?? null),
|
|
52
58
|
'- A transition that fails its gates is BLOCKED (strict) or warns (advisory); no rejected transition advances state.',
|
|
53
59
|
'- Writes to a Status: LOCKED phase doc are denied/asked; reopen explicitly to edit.',
|
|
54
60
|
'- Phase 3 lock requires TDD evidence (strict) or rationale (pragmatic); Phase 5 requires QA sign-off for human/hybrid modes.',
|
|
@@ -78,4 +84,63 @@ export function renderRecursivePolicy(context: PolicyContext | null): string {
|
|
|
78
84
|
}
|
|
79
85
|
|
|
80
86
|
return lines.join('\n')
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* T22 — the STABLE contract: the part of the policy section that does not depend on the phase.
|
|
91
|
+
*
|
|
92
|
+
* WHY A SPLIT AT ALL, given the item's own premise check: the mechanism this was modelled on
|
|
93
|
+
* (iii's `system_sections` + `cache_boundary` + `cache_intent.surface_digest`) does **not** exist in
|
|
94
|
+
* DSH as a plugin-visible seam — the harness's only cache concepts live in the provider layer
|
|
95
|
+
* (`llm-pi-ai` accepts prompt-cache MARKERS and a retention preference), and whether any provider
|
|
96
|
+
* caches the prefix is **provider-side and unverified**. The item **withdrew its "largest cost
|
|
97
|
+
* lever" label for exactly that reason**, and this module keeps only what survives the check:
|
|
98
|
+
* **a byte-identical prefix is a PRECONDITION for any provider-side caching**, whatever the provider
|
|
99
|
+
* does with it — and a stable prefix costs nothing to produce.
|
|
100
|
+
*
|
|
101
|
+
* ⚠ THE CONTRACT DEPENDS ON THE CONFIG, NOT ON THE PHASE, and that distinction is the whole point:
|
|
102
|
+
* the enforcement modes and the lock rules are fixed for a RUN, so they belong in the prefix; the
|
|
103
|
+
* current phase, its required sections and its gate checklist change per phase and belong in the
|
|
104
|
+
* tail. A prefix that varied with the phase would be stable in name only.
|
|
105
|
+
*/
|
|
106
|
+
export function renderStableContract(config: EnforcementConfig = DEFAULT_ENFORCEMENT): string {
|
|
107
|
+
return [
|
|
108
|
+
'You are in a recursive-mode session (enforcement active).',
|
|
109
|
+
'- Lock chain: phases lock monotonically (' + PHASE_SEQUENCE.join(' -> ') + ').',
|
|
110
|
+
'- Gates in force: pre-step ' + config.preStep + ', tool guards ' + config.toolGuards
|
|
111
|
+
+ ', tamper detection ' + config.tamper + '.',
|
|
112
|
+
'- A transition that fails its gates is BLOCKED (strict) or warns (advisory); no rejected transition proceeds silently.',
|
|
113
|
+
'- Writes to a Status: LOCKED phase doc are denied/asked; reopen explicitly to edit.',
|
|
114
|
+
'- Phase 3 lock requires TDD evidence (strict) or rationale (pragmatic); Phase 5 requires QA evidence.',
|
|
115
|
+
'- The control-plane root is resolved STRICTLY from this session workspace (never scanned from another).',
|
|
116
|
+
].join('\n')
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* T22 — a LOCAL identifier for the contract, not a cache directive.
|
|
121
|
+
*
|
|
122
|
+
* The item's rescope is explicit that the digest is worth computing as a **local identifier**: it is
|
|
123
|
+
* stable within a run and changes when the contract changes, which is what makes "did the contract
|
|
124
|
+
* change under me?" answerable from the prompt alone. It claims **nothing** about provider caching —
|
|
125
|
+
* a digest that implied one would be the withdrawn label wearing a hash.
|
|
126
|
+
*/
|
|
127
|
+
export function contractDigest(config: EnforcementConfig = DEFAULT_ENFORCEMENT): string {
|
|
128
|
+
return createHash('sha256').update(renderStableContract(config), 'utf8').digest('hex').slice(0, 16)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* T22 — the per-phase TAIL: everything that legitimately changes between phases.
|
|
133
|
+
*
|
|
134
|
+
* Kept as its own name so the split is a fact in the code rather than a convention: a caller that
|
|
135
|
+
* wants a cacheable prefix takes {@link renderStableContract} and puts this after it.
|
|
136
|
+
*/
|
|
137
|
+
export function renderPhaseTail(
|
|
138
|
+
phase: { label: string; phaseName: string; status: string; key?: string } | null,
|
|
139
|
+
): string {
|
|
140
|
+
if (phase === null) return '- Current phase: unknown\n- Next required artifact: none - run complete'
|
|
141
|
+
// The key is part of the original wording (`phaseName (key)`), so it is carried when present
|
|
142
|
+
// rather than dropped — a recomposition must not quietly lose information a reader had.
|
|
143
|
+
const next = phase.key === undefined ? phase.phaseName : phase.phaseName + ' (' + phase.key + ')'
|
|
144
|
+
return '- Current phase: ' + phase.label + ' (' + phase.status + ')\n'
|
|
145
|
+
+ '- Next required artifact: ' + next
|
|
81
146
|
}
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* T23 — `recursive_ask`: the three human gates as STRUCTURED decisions, not prose.
|
|
3
|
+
*
|
|
4
|
+
* WHY. Three points in this workflow genuinely block on a person, and all three are prose today:
|
|
5
|
+
* the `TDD Mode: strict|pragmatic` choice at phase-3 entry, QA sign-off at phase 5, and resolving a
|
|
6
|
+
* gate block. Prose means the answer arrives as free text in a transcript — unvalidated, unciteable,
|
|
7
|
+
* and impossible to write back into the artifact as a fact. Asking as a STRUCTURED question gives
|
|
8
|
+
* the answer a shape the workflow can consume, and the harness renders that shape as a card.
|
|
9
|
+
*
|
|
10
|
+
* ⚠ WHAT IS MEASURED AND WHAT IS NOT. The ask flow is a CLIENT concern: the harness renders these
|
|
11
|
+
* questions as cards (`ui-user-questions`, the ask-question card model), so the plugin's job is to
|
|
12
|
+
* produce a well-formed question and to record the answer — not to draw anything. The LENGTH LIMITS
|
|
13
|
+
* below are therefore the plugin's OWN documented bounds rather than a claimed mirror of a harness
|
|
14
|
+
* constant I did not measure; what matters for the workflow is that an over-long field is refused
|
|
15
|
+
* **with its field path**, which is what the item's RED asks for.
|
|
16
|
+
*
|
|
17
|
+
* ⚠ ONE ASK PER STEP. A step that asks twice produces two cards for one decision, and the second
|
|
18
|
+
* answer silently overwrites the first — a decision the workflow never sees. The guard refuses the
|
|
19
|
+
* second ask in the same step rather than letting the last writer win.
|
|
20
|
+
*/
|
|
21
|
+
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
22
|
+
import type { JsonValue } from '@deepseek-ai/dsh-util-values'
|
|
23
|
+
import type { RecursiveRuntime } from './runtime.ts'
|
|
24
|
+
import { toolError } from './errors.ts'
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
/** The identifiers the workflow uses for its three human gates. */
|
|
28
|
+
export const ASK_GATE_IDS = ['tdd-mode', 'qa-signoff', 'gate-block'] as const
|
|
29
|
+
export type AskGateId = (typeof ASK_GATE_IDS)[number]
|
|
30
|
+
|
|
31
|
+
/** The plugin's own documented bounds — see the module comment on why these are not a claimed mirror. */
|
|
32
|
+
export const MAX_HEADER_CHARS = 12
|
|
33
|
+
export const MAX_LABEL_CHARS = 30
|
|
34
|
+
export const MAX_DESCRIPTION_CHARS = 200
|
|
35
|
+
|
|
36
|
+
export interface AskOption {
|
|
37
|
+
label: string
|
|
38
|
+
description?: string
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** A structured question, in the shape the ask flow renders as a card. */
|
|
42
|
+
export interface AskQuestion {
|
|
43
|
+
id: string
|
|
44
|
+
header: string
|
|
45
|
+
question: string
|
|
46
|
+
options: AskOption[]
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** One of the three gates, as data: what to ask, what the options mean, and what the answer means. */
|
|
50
|
+
export interface AskGate {
|
|
51
|
+
id: AskGateId
|
|
52
|
+
header: string
|
|
53
|
+
question: string
|
|
54
|
+
options: AskOption[]
|
|
55
|
+
/** The artifact marker name the answer is written under. */
|
|
56
|
+
marker: string
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export const ASK_GATES: Record<AskGateId, AskGate> = {
|
|
60
|
+
'tdd-mode': {
|
|
61
|
+
id: 'tdd-mode',
|
|
62
|
+
header: 'TDD Mode',
|
|
63
|
+
question: 'How should phase 3 handle test evidence?',
|
|
64
|
+
options: [
|
|
65
|
+
{ label: 'strict', description: 'RED and GREEN evidence paths are required before the phase can lock.' },
|
|
66
|
+
{ label: 'pragmatic', description: 'A written rationale is accepted in place of evidence paths.' },
|
|
67
|
+
],
|
|
68
|
+
marker: 'TDD Mode',
|
|
69
|
+
},
|
|
70
|
+
'qa-signoff': {
|
|
71
|
+
id: 'qa-signoff',
|
|
72
|
+
header: 'QA sign-off',
|
|
73
|
+
question: 'Who signs off the manual QA phase?',
|
|
74
|
+
options: [
|
|
75
|
+
{ label: 'human', description: 'A person runs and signs the QA checklist.' },
|
|
76
|
+
{ label: 'agent-operated', description: 'The agent runs it and records the results.' },
|
|
77
|
+
{ label: 'hybrid', description: 'The agent runs it; a person signs the result.' },
|
|
78
|
+
],
|
|
79
|
+
marker: 'QA Execution Mode',
|
|
80
|
+
},
|
|
81
|
+
'gate-block': {
|
|
82
|
+
id: 'gate-block',
|
|
83
|
+
header: 'Gate block',
|
|
84
|
+
question: 'A gate is blocking this transition. How should it be resolved?',
|
|
85
|
+
options: [
|
|
86
|
+
{ label: 'fix', description: 'Return to the phase and satisfy the gate.' },
|
|
87
|
+
{ label: 'reopen', description: 'Reopen an earlier locked artifact and repair it there.' },
|
|
88
|
+
{ label: 'abandon', description: 'Stop the run; the block is not resolvable now.' },
|
|
89
|
+
],
|
|
90
|
+
marker: 'Gate Resolution',
|
|
91
|
+
},
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Thrown for a request the ask flow would refuse; carries the FAILING FIELD PATH. */
|
|
95
|
+
export class AskValidationError extends Error {
|
|
96
|
+
readonly field: string
|
|
97
|
+
constructor(field: string, message: string) {
|
|
98
|
+
super(field + ': ' + message)
|
|
99
|
+
this.name = 'AskValidationError'
|
|
100
|
+
this.field = field
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Validate a question and return it unchanged.
|
|
106
|
+
*
|
|
107
|
+
* The FIELD PATH travels in the error because a caller fixing an over-long header needs to know
|
|
108
|
+
* WHICH field — a bare "too long" for a request with four fields is a puzzle, not a diagnostic.
|
|
109
|
+
*/
|
|
110
|
+
export function validateAskQuestion(question: AskQuestion): AskQuestion {
|
|
111
|
+
if (question.header.trim() === '') throw new AskValidationError('header', 'must not be empty')
|
|
112
|
+
if (question.header.length > MAX_HEADER_CHARS) {
|
|
113
|
+
throw new AskValidationError('header', 'is ' + question.header.length + ' characters, over the ' + MAX_HEADER_CHARS + '-character limit')
|
|
114
|
+
}
|
|
115
|
+
if (question.question.trim() === '') throw new AskValidationError('question', 'must not be empty')
|
|
116
|
+
if (question.options.length === 0) throw new AskValidationError('options', 'at least one option is required')
|
|
117
|
+
question.options.forEach((option, index) => {
|
|
118
|
+
if (option.label.trim() === '') throw new AskValidationError('options[' + index + '].label', 'must not be empty')
|
|
119
|
+
if (option.label.length > MAX_LABEL_CHARS) {
|
|
120
|
+
throw new AskValidationError('options[' + index + '].label', 'is ' + option.label.length + ' characters, over the ' + MAX_LABEL_CHARS + '-character limit')
|
|
121
|
+
}
|
|
122
|
+
if (option.description !== undefined && option.description.length > MAX_DESCRIPTION_CHARS) {
|
|
123
|
+
throw new AskValidationError('options[' + index + '].description', 'is ' + option.description.length + ' characters, over the ' + MAX_DESCRIPTION_CHARS + '-character limit')
|
|
124
|
+
}
|
|
125
|
+
})
|
|
126
|
+
return question
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Build the question for a gate, validated. */
|
|
130
|
+
export function buildAskQuestion(gateId: AskGateId): AskQuestion {
|
|
131
|
+
const gate = ASK_GATES[gateId]
|
|
132
|
+
return validateAskQuestion({
|
|
133
|
+
id: gate.id,
|
|
134
|
+
header: gate.header,
|
|
135
|
+
question: gate.question,
|
|
136
|
+
options: gate.options.map((option) => ({ ...option })),
|
|
137
|
+
})
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Validate an answer against its gate.
|
|
142
|
+
*
|
|
143
|
+
* An answer that is not one of the offered labels is REFUSED rather than recorded: a marker saying
|
|
144
|
+
* `TDD Mode: maybe` would look like a decision and be a transcription error.
|
|
145
|
+
*/
|
|
146
|
+
export function validateAskAnswer(gateId: AskGateId, answer: string): string {
|
|
147
|
+
const gate = ASK_GATES[gateId]
|
|
148
|
+
if (!gate.options.some((option) => option.label === answer)) {
|
|
149
|
+
throw new AskValidationError('answer', 'must be one of ' + gate.options.map((option) => option.label).join(' | ') + ' (got ' + JSON.stringify(answer) + ')')
|
|
150
|
+
}
|
|
151
|
+
return answer
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** The durable marker an accepted answer is written back as — a fact, not a chat aside. */
|
|
155
|
+
export function answerMarker(gateId: AskGateId, answer: string): string {
|
|
156
|
+
const gate = ASK_GATES[gateId]
|
|
157
|
+
return '- ' + gate.marker + ': ' + answer
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* One-ask-per-step guard.
|
|
162
|
+
*
|
|
163
|
+
* A step that asks twice yields two cards for one decision and the second answer silently wins —
|
|
164
|
+
* so the second ask is REFUSED, naming the gate already asked this step.
|
|
165
|
+
*/
|
|
166
|
+
export function createAskLedger(): { claim(gateId: AskGateId): void; asked(): AskGateId[] } {
|
|
167
|
+
const asked: AskGateId[] = []
|
|
168
|
+
return {
|
|
169
|
+
claim(gateId: AskGateId): void {
|
|
170
|
+
if (asked.includes(gateId)) {
|
|
171
|
+
throw new AskValidationError('gate', 'the ' + gateId + ' gate was already asked in this step; one ask per step')
|
|
172
|
+
}
|
|
173
|
+
if (asked.length > 0) {
|
|
174
|
+
throw new AskValidationError('gate', 'this step already asked ' + asked.join(', ') + '; answer it before asking again')
|
|
175
|
+
}
|
|
176
|
+
asked.push(gateId)
|
|
177
|
+
},
|
|
178
|
+
asked: () => [...asked],
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Which artifact each gate's answer belongs in, when the caller does not name one. */
|
|
183
|
+
export const GATE_DEFAULT_ARTIFACT: Record<AskGateId, string> = {
|
|
184
|
+
'tdd-mode': '03-implementation-summary.md',
|
|
185
|
+
'qa-signoff': '05-manual-qa.md',
|
|
186
|
+
// A gate block can happen at any phase, so its marker belongs where the caller says.
|
|
187
|
+
'gate-block': '',
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* FU-7 — THE CALL POINTS: which gate, if any, a phase ENTRY still owes.
|
|
192
|
+
*
|
|
193
|
+
* ⚠ THE ARTIFACT IS THE RECORD, so no ledger is needed to ask once: if the document already carries the
|
|
194
|
+
* gate's marker line, the question has been answered and is not asked again. That is the same
|
|
195
|
+
* "once per run at entry" property T29 got from riding an existing gate — one mechanism, not two.
|
|
196
|
+
*
|
|
197
|
+
* ⚠ AND AN UNKNOWN PHASE OWES NOTHING. Returning a gate for a phase that has no such decision would ask a
|
|
198
|
+
* person a question the workflow does not act on, which is worse than not asking at all.
|
|
199
|
+
*/
|
|
200
|
+
export function pendingGateFor(artifactFile: string, artifactText: string | null): AskGateId | null {
|
|
201
|
+
const gate = (Object.keys(GATE_DEFAULT_ARTIFACT) as AskGateId[])
|
|
202
|
+
.find((id) => GATE_DEFAULT_ARTIFACT[id] === artifactFile)
|
|
203
|
+
if (gate === undefined) return null
|
|
204
|
+
const marker = ASK_GATES[gate].marker
|
|
205
|
+
// Already answered: the marker is in the document, so the decision is settled.
|
|
206
|
+
if (artifactText !== null && new RegExp('^- ' + marker + ':', 'm').test(artifactText)) return null
|
|
207
|
+
return gate
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* T23 — the tool.
|
|
212
|
+
*
|
|
213
|
+
* TWO BRANCHES, and the split is the point: called WITHOUT an answer it ASKS (returning the validated
|
|
214
|
+
* question, which the host renders as a card), and called WITH one it RECORDS — validating the label,
|
|
215
|
+
* writing the marker into the artifact, and reporting the line it wrote. A tool that did both in one
|
|
216
|
+
* call would have to invent the answer.
|
|
217
|
+
*
|
|
218
|
+
* ⚠ THE WRITE-BACK IS A MARKER LINE, REPLACED IN PLACE when the artifact already carries one. A
|
|
219
|
+
* second `TDD Mode:` line would leave two answers to one question and make "what was decided?"
|
|
220
|
+
* depend on which a reader found first.
|
|
221
|
+
*/
|
|
222
|
+
export function createRecursiveAskTool(recursive: RecursiveRuntime) {
|
|
223
|
+
return defineTool({
|
|
224
|
+
name: 'recursive_ask',
|
|
225
|
+
description: 'Ask one of the three human gates as a structured decision (tdd-mode, qa-signoff, gate-block), or record the answer. Call without `answer` to ask; call with it to write the answer into the artifact as a durable marker. One ask per step.',
|
|
226
|
+
parameters: {
|
|
227
|
+
gate: { type: 'string', description: 'tdd-mode | qa-signoff | gate-block. Required.' },
|
|
228
|
+
runId: { type: 'string', description: 'Run id. Required; must resolve inside the current workspace.' },
|
|
229
|
+
artifact: { type: 'string', description: 'Artifact file the answer belongs in. Optional; defaults per gate (gate-block has none, so it is required for that gate).' },
|
|
230
|
+
answer: { type: 'string', description: 'One of the gate\'s option labels. Omit to ASK.' },
|
|
231
|
+
},
|
|
232
|
+
output: {
|
|
233
|
+
schema: { type: 'json' },
|
|
234
|
+
render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 2) }],
|
|
235
|
+
},
|
|
236
|
+
async execute(args: { gate?: string; runId?: string; artifact?: string; answer?: string }, exec) {
|
|
237
|
+
const gateId = (args.gate ?? '').trim() as AskGateId
|
|
238
|
+
const runId = args.runId?.trim() ?? ''
|
|
239
|
+
if (runId === '') return { error: toolError('MISSING_RUN_ID') } as const
|
|
240
|
+
if (!ASK_GATE_IDS.includes(gateId)) {
|
|
241
|
+
return { error: toolError('BAD_ASK_GATE', 'gate must be one of ' + ASK_GATE_IDS.join(' | ')) } as const
|
|
242
|
+
}
|
|
243
|
+
const root = await recursive.resolveWorkspaceRoot(exec.agent)
|
|
244
|
+
if (!root) return { error: toolError('NO_WORKSPACE') } as const
|
|
245
|
+
|
|
246
|
+
const artifact = (args.artifact ?? GATE_DEFAULT_ARTIFACT[gateId]).trim()
|
|
247
|
+
let question: AskQuestion
|
|
248
|
+
try {
|
|
249
|
+
question = buildAskQuestion(gateId)
|
|
250
|
+
} catch (err) {
|
|
251
|
+
// The plugin's own gate data failing validation is a defect, so it is reported as one
|
|
252
|
+
// rather than asked: a malformed card would be answered by a person who cannot fix it.
|
|
253
|
+
return { error: toolError('BAD_ASK_GATE', err instanceof Error ? err.message : String(err)) } as const
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// ASK.
|
|
257
|
+
if (args.answer === undefined) {
|
|
258
|
+
return { gate: gateId, marker: ASK_GATES[gateId].marker, artifact, question } as unknown as JsonValue
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// RECORD.
|
|
262
|
+
let answer: string
|
|
263
|
+
try {
|
|
264
|
+
answer = validateAskAnswer(gateId, args.answer)
|
|
265
|
+
} catch (err) {
|
|
266
|
+
return { error: toolError('BAD_ASK_ANSWER', err instanceof Error ? err.message : String(err)) } as const
|
|
267
|
+
}
|
|
268
|
+
if (artifact === '') {
|
|
269
|
+
return { error: toolError('MISSING_ASK_ARTIFACT', 'this gate needs an explicit artifact to record into') } as const
|
|
270
|
+
}
|
|
271
|
+
const marker = answerMarker(gateId, answer)
|
|
272
|
+
const written = recursive.recordAskAnswer(root, runId, artifact, marker)
|
|
273
|
+
return { gate: gateId, answer, marker, artifact, path: written.path, replaced: written.replaced } as unknown as JsonValue
|
|
274
|
+
},
|
|
275
|
+
})
|
|
276
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
2
|
-
import
|
|
2
|
+
import { codeRuntimeRefusal } from './errors.ts'
|
|
3
|
+
import type { JsonValue } from '@deepseek-ai/dsh-util-values'
|
|
3
4
|
import type {
|
|
4
5
|
TeamRuntimeLike,
|
|
5
6
|
TeamCallerHandle,
|
|
@@ -40,9 +41,12 @@ function taskViewToJson(view: TeamTaskViewLike): JsonValue {
|
|
|
40
41
|
}
|
|
41
42
|
}
|
|
42
43
|
|
|
43
|
-
/** Wrap a non-JSON-pure value in the standard error envelope. */
|
|
44
|
+
/** Wrap a non-JSON-pure value in the standard error envelope, coded for greppability. */
|
|
44
45
|
function errorJson(message: string): JsonValue {
|
|
45
|
-
|
|
46
|
+
// T24: every refusal must begin with a stable code so a consumer can branch on
|
|
47
|
+
// it. `codeRuntimeRefusal` wraps the teams loop's own uncoded messages and
|
|
48
|
+
// passes an already-coded one through rather than nesting it.
|
|
49
|
+
return { error: codeRuntimeRefusal(message) }
|
|
46
50
|
}
|
|
47
51
|
|
|
48
52
|
export function createRecursiveAuditTeamTool(teams: TeamRuntimeLike | null) {
|