@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/runtime.ts
ADDED
|
@@ -0,0 +1,564 @@
|
|
|
1
|
+
import { Service, type Context } from '@deepseek-ai/cordis'
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import { foldRun, resolveRunDir } from './status.ts'
|
|
5
|
+
import {
|
|
6
|
+
getLockStatus,
|
|
7
|
+
getNextLegalPhase,
|
|
8
|
+
getPrerequisiteBlockers,
|
|
9
|
+
getStaleDownstreamPhases,
|
|
10
|
+
invalidateReceipt,
|
|
11
|
+
lockHashFromContent,
|
|
12
|
+
writeReceipt,
|
|
13
|
+
} from './lock.ts'
|
|
14
|
+
import type { RecursiveStatusResult } from './types.ts'
|
|
15
|
+
import { resolveControlPlaneRoot, type WorkspaceRegistryLike } from './workspace.ts'
|
|
16
|
+
import { closeoutPhase } from './closeout.ts'
|
|
17
|
+
import { readScratch, writeScratch, appendScratch, type ScratchTarget } from './scratch.ts'
|
|
18
|
+
import { buildReviewBundle, type ReviewBundleInput } from './review.ts'
|
|
19
|
+
import { createHandoff, createChildBrief, replyPath, childScratchPath, buildDelegationPrompt, type HandoffInput, type ChildBriefInput } from './handoff.ts'
|
|
20
|
+
import { loadRouterPolicy, routerPolicyPath, resolveRole, capabilityProbe, delegationDecisionBasis, type RouterPolicy, type SubagentProviderLike, type RouteDecision, type CapabilityProbe } from './router.ts'
|
|
21
|
+
import { delegate, validateReferences, writeActionRecord, evaluateDelegationResult, reviewOutputSchema, defaultReviewToolFilter, type SubagentsRuntimeLike, type SubagentStartRequestLike, type SubagentResultLike, type Reference, type ActionRecordInput } from './delegation.ts'
|
|
22
|
+
import { foldRecursivePhase, validateTransition, detectTransitionIntent, LifecycleDriver, coupleGateBlockToGoal, type PhaseTransitionIntent, type SessionEventLike, type RecursivePhaseState, type GateCheckResult } from './lifecycle.ts'
|
|
23
|
+
import { resolveEnforcementConfig, DEFAULT_ENFORCEMENT, evaluatePreStepGate, evaluateToolGuard, detectTamper, type EnforcementConfig, type PreStepGateDecision, type ToolGuardDecision, type ToolExecLike } from './enforcement.ts'
|
|
24
|
+
import type { Session } from '@deepseek-ai/dsh-session'
|
|
25
|
+
import { runCreated as runCreatedEvent, phase as phaseEvent, phaseLocked as phaseLockedEvent, runState as runStateEvent } from './events.ts'
|
|
26
|
+
import { renderRecursivePolicy, type PolicyContext } from './policy.ts'
|
|
27
|
+
import { foldRecursiveProjection as foldProjectionEvent, emptyRecursiveFoldState, recursiveProjectionUnit, isInsideWorkspace, type RecursiveEventLike, type RecursiveFoldState } from './projection.ts'
|
|
28
|
+
|
|
29
|
+
declare module '@deepseek-ai/cordis' {
|
|
30
|
+
interface Context {
|
|
31
|
+
recursive: RecursiveRuntime
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface LockArtifactResult {
|
|
36
|
+
artifact: string
|
|
37
|
+
runId: string
|
|
38
|
+
status: string
|
|
39
|
+
lockedAt: string | null
|
|
40
|
+
lockHash: string | null
|
|
41
|
+
receipt?: unknown
|
|
42
|
+
blockers: string[]
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface LintArtifactResult {
|
|
46
|
+
artifact: string
|
|
47
|
+
runId: string
|
|
48
|
+
errors: string[]
|
|
49
|
+
warnings: string[]
|
|
50
|
+
passed: boolean
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const ARTIFACT_STUB = {
|
|
54
|
+
'00-requirements.md': ['Run: ', 'Phase: 0', 'Status: DRAFT', 'Workflow version: recursive-mode-audit-v2', 'Inputs: none', 'Outputs: none', 'Scope note: '],
|
|
55
|
+
'00-worktree.md': ['Run: ', 'Phase: 0 (Worktree)', 'Status: DRAFT', 'Workflow version: recursive-mode-audit-v2', 'Inputs: none', 'Outputs: none', 'Scope note: '],
|
|
56
|
+
'01-as-is.md': ['Run: ', 'Phase: 1 (AS-IS)', 'Status: DRAFT', 'Workflow version: recursive-mode-audit-v2', 'Inputs: none', 'Outputs: none', 'Scope note: '],
|
|
57
|
+
'02-to-be-plan.md': ['Run: ', 'Phase: 2 (TO-BE Plan)', 'Status: DRAFT', 'Workflow version: recursive-mode-audit-v2', 'Inputs: none', 'Outputs: none', 'Scope note: '],
|
|
58
|
+
'03-implementation-summary.md': ['Run: ', 'Phase: 3 (Implementation)', 'Status: DRAFT', 'Workflow version: recursive-mode-audit-v2', 'Inputs: none', 'Outputs: none', 'Scope note: '],
|
|
59
|
+
'04-test-summary.md': ['Run: ', 'Phase: 4 (Test Summary)', 'Status: DRAFT', 'Workflow version: recursive-mode-audit-v2', 'Inputs: none', 'Outputs: none', 'Scope note: '],
|
|
60
|
+
'05-manual-qa.md': ['Run: ', 'Phase: 5 (Manual QA)', 'Status: DRAFT', 'Workflow version: recursive-mode-audit-v2', 'Inputs: none', 'Outputs: none', 'Scope note: '],
|
|
61
|
+
'06-decisions-update.md': ['Run: ', 'Phase: 6 (Decisions Update)', 'Status: DRAFT', 'Workflow version: recursive-mode-audit-v2', 'Inputs: none', 'Outputs: none', 'Scope note: '],
|
|
62
|
+
'07-state-update.md': ['Run: ', 'Phase: 7 (State Update)', 'Status: DRAFT', 'Workflow version: recursive-mode-audit-v2', 'Inputs: none', 'Outputs: none', 'Scope note: '],
|
|
63
|
+
'08-memory-impact.md': ['Run: ', 'Phase: 8 (Memory Impact)', 'Status: DRAFT', 'Workflow version: recursive-mode-audit-v2', 'Inputs: none', 'Outputs: none', 'Scope note: '],
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export class RecursiveRuntime extends Service {
|
|
67
|
+
/** Recursive-mode runtime service. Owns run-state reads + lock/init/lint operations. */
|
|
68
|
+
|
|
69
|
+
constructor(ctx: Context, config: { repoRoot?: string; workspaceRegistry?: WorkspaceRegistryLike } = {}) {
|
|
70
|
+
super(ctx, 'recursive')
|
|
71
|
+
this.repoRoot = config.repoRoot ?? process.cwd()
|
|
72
|
+
this.workspaceRegistry = config.workspaceRegistry ?? null
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
private readonly repoRoot: string
|
|
76
|
+
private readonly workspaceRegistry: WorkspaceRegistryLike | null
|
|
77
|
+
private _enforcementConfig: EnforcementConfig | null = null
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Workspace-scoped control-plane root (R1 binding invariant).
|
|
81
|
+
* Resolves the session agent's canonical cwd -> workspace path via the
|
|
82
|
+
* registry; NEVER scans list(). Returns null when unavailable (defer).
|
|
83
|
+
*/
|
|
84
|
+
async resolveWorkspaceRoot(agent?: { session?: { header?: { cwd?: string } } } | null): Promise<string | null> {
|
|
85
|
+
return resolveControlPlaneRoot(agent, this.workspaceRegistry)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Run-scoped closeout receipt scaffold (R2), rooted under the given
|
|
90
|
+
* workspace root. Refuses runIds outside the root (never crosses workspaces).
|
|
91
|
+
*/
|
|
92
|
+
closeoutRun(root: string, runId: string, phase: string) {
|
|
93
|
+
const runDir = join(root, '.recursive', 'run', runId)
|
|
94
|
+
const runRoot = join(root, '.recursive', 'run')
|
|
95
|
+
// workspace-scoping guard: the run must be under this root
|
|
96
|
+
if (!runDir.startsWith(runRoot) || !existsSync(runDir)) {
|
|
97
|
+
return { error: 'Run not found in current workspace: ' + runId }
|
|
98
|
+
}
|
|
99
|
+
try {
|
|
100
|
+
const result = closeoutPhase(runDir, phase)
|
|
101
|
+
return { closeoutPhase: phase, runId, ...result }
|
|
102
|
+
} catch (err) {
|
|
103
|
+
return { error: (err as Error).message }
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Run-scoped scratchpad access (R5), rooted under the given workspace root.
|
|
109
|
+
*/
|
|
110
|
+
scratchRun(root: string, runId: string, action: string, target: ScratchTarget, content?: string) {
|
|
111
|
+
const runDir = join(root, '.recursive', 'run', runId)
|
|
112
|
+
const runRoot = join(root, '.recursive', 'run')
|
|
113
|
+
if (!runDir.startsWith(runRoot) || !existsSync(runDir)) {
|
|
114
|
+
return { error: 'Run not found in current workspace: ' + runId }
|
|
115
|
+
}
|
|
116
|
+
try {
|
|
117
|
+
if (action === 'read') {
|
|
118
|
+
return { runId, target, action, content: readScratch(runDir, target), path: join(runDir, 'scratch', 'scratch.' + target) }
|
|
119
|
+
}
|
|
120
|
+
if (action === 'write') {
|
|
121
|
+
const path = writeScratch(runDir, target, content ?? '')
|
|
122
|
+
return { runId, target, action, path }
|
|
123
|
+
}
|
|
124
|
+
if (action === 'append') {
|
|
125
|
+
const path = appendScratch(runDir, target, content ?? '')
|
|
126
|
+
return { runId, target, action, path }
|
|
127
|
+
}
|
|
128
|
+
return { error: 'Unsupported scratch action: ' + action + ' (expected read|write|append)' }
|
|
129
|
+
} catch (err) {
|
|
130
|
+
return { error: (err as Error).message }
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Phase B (native delegation): build a review bundle (R1) + file-backed
|
|
136
|
+
* handoff docs (R2), resolve the role via the router policy (R3), and call
|
|
137
|
+
* ctx.subagents.start() with the full request (R4). Workspace-scoped: every
|
|
138
|
+
* path resolves under the session's control-plane root.
|
|
139
|
+
*/
|
|
140
|
+
async delegateReview(input: {
|
|
141
|
+
root: string
|
|
142
|
+
runId: string
|
|
143
|
+
phase: string
|
|
144
|
+
role: string
|
|
145
|
+
delegationId: string
|
|
146
|
+
childId: string
|
|
147
|
+
artifactPath: string
|
|
148
|
+
upstreamArtifacts: string[]
|
|
149
|
+
auditQuestions: string[]
|
|
150
|
+
requiredOutput: string
|
|
151
|
+
codeRefs?: string[]
|
|
152
|
+
changedFiles?: string[]
|
|
153
|
+
diffBasis?: ReviewBundleInput['diffBasis']
|
|
154
|
+
policyPath?: string
|
|
155
|
+
providers?: Record<string, SubagentProviderLike>
|
|
156
|
+
subagents?: SubagentsRuntimeLike
|
|
157
|
+
maxDepth?: number
|
|
158
|
+
toolFilter?: unknown
|
|
159
|
+
}) {
|
|
160
|
+
const policy = loadRouterPolicy(input.policyPath ?? routerPolicyPath(input.root))
|
|
161
|
+
const providers = input.providers ?? {}
|
|
162
|
+
const decision = resolveRole(input.role, policy, providers)
|
|
163
|
+
const probe = capabilityProbe({ providers, role: input.role, policy })
|
|
164
|
+
|
|
165
|
+
// R1 bundle + R2 handoff/brief/prompt (file-backed context-in contract).
|
|
166
|
+
const bundle = buildReviewBundle({
|
|
167
|
+
root: input.root,
|
|
168
|
+
runId: input.runId,
|
|
169
|
+
phase: input.phase,
|
|
170
|
+
role: input.role,
|
|
171
|
+
artifactPath: input.artifactPath,
|
|
172
|
+
upstreamArtifacts: input.upstreamArtifacts,
|
|
173
|
+
auditQuestions: input.auditQuestions,
|
|
174
|
+
requiredOutput: input.requiredOutput,
|
|
175
|
+
codeRefs: input.codeRefs,
|
|
176
|
+
changedFiles: input.changedFiles,
|
|
177
|
+
diffBasis: input.diffBasis,
|
|
178
|
+
})
|
|
179
|
+
const handoffPath = createHandoff({
|
|
180
|
+
root: input.root,
|
|
181
|
+
runId: input.runId,
|
|
182
|
+
delegationId: input.delegationId,
|
|
183
|
+
role: input.role,
|
|
184
|
+
objective: input.requiredOutput,
|
|
185
|
+
runDocRefs: input.upstreamArtifacts,
|
|
186
|
+
codeRefs: input.codeRefs ?? [],
|
|
187
|
+
auditQuestions: input.auditQuestions,
|
|
188
|
+
requiredOutput: input.requiredOutput,
|
|
189
|
+
decisionBasis: decision.reason,
|
|
190
|
+
constraints: [
|
|
191
|
+
'Workspace-scoped: never read another workspace\'s .recursive/ tree.',
|
|
192
|
+
'Optionality: if the probe fails, fall back to self-audit — never weaken the audit.',
|
|
193
|
+
'Fail loud: capability mismatches reject; do not silently degrade.',
|
|
194
|
+
],
|
|
195
|
+
})
|
|
196
|
+
const briefPath = createChildBrief({
|
|
197
|
+
root: input.root,
|
|
198
|
+
runId: input.runId,
|
|
199
|
+
delegationId: input.delegationId,
|
|
200
|
+
childId: input.childId,
|
|
201
|
+
slice: 'Perform the delegated ' + input.role + ' for run ' + input.runId + ' (' + input.phase + ') and write your submission to reply.md.',
|
|
202
|
+
})
|
|
203
|
+
const prompt = buildDelegationPrompt({
|
|
204
|
+
root: input.root,
|
|
205
|
+
runId: input.runId,
|
|
206
|
+
delegationId: input.delegationId,
|
|
207
|
+
childId: input.childId,
|
|
208
|
+
handoffPath,
|
|
209
|
+
briefPath,
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
// R4: plugin-driven delegation with the full request shape.
|
|
213
|
+
const request: SubagentStartRequestLike = {
|
|
214
|
+
prompt: [{ type: 'text', text: prompt }],
|
|
215
|
+
label: input.delegationId + '/' + input.childId,
|
|
216
|
+
outputSchema: reviewOutputSchema(),
|
|
217
|
+
toolFilter: input.toolFilter ?? defaultReviewToolFilter(),
|
|
218
|
+
maxDepth: input.maxDepth ?? 2,
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
let result: SubagentResultLike | null = null
|
|
222
|
+
let error: string | null = null
|
|
223
|
+
if (decision.tier === 'native' || decision.tier === 'external-cli') {
|
|
224
|
+
if (!input.subagents) {
|
|
225
|
+
error = 'no ctx.subagents runtime available (self-audit fallback)'
|
|
226
|
+
} else {
|
|
227
|
+
try {
|
|
228
|
+
result = await delegate({
|
|
229
|
+
subagents: input.subagents,
|
|
230
|
+
provider: decision.provider as string,
|
|
231
|
+
request,
|
|
232
|
+
})
|
|
233
|
+
} catch (err) {
|
|
234
|
+
error = (err as Error).message
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
} else {
|
|
238
|
+
error = 'delegation resolved to ' + decision.tier + ' (' + decision.reason + ')'
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const evaluation = result ? evaluateDelegationResult(result) : { accepted: false, reason: error ?? 'no result' }
|
|
242
|
+
|
|
243
|
+
// R6: record the attempt as an action record (accepted only if evaluation passes).
|
|
244
|
+
const actionRecordPath = writeActionRecord({
|
|
245
|
+
root: input.root,
|
|
246
|
+
runId: input.runId,
|
|
247
|
+
subagentId: input.childId,
|
|
248
|
+
phase: input.phase,
|
|
249
|
+
purpose: input.role + ' for run ' + input.runId,
|
|
250
|
+
executionMode: decision.tier,
|
|
251
|
+
artifactPath: input.artifactPath,
|
|
252
|
+
upstreamArtifacts: input.upstreamArtifacts,
|
|
253
|
+
reviewBundle: bundle.repoRelativePath,
|
|
254
|
+
diffBasis: input.diffBasis?.normalizedDiffCommand,
|
|
255
|
+
codeRefs: input.codeRefs,
|
|
256
|
+
auditQuestions: input.auditQuestions,
|
|
257
|
+
findings: evaluation.accepted && result?.structured ? [(result.structured as { verdict?: string })?.verdict ?? 'accepted'] : undefined,
|
|
258
|
+
success: evaluation.accepted,
|
|
259
|
+
stopReason: result?.stopReason,
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
return {
|
|
263
|
+
decision,
|
|
264
|
+
probe,
|
|
265
|
+
bundle,
|
|
266
|
+
handoffPath,
|
|
267
|
+
briefPath,
|
|
268
|
+
replyPath: replyPath({ root: input.root, runId: input.runId, delegationId: input.delegationId, childId: input.childId }),
|
|
269
|
+
childScratchPath: childScratchPath({ root: input.root, runId: input.runId, childId: input.childId }),
|
|
270
|
+
prompt,
|
|
271
|
+
request,
|
|
272
|
+
result,
|
|
273
|
+
evaluation,
|
|
274
|
+
actionRecordPath,
|
|
275
|
+
error,
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** R6: validate a child's claimed references against actual files. */
|
|
280
|
+
validateReferences(root: string, references: Reference[]) {
|
|
281
|
+
return validateReferences(root, references)
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** R7: probe availability for a role and render the decision basis prose. */
|
|
285
|
+
probeDelegation(root: string, role: string, providers: Record<string, SubagentProviderLike> = {}) {
|
|
286
|
+
const policy = loadRouterPolicy(routerPolicyPath(root))
|
|
287
|
+
const decision = resolveRole(role, policy, providers)
|
|
288
|
+
const probe = capabilityProbe({ providers, role, policy })
|
|
289
|
+
return {
|
|
290
|
+
decision,
|
|
291
|
+
probe,
|
|
292
|
+
basis: delegationDecisionBasis({ role, available: probe.available, provider: probe.provider, fallback: 'self-audit' }),
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* B3: per-call workspace root resolution. The control-plane root is the
|
|
298
|
+
* session's cwd (or registry-canonicalized), NEVER process.cwd() — the host
|
|
299
|
+
* checkout is not the run's workspace. Reads resolve under that root only.
|
|
300
|
+
*/
|
|
301
|
+
async resolveRootFor(agent?: { session?: { header?: { cwd?: string } } } | null): Promise<string | null> {
|
|
302
|
+
return resolveControlPlaneRoot(agent, this.workspaceRegistry, this.repoRoot)
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Emit a recursive/* event into the session whose control-plane root matches
|
|
307
|
+
* `root` (additive log-only; the projection drives from these). Falls back to
|
|
308
|
+
* the agent's session when passed, else the session store list.
|
|
309
|
+
*/
|
|
310
|
+
private emitToSession(root: string, type: string, data: unknown, agent?: { session?: { header?: { cwd?: string } } | null } | null) {
|
|
311
|
+
const sessions = this.ctx.get('sessions') as { list?: () => Session[] } | undefined
|
|
312
|
+
const candidates: Session[] = []
|
|
313
|
+
if (agent?.session) candidates.push(agent.session as unknown as Session)
|
|
314
|
+
const listed = sessions?.list?.() ?? []
|
|
315
|
+
for (const s of listed) candidates.push(s)
|
|
316
|
+
const norm = (x: string) => x.split(String.fromCharCode(92)).join("/")
|
|
317
|
+
const target = candidates.find((s) => {
|
|
318
|
+
const cwd = norm(s.header?.cwd ?? "")
|
|
319
|
+
return cwd === "" || cwd === norm(root) || cwd.startsWith(norm(root) + "/")
|
|
320
|
+
})
|
|
321
|
+
if (!target) return
|
|
322
|
+
try { (target.append as unknown as (t: string, d: unknown) => void)(type, data) } catch { /* log-only */ }
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
async status(runId?: string, agent?: { session?: { header?: { cwd?: string } } } | null): Promise<RecursiveStatusResult | null> {
|
|
326
|
+
const root = await this.resolveRootFor(agent)
|
|
327
|
+
if (!root) return null
|
|
328
|
+
const resolved = resolveRunDir(root, runId)
|
|
329
|
+
if (!resolved) return null
|
|
330
|
+
return foldRun(resolved.runDir, resolved.runId)
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Scaffold a run directory with stub artifact headers (no-op if exists).
|
|
335
|
+
* Returns the run dir + created artifacts.
|
|
336
|
+
*/
|
|
337
|
+
async initRun(runId: string, agent?: { session?: { header?: { cwd?: string } } } | null): Promise<{ runDir: string; runId: string; created: string[]; existing: string[] }> {
|
|
338
|
+
const root = await this.resolveRootFor(agent)
|
|
339
|
+
if (!root) throw new Error('cannot resolve workspace control-plane root for this session')
|
|
340
|
+
const runDir = join(root, '.recursive', 'run', runId)
|
|
341
|
+
mkdirSync(runDir, { recursive: true })
|
|
342
|
+
const created: string[] = []
|
|
343
|
+
const existing: string[] = []
|
|
344
|
+
for (const [file, fields] of Object.entries(ARTIFACT_STUB)) {
|
|
345
|
+
const path = join(runDir, file)
|
|
346
|
+
if (existsSync(path)) {
|
|
347
|
+
existing.push(file)
|
|
348
|
+
continue
|
|
349
|
+
}
|
|
350
|
+
const header = fields.map((f, i) => (i === 0 ? f + runId : f)).join('\n')
|
|
351
|
+
writeFileSync(path, '# ' + file.replace(/\.md$/, '') + ' — ' + file + '\n\n' + header + '\n', 'utf8')
|
|
352
|
+
created.push(file)
|
|
353
|
+
}
|
|
354
|
+
// B2: a run's files first exist here — the run-created event feeds the
|
|
355
|
+
// projection's worktree-grouped card. Emit only when the scaffold is fresh
|
|
356
|
+
// (created.length > 0) so resume/ensure does not double-log.
|
|
357
|
+
if (created.length > 0) {
|
|
358
|
+
this.emitToSession(root, 'recursive/run-created', runCreatedEvent({ runId, worktreeRoot: root }), agent)
|
|
359
|
+
this.emitToSession(root, 'recursive/run-state', runStateEvent({ runId, worktreeRoot: root, state: 'new' }), agent)
|
|
360
|
+
}
|
|
361
|
+
return { runDir, runId, created, existing }
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Lock a DRAFT artifact (or reopen a LOCKED one). Validates prerequisites;
|
|
366
|
+
* writes Status/LockedAt/LockHash + receipt. Returns the lock result.
|
|
367
|
+
*/
|
|
368
|
+
async lockArtifact(runId: string, artifact: string, reopen = false, agent?: { session?: { header?: { cwd?: string } } } | null): Promise<LockArtifactResult> {
|
|
369
|
+
const root = await this.resolveRootFor(agent)
|
|
370
|
+
if (!root) throw new Error('cannot resolve workspace control-plane root for this session')
|
|
371
|
+
const runDir = join(root, '.recursive', 'run', runId)
|
|
372
|
+
const artifactPath = join(runDir, artifact)
|
|
373
|
+
if (reopen) {
|
|
374
|
+
return this.reopenArtifact(root, runDir, runId, artifact, artifactPath, agent)
|
|
375
|
+
}
|
|
376
|
+
if (!existsSync(artifactPath)) throw new Error('Artifact not found: ' + artifact)
|
|
377
|
+
const status = getLockStatus(artifactPath)
|
|
378
|
+
if (status === 'LOCKED') throw new Error('Artifact already LOCKED: ' + artifact)
|
|
379
|
+
// B2: log the phase-intent BEFORE validation so the pre-step gate reads it
|
|
380
|
+
// (the caller of the transition set). Never a projection event.
|
|
381
|
+
this.emitToSession(root, 'recursive/phase-intent', { runId, worktreeRoot: root, targetArtifact: artifact, kind: 'lock' }, agent)
|
|
382
|
+
const blockers = getPrerequisiteBlockers(runDir, artifact)
|
|
383
|
+
if (blockers.length > 0) {
|
|
384
|
+
throw new Error('Prerequisite blockers: ' + blockers.map(b => b.artifact + ' (' + b.status + ')').join(', '))
|
|
385
|
+
}
|
|
386
|
+
let content = readFileSync(artifactPath, 'utf8')
|
|
387
|
+
const lockedAt = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z')
|
|
388
|
+
content = setOrInsertField(content, 'Status', 'LOCKED', ['Phase'])
|
|
389
|
+
content = setOrInsertField(content, 'LockedAt', lockedAt, ['Status'])
|
|
390
|
+
const provisional = setOrInsertField(content, 'LockHash', '0'.repeat(64), ['LockedAt', 'Status'])
|
|
391
|
+
const lockHash = lockHashFromContent(provisional)
|
|
392
|
+
content = setOrInsertField(content, 'LockHash', lockHash, ['LockedAt', 'Status'])
|
|
393
|
+
writeFileSync(artifactPath, content, 'utf8')
|
|
394
|
+
const receipt = writeReceipt(runDir, artifact, artifactPath)
|
|
395
|
+
// B2: the commit is durable — emit phase + phase-locked (the projection's
|
|
396
|
+
// last-wins phase chain + lock badge + truncated hash).
|
|
397
|
+
this.emitToSession(root, 'recursive/phase', phaseEvent({ runId, worktreeRoot: root, phase: artifact, status: 'LOCKED' }), agent)
|
|
398
|
+
this.emitToSession(root, 'recursive/phase-locked', phaseLockedEvent({ runId, worktreeRoot: root, phase: artifact, lockedAt, lockHash }), agent)
|
|
399
|
+
this.emitToSession(root, 'recursive/run-state', runStateEvent({ runId, worktreeRoot: root, state: 'active' }), agent)
|
|
400
|
+
return {
|
|
401
|
+
artifact,
|
|
402
|
+
runId,
|
|
403
|
+
status: 'LOCKED',
|
|
404
|
+
lockedAt,
|
|
405
|
+
lockHash,
|
|
406
|
+
receipt,
|
|
407
|
+
blockers: [],
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/** Reopen a LOCKED artifact to DRAFT (delete LockedAt/LockHash, invalidate downstream receipts). */
|
|
412
|
+
private reopenArtifact(root: string, runDir: string, runId: string, artifact: string, artifactPath: string, agent?: { session?: { header?: { cwd?: string } } } | null): LockArtifactResult {
|
|
413
|
+
if (!existsSync(artifactPath)) throw new Error('Artifact not found: ' + artifact)
|
|
414
|
+
let content = readFileSync(artifactPath, 'utf8')
|
|
415
|
+
content = content.replace(/^[ \t]*Status:.*$/m, 'Status: `DRAFT`')
|
|
416
|
+
content = content.replace(/^[ \t]*LockedAt:.*\n?/m, '')
|
|
417
|
+
content = content.replace(/^[ \t]*LockHash:.*\n?/m, '')
|
|
418
|
+
writeFileSync(artifactPath, content, 'utf8')
|
|
419
|
+
const hadReceipt = invalidateReceipt(runDir, artifact)
|
|
420
|
+
const stale = getStaleDownstreamPhases(runDir, artifact)
|
|
421
|
+
for (const entry of stale) invalidateReceipt(runDir, entry.artifact)
|
|
422
|
+
// B2: reopen reverts to DRAFT — log phase-intent + emit the transition so
|
|
423
|
+
// the pre-step gate and projection both see the reverted state.
|
|
424
|
+
this.emitToSession(root, 'recursive/phase-intent', { runId, worktreeRoot: root, targetArtifact: artifact, kind: 'reopen' }, agent)
|
|
425
|
+
this.emitToSession(root, 'recursive/phase', phaseEvent({ runId, worktreeRoot: root, phase: artifact, status: 'DRAFT' }), agent)
|
|
426
|
+
this.emitToSession(root, 'recursive/run-state', runStateEvent({ runId, worktreeRoot: root, state: 'active', reason: 'reopened ' + artifact }), agent)
|
|
427
|
+
return {
|
|
428
|
+
artifact,
|
|
429
|
+
runId,
|
|
430
|
+
status: 'DRAFT',
|
|
431
|
+
lockedAt: null,
|
|
432
|
+
lockHash: null,
|
|
433
|
+
blockers: hadReceipt || stale.length > 0 ? ['Downstream receipts invalidated'] : [],
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* Lint an artifact for phase-specific issues. Minimal structural checks:
|
|
439
|
+
* gates present, TODO section, Status field, LockHash consistency.
|
|
440
|
+
*/
|
|
441
|
+
async lintArtifact(runId: string, artifact?: string, agent?: { session?: { header?: { cwd?: string } } } | null): Promise<LintArtifactResult> {
|
|
442
|
+
const root = await this.resolveRootFor(agent)
|
|
443
|
+
if (!root) throw new Error('cannot resolve workspace control-plane root for this session')
|
|
444
|
+
const runDir = join(root, '.recursive', 'run', runId)
|
|
445
|
+
const target = artifact ?? getNextLegalPhase(runDir) ?? '00-requirements.md'
|
|
446
|
+
const artifactPath = join(runDir, target)
|
|
447
|
+
if (!existsSync(artifactPath)) {
|
|
448
|
+
return { artifact: target, runId, errors: ['Artifact not found'], warnings: [], passed: false }
|
|
449
|
+
}
|
|
450
|
+
const content = readFileSync(artifactPath, 'utf8')
|
|
451
|
+
const errors: string[] = []
|
|
452
|
+
const warnings: string[] = []
|
|
453
|
+
const status = getLockStatus(artifactPath)
|
|
454
|
+
if (status === 'MISSING') errors.push('File missing')
|
|
455
|
+
else if (status === 'DRAFT') warnings.push('Artifact is DRAFT (not locked)')
|
|
456
|
+
else if (status === 'STALE_LOCK') errors.push('LockHash mismatch or missing lock fields')
|
|
457
|
+
if (!/^[ \t]*## TODO[ \t]*$/m.test(content)) errors.push('Missing ## TODO section')
|
|
458
|
+
for (const gate of ['Coverage', 'Approval']) {
|
|
459
|
+
if (!new RegExp('^[ \\t]*' + gate + ':\\s*(PASS|FAIL)\\s*$', 'm').test(content)) warnings.push('Missing ' + gate + ' gate')
|
|
460
|
+
}
|
|
461
|
+
if (target === '03-implementation-summary.md' && !new RegExp('^[ \\t]*TDD Compliance:\\s*(PASS|FAIL)\\s*$', 'm').test(content)) {
|
|
462
|
+
warnings.push('Missing TDD Compliance gate')
|
|
463
|
+
}
|
|
464
|
+
return { artifact: target, runId, errors, warnings, passed: errors.length === 0 }
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/** Phase C R1/R2: fold the log into phase state; validate a transition intent. */
|
|
468
|
+
foldPhase(events: readonly SessionEventLike[]): RecursivePhaseState | null {
|
|
469
|
+
return foldRecursivePhase(events)
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
validateTransition(intent: PhaseTransitionIntent): GateCheckResult {
|
|
473
|
+
return validateTransition(intent)
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
detectTransitionIntent(events: readonly SessionEventLike[]): PhaseTransitionIntent | null {
|
|
477
|
+
return detectTransitionIntent(events)
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/** Phase C R3: Layer 1 pre-step gate decision (caller of the transition set). */
|
|
481
|
+
gatePreStep(events: readonly SessionEventLike[], config?: EnforcementConfig): PreStepGateDecision {
|
|
482
|
+
const mode = (config ?? this.enforcementConfig).preStep
|
|
483
|
+
return evaluatePreStepGate(events, mode)
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/** Phase C R4: Layer 2 tool guard decision (caller of the transition set). */
|
|
487
|
+
guardTool(exec: ToolExecLike, root: string, runId: string, config?: EnforcementConfig): ToolGuardDecision {
|
|
488
|
+
const mode = (config ?? this.enforcementConfig).toolGuards
|
|
489
|
+
return evaluateToolGuard(exec, root, runId, mode)
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/** Phase C R8: fs/observed tamper detection. */
|
|
493
|
+
detectTamper(targetPath: string, root: string, runId: string) {
|
|
494
|
+
return detectTamper(targetPath, root, runId)
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/** Phase C R5: render the current-phase policy contract. */
|
|
498
|
+
renderPolicy(root: string, runId: string, folded: RecursivePhaseState | null): string {
|
|
499
|
+
return renderRecursivePolicy({ worktreeRoot: root, runId, folded, config: this.enforcementConfig } as PolicyContext)
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/** Phase C R6: couple a gate-block to the goal service (graceful no-op). */
|
|
503
|
+
coupleGateBlockToGoal(goalService: unknown, agent: unknown, ref: unknown, reason: { code: string; message: string }): boolean {
|
|
504
|
+
return coupleGateBlockToGoal(goalService as never, agent, ref, reason)
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/** Phase D R1: append a recursive/* event through the host session adapter (worktree-keyed). */
|
|
508
|
+
emitRecursiveEvent(session: unknown, type: string, data: Record<string, unknown>): void {
|
|
509
|
+
const s = session as { append?: (type: string, data: unknown) => void } | null | undefined
|
|
510
|
+
if (s && typeof s.append === 'function') {
|
|
511
|
+
s.append(type, data)
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/** Phase D R2/R9: fold a session log into the worktree-grouped projection. */
|
|
516
|
+
foldRecursiveProjection(events: readonly RecursiveEventLike[], workspaceRoot = ''): RecursiveFoldState {
|
|
517
|
+
let state = emptyRecursiveFoldState(workspaceRoot)
|
|
518
|
+
for (const event of events) state = foldProjectionEvent(state, event)
|
|
519
|
+
return state
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/** Phase D R2: the registered projection unit (key 'recursive'). */
|
|
523
|
+
get projectionUnit() {
|
|
524
|
+
return recursiveProjectionUnit
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/** Phase D R9: whether a worktreeRoot is inside the workspace control-plane root. */
|
|
528
|
+
isInsideWorkspace(worktreeRoot: string, workspaceRoot: string): boolean {
|
|
529
|
+
return isInsideWorkspace(worktreeRoot, workspaceRoot)
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/** Phase C R7: resolve the enforcement config (strict|advisory, default advisory). */
|
|
533
|
+
get enforcementConfig(): EnforcementConfig {
|
|
534
|
+
return this._enforcementConfig ?? DEFAULT_ENFORCEMENT
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
setEnforcementConfig(config: unknown): EnforcementConfig {
|
|
538
|
+
this._enforcementConfig = resolveEnforcementConfig(config)
|
|
539
|
+
return this._enforcementConfig
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/** set_or_insert_field: replace first field occurrence, else insert after the last listed after-field. */
|
|
544
|
+
function setOrInsertField(content: string, fieldName: string, value: string, afterFields: string[]): string {
|
|
545
|
+
const line = fieldName + ': `' + value + '`'
|
|
546
|
+
const fieldRe = new RegExp('^[ \\t]*(?:[-*][ \\t]+)?' + escapeRegExp(fieldName) + ':\\s*.*$', 'm')
|
|
547
|
+
if (fieldRe.test(content)) {
|
|
548
|
+
return content.replace(fieldRe, line)
|
|
549
|
+
}
|
|
550
|
+
const lines = content.replace(/\r\n/g, '\n').replace(/\r/g, '\n').split('\n')
|
|
551
|
+
let insertAt = 0
|
|
552
|
+
for (const afterField of afterFields) {
|
|
553
|
+
const afterRe = new RegExp('^[ \\t]*(?:[-*][ \\t]+)?' + escapeRegExp(afterField) + ':\\s*.*$', 'm')
|
|
554
|
+
for (let i = 0; i < lines.length; i++) {
|
|
555
|
+
if (afterRe.test(lines[i])) insertAt = Math.max(insertAt, i + 1)
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
lines.splice(insertAt, 0, line)
|
|
559
|
+
return lines.join('\n')
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
function escapeRegExp(s: string): string {
|
|
563
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
564
|
+
}
|
package/src/scratch.ts
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Run-scoped disposable scratchpad (R5). Lives at <run-dir>/scratch/scratch.md
|
|
3
|
+
* and <run-dir>/scratch/scratch.ts, is git-ignored, and is NEVER citable as an
|
|
4
|
+
* Input in phase docs (enforced by the recursive:policy + lint, Phase C).
|
|
5
|
+
* The plugin never creates a run implicitly: writing scratch requires an
|
|
6
|
+
* existing run directory.
|
|
7
|
+
*
|
|
8
|
+
* Phase B R5 adds child-scoped scratch (PROPOSAL 10.7): each delegated child
|
|
9
|
+
* gets its OWN disposable scratch under <run-dir>/scratch/<child-id>.md, while
|
|
10
|
+
* the parent's scratch.md stays the main agent's working memory. A child MAY
|
|
11
|
+
* read the parent scratch when the prompt includes it, but writes go only to
|
|
12
|
+
* the child's own file.
|
|
13
|
+
*/
|
|
14
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, appendFileSync } from 'node:fs'
|
|
15
|
+
import { join, resolve, sep } from 'node:path'
|
|
16
|
+
|
|
17
|
+
export type ScratchTarget = 'md' | 'ts'
|
|
18
|
+
|
|
19
|
+
export function scratchPathFor(runDir: string, target: ScratchTarget): string {
|
|
20
|
+
if (target !== 'md' && target !== 'ts') {
|
|
21
|
+
throw new Error('Unsupported scratch target: ' + String(target) + ' (expected md or ts)')
|
|
22
|
+
}
|
|
23
|
+
return join(runDir, 'scratch', 'scratch.' + target)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function assertRunExists(runDir: string): void {
|
|
27
|
+
if (!existsSync(runDir)) {
|
|
28
|
+
throw new Error('Run directory does not exist: ' + runDir + ' (scratch never creates runs)')
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function readScratch(runDir: string, target: ScratchTarget): string {
|
|
33
|
+
assertRunExists(runDir)
|
|
34
|
+
const path = scratchPathFor(runDir, target)
|
|
35
|
+
if (!existsSync(path)) return ''
|
|
36
|
+
return readFileSync(path, 'utf8')
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function writeScratch(runDir: string, target: ScratchTarget, content: string): string {
|
|
40
|
+
assertRunExists(runDir)
|
|
41
|
+
const path = scratchPathFor(runDir, target)
|
|
42
|
+
mkdirSync(join(runDir, 'scratch'), { recursive: true })
|
|
43
|
+
writeFileSync(path, content, 'utf8')
|
|
44
|
+
return path
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function appendScratch(runDir: string, target: ScratchTarget, content: string): string {
|
|
48
|
+
assertRunExists(runDir)
|
|
49
|
+
const path = scratchPathFor(runDir, target)
|
|
50
|
+
mkdirSync(join(runDir, 'scratch'), { recursive: true })
|
|
51
|
+
appendFileSync(path, content + '\n', 'utf8')
|
|
52
|
+
return path
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Child-scoped scratch path (Phase B R5): <run-dir>/scratch/<child-id>.md.
|
|
57
|
+
* Resolved under the run dir; a child id escaping the scratch dir is rejected.
|
|
58
|
+
*/
|
|
59
|
+
export function childScratchPath(runDir: string, childId: string): string {
|
|
60
|
+
assertRunExists(runDir)
|
|
61
|
+
const scratchDir = resolve(runDir, 'scratch')
|
|
62
|
+
const candidate = resolve(scratchDir, childId + '.md')
|
|
63
|
+
const prefix = scratchDir.endsWith(sep) ? scratchDir : scratchDir + sep
|
|
64
|
+
if (!candidate.startsWith(prefix)) {
|
|
65
|
+
throw new Error('Child scratch escapes the run scratch dir: ' + childId)
|
|
66
|
+
}
|
|
67
|
+
return candidate
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Read-only access to the PARENT's scratch (the main agent's working memory). */
|
|
71
|
+
export function readParentScratch(runDir: string, target: ScratchTarget = 'md'): string {
|
|
72
|
+
return readScratch(runDir, target)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Write ONLY the child's own scratch file. Refuses any target outside
|
|
77
|
+
* <run-dir>/scratch/<child-id>.md (a child cannot overwrite the parent's
|
|
78
|
+
* scratch.md through this writer).
|
|
79
|
+
*/
|
|
80
|
+
export function writeChildScratch(runDir: string, childId: string, content: string): string {
|
|
81
|
+
const path = childScratchPath(runDir, childId)
|
|
82
|
+
mkdirSync(join(runDir, 'scratch'), { recursive: true })
|
|
83
|
+
writeFileSync(path, content, 'utf8')
|
|
84
|
+
return path
|
|
85
|
+
}
|