@try-works/dsh-recursive-mode 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) 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 +33 -12
  17. package/lib/index.js +10017 -3969
  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/skills.d.ts +70 -0
  46. package/lib/status.d.ts +53 -1
  47. package/lib/teams-loop.d.ts +91 -2
  48. package/lib/training.d.ts +211 -0
  49. package/lib/ts-lint.d.ts +15 -0
  50. package/lib/types.d.ts +48 -0
  51. package/lib/workflow-audit.d.ts +207 -0
  52. package/package.json +31 -31
  53. package/preset/recursive.patch.yml +312 -0
  54. package/scripts/e2e-run.mjs +51 -0
  55. package/scripts/link-dsh.mjs +233 -0
  56. package/scripts/live/fake-llm.mjs +150 -0
  57. package/scripts/live-session-plugin.mjs +179 -0
  58. package/scripts/live-session-stock.mjs +106 -0
  59. package/scripts/live-session.mjs +139 -0
  60. package/skills/recursive-mode/SKILL.md +66 -0
  61. package/src/client/derive.ts +18 -2
  62. package/src/closeout-report.ts +274 -0
  63. package/src/closeout-standards.ts +102 -0
  64. package/src/closeout.ts +39 -2
  65. package/src/commands.ts +116 -4
  66. package/src/config.ts +113 -0
  67. package/src/delegation.ts +336 -18
  68. package/src/enforcement.ts +262 -72
  69. package/src/errors.ts +197 -0
  70. package/src/git-context.ts +33 -2
  71. package/src/guard-log.ts +134 -0
  72. package/src/handoff.ts +62 -0
  73. package/src/hooks.ts +316 -0
  74. package/src/identity.ts +230 -0
  75. package/src/index.ts +394 -20
  76. package/src/job-log.ts +112 -0
  77. package/src/jobs-runner.ts +222 -0
  78. package/src/json-safe.ts +75 -0
  79. package/src/lock.ts +153 -16
  80. package/src/memory-feedback.ts +185 -0
  81. package/src/memory-select.ts +187 -0
  82. package/src/memory.ts +309 -0
  83. package/src/model-inventory.ts +196 -0
  84. package/src/phase-graph.ts +191 -0
  85. package/src/phase-rules.ts +236 -0
  86. package/src/plan-gate.ts +111 -0
  87. package/src/policy-globs.ts +636 -0
  88. package/src/policy-write.ts +210 -0
  89. package/src/policy.ts +70 -5
  90. package/src/recursive_ask.tool.ts +276 -0
  91. package/src/recursive_audit_team.tool.ts +7 -3
  92. package/src/recursive_closeout.tool.ts +36 -35
  93. package/src/recursive_delegate.tool.ts +194 -0
  94. package/src/recursive_init.tool.ts +4 -3
  95. package/src/recursive_lint.tool.ts +81 -6
  96. package/src/recursive_lock.tool.ts +21 -4
  97. package/src/recursive_phase.tool.ts +3 -2
  98. package/src/recursive_preview.tool.ts +142 -0
  99. package/src/recursive_review.tool.ts +190 -0
  100. package/src/recursive_scratch.tool.ts +5 -4
  101. package/src/recursive_status.tool.ts +3 -2
  102. package/src/recursive_worktree.tool.ts +6 -5
  103. package/src/result-cap.ts +130 -0
  104. package/src/review-round.ts +335 -0
  105. package/src/review.ts +17 -3
  106. package/src/role-route.ts +230 -0
  107. package/src/router.ts +128 -2
  108. package/src/runtime.ts +968 -39
  109. package/src/settlement.ts +355 -0
  110. package/src/skills-phase.ts +143 -0
  111. package/src/skills.ts +151 -0
  112. package/src/snapshot.ts +39 -8
  113. package/src/status.ts +209 -4
  114. package/src/teams-loop.ts +223 -9
  115. package/src/training.ts +565 -0
  116. package/src/ts-lint.ts +38 -4
  117. package/src/types.ts +51 -0
  118. package/src/workflow-audit.ts +288 -0
  119. package/scripts/install-preset.cmd +0 -7
  120. package/scripts/install-preset.js +0 -101
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Closeout standards check (FU-12b) — does every artifact in a run fit the required standard?
3
+ *
4
+ * ⚠ WHAT THIS IS FOR, in the user's words: *"phase 6 7 8 writes the state decisions memory artifacts, and
5
+ * closeout just makes sure that all the artifacts fit the required standards."* The closeout does not author
6
+ * the ledgers. It holds the run to the standard and reports what is missing.
7
+ *
8
+ * ⚠ WHY IT READS `phase-rules` INSTEAD OF KEEPING ITS OWN LIST: the required sections already have exactly
9
+ * one home. `phase-rules.ts` owns them and is PROFILE-AWARE — it appends `AUDIT_REQUIRED_HEADINGS` for
10
+ * audited artifacts under a strict workflow profile, and `Prior Recursive Evidence Reviewed` for the files
11
+ * that need it. A private copy cannot match that, and this session has already produced three separate
12
+ * defects from exactly that duplication (the closeout's own `REQUIRED_SECTIONS`, the 00–03 stubs, and the
13
+ * `meta` block). So this module invents nothing: it asks the rules and reports the difference.
14
+ */
15
+ import { existsSync, readFileSync } from 'node:fs'
16
+ import { join } from 'node:path'
17
+ import { getLockStatus } from './lock.ts'
18
+ import { CURRENT_WORKFLOW_PROFILE, getArtifactRequiredSections } from './phase-rules.ts'
19
+ import { RUN_ARTIFACT_SEQUENCE, getGateStatus, hasHeading } from './ts-lint.ts'
20
+
21
+ /** Gate lines every phase document carries, as `Coverage: PASS|FAIL` / `Approval: PASS|FAIL`. */
22
+ const UNIVERSAL_GATES = ['Coverage', 'Approval'] as const
23
+
24
+ export interface StandardViolation {
25
+ /** Run-relative artifact name, or the sequence entry that was absent. */
26
+ artifact: string
27
+ kind: 'missing-artifact' | 'missing-section' | 'gate-not-passing' | 'not-locked'
28
+ /** What is wrong, spelled so a caller can print it without re-deriving anything. */
29
+ detail: string
30
+ }
31
+
32
+ export interface StandardsReport {
33
+ /** How many artifacts from the sequence were present and examined. */
34
+ checked: number
35
+ /** Artifacts in the sequence that do not exist yet — informational, not necessarily a failure. */
36
+ absent: string[]
37
+ violations: StandardViolation[]
38
+ }
39
+
40
+ export interface StandardsOptions {
41
+ /** Workflow profile whose rules apply; defaults to the current one, as every caller does. */
42
+ workflowProfile?: string
43
+ /**
44
+ * Report artifacts that are present but not LOCKED. **OFF by default**: a closeout runs mid-run and a DRAFT
45
+ * artifact is the normal state, so treating it as a violation would make the check useless exactly when it
46
+ * is most wanted. Callers that need an all-locked assertion ask for it.
47
+ */
48
+ requireLocked?: boolean
49
+ }
50
+
51
+ /**
52
+ * Examine every artifact in the run against the phase rules.
53
+ *
54
+ * Reports, per present artifact: required headings that are missing, gate lines that are missing or FAIL, and
55
+ * (only when `requireLocked`) a status that is not LOCKED. Absent artifacts are listed separately — during a
56
+ * closeout most of the sequence legitimately does not exist yet, and conflating "not written yet" with
57
+ * "written wrong" would bury the findings that matter.
58
+ */
59
+ export function verifyRunStandards(runDir: string, options: StandardsOptions = {}): StandardsReport {
60
+ const profile = options.workflowProfile ?? CURRENT_WORKFLOW_PROFILE
61
+ const violations: StandardViolation[] = []
62
+ const absent: string[] = []
63
+ let checked = 0
64
+
65
+ for (const artifact of RUN_ARTIFACT_SEQUENCE) {
66
+ const path = join(runDir, artifact)
67
+ if (!existsSync(path)) { absent.push(artifact); continue }
68
+ checked += 1
69
+ let content = ''
70
+ try { content = readFileSync(path, 'utf8') } catch { continue }
71
+
72
+ for (const heading of getArtifactRequiredSections(artifact, profile)) {
73
+ if (!hasHeading(content, heading)) {
74
+ violations.push({ artifact, kind: 'missing-section', detail: 'missing required section: ## ' + heading })
75
+ }
76
+ }
77
+ for (const gate of UNIVERSAL_GATES) {
78
+ const status = getGateStatus(content, gate)
79
+ if (status !== 'PASS') {
80
+ violations.push({ artifact, kind: 'gate-not-passing', detail: gate + ': ' + status })
81
+ }
82
+ }
83
+ if (options.requireLocked === true) {
84
+ const status = getLockStatus(path)
85
+ if (status !== 'LOCKED') {
86
+ violations.push({ artifact, kind: 'not-locked', detail: 'status is ' + status })
87
+ }
88
+ }
89
+ }
90
+
91
+ return { checked, absent, violations }
92
+ }
93
+
94
+ /** One line per violation, for a tool result or a CLI print. Empty when the run fits the standard. */
95
+ export function formatStandardsReport(report: StandardsReport): string[] {
96
+ const lines = report.violations.map((v) => v.artifact + ': ' + v.detail)
97
+ if (lines.length === 0) {
98
+ return ['All ' + report.checked + ' present artifact(s) fit the required standards'
99
+ + (report.absent.length > 0 ? ' (' + report.absent.length + ' not written yet)' : '') + '.']
100
+ }
101
+ return lines
102
+ }
package/src/closeout.ts CHANGED
@@ -1,11 +1,12 @@
1
1
  /**
2
2
  * Closeout receipt scaffolding (R2) — TS port of recursive-closeout.py.
3
- * Creates/updates Phase 4/5/6/7/8 delta-receipt stubs with canonical headers
3
+ * Creates/updates Phase 0-8 receipt stubs with canonical headers
4
4
  * and required sections, gated on prerequisite phases being LOCKED.
5
5
  */
6
6
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
7
7
  import { join } from 'node:path'
8
- import { getPrerequisiteBlockers } from './lock.ts'
8
+ import { getLockStatus, getPrerequisiteBlockers } from './lock.ts'
9
+ import { getRunTreeAddenda } from './ts-lint.ts'
9
10
 
10
11
  export interface CloseoutPhaseConfig {
11
12
  file: string
@@ -68,6 +69,7 @@ export const PHASE_CONFIG: Record<string, CloseoutPhaseConfig> = {
68
69
  }
69
70
 
70
71
  const REQUIRED_SECTIONS: Record<string, string[]> = {
72
+ // `recursive_lint` both know; these are the stub's minimum, not a replacement for them.
71
73
  '04': ['TODO', 'Pre-Test Implementation Audit', 'Environment', 'Execution Mode', 'Commands Executed (Exact)', 'Results Summary', 'Evidence and Artifacts', 'Failures and Diagnostics (if any)', 'Flake/Rerun Notes', 'Requirement Completion Status', 'Traceability', 'Coverage Gate', 'Approval Gate'],
72
74
  '05': ['TODO', 'Execution Mode', 'QA Execution Record', 'QA Scenarios and Results', 'User Sign-Off', 'Requirement Completion Status', 'Traceability', 'Coverage Gate', 'Approval Gate'],
73
75
  '06': ['TODO', 'Decisions Changes Applied', 'Rationale', 'Resulting Decision Entry', 'Traceability', 'Coverage Gate', 'Approval Gate'],
@@ -126,6 +128,12 @@ export interface CloseoutResult {
126
128
  file: string
127
129
  created: string[]
128
130
  existing: string[]
131
+ /**
132
+ * Every addendum found anywhere in the run tree, as run-relative paths — and cited in the scaffolded
133
+ * receipt. Optional on the TYPE so a caller that builds its own result literal still compiles; the
134
+ * closeout itself always populates it.
135
+ */
136
+ addenda?: string[]
129
137
  }
130
138
 
131
139
  export interface CloseoutOptions {
@@ -137,6 +145,12 @@ export interface CloseoutOptions {
137
145
  * Create or update a closeout receipt stub for the given phase.
138
146
  * Returns the file + created/existing lists. When strict, refuses phases whose
139
147
  * prerequisite artifacts are not all LOCKED (reuses lock.ts chain validation).
148
+ *
149
+ * ⚠ ADDENDA ARE PART OF THE CLOSEOUT WORK, and they are CITED, never written. An addendum closes a gap in
150
+ * an artifact that is already locked, which is precisely the kind of thing a receipt must account for — and
151
+ * they are not always in `addenda/`: the real ones sit beside the artifact they close, in the run ROOT. So
152
+ * the receipt lists every addendum in the run tree (see {@link getRunTreeAddenda}), and says so explicitly
153
+ * when there are none, because "we looked and found none" and "we never looked" must not read alike.
140
154
  */
141
155
  export function closeoutPhase(runDir: string, phase: string, opts: CloseoutOptions = {}): CloseoutResult {
142
156
  const config = PHASE_CONFIG[phase]
@@ -156,7 +170,25 @@ export function closeoutPhase(runDir: string, phase: string, opts: CloseoutOptio
156
170
  const filePath = join(runDir, config.file)
157
171
  const existed = existsSync(filePath)
158
172
 
173
+ // ⚠ FU-8 — A CLOSEOUT MUST NOT SILENTLY UNLOCK ITS OWN ARTIFACT. Found by the FU-1 harness: a
174
+ // closeout run after the phase had locked rewrote the file to `Status: \`DRAFT\`` while its RECEIPT
175
+ // still said LOCKED, leaving a run whose file and receipt disagree. The plugin publishes the rule this
176
+ // breaks, in the policy section a model reads every turn: *"Writes to a Status: LOCKED phase doc are
177
+ // denied/asked; reopen explicitly to edit."* The prerequisite check above guards EARLIER phases; this
178
+ // guards the artifact the closeout actually writes.
179
+ //
180
+ // ⚠ IT REFUSES RATHER THAN SKIPPING QUIETLY, and it names the remedy, because the caller's next move
181
+ // differs: a refusal means "reopen it first if you really mean to", while a silent skip would look
182
+ // like a successful closeout that changed nothing.
183
+ if (getLockStatus(filePath) === 'LOCKED') {
184
+ throw new Error(
185
+ 'Artifact is LOCKED: ' + config.file + ' — the contract denies a write to a LOCKED phase doc; '
186
+ + 'reopen it explicitly (recursive_lock with reopen) before scaffolding its receipt',
187
+ )
188
+ }
189
+
159
190
  const runId = runDir.split(/[\\/]/).filter(Boolean).pop() ?? 'unknown-run'
191
+ const addenda = getRunTreeAddenda(runDir)
160
192
  const lines: string[] = [
161
193
  'Run: `/.recursive/run/' + runId + '/`',
162
194
  'Phase: `' + config.label + '`',
@@ -167,6 +199,10 @@ export function closeoutPhase(runDir: string, phase: string, opts: CloseoutOptio
167
199
  'Outputs:',
168
200
  '- `/.recursive/run/' + runId + '/' + config.file + '`',
169
201
  'Scope note: ' + config.scopeNote,
202
+ 'Addenda:',
203
+ ...(addenda.length > 0
204
+ ? addenda.map(a => '- `/.recursive/run/' + runId + '/' + a + '`')
205
+ : ['- none found in the run tree']),
170
206
  '',
171
207
  ]
172
208
  for (const heading of REQUIRED_SECTIONS[phase]) {
@@ -179,5 +215,6 @@ export function closeoutPhase(runDir: string, phase: string, opts: CloseoutOptio
179
215
  file: config.file,
180
216
  created: existed ? [] : [config.file],
181
217
  existing: existed ? [config.file] : [],
218
+ addenda,
182
219
  }
183
220
  }
package/src/commands.ts CHANGED
@@ -14,10 +14,17 @@ import { join } from 'node:path'
14
14
  import type { RecursiveRuntime } from './runtime.ts'
15
15
  import { resolveControlPlaneRoot } from './workspace.ts'
16
16
  import { bootstrapScaffold } from './bootstrap.ts'
17
+ import { loadMemoryIndex } from './memory.ts'
18
+ import { explainMemorySelection } from './memory-select.ts'
19
+ import { readFeedback } from './memory-feedback.ts'
20
+ import { closeoutReport } from './closeout-report.ts'
21
+ import { loadRouterPolicy, routerPolicyPath } from './router.ts'
22
+ import { resolveSubagentTarget } from './role-route.ts'
23
+ import { writePolicySelection, type SelectionScope } from './policy-write.ts'
17
24
 
18
- export type RecursiveVerb = 'status' | 'spec' | 'worktree' | 'init' | 'lock' | 'qa' | 'closeout' | 'addendum' | 'review' | 'scratch' | 'bootstrap' | 'list' | 'help'
25
+ export type RecursiveVerb = 'status' | 'spec' | 'worktree' | 'init' | 'lock' | 'qa' | 'closeout' | 'addendum' | 'review' | 'scratch' | 'memory' | 'model' | 'bootstrap' | 'list' | 'help'
19
26
 
20
- export const PRESET_VERBS: RecursiveVerb[] = ['status', 'spec', 'worktree', 'init', 'lock', 'qa', 'closeout', 'addendum', 'review', 'scratch']
27
+ export const PRESET_VERBS: RecursiveVerb[] = ['status', 'spec', 'worktree', 'init', 'lock', 'qa', 'closeout', 'addendum', 'review', 'scratch', 'memory', 'model']
21
28
  export const GLOBAL_VERBS: RecursiveVerb[] = ['bootstrap', 'list', 'help']
22
29
  export const ALL_VERBS: RecursiveVerb[] = [...PRESET_VERBS, ...GLOBAL_VERBS]
23
30
 
@@ -76,6 +83,33 @@ export function executeRecursiveCommand(root: string, rawInput: string): Recursi
76
83
  : 'created ' + result.created.length + ' item(s), ' + result.existing.length + ' existing'
77
84
  return { kind: 'success', text: 'recursive-mode scaffold repair: ' + summary + ' — root ' + root }
78
85
  }
86
+ case 'memory': {
87
+ // P4 — THE SELECTOR WITH NO AGENT LOOP, printing its score COMPONENTS. The reference's loader is a
88
+ // script an agent runs; this is the same job on the surface the plugin already has, and it explains
89
+ // itself, so "why did the agent get this?" is answerable from a shell rather than only from a test.
90
+ const phaseMatch = arg.match(/--phase\s+([\d.]+)/)
91
+ const phase = phaseMatch?.[1]
92
+ const query = arg.replace(/--phase\s+[\d.]+/, '').trim()
93
+ if (query === '') {
94
+ return { kind: 'error', text: 'memory requires a query: /recursive memory <query> [--phase 04]' }
95
+ }
96
+ const entries = loadMemoryIndex(root)
97
+ if (entries.length === 0) {
98
+ return { kind: 'success', text: 'the memory plane is empty, so nothing is injected rather than fabricating memory' }
99
+ }
100
+ const explanation = explainMemorySelection(entries, {
101
+ query,
102
+ ...(phase === undefined ? {} : { phase }),
103
+ // The counters live in the sidecar; the caller reads them, exactly as the runtime does.
104
+ feedback: readFeedback(root),
105
+ })
106
+ const lines = [explanation.rendered]
107
+ if (explanation.excluded.length > 0) {
108
+ lines.push('', 'Excluded ' + explanation.excluded.length + ' entry(s):')
109
+ for (const item of explanation.excluded.slice(0, 5)) lines.push('- ' + item.title + ': ' + item.reason)
110
+ }
111
+ return { kind: 'success', text: lines.join('\n') }
112
+ }
79
113
  case 'closeout': {
80
114
  const phaseMatch = arg.match(/--phase\s+(\d{2})/)
81
115
  const phase = phaseMatch?.[1] ?? ''
@@ -83,7 +117,85 @@ export function executeRecursiveCommand(root: string, rawInput: string): Recursi
83
117
  if (!phase || !runId) return { kind: 'error', text: 'closeout requires <run-id> --phase 04|05|06|07|08' }
84
118
  const runDir = join(runRoot, runId)
85
119
  if (!existsSync(runDir)) return { kind: 'error', text: 'Run not found in current workspace: ' + runId }
86
- return { kind: 'success', text: 'closeout scaffolded for ' + runId + ' phase ' + phase }
120
+ // ⚠ THIS BRANCH USED TO LIE. It returned "closeout scaffolded for <run> phase <n>" having read nothing,
121
+ // written nothing and checked nothing — a command that reported success for work it never did. It now
122
+ // runs the LINTER the closeout actually is: it reads the phase artifact and reports what is missing,
123
+ // and WRITES NOTHING, which is the design the user specified ("like a linter checking if the agent
124
+ // missed anything; it is not supposed to edit files by itself").
125
+ try {
126
+ const report = closeoutReport(runDir, phase)
127
+ const lines = [
128
+ report.artifact + ' — ' + (report.exists ? report.status : 'ABSENT'),
129
+ report.findings.length === 0
130
+ ? 'fits the required standard.'
131
+ : report.findings.length + ' finding(s) before it can lock:',
132
+ ]
133
+ for (const finding of report.findings.slice(0, 12)) lines.push('- ' + finding.detail)
134
+ if (report.prerequisites.length > 0) {
135
+ lines.push('Advisory — earlier phases not yet LOCKED: '
136
+ + report.prerequisites.map((p) => p.artifact + ' (' + p.status + ')').join(', '))
137
+ }
138
+ if (report.addenda.length > 0) lines.push('Addenda cited: ' + report.addenda.length)
139
+ return { kind: 'success', text: lines.join('\n') }
140
+ } catch (err) {
141
+ return { kind: 'error', text: err instanceof Error ? err.message : String(err) }
142
+ }
143
+ }
144
+ case 'model': {
145
+ // ⚠ FU-19 — THE EFFECTIVE PROVIDER AND MODEL FOR A PHASE, WITH ITS PROVENANCE, and no agent loop. The same
146
+ // shape as `/recursive memory`: the workflow's own resolution, printed, so "why did this child run on that
147
+ // model" is answerable without reading the code or starting anything.
148
+ const phaseMatch = arg.match(/--phase\s+(\S+)/)
149
+ const roleMatch = arg.match(/--role\s+(\S+)/)
150
+ // ⚠ QUOTES ARE STRIPPED, because a shell-typed selector often arrives quoted and `--role ""` must be
151
+ // understood as "no role given" rather than as a role literally named two quote characters. That mistake was
152
+ // caught by this verb's own spec, which is the point of writing the spec before believing the code.
153
+ const unquote = (value: string | undefined): string => (value ?? '').replace(/^["']|["']$/g, '').trim()
154
+ const phase = unquote(phaseMatch?.[1])
155
+ const role = unquote(roleMatch?.[1])
156
+ // ⚠ FU-19 — SET OR CLEAR, when the user supplied something to set. A bare `/recursive model` still READS.
157
+ // The scope is inferred from which selectors were given, so `/recursive model --model cheap` sets the GENERAL
158
+ // default, `--phase 03 --model cheap` sets that phase's override, and `--role X --provider fork` sets the
159
+ // role route. `--clear` removes the named fields, or the whole level when none are named.
160
+ const providerMatch = arg.match(/--provider\s+(\S+)/)
161
+ const modelMatch = arg.match(/--model\s+(\S+)/)
162
+ const modelProviderMatch = arg.match(/--model-provider\s+(\S+)/)
163
+ const clear = /--clear\b/.test(arg)
164
+ if (providerMatch !== null || modelMatch !== null || modelProviderMatch !== null || clear) {
165
+ const scope: SelectionScope = phase !== '' ? 'phase' : role !== '' ? 'role' : 'general'
166
+ const written = writePolicySelection(root, {
167
+ scope,
168
+ ...(phase === '' ? {} : { phase }),
169
+ ...(role === '' ? {} : { role }),
170
+ ...(providerMatch === null ? {} : { provider: providerMatch[1]! }),
171
+ ...(modelMatch === null ? {} : { model: modelMatch[1]! }),
172
+ ...(modelProviderMatch === null ? {} : { modelProvider: modelProviderMatch[1]! }),
173
+ ...(clear ? { clear: true } : {}),
174
+ })
175
+ if ('refused' in written) return { kind: 'error', text: written.refused }
176
+ return { kind: 'success', text: written.message }
177
+ }
178
+ const policy = loadRouterPolicy(routerPolicyPath(root))
179
+ // `ladderProvider: null` on purpose: this verb reports what is CONFIGURED, and the provider ladder needs a
180
+ // live host's registered providers to answer. A caller wanting the full answer gets it from a delegation's
181
+ // routing notes, which is where the resolved ladder actually appears.
182
+ const target = resolveSubagentTarget({ role, phase, policy, ladderProvider: null })
183
+ const general = policy.defaults.subagent
184
+ return {
185
+ kind: 'success',
186
+ text: [
187
+ 'effective selection for role ' + (role === '' ? '(none given)' : role) + (phase === '' ? '' : ' in phase ' + phase) + ':',
188
+ '- subagent provider, who creates the child: ' + (target.provider ?? 'none configured') + ' [from ' + target.chosen.provider + ']',
189
+ '- model, what the child runs on: ' + (target.model ?? 'inherited — no model is sent') + ' [from ' + target.chosen.model + ']',
190
+ target.reason,
191
+ 'configured: general ' + (general === undefined ? 'unset' : JSON.stringify(general))
192
+ + ', phase routes ' + Object.keys(policy.phase_routes ?? {}).length
193
+ + ', role routes ' + Object.keys(policy.role_routes ?? {}).length,
194
+ 'a model chosen here is checked against what DSH has when a delegation runs: available is applied, missing'
195
+ + ' is reported and NOT substituted, and unverified means there was no inventory to ask.',
196
+ '⚠ changing a default applies to FUTURE delegations only — it never rewrites a run\'s recorded history.',
197
+ ].join('\n'),
198
+ }
87
199
  }
88
200
  case 'worktree': {
89
201
  // /recursive worktree create <runId> [--base <branch>]
@@ -152,7 +264,7 @@ export function registerRecursiveCommand(
152
264
  return ctx.commands.register({
153
265
  name: 'recursive',
154
266
  description: 'recursive-mode workflow commands (workspace-scoped)',
155
- input: { hint: 'status|spec|worktree|init|lock|qa|closeout|addendum|review|scratch|bootstrap|list|help' },
267
+ input: { hint: 'status|spec|worktree|init|lock|qa|closeout|addendum|review|scratch|memory|bootstrap|list|help' },
156
268
  handler: async ({ rawInput, agent }) => {
157
269
  const root = await resolveControlPlaneRoot(agent, (recursive as unknown as { workspaceRegistry?: unknown }).workspaceRegistry as never)
158
270
  if (!root) {
package/src/config.ts ADDED
@@ -0,0 +1,113 @@
1
+ /**
2
+ * T7 — the plugin's Config: the `recursive` settings namespace.
3
+ *
4
+ * HOW THE SETTINGS SERVICE ACTUALLY SEES THIS, measured against
5
+ * `@deepseek-ai/dsh-settings`'s own types rather than assumed: `describe()` "read[s]
6
+ * active plugin schemas and their live values", and `update(ns, patch, expectedRevision)`
7
+ * "merge[s] editable fields into an entry's config". There is **no `register(...)` call
8
+ * for a plugin to make** — the item's `registerEnforcementSettings(settings)` wording
9
+ * describes a seam that does not exist. **Declaring this schema IS the registration:**
10
+ * the Loader owns the profile entry, the service projects the schema into a form, and an
11
+ * edit re-applies the plugin with the new config. That re-application IS the hot reload,
12
+ * which is why there is no watcher here to keep in sync.
13
+ *
14
+ * VALIDATION IS NOT DUPLICATED. The schema is what the UI reads and what gives the form
15
+ * its shape; `resolveEnforcementConfig` remains the STRICT validator (unknown keys
16
+ * rejected, budgets required to be positive integers, fail-loud at load). A schema that
17
+ * silently coerced a bad value would be a second, weaker contract beside the real one.
18
+ */
19
+ import z from '@deepseek-ai/schemastery'
20
+ import { DEFAULT_BUDGETS } from './enforcement.ts'
21
+
22
+ /** One enforcement mode, as the settings form presents it. */
23
+ const enforcementMode = z.union([z.const('strict'), z.const('advisory')])
24
+
25
+ export interface RecursiveModeBudgets {
26
+ maxAuditRounds?: number
27
+ maxRepairAttempts?: number
28
+ maxDelegationDepth?: number
29
+ maxChildrenPerPhase?: number
30
+ maxResultBytes?: number
31
+ }
32
+
33
+ export interface RecursiveModeEnforcement {
34
+ preStep?: 'strict' | 'advisory'
35
+ toolGuards?: 'strict' | 'advisory'
36
+ tamper?: 'strict' | 'advisory'
37
+ budgets?: RecursiveModeBudgets
38
+ }
39
+
40
+ export interface RecursiveModeConfig {
41
+ /** R4 shell split: expose ONLY the client bundle, registering nothing server-side. */
42
+ shellOnly?: boolean
43
+ repoRoot?: string
44
+ /** T7/T28: the enforcement modes and the budgets, editable live. */
45
+ enforcement?: RecursiveModeEnforcement
46
+ /** T7: overrides for the workspace router file — see the schema comment on defaults. */
47
+ router?: { defaults?: Record<string, string | number | boolean | undefined> }
48
+ }
49
+
50
+ /**
51
+ * The schema the settings service discovers. Field descriptions are the form's help
52
+ * text, so they are written for the person toggling them, not for the reader of this file.
53
+ */
54
+ export const Config = z.object({
55
+ shellOnly: z.boolean().default(false).description(
56
+ 'Client-shell only: registers nothing on the server (no tools, no command, no projection).',
57
+ ),
58
+ repoRoot: z.string().description(
59
+ 'Control-plane root. Defaults to the process working directory when unset.',
60
+ ),
61
+ enforcement: z.object({
62
+ preStep: enforcementMode.default('advisory').description(
63
+ 'Phase pre-step enforcement: strict refuses an out-of-order transition, advisory warns and proceeds.',
64
+ ),
65
+ toolGuards: enforcementMode.default('advisory').description(
66
+ 'Tool guard mode: strict DENIES an out-of-order tool call, advisory allows it and carries the warning.',
67
+ ),
68
+ tamper: enforcementMode.default('advisory').description(
69
+ 'Tamper detection: strict refuses an artifact whose LockHash no longer matches its body.',
70
+ ),
71
+ budgets: z.object({
72
+ maxAuditRounds: z.natural().default(DEFAULT_BUDGETS.maxAuditRounds).description(
73
+ 'Rounds one phase audit loop may run, even while every round makes progress.',
74
+ ),
75
+ maxRepairAttempts: z.natural().default(DEFAULT_BUDGETS.maxRepairAttempts).description(
76
+ 'How many times a phase may be sent back for repair before the loop stops.',
77
+ ),
78
+ maxDelegationDepth: z.natural().default(DEFAULT_BUDGETS.maxDelegationDepth).description(
79
+ 'Ceiling for the WHOLE recursion. A child narrows it; it is never re-granted per level.',
80
+ ),
81
+ maxChildrenPerPhase: z.natural().default(DEFAULT_BUDGETS.maxChildrenPerPhase).description(
82
+ 'Delegated children one phase may start, counted by distinct operation.',
83
+ ),
84
+ maxResultBytes: z.natural().default(DEFAULT_BUDGETS.maxResultBytes).description(
85
+ 'Byte ceiling for one tool result, applied after the finding-count cap.',
86
+ ),
87
+ }).description('T28 budgets.'),
88
+ }).description('The enforcement modes plus the T28 budgets.'),
89
+ /**
90
+ * T7 — the ROUTER overrides. Deliberately NO defaults on these fields: `.default()`
91
+ * would make every field present, and a present field overrides the workspace's
92
+ * `recursive-router.json` — so defaulting them would silently shadow the declarative
93
+ * file forever. Absent means "defer to the file"; present means "override it".
94
+ */
95
+ router: z.object({
96
+ defaults: z.object({
97
+ when_role_unconfigured: z.union([z.const('ask'), z.const('fallback-local')]).description(
98
+ 'What to do when a role has no configured route. Leave unset to use the workspace router file.',
99
+ ),
100
+ when_cli_unavailable: z.union([z.const('ask'), z.const('fallback-local')]).description(
101
+ 'What to do when the routed CLI is unavailable. Leave unset to use the workspace router file.',
102
+ ),
103
+ when_model_unknown: z.union([z.const('ask'), z.const('fallback-local')]).description(
104
+ 'What to do when a routed model is not a known provider. Leave unset to use the workspace router file.',
105
+ ),
106
+ allow_auto_assign_if_single_cli: z.boolean().description(
107
+ 'Assign a role automatically when exactly one CLI is configured. Unset defers to the file.',
108
+ ),
109
+ probe_timeout_ms: z.natural().description('CLI capability probe timeout in ms. Unset defers to the file.'),
110
+ invoke_timeout_ms: z.natural().description('Routed CLI invocation timeout in ms. Unset defers to the file.'),
111
+ }).description('Router default policy fields this namespace overrides.'),
112
+ }).description('Overrides for .recursive/config/recursive-router.json — one path, not two.'),
113
+ })