@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/handoff.ts
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File-backed delegation handoff docs (Phase B R2, PROPOSAL 10.6): a
|
|
3
|
+
* main-agent handoff.md plus per-child brief.md / reply.md / scratch.md. The
|
|
4
|
+
* delegation prompt is reference-based (pointers, not full context), so the
|
|
5
|
+
* child reads the files it needs rather than receiving a monolithic paste.
|
|
6
|
+
*
|
|
7
|
+
* Workspace-scoped (run 03 R1): every path resolves under the given root.
|
|
8
|
+
*/
|
|
9
|
+
import { mkdirSync, writeFileSync } from 'node:fs'
|
|
10
|
+
import { join, resolve, sep } from 'node:path'
|
|
11
|
+
|
|
12
|
+
export interface HandoffInput {
|
|
13
|
+
root: string
|
|
14
|
+
runId: string
|
|
15
|
+
delegationId: string
|
|
16
|
+
role: string
|
|
17
|
+
objective: string
|
|
18
|
+
runDocRefs: string[]
|
|
19
|
+
codeRefs: string[]
|
|
20
|
+
auditQuestions: string[]
|
|
21
|
+
requiredOutput: string
|
|
22
|
+
decisionBasis: string
|
|
23
|
+
constraints?: string[]
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface ChildBriefInput {
|
|
27
|
+
root: string
|
|
28
|
+
runId: string
|
|
29
|
+
delegationId: string
|
|
30
|
+
childId: string
|
|
31
|
+
slice: string
|
|
32
|
+
replyContract?: string
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function norm(repoRelative: string): string {
|
|
36
|
+
return repoRelative.replace(/\\/g, '/').replace(/^\/+/, '')
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function resolveUnderRoot(root: string, repoRelative: string): string {
|
|
40
|
+
const normalized = norm(repoRelative)
|
|
41
|
+
const rootAbs = resolve(root)
|
|
42
|
+
const abs = resolve(rootAbs, normalized)
|
|
43
|
+
const rootPrefix = rootAbs.endsWith(sep) ? rootAbs : rootAbs + sep
|
|
44
|
+
if (abs !== rootAbs && !abs.startsWith(rootPrefix)) {
|
|
45
|
+
throw new Error('Path escapes the workspace root: ' + repoRelative)
|
|
46
|
+
}
|
|
47
|
+
return abs
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function subagentsDir(root: string, runId: string): string {
|
|
51
|
+
return resolveUnderRoot(root, '.recursive/run/' + runId + '/subagents')
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function bulletList(title: string, values: string[]): string[] {
|
|
55
|
+
const lines: string[] = [title]
|
|
56
|
+
if (!values.length) {
|
|
57
|
+
lines.push('- none')
|
|
58
|
+
return lines
|
|
59
|
+
}
|
|
60
|
+
for (const v of values) lines.push('- ' + String.fromCharCode(96) + norm(v) + String.fromCharCode(96))
|
|
61
|
+
return lines
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Main-agent handoff doc: the full delegation info for one delegation. */
|
|
65
|
+
export function createHandoff(input: HandoffInput): string {
|
|
66
|
+
const { root, runId, delegationId, role, objective } = input
|
|
67
|
+
const dir = join(subagentsDir(root, runId), delegationId)
|
|
68
|
+
mkdirSync(dir, { recursive: true })
|
|
69
|
+
const path = join(dir, 'handoff.md')
|
|
70
|
+
|
|
71
|
+
const lines: string[] = [
|
|
72
|
+
'# Delegation handoff: ' + delegationId,
|
|
73
|
+
'',
|
|
74
|
+
'Role: ' + role,
|
|
75
|
+
'Objective: ' + objective,
|
|
76
|
+
'',
|
|
77
|
+
'## Run Document References',
|
|
78
|
+
...bulletList('', input.runDocRefs).slice(1),
|
|
79
|
+
'',
|
|
80
|
+
'## Code References (with line ranges where applicable)',
|
|
81
|
+
...bulletList('', input.codeRefs).slice(1),
|
|
82
|
+
'',
|
|
83
|
+
'## Audit Questions',
|
|
84
|
+
...(input.auditQuestions.length ? input.auditQuestions.map((q, i) => String(i + 1) + '. ' + q) : ['- none']),
|
|
85
|
+
'',
|
|
86
|
+
'## Required Output Shape',
|
|
87
|
+
'',
|
|
88
|
+
input.requiredOutput,
|
|
89
|
+
'',
|
|
90
|
+
'## Constraints',
|
|
91
|
+
...(input.constraints?.length ? input.constraints.map((c) => '- ' + c) : ['- none']),
|
|
92
|
+
'',
|
|
93
|
+
'## Delegation Decision Basis',
|
|
94
|
+
'',
|
|
95
|
+
input.decisionBasis,
|
|
96
|
+
'',
|
|
97
|
+
]
|
|
98
|
+
|
|
99
|
+
writeFileSync(path, lines.join('\n'), 'utf8')
|
|
100
|
+
return path
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Per-child brief: the receiving slice for one child. */
|
|
104
|
+
export function createChildBrief(input: ChildBriefInput): string {
|
|
105
|
+
const { root, runId, delegationId, childId, slice } = input
|
|
106
|
+
const dir = join(subagentsDir(root, runId), delegationId, 'child-' + childId)
|
|
107
|
+
mkdirSync(dir, { recursive: true })
|
|
108
|
+
const path = join(dir, 'brief.md')
|
|
109
|
+
const rootNorm = resolve(root).replace(/\\/g, '/')
|
|
110
|
+
|
|
111
|
+
const lines: string[] = [
|
|
112
|
+
'# Child brief: ' + childId,
|
|
113
|
+
'',
|
|
114
|
+
'Delegation: ' + delegationId,
|
|
115
|
+
'Reply file: ' + replyPath(input).replace(/\\/g, '/').replace(rootNorm + '/', ''),
|
|
116
|
+
'',
|
|
117
|
+
'## Your Slice',
|
|
118
|
+
'',
|
|
119
|
+
slice,
|
|
120
|
+
'',
|
|
121
|
+
'## Reply Contract',
|
|
122
|
+
'',
|
|
123
|
+
input.replyContract ?? 'Write your submission to reply.md, then call the report tool citing reply.md.',
|
|
124
|
+
'',
|
|
125
|
+
]
|
|
126
|
+
|
|
127
|
+
writeFileSync(path, lines.join('\n'), 'utf8')
|
|
128
|
+
return path
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Reply path the child must write its submission to. */
|
|
132
|
+
export function replyPath(input: { root: string; runId: string; delegationId: string; childId: string }): string {
|
|
133
|
+
const { root, runId, delegationId, childId } = input
|
|
134
|
+
return join(subagentsDir(root, runId), delegationId, 'child-' + childId, 'reply.md')
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Child-scoped disposable scratch (Phase B R5, PROPOSAL 10.7). */
|
|
138
|
+
export function childScratchPath(input: { root: string; runId: string; childId: string }): string {
|
|
139
|
+
const { root, runId, childId } = input
|
|
140
|
+
return resolveUnderRoot(root, '.recursive/run/' + runId + '/scratch/' + childId + '.md')
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Build the reference-based delegation prompt (PROPOSAL 10.6): a pointer to
|
|
145
|
+
* handoff.md + brief.md, instructions to write reply.md and call report citing
|
|
146
|
+
* it. Short, not a monolithic paste.
|
|
147
|
+
*/
|
|
148
|
+
export function buildDelegationPrompt(input: { root: string; runId: string; delegationId: string; childId: string; handoffPath: string; briefPath: string }): string {
|
|
149
|
+
const { root, runId, delegationId, childId, handoffPath, briefPath } = input
|
|
150
|
+
const rootNorm = resolve(root).replace(/\\/g, '/')
|
|
151
|
+
const handoffRel = norm(handoffPath).replace(rootNorm + '/', '')
|
|
152
|
+
const briefRel = norm(briefPath).replace(rootNorm + '/', '')
|
|
153
|
+
const replyRel = norm(replyPath(input)).replace(rootNorm + '/', '')
|
|
154
|
+
return [
|
|
155
|
+
'You are a delegated worker for recursive-mode delegation ' + delegationId + ' (child ' + childId + ').',
|
|
156
|
+
'',
|
|
157
|
+
'1. Read the full delegation handoff: ' + handoffRel,
|
|
158
|
+
'2. Read your receiving slice: ' + briefRel,
|
|
159
|
+
'3. Do the work described in your slice, reading the referenced run docs and code files as needed.',
|
|
160
|
+
'4. Write your complete submission to: ' + replyRel,
|
|
161
|
+
'5. Call the report tool citing ' + replyRel + ' and include your verdict, findings, and every reference you used.',
|
|
162
|
+
'',
|
|
163
|
+
'Do not modify files outside your child scratch and reply.md unless the handoff explicitly authorizes it.',
|
|
164
|
+
].join('\n')
|
|
165
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
import { type Context } from '@deepseek-ai/cordis'
|
|
2
|
+
import type { Session } from '@deepseek-ai/dsh-session'
|
|
3
|
+
import { RecursiveRuntime } from './runtime.ts'
|
|
4
|
+
import { createRecursiveStatusTool } from './recursive_status.tool.ts'
|
|
5
|
+
import { createRecursiveInitTool } from './recursive_init.tool.ts'
|
|
6
|
+
import { createRecursiveLockTool } from './recursive_lock.tool.ts'
|
|
7
|
+
import { createRecursiveLintTool } from './recursive_lint.tool.ts'
|
|
8
|
+
import { createRecursiveCloseoutTool } from './recursive_closeout.tool.ts'
|
|
9
|
+
import { createRecursiveScratchTool } from './recursive_scratch.tool.ts'
|
|
10
|
+
import { registerRecursiveCommand } from './commands.ts'
|
|
11
|
+
import { stageBWorkflowInit } from './bootstrap.ts'
|
|
12
|
+
import { evaluatePreStepGate, evaluateToolGuard, detectTamper, resolveEnforcementConfig, DEFAULT_ENFORCEMENT, type EnforcementConfig } from './enforcement.ts'
|
|
13
|
+
import { detectTransitionIntent } from './lifecycle.ts'
|
|
14
|
+
import { renderRecursivePolicy } from './policy.ts'
|
|
15
|
+
import {
|
|
16
|
+
gateBlocked as gateBlockedEvent,
|
|
17
|
+
tamper as tamperEvent,
|
|
18
|
+
phaseIntent as phaseIntentEvent,
|
|
19
|
+
runCreated as runCreatedEvent,
|
|
20
|
+
phase as phaseEvent,
|
|
21
|
+
phaseLocked as phaseLockedEvent,
|
|
22
|
+
runState as runStateEvent,
|
|
23
|
+
runMerged as runMergedEvent,
|
|
24
|
+
subagentStart as subagentStartEvent,
|
|
25
|
+
subagentEnd as subagentEndEvent,
|
|
26
|
+
} from './events.ts'
|
|
27
|
+
import { recursiveProjectionUnit } from './projection.ts'
|
|
28
|
+
|
|
29
|
+
export const name = '@try-works/dsh-recursive-mode'
|
|
30
|
+
|
|
31
|
+
export { RecursiveRuntime } from './runtime.ts'
|
|
32
|
+
export { createRecursiveStatusTool } from './recursive_status.tool.ts'
|
|
33
|
+
export { createRecursiveInitTool } from './recursive_init.tool.ts'
|
|
34
|
+
export { createRecursiveLockTool } from './recursive_lock.tool.ts'
|
|
35
|
+
export { createRecursiveLintTool } from './recursive_lint.tool.ts'
|
|
36
|
+
export { createRecursiveCloseoutTool } from './recursive_closeout.tool.ts'
|
|
37
|
+
export { createRecursiveScratchTool } from './recursive_scratch.tool.ts'
|
|
38
|
+
export * from './status.ts'
|
|
39
|
+
export {
|
|
40
|
+
PHASE_SEQUENCE,
|
|
41
|
+
OPTIONAL_PHASES,
|
|
42
|
+
normalizeForLockHash,
|
|
43
|
+
lockHashFromContent,
|
|
44
|
+
phaseIndex,
|
|
45
|
+
isCoreArtifact,
|
|
46
|
+
getPrerequisites,
|
|
47
|
+
getLockStatus,
|
|
48
|
+
getPrerequisiteBlockers,
|
|
49
|
+
receiptPath,
|
|
50
|
+
readReceipt,
|
|
51
|
+
writeReceipt,
|
|
52
|
+
invalidateReceipt,
|
|
53
|
+
getStaleDownstreamPhases,
|
|
54
|
+
getNextLegalPhase,
|
|
55
|
+
getAllStaleReceipts,
|
|
56
|
+
validateChain,
|
|
57
|
+
} from './lock.ts'
|
|
58
|
+
export type {
|
|
59
|
+
LockReceipt,
|
|
60
|
+
LockStatus,
|
|
61
|
+
PrerequisiteBlocker,
|
|
62
|
+
StaleDownstream,
|
|
63
|
+
ChainPhaseResult,
|
|
64
|
+
LockChainResult,
|
|
65
|
+
} from './lock.ts'
|
|
66
|
+
export * from './run.ts'
|
|
67
|
+
export * from './review.ts'
|
|
68
|
+
export * from './handoff.ts'
|
|
69
|
+
export * from './router.ts'
|
|
70
|
+
export * from './delegation.ts'
|
|
71
|
+
export * from './lifecycle.ts'
|
|
72
|
+
export * from './enforcement.ts'
|
|
73
|
+
export * from './policy.ts'
|
|
74
|
+
export * from './events.ts'
|
|
75
|
+
export * from './projection.ts'
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Bundle plugin entry. The Loader activates this row once `tools` is available
|
|
79
|
+
* (`inject` below); the RecursiveRuntime service is constructed directly so it
|
|
80
|
+
* is provided on `ctx.recursive` for the lifetime of this fiber, and the
|
|
81
|
+
* read-path tools (status/init/lock/lint) are registered through it (R2/R4).
|
|
82
|
+
*/
|
|
83
|
+
export const inject = ['tools']
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Two-stage init (PROPOSAL §5.10):
|
|
87
|
+
* - Stage A (mount-time, this apply): register the isolated ctx.recursive
|
|
88
|
+
* service + the recursive_* tools + the /recursive command + the
|
|
89
|
+
* recursive:policy prompt section. No repo/run work here.
|
|
90
|
+
* - Stage B (agent/session-start): resolve the session's workspace root (R1),
|
|
91
|
+
* bootstrap the scaffold if missing, enumerate runs as dir names only, and
|
|
92
|
+
* inject a workspace-scoped notice. See stageBWorkflowInit + the
|
|
93
|
+
* agent/session-start listener below.
|
|
94
|
+
*/
|
|
95
|
+
export function apply(ctx: Context) {
|
|
96
|
+
ctx.effect(function* () {
|
|
97
|
+
// Workspace registry: optional host service (durable). Access via ctx.get —
|
|
98
|
+
// property access requires inject and would fail boot when undeclared.
|
|
99
|
+
// Resolve the control-plane root strictly from the session agent's cwd.
|
|
100
|
+
const workspaceRegistry = ctx.get('workspaceRegistry') as never
|
|
101
|
+
const recursive = new RecursiveRuntime(ctx, { repoRoot: process.cwd(), workspaceRegistry })
|
|
102
|
+
|
|
103
|
+
/** Typed agent payload shape (session is a full Session). */
|
|
104
|
+
type AgentLike = { session?: Session | null }
|
|
105
|
+
|
|
106
|
+
/** Extract the session from a listener payload. */
|
|
107
|
+
const sessionOf = (p: unknown): Session | null => {
|
|
108
|
+
const a = (p as { agent?: AgentLike | null } | null)?.agent
|
|
109
|
+
return a?.session ?? null
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Resolve the session's control-plane root (registry first, cwd fallback). */
|
|
113
|
+
const rootOf = async (p: unknown): Promise<string> => {
|
|
114
|
+
const a = (p as { agent?: AgentLike | null } | null)?.agent
|
|
115
|
+
const root = await recursive.resolveRootFor(a as { session?: { header?: { cwd?: string } } } | null)
|
|
116
|
+
return root ?? a?.session?.header.cwd ?? process.cwd()
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Emit one recursive/* event into the session log (log-only). */
|
|
120
|
+
const emit = (session: Session | null, type: string, data: unknown) => {
|
|
121
|
+
if (!session) return
|
|
122
|
+
try { (session.append as unknown as (t: string, d: unknown) => void)(type, data) } catch { /* log-only; never fail the gate on an emit error */ }
|
|
123
|
+
}
|
|
124
|
+
const disposers = [
|
|
125
|
+
ctx.tools.register(createRecursiveStatusTool(recursive)),
|
|
126
|
+
ctx.tools.register(createRecursiveInitTool(recursive)),
|
|
127
|
+
ctx.tools.register(createRecursiveLockTool(recursive)),
|
|
128
|
+
ctx.tools.register(createRecursiveLintTool(recursive)),
|
|
129
|
+
ctx.tools.register(createRecursiveCloseoutTool(recursive)),
|
|
130
|
+
ctx.tools.register(createRecursiveScratchTool(recursive)),
|
|
131
|
+
]
|
|
132
|
+
|
|
133
|
+
// /recursive command (R4): preset-scoped registration, workspace-scoped dispatch.
|
|
134
|
+
const commands = ctx.get('commands') as { register: (def: unknown) => () => void } | undefined
|
|
135
|
+
if (commands) {
|
|
136
|
+
disposers.push(registerRecursiveCommand({ commands } as never, recursive))
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// recursive:policy prompt section (Phase C R5): workspace-scoped behavior +
|
|
140
|
+
// current-phase contract rendered from folded state + enforcement config.
|
|
141
|
+
const systemPrompt = ctx.get('systemPrompt') as { section: (def: unknown) => () => void } | undefined
|
|
142
|
+
if (systemPrompt) {
|
|
143
|
+
disposers.push(systemPrompt.section({
|
|
144
|
+
name: 'recursive:policy',
|
|
145
|
+
order: 55,
|
|
146
|
+
text: (context: unknown) => {
|
|
147
|
+
const agent = (context as { agent?: { session?: { header?: { cwd?: string }; events?: { type: string; data?: Record<string, unknown> }[] } } } | undefined)?.agent
|
|
148
|
+
if (!agent) return ''
|
|
149
|
+
const cwd = agent.session?.header?.cwd ?? ''
|
|
150
|
+
if (!cwd) return ''
|
|
151
|
+
const events = agent.session?.events ?? []
|
|
152
|
+
const intent = detectTransitionIntent(events as never)
|
|
153
|
+
if (!intent || intent.worktreeRoot === '') return ''
|
|
154
|
+
return renderRecursivePolicy({ worktreeRoot: intent.worktreeRoot, runId: intent.runId, folded: recursive.foldPhase(events as never), config: recursive.enforcementConfig })
|
|
155
|
+
},
|
|
156
|
+
}))
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Phase C R3: agent/pre-step phase-transition gate (Layer 1, caller of the
|
|
160
|
+
// lifecycle transition set). Reads on transition intent only; rejects (strict)
|
|
161
|
+
// or warns (advisory) when the target gates fail.
|
|
162
|
+
const agentEvents = ctx as unknown as { on?: (event: string, listener: (payload: unknown, next?: unknown) => unknown) => () => void }
|
|
163
|
+
if (agentEvents.on) {
|
|
164
|
+
disposers.push(agentEvents.on('agent/pre-step', (payload, next) => {
|
|
165
|
+
const p = payload as { agent?: { session?: { events?: { type: string; data?: Record<string, unknown> }[] } } } | null
|
|
166
|
+
const events = p?.agent?.session?.events ?? []
|
|
167
|
+
const session = sessionOf(p)
|
|
168
|
+
const intent = recursive.detectTransitionIntent(events as never)
|
|
169
|
+
const decision = evaluatePreStepGate(events as never, recursive.enforcementConfig.preStep)
|
|
170
|
+
void (async () => {
|
|
171
|
+
// Emit a committed run-state (blocked) when a gate rejects the intent —
|
|
172
|
+
// the board then shows the run blocked, not merely a log message.
|
|
173
|
+
if (decision.gateBlocked && intent?.runId) {
|
|
174
|
+
const worktreeRoot = intent?.worktreeRoot ?? (await rootOf(p))
|
|
175
|
+
emit(session, 'recursive/gate-blocked', gateBlockedEvent({ runId: intent.runId, worktreeRoot, phase: intent?.targetArtifact ?? '', failures: decision.failures, kind: 'pre-step' }))
|
|
176
|
+
emit(session, 'recursive/run-state', runStateEvent({ runId: intent.runId, worktreeRoot, state: 'blocked', reason: 'gate-blocked: ' + decision.failures.join('; ') }))
|
|
177
|
+
}
|
|
178
|
+
})()
|
|
179
|
+
if (decision.kind === 'reject') return { kind: 'reject' }
|
|
180
|
+
return typeof next === 'function' ? next() : { kind: 'enter', messages: [] }
|
|
181
|
+
}))
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Phase C R4: tools/pre-execute surgical guards (Layer 2, caller of the
|
|
185
|
+
// transition set). Scope-filtered to the active run's worktree.
|
|
186
|
+
const toolRuntime = ctx as unknown as { on?: (event: string, listener: (payload: unknown, next?: unknown) => unknown) => () => void }
|
|
187
|
+
if (toolRuntime.on) {
|
|
188
|
+
disposers.push(toolRuntime.on('tools/pre-execute', (payload, next) => {
|
|
189
|
+
const exec = payload as { name?: string; arguments?: unknown; agent?: { session?: { header?: { cwd?: string } } } | null } | null
|
|
190
|
+
if (!exec?.name) return typeof next === 'function' ? next() : { kind: 'allow' }
|
|
191
|
+
// B3: per-call root is the session cwd (authoritative when the registry is
|
|
192
|
+
// absent), never process.cwd().
|
|
193
|
+
const root = exec?.agent?.session?.header?.cwd ?? ''
|
|
194
|
+
const decision = evaluateToolGuard(exec as never, root, '', recursive.enforcementConfig.toolGuards)
|
|
195
|
+
if (decision.kind === 'allow') return typeof next === 'function' ? next() : { kind: 'allow' }
|
|
196
|
+
if (decision.kind === 'deny') return decision
|
|
197
|
+
// ask -> in advisory we let it through with a warn (the approval seam is Phase D)
|
|
198
|
+
return typeof next === 'function' ? next() : { kind: 'allow' }
|
|
199
|
+
}))
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Phase C R8 + Phase D R1: fs/observed lock-tamper warnings -> recursive/tamper event.
|
|
203
|
+
const fsRuntime = ctx as unknown as { on?: (event: string, listener: (payload: unknown) => void) => () => void }
|
|
204
|
+
if (fsRuntime.on) {
|
|
205
|
+
disposers.push(fsRuntime.on('fs/observed', (payload) => {
|
|
206
|
+
const p = payload as { target?: { displayPath?: string }; agent?: AgentLike | null } | null
|
|
207
|
+
const path = p?.target?.displayPath ?? ''
|
|
208
|
+
if (!path) return
|
|
209
|
+
// B3: tamper detection is scoped to the SESSION's control-plane root,
|
|
210
|
+
// not the host checkout.
|
|
211
|
+
const sessionCwd = p?.agent?.session?.header.cwd ?? ''
|
|
212
|
+
const tamper = detectTamper(path, sessionCwd, '')
|
|
213
|
+
if (!tamper) return
|
|
214
|
+
// Resolve the worktree root from the tampered path (strip /.recursive/run/<id>/).
|
|
215
|
+
const normalized = path.replace(/\\/g, '/')
|
|
216
|
+
const marker = '/.recursive/run/'
|
|
217
|
+
const idx = normalized.indexOf(marker)
|
|
218
|
+
const worktreeRoot = idx >= 0 ? normalized.slice(0, idx) : sessionCwd
|
|
219
|
+
// Prefer the observing session's own log (additive); fall back to the
|
|
220
|
+
// session store list only when the payload carries no session.
|
|
221
|
+
const session = sessionOf(p) ?? null
|
|
222
|
+
if (session) {
|
|
223
|
+
emit(session, 'recursive/tamper', tamperEvent({ runId: tamper.runId, worktreeRoot, path: tamper.path, reason: tamper.reason }))
|
|
224
|
+
return
|
|
225
|
+
}
|
|
226
|
+
const sessions = ctx.get('sessions') as { list?: () => Session[] } | undefined
|
|
227
|
+
const match = sessions?.list?.().find((s) => {
|
|
228
|
+
const cwd = (s.header?.cwd ?? '').replace(/\\/g, '/')
|
|
229
|
+
return cwd === '' || cwd === worktreeRoot || cwd.startsWith(worktreeRoot + '/')
|
|
230
|
+
})
|
|
231
|
+
if (match) emit(match, 'recursive/tamper', tamperEvent({ runId: tamper.runId, worktreeRoot, path: tamper.path, reason: tamper.reason }))
|
|
232
|
+
}))
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Phase D R2: register the recursive projection unit (activates only when a
|
|
236
|
+
// projection registry is composed — headless assemblies stay unaffected).
|
|
237
|
+
// The unit's cell is per-session: a session's own log is its workspace, so
|
|
238
|
+
// an accept-all init is scoped by the session boundary (see projection.ts
|
|
239
|
+
// emptyRecursiveFoldState). The registry (if present) is cached here so the
|
|
240
|
+
// session-start boundary can re-read run state after the projection drove.
|
|
241
|
+
let sessionProjections: { snapshot?: (session: Session) => { values: Partial<Record<string, unknown>> } } | null = null
|
|
242
|
+
ctx.inject(['sessionProjections'], (projectionCtx) => {
|
|
243
|
+
const registry = (projectionCtx as { sessionProjections?: { register: (def: unknown) => () => void; snapshot?: (session: Session) => unknown } }).sessionProjections
|
|
244
|
+
if (registry?.register) {
|
|
245
|
+
disposers.push(registry.register(recursiveProjectionUnit))
|
|
246
|
+
sessionProjections = registry as { snapshot?: (session: Session) => { values: Partial<Record<string, unknown>> } }
|
|
247
|
+
}
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
// Stage B (R6): agent/session-start workflow init, workspace-scoped.
|
|
251
|
+
// NEW sessions emit recursive/run-state('active') after init; resumed
|
|
252
|
+
// sessions replay the projection (already folded from the persisted log) —
|
|
253
|
+
// no duplicate run-created. run-created is emitted by the lifecycle at the
|
|
254
|
+
// init/lock boundary (the only place a run's files first exist).
|
|
255
|
+
const events = ctx as unknown as { on?: (event: string, listener: (payload: unknown) => void) => () => void }
|
|
256
|
+
if (events.on) {
|
|
257
|
+
disposers.push(events.on('agent/session-start', (payload) => {
|
|
258
|
+
const p = payload as { agent?: AgentLike | null; source?: 'new' | 'resume' }
|
|
259
|
+
const session = sessionOf(p)
|
|
260
|
+
const cwd = p?.agent?.session?.header?.cwd
|
|
261
|
+
if (!cwd) return
|
|
262
|
+
void (async () => {
|
|
263
|
+
const root = await recursive.resolveRootFor(p?.agent as { session?: { header?: { cwd?: string } } } | null)
|
|
264
|
+
if (!root) return
|
|
265
|
+
const result = stageBWorkflowInit({ root, source: p.source === 'resume' ? 'resume' : 'new' })
|
|
266
|
+
// Emit run-state for the active run (from the projected wire value, the
|
|
267
|
+
// one same-source view) when the projection registry is present.
|
|
268
|
+
if (session) {
|
|
269
|
+
const runId = result.activeRunId ?? result.runs[result.runs.length - 1] ?? ''
|
|
270
|
+
if (runId) {
|
|
271
|
+
const snap = sessionProjections?.snapshot?.(session)
|
|
272
|
+
const proj = (snap?.values as { recursive?: Record<string, Record<string, { state?: string }>> } | undefined)?.recursive
|
|
273
|
+
const state = proj?.[root]?.[runId]?.state ?? 'active'
|
|
274
|
+
emit(session, 'recursive/run-state', runStateEvent({ runId, worktreeRoot: root, state: state as 'new' | 'active' | 'paused' | 'blocked' | 'complete' }))
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
})()
|
|
278
|
+
}))
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
yield () => { for (const d of disposers) d() }
|
|
282
|
+
})
|
|
283
|
+
}
|
package/src/lifecycle.ts
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Run state machine + serialized transition driver + recursive/* events
|
|
3
|
+
* (Phase C R1/R2/R6, PROPOSAL 8.8).
|
|
4
|
+
*
|
|
5
|
+
* Authority is TRANSITIONS AND EVENTS ONLY - this module never stores run
|
|
6
|
+
* state in a second place. It reconciles the file tree via the existing
|
|
7
|
+
* read path (run.ts/status.ts/lock.ts), validates the target phase's gates
|
|
8
|
+
* (PROPOSAL 8.4), delegates the artifact write to lock.ts (canonical
|
|
9
|
+
* lock-hash + monotonic chain preserved), and emits the recursive/* events.
|
|
10
|
+
* State is DERIVED on every transition; resume/fork/session-restart
|
|
11
|
+
* reconstruct identical state by re-reading files + folding the session log.
|
|
12
|
+
*/
|
|
13
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
14
|
+
import { join } from 'node:path'
|
|
15
|
+
import { getPrerequisiteBlockers, getLockStatus } from './lock.ts'
|
|
16
|
+
import { getMdFieldValue, getGateStatus } from './status.ts'
|
|
17
|
+
|
|
18
|
+
/** Run-level durable states (PROPOSAL 8.8). */
|
|
19
|
+
export const RUN_STATES = ['new', 'active', 'paused', 'blocked', 'complete'] as const
|
|
20
|
+
export type RunState = (typeof RUN_STATES)[number]
|
|
21
|
+
|
|
22
|
+
/** A proposed phase transition the gates validate before any file write. */
|
|
23
|
+
export interface PhaseTransitionIntent {
|
|
24
|
+
runId: string
|
|
25
|
+
worktreeRoot: string
|
|
26
|
+
targetArtifact: string
|
|
27
|
+
kind: 'lock' | 'reopen' | 'advance'
|
|
28
|
+
evidence?: {
|
|
29
|
+
tddMode?: string
|
|
30
|
+
redEvidencePath?: string
|
|
31
|
+
greenEvidencePath?: string
|
|
32
|
+
qaMode?: string
|
|
33
|
+
qaSignOff?: boolean
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Folded phase state (last-wins over recursive/phase + recursive/run-state). */
|
|
38
|
+
export interface RecursivePhaseState {
|
|
39
|
+
runId: string
|
|
40
|
+
phase: string
|
|
41
|
+
status: string
|
|
42
|
+
runState: RunState
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** A session-event-like carrier (the pure fold reads events by shape). */
|
|
46
|
+
export interface SessionEventLike {
|
|
47
|
+
type: string
|
|
48
|
+
data?: Record<string, unknown>
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Gate check result - the transition set's single output. */
|
|
52
|
+
export interface GateCheckResult {
|
|
53
|
+
passed: boolean
|
|
54
|
+
failures: string[]
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Payload shapes for the recursive/* events. These are LEGACY structural
|
|
59
|
+
* views kept for internal consumers; the single source of truth for the
|
|
60
|
+
* emitted payloads is events.ts (every event carries { runId, worktreeRoot }).
|
|
61
|
+
* Aligned here so no local interface drifts out of the worktree-keyed
|
|
62
|
+
* invariant (B7).
|
|
63
|
+
*/
|
|
64
|
+
export interface RecursivePhaseEvent { runId: string; worktreeRoot: string; phase: string; status: string }
|
|
65
|
+
export interface RecursiveRunStateEvent { runId: string; worktreeRoot: string; state: RunState; reason?: string }
|
|
66
|
+
export interface RecursiveGateBlockedEvent { runId: string; worktreeRoot: string; phase: string; failures: string[]; kind: string }
|
|
67
|
+
export interface RecursiveTamperEvent { runId: string; worktreeRoot: string; path: string; reason: string }
|
|
68
|
+
export interface RecursiveTransitionFailedEvent { runId: string; worktreeRoot: string; phase: string; error: string }
|
|
69
|
+
|
|
70
|
+
/** The audited phase files whose lock requires Audit: PASS (parity with status.ts). */
|
|
71
|
+
const AUDITED_PHASE_FILES = new Set([
|
|
72
|
+
'01-as-is.md', '01.5-root-cause.md', '02-to-be-plan.md', '03-implementation-summary.md',
|
|
73
|
+
'03.5-code-review.md', '04-test-summary.md', '06-decisions-update.md', '07-state-update.md', '08-memory-impact.md',
|
|
74
|
+
])
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Pure last-wins fold over the recursive/* events (the foldPlanMode pattern).
|
|
78
|
+
* A log with no recursive/phase folds to null; the last recursive/run-state
|
|
79
|
+
* wins for the run-level flag.
|
|
80
|
+
*/
|
|
81
|
+
export function foldRecursivePhase(events: readonly SessionEventLike[], end = events.length): RecursivePhaseState | null {
|
|
82
|
+
let runId = ''
|
|
83
|
+
let phaseName = ''
|
|
84
|
+
let status = ''
|
|
85
|
+
let runState: RunState = 'active'
|
|
86
|
+
let seen = false
|
|
87
|
+
let index = 0
|
|
88
|
+
for (const event of events) {
|
|
89
|
+
if (index >= end) break
|
|
90
|
+
index++
|
|
91
|
+
if (event.type === 'recursive/phase') {
|
|
92
|
+
const d = event.data ?? {}
|
|
93
|
+
runId = String(d.runId ?? runId)
|
|
94
|
+
phaseName = String(d.phase ?? phaseName)
|
|
95
|
+
status = String(d.status ?? status)
|
|
96
|
+
seen = true
|
|
97
|
+
} else if (event.type === 'recursive/run-state') {
|
|
98
|
+
const d = event.data ?? {}
|
|
99
|
+
runId = String(d.runId ?? runId)
|
|
100
|
+
runState = asRunState(d.state)
|
|
101
|
+
seen = true
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return seen ? { runId, phase: phaseName, status, runState } : null
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function asRunState(value: unknown): RunState {
|
|
108
|
+
return RUN_STATES.includes(value as RunState) ? (value as RunState) : 'active'
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Whether the session log holds an opened turn without its closing turn/end. */
|
|
112
|
+
export function hasOpenTurn(events: readonly SessionEventLike[]): boolean {
|
|
113
|
+
let open = false
|
|
114
|
+
for (const event of events) {
|
|
115
|
+
if (event.type === 'turn/start') open = true
|
|
116
|
+
else if (event.type === 'turn/end') open = false
|
|
117
|
+
}
|
|
118
|
+
return open
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Detect a pending transition intent from the session log (the lock tool logs it). */
|
|
122
|
+
export function detectTransitionIntent(events: readonly SessionEventLike[]): PhaseTransitionIntent | null {
|
|
123
|
+
let intent: PhaseTransitionIntent | null = null
|
|
124
|
+
for (const event of events) {
|
|
125
|
+
if (event.type === 'recursive/phase-intent') {
|
|
126
|
+
const d = event.data ?? {}
|
|
127
|
+
intent = {
|
|
128
|
+
runId: String(d.runId ?? ''),
|
|
129
|
+
worktreeRoot: String(d.worktreeRoot ?? ''),
|
|
130
|
+
targetArtifact: String(d.targetArtifact ?? ''),
|
|
131
|
+
kind: (d.kind === 'reopen' || d.kind === 'advance' ? d.kind : 'lock'),
|
|
132
|
+
evidence: d.evidence as PhaseTransitionIntent['evidence'],
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return intent
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Validate a proposed transition against the target phase's gates (PROPOSAL 8.4).
|
|
141
|
+
* Pure: reads the current file tree + lock.ts chain; writes nothing.
|
|
142
|
+
*/
|
|
143
|
+
export function validateTransition(intent: PhaseTransitionIntent): GateCheckResult {
|
|
144
|
+
const failures: string[] = []
|
|
145
|
+
const runDir = join(intent.worktreeRoot, '.recursive', 'run', intent.runId)
|
|
146
|
+
const artifactPath = join(runDir, intent.targetArtifact)
|
|
147
|
+
|
|
148
|
+
if (intent.kind === 'reopen') {
|
|
149
|
+
const status = getLockStatus(artifactPath)
|
|
150
|
+
if (status !== 'LOCKED') failures.push('reopen requires a LOCKED artifact (current: ' + status + ')')
|
|
151
|
+
return { passed: failures.length === 0, failures }
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// 1. Phase doc exists.
|
|
155
|
+
if (!existsSync(artifactPath)) {
|
|
156
|
+
failures.push('phase doc does not exist: ' + intent.targetArtifact)
|
|
157
|
+
return { passed: false, failures }
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const content = readFileSync(artifactPath, 'utf8')
|
|
161
|
+
|
|
162
|
+
// 2. Monotonic gating (parity with recursive-lock.py).
|
|
163
|
+
const blockers = getPrerequisiteBlockers(runDir, intent.targetArtifact)
|
|
164
|
+
for (const b of blockers) failures.push('unlocked prerequisite: ' + b.artifact + ' (' + b.status + ')')
|
|
165
|
+
|
|
166
|
+
// 3. TDD evidence (Phase 3).
|
|
167
|
+
if (intent.targetArtifact === '03-implementation-summary.md') {
|
|
168
|
+
const tddMode = getMdFieldValue(content, 'TDD Mode') ?? intent.evidence?.tddMode ?? ''
|
|
169
|
+
if (tddMode === 'strict') {
|
|
170
|
+
const red = intent.evidence?.redEvidencePath ?? ''
|
|
171
|
+
const green = intent.evidence?.greenEvidencePath ?? ''
|
|
172
|
+
if (!red || !existsSync(join(runDir, red))) failures.push('TDD Mode: strict requires a RED evidence path')
|
|
173
|
+
if (!green || !existsSync(join(runDir, green))) failures.push('TDD Mode: strict requires a GREEN evidence path')
|
|
174
|
+
} else if (tddMode === 'pragmatic') {
|
|
175
|
+
if (!/rationale/i.test(content)) failures.push('TDD Mode: pragmatic requires an exception rationale')
|
|
176
|
+
} else {
|
|
177
|
+
failures.push('TDD Mode must be declared strict|pragmatic')
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// 4. Audit closed (audited phases).
|
|
182
|
+
if (AUDITED_PHASE_FILES.has(intent.targetArtifact)) {
|
|
183
|
+
if (getGateStatus(content, 'Audit') !== 'PASS') failures.push('audited phase must end with Audit: PASS')
|
|
184
|
+
if (!/Requirement Completion Status/.test(content)) failures.push('audited phase requires a Requirement Completion Status section')
|
|
185
|
+
if (!/Delegation Decision Basis|Delegation Override Reason|Subagent Capability Probe/.test(content)) {
|
|
186
|
+
failures.push('audited phase requires Delegation Decision Basis / Subagent Capability Probe')
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// 5. QA sign-off (Phase 5).
|
|
191
|
+
if (intent.targetArtifact === '05-manual-qa.md') {
|
|
192
|
+
const qaMode = getMdFieldValue(content, 'QA Execution Mode') ?? intent.evidence?.qaMode ?? ''
|
|
193
|
+
if (!qaMode) failures.push('QA Execution Mode must be declared human|agent-operated|hybrid')
|
|
194
|
+
else if ((qaMode === 'human' || qaMode === 'hybrid') && intent.evidence?.qaSignOff !== true) {
|
|
195
|
+
failures.push('QA Execution Mode ' + qaMode + ' requires user sign-off')
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// 6. Effective inputs re-read.
|
|
200
|
+
if (!/Effective Inputs Re-read/.test(content)) failures.push('phase doc must re-read effective inputs (## Effective Inputs Re-read)')
|
|
201
|
+
|
|
202
|
+
return { passed: failures.length === 0, failures }
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* A serialized per-run transition driver (coalesced - the single-reservation
|
|
207
|
+
* pattern). Two concurrent 'lock Phase 3' intents queue; the second observes
|
|
208
|
+
* the first's committed state instead of racing the write.
|
|
209
|
+
*/
|
|
210
|
+
export class LifecycleDriver {
|
|
211
|
+
private readonly drivers = new Map<string, Promise<void>>()
|
|
212
|
+
|
|
213
|
+
/** Run one transition serially per runId. */
|
|
214
|
+
serialize(runId: string, run: () => Promise<void>): Promise<void> {
|
|
215
|
+
const previous = this.drivers.get(runId) ?? Promise.resolve()
|
|
216
|
+
const next = previous.then(run, run)
|
|
217
|
+
this.drivers.set(runId, next.then(() => undefined, () => undefined))
|
|
218
|
+
return next
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Couple a gate-block to the goal service (PROPOSAL 8.4 goal integration).
|
|
224
|
+
* Graceful no-op when the goal service or agent is unavailable.
|
|
225
|
+
*/
|
|
226
|
+
export function coupleGateBlockToGoal(
|
|
227
|
+
goalService: { block?: (agent: unknown, ref: unknown, reason: unknown) => unknown } | null | undefined,
|
|
228
|
+
agent: unknown,
|
|
229
|
+
ref: unknown,
|
|
230
|
+
reason: { code: string; message: string },
|
|
231
|
+
): boolean {
|
|
232
|
+
if (!goalService || typeof goalService.block !== 'function' || !agent) return false
|
|
233
|
+
goalService.block(agent, ref, reason)
|
|
234
|
+
return true
|
|
235
|
+
}
|