@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/status.ts
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import { readFileSync, statSync } from 'node:fs'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import type { ArtifactState, PhaseDef, PhaseState, RecursiveStatusResult } from './types.ts'
|
|
5
|
+
|
|
6
|
+
export const RUN_ARTIFACT_SEQUENCE = [
|
|
7
|
+
'00-requirements.md',
|
|
8
|
+
'00-worktree.md',
|
|
9
|
+
'01-as-is.md',
|
|
10
|
+
'01.5-root-cause.md',
|
|
11
|
+
'02-to-be-plan.md',
|
|
12
|
+
'03-implementation-summary.md',
|
|
13
|
+
'03.5-code-review.md',
|
|
14
|
+
'04-test-summary.md',
|
|
15
|
+
'05-manual-qa.md',
|
|
16
|
+
'06-decisions-update.md',
|
|
17
|
+
'07-state-update.md',
|
|
18
|
+
'08-memory-impact.md',
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
export const PHASES: PhaseDef[] = [
|
|
22
|
+
{ key: '00R', label: 'Phase 0 (Requirements)', file: '00-requirements.md', optional: false, phaseName: '0 (Requirements)' },
|
|
23
|
+
{ key: '00W', label: 'Phase 0 (Worktree)', file: '00-worktree.md', optional: false, phaseName: '0 (Worktree)' },
|
|
24
|
+
{ key: '01', label: 'Phase 1 (AS-IS)', file: '01-as-is.md', optional: false, phaseName: '1 (AS-IS)' },
|
|
25
|
+
{ key: '01.5', label: 'Phase 1.5 (Root Cause)', file: '01.5-root-cause.md', optional: true, phaseName: '1.5 (Root Cause)' },
|
|
26
|
+
{ key: '02', label: 'Phase 2 (TO-BE Plan)', file: '02-to-be-plan.md', optional: false, phaseName: '2 (TO-BE Plan)' },
|
|
27
|
+
{ key: '03', label: 'Phase 3 (Implementation)', file: '03-implementation-summary.md', optional: false, phaseName: '3 (Implementation)' },
|
|
28
|
+
{ key: '03.5', label: 'Phase 3.5 (Code Review)', file: '03.5-code-review.md', optional: true, phaseName: '3.5 (Code Review)' },
|
|
29
|
+
{ key: '04', label: 'Phase 4 (Test Summary)', file: '04-test-summary.md', optional: false, phaseName: '4 (Test Summary)' },
|
|
30
|
+
{ key: '05', label: 'Phase 5 (Manual QA)', file: '05-manual-qa.md', optional: false, phaseName: '5 (Manual QA)' },
|
|
31
|
+
{ key: '06', label: 'Phase 6 (Decisions)', file: '06-decisions-update.md', optional: false, phaseName: '6 (Decisions Update)' },
|
|
32
|
+
{ key: '07', label: 'Phase 7 (State)', file: '07-state-update.md', optional: false, phaseName: '7 (State Update)' },
|
|
33
|
+
{ key: '08', label: 'Phase 8 (Memory)', file: '08-memory-impact.md', optional: false, phaseName: '8 (Memory Impact)' },
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
const LOCK_HASH_LINE_RE = /^[ \t]*LockHash:.*(?:\n|$)/gm
|
|
37
|
+
const STRICT_WORKFLOW_PROFILES = new Set(['recursive-mode-audit-v1', 'recursive-mode-audit-v2'])
|
|
38
|
+
const AUDITED_PHASE_FILES = new Set([
|
|
39
|
+
'01-as-is.md', '01.5-root-cause.md', '02-to-be-plan.md', '03-implementation-summary.md',
|
|
40
|
+
'03.5-code-review.md', '04-test-summary.md', '06-decisions-update.md', '07-state-update.md', '08-memory-impact.md',
|
|
41
|
+
])
|
|
42
|
+
const CURRENT_WORKFLOW_PROFILE = 'recursive-mode-audit-v2'
|
|
43
|
+
const STRICT_WORKFLOW_PROFILE = 'recursive-mode-audit-v1'
|
|
44
|
+
const COMPAT_WORKFLOW_PROFILE = 'memory-phase8'
|
|
45
|
+
const LATE_PHASE_FILES = ['06-decisions-update.md', '07-state-update.md', '08-memory-impact.md']
|
|
46
|
+
const LATE_PHASE_KEYS = new Set(['06', '07', '08'])
|
|
47
|
+
|
|
48
|
+
export function escapeRegExp(value: string): string {
|
|
49
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function trimMdValue(value: string): string {
|
|
53
|
+
let trimmed = value.trim()
|
|
54
|
+
for (const quote of ['`', '"', "'"]) {
|
|
55
|
+
if (trimmed.startsWith(quote) && trimmed.endsWith(quote) && trimmed.length >= 2) {
|
|
56
|
+
const inner = trimmed.slice(1, -1)
|
|
57
|
+
if (!inner.includes(quote)) return inner.trim()
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return trimmed
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function getMdFieldValue(content: string, fieldName: string): string | null {
|
|
64
|
+
const pattern = new RegExp(`^[ \\t]*(?:[-*][ \\t]+)?${escapeRegExp(fieldName)}:[ \\t]*(.+?)\\s*$`, 'm')
|
|
65
|
+
const match = pattern.exec(content)
|
|
66
|
+
if (!match) return null
|
|
67
|
+
return trimMdValue(match[1])
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function getGateStatus(content: string, gateName: string): string {
|
|
71
|
+
const pattern = new RegExp(`^[ \\t]*${escapeRegExp(gateName)}:\\s*(PASS|FAIL)\\s*$`, 'm')
|
|
72
|
+
const match = pattern.exec(content)
|
|
73
|
+
return match ? match[1].toUpperCase() : 'MISSING'
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function getTodoStats(content: string): { hasTodo: boolean; total: number; checked: number; unchecked: number } {
|
|
77
|
+
const lines = content.split(/\r?\n/)
|
|
78
|
+
let inTodo = false
|
|
79
|
+
let hasTodo = false
|
|
80
|
+
let checked = 0
|
|
81
|
+
let unchecked = 0
|
|
82
|
+
let total = 0
|
|
83
|
+
for (const line of lines) {
|
|
84
|
+
if (!inTodo) {
|
|
85
|
+
if (/^\s*##\s+TODO\s*$/.test(line)) { inTodo = true; hasTodo = true }
|
|
86
|
+
continue
|
|
87
|
+
}
|
|
88
|
+
if (/^\s*##\s+/.test(line) || /^\s*#\s+/.test(line)) break
|
|
89
|
+
const item = /^\s*[-*]\s+\[([ xX])\]\s+/.exec(line)
|
|
90
|
+
if (item) {
|
|
91
|
+
total += 1
|
|
92
|
+
if (item[1].toLowerCase() === 'x') checked += 1
|
|
93
|
+
else unchecked += 1
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return { hasTodo, total, checked, unchecked }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function lockHashFromContent(content: string): string {
|
|
100
|
+
const normalized = content.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
|
|
101
|
+
const stripped = normalized.replace(LOCK_HASH_LINE_RE, '')
|
|
102
|
+
return createHash('sha256').update(stripped, 'utf8').digest('hex')
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function getWorkflowProfile(runDir: string): string {
|
|
106
|
+
const requirementsPath = join(runDir, '00-requirements.md')
|
|
107
|
+
try {
|
|
108
|
+
const content = readFileSync(requirementsPath, 'utf8')
|
|
109
|
+
const workflowVersion = getMdFieldValue(content, 'Workflow version')
|
|
110
|
+
if (workflowVersion === CURRENT_WORKFLOW_PROFILE) return CURRENT_WORKFLOW_PROFILE
|
|
111
|
+
if (workflowVersion === STRICT_WORKFLOW_PROFILE) return STRICT_WORKFLOW_PROFILE
|
|
112
|
+
if (workflowVersion === COMPAT_WORKFLOW_PROFILE) return COMPAT_WORKFLOW_PROFILE
|
|
113
|
+
} catch { /* fall through */ }
|
|
114
|
+
if (LATE_PHASE_FILES.some(file => { try { statSync(join(runDir, file)); return true } catch { return false } })) return COMPAT_WORKFLOW_PROFILE
|
|
115
|
+
return 'legacy'
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export { getLatestRunDirectory, discoverRuns, resolveRunDir } from './run.ts'
|
|
119
|
+
export type { RunDiscoveryResult } from './run.ts'
|
|
120
|
+
|
|
121
|
+
export function getArtifactState(artifactPath: string, workflowProfile: string): ArtifactState {
|
|
122
|
+
let content: string | null = null
|
|
123
|
+
try { content = readFileSync(artifactPath, 'utf8') } catch { /* missing */ }
|
|
124
|
+
if (content === null) {
|
|
125
|
+
return { exists: false, status: 'PENDING', lockValid: false, lockProblems: ['File missing'], blockers: ['File missing'], lockedAt: null, storedHash: null, actualHash: null, coverage: 'MISSING', approval: 'MISSING', audit: 'MISSING', todoHasSection: false, todoUnchecked: 0 }
|
|
126
|
+
}
|
|
127
|
+
const status = getMdFieldValue(content, 'Status') ?? 'UNKNOWN'
|
|
128
|
+
const todo = getTodoStats(content)
|
|
129
|
+
const coverage = getGateStatus(content, 'Coverage')
|
|
130
|
+
const approval = getGateStatus(content, 'Approval')
|
|
131
|
+
const audit = getGateStatus(content, 'Audit')
|
|
132
|
+
const tddCompliance = getGateStatus(content, 'TDD Compliance')
|
|
133
|
+
const lockedAt = getMdFieldValue(content, 'LockedAt')
|
|
134
|
+
const storedHash = getMdFieldValue(content, 'LockHash')
|
|
135
|
+
let actualHash: string | null = null
|
|
136
|
+
const lockProblems: string[] = []
|
|
137
|
+
const blockers: string[] = []
|
|
138
|
+
let lockValid = false
|
|
139
|
+
const fileName = artifactPath.split(/[\\/]/).pop() ?? ''
|
|
140
|
+
|
|
141
|
+
if (status !== 'LOCKED') {
|
|
142
|
+
lockProblems.push(`Status is '${status}' (expected LOCKED for lock-valid)`)
|
|
143
|
+
} else {
|
|
144
|
+
if (!lockedAt) lockProblems.push('Missing LockedAt')
|
|
145
|
+
if (!storedHash) lockProblems.push('Missing LockHash')
|
|
146
|
+
if (storedHash) {
|
|
147
|
+
actualHash = lockHashFromContent(content)
|
|
148
|
+
if (storedHash.toLowerCase() !== actualHash.toLowerCase()) lockProblems.push('LockHash mismatch')
|
|
149
|
+
}
|
|
150
|
+
if (coverage !== 'PASS') lockProblems.push(`Coverage gate is ${coverage}`)
|
|
151
|
+
if (approval !== 'PASS') lockProblems.push(`Approval gate is ${approval}`)
|
|
152
|
+
if (STRICT_WORKFLOW_PROFILES.has(workflowProfile) && AUDITED_PHASE_FILES.has(fileName) && audit !== 'PASS') lockProblems.push(`Audit gate is ${audit}`)
|
|
153
|
+
if (fileName === '03-implementation-summary.md' && tddCompliance !== 'PASS') lockProblems.push(`TDD Compliance gate is ${tddCompliance}`)
|
|
154
|
+
if (!todo.hasTodo) lockProblems.push('Missing ## TODO section')
|
|
155
|
+
else if (todo.unchecked > 0) lockProblems.push(`Unchecked TODO items: ${todo.unchecked}`)
|
|
156
|
+
if (lockProblems.length === 0) lockValid = true
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (!todo.hasTodo) blockers.push('Missing ## TODO section')
|
|
160
|
+
else if (todo.unchecked > 0) blockers.push(`Unchecked TODO items: ${todo.unchecked}`)
|
|
161
|
+
if (coverage !== 'PASS') blockers.push(`Coverage gate is ${coverage}`)
|
|
162
|
+
if (approval !== 'PASS') blockers.push(`Approval gate is ${approval}`)
|
|
163
|
+
if (fileName === '03-implementation-summary.md' && tddCompliance !== 'PASS') blockers.push(`TDD Compliance gate is ${tddCompliance}`)
|
|
164
|
+
|
|
165
|
+
const dedupedBlockers: string[] = []
|
|
166
|
+
for (const blocker of blockers) if (!dedupedBlockers.includes(blocker)) dedupedBlockers.push(blocker)
|
|
167
|
+
|
|
168
|
+
return { exists: true, status, lockValid, lockProblems, blockers: dedupedBlockers, lockedAt, storedHash, actualHash, coverage, approval, audit, todoHasSection: todo.hasTodo, todoUnchecked: todo.unchecked }
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function foldRun(runDir: string, runId: string): RecursiveStatusResult {
|
|
172
|
+
const workflowProfile = getWorkflowProfile(runDir)
|
|
173
|
+
const states = new Map<string, ArtifactState>()
|
|
174
|
+
for (const phase of PHASES) {
|
|
175
|
+
const state = getArtifactState(join(runDir, phase.file), workflowProfile)
|
|
176
|
+
if (phase.optional && !state.exists) state.status = 'SKIPPED'
|
|
177
|
+
else if (workflowProfile === 'legacy' && LATE_PHASE_KEYS.has(phase.key) && !state.exists) state.status = 'SKIPPED'
|
|
178
|
+
states.set(phase.key, state)
|
|
179
|
+
}
|
|
180
|
+
let currentPhase: RecursiveStatusResult['currentPhase'] = null
|
|
181
|
+
for (const phase of PHASES) {
|
|
182
|
+
const state = states.get(phase.key)!
|
|
183
|
+
if (state.status === 'SKIPPED') continue
|
|
184
|
+
if (!state.exists || !state.lockValid) {
|
|
185
|
+
currentPhase = { key: phase.key, label: phase.label, phaseName: phase.phaseName, status: state.status }
|
|
186
|
+
break
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
const phases: PhaseState[] = PHASES.map(phase => {
|
|
190
|
+
const state = states.get(phase.key)!
|
|
191
|
+
return { key: phase.key, label: phase.label, file: phase.file, optional: phase.optional, exists: state.exists, status: state.status, lockValid: state.lockValid, lockProblems: state.lockProblems, blockers: state.blockers }
|
|
192
|
+
})
|
|
193
|
+
return { runId, currentPhase, phases, workflowProfile }
|
|
194
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
export interface PhaseDef {
|
|
2
|
+
key: string
|
|
3
|
+
label: string
|
|
4
|
+
file: string
|
|
5
|
+
optional: boolean
|
|
6
|
+
phaseName: string
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface ArtifactState {
|
|
10
|
+
exists: boolean
|
|
11
|
+
status: string
|
|
12
|
+
lockValid: boolean
|
|
13
|
+
lockProblems: string[]
|
|
14
|
+
blockers: string[]
|
|
15
|
+
lockedAt: string | null
|
|
16
|
+
storedHash: string | null
|
|
17
|
+
actualHash: string | null
|
|
18
|
+
coverage: string
|
|
19
|
+
approval: string
|
|
20
|
+
audit: string
|
|
21
|
+
todoHasSection: boolean
|
|
22
|
+
todoUnchecked: number
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface PhaseState {
|
|
26
|
+
key: string
|
|
27
|
+
label: string
|
|
28
|
+
file: string
|
|
29
|
+
optional: boolean
|
|
30
|
+
exists: boolean
|
|
31
|
+
status: string
|
|
32
|
+
lockValid: boolean
|
|
33
|
+
lockProblems: string[]
|
|
34
|
+
blockers: string[]
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface RecursiveStatusResult {
|
|
38
|
+
runId: string
|
|
39
|
+
currentPhase: { key: string; label: string; phaseName: string; status: string } | null
|
|
40
|
+
phases: PhaseState[]
|
|
41
|
+
workflowProfile: string
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/* ================= Phase D wire vocabulary (run 06 R3, PROPOSAL 11.4-11.7) === */
|
|
45
|
+
|
|
46
|
+
/** Run-level durable state (mirrors lifecycle.ts RUN_STATES; single pure-type home for the wire). */
|
|
47
|
+
export type RecursiveRunState = 'new' | 'active' | 'paused' | 'blocked' | 'complete'
|
|
48
|
+
|
|
49
|
+
/** One locked-phase fact carried by a recursive/phase-locked event. */
|
|
50
|
+
export interface RecursivePhaseLock {
|
|
51
|
+
lockedAt: string
|
|
52
|
+
lockHash: string
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** One gate failure fact carried by a recursive/gate-blocked event. */
|
|
56
|
+
export interface RecursiveGateBlock {
|
|
57
|
+
failures: string[]
|
|
58
|
+
kind: string
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** One tamper fact carried by a recursive/tamper event. */
|
|
62
|
+
export interface RecursiveTamper {
|
|
63
|
+
path: string
|
|
64
|
+
reason: string
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** One subagent activity fact (start or end). */
|
|
68
|
+
export interface RecursiveSubagent {
|
|
69
|
+
childId: string
|
|
70
|
+
role: string
|
|
71
|
+
provider: string
|
|
72
|
+
status?: 'running' | 'done' | 'failed'
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** One phase row in a run card's folded phase chain. */
|
|
76
|
+
export interface RecursivePhaseRow {
|
|
77
|
+
phase: string
|
|
78
|
+
status: string
|
|
79
|
+
lockedAt?: string
|
|
80
|
+
lockHash?: string
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The per-run wire card the recursive projection folds: the board, inspector,
|
|
85
|
+
* node, and strip all render from this whole value — never from the file tree.
|
|
86
|
+
*/
|
|
87
|
+
export interface RecursiveRunCard {
|
|
88
|
+
runId: string
|
|
89
|
+
worktreeRoot: string
|
|
90
|
+
repo?: string
|
|
91
|
+
template?: string
|
|
92
|
+
/** Folded phase chain: last-wins per phase key. */
|
|
93
|
+
phases: Record<string, RecursivePhaseRow>
|
|
94
|
+
/** Folded run-level state: last recursive/run-state wins (default 'active'). */
|
|
95
|
+
state: RecursiveRunState
|
|
96
|
+
stateReason?: string
|
|
97
|
+
/** Latest gate block (last-wins); absent when no block is in force. */
|
|
98
|
+
gateBlocked?: RecursiveGateBlock
|
|
99
|
+
/** Tamper facts (accumulated, latest wins per path). */
|
|
100
|
+
tampers: Record<string, RecursiveTamper>
|
|
101
|
+
/** Subagent activity (latest status wins per childId). */
|
|
102
|
+
subagents: Record<string, RecursiveSubagent>
|
|
103
|
+
/** Set on recursive/run-merged; the run re-keys to this root. */
|
|
104
|
+
mergedToRepoRoot?: string
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The recursive projection whole value: runs grouped by worktree root first,
|
|
109
|
+
* then by runId. The board renders one lane per run; the client enumerates by
|
|
110
|
+
* worktree root so it never crosses the workspace boundary.
|
|
111
|
+
*/
|
|
112
|
+
export type RecursiveProjection = Record<string, Record<string, RecursiveRunCard>>
|
package/src/workspace.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workspace-scoped control-plane root resolution (R1, binding invariant).
|
|
3
|
+
*
|
|
4
|
+
* The plugin NEVER scans `workspaceRegistry.list()` to find "the" workspace and
|
|
5
|
+
* NEVER reads another open workspace's `.recursive/` tree. The ONLY root-resolution
|
|
6
|
+
* path is: session canonical cwd (`agent.session.header.cwd`) ->
|
|
7
|
+
* `workspaceRegistry.resolveByPath(cwd)` -> the owning workspace's canonical `path`,
|
|
8
|
+
* which IS the control-plane root.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export interface WorkspaceLike {
|
|
12
|
+
readonly path: string
|
|
13
|
+
readonly id: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface WorkspaceRegistryLike {
|
|
17
|
+
/** Resolve by canonical directory path without creating or mutating. */
|
|
18
|
+
resolveByPath(path: string): Promise<WorkspaceLike | undefined> | WorkspaceLike | undefined
|
|
19
|
+
/** MUST NOT be used by this plugin (workspace-scoping invariant). */
|
|
20
|
+
list?(): unknown
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface WorkspaceResolver {
|
|
24
|
+
(cwd: string): Promise<string | null>
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Build a resolver bound to a workspace registry. Returns the canonical workspace
|
|
29
|
+
* path for the given cwd, or `null` when the cwd is not a registered workspace
|
|
30
|
+
* (no repo -> defer, never fail hard).
|
|
31
|
+
*/
|
|
32
|
+
export function makeWorkspaceResolver(registry: WorkspaceRegistryLike): WorkspaceResolver {
|
|
33
|
+
return async (cwd: string): Promise<string | null> => {
|
|
34
|
+
if (!cwd) return null
|
|
35
|
+
const workspace = await registry.resolveByPath(cwd)
|
|
36
|
+
return workspace?.path ?? null
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Resolve the control-plane root for an agent-like object with a session header
|
|
42
|
+
* carrying a canonical `cwd`. Degrades to `null` when the registry or cwd is
|
|
43
|
+
* unavailable (callers defer, never fail hard).
|
|
44
|
+
*/
|
|
45
|
+
export async function resolveControlPlaneRoot(
|
|
46
|
+
agent: { session?: { header?: { cwd?: string } } } | null | undefined,
|
|
47
|
+
registry?: WorkspaceRegistryLike | null,
|
|
48
|
+
cwdFallback?: string,
|
|
49
|
+
): Promise<string | null> {
|
|
50
|
+
const cwd = agent?.session?.header?.cwd ?? cwdFallback ?? null
|
|
51
|
+
if (!cwd) return null
|
|
52
|
+
// B4: the session header cwd IS the workspace/control-plane root when the
|
|
53
|
+
// registry is absent (headless/code-mode assemblies do not compose the
|
|
54
|
+
// workspace service). Registry, when present, canonicalizes; otherwise the
|
|
55
|
+
// session cwd is authoritative — never null just because the registry is
|
|
56
|
+
// missing, or scratch/closeout (and every other per-call read) would be
|
|
57
|
+
// permanently dead in headless hosts.
|
|
58
|
+
if (!registry) return cwd
|
|
59
|
+
return makeWorkspaceResolver(registry)(cwd)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Extract the canonical session cwd (for callers that pass it explicitly).
|
|
64
|
+
*/
|
|
65
|
+
export function sessionCwd(agent: { session?: { header?: { cwd?: string } } } | null | undefined): string | null {
|
|
66
|
+
return agent?.session?.header?.cwd ?? null
|
|
67
|
+
}
|