@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
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
#!/usr/bin/env -S npx tsx
|
|
2
|
+
/**
|
|
3
|
+
* dsh-recursive-mode smoke harness (R7). Exercises the Phase A completion
|
|
4
|
+
* surfaces end-to-end against a throwaway workspace on D:, workspace-scoped:
|
|
5
|
+
* R1 workspace root resolution (never scans other workspaces)
|
|
6
|
+
* R2 closeout scaffold
|
|
7
|
+
* R4 /recursive command grammar + scoped execution
|
|
8
|
+
* R5 scratch lifecycle
|
|
9
|
+
* R6 bootstrap idempotency + Stage B
|
|
10
|
+
* Run: npx tsx scripts/test-recursive-mode-smoke.ts
|
|
11
|
+
*/
|
|
12
|
+
import { mkdtempSync, rmSync, mkdirSync, writeFileSync, existsSync, readFileSync } from 'node:fs'
|
|
13
|
+
import { join } from 'node:path'
|
|
14
|
+
import { tmpdir } from 'node:os'
|
|
15
|
+
|
|
16
|
+
import { bootstrapScaffold, enumerateRuns, stageBWorkflowInit } from '../src/bootstrap.ts'
|
|
17
|
+
import { executeRecursiveCommand, parseRecursiveCommand } from '../src/commands.ts'
|
|
18
|
+
import { closeoutPhase } from '../src/closeout.ts'
|
|
19
|
+
import { readScratch, writeScratch } from '../src/scratch.ts'
|
|
20
|
+
import { resolveControlPlaneRoot, makeWorkspaceResolver } from '../src/workspace.ts'
|
|
21
|
+
import { lockHashFromContent } from '../src/lock.ts'
|
|
22
|
+
import { buildReviewBundle, contentSha256 } from '../src/review.ts'
|
|
23
|
+
import { createHandoff, createChildBrief, replyPath, childScratchPath, buildDelegationPrompt } from '../src/handoff.ts'
|
|
24
|
+
import { resolveRole, capabilityProbe, loadRouterPolicy } from '../src/router.ts'
|
|
25
|
+
import { validateReferences, writeActionRecord, reviewOutputSchema } from '../src/delegation.ts'
|
|
26
|
+
import { writeChildScratch, readParentScratch } from '../src/scratch.ts'
|
|
27
|
+
import { foldRecursivePhase, validateTransition, LifecycleDriver, coupleGateBlockToGoal, type SessionEventLike, type PhaseTransitionIntent } from '../src/lifecycle.ts'
|
|
28
|
+
import { runCreated, phase, phaseLocked, gateBlocked, tamper, runMerged, runState } from '../src/events.ts'
|
|
29
|
+
import { foldRecursiveProjection, emptyRecursiveFoldState, recursiveProjectionUnit, isInsideWorkspace } from '../src/projection.ts'
|
|
30
|
+
import { columnForRun, cardFacts, nodeKeyOf, expandPhaseRows } from '../src/client/derive.ts'
|
|
31
|
+
import { evaluatePreStepGate, evaluateToolGuard, resolveEnforcementConfig, DEFAULT_ENFORCEMENT, detectTamper } from '../src/enforcement.ts'
|
|
32
|
+
import { renderRecursivePolicy } from '../src/policy.ts'
|
|
33
|
+
|
|
34
|
+
let failures = 0
|
|
35
|
+
function check(name: string, ok: boolean, detail = '') {
|
|
36
|
+
console.log((ok ? ' [PASS] ' : ' [FAIL] ') + name + (detail ? ' — ' + detail : ''))
|
|
37
|
+
if (!ok) failures++
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function main() {
|
|
41
|
+
console.log('dsh-recursive-mode smoke (R7)')
|
|
42
|
+
const base = join(process.env.TEMP || tmpdir(), 'rm-smoke-' + Date.now())
|
|
43
|
+
mkdirSync(base, { recursive: true })
|
|
44
|
+
const wsA = join(base, 'ws-a')
|
|
45
|
+
const wsB = join(base, 'ws-b')
|
|
46
|
+
mkdirSync(wsA, { recursive: true })
|
|
47
|
+
mkdirSync(wsB, { recursive: true })
|
|
48
|
+
|
|
49
|
+
try {
|
|
50
|
+
// R6 bootstrap idempotency
|
|
51
|
+
const b1 = bootstrapScaffold(wsA)
|
|
52
|
+
check('R6 bootstrap creates scaffold', b1.created.length > 0, b1.created.join(','))
|
|
53
|
+
const b2 = bootstrapScaffold(wsA)
|
|
54
|
+
check('R6 bootstrap idempotent (no-op)', b2.created.length === 0, 'created=' + b2.created.length)
|
|
55
|
+
check('R6 scaffold files exist', existsSync(join(wsA, '.recursive', 'RECURSIVE.md')))
|
|
56
|
+
|
|
57
|
+
// R6 Stage B new + resume
|
|
58
|
+
mkdirSync(join(wsA, '.recursive', 'run', '10-smoke'), { recursive: true })
|
|
59
|
+
const sNew = stageBWorkflowInit({ root: wsA, source: 'new' })
|
|
60
|
+
check('R6 Stage B new bootstraps', sNew.bootstrapped === true || sNew.runs.length > 0, 'runs=' + sNew.runs.join(','))
|
|
61
|
+
const sResume = stageBWorkflowInit({ root: wsA, source: 'resume', activeRunId: '10-smoke' })
|
|
62
|
+
check('R6 Stage B resume never re-bootstraps', sResume.bootstrapped === false, 'bootstrapped=' + String(sResume.bootstrapped))
|
|
63
|
+
check('R6 enumerate runs dir-names only', sResume.runs.includes('10-smoke'))
|
|
64
|
+
|
|
65
|
+
// R1 workspace scoping: wsA sees only its runs; wsB untouched
|
|
66
|
+
const registry = {
|
|
67
|
+
async resolveByPath(path: string) { return path === wsA ? { path: wsA, id: 'a' } : path === wsB ? { path: wsB, id: 'b' } : undefined },
|
|
68
|
+
}
|
|
69
|
+
const resolve = makeWorkspaceResolver(registry as never)
|
|
70
|
+
const rootA = await resolve(wsA)
|
|
71
|
+
const rootB = await resolve(wsB)
|
|
72
|
+
check('R1 resolve A', rootA === wsA)
|
|
73
|
+
check('R1 resolve B', rootB === wsB)
|
|
74
|
+
const listA = executeRecursiveCommand(rootA as string, 'list')
|
|
75
|
+
check('R1 scoped list (A has run, B does not)', listA.kind === 'success' && (listA.text ?? '').includes('10-smoke'))
|
|
76
|
+
const listB = executeRecursiveCommand(rootB as string, 'list')
|
|
77
|
+
check('R1 B sees no A runs', listB.kind === 'success' && !(listB.text ?? '').includes('10-smoke'), listB.text)
|
|
78
|
+
const rootNoWs = await resolveControlPlaneRoot({ session: { header: { cwd: join(base, 'nowhere') } } } as never, registry as never)
|
|
79
|
+
check('R1 unregistered cwd -> null (defer)', rootNoWs === null)
|
|
80
|
+
|
|
81
|
+
// R2 closeout scaffold
|
|
82
|
+
const runDir = join(wsA, '.recursive', 'run', '10-smoke')
|
|
83
|
+
const req = [
|
|
84
|
+
'Run: 10-smoke', 'Phase: 0', 'Status: \`LOCKED\`', 'Workflow version: recursive-mode-audit-v2', '',
|
|
85
|
+
'## TODO', '', '- [x] done', '', 'Coverage: PASS', 'Approval: PASS',
|
|
86
|
+
'LockedAt: \`2026-01-15T10:00:00Z\`', 'LockHash: \`PLACEHOLDER\`', '',
|
|
87
|
+
].join('\n')
|
|
88
|
+
const hash = lockHashFromContent(req.replace('PLACEHOLDER', '0'.repeat(64)))
|
|
89
|
+
writeFileSync(join(runDir, '00-requirements.md'), req.replace('PLACEHOLDER', hash), 'utf8')
|
|
90
|
+
const co = closeoutPhase(runDir, '06', { strict: false })
|
|
91
|
+
check('R2 closeout scaffolds 06', co.created.includes('06-decisions-update.md'), co.created.join(','))
|
|
92
|
+
check('R2 closeout header', readFileSync(join(runDir, '06-decisions-update.md'), 'utf8').includes('## TODO'))
|
|
93
|
+
|
|
94
|
+
// R4 command grammar + scoped execution
|
|
95
|
+
check('R4 parse closeout', parseRecursiveCommand('closeout 10-smoke --phase 06').verb === 'closeout')
|
|
96
|
+
const cmdCloseout = executeRecursiveCommand(wsA, 'closeout 10-smoke --phase 06')
|
|
97
|
+
check('R4 closeout command success', cmdCloseout.kind === 'success')
|
|
98
|
+
const cmdUnknown = executeRecursiveCommand(wsA, 'frobnicate')
|
|
99
|
+
check('R4 unknown verb error', cmdUnknown.kind === 'error')
|
|
100
|
+
|
|
101
|
+
// R5 scratch lifecycle
|
|
102
|
+
writeScratch(runDir, 'md', '# scratch smoke')
|
|
103
|
+
check('R5 scratch write+read', readScratch(runDir, 'md').includes('scratch smoke'))
|
|
104
|
+
check('R5 scratch path', existsSync(join(runDir, 'scratch', 'scratch.md')))
|
|
105
|
+
|
|
106
|
+
// Phase B (run 04) smoke: bundle, handoff, router, delegation shape, child scratch, validation
|
|
107
|
+
writeFileSync(join(runDir, '03-implementation-summary.md'), '# impl\nStatus: `DRAFT`\n', 'utf8')
|
|
108
|
+
const bundle = buildReviewBundle({
|
|
109
|
+
root: wsA, runId: '10-smoke', phase: '03.5 Code Review', role: 'code-reviewer',
|
|
110
|
+
artifactPath: '.recursive/run/10-smoke/03-implementation-summary.md',
|
|
111
|
+
upstreamArtifacts: ['.recursive/run/10-smoke/00-requirements.md'],
|
|
112
|
+
auditQuestions: ['scoped?'], requiredOutput: 'verdict',
|
|
113
|
+
codeRefs: ['src/workspace.ts'], changedFiles: ['src/workspace.ts'],
|
|
114
|
+
})
|
|
115
|
+
check('R1 bundle built', existsSync(bundle.bundlePath) && bundle.markdown.includes('## Diff Basis'))
|
|
116
|
+
check('R1 bundle hash', bundle.artifactContentHash === contentSha256(readFileSync(join(runDir, '03-implementation-summary.md'), 'utf8')))
|
|
117
|
+
|
|
118
|
+
const handoff = createHandoff({ root: wsA, runId: '10-smoke', delegationId: 'rev-1', role: 'code-reviewer', objective: 'review', runDocRefs: [], codeRefs: [], auditQuestions: [], requiredOutput: 'v', decisionBasis: 'self-audit' })
|
|
119
|
+
const brief = createChildBrief({ root: wsA, runId: '10-smoke', delegationId: 'rev-1', childId: 'c1', slice: 's' })
|
|
120
|
+
check('R2 handoff+brief', existsSync(handoff) && existsSync(brief))
|
|
121
|
+
check('R2 prompt pointers', buildDelegationPrompt({ root: wsA, runId: '10-smoke', delegationId: 'rev-1', childId: 'c1', handoffPath: handoff, briefPath: brief }).includes('reply.md'))
|
|
122
|
+
|
|
123
|
+
const decision = resolveRole('code-reviewer', loadRouterPolicy(undefined), {})
|
|
124
|
+
const probe = capabilityProbe({ providers: {}, role: 'code-reviewer', policy: loadRouterPolicy(undefined) })
|
|
125
|
+
check('R3 router -> self-audit', decision.tier === 'self-audit' && probe.available === false)
|
|
126
|
+
|
|
127
|
+
check('R4 outputSchema', (reviewOutputSchema() as { required?: string[] }).required?.includes('verdict') === true)
|
|
128
|
+
|
|
129
|
+
const childScratch = childScratchPath({ root: wsA, runId: '10-smoke', childId: 'c1' })
|
|
130
|
+
writeChildScratch(runDir, 'c1', 'child note')
|
|
131
|
+
check('R5 child scratch written', existsSync(childScratch) && readFileSync(childScratch, 'utf8').includes('child note'))
|
|
132
|
+
check('R5 parent scratch intact', readParentScratch(runDir, 'md').includes('scratch smoke'))
|
|
133
|
+
|
|
134
|
+
const vref = validateReferences(wsA, [{ path: '.recursive/run/10-smoke/03-implementation-summary.md' }])
|
|
135
|
+
check('R6 references valid', vref.ok === true)
|
|
136
|
+
const arec = writeActionRecord({ root: wsA, runId: '10-smoke', subagentId: 'c1', phase: '03.5', purpose: 'review', executionMode: 'self-audit', success: false, stopReason: 'none' })
|
|
137
|
+
check('R6 action record', existsSync(arec) && readFileSync(arec, 'utf8').includes('## Verification Handoff'))
|
|
138
|
+
|
|
139
|
+
// Phase C (run 05) smoke: lifecycle fold + transition, pre-step gate, tool guard, policy, tamper, goal coupling
|
|
140
|
+
const lifeEvents: SessionEventLike[] = [
|
|
141
|
+
{ type: 'recursive/phase', data: { runId: '10-smoke', phase: '03', status: 'LOCKED' } },
|
|
142
|
+
{ type: 'recursive/run-state', data: { runId: '10-smoke', state: 'active' } },
|
|
143
|
+
]
|
|
144
|
+
const folded = foldRecursivePhase(lifeEvents)
|
|
145
|
+
check('R1 fold last-wins phase', folded?.phase === '03' && folded?.runState === 'active')
|
|
146
|
+
const gatedIntent: PhaseTransitionIntent = { runId: '10-smoke', worktreeRoot: wsA, targetArtifact: '03-implementation-summary.md', kind: 'lock' }
|
|
147
|
+
const gate = evaluatePreStepGate([{ type: 'recursive/phase-intent', data: gatedIntent }], 'advisory')
|
|
148
|
+
check('R3 pre-step advisory warns (gateBlocked)', gate.gateBlocked === true && gate.kind === 'enter')
|
|
149
|
+
const gateStrict = evaluatePreStepGate([{ type: 'recursive/phase-intent', data: gatedIntent }], 'strict')
|
|
150
|
+
check('R3 pre-step strict rejects', gateStrict.kind === 'reject' && gateStrict.gateBlocked === true)
|
|
151
|
+
check('R3 pre-step no intent enters', evaluatePreStepGate([], 'strict').kind === 'enter')
|
|
152
|
+
const tddContent = 'Run: 10-smoke\nPhase: 3\nStatus: DRAFT\nTDD Mode: strict\nRED: evidence/logs/red/tdd-red.md\nGREEN: evidence/logs/green/tdd-green.md\n'
|
|
153
|
+
writeFileSync(join(runDir, '03-implementation-summary.md'), tddContent, 'utf8')
|
|
154
|
+
const lockGuard = evaluateToolGuard({ name: 'recursive_lock', arguments: { artifact: '03-implementation-summary.md' } }, wsA, '10-smoke', 'strict')
|
|
155
|
+
check('R4 tool guard allows in-order lock', lockGuard.kind === 'allow')
|
|
156
|
+
const outOfOrder = evaluateToolGuard({ name: 'recursive_lock', arguments: { artifact: '05-manual-qa.md' } }, wsA, '10-smoke', 'strict')
|
|
157
|
+
check('R4 tool guard denies out-of-order lock', outOfOrder.kind === 'deny' && (outOfOrder as { reason: string }).reason.includes('monotonic'))
|
|
158
|
+
check('R7 config default advisory', JSON.stringify(resolveEnforcementConfig(undefined)) === JSON.stringify(DEFAULT_ENFORCEMENT))
|
|
159
|
+
let configError = ''
|
|
160
|
+
try { resolveEnforcementConfig({ bogus: 1 }) } catch (err) { configError = (err as Error).message }
|
|
161
|
+
check('R7 unknown config key fails', configError.includes('unknown key'))
|
|
162
|
+
const policyText = renderRecursivePolicy({ worktreeRoot: wsA, runId: '10-smoke', folded: null })
|
|
163
|
+
check('R5 policy renders contract', policyText.includes('recursive-mode session') && policyText.includes('Current phase'))
|
|
164
|
+
check('R8 tamper clean', detectTamper(join(runDir, '03-implementation-summary.md'), wsA, '10-smoke') === null)
|
|
165
|
+
check('R6 goal coupling no-op', coupleGateBlockToGoal(null, {}, { id: 'g' }, { code: 'G', message: 'x' }) === false)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
// ---- Phase D checks (run 06 R10) ----
|
|
169
|
+
const pEvents: { type: string; data: unknown }[] = [
|
|
170
|
+
{ type: 'recursive/run-created', data: runCreated({ runId: '10-smoke', worktreeRoot: wsA, template: 't', repo: 'r' }) },
|
|
171
|
+
{ type: 'recursive/phase', data: phase({ runId: '10-smoke', worktreeRoot: wsA, phase: '03-implementation-summary.md', status: 'DRAFT' }) },
|
|
172
|
+
{ type: 'recursive/phase-locked', data: phaseLocked({ runId: '10-smoke', worktreeRoot: wsA, phase: '03-implementation-summary.md', lockedAt: '2026-08-16T00:00:00Z', lockHash: 'h' }) },
|
|
173
|
+
{ type: 'recursive/gate-blocked', data: gateBlocked({ runId: '10-smoke', worktreeRoot: wsA, phase: '04', failures: ['f'], kind: 'pre-step' }) },
|
|
174
|
+
{ type: 'recursive/tamper', data: tamper({ runId: '10-smoke', worktreeRoot: wsA, path: 'p.md', reason: 'hash' }) },
|
|
175
|
+
{ type: 'recursive/run-state', data: runState({ runId: '10-smoke', worktreeRoot: wsA, state: 'blocked' }) },
|
|
176
|
+
]
|
|
177
|
+
let fold = emptyRecursiveFoldState(wsA)
|
|
178
|
+
for (const e of pEvents) fold = foldRecursiveProjection(fold, e as never)
|
|
179
|
+
check('R2 projection fold groups by worktreeRoot', Object.keys(fold.byWorktree).length === 1 && fold.byWorktree[wsA] !== undefined)
|
|
180
|
+
const card = fold.byWorktree[wsA]['10-smoke']
|
|
181
|
+
check('R2/R9 projection card facts', card !== undefined && card.phases['03-implementation-summary.md'].status === 'LOCKED' && card.state === 'blocked' && card.tampers['p.md'].reason === 'hash')
|
|
182
|
+
// R9 worktree-scope reject: fold an event whose worktreeRoot is outside wsA
|
|
183
|
+
const before = fold
|
|
184
|
+
fold = foldRecursiveProjection(fold, { type: 'recursive/run-created', data: runCreated({ runId: 'x', worktreeRoot: 'C:' + String.fromCharCode(92) + 'elsewhere' }) } as never)
|
|
185
|
+
check('R9 out-of-workspace event rejected (same ref)', fold === before)
|
|
186
|
+
// R5/R6 derive
|
|
187
|
+
check('R5 columnForRun maps to implementation lane', columnForRun(card) === '3/3.5')
|
|
188
|
+
const facts = cardFacts(card)
|
|
189
|
+
check('R5 cardFacts progress + tampered + gateBlocked', facts.lockedCount === 1 && facts.tampered === true && facts.gateBlocked === true)
|
|
190
|
+
// R7 node key
|
|
191
|
+
check('R7 nodeKeyOf includes worktreeRoot', nodeKeyOf('10-smoke', wsA).includes(wsA))
|
|
192
|
+
check('R6 expandPhaseRows always shows 3.5', expandPhaseRows(card).some(r => r.phase === '03.5'))
|
|
193
|
+
// R2 unit validity
|
|
194
|
+
check('R2 projection unit key + stateVersion', recursiveProjectionUnit.key === 'recursive' && recursiveProjectionUnit.stateVersion === 1 && recursiveProjectionUnit.schema.safeParse(fold.byWorktree).success)
|
|
195
|
+
check('R9 isInsideWorkspace normalization', isInsideWorkspace('D:' + String.fromCharCode(92) + 'DEV' + String.fromCharCode(92) + 'x', 'D:/DEV/x') === true)
|
|
196
|
+
|
|
197
|
+
console.log(failures === 0 ? 'SMOKE PASS' : 'SMOKE FAIL (' + failures + ' failures)')
|
|
198
|
+
process.exitCode = failures === 0 ? 0 : 1
|
|
199
|
+
} finally {
|
|
200
|
+
rmSync(base, { recursive: true, force: true })
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
void main()
|
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Verify recursive-mode artifact lock integrity.
|
|
4
|
+
|
|
5
|
+
Python equivalent to scripts/verify-locks.ps1.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import hashlib
|
|
12
|
+
import importlib.util
|
|
13
|
+
import re
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from datetime import datetime, timezone
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def load_phase_rules_module():
|
|
20
|
+
module_path = Path(__file__).with_name("recursive_phase_rules.py")
|
|
21
|
+
spec = importlib.util.spec_from_file_location("recursive_phase_rules", module_path)
|
|
22
|
+
if spec is None or spec.loader is None:
|
|
23
|
+
raise RuntimeError(f"Unable to load phase rules module from {module_path}")
|
|
24
|
+
module = importlib.util.module_from_spec(spec)
|
|
25
|
+
try:
|
|
26
|
+
spec.loader.exec_module(module)
|
|
27
|
+
except FileNotFoundError:
|
|
28
|
+
raise RuntimeError(f"Phase rules module not found: {module_path}")
|
|
29
|
+
return module
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
STATUS_RE = re.compile(r'(?m)^[ \t]*Status:\s*(?:`|")?(\w+)(?:`|")?\s*$')
|
|
33
|
+
LOCK_HASH_RE = re.compile(r'(?m)^[ \t]*LockHash:\s*(?:`|")?([a-fA-F0-9]{64})(?:`|")?\s*$')
|
|
34
|
+
LOCKED_AT_RE = re.compile(r'(?m)^[ \t]*LockedAt:\s*(?:`|")?([^`"\r\n]+)(?:`|")?\s*$')
|
|
35
|
+
LOCK_HASH_LINE_RE = re.compile(r'(?m)^[ \t]*LockHash:.*(?:\n|$)')
|
|
36
|
+
WORKFLOW_VERSION_RE = re.compile(r'(?m)^[ \t]*Workflow version:\s*(?:`|")?([^`"\r\n]+)(?:`|")?\s*$')
|
|
37
|
+
CURRENT_WORKFLOW_PROFILE = "recursive-mode-audit-v2"
|
|
38
|
+
STRICT_WORKFLOW_PROFILE = "recursive-mode-audit-v1"
|
|
39
|
+
COMPAT_WORKFLOW_PROFILE = "memory-phase8"
|
|
40
|
+
STRICT_WORKFLOW_PROFILES = {CURRENT_WORKFLOW_PROFILE, STRICT_WORKFLOW_PROFILE}
|
|
41
|
+
AUDITED_PHASE_FILES = {
|
|
42
|
+
"01-as-is.md",
|
|
43
|
+
"01.5-root-cause.md",
|
|
44
|
+
"02-to-be-plan.md",
|
|
45
|
+
"03-implementation-summary.md",
|
|
46
|
+
"03.5-code-review.md",
|
|
47
|
+
"04-test-summary.md",
|
|
48
|
+
"06-decisions-update.md",
|
|
49
|
+
"07-state-update.md",
|
|
50
|
+
"08-memory-impact.md",
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class LockResult:
|
|
56
|
+
valid: bool
|
|
57
|
+
error: str | None = None
|
|
58
|
+
stored_hash: str | None = None
|
|
59
|
+
actual_hash: str | None = None
|
|
60
|
+
locked_at: str | None = None
|
|
61
|
+
fixed: bool = False
|
|
62
|
+
new_locked_at: str | None = None
|
|
63
|
+
new_hash: str | None = None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def write_status(status: str, message: str) -> None:
|
|
67
|
+
print(f"[{status}] {message}")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def normalize_for_lock_hash(content: str) -> str:
|
|
71
|
+
normalized = content.replace("\r\n", "\n").replace("\r", "\n")
|
|
72
|
+
normalized = LOCK_HASH_LINE_RE.sub("", normalized)
|
|
73
|
+
return normalized
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def lock_hash_from_content(content: str) -> str:
|
|
77
|
+
normalized = normalize_for_lock_hash(content)
|
|
78
|
+
return hashlib.sha256(normalized.encode("utf-8")).hexdigest()
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def get_workflow_profile(run_dir: Path) -> str:
|
|
82
|
+
requirements_path = run_dir / "00-requirements.md"
|
|
83
|
+
if requirements_path.exists():
|
|
84
|
+
content = requirements_path.read_text(encoding="utf-8")
|
|
85
|
+
match = WORKFLOW_VERSION_RE.search(content)
|
|
86
|
+
if match:
|
|
87
|
+
workflow_version = match.group(1).strip()
|
|
88
|
+
if workflow_version == CURRENT_WORKFLOW_PROFILE:
|
|
89
|
+
return CURRENT_WORKFLOW_PROFILE
|
|
90
|
+
if workflow_version == STRICT_WORKFLOW_PROFILE:
|
|
91
|
+
return STRICT_WORKFLOW_PROFILE
|
|
92
|
+
if workflow_version == COMPAT_WORKFLOW_PROFILE:
|
|
93
|
+
return COMPAT_WORKFLOW_PROFILE
|
|
94
|
+
|
|
95
|
+
for artifact in ("06-decisions-update.md", "07-state-update.md", "08-memory-impact.md"):
|
|
96
|
+
if (run_dir / artifact).exists():
|
|
97
|
+
return COMPAT_WORKFLOW_PROFILE
|
|
98
|
+
|
|
99
|
+
return "legacy"
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def test_lock_valid(artifact_path: Path, fix: bool = False) -> LockResult:
|
|
103
|
+
if not artifact_path.exists():
|
|
104
|
+
return LockResult(valid=False, error="File not found")
|
|
105
|
+
|
|
106
|
+
content = artifact_path.read_text(encoding="utf-8")
|
|
107
|
+
|
|
108
|
+
status_match = STATUS_RE.search(content)
|
|
109
|
+
if not status_match:
|
|
110
|
+
return LockResult(valid=False, error="No Status field found")
|
|
111
|
+
status = status_match.group(1)
|
|
112
|
+
if status != "LOCKED":
|
|
113
|
+
return LockResult(valid=False, error=f"Status is '{status}', expected 'LOCKED'")
|
|
114
|
+
|
|
115
|
+
hash_match = LOCK_HASH_RE.search(content)
|
|
116
|
+
if not hash_match:
|
|
117
|
+
return LockResult(valid=False, error="No LockHash field found")
|
|
118
|
+
stored_hash = hash_match.group(1).lower()
|
|
119
|
+
|
|
120
|
+
locked_at_match = LOCKED_AT_RE.search(content)
|
|
121
|
+
if not locked_at_match:
|
|
122
|
+
return LockResult(valid=False, error="No LockedAt field found")
|
|
123
|
+
locked_at = locked_at_match.group(1)
|
|
124
|
+
|
|
125
|
+
actual_hash = lock_hash_from_content(content)
|
|
126
|
+
if stored_hash == actual_hash:
|
|
127
|
+
return LockResult(valid=True, locked_at=locked_at, stored_hash=stored_hash)
|
|
128
|
+
|
|
129
|
+
result = LockResult(
|
|
130
|
+
valid=False,
|
|
131
|
+
error="Hash mismatch",
|
|
132
|
+
stored_hash=stored_hash,
|
|
133
|
+
actual_hash=actual_hash,
|
|
134
|
+
locked_at=locked_at,
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
if fix:
|
|
138
|
+
new_locked_at = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
139
|
+
content_with_new_locked_at = re.sub(
|
|
140
|
+
r"(?m)^[ \t]*LockedAt:.*$",
|
|
141
|
+
f"LockedAt: `{new_locked_at}`",
|
|
142
|
+
content,
|
|
143
|
+
)
|
|
144
|
+
new_hash = lock_hash_from_content(content_with_new_locked_at)
|
|
145
|
+
new_content = re.sub(
|
|
146
|
+
r"(?m)^[ \t]*LockHash:.*$",
|
|
147
|
+
f"LockHash: `{new_hash}`",
|
|
148
|
+
content_with_new_locked_at,
|
|
149
|
+
)
|
|
150
|
+
artifact_path.write_text(new_content, encoding="utf-8", newline="")
|
|
151
|
+
|
|
152
|
+
result.fixed = True
|
|
153
|
+
result.new_locked_at = new_locked_at
|
|
154
|
+
result.new_hash = new_hash
|
|
155
|
+
|
|
156
|
+
return result
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def test_gate(content: str, name: str) -> tuple[bool, str | None]:
|
|
160
|
+
pass_re = re.compile(rf"{name}:\s*PASS")
|
|
161
|
+
fail_re = re.compile(rf"{name}:\s*FAIL")
|
|
162
|
+
if pass_re.search(content):
|
|
163
|
+
return True, None
|
|
164
|
+
if fail_re.search(content):
|
|
165
|
+
return False, f"{name} gate shows FAIL"
|
|
166
|
+
return False, f"No {name} gate result found"
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def main() -> int:
|
|
170
|
+
parser = argparse.ArgumentParser(description="Verify recursive-mode artifact lock integrity.")
|
|
171
|
+
parser.add_argument("--run-id", default="", help="Run ID to verify. If omitted, scans all runs.")
|
|
172
|
+
parser.add_argument("--repo-root", default=".", help="Repository root path.")
|
|
173
|
+
parser.add_argument("--fix", action="store_true", help="Fix incorrect lock hashes (updates LockedAt + LockHash).")
|
|
174
|
+
parser.add_argument("--show-hashes", action="store_true", help="Show valid lock hashes in output.")
|
|
175
|
+
args = parser.parse_args()
|
|
176
|
+
|
|
177
|
+
resolved_repo_root = Path(args.repo_root).resolve()
|
|
178
|
+
print("\nRecursive Lock Verification")
|
|
179
|
+
print("=====================\n")
|
|
180
|
+
print(f"Repository: {resolved_repo_root}")
|
|
181
|
+
print(f"Run ID: {args.run_id if args.run_id else '(scanning all runs)'}\n")
|
|
182
|
+
|
|
183
|
+
run_base_dir = resolved_repo_root / ".recursive" / "run"
|
|
184
|
+
if not run_base_dir.exists():
|
|
185
|
+
write_status("FAIL", f"recursive run directory not found: {run_base_dir}")
|
|
186
|
+
return 1
|
|
187
|
+
|
|
188
|
+
if args.run_id:
|
|
189
|
+
runs = [args.run_id]
|
|
190
|
+
else:
|
|
191
|
+
runs = sorted([d.name for d in run_base_dir.iterdir() if d.is_dir()])
|
|
192
|
+
|
|
193
|
+
artifacts = [
|
|
194
|
+
{"file": "00-requirements.md", "name": "Requirements", "optional": False},
|
|
195
|
+
{"file": "00-worktree.md", "name": "Worktree Setup", "optional": False},
|
|
196
|
+
{"file": "01-as-is.md", "name": "AS-IS Analysis", "optional": True},
|
|
197
|
+
{"file": "01.5-root-cause.md", "name": "Root Cause", "optional": True},
|
|
198
|
+
{"file": "02-to-be-plan.md", "name": "TO-BE Plan", "optional": True},
|
|
199
|
+
{"file": "03-implementation-summary.md", "name": "Implementation", "optional": True},
|
|
200
|
+
{"file": "03.5-code-review.md", "name": "Code Review", "optional": True},
|
|
201
|
+
{"file": "04-test-summary.md", "name": "Test Summary", "optional": True},
|
|
202
|
+
{"file": "05-manual-qa.md", "name": "Manual QA", "optional": True},
|
|
203
|
+
{"file": "06-decisions-update.md", "name": "Decisions Update", "optional": False},
|
|
204
|
+
{"file": "07-state-update.md", "name": "State Update", "optional": False},
|
|
205
|
+
{"file": "08-memory-impact.md", "name": "Memory Impact", "optional": False},
|
|
206
|
+
]
|
|
207
|
+
|
|
208
|
+
total_runs = 0
|
|
209
|
+
valid_runs = 0
|
|
210
|
+
fixed_runs = 0
|
|
211
|
+
failed_runs = 0
|
|
212
|
+
stale_chain_runs = 0
|
|
213
|
+
|
|
214
|
+
phase_rules = load_phase_rules_module()
|
|
215
|
+
|
|
216
|
+
for run in runs:
|
|
217
|
+
total_runs += 1
|
|
218
|
+
run_dir = run_base_dir / run
|
|
219
|
+
workflow_profile = get_workflow_profile(run_dir)
|
|
220
|
+
print(f"Checking run: {run}")
|
|
221
|
+
print("-" * 50)
|
|
222
|
+
print(f"Workflow profile: {workflow_profile}")
|
|
223
|
+
|
|
224
|
+
run_valid = True
|
|
225
|
+
run_fixed = False
|
|
226
|
+
|
|
227
|
+
for artifact in artifacts:
|
|
228
|
+
artifact_path = run_dir / artifact["file"]
|
|
229
|
+
is_required = not artifact["optional"]
|
|
230
|
+
if workflow_profile == "legacy" and artifact["file"] in {"06-decisions-update.md", "07-state-update.md", "08-memory-impact.md"}:
|
|
231
|
+
is_required = False
|
|
232
|
+
if not artifact_path.exists():
|
|
233
|
+
if not is_required:
|
|
234
|
+
write_status("INFO", f"{artifact['name']}: Not found (optional)")
|
|
235
|
+
else:
|
|
236
|
+
write_status("FAIL", f"{artifact['name']}: Missing (required)")
|
|
237
|
+
run_valid = False
|
|
238
|
+
continue
|
|
239
|
+
|
|
240
|
+
content = artifact_path.read_text(encoding="utf-8")
|
|
241
|
+
if not re.search(r'(?m)^[ \t]*Status:\s*(?:`|")?LOCKED(?:`|")?\s*$', content):
|
|
242
|
+
write_status("WARN", f"{artifact['name']}: Not locked (DRAFT)")
|
|
243
|
+
run_valid = False
|
|
244
|
+
continue
|
|
245
|
+
|
|
246
|
+
result = test_lock_valid(artifact_path, fix=args.fix)
|
|
247
|
+
if result.valid:
|
|
248
|
+
write_status("PASS", f"{artifact['name']}: Valid (locked at {result.locked_at})")
|
|
249
|
+
if args.show_hashes and result.stored_hash:
|
|
250
|
+
print(f" Hash: {result.stored_hash}")
|
|
251
|
+
else:
|
|
252
|
+
if result.fixed:
|
|
253
|
+
write_status("WARN", f"{artifact['name']}: Fixed hash mismatch (was tampered)")
|
|
254
|
+
if result.stored_hash:
|
|
255
|
+
print(f" Old hash: {result.stored_hash}")
|
|
256
|
+
if result.new_hash:
|
|
257
|
+
print(f" New hash: {result.new_hash}")
|
|
258
|
+
if result.new_locked_at:
|
|
259
|
+
print(f" Updated at: {result.new_locked_at}")
|
|
260
|
+
run_fixed = True
|
|
261
|
+
else:
|
|
262
|
+
write_status("FAIL", f"{artifact['name']}: {result.error}")
|
|
263
|
+
if result.stored_hash and result.actual_hash:
|
|
264
|
+
print(f" Stored: {result.stored_hash}")
|
|
265
|
+
print(f" Actual: {result.actual_hash}")
|
|
266
|
+
run_valid = False
|
|
267
|
+
|
|
268
|
+
# Gate checks
|
|
269
|
+
updated_content = artifact_path.read_text(encoding="utf-8")
|
|
270
|
+
coverage_ok, coverage_reason = test_gate(updated_content, "Coverage")
|
|
271
|
+
approval_ok, approval_reason = test_gate(updated_content, "Approval")
|
|
272
|
+
audit_required = workflow_profile in STRICT_WORKFLOW_PROFILES and artifact["file"] in AUDITED_PHASE_FILES
|
|
273
|
+
audit_ok = True
|
|
274
|
+
audit_reason = None
|
|
275
|
+
if audit_required:
|
|
276
|
+
audit_ok, audit_reason = test_gate(updated_content, "Audit")
|
|
277
|
+
if not coverage_ok:
|
|
278
|
+
write_status("FAIL", f"{artifact['name']}: Coverage gate - {coverage_reason}")
|
|
279
|
+
run_valid = False
|
|
280
|
+
if not approval_ok:
|
|
281
|
+
write_status("FAIL", f"{artifact['name']}: Approval gate - {approval_reason}")
|
|
282
|
+
run_valid = False
|
|
283
|
+
if not audit_ok:
|
|
284
|
+
write_status("FAIL", f"{artifact['name']}: Audit gate - {audit_reason}")
|
|
285
|
+
run_valid = False
|
|
286
|
+
|
|
287
|
+
addenda_dir = run_dir / "addenda"
|
|
288
|
+
if addenda_dir.exists():
|
|
289
|
+
addenda_files = sorted(addenda_dir.glob("*.md"))
|
|
290
|
+
if addenda_files:
|
|
291
|
+
print("\nAddenda:")
|
|
292
|
+
for addendum in addenda_files:
|
|
293
|
+
result = test_lock_valid(addendum, fix=args.fix)
|
|
294
|
+
if result.valid:
|
|
295
|
+
write_status("PASS", f" {addendum.name}: Valid")
|
|
296
|
+
elif result.fixed:
|
|
297
|
+
write_status("WARN", f" {addendum.name}: Fixed")
|
|
298
|
+
run_fixed = True
|
|
299
|
+
else:
|
|
300
|
+
write_status("FAIL", f" {addendum.name}: {result.error}")
|
|
301
|
+
run_valid = False
|
|
302
|
+
|
|
303
|
+
# Stale-chain detection: check whether any prerequisite hash in a receipt
|
|
304
|
+
# no longer matches the current artifact content.
|
|
305
|
+
stale_entries = phase_rules.get_all_stale_receipts(run_dir)
|
|
306
|
+
run_stale = False
|
|
307
|
+
if stale_entries:
|
|
308
|
+
print("\nStale lock-chain receipts:")
|
|
309
|
+
for entry in stale_entries:
|
|
310
|
+
write_status("WARN", f" {entry['artifact']}: {entry['reason']}")
|
|
311
|
+
run_stale = True
|
|
312
|
+
|
|
313
|
+
print()
|
|
314
|
+
if run_stale:
|
|
315
|
+
stale_chain_runs += 1
|
|
316
|
+
run_valid = False
|
|
317
|
+
write_status("WARN", f"Run '{run}': Stale lock-chain receipts detected (re-lock in phase order)")
|
|
318
|
+
elif run_valid and not run_fixed:
|
|
319
|
+
valid_runs += 1
|
|
320
|
+
write_status("PASS", f"Run '{run}': All locks valid")
|
|
321
|
+
elif run_fixed:
|
|
322
|
+
fixed_runs += 1
|
|
323
|
+
write_status("WARN", f"Run '{run}': Locks fixed (tampering detected)")
|
|
324
|
+
else:
|
|
325
|
+
failed_runs += 1
|
|
326
|
+
write_status("FAIL", f"Run '{run}': Lock verification failed")
|
|
327
|
+
print()
|
|
328
|
+
|
|
329
|
+
print("=====================")
|
|
330
|
+
print("Summary")
|
|
331
|
+
print("=====================\n")
|
|
332
|
+
print(f"Total runs checked: {total_runs}")
|
|
333
|
+
write_status("PASS", f"Valid runs: {valid_runs}")
|
|
334
|
+
if stale_chain_runs > 0:
|
|
335
|
+
write_status("WARN", f"Stale-chain runs: {stale_chain_runs}")
|
|
336
|
+
if fixed_runs > 0:
|
|
337
|
+
write_status("WARN", f"Fixed runs (tampered): {fixed_runs}")
|
|
338
|
+
if failed_runs > 0:
|
|
339
|
+
write_status("FAIL", f"Failed runs: {failed_runs}")
|
|
340
|
+
print()
|
|
341
|
+
|
|
342
|
+
if failed_runs == 0 and stale_chain_runs == 0:
|
|
343
|
+
write_status("PASS", "All locks verified successfully")
|
|
344
|
+
return 0
|
|
345
|
+
if failed_runs == 0:
|
|
346
|
+
write_status("WARN", "Lock hashes valid but stale-chain receipts detected")
|
|
347
|
+
return 1
|
|
348
|
+
write_status("FAIL", "Some locks failed verification")
|
|
349
|
+
return 1
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
if __name__ == "__main__":
|
|
353
|
+
raise SystemExit(main())
|