@try-works/dsh-recursive-mode 0.1.5 → 0.1.6

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 (85) hide show
  1. package/lib/bootstrap.d.ts +6 -3
  2. package/lib/client/board.d.ts +4 -1
  3. package/lib/client/open-state.d.ts +20 -0
  4. package/lib/client/slots.d.ts +7 -1
  5. package/lib/client.js +135 -47
  6. package/lib/index.js +3391 -906
  7. package/lib/runtime.d.ts +12 -5
  8. package/lib/ts-lint.d.ts +205 -0
  9. package/package.json +1 -1
  10. package/src/bootstrap.ts +338 -331
  11. package/src/client/board.tsx +0 -0
  12. package/src/client/open-state.ts +46 -0
  13. package/src/client/slots.ts +41 -20
  14. package/src/commands.ts +11 -0
  15. package/src/index.ts +238 -228
  16. package/src/runtime.ts +19 -23
  17. package/src/ts-lint.ts +2110 -0
  18. package/references/scripts/__pycache__/recursive_phase_rules.cpython-314.pyc +0 -0
  19. package/references/scripts/lint-recursive-run.ps1 +0 -25
  20. package/references/scripts/lint-recursive-run.py +0 -2870
  21. package/references/scripts/recursive-closeout.ps1 +0 -38
  22. package/references/scripts/recursive-closeout.py +0 -541
  23. package/references/scripts/recursive-init.ps1 +0 -515
  24. package/references/scripts/recursive-init.py +0 -356
  25. package/references/scripts/recursive-lock.ps1 +0 -26
  26. package/references/scripts/recursive-lock.py +0 -302
  27. package/references/scripts/recursive-review-bundle.ps1 +0 -58
  28. package/references/scripts/recursive-review-bundle.py +0 -503
  29. package/references/scripts/recursive-router-cli-configure.ps1 +0 -2
  30. package/references/scripts/recursive-router-cli-configure.py +0 -9
  31. package/references/scripts/recursive-router-cli-init.ps1 +0 -2
  32. package/references/scripts/recursive-router-cli-init.py +0 -9
  33. package/references/scripts/recursive-router-cli-invoke.ps1 +0 -2
  34. package/references/scripts/recursive-router-cli-invoke.py +0 -9
  35. package/references/scripts/recursive-router-cli-probe.ps1 +0 -2
  36. package/references/scripts/recursive-router-cli-probe.py +0 -9
  37. package/references/scripts/recursive-router-cli-resolve.ps1 +0 -2
  38. package/references/scripts/recursive-router-cli-resolve.py +0 -9
  39. package/references/scripts/recursive-router-cli-validate.ps1 +0 -2
  40. package/references/scripts/recursive-router-cli-validate.py +0 -9
  41. package/references/scripts/recursive-router-configure.ps1 +0 -27
  42. package/references/scripts/recursive-router-configure.py +0 -74
  43. package/references/scripts/recursive-router-init.ps1 +0 -17
  44. package/references/scripts/recursive-router-init.py +0 -29
  45. package/references/scripts/recursive-router-invoke.ps1 +0 -47
  46. package/references/scripts/recursive-router-invoke.py +0 -103
  47. package/references/scripts/recursive-router-probe.ps1 +0 -25
  48. package/references/scripts/recursive-router-probe.py +0 -44
  49. package/references/scripts/recursive-router-resolve.ps1 +0 -26
  50. package/references/scripts/recursive-router-resolve.py +0 -46
  51. package/references/scripts/recursive-router-validate.ps1 +0 -17
  52. package/references/scripts/recursive-router-validate.py +0 -27
  53. package/references/scripts/recursive-status.ps1 +0 -23
  54. package/references/scripts/recursive-status.py +0 -2124
  55. package/references/scripts/recursive-subagent-action.ps1 +0 -98
  56. package/references/scripts/recursive-subagent-action.py +0 -197
  57. package/references/scripts/recursive-training-extract.ps1 +0 -23
  58. package/references/scripts/recursive-training-extract.py +0 -99
  59. package/references/scripts/recursive-training-grpo.ps1 +0 -52
  60. package/references/scripts/recursive-training-grpo.py +0 -1341
  61. package/references/scripts/recursive-training-loader.ps1 +0 -78
  62. package/references/scripts/recursive-training-loader.py +0 -558
  63. package/references/scripts/recursive-training-mcp.ps1 +0 -29
  64. package/references/scripts/recursive-training-mcp.py +0 -261
  65. package/references/scripts/recursive-training-phase8-trigger.ps1 +0 -55
  66. package/references/scripts/recursive-training-phase8-trigger.py +0 -137
  67. package/references/scripts/recursive-training-sync.ps1 +0 -29
  68. package/references/scripts/recursive-training-sync.py +0 -183
  69. package/references/scripts/recursive_phase_rules.py +0 -367
  70. package/references/scripts/recursive_router_cli_lib.py +0 -2
  71. package/references/scripts/recursive_router_lib.py +0 -2282
  72. package/references/scripts/verify-locks.ps1 +0 -25
  73. package/references/scripts/verify-locks.py +0 -353
  74. package/scripts/__pycache__/lint-recursive-run.cpython-314.pyc +0 -0
  75. package/scripts/__pycache__/recursive_phase_rules.cpython-314.pyc +0 -0
  76. package/scripts/install-recursive-mode.ps1 +0 -956
  77. package/scripts/install-recursive-mode.py +0 -750
  78. package/scripts/lint-recursive-run.py +0 -2870
  79. package/scripts/recursive-closeout.py +0 -541
  80. package/scripts/recursive-init.py +0 -356
  81. package/scripts/recursive-lock.py +0 -302
  82. package/scripts/recursive-status.py +0 -2124
  83. package/scripts/recursive_phase_rules.py +0 -367
  84. package/scripts/recursive_router_lib.py +0 -2282
  85. package/scripts/verify-locks.py +0 -353
package/src/bootstrap.ts CHANGED
@@ -1,332 +1,339 @@
1
- /**
2
- * Idempotent scaffold installer (R3). TS port of install-recursive-mode.py's
3
- * core: bootstrap the FULL canonical /.recursive/ control plane + cross-tool
4
- * bridges byte-identically (RECURSIVE.md marker-wrapped, AGENTS.md, STATE/
5
- * DECISIONS, memory routers + shards, config/recursive-router.json, .gitignore,
6
- * vendored runtime scripts copied into .recursive/scripts/), plus the
7
- * agent/session-start Stage B (new vs resume) workspace-scoped to the session's
8
- * control-plane root (R1).
9
- *
10
- * Templates + bodies + runtime scripts are SHIPPED package files under
11
- * references/ (never inlined TS string literals) and resolved relative to this
12
- * module (package install location), never process.cwd().
13
- */
14
- import { existsSync, mkdirSync, readdirSync, statSync, writeFileSync, readFileSync, copyFileSync } from 'node:fs'
15
- import { join, dirname } from 'node:path'
16
- import { fileURLToPath } from 'node:url'
17
-
18
- const MODULE_DIR = dirname(fileURLToPath(import.meta.url))
19
- /** Package root: <package>/src/.. — references/ sits next to src/. */
20
- export const PACKAGE_ROOT = join(MODULE_DIR, '..')
21
-
22
- /** references/ root (shipped package files). */
23
- export function referencesRoot(): string {
24
- return join(PACKAGE_ROOT, 'references')
25
- }
26
-
27
- export interface BootstrapResult {
28
- root: string
29
- bootstrapped: boolean
30
- created: string[]
31
- existing: string[]
32
- }
33
-
34
- export interface StageBResult {
35
- root: string
36
- source: 'new' | 'resume'
37
- bootstrapped: boolean
38
- runs: string[]
39
- activeRunId?: string
40
- }
41
-
42
- // Marker strings (byte-identical to the canonical installer).
43
- const M = {
44
- recursiveStart: '<!-- RECURSIVE-MODE-CANONICAL:START -->',
45
- recursiveEnd: '<!-- RECURSIVE-MODE-CANONICAL:END -->',
46
- memoryStart: '<!-- RECURSIVE-MODE-MEMORY:START -->',
47
- memoryEnd: '<!-- RECURSIVE-MODE-MEMORY:END -->',
48
- agentsStart: '<!-- RECURSIVE-MODE-AGENTS:START -->',
49
- agentsEnd: '<!-- RECURSIVE-MODE-AGENTS:END -->',
50
- plansStart: '<!-- RECURSIVE-MODE-PLANS-BRIDGE:START -->',
51
- plansEnd: '<!-- RECURSIVE-MODE-PLANS-BRIDGE:END -->',
52
- cursorrulesStart: '# RECURSIVE-MODE-MEMORY-POINTERS:START',
53
- cursorrulesEnd: '# RECURSIVE-MODE-MEMORY-POINTERS:END',
54
- repoMdStart: '<!-- RECURSIVE-MODE-MEMORY-POINTERS:START -->',
55
- repoMdEnd: '<!-- RECURSIVE-MODE-MEMORY-POINTERS:END -->',
56
- }
57
-
58
- /** write_utf8_no_bom: LF newlines, no BOM. */
59
- export function writeUtf8NoBom(path: string, content: string): void {
60
- writeFileSync(path, content, { encoding: 'utf8' })
61
- }
62
-
63
- /** ensure_gitignore_line: add the line when absent (preserving existing). */
64
- function ensureGitignoreLine(repoRoot: string, line: string): void {
65
- const gitignorePath = join(repoRoot, '.gitignore')
66
- const normalized = line.trim()
67
- const existing = existsSync(gitignorePath) ? readFileSync(gitignorePath, 'utf8') : ''
68
- const existingLines = existing.split(/\r?\n/)
69
- if (existingLines.some(c => c.trim() === normalized)) return
70
- let updated = existing
71
- if (updated && !updated.endsWith('\n')) updated += '\n'
72
- updated += normalized + '\n'
73
- writeUtf8NoBom(gitignorePath, updated)
74
- }
75
-
76
- /** upsert_marked_block: replace the START..END block, else append after existing. */
77
- function upsertMarkedBlock(filePath: string, startMarker: string, endMarker: string, blockBody: string): void {
78
- const existing = existsSync(filePath) ? readFileSync(filePath, 'utf8') : ''
79
- const block = startMarker + '\n' + blockBody + '\n' + endMarker
80
- const pattern = new RegExp(escapeRegExp(startMarker) + '[\\s\\S]*?' + escapeRegExp(endMarker))
81
- let updated: string
82
- if (pattern.test(existing)) {
83
- updated = existing.replace(pattern, block)
84
- } else if (existing.trim() !== '') {
85
- updated = existing.replace(/\s+$/, '') + '\n\n' + block + '\n'
86
- } else {
87
- updated = block + '\n'
88
- }
89
- if (updated !== existing) writeUtf8NoBom(filePath, updated)
90
- }
91
-
92
- function escapeRegExp(s: string): string {
93
- return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
94
- }
95
-
96
- /** normalize_plain_or_wrapped_content: strip markers; return plain body when wrapped. */
97
- function normalizePlainOrWrappedContent(content: string, startMarker: string, endMarker: string): string {
98
- const startIndex = content.indexOf(startMarker)
99
- const endIndex = content.lastIndexOf(endMarker)
100
- if (startIndex === -1 || endIndex === -1 || endIndex < startIndex) {
101
- return content.replace(/\r?\n+$/, '')
102
- }
103
- const prefix = content.slice(0, startIndex).replace(/\r?\n+$/, '')
104
- if (prefix.trim() !== '') return prefix
105
- const bodyStart = startIndex + startMarker.length
106
- return content.slice(bodyStart, endIndex).replace(/^\r?\n+|\r?\n+$/g, '')
107
- }
108
-
109
- /** upsert_or_migrate_canonical: canonical block upsert with plain-file migration. */
110
- function upsertOrMigrateCanonical(filePath: string, startMarker: string, endMarker: string, canonicalBody: string): void {
111
- const existing = existsSync(filePath) ? readFileSync(filePath, 'utf8') : ''
112
- const block = startMarker + '\n' + canonicalBody.replace(/\r?\n+$/, '') + '\n' + endMarker
113
- const pattern = new RegExp(escapeRegExp(startMarker) + '[\\s\\S]*?' + escapeRegExp(endMarker))
114
- let updated: string
115
- if (pattern.test(existing)) {
116
- updated = existing.replace(pattern, block)
117
- } else if (existing.trim() === '') {
118
- updated = block + '\n'
119
- } else if (existing.replace(/\r?\n+$/, '') === canonicalBody.replace(/\r?\n+$/, '')) {
120
- updated = block + '\n'
121
- } else {
122
- updated = existing.replace(/\s+$/, '') + '\n\n' + block + '\n'
123
- }
124
- if (updated !== existing) writeUtf8NoBom(filePath, updated)
125
- }
126
-
127
- /** Read a shipped body file (references/bodies/<name>) — CRLF-normalized. */
128
- function body(name: string): string {
129
- return readFileSync(join(referencesRoot(), 'bodies', name), 'utf8').replace(/\r\n/g, '\n')
130
- }
131
-
132
- /** Read the canonical RECURSIVE.md template from shipped references/bootstrap/ — CRLF-normalized. */
133
- function canonicalWorkflowContent(): string {
134
- return readFileSync(join(referencesRoot(), 'bootstrap', 'RECURSIVE.md'), 'utf8').replace(/\r\n/g, '\n')
135
- }
136
-
137
- /** Read the agents bridge block from shipped references/ — CRLF-normalized. */
138
- function agentsBlockContent(): string {
139
- return readFileSync(join(referencesRoot(), 'agents-block.md'), 'utf8').replace(/\r\n/g, '\n').replace(/\r?\n+$/, '')
140
- }
141
-
142
- /** default_router_policy(): byte-identical to recursive_router_lib.default_router_policy + pretty_json. */
143
- function defaultRouterPolicy(): string {
144
- const roleRoutes: Record<string, Record<string, unknown>> = {
145
- orchestrator: { enabled: true, mode: 'local-only', cli: null, model: null, fallback: 'local-controller' },
146
- analyst: { enabled: true, mode: 'external-cli', cli: null, model: null, fallback: 'self-audit' },
147
- planner: { enabled: true, mode: 'external-cli', cli: null, model: null, fallback: 'self-audit' },
148
- implementer: { enabled: false, mode: 'external-cli', cli: null, model: null, fallback: 'local-controller' },
149
- 'code-reviewer': { enabled: true, mode: 'external-cli', cli: null, model: null, fallback: 'self-audit' },
150
- tester: { enabled: true, mode: 'external-cli', cli: null, model: null, fallback: 'self-audit' },
151
- 'memory-auditor': { enabled: true, mode: 'external-cli', cli: null, model: null, fallback: 'self-audit' },
152
- }
153
- const policy = {
154
- version: 1,
155
- defaults: {
156
- when_role_unconfigured: 'ask',
157
- when_cli_unavailable: 'fallback-local',
158
- when_model_unknown: 'ask',
159
- allow_auto_assign_if_single_cli: false,
160
- probe_timeout_ms: 50000,
161
- invoke_timeout_ms: 180000,
162
- },
163
- role_routes: roleRoutes,
164
- cli_overrides: {},
165
- custom_clis: [],
166
- }
167
- return JSON.stringify(policy, null, 2) + '\n'
168
- }
169
-
170
- /** ensure_router_scaffold: write config/recursive-router.json when missing. */
171
- function ensureRouterScaffold(repoRoot: string): void {
172
- const configDir = join(repoRoot, '.recursive', 'config')
173
- const policyPath = join(configDir, 'recursive-router.json')
174
- if (!existsSync(policyPath)) {
175
- mkdirSync(configDir, { recursive: true })
176
- writeUtf8NoBom(policyPath, defaultRouterPolicy())
177
- }
178
- }
179
-
180
- /**
181
- * Idempotent scaffold install: reproduce the canonical installer's 82-file
182
- * tree byte-for-byte. Never overwrites existing user content; marked blocks
183
- * are re-upserted in place.
184
- */
185
- export function bootstrapScaffold(root: string): BootstrapResult {
186
- const created: string[] = []
187
- const existing: string[] = []
188
-
189
- const noteDir = (rel: string) => {
190
- const p = join(root, rel)
191
- if (existsSync(p)) existing.push(rel + '/')
192
- else { mkdirSync(p, { recursive: true }); created.push(rel + '/') }
193
- }
194
- const noteFile = (rel: string, content?: string) => {
195
- const p = join(root, rel)
196
- if (existsSync(p)) { existing.push(rel); return }
197
- if (content !== undefined) {
198
- mkdirSync(dirname(p), { recursive: true })
199
- writeUtf8NoBom(p, content)
200
- } else {
201
- mkdirSync(dirname(p), { recursive: true })
202
- writeFileSync(p, '')
203
- }
204
- created.push(rel)
205
- }
206
-
207
- const recursiveRoot = join(root, '.recursive')
208
- const memoryRoot = join(recursiveRoot, 'memory')
209
- const skillMemoryRoot = join(memoryRoot, 'skills')
210
-
211
- // Directories.
212
- for (const rel of [
213
- '.recursive', '.codex', '.agent', '.github',
214
- '.recursive/memory', '.recursive/memory/training', '.recursive/memory/skills', '.recursive/run', '.recursive/config',
215
- ]) noteDir(rel)
216
- for (const sub of ['domains', 'patterns', 'incidents', 'episodes', 'archive']) noteDir(join('.recursive/memory', sub))
217
- for (const sub of ['availability', 'usage', 'issues', 'patterns']) noteDir(join('.recursive/memory/skills', sub))
218
-
219
- // .gitkeeps.
220
- for (const rel of [
221
- '.recursive/memory/domains/.gitkeep', '.recursive/memory/patterns/.gitkeep', '.recursive/memory/incidents/.gitkeep',
222
- '.recursive/memory/episodes/.gitkeep', '.recursive/memory/archive/.gitkeep', '.recursive/memory/training/.gitkeep',
223
- '.recursive/memory/skills/availability/.gitkeep', '.recursive/memory/skills/usage/.gitkeep',
224
- '.recursive/memory/skills/issues/.gitkeep', '.recursive/memory/skills/patterns/.gitkeep', '.recursive/run/.gitkeep',
225
- ]) noteFile(rel, '')
226
-
227
- // Runtime scripts: copy vendored references/scripts/* into .recursive/scripts/.
228
- const scriptsSrc = join(referencesRoot(), 'scripts')
229
- const scriptsDst = join(recursiveRoot, 'scripts')
230
- mkdirSync(scriptsDst, { recursive: true })
231
- for (const name of readdirSync(scriptsSrc)) {
232
- const src = join(scriptsSrc, name)
233
- if (!statSync(src).isFile()) continue
234
- const dst = join(scriptsDst, name)
235
- if (!existsSync(dst) || !buffersEqual(src, dst)) {
236
- copyFileSync(src, dst)
237
- created.push('.recursive/scripts/' + name)
238
- } else {
239
- existing.push('.recursive/scripts/' + name)
240
- }
241
- }
242
-
243
- // Plain control-plane files (trailing newline).
244
- noteFile('.recursive/RECURSIVE.md', '# RECURSIVE.md\n')
245
- noteFile('.recursive/AGENTS.md', '# AGENTS.md\n')
246
- noteFile('.recursive/STATE.md', body('state.md') + '\n')
247
- noteFile('.recursive/DECISIONS.md', body('decisions.md') + '\n')
248
- noteFile('.recursive/memory/MEMORY.md', '# MEMORY.md\n')
249
- noteFile('.recursive/memory/skills/SKILLS.md', '# SKILLS.md\n')
250
- noteFile('.recursive/memory/skills/usage/skill-discovery-and-evaluation.md', body('skill-discovery.md') + '\n')
251
- noteFile('.recursive/memory/skills/patterns/delegated-verification-and-refresh.md', body('delegated-verification.md') + '\n')
252
- noteFile('.recursive/memory/skills/patterns/phase8-skill-memory-promotion.md', body('phase8-skill-memory.md') + '\n')
253
- noteFile('.codex/AGENTS.md', '# AGENTS.md\n')
254
- noteFile('.agent/PLANS.md', '# PLANS.md\n')
255
- noteFile('.cursorrules', '')
256
- noteFile('CLAUDE.md', '')
257
- noteFile('.github/copilot-instructions.md', '')
258
-
259
- // .gitignore.
260
- ensureGitignoreLine(root, '/.recursive/config/recursive-router-discovered.json')
261
-
262
- // Router config.
263
- ensureRouterScaffold(root)
264
-
265
- // Marked blocks.
266
- upsertMarkedBlock(join(recursiveRoot, 'AGENTS.md'), M.agentsStart, M.agentsEnd, body('recursive-agents-router.md'))
267
- upsertMarkedBlock(join(memoryRoot, 'MEMORY.md'), M.memoryStart, M.memoryEnd, body('memory-router.md'))
268
- upsertMarkedBlock(join(skillMemoryRoot, 'SKILLS.md'), M.memoryStart, M.memoryEnd, body('skill-memory-router.md'))
269
- upsertMarkedBlock(join(root, '.cursorrules'), M.cursorrulesStart, M.cursorrulesEnd, body('cursorrules.md'))
270
- upsertMarkedBlock(join(root, 'CLAUDE.md'), M.repoMdStart, M.repoMdEnd, body('claude.md'))
271
- upsertMarkedBlock(join(root, '.github', 'copilot-instructions.md'), M.repoMdStart, M.repoMdEnd, body('copilot.md'))
272
- upsertMarkedBlock(join(root, '.codex', 'AGENTS.md'), M.agentsStart, M.agentsEnd, body('codex-agents.md'))
273
- const rootAgents = join(root, 'AGENTS.md')
274
- if (existsSync(rootAgents)) {
275
- upsertMarkedBlock(rootAgents, M.agentsStart, M.agentsEnd, agentsBlockContent())
276
- }
277
- upsertMarkedBlock(join(root, '.agent', 'PLANS.md'), M.plansStart, M.plansEnd, body('plans-bridge.md'))
278
-
279
- // Canonical RECURSIVE.md (marker-wrapped, migrate plain installs).
280
- const canonicalBody = normalizePlainOrWrappedContent(canonicalWorkflowContent(), M.recursiveStart, M.recursiveEnd)
281
- upsertOrMigrateCanonical(join(recursiveRoot, 'RECURSIVE.md'), M.recursiveStart, M.recursiveEnd, canonicalBody)
282
-
283
- return { root, bootstrapped: created.length > 0, created, existing }
284
- }
285
-
286
- function buffersEqual(a: string, b: string): boolean {
287
- const ab = readFileSync(a)
288
- const bb = readFileSync(b)
289
- return ab.length === bb.length && ab.equals(bb)
290
- }
291
-
292
- /**
293
- * Enumerate runs as directory names only (bounded O(#dirs)). Never reads
294
- * run docs; never returns file paths.
295
- */
296
- export function enumerateRuns(root: string): string[] {
297
- const runRoot = join(root, '.recursive', 'run')
298
- if (!existsSync(runRoot)) return []
299
- return readdirSync(runRoot, { withFileTypes: true })
300
- .filter(d => {
301
- if (d.isDirectory()) return true
302
- if (!d.isSymbolicLink()) return false
303
- try { return statSync(join(runRoot, d.name)).isDirectory() } catch { return false }
304
- })
305
- .map(d => d.name)
306
- .sort()
307
- }
308
-
309
- export interface StageBInput {
310
- root: string
311
- source: 'new' | 'resume'
312
- activeRunId?: string
313
- }
314
-
315
- /**
316
- * Stage B workflow init at agent/session-start. New: bootstrap if missing +
317
- * enumerate runs. Resume: never re-bootstrap; enumerate + note the active run
318
- * (the caller re-reads only that run's current phase doc). Always scoped to
319
- * the given control-plane root.
320
- */
321
- export function stageBWorkflowInit(input: StageBInput): StageBResult {
322
- const { root, source, activeRunId } = input
323
- const bootstrap = bootstrapScaffold(root)
324
- const runs = enumerateRuns(root)
325
- return {
326
- root,
327
- source,
328
- bootstrapped: source === 'new' ? bootstrap.bootstrapped : false,
329
- runs,
330
- activeRunId,
331
- }
1
+ /**
2
+ * Idempotent scaffold installer (R3). TS port of install-recursive-mode.py's
3
+ * core: bootstrap the FULL canonical /.recursive/ control plane + cross-tool
4
+ * bridges byte-identically (RECURSIVE.md marker-wrapped, AGENTS.md, STATE/
5
+ * DECISIONS, memory routers + shards, config/recursive-router.json, .gitignore,
6
+ * vendored runtime scripts copied into .recursive/scripts/), plus the
7
+ * agent/session-start Stage B (new vs resume) workspace-scoped to the session's
8
+ * control-plane root (R1).
9
+ *
10
+ * Templates + bodies + runtime scripts are SHIPPED package files under
11
+ * references/ (never inlined TS string literals) and resolved relative to this
12
+ * module (package install location), never process.cwd().
13
+ */
14
+ import { existsSync, mkdirSync, readdirSync, statSync, writeFileSync, readFileSync, rmSync } from 'node:fs'
15
+ import { join, dirname } from 'node:path'
16
+ import { fileURLToPath } from 'node:url'
17
+
18
+ const MODULE_DIR = dirname(fileURLToPath(import.meta.url))
19
+ /** Package root: <package>/src/.. — references/ sits next to src/. */
20
+ export const PACKAGE_ROOT = join(MODULE_DIR, '..')
21
+
22
+ /** references/ root (shipped package files). */
23
+ export function referencesRoot(): string {
24
+ return join(PACKAGE_ROOT, 'references')
25
+ }
26
+
27
+ export interface BootstrapResult {
28
+ root: string
29
+ bootstrapped: boolean
30
+ created: string[]
31
+ existing: string[]
32
+ }
33
+
34
+ export interface StageBResult {
35
+ root: string
36
+ source: 'new' | 'resume'
37
+ bootstrapped: boolean
38
+ runs: string[]
39
+ activeRunId?: string
40
+ }
41
+
42
+ // Marker strings (byte-identical to the canonical installer).
43
+ const M = {
44
+ recursiveStart: '<!-- RECURSIVE-MODE-CANONICAL:START -->',
45
+ recursiveEnd: '<!-- RECURSIVE-MODE-CANONICAL:END -->',
46
+ memoryStart: '<!-- RECURSIVE-MODE-MEMORY:START -->',
47
+ memoryEnd: '<!-- RECURSIVE-MODE-MEMORY:END -->',
48
+ agentsStart: '<!-- RECURSIVE-MODE-AGENTS:START -->',
49
+ agentsEnd: '<!-- RECURSIVE-MODE-AGENTS:END -->',
50
+ plansStart: '<!-- RECURSIVE-MODE-PLANS-BRIDGE:START -->',
51
+ plansEnd: '<!-- RECURSIVE-MODE-PLANS-BRIDGE:END -->',
52
+ cursorrulesStart: '# RECURSIVE-MODE-MEMORY-POINTERS:START',
53
+ cursorrulesEnd: '# RECURSIVE-MODE-MEMORY-POINTERS:END',
54
+ repoMdStart: '<!-- RECURSIVE-MODE-MEMORY-POINTERS:START -->',
55
+ repoMdEnd: '<!-- RECURSIVE-MODE-MEMORY-POINTERS:END -->',
56
+ }
57
+
58
+ /** write_utf8_no_bom: LF newlines, no BOM. */
59
+ export function writeUtf8NoBom(path: string, content: string): void {
60
+ writeFileSync(path, content, { encoding: 'utf8' })
61
+ }
62
+
63
+ /** ensure_gitignore_line: add the line when absent (preserving existing). */
64
+ function ensureGitignoreLine(repoRoot: string, line: string): void {
65
+ const gitignorePath = join(repoRoot, '.gitignore')
66
+ const normalized = line.trim()
67
+ const existing = existsSync(gitignorePath) ? readFileSync(gitignorePath, 'utf8') : ''
68
+ const existingLines = existing.split(/\r?\n/)
69
+ if (existingLines.some(c => c.trim() === normalized)) return
70
+ let updated = existing
71
+ if (updated && !updated.endsWith('\n')) updated += '\n'
72
+ updated += normalized + '\n'
73
+ writeUtf8NoBom(gitignorePath, updated)
74
+ }
75
+
76
+ /** upsert_marked_block: replace the START..END block, else append after existing. */
77
+ function upsertMarkedBlock(filePath: string, startMarker: string, endMarker: string, blockBody: string): void {
78
+ const existing = existsSync(filePath) ? readFileSync(filePath, 'utf8') : ''
79
+ const block = startMarker + '\n' + blockBody + '\n' + endMarker
80
+ const pattern = new RegExp(escapeRegExp(startMarker) + '[\\s\\S]*?' + escapeRegExp(endMarker))
81
+ let updated: string
82
+ if (pattern.test(existing)) {
83
+ updated = existing.replace(pattern, block)
84
+ } else if (existing.trim() !== '') {
85
+ updated = existing.replace(/\s+$/, '') + '\n\n' + block + '\n'
86
+ } else {
87
+ updated = block + '\n'
88
+ }
89
+ if (updated !== existing) writeUtf8NoBom(filePath, updated)
90
+ }
91
+
92
+ function escapeRegExp(s: string): string {
93
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
94
+ }
95
+
96
+ /** normalize_plain_or_wrapped_content: strip markers; return plain body when wrapped. */
97
+ function normalizePlainOrWrappedContent(content: string, startMarker: string, endMarker: string): string {
98
+ const startIndex = content.indexOf(startMarker)
99
+ const endIndex = content.lastIndexOf(endMarker)
100
+ if (startIndex === -1 || endIndex === -1 || endIndex < startIndex) {
101
+ return content.replace(/\r?\n+$/, '')
102
+ }
103
+ const prefix = content.slice(0, startIndex).replace(/\r?\n+$/, '')
104
+ if (prefix.trim() !== '') return prefix
105
+ const bodyStart = startIndex + startMarker.length
106
+ return content.slice(bodyStart, endIndex).replace(/^\r?\n+|\r?\n+$/g, '')
107
+ }
108
+
109
+ /** upsert_or_migrate_canonical: canonical block upsert with plain-file migration. */
110
+ function upsertOrMigrateCanonical(filePath: string, startMarker: string, endMarker: string, canonicalBody: string): void {
111
+ const existing = existsSync(filePath) ? readFileSync(filePath, 'utf8') : ''
112
+ const block = startMarker + '\n' + canonicalBody.replace(/\r?\n+$/, '') + '\n' + endMarker
113
+ const pattern = new RegExp(escapeRegExp(startMarker) + '[\\s\\S]*?' + escapeRegExp(endMarker))
114
+ let updated: string
115
+ if (pattern.test(existing)) {
116
+ updated = existing.replace(pattern, block)
117
+ } else if (existing.trim() === '') {
118
+ updated = block + '\n'
119
+ } else if (existing.replace(/\r?\n+$/, '') === canonicalBody.replace(/\r?\n+$/, '')) {
120
+ updated = block + '\n'
121
+ } else {
122
+ updated = existing.replace(/\s+$/, '') + '\n\n' + block + '\n'
123
+ }
124
+ if (updated !== existing) writeUtf8NoBom(filePath, updated)
125
+ }
126
+
127
+ /** Read a shipped body file (references/bodies/<name>) — CRLF-normalized. */
128
+ function body(name: string): string {
129
+ return readFileSync(join(referencesRoot(), 'bodies', name), 'utf8').replace(/\r\n/g, '\n')
130
+ }
131
+
132
+ /** Read the canonical RECURSIVE.md template from shipped references/bootstrap/ — CRLF-normalized. */
133
+ function canonicalWorkflowContent(): string {
134
+ return readFileSync(join(referencesRoot(), 'bootstrap', 'RECURSIVE.md'), 'utf8').replace(/\r\n/g, '\n')
135
+ }
136
+
137
+ /** Read the agents bridge block from shipped references/ — CRLF-normalized. */
138
+ function agentsBlockContent(): string {
139
+ return readFileSync(join(referencesRoot(), 'agents-block.md'), 'utf8').replace(/\r\n/g, '\n').replace(/\r?\n+$/, '')
140
+ }
141
+
142
+ /** default_router_policy(): byte-identical to recursive_router_lib.default_router_policy + pretty_json. */
143
+ function defaultRouterPolicy(): string {
144
+ const roleRoutes: Record<string, Record<string, unknown>> = {
145
+ orchestrator: { enabled: true, mode: 'local-only', cli: null, model: null, fallback: 'local-controller' },
146
+ analyst: { enabled: true, mode: 'external-cli', cli: null, model: null, fallback: 'self-audit' },
147
+ planner: { enabled: true, mode: 'external-cli', cli: null, model: null, fallback: 'self-audit' },
148
+ implementer: { enabled: false, mode: 'external-cli', cli: null, model: null, fallback: 'local-controller' },
149
+ 'code-reviewer': { enabled: true, mode: 'external-cli', cli: null, model: null, fallback: 'self-audit' },
150
+ tester: { enabled: true, mode: 'external-cli', cli: null, model: null, fallback: 'self-audit' },
151
+ 'memory-auditor': { enabled: true, mode: 'external-cli', cli: null, model: null, fallback: 'self-audit' },
152
+ }
153
+ const policy = {
154
+ version: 1,
155
+ defaults: {
156
+ when_role_unconfigured: 'ask',
157
+ when_cli_unavailable: 'fallback-local',
158
+ when_model_unknown: 'ask',
159
+ allow_auto_assign_if_single_cli: false,
160
+ probe_timeout_ms: 50000,
161
+ invoke_timeout_ms: 180000,
162
+ },
163
+ role_routes: roleRoutes,
164
+ cli_overrides: {},
165
+ custom_clis: [],
166
+ }
167
+ return JSON.stringify(policy, null, 2) + '\n'
168
+ }
169
+
170
+ /** ensure_router_scaffold: write config/recursive-router.json when missing. */
171
+ function ensureRouterScaffold(repoRoot: string): void {
172
+ const configDir = join(repoRoot, '.recursive', 'config')
173
+ const policyPath = join(configDir, 'recursive-router.json')
174
+ if (!existsSync(policyPath)) {
175
+ mkdirSync(configDir, { recursive: true })
176
+ writeUtf8NoBom(policyPath, defaultRouterPolicy())
177
+ }
178
+ }
179
+
180
+ /**
181
+ * Idempotent scaffold install: reproduce the canonical installer's 82-file
182
+ * tree byte-for-byte. Never overwrites existing user content; marked blocks
183
+ * are re-upserted in place.
184
+ */
185
+ export function bootstrapScaffold(root: string): BootstrapResult {
186
+ const created: string[] = []
187
+ const existing: string[] = []
188
+
189
+ const noteDir = (rel: string) => {
190
+ const p = join(root, rel)
191
+ if (existsSync(p)) existing.push(rel + '/')
192
+ else { mkdirSync(p, { recursive: true }); created.push(rel + '/') }
193
+ }
194
+ const noteFile = (rel: string, content?: string) => {
195
+ const p = join(root, rel)
196
+ if (existsSync(p)) { existing.push(rel); return }
197
+ if (content !== undefined) {
198
+ mkdirSync(dirname(p), { recursive: true })
199
+ writeUtf8NoBom(p, content)
200
+ } else {
201
+ mkdirSync(dirname(p), { recursive: true })
202
+ writeFileSync(p, '')
203
+ }
204
+ created.push(rel)
205
+ }
206
+
207
+ const recursiveRoot = join(root, '.recursive')
208
+ const memoryRoot = join(recursiveRoot, 'memory')
209
+ const skillMemoryRoot = join(memoryRoot, 'skills')
210
+
211
+ // Directories.
212
+ for (const rel of [
213
+ '.recursive', '.codex', '.agent', '.github',
214
+ '.recursive/memory', '.recursive/memory/training', '.recursive/memory/skills', '.recursive/run', '.recursive/config',
215
+ ]) noteDir(rel)
216
+ for (const sub of ['domains', 'patterns', 'incidents', 'episodes', 'archive']) noteDir(join('.recursive/memory', sub))
217
+ for (const sub of ['availability', 'usage', 'issues', 'patterns']) noteDir(join('.recursive/memory/skills', sub))
218
+
219
+ // .gitkeeps.
220
+ for (const rel of [
221
+ '.recursive/memory/domains/.gitkeep', '.recursive/memory/patterns/.gitkeep', '.recursive/memory/incidents/.gitkeep',
222
+ '.recursive/memory/episodes/.gitkeep', '.recursive/memory/archive/.gitkeep', '.recursive/memory/training/.gitkeep',
223
+ '.recursive/memory/skills/availability/.gitkeep', '.recursive/memory/skills/usage/.gitkeep',
224
+ '.recursive/memory/skills/issues/.gitkeep', '.recursive/memory/skills/patterns/.gitkeep', '.recursive/run/.gitkeep',
225
+ ]) noteFile(rel, '')
226
+
227
+ // Runtime scripts: TS-only scaffold (R2). No .py/.ps1 are vendored; the
228
+ // plugin's lint/lock/status/init run in-process via TS tools, so the
229
+ // scaffold carries an empty .recursive/scripts/ dir (kept for canonical
230
+ // tree-shape parity with the golden fixture).
231
+ noteDir('.recursive/scripts')
232
+ // R3 (run 09): TS-only repair — drop python-era artifacts from an EXISTING
233
+ // workspace's .recursive/scripts/ (the pre-0.1.6 scaffold vendored 29 .py +
234
+ // .ps1 wrappers; those are no longer shipped or needed). Deletes only
235
+ // .py/.ps1 under scripts/; never touches user content elsewhere.
236
+ {
237
+ const scriptsDir = join(recursiveRoot, 'scripts')
238
+ if (existsSync(scriptsDir)) {
239
+ for (const name of readdirSync(scriptsDir)) {
240
+ if (name.endsWith('.py') || name.endsWith('.ps1')) {
241
+ rmSync(join(scriptsDir, name), { force: true })
242
+ }
243
+ }
244
+ }
245
+ }
246
+
247
+ // Plain control-plane files (trailing newline).
248
+ noteFile('.recursive/RECURSIVE.md', '# RECURSIVE.md\n')
249
+ noteFile('.recursive/AGENTS.md', '# AGENTS.md\n')
250
+ noteFile('.recursive/STATE.md', body('state.md') + '\n')
251
+ noteFile('.recursive/DECISIONS.md', body('decisions.md') + '\n')
252
+ noteFile('.recursive/memory/MEMORY.md', '# MEMORY.md\n')
253
+ noteFile('.recursive/memory/skills/SKILLS.md', '# SKILLS.md\n')
254
+ noteFile('.recursive/memory/skills/usage/skill-discovery-and-evaluation.md', body('skill-discovery.md') + '\n')
255
+ noteFile('.recursive/memory/skills/patterns/delegated-verification-and-refresh.md', body('delegated-verification.md') + '\n')
256
+ noteFile('.recursive/memory/skills/patterns/phase8-skill-memory-promotion.md', body('phase8-skill-memory.md') + '\n')
257
+ noteFile('.codex/AGENTS.md', '# AGENTS.md\n')
258
+ noteFile('.agent/PLANS.md', '# PLANS.md\n')
259
+ noteFile('.cursorrules', '')
260
+ noteFile('CLAUDE.md', '')
261
+ noteFile('.github/copilot-instructions.md', '')
262
+
263
+ // .gitignore.
264
+ ensureGitignoreLine(root, '/.recursive/config/recursive-router-discovered.json')
265
+
266
+ // Router config.
267
+ ensureRouterScaffold(root)
268
+
269
+ // Marked blocks.
270
+ upsertMarkedBlock(join(recursiveRoot, 'AGENTS.md'), M.agentsStart, M.agentsEnd, body('recursive-agents-router.md'))
271
+ upsertMarkedBlock(join(memoryRoot, 'MEMORY.md'), M.memoryStart, M.memoryEnd, body('memory-router.md'))
272
+ upsertMarkedBlock(join(skillMemoryRoot, 'SKILLS.md'), M.memoryStart, M.memoryEnd, body('skill-memory-router.md'))
273
+ upsertMarkedBlock(join(root, '.cursorrules'), M.cursorrulesStart, M.cursorrulesEnd, body('cursorrules.md'))
274
+ upsertMarkedBlock(join(root, 'CLAUDE.md'), M.repoMdStart, M.repoMdEnd, body('claude.md'))
275
+ upsertMarkedBlock(join(root, '.github', 'copilot-instructions.md'), M.repoMdStart, M.repoMdEnd, body('copilot.md'))
276
+ upsertMarkedBlock(join(root, '.codex', 'AGENTS.md'), M.agentsStart, M.agentsEnd, body('codex-agents.md'))
277
+ const rootAgents = join(root, 'AGENTS.md')
278
+ if (existsSync(rootAgents)) {
279
+ upsertMarkedBlock(rootAgents, M.agentsStart, M.agentsEnd, agentsBlockContent())
280
+ }
281
+ upsertMarkedBlock(join(root, '.agent', 'PLANS.md'), M.plansStart, M.plansEnd, body('plans-bridge.md'))
282
+
283
+ // Canonical RECURSIVE.md (marker-wrapped, migrate plain installs).
284
+ const canonicalBody = normalizePlainOrWrappedContent(canonicalWorkflowContent(), M.recursiveStart, M.recursiveEnd)
285
+ upsertOrMigrateCanonical(join(recursiveRoot, 'RECURSIVE.md'), M.recursiveStart, M.recursiveEnd, canonicalBody)
286
+
287
+ return { root, bootstrapped: created.length > 0, created, existing }
288
+ }
289
+
290
+ function buffersEqual(a: string, b: string): boolean {
291
+ const ab = readFileSync(a)
292
+ const bb = readFileSync(b)
293
+ return ab.length === bb.length && ab.equals(bb)
294
+ }
295
+
296
+ /**
297
+ * Enumerate runs as directory names only (bounded O(#dirs)). Never reads
298
+ * run docs; never returns file paths.
299
+ */
300
+ export function enumerateRuns(root: string): string[] {
301
+ const runRoot = join(root, '.recursive', 'run')
302
+ if (!existsSync(runRoot)) return []
303
+ return readdirSync(runRoot, { withFileTypes: true })
304
+ .filter(d => {
305
+ if (d.isDirectory()) return true
306
+ if (!d.isSymbolicLink()) return false
307
+ try { return statSync(join(runRoot, d.name)).isDirectory() } catch { return false }
308
+ })
309
+ .map(d => d.name)
310
+ .sort()
311
+ }
312
+
313
+ export interface StageBInput {
314
+ root: string
315
+ source: 'new' | 'resume'
316
+ activeRunId?: string
317
+ }
318
+
319
+ /**
320
+ * Stage B workflow init at agent/session-start. New: bootstrap if missing +
321
+ * enumerate runs. Resume: idempotent REPAIR (not a full re-bootstrap) the
322
+ * upsert-only bootstrapScaffold adds missing control-plane files/dirs and
323
+ * re-upserts marked blocks WITHOUT overwriting user content or touching run/
324
+ * artifacts, then enumerates + notes the active run. The repair is safe on
325
+ * fresh, partial, AND complete scaffolds (run 09 R3/R6). Always scoped to the
326
+ * given control-plane root.
327
+ */
328
+ export function stageBWorkflowInit(input: StageBInput): StageBResult {
329
+ const { root, source, activeRunId } = input
330
+ const bootstrap = bootstrapScaffold(root)
331
+ const runs = enumerateRuns(root)
332
+ return {
333
+ root,
334
+ source,
335
+ bootstrapped: source === 'new' ? bootstrap.bootstrapped : false,
336
+ runs,
337
+ activeRunId,
338
+ }
332
339
  }