@try-works/dsh-recursive-mode 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/cordis.patch.yml +12 -0
- package/lib/bootstrap.d.ts +35 -0
- package/lib/client/board.d.ts +10 -0
- package/lib/client/contract.d.ts +51 -0
- package/lib/client/derive.d.ts +92 -0
- package/lib/client/index.d.ts +21 -0
- package/lib/client/inspector.d.ts +10 -0
- package/lib/client/node.d.ts +71 -0
- package/lib/client/settings.d.ts +6 -0
- package/lib/client/slots.d.ts +7 -0
- package/lib/client/strip.d.ts +7 -0
- package/lib/client.d.ts +10 -0
- package/lib/client.js +490 -0
- package/lib/closeout.d.ts +23 -0
- package/lib/commands.d.ts +51 -0
- package/lib/delegation.d.ts +92 -0
- package/lib/enforcement.d.ts +53 -0
- package/lib/events.d.ts +173 -0
- package/lib/handoff.d.ts +51 -0
- package/lib/index.d.ts +40 -0
- package/lib/lifecycle.d.ts +107 -0
- package/lib/lock.d.ts +92 -0
- package/lib/policy.d.ts +12 -0
- package/lib/projection.d.ts +29 -0
- package/lib/recursive_closeout.tool.d.ts +8 -0
- package/lib/recursive_init.tool.d.ts +2 -0
- package/lib/recursive_lint.tool.d.ts +2 -0
- package/lib/recursive_lock.tool.d.ts +2 -0
- package/lib/recursive_scratch.tool.d.ts +7 -0
- package/lib/recursive_status.tool.d.ts +2 -0
- package/lib/review.d.ts +39 -0
- package/lib/router.d.ts +77 -0
- package/lib/run.d.ts +29 -0
- package/lib/runtime.d.ts +241 -0
- package/lib/scratch.d.ts +18 -0
- package/lib/status.d.ts +19 -0
- package/lib/types.d.ts +104 -0
- package/lib/workspace.d.ts +50 -0
- package/package.json +119 -0
- package/preset/recursive/agent.cordis.yml +282 -0
- package/preset/recursive/preset.yml +3 -0
- package/scripts/install-recursive-mode.ps1 +956 -0
- package/scripts/install-recursive-mode.py +750 -0
- package/scripts/lint-recursive-run.py +2868 -0
- package/scripts/recursive-closeout.py +541 -0
- package/scripts/recursive-init.py +356 -0
- package/scripts/recursive-lock.py +302 -0
- package/scripts/recursive-status.py +2124 -0
- package/scripts/recursive_phase_rules.py +367 -0
- package/scripts/recursive_router_lib.py +2282 -0
- package/scripts/test-recursive-mode-smoke.ts +204 -0
- package/scripts/verify-locks.py +353 -0
- package/src/bootstrap.ts +118 -0
- package/src/client/board.tsx +61 -0
- package/src/client/contract.ts +58 -0
- package/src/client/derive.ts +241 -0
- package/src/client/index.ts +28 -0
- package/src/client/inspector.tsx +49 -0
- package/src/client/node.ts +156 -0
- package/src/client/settings.tsx +18 -0
- package/src/client/slots.ts +67 -0
- package/src/client/strip.tsx +28 -0
- package/src/client.ts +11 -0
- package/src/closeout.ts +183 -0
- package/src/commands.ts +142 -0
- package/src/delegation.ts +306 -0
- package/src/enforcement.ts +180 -0
- package/src/events.ts +173 -0
- package/src/handoff.ts +165 -0
- package/src/index.ts +283 -0
- package/src/lifecycle.ts +235 -0
- package/src/lock.ts +369 -0
- package/src/policy.ts +56 -0
- package/src/projection.ts +237 -0
- package/src/recursive_closeout.tool.ts +35 -0
- package/src/recursive_init.tool.ts +28 -0
- package/src/recursive_lint.tool.ts +29 -0
- package/src/recursive_lock.tool.ts +33 -0
- package/src/recursive_scratch.tool.ts +42 -0
- package/src/recursive_status.tool.ts +24 -0
- package/src/review.ts +178 -0
- package/src/router.ts +197 -0
- package/src/run.ts +85 -0
- package/src/runtime.ts +564 -0
- package/src/scratch.ts +85 -0
- package/src/status.ts +194 -0
- package/src/types.ts +112 -0
- package/src/workspace.ts +67 -0
package/src/lock.ts
ADDED
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Lock-hash + lock-chain validation for recursive-mode runs.
|
|
7
|
+
*
|
|
8
|
+
* Ports recursive-lock.py / verify-locks.py / recursive_phase_rules.py
|
|
9
|
+
* semantics (byte-for-byte where observable):
|
|
10
|
+
* - lockHashFromContent: LF-normalize, strip LockHash lines, SHA-256 hex
|
|
11
|
+
* - getLockStatus: MISSING / DRAFT / STALE_LOCK / LOCKED
|
|
12
|
+
* - getPrerequisites / getPrerequisiteBlockers: sequence ordering
|
|
13
|
+
* - validateChain: per-phase validity + break + next legal phase
|
|
14
|
+
* - receipts: read/write/invalidate with Python-identical JSON
|
|
15
|
+
*
|
|
16
|
+
* R3 (run 02).
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export const PHASE_SEQUENCE = [
|
|
20
|
+
'00-requirements.md',
|
|
21
|
+
'00-worktree.md',
|
|
22
|
+
'01-as-is.md',
|
|
23
|
+
'01.5-root-cause.md',
|
|
24
|
+
'02-to-be-plan.md',
|
|
25
|
+
'03-implementation-summary.md',
|
|
26
|
+
'03.5-code-review.md',
|
|
27
|
+
'04-test-summary.md',
|
|
28
|
+
'05-manual-qa.md',
|
|
29
|
+
'06-decisions-update.md',
|
|
30
|
+
'07-state-update.md',
|
|
31
|
+
'08-memory-impact.md',
|
|
32
|
+
] as const
|
|
33
|
+
|
|
34
|
+
export const OPTIONAL_PHASES = new Set([
|
|
35
|
+
'01-as-is.md',
|
|
36
|
+
'01.5-root-cause.md',
|
|
37
|
+
'02-to-be-plan.md',
|
|
38
|
+
'03-implementation-summary.md',
|
|
39
|
+
'03.5-code-review.md',
|
|
40
|
+
'04-test-summary.md',
|
|
41
|
+
'05-manual-qa.md',
|
|
42
|
+
])
|
|
43
|
+
|
|
44
|
+
const LOCK_HASH_LINE_RE = /^[ \t]*LockHash:.*(?:\n|$)/gm
|
|
45
|
+
const STATUS_RE = new RegExp('^[ \\t]*Status:\\s*(?:`|")?(\\w+)(?:`|")?\\s*$', 'm')
|
|
46
|
+
const LOCK_HASH_RE = new RegExp('^[ \\t]*LockHash:\\s*(?:`|")?([a-fA-F0-9]{64})(?:`|")?\\s*$', 'm')
|
|
47
|
+
const LOCKED_AT_RE = new RegExp('^[ \\t]*LockedAt:\\s*(?:`|")?([^`"\\r\\n]+)(?:`|")?\\s*$', 'm')
|
|
48
|
+
|
|
49
|
+
export interface LockReceipt {
|
|
50
|
+
artifact: string
|
|
51
|
+
artifact_path: string
|
|
52
|
+
artifact_hash: string
|
|
53
|
+
locked_at: string
|
|
54
|
+
prerequisite_hashes: Record<string, string>
|
|
55
|
+
previous_receipt_hash: string | null
|
|
56
|
+
receipt_hash: string
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export type LockStatus = 'MISSING' | 'DRAFT' | 'STALE_LOCK' | 'LOCKED'
|
|
60
|
+
|
|
61
|
+
export interface PrerequisiteBlocker {
|
|
62
|
+
artifact: string
|
|
63
|
+
status: string
|
|
64
|
+
path: string
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface StaleDownstream {
|
|
68
|
+
artifact: string
|
|
69
|
+
reason: string
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface ChainPhaseResult {
|
|
73
|
+
file: string
|
|
74
|
+
status: LockStatus
|
|
75
|
+
lockValid: boolean
|
|
76
|
+
lockProblems: string[]
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface LockChainResult {
|
|
80
|
+
runId: string
|
|
81
|
+
phases: ChainPhaseResult[]
|
|
82
|
+
breakPhase: string | null
|
|
83
|
+
nextLegalPhase: string | null
|
|
84
|
+
complete: boolean
|
|
85
|
+
staleReceipts: StaleDownstream[]
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** LF-normalize, strip every LockHash: line, return the normalized content. */
|
|
89
|
+
export function normalizeForLockHash(content: string): string {
|
|
90
|
+
const normalized = content.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
|
|
91
|
+
return normalized.replace(LOCK_HASH_LINE_RE, '')
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** SHA-256 hex over the UTF-8 bytes of the normalized content. */
|
|
95
|
+
export function lockHashFromContent(content: string): string {
|
|
96
|
+
const normalized = normalizeForLockHash(content)
|
|
97
|
+
return createHash('sha256').update(normalized, 'utf8').digest('hex')
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function phaseIndex(artifactFile: string): number {
|
|
101
|
+
return PHASE_SEQUENCE.indexOf(artifactFile as (typeof PHASE_SEQUENCE)[number])
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function isCoreArtifact(artifactFile: string): boolean {
|
|
105
|
+
return phaseIndex(artifactFile) >= 0
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Every earlier phase in PHASE_SEQUENCE that exists on disk.
|
|
110
|
+
* Mirrors: [phase for phase in PHASE_SEQUENCE[:idx] if (run_dir / phase).exists()]
|
|
111
|
+
*/
|
|
112
|
+
export function getPrerequisites(runDir: string, artifactFile: string): string[] {
|
|
113
|
+
const idx = phaseIndex(artifactFile)
|
|
114
|
+
if (idx <= 0) return []
|
|
115
|
+
const prereqs: string[] = []
|
|
116
|
+
for (const phase of PHASE_SEQUENCE.slice(0, idx)) {
|
|
117
|
+
if (existsSync(join(runDir, phase))) prereqs.push(phase)
|
|
118
|
+
}
|
|
119
|
+
return prereqs
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Canonical lock-validity classifier:
|
|
124
|
+
* MISSING (no file) / DRAFT (not LOCKED) / STALE_LOCK (missing or mismatched hash fields) / LOCKED.
|
|
125
|
+
*/
|
|
126
|
+
export function getLockStatus(artifactPath: string): LockStatus {
|
|
127
|
+
if (!existsSync(artifactPath)) return 'MISSING'
|
|
128
|
+
let content: string
|
|
129
|
+
try { content = readFileSync(artifactPath, 'utf8') } catch { return 'MISSING' }
|
|
130
|
+
const statusMatch = STATUS_RE.exec(content)
|
|
131
|
+
if (!statusMatch || statusMatch[1] !== 'LOCKED') return 'DRAFT'
|
|
132
|
+
const hashMatch = LOCK_HASH_RE.exec(content)
|
|
133
|
+
const lockedAtMatch = LOCKED_AT_RE.exec(content)
|
|
134
|
+
if (!hashMatch || !lockedAtMatch) return 'STALE_LOCK'
|
|
135
|
+
const storedHash = hashMatch[1].toLowerCase()
|
|
136
|
+
const actualHash = lockHashFromContent(content)
|
|
137
|
+
if (storedHash !== actualHash) return 'STALE_LOCK'
|
|
138
|
+
return 'LOCKED'
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Every prerequisite whose status is not LOCKED, in PHASE_SEQUENCE order. */
|
|
142
|
+
export function getPrerequisiteBlockers(runDir: string, artifactFile: string): PrerequisiteBlocker[] {
|
|
143
|
+
const blockers: PrerequisiteBlocker[] = []
|
|
144
|
+
for (const prereq of getPrerequisites(runDir, artifactFile)) {
|
|
145
|
+
const status = getLockStatus(join(runDir, prereq))
|
|
146
|
+
if (status !== 'LOCKED') {
|
|
147
|
+
blockers.push({ artifact: prereq, status, path: join(runDir, prereq) })
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return blockers
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function receiptPath(runDir: string, artifactFile: string): string {
|
|
154
|
+
const stem = artifactFile.replace(/\.md$/, '')
|
|
155
|
+
return join(runDir, 'locks', stem + '.receipt.json')
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function readReceipt(runDir: string, artifactFile: string): LockReceipt | null {
|
|
159
|
+
const rpath = receiptPath(runDir, artifactFile)
|
|
160
|
+
if (!existsSync(rpath)) return null
|
|
161
|
+
try {
|
|
162
|
+
return JSON.parse(readFileSync(rpath, 'utf8')) as LockReceipt
|
|
163
|
+
} catch {
|
|
164
|
+
return null
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Write a lock receipt with Python-identical JSON semantics.
|
|
170
|
+
*/
|
|
171
|
+
export function writeReceipt(runDir: string, artifactFile: string, artifactPath: string): LockReceipt {
|
|
172
|
+
const content = readFileSync(artifactPath, 'utf8')
|
|
173
|
+
const artifactHash = lockHashFromContent(content)
|
|
174
|
+
|
|
175
|
+
const prereqHashes: Record<string, string> = {}
|
|
176
|
+
for (const prereq of getPrerequisites(runDir, artifactFile)) {
|
|
177
|
+
const prereqPath = join(runDir, prereq)
|
|
178
|
+
if (existsSync(prereqPath)) {
|
|
179
|
+
const prereqStatus = getLockStatus(prereqPath)
|
|
180
|
+
if (prereqStatus !== 'LOCKED') {
|
|
181
|
+
throw new Error(`Cannot write receipt for '${artifactFile}': prerequisite '${prereq}' is not LOCKED (status: ${prereqStatus})`)
|
|
182
|
+
}
|
|
183
|
+
prereqHashes[prereq] = lockHashFromContent(readFileSync(prereqPath, 'utf8'))
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const existing = readReceipt(runDir, artifactFile)
|
|
188
|
+
const prevReceiptHash: string | null = existing ? existing.receipt_hash : null
|
|
189
|
+
|
|
190
|
+
const now = new Date()
|
|
191
|
+
const lockedAt = now.toISOString().replace(/\.\d{3}Z$/, 'Z')
|
|
192
|
+
|
|
193
|
+
const base: Omit<LockReceipt, 'receipt_hash'> = {
|
|
194
|
+
artifact: artifactFile,
|
|
195
|
+
artifact_path: artifactPath,
|
|
196
|
+
artifact_hash: artifactHash,
|
|
197
|
+
locked_at: lockedAt,
|
|
198
|
+
prerequisite_hashes: prereqHashes,
|
|
199
|
+
previous_receipt_hash: prevReceiptHash,
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Python json.dumps(sort_keys=True) compact serialization:
|
|
203
|
+
// keys sorted lexicographically, separators (', ', ': '), ensure_ascii.
|
|
204
|
+
const receiptHash = createHash('sha256').update(pythonJsonDumps(base), 'utf8').digest('hex')
|
|
205
|
+
|
|
206
|
+
const receipt: LockReceipt = { ...base, receipt_hash: receiptHash }
|
|
207
|
+
|
|
208
|
+
const locksDir = join(runDir, 'locks')
|
|
209
|
+
mkdirSync(locksDir, { recursive: true })
|
|
210
|
+
const rpath = receiptPath(runDir, artifactFile)
|
|
211
|
+
writeFileSync(rpath, pythonJsonDumpsIndent(receipt), 'utf8')
|
|
212
|
+
return receipt
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export function invalidateReceipt(runDir: string, artifactFile: string): boolean {
|
|
216
|
+
const rpath = receiptPath(runDir, artifactFile)
|
|
217
|
+
if (existsSync(rpath)) {
|
|
218
|
+
rmSync(rpath, { force: true })
|
|
219
|
+
return true
|
|
220
|
+
}
|
|
221
|
+
return false
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Downstream phases (strictly after artifactFile in PHASE_SEQUENCE) whose
|
|
226
|
+
* receipt's prerequisite_hashes[artifactFile] differs from the current hash
|
|
227
|
+
* (or the artifact no longer exists).
|
|
228
|
+
*/
|
|
229
|
+
export function getStaleDownstreamPhases(runDir: string, artifactFile: string): StaleDownstream[] {
|
|
230
|
+
const idx = phaseIndex(artifactFile)
|
|
231
|
+
if (idx < 0) return []
|
|
232
|
+
let currentHash: string | null = null
|
|
233
|
+
const artifactPath = join(runDir, artifactFile)
|
|
234
|
+
if (existsSync(artifactPath)) {
|
|
235
|
+
currentHash = lockHashFromContent(readFileSync(artifactPath, 'utf8'))
|
|
236
|
+
}
|
|
237
|
+
const stale: StaleDownstream[] = []
|
|
238
|
+
for (const downstream of PHASE_SEQUENCE.slice(idx + 1)) {
|
|
239
|
+
const receipt = readReceipt(runDir, downstream)
|
|
240
|
+
if (!receipt) continue
|
|
241
|
+
const prereqHashes = receipt.prerequisite_hashes ?? {}
|
|
242
|
+
if (!(artifactFile in prereqHashes)) continue
|
|
243
|
+
const storedHash = prereqHashes[artifactFile]
|
|
244
|
+
if (currentHash === null || storedHash !== currentHash) {
|
|
245
|
+
stale.push({ artifact: downstream, reason: `prerequisite '${artifactFile}' hash changed` })
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
return stale
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export function getNextLegalPhase(runDir: string): string | null {
|
|
252
|
+
for (const phase of PHASE_SEQUENCE) {
|
|
253
|
+
const phasePath = join(runDir, phase)
|
|
254
|
+
const status = getLockStatus(phasePath)
|
|
255
|
+
if (status === 'LOCKED') continue
|
|
256
|
+
if (!existsSync(phasePath) && OPTIONAL_PHASES.has(phase)) continue
|
|
257
|
+
if (getPrerequisiteBlockers(runDir, phase).length > 0) return null
|
|
258
|
+
return phase
|
|
259
|
+
}
|
|
260
|
+
return null
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** All receipts whose prerequisite_hashes reference a missing or changed artifact. */
|
|
264
|
+
export function getAllStaleReceipts(runDir: string): StaleDownstream[] {
|
|
265
|
+
const stale: StaleDownstream[] = []
|
|
266
|
+
for (const phase of PHASE_SEQUENCE) {
|
|
267
|
+
const receipt = readReceipt(runDir, phase)
|
|
268
|
+
if (!receipt) continue
|
|
269
|
+
const prereqHashes = receipt.prerequisite_hashes ?? {}
|
|
270
|
+
for (const [prereq, storedHash] of Object.entries(prereqHashes)) {
|
|
271
|
+
const prereqPath = join(runDir, prereq)
|
|
272
|
+
if (!existsSync(prereqPath)) {
|
|
273
|
+
stale.push({ artifact: phase, reason: `prerequisite '${prereq}' no longer exists` })
|
|
274
|
+
continue
|
|
275
|
+
}
|
|
276
|
+
const currentHash = lockHashFromContent(readFileSync(prereqPath, 'utf8'))
|
|
277
|
+
if (storedHash !== currentHash) {
|
|
278
|
+
stale.push({ artifact: phase, reason: `prerequisite '${prereq}' content changed since lock at ${receipt.locked_at}` })
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
return stale
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Validate the full lock chain of a run: per-phase lock status, break phase,
|
|
287
|
+
* next legal phase, completion, and stale receipts.
|
|
288
|
+
*/
|
|
289
|
+
export function validateChain(runDir: string, runId: string): LockChainResult {
|
|
290
|
+
const phases: ChainPhaseResult[] = []
|
|
291
|
+
let breakPhase: string | null = null
|
|
292
|
+
let complete = true
|
|
293
|
+
for (const phase of PHASE_SEQUENCE) {
|
|
294
|
+
const phasePath = join(runDir, phase)
|
|
295
|
+
const status = getLockStatus(phasePath)
|
|
296
|
+
const problems: string[] = []
|
|
297
|
+
const lockValid = status === 'LOCKED'
|
|
298
|
+
if (!lockValid && breakPhase === null) {
|
|
299
|
+
breakPhase = phase
|
|
300
|
+
complete = false
|
|
301
|
+
}
|
|
302
|
+
if (!lockValid && status !== 'MISSING') {
|
|
303
|
+
if (status === 'DRAFT') problems.push(`Status is not LOCKED (${status})`)
|
|
304
|
+
else if (status === 'STALE_LOCK') problems.push('LockHash mismatch or missing lock fields')
|
|
305
|
+
}
|
|
306
|
+
phases.push({ file: phase, status, lockValid, lockProblems: problems })
|
|
307
|
+
}
|
|
308
|
+
const nextLegalPhase = getNextLegalPhase(runDir)
|
|
309
|
+
const staleReceipts = getAllStaleReceipts(runDir)
|
|
310
|
+
return { runId, phases, breakPhase, nextLegalPhase, complete, staleReceipts }
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Python json.dumps(obj, sort_keys=True, separators=(', ', ': '), ensure_ascii=True)
|
|
315
|
+
* compact serialization. Exported for parity testing against the Python oracle.
|
|
316
|
+
*/
|
|
317
|
+
export function serializeForReceiptHash(obj: object): string {
|
|
318
|
+
return pythonJsonDumps(obj)
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** Python json.dumps(obj, sort_keys=True) compact serialization (receipt_hash input). */
|
|
322
|
+
function pythonJsonDumps(obj: object): string {
|
|
323
|
+
const rec = obj as Record<string, unknown>
|
|
324
|
+
const keys = Object.keys(rec).sort()
|
|
325
|
+
const parts = keys.map(key => {
|
|
326
|
+
const value = rec[key]
|
|
327
|
+
return `${jsonString(key)}: ${jsonValue(value)}`
|
|
328
|
+
})
|
|
329
|
+
return '{' + parts.join(', ') + '}'
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** Python json.dumps(obj, indent=2, sort_keys=True) disk serialization. */
|
|
333
|
+
function pythonJsonDumpsIndent(obj: object): string {
|
|
334
|
+
const rec = obj as Record<string, unknown>
|
|
335
|
+
const keys = Object.keys(rec).sort()
|
|
336
|
+
const inner = keys.map(key => {
|
|
337
|
+
const value = rec[key]
|
|
338
|
+
return ' ' + jsonString(key) + ': ' + jsonValueIndent(value, 2)
|
|
339
|
+
})
|
|
340
|
+
return '{\n' + inner.join(',\n') + '\n}'
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function jsonString(s: string): string {
|
|
344
|
+
return JSON.stringify(s)
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function jsonValue(value: unknown): string {
|
|
348
|
+
if (value === null) return 'null'
|
|
349
|
+
if (typeof value === 'string') return jsonString(value)
|
|
350
|
+
if (typeof value === 'number' || typeof value === 'boolean') return String(value)
|
|
351
|
+
if (Array.isArray(value)) return '[' + value.map(jsonValue).join(', ') + ']'
|
|
352
|
+
if (typeof value === 'object') return pythonJsonDumps(value as Record<string, unknown>)
|
|
353
|
+
return 'null'
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function jsonValueIndent(value: unknown, depth: number): string {
|
|
357
|
+
if (value === null) return 'null'
|
|
358
|
+
if (typeof value === 'string') return jsonString(value)
|
|
359
|
+
if (typeof value === 'number' || typeof value === 'boolean') return String(value)
|
|
360
|
+
if (Array.isArray(value)) return '[' + value.map(v => jsonValueIndent(v, depth)).join(', ') + ']'
|
|
361
|
+
if (typeof value === 'object') {
|
|
362
|
+
const obj = value as Record<string, unknown>
|
|
363
|
+
const keys = Object.keys(obj).sort()
|
|
364
|
+
const pad = ' '.repeat(depth)
|
|
365
|
+
const inner = keys.map(key => pad + ' ' + jsonString(key) + ': ' + jsonValueIndent(obj[key], depth + 1))
|
|
366
|
+
return '{\n' + inner.join(',\n') + '\n' + pad + '}'
|
|
367
|
+
}
|
|
368
|
+
return 'null'
|
|
369
|
+
}
|
package/src/policy.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* recursive:policy prompt-section renderer (Phase C R5, PROPOSAL 8.4 Layer 3).
|
|
3
|
+
* Renders the CURRENT phase's contract from folded state + enforcement config:
|
|
4
|
+
* what must exist before advancing, which tools are denied/asked this phase,
|
|
5
|
+
* the strict/pragmatic TDD mode, the QA mode, and the lock chain - the same
|
|
6
|
+
* rules the machine gates enforce (no prompt/gate contradiction).
|
|
7
|
+
*/
|
|
8
|
+
import { existsSync } from 'node:fs'
|
|
9
|
+
import { join } from 'node:path'
|
|
10
|
+
import { PHASE_SEQUENCE, getLockStatus } from './lock.ts'
|
|
11
|
+
import { foldRun } from './status.ts'
|
|
12
|
+
import type { RecursivePhaseState } from './lifecycle.ts'
|
|
13
|
+
import type { EnforcementConfig } from './enforcement.ts'
|
|
14
|
+
import { DEFAULT_ENFORCEMENT } from './enforcement.ts'
|
|
15
|
+
|
|
16
|
+
export interface PolicyContext {
|
|
17
|
+
worktreeRoot: string
|
|
18
|
+
runId: string
|
|
19
|
+
folded: RecursivePhaseState | null
|
|
20
|
+
config?: EnforcementConfig
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Render the current-phase contract. Empty string when no run is active.
|
|
25
|
+
*/
|
|
26
|
+
export function renderRecursivePolicy(context: PolicyContext | null): string {
|
|
27
|
+
if (!context || !context.runId) return ''
|
|
28
|
+
const runDir = join(context.worktreeRoot, '.recursive', 'run', context.runId)
|
|
29
|
+
const folded = context.folded ?? null
|
|
30
|
+
const config = context.config ?? DEFAULT_ENFORCEMENT
|
|
31
|
+
|
|
32
|
+
// Derive the current phase from files (never trust an unchecked fold).
|
|
33
|
+
let currentPhase = folded?.phase ?? ''
|
|
34
|
+
let nextRequired = ''
|
|
35
|
+
const status = existsSync(runDir) ? foldRun(runDir, context.runId) : null
|
|
36
|
+
if (status?.currentPhase) {
|
|
37
|
+
currentPhase = status.currentPhase.label + ' (' + status.currentPhase.status + ')'
|
|
38
|
+
nextRequired = status.currentPhase.phaseName + ' (' + status.currentPhase.key + ')'
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const strictness = (gate: EnforcementConfig[keyof EnforcementConfig]) => gate
|
|
42
|
+
const lines = [
|
|
43
|
+
'You are in a recursive-mode session (enforcement active).',
|
|
44
|
+
'- Current phase: ' + (currentPhase || 'unknown'),
|
|
45
|
+
'- Next required artifact: ' + (nextRequired || 'none - run complete'),
|
|
46
|
+
'- Lock chain: phases lock monotonically (' + PHASE_SEQUENCE.join(' -> ') + ').',
|
|
47
|
+
'- Gates in force: pre-step ' + strictness(config.preStep) + ', tool guards ' + strictness(config.toolGuards) + ', tamper ' + strictness(config.tamper) + '.',
|
|
48
|
+
'- A transition that fails its gates is BLOCKED (strict) or warns (advisory); no rejected transition advances state.',
|
|
49
|
+
'- Writes to a Status: LOCKED phase doc are denied/asked; reopen explicitly to edit.',
|
|
50
|
+
'- Phase 3 lock requires TDD evidence (strict) or rationale (pragmatic); Phase 5 requires QA sign-off for human/hybrid modes.',
|
|
51
|
+
'- The control-plane root is resolved STRICTLY from this session workspace (never scan other workspaces).',
|
|
52
|
+
'- Scratch is disposable, git-ignored, and never citable as an Input.',
|
|
53
|
+
'- The workflow spec lives at /.recursive/RECURSIVE.md; bootstrap it when missing.',
|
|
54
|
+
]
|
|
55
|
+
return lines.join('\n')
|
|
56
|
+
}
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* recursive session projection unit (Phase D R2/R9, PROPOSAL 11.2/11.4): the
|
|
3
|
+
* pure fold that maps SessionEvent[] -> per-run wire-JSON, grouped by
|
|
4
|
+
* worktree root first then runId. The board, inspector, node, and strip all
|
|
5
|
+
* render from this whole value; the client NEVER scrapes /.recursive/run/.
|
|
6
|
+
*
|
|
7
|
+
* The unit is three pure synchronous functions (init/apply/view) plus a zod
|
|
8
|
+
* schema + stateVersion (the session-projection ProjectionDefinition
|
|
9
|
+
* contract). apply returns the same state reference for unrelated events
|
|
10
|
+
* (Object.is zero-work rule), so non-recursive sessions cost nothing.
|
|
11
|
+
*
|
|
12
|
+
* Worktree scoping (binding invariant, run 03 R1): the fold only commits a
|
|
13
|
+
* recursive/* event whose worktreeRoot is INSIDE the workspace control-plane
|
|
14
|
+
* root captured at registration; out-of-workspace events are dropped.
|
|
15
|
+
*
|
|
16
|
+
* The core fold is structural (RecursiveEventLike) so it is unit-testable
|
|
17
|
+
* without a live session; the ProjectionDefinition adapts the framework's
|
|
18
|
+
* SessionEvent to it.
|
|
19
|
+
*/
|
|
20
|
+
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
|
21
|
+
import { z as zod } from 'zod'
|
|
22
|
+
import type { ZodType } from 'zod'
|
|
23
|
+
import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection'
|
|
24
|
+
import type { RecursiveProjection, RecursiveRunCard, RecursiveRunState, RecursivePhaseRow, RecursiveGateBlock, RecursiveTamper, RecursiveSubagent } from './types.ts'
|
|
25
|
+
|
|
26
|
+
/** Declare the 'recursive' projection key on the shared type table. */
|
|
27
|
+
declare module '@deepseek-ai/dsh-session-projection/types' {
|
|
28
|
+
interface SessionProjectionMap {
|
|
29
|
+
/** Runs grouped by worktree root, then runId. */
|
|
30
|
+
recursive: RecursiveProjection
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Structural event shape the pure fold reads (unit-testable without a session). */
|
|
35
|
+
export interface RecursiveEventLike {
|
|
36
|
+
type: string
|
|
37
|
+
data?: Record<string, unknown>
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Mutable fold accumulator (private to the pure fold; never leaks). */
|
|
41
|
+
export interface RecursiveFoldState {
|
|
42
|
+
/** The workspace control-plane root this unit is scoped to ('' = accept all). */
|
|
43
|
+
workspaceRoot: string
|
|
44
|
+
/** Runs grouped by worktree root then runId. */
|
|
45
|
+
byWorktree: RecursiveProjection
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function emptyRecursiveFoldState(workspaceRoot = ''): RecursiveFoldState {
|
|
49
|
+
// B6: the unit itself has no session/agent to learn the workspace root from —
|
|
50
|
+
// init() is synchronous and receives no context. The EMITTERS are the scope
|
|
51
|
+
// authority: they resolve the control-plane root per-call and never append a
|
|
52
|
+
// cross-workspace event. workspaceRoot = '' (accept-all) is therefore a
|
|
53
|
+
// per-session safety default, not a no-op: (a) each session's projection cell
|
|
54
|
+
// is its own workspace (the log belongs to one agent), and (b) the re-key
|
|
55
|
+
// guard still refuses a merge whose repoRoot falls outside the captured
|
|
56
|
+
// root. A host that registers a session-scoped root may still pass it; the
|
|
57
|
+
// default is the per-session invariant, not a cross-workspace hole.
|
|
58
|
+
return { workspaceRoot, byWorktree: {} }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Whether a worktreeRoot is inside (or equal to) the workspace root. */
|
|
62
|
+
export function isInsideWorkspace(worktreeRoot: string, workspaceRoot: string): boolean {
|
|
63
|
+
if (workspaceRoot === '') return true
|
|
64
|
+
const w = worktreeRoot.replace(/\\/g, '/').replace(/\/$/, '')
|
|
65
|
+
const r = workspaceRoot.replace(/\\/g, '/').replace(/\/$/, '')
|
|
66
|
+
return w === r || w.startsWith(r + '/')
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function ensureCard(state: RecursiveFoldState, runId: string, worktreeRoot: string): RecursiveRunCard {
|
|
70
|
+
let tree = state.byWorktree[worktreeRoot]
|
|
71
|
+
if (tree === undefined) {
|
|
72
|
+
tree = {}
|
|
73
|
+
state.byWorktree[worktreeRoot] = tree
|
|
74
|
+
}
|
|
75
|
+
let card = tree[runId]
|
|
76
|
+
if (card === undefined) {
|
|
77
|
+
card = { runId, worktreeRoot, phases: {}, state: 'active', tampers: {}, subagents: {} }
|
|
78
|
+
tree[runId] = card
|
|
79
|
+
}
|
|
80
|
+
return card
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function asRunState(value: unknown): RecursiveRunState {
|
|
84
|
+
return value === 'new' || value === 'active' || value === 'paused' || value === 'blocked' || value === 'complete'
|
|
85
|
+
? value as RecursiveRunState
|
|
86
|
+
: 'active'
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Pure transition: previous state + one committed event -> next state. */
|
|
90
|
+
/** Internal transition signals (never commit into the projection). */
|
|
91
|
+
const INTERNAL_RECURSIVE_EVENTS = new Set(['recursive/phase-intent'])
|
|
92
|
+
|
|
93
|
+
export function foldRecursiveProjection(state: RecursiveFoldState, event: RecursiveEventLike): RecursiveFoldState {
|
|
94
|
+
const type = event.type
|
|
95
|
+
if (typeof type !== 'string' || !type.startsWith('recursive/')) return state
|
|
96
|
+
if (INTERNAL_RECURSIVE_EVENTS.has(type)) return state
|
|
97
|
+
const data = event.data ?? {}
|
|
98
|
+
const runId = typeof data.runId === 'string' ? data.runId : ''
|
|
99
|
+
const worktreeRoot = typeof data.worktreeRoot === 'string' ? data.worktreeRoot : ''
|
|
100
|
+
if (runId === '' || worktreeRoot === '') return state
|
|
101
|
+
// Binding workspace-scoping invariant: drop out-of-workspace events.
|
|
102
|
+
if (!isInsideWorkspace(worktreeRoot, state.workspaceRoot)) return state
|
|
103
|
+
|
|
104
|
+
const card = ensureCard(state, runId, worktreeRoot)
|
|
105
|
+
|
|
106
|
+
switch (type) {
|
|
107
|
+
case 'recursive/run-created': {
|
|
108
|
+
if (typeof data.template === 'string') card.template = data.template
|
|
109
|
+
if (typeof data.repo === 'string') card.repo = data.repo
|
|
110
|
+
return state
|
|
111
|
+
}
|
|
112
|
+
case 'recursive/phase': {
|
|
113
|
+
const phase = String(data.phase ?? '')
|
|
114
|
+
if (phase === '') return state
|
|
115
|
+
const row: RecursivePhaseRow = card.phases[phase] ?? { phase, status: String(data.status ?? '') }
|
|
116
|
+
row.status = String(data.status ?? '')
|
|
117
|
+
card.phases[phase] = row
|
|
118
|
+
return state
|
|
119
|
+
}
|
|
120
|
+
case 'recursive/phase-locked': {
|
|
121
|
+
const phase = String(data.phase ?? '')
|
|
122
|
+
if (phase === '') return state
|
|
123
|
+
const row: RecursivePhaseRow = card.phases[phase] ?? { phase, status: 'LOCKED' }
|
|
124
|
+
row.status = 'LOCKED'
|
|
125
|
+
row.lockedAt = String(data.lockedAt ?? '')
|
|
126
|
+
row.lockHash = String(data.lockHash ?? '')
|
|
127
|
+
card.phases[phase] = row
|
|
128
|
+
return state
|
|
129
|
+
}
|
|
130
|
+
case 'recursive/gate-blocked': {
|
|
131
|
+
const gate: RecursiveGateBlock = {
|
|
132
|
+
failures: Array.isArray(data.failures) ? data.failures.map(String) : [],
|
|
133
|
+
kind: String(data.kind ?? ''),
|
|
134
|
+
}
|
|
135
|
+
card.gateBlocked = gate
|
|
136
|
+
return state
|
|
137
|
+
}
|
|
138
|
+
case 'recursive/tamper': {
|
|
139
|
+
const path = String(data.path ?? '')
|
|
140
|
+
if (path === '') return state
|
|
141
|
+
const t: RecursiveTamper = { path, reason: String(data.reason ?? '') }
|
|
142
|
+
card.tampers[path] = t
|
|
143
|
+
return state
|
|
144
|
+
}
|
|
145
|
+
case 'recursive/run-merged': {
|
|
146
|
+
const repoRoot = typeof data.repoRoot === 'string' ? data.repoRoot : ''
|
|
147
|
+
if (repoRoot === '') return state
|
|
148
|
+
// R9: a merged run is listed under the repo root, not the stale worktree
|
|
149
|
+
// path. Re-key by moving the card across the outer group — but only when
|
|
150
|
+
// the repo root itself is inside the workspace (binding invariant);
|
|
151
|
+
// otherwise the merge note is recorded without crossing scope.
|
|
152
|
+
card.mergedToRepoRoot = repoRoot
|
|
153
|
+
if (isInsideWorkspace(repoRoot, state.workspaceRoot)) {
|
|
154
|
+
const tree = state.byWorktree[worktreeRoot]
|
|
155
|
+
if (tree !== undefined && tree[runId] === card) {
|
|
156
|
+
delete tree[runId]
|
|
157
|
+
if (Object.keys(tree).length === 0) delete state.byWorktree[worktreeRoot]
|
|
158
|
+
}
|
|
159
|
+
const target = state.byWorktree[repoRoot] ?? (state.byWorktree[repoRoot] = {})
|
|
160
|
+
target[runId] = card
|
|
161
|
+
// The card's own worktreeRoot now reflects the merge target.
|
|
162
|
+
card.worktreeRoot = repoRoot
|
|
163
|
+
}
|
|
164
|
+
return state
|
|
165
|
+
}
|
|
166
|
+
case 'recursive/run-state': {
|
|
167
|
+
card.state = asRunState(data.state)
|
|
168
|
+
if (typeof data.reason === 'string') card.stateReason = data.reason
|
|
169
|
+
return state
|
|
170
|
+
}
|
|
171
|
+
case 'recursive/subagent-start':
|
|
172
|
+
case 'recursive/subagent-end': {
|
|
173
|
+
const childId = String(data.childId ?? '')
|
|
174
|
+
if (childId === '') return state
|
|
175
|
+
const sub: RecursiveSubagent = card.subagents[childId] ?? {
|
|
176
|
+
childId,
|
|
177
|
+
role: String(data.role ?? ''),
|
|
178
|
+
provider: String(data.provider ?? ''),
|
|
179
|
+
}
|
|
180
|
+
sub.role = String(data.role ?? sub.role)
|
|
181
|
+
sub.provider = String(data.provider ?? sub.provider)
|
|
182
|
+
if (type === 'recursive/subagent-end') {
|
|
183
|
+
sub.status = (data.status === 'running' || data.status === 'done' || data.status === 'failed')
|
|
184
|
+
? data.status as 'running' | 'done' | 'failed'
|
|
185
|
+
: 'done'
|
|
186
|
+
}
|
|
187
|
+
card.subagents[childId] = sub
|
|
188
|
+
return state
|
|
189
|
+
}
|
|
190
|
+
default:
|
|
191
|
+
return state
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Wire-payload schema (view output) — plain-JSON validation before it leaves the host. */
|
|
196
|
+
const recursivePhaseRowSchema = zod.object({
|
|
197
|
+
phase: zod.string(),
|
|
198
|
+
status: zod.string(),
|
|
199
|
+
lockedAt: zod.string().optional(),
|
|
200
|
+
lockHash: zod.string().optional(),
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
const recursiveSubagentSchema = zod.object({
|
|
204
|
+
childId: zod.string(),
|
|
205
|
+
role: zod.string(),
|
|
206
|
+
provider: zod.string(),
|
|
207
|
+
status: zod.enum(['running', 'done', 'failed']).optional(),
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
const recursiveRunCardSchema = zod.object({
|
|
211
|
+
runId: zod.string(),
|
|
212
|
+
worktreeRoot: zod.string(),
|
|
213
|
+
repo: zod.string().optional(),
|
|
214
|
+
template: zod.string().optional(),
|
|
215
|
+
phases: zod.record(zod.string(), recursivePhaseRowSchema),
|
|
216
|
+
state: zod.enum(['new', 'active', 'paused', 'blocked', 'complete']),
|
|
217
|
+
stateReason: zod.string().optional(),
|
|
218
|
+
gateBlocked: zod.object({ failures: zod.array(zod.string()), kind: zod.string() }).optional(),
|
|
219
|
+
tampers: zod.record(zod.string(), zod.object({ path: zod.string(), reason: zod.string() })),
|
|
220
|
+
subagents: zod.record(zod.string(), recursiveSubagentSchema),
|
|
221
|
+
mergedToRepoRoot: zod.string().optional(),
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
export const recursiveProjectionSchema: ZodType<RecursiveProjection> = zod.record(
|
|
225
|
+
zod.string(),
|
|
226
|
+
zod.record(zod.string(), recursiveRunCardSchema),
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
/** The registered projection unit (key 'recursive'). */
|
|
230
|
+
export const recursiveProjectionUnit: ProjectionDefinition<'recursive', RecursiveFoldState> = {
|
|
231
|
+
key: 'recursive',
|
|
232
|
+
schema: recursiveProjectionSchema,
|
|
233
|
+
init: () => emptyRecursiveFoldState(),
|
|
234
|
+
apply: (state, event: SessionEvent) => foldRecursiveProjection(state, event as RecursiveEventLike),
|
|
235
|
+
view: (state) => state.byWorktree,
|
|
236
|
+
stateVersion: 1,
|
|
237
|
+
}
|