@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
@@ -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
- 'You are in a recursive-mode session (enforcement active).',
48
- '- Current phase: ' + (currentPhase || 'unknown'),
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 type { JsonValue } from '@deepseek-ai/dsh-tools'
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
- return { error: message }
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) {