@try-works/dsh-recursive-mode 0.3.0 → 0.4.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/README.md +959 -0
- package/lib/client.js +9 -2
- package/lib/closeout-report.d.ts +113 -0
- package/lib/closeout-standards.d.ts +35 -0
- package/lib/closeout.d.ts +12 -0
- package/lib/commands.d.ts +1 -1
- package/lib/config.d.ts +202 -0
- package/lib/delegation.d.ts +123 -3
- package/lib/enforcement.d.ts +90 -1
- package/lib/errors.d.ts +168 -0
- package/lib/git-context.d.ts +17 -0
- package/lib/guard-log.d.ts +39 -0
- package/lib/handoff.d.ts +29 -0
- package/lib/hooks.d.ts +103 -0
- package/lib/identity.d.ts +61 -0
- package/lib/index.d.ts +33 -12
- package/lib/index.js +10017 -3969
- package/lib/job-log.d.ts +34 -0
- package/lib/jobs-runner.d.ts +105 -0
- package/lib/json-safe.d.ts +33 -0
- package/lib/lock.d.ts +42 -0
- package/lib/memory-feedback.d.ts +52 -0
- package/lib/memory-select.d.ts +78 -0
- package/lib/memory.d.ts +137 -0
- package/lib/model-inventory.d.ts +106 -0
- package/lib/phase-graph.d.ts +111 -0
- package/lib/phase-rules.d.ts +67 -8
- package/lib/plan-gate.d.ts +68 -0
- package/lib/policy-globs.d.ts +222 -0
- package/lib/policy-write.d.ts +42 -0
- package/lib/policy.d.ts +39 -0
- package/lib/recursive_ask.tool.d.ts +88 -0
- package/lib/recursive_closeout.tool.d.ts +1 -1
- package/lib/recursive_delegate.tool.d.ts +22 -0
- package/lib/recursive_preview.tool.d.ts +48 -0
- package/lib/recursive_review.tool.d.ts +28 -0
- package/lib/result-cap.d.ts +70 -0
- package/lib/review-round.d.ts +82 -0
- package/lib/review.d.ts +9 -0
- package/lib/role-route.d.ts +122 -0
- package/lib/router.d.ts +90 -5
- package/lib/runtime.d.ts +252 -12
- package/lib/settlement.d.ts +132 -0
- package/lib/skills-phase.d.ts +71 -0
- package/lib/skills.d.ts +70 -0
- package/lib/status.d.ts +53 -1
- package/lib/teams-loop.d.ts +91 -2
- package/lib/training.d.ts +211 -0
- package/lib/ts-lint.d.ts +15 -0
- package/lib/types.d.ts +48 -0
- package/lib/workflow-audit.d.ts +207 -0
- package/package.json +31 -31
- package/preset/recursive.patch.yml +312 -0
- package/scripts/e2e-run.mjs +51 -0
- package/scripts/link-dsh.mjs +233 -0
- package/scripts/live/fake-llm.mjs +150 -0
- package/scripts/live-session-plugin.mjs +179 -0
- package/scripts/live-session-stock.mjs +106 -0
- package/scripts/live-session.mjs +139 -0
- package/skills/recursive-mode/SKILL.md +66 -0
- package/src/client/derive.ts +18 -2
- package/src/closeout-report.ts +274 -0
- package/src/closeout-standards.ts +102 -0
- package/src/closeout.ts +39 -2
- package/src/commands.ts +116 -4
- package/src/config.ts +113 -0
- package/src/delegation.ts +336 -18
- package/src/enforcement.ts +262 -72
- package/src/errors.ts +197 -0
- package/src/git-context.ts +33 -2
- package/src/guard-log.ts +134 -0
- package/src/handoff.ts +62 -0
- package/src/hooks.ts +316 -0
- package/src/identity.ts +230 -0
- package/src/index.ts +394 -20
- package/src/job-log.ts +112 -0
- package/src/jobs-runner.ts +222 -0
- package/src/json-safe.ts +75 -0
- package/src/lock.ts +153 -16
- package/src/memory-feedback.ts +185 -0
- package/src/memory-select.ts +187 -0
- package/src/memory.ts +309 -0
- package/src/model-inventory.ts +196 -0
- package/src/phase-graph.ts +191 -0
- package/src/phase-rules.ts +236 -0
- package/src/plan-gate.ts +111 -0
- package/src/policy-globs.ts +636 -0
- package/src/policy-write.ts +210 -0
- package/src/policy.ts +70 -5
- package/src/recursive_ask.tool.ts +276 -0
- package/src/recursive_audit_team.tool.ts +7 -3
- package/src/recursive_closeout.tool.ts +36 -35
- package/src/recursive_delegate.tool.ts +194 -0
- package/src/recursive_init.tool.ts +4 -3
- package/src/recursive_lint.tool.ts +81 -6
- package/src/recursive_lock.tool.ts +21 -4
- package/src/recursive_phase.tool.ts +3 -2
- package/src/recursive_preview.tool.ts +142 -0
- package/src/recursive_review.tool.ts +190 -0
- package/src/recursive_scratch.tool.ts +5 -4
- package/src/recursive_status.tool.ts +3 -2
- package/src/recursive_worktree.tool.ts +6 -5
- package/src/result-cap.ts +130 -0
- package/src/review-round.ts +335 -0
- package/src/review.ts +17 -3
- package/src/role-route.ts +230 -0
- package/src/router.ts +128 -2
- package/src/runtime.ts +968 -39
- package/src/settlement.ts +355 -0
- package/src/skills-phase.ts +143 -0
- package/src/skills.ts +151 -0
- package/src/snapshot.ts +39 -8
- package/src/status.ts +209 -4
- package/src/teams-loop.ts +223 -9
- package/src/training.ts +565 -0
- package/src/ts-lint.ts +38 -4
- package/src/types.ts +51 -0
- package/src/workflow-audit.ts +288 -0
- package/scripts/install-preset.cmd +0 -7
- package/scripts/install-preset.js +0 -101
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* FU-10 step 1 — THE STOCK SESSION, copied VERBATIM from the harness's own e2e.
|
|
4
|
+
*
|
|
5
|
+
* WHY A SEPARATE SCRIPT. My first attempt added this plugin on the very first run, which mixed two
|
|
6
|
+
* unknowns: whether the launch works at all, and whether my plugin mounts. This script removes the second
|
|
7
|
+
* unknown completely — it is the harness's own fixture (same profile package.json, same bundles, same
|
|
8
|
+
* patch, same scripted LLM, same prompt, same invocation) with nothing of mine added. If this boots, the
|
|
9
|
+
* remaining question is only how to mount a plugin; if it does not, the route is blocked here and no
|
|
10
|
+
* amount of plugin work would have fixed it.
|
|
11
|
+
*
|
|
12
|
+
* Source of every line below: apps/cli/tests/agent-team-headless.e2e.ts (L21-78) and the invocation
|
|
13
|
+
* printed from resolveExampleLaunch.
|
|
14
|
+
*/
|
|
15
|
+
import { spawnSync } from 'node:child_process'
|
|
16
|
+
import { existsSync, mkdirSync, mkdtempSync, openSync, closeSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'
|
|
17
|
+
import { tmpdir } from 'node:os'
|
|
18
|
+
import { join } from 'node:path'
|
|
19
|
+
|
|
20
|
+
const HARNESS = 'D:\\deepseek-harness'
|
|
21
|
+
const SRC_BIN = join(HARNESS, 'apps', 'cli', 'src', 'bin.ts')
|
|
22
|
+
const TSCONFIG = join(HARNESS, 'tsconfig.json')
|
|
23
|
+
// The absolute file URL to tsx's loader, exactly as resolveExampleLaunch emits it.
|
|
24
|
+
const TSX_LOADER = 'file:///D:/deepseek-harness/node_modules/.pnpm/tsx@4.22.4/node_modules/tsx/dist/loader.mjs'
|
|
25
|
+
const FIXTURE_LLM = join(HARNESS, 'apps', 'cli', 'tests', 'profiles', 'headless', 'tests', 'fixtures', 'team-llm.mjs')
|
|
26
|
+
// The prompt the e2e uses, which is what its scripted LLM is written against.
|
|
27
|
+
const PROMPT = '请先运行 workflow 检查,再使用 Agent Teams 把调研和实现拆给两个 teammate,等待完成后汇总。'
|
|
28
|
+
|
|
29
|
+
const fileUrl = (path) => 'file:///' + path.replace(/\\/g, '/')
|
|
30
|
+
const base = join(existsSync('E:\\') ? 'E:\\dsh-rm-live' : tmpdir(), 'stock-' + new Date().toISOString().replace(/[:.]/g, '-'))
|
|
31
|
+
const home = join(base, '.dsh')
|
|
32
|
+
const sessions = join(home, 'sessions')
|
|
33
|
+
const profileDir = join(home, 'profiles', 'headless')
|
|
34
|
+
|
|
35
|
+
mkdirSync(profileDir, { recursive: true })
|
|
36
|
+
mkdirSync(sessions, { recursive: true })
|
|
37
|
+
|
|
38
|
+
// 1:1 with the e2e's profile.
|
|
39
|
+
writeFileSync(join(profileDir, 'package.json'), JSON.stringify({
|
|
40
|
+
name: 'dsh-profile-headless',
|
|
41
|
+
private: true,
|
|
42
|
+
dependencies: { '@deepseek-ai/dsh-experimental-agent-team-profile': 'workspace:^' },
|
|
43
|
+
dsh: {
|
|
44
|
+
profile: {
|
|
45
|
+
bundles: [
|
|
46
|
+
'@deepseek-ai/dsh-base',
|
|
47
|
+
'@deepseek-ai/dsh-headless',
|
|
48
|
+
'@deepseek-ai/dsh-experimental-agent-team-profile',
|
|
49
|
+
],
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
}, undefined, 2) + '\n', 'utf8')
|
|
53
|
+
|
|
54
|
+
// 1:1 with the e2e's patch: real provider off, sessions into the temp home, fixture LLM inserted.
|
|
55
|
+
writeFileSync(join(profileDir, 'cordis.patch.yml'), [
|
|
56
|
+
'- id: llm-deepseek',
|
|
57
|
+
' disabled: true',
|
|
58
|
+
'- id: session-persistence-jsonl',
|
|
59
|
+
' config:',
|
|
60
|
+
" root: '" + sessions.replace(/\\/g, '/') + "'",
|
|
61
|
+
' compression: none',
|
|
62
|
+
'- insert:',
|
|
63
|
+
' - id: team-fixture-llm',
|
|
64
|
+
" name: '" + fileUrl(FIXTURE_LLM) + "'",
|
|
65
|
+
'',
|
|
66
|
+
].join('\n'), 'utf8')
|
|
67
|
+
|
|
68
|
+
const outFile = join(base, 'stdout.txt')
|
|
69
|
+
const errFile = join(base, 'stderr.txt')
|
|
70
|
+
const outFd = openSync(outFile, 'w')
|
|
71
|
+
const errFd = openSync(errFile, 'w')
|
|
72
|
+
|
|
73
|
+
// ⚠ The child must not inherit this session's identity: DSH_PROFILE=web made the CLI see two profiles.
|
|
74
|
+
const env = { ...process.env }
|
|
75
|
+
for (const key of ['DSH_PROFILE', 'DSH_PROFILE_DIR', 'DSH_SESSION_ID', 'DSH_SHELL', 'DSH_WEB_URL']) delete env[key]
|
|
76
|
+
|
|
77
|
+
console.log('[stock] base: ' + base)
|
|
78
|
+
const result = spawnSync(process.execPath, [
|
|
79
|
+
'--import', TSX_LOADER,
|
|
80
|
+
SRC_BIN,
|
|
81
|
+
'--profile', 'headless',
|
|
82
|
+
PROMPT,
|
|
83
|
+
], {
|
|
84
|
+
cwd: base,
|
|
85
|
+
stdio: ['ignore', outFd, errFd],
|
|
86
|
+
env: {
|
|
87
|
+
...env,
|
|
88
|
+
DSH_HOME: home,
|
|
89
|
+
DSH_AGENTS_HOME: join(base, '.agents'),
|
|
90
|
+
DSH_TELEMETRY_DISABLED: '1',
|
|
91
|
+
DEEPSEEK_API_KEY: '',
|
|
92
|
+
TSX_TSCONFIG_PATH: TSCONFIG,
|
|
93
|
+
},
|
|
94
|
+
})
|
|
95
|
+
closeSync(outFd)
|
|
96
|
+
closeSync(errFd)
|
|
97
|
+
|
|
98
|
+
const stdout = existsSync(outFile) ? readFileSync(outFile, 'utf8') : ''
|
|
99
|
+
const stderr = existsSync(errFile) ? readFileSync(errFile, 'utf8') : ''
|
|
100
|
+
console.log('[stock] exit: ' + result.status + (result.error ? ' (spawn error: ' + result.error.message + ')' : ''))
|
|
101
|
+
console.log('[stock] stdout: ' + stdout.length + ' bytes | stderr: ' + stderr.length + ' bytes')
|
|
102
|
+
if (stdout.trim() !== '') console.log('--- stdout tail ---\n' + stdout.trim().split('\n').slice(-10).join('\n'))
|
|
103
|
+
if (stderr.trim() !== '') console.log('--- stderr tail ---\n' + stderr.trim().split('\n').slice(-10).join('\n'))
|
|
104
|
+
const logs = existsSync(sessions) ? readdirSync(sessions, { recursive: true }).map(String).filter((n) => n.endsWith('.jsonl')) : []
|
|
105
|
+
console.log('[stock] session logs: ' + logs.length)
|
|
106
|
+
console.log('[stock] workflow marker (TEAM_WORKFLOW_OK): ' + stdout.includes('TEAM_WORKFLOW_OK'))
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* FU-10 — THE LIVE-SESSION HARNESS.
|
|
4
|
+
*
|
|
5
|
+
* WHAT IT DOES. Opens a REAL `dsh` session in a temp folder on `E:` with a profile that mounts THIS plugin
|
|
6
|
+
* (by file URL — no install, no dependency added) and a scripted LLM, then reads the session log back.
|
|
7
|
+
*
|
|
8
|
+
* WHY IT EXISTS. FU-9 claims something the in-process harness cannot show: a delegation that reaches a
|
|
9
|
+
* REAL live Agent, because the continuable seam requires the exact Agent ("never a `{ id }` copy"). It is
|
|
10
|
+
* also the only way to measure whether a real session mounts a workflow engine at all, which is what
|
|
11
|
+
* FU-6's live half turns on.
|
|
12
|
+
*
|
|
13
|
+
* ⚠ OUTPUT GOES TO FILES, NOT PIPES. This sandbox denies a child process the piped stdio that capturing
|
|
14
|
+
* stdout needs (measured: EPERM), so the launch redirects the child's stdout/stderr to files and the
|
|
15
|
+
* harness reads those. The same constraint is why FU-5's extractor talks through a response file.
|
|
16
|
+
*
|
|
17
|
+
* ⚠ `src` MODE, because resolution comes from the HARNESS's tsconfig path mappings (`TSX_TSCONFIG_PATH`),
|
|
18
|
+
* not from a `node_modules` beside the temp profile — and `E:` has none.
|
|
19
|
+
*/
|
|
20
|
+
import { spawnSync } from 'node:child_process'
|
|
21
|
+
import { existsSync, mkdirSync, openSync, closeSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'
|
|
22
|
+
import { join } from 'node:path'
|
|
23
|
+
|
|
24
|
+
const HARNESS = 'D:\\deepseek-harness'
|
|
25
|
+
const PLUGIN = 'D:\\DEV\\dsh-recursive-mode'
|
|
26
|
+
const SRC_BIN = join(HARNESS, 'apps', 'cli', 'src', 'bin.ts')
|
|
27
|
+
const TSCONFIG = join(HARNESS, 'tsconfig.json')
|
|
28
|
+
|
|
29
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-')
|
|
30
|
+
const base = join(existsSync('E:\\') ? 'E:\\dsh-rm-live' : join(process.env.TEMP ?? '.', 'dsh-rm-live'), 'live-' + stamp)
|
|
31
|
+
const home = join(base, '.dsh')
|
|
32
|
+
const repo = join(base, 'repo')
|
|
33
|
+
const sessions = join(home, 'sessions')
|
|
34
|
+
// ⚠ THE PROFILE IS NAMED `headless`, AND NO COMMAND WORD IS PASSED. Measured: invoking the CLI as
|
|
35
|
+
// `… headless --profile live` fails with *"select a profile only once"* — the first positional is itself a
|
|
36
|
+
// profile selection. The harness's own e2e therefore writes its profile to `profiles/headless` and passes
|
|
37
|
+
// only `--profile headless <prompt>`.
|
|
38
|
+
const profileDir = join(home, 'profiles', 'headless')
|
|
39
|
+
|
|
40
|
+
const PROMPT = process.argv[2] ?? 'Run recursive_init for run live-run, then ask recursive_review to review phase 03.'
|
|
41
|
+
|
|
42
|
+
function fileUrl(path) {
|
|
43
|
+
return 'file:///' + path.replace(/\\/g, '/')
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
mkdirSync(profileDir, { recursive: true })
|
|
47
|
+
mkdirSync(sessions, { recursive: true })
|
|
48
|
+
mkdirSync(join(repo, 'src'), { recursive: true })
|
|
49
|
+
writeFileSync(join(repo, 'README.md'), '# live session scratch repo\n', 'utf8')
|
|
50
|
+
spawnSync('git', ['init', '-q'], { cwd: repo, stdio: 'ignore' })
|
|
51
|
+
|
|
52
|
+
// The profile: the harness's own two bundles, plus OUR plugin inserted by file URL (the pattern the
|
|
53
|
+
// harness uses for its own fixture), plus the scripted LLM.
|
|
54
|
+
writeFileSync(join(profileDir, 'package.json'), JSON.stringify({
|
|
55
|
+
name: 'dsh-profile-headless',
|
|
56
|
+
private: true,
|
|
57
|
+
dsh: { profile: { bundles: ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-headless'] } },
|
|
58
|
+
}, undefined, 2) + '\n', 'utf8')
|
|
59
|
+
|
|
60
|
+
writeFileSync(join(profileDir, 'cordis.patch.yml'), [
|
|
61
|
+
'- id: llm-deepseek',
|
|
62
|
+
' disabled: true',
|
|
63
|
+
'- id: session-persistence-jsonl',
|
|
64
|
+
' config:',
|
|
65
|
+
" root: '" + sessions.replace(/\\/g, '/') + "'",
|
|
66
|
+
' compression: none',
|
|
67
|
+
'- insert:',
|
|
68
|
+
' - id: live-fixture-llm',
|
|
69
|
+
" name: '" + fileUrl(join(PLUGIN, 'scripts', 'live', 'fake-llm.mjs')) + "'",
|
|
70
|
+
' - id: recursive-mode',
|
|
71
|
+
" name: '" + fileUrl(join(PLUGIN, 'src', 'index.ts')) + "'",
|
|
72
|
+
'',
|
|
73
|
+
].join('\n'), 'utf8')
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* ⚠ THE CHILD INHERITS `DSH_PROFILE=web` FROM THE SESSION THAT LAUNCHED IT, which made the CLI fail with
|
|
77
|
+
* "select a profile only once" — the environment supplied one profile and the arguments another. A live
|
|
78
|
+
* session must not inherit its parent's identity at all, so the launch env is filtered rather than spread.
|
|
79
|
+
*/
|
|
80
|
+
function cleanEnv() {
|
|
81
|
+
const env = { ...process.env }
|
|
82
|
+
for (const key of ['DSH_PROFILE', 'DSH_PROFILE_DIR', 'DSH_SESSION_ID', 'DSH_SHELL', 'DSH_WEB_URL']) {
|
|
83
|
+
delete env[key]
|
|
84
|
+
}
|
|
85
|
+
return env
|
|
86
|
+
}
|
|
87
|
+
const outFile = join(base, 'stdout.txt')
|
|
88
|
+
const errFile = join(base, 'stderr.txt')
|
|
89
|
+
const outFd = openSync(outFile, 'w')
|
|
90
|
+
const errFd = openSync(errFile, 'w')
|
|
91
|
+
|
|
92
|
+
console.log('[live] base: ' + base)
|
|
93
|
+
console.log('[live] home: ' + home)
|
|
94
|
+
console.log('[live] repo: ' + repo)
|
|
95
|
+
|
|
96
|
+
// ⚠ THE tsx **CLI**, NOT `--import tsx`. Measured: `node --import tsx` resolves the specifier relative to
|
|
97
|
+
// the CWD, so a temp repo on `E:` gives `ERR_MODULE_NOT_FOUND: Cannot find package 'tsx'`, and `NODE_PATH`
|
|
98
|
+
// does not help because it applies to CommonJS resolution only. Running tsx's own CLI by absolute path
|
|
99
|
+
// makes it resolve its dependencies from ITS location instead of the session's.
|
|
100
|
+
const TSX_LOADER = 'file:///D:/deepseek-harness/node_modules/.pnpm/tsx@4.22.4/node_modules/tsx/dist/loader.mjs'
|
|
101
|
+
|
|
102
|
+
const result = spawnSync(process.execPath, [
|
|
103
|
+
'--import', TSX_LOADER,
|
|
104
|
+
SRC_BIN,
|
|
105
|
+
'headless',
|
|
106
|
+
'--profile', 'headless',
|
|
107
|
+
PROMPT,
|
|
108
|
+
], {
|
|
109
|
+
cwd: repo,
|
|
110
|
+
// ⚠ Files, not pipes — see the header note.
|
|
111
|
+
stdio: ['ignore', outFd, errFd],
|
|
112
|
+
env: {
|
|
113
|
+
...cleanEnv(),
|
|
114
|
+
DSH_HOME: home,
|
|
115
|
+
DSH_AGENTS_HOME: join(base, '.agents'),
|
|
116
|
+
DSH_TELEMETRY_DISABLED: '1',
|
|
117
|
+
DEEPSEEK_API_KEY: '',
|
|
118
|
+
TSX_TSCONFIG_PATH: TSCONFIG,
|
|
119
|
+
// tsx and the harness's own modules live in the checkout, which is not an ancestor of the temp dir.
|
|
120
|
+
NODE_PATH: join(HARNESS, 'node_modules'),
|
|
121
|
+
},
|
|
122
|
+
})
|
|
123
|
+
closeSync(outFd)
|
|
124
|
+
closeSync(errFd)
|
|
125
|
+
|
|
126
|
+
console.log('[live] exit: ' + result.status + (result.error ? ' (spawn error: ' + result.error.message + ')' : ''))
|
|
127
|
+
const stdout = existsSync(outFile) ? readFileSync(outFile, 'utf8') : ''
|
|
128
|
+
const stderr = existsSync(errFile) ? readFileSync(errFile, 'utf8') : ''
|
|
129
|
+
console.log('[live] stdout: ' + stdout.length + ' bytes | stderr: ' + stderr.length + ' bytes')
|
|
130
|
+
if (stdout.trim() !== '') console.log('--- stdout tail ---\n' + stdout.trim().split('\n').slice(-12).join('\n'))
|
|
131
|
+
if (stderr.trim() !== '') console.log('--- stderr tail ---\n' + stderr.trim().split('\n').slice(-12).join('\n'))
|
|
132
|
+
|
|
133
|
+
const logs = existsSync(sessions)
|
|
134
|
+
? readdirSync(sessions, { recursive: true }).map(String).filter((name) => name.endsWith('.jsonl'))
|
|
135
|
+
: []
|
|
136
|
+
console.log('[live] session logs: ' + logs.length)
|
|
137
|
+
for (const log of logs) console.log(' ' + join(sessions, log))
|
|
138
|
+
console.log('[live] reached review marker: ' + stdout.includes('LIVE_SESSION_REACHED_REVIEW'))
|
|
139
|
+
process.exit(result.status ?? 1)
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# recursive-mode
|
|
2
|
+
|
|
3
|
+
Drive the recursive-mode workflow: an audit-gated, phase-disciplined loop that
|
|
4
|
+
carries one requirement from an AS-IS understanding through a TO-BE plan,
|
|
5
|
+
implementation, test, and manual QA, to locked control-plane state and durable
|
|
6
|
+
memory. Every phase follows `draft → audit → repair → re-audit → pass → lock`.
|
|
7
|
+
|
|
8
|
+
## When to use
|
|
9
|
+
|
|
10
|
+
- The user says "implement the run", "implement run <id>", "start a recursive
|
|
11
|
+
run", "resume <run-id>", or otherwise asks to advance a run.
|
|
12
|
+
- The repo carries a `/.recursive/` control plane (RECURSIVE.md, STATE.md,
|
|
13
|
+
DECISIONS.md, memory/, run/).
|
|
14
|
+
|
|
15
|
+
## Source of truth
|
|
16
|
+
|
|
17
|
+
The canonical spec is `/.recursive/RECURSIVE.md`; read it before starting or
|
|
18
|
+
resuming. The control plane lives under `/.recursive/`, one run under
|
|
19
|
+
`/.recursive/run/<run-id>/`, and durable memory under `/.recursive/memory/`.
|
|
20
|
+
Treat bridge docs (`AGENTS.md`, `CLAUDE.md`, `.codex/AGENTS.md`, `.agent/PLANS.md`)
|
|
21
|
+
as harness adapters, not a second spec — on conflict, follow `/.recursive/RECURSIVE.md`.
|
|
22
|
+
|
|
23
|
+
## Phases
|
|
24
|
+
|
|
25
|
+
A run advances one phase at a time in order; each phase owns a Markdown artifact
|
|
26
|
+
under `/.recursive/run/<run-id>/`:
|
|
27
|
+
|
|
28
|
+
- Phase 0 — `00-requirements.md` (gather requirements)
|
|
29
|
+
- Phase 0 (Worktree) — `00-worktree.md` (record the diff basis and scope)
|
|
30
|
+
- Phase 1 — `01-as-is.md` (AS-IS analysis)
|
|
31
|
+
- Phase 2 — `02-to-be-plan.md` (TO-BE plan)
|
|
32
|
+
- Phase 3 — `03-implementation-summary.md` (implement, strict/pragmatic TDD)
|
|
33
|
+
- Phase 3.5 — `03.5-code-review.md` (delegated or self review)
|
|
34
|
+
- Phase 4 — `04-test-summary.md` (test evidence)
|
|
35
|
+
- Phase 5 — `05-manual-qa.md` (manual QA)
|
|
36
|
+
- Phase 6 — `06-decisions-update.md` (update `/.recursive/DECISIONS.md`)
|
|
37
|
+
- Phase 7 — `07-state-update.md` (update `/.recursive/STATE.md`)
|
|
38
|
+
- Phase 8 — `08-memory-impact.md` (update `/.recursive/memory/`)
|
|
39
|
+
|
|
40
|
+
Use `recursive_status` to read the current phase and its state. Use `recursive_init`
|
|
41
|
+
to scaffold a run, `recursive_lock` to lock a passed phase (it refuses premature
|
|
42
|
+
locks), `recursive_lint` to machine-check an artifact before acceptance, and
|
|
43
|
+
`recursive_closeout` to advance the receipt when the phase is done.
|
|
44
|
+
|
|
45
|
+
## The audit loop
|
|
46
|
+
|
|
47
|
+
Audited phases must follow `draft → audit → repair → re-audit → pass → lock`.
|
|
48
|
+
Never set `Coverage: PASS` or `Approval: PASS` unless the artifact ends with
|
|
49
|
+
`Audit: PASS`. When subagents are unavailable, perform the same audit as
|
|
50
|
+
self-audit; do not weaken or skip it. Delegate an audit only with the full
|
|
51
|
+
context bundle (phase name + artifact path, upstream artifacts reread for the
|
|
52
|
+
audit, diff basis from `00-worktree.md`, changed files + code references,
|
|
53
|
+
phase-specific audit questions); if the bundle is incomplete, perform the audit
|
|
54
|
+
yourself and record `Audit Execution Mode: self-audit`. A `success: false` or any
|
|
55
|
+
nonzero delegated exit is a failed attempt — preserve diagnostics, repair owned
|
|
56
|
+
issues, rerun, and verify before acceptance.
|
|
57
|
+
|
|
58
|
+
## Locking and memory
|
|
59
|
+
|
|
60
|
+
Lock a phase with `recursive_lock` — it is the supported way to write
|
|
61
|
+
`Status: LOCKED`, `LockedAt`, and `LockHash`, and it refuses a lock whose
|
|
62
|
+
prerequisites are unmet. Phase 6 owns `/.recursive/DECISIONS.md`, Phase 7 owns
|
|
63
|
+
`/.recursive/STATE.md`, Phase 8 owns `/.recursive/memory/**`. Read
|
|
64
|
+
`/.recursive/memory/MEMORY.md` before loading other memory docs, and record
|
|
65
|
+
durable, reusable conclusions (not raw transcripts) when a run teaches the repo
|
|
66
|
+
something about capability availability, fit, or quality.
|
package/src/client/derive.ts
CHANGED
|
@@ -114,6 +114,13 @@ export interface RecursiveCardFacts {
|
|
|
114
114
|
subagents: RecursiveSubagentChip[]
|
|
115
115
|
}
|
|
116
116
|
|
|
117
|
+
/** T21: the single derived positions present on a card's rows, in phase order. */
|
|
118
|
+
function rowPositions(card: RecursiveRunCard): string[] {
|
|
119
|
+
return Object.values(card.phases)
|
|
120
|
+
.map((row) => row.position)
|
|
121
|
+
.filter((p): p is NonNullable<typeof p> => p !== undefined)
|
|
122
|
+
}
|
|
123
|
+
|
|
117
124
|
/** Derive §11.4 presentation facts from one card (no fs, no session). */
|
|
118
125
|
export function cardFacts(card: RecursiveRunCard): RecursiveCardFacts {
|
|
119
126
|
const groups = new Set<string>()
|
|
@@ -148,12 +155,21 @@ export function cardFacts(card: RecursiveRunCard): RecursiveCardFacts {
|
|
|
148
155
|
}
|
|
149
156
|
}
|
|
150
157
|
|
|
151
|
-
const tampered = Object.keys(card.tampers).length > 0
|
|
158
|
+
const tampered = Object.keys(card.tampers).length > 0 || rowPositions(card).includes('tampered')
|
|
152
159
|
// Lock validity: tampered beats everything; else every MANDATORY phase present
|
|
153
160
|
// and LOCKED -> locked; else still in-progress (01.5 stays optional).
|
|
161
|
+
//
|
|
162
|
+
// T21: when a row carries the server's single derived `position`, USE IT rather
|
|
163
|
+
// than recombining `status` here. Recombining is how this consumer came to call
|
|
164
|
+
// a LOCKED-but-gate-failing phase "locked" while the server's fold called it
|
|
165
|
+
// `invalid-lock` — two consumers, two answers, one phase. The `status` test
|
|
166
|
+
// remains as the fallback for a row produced before `position` existed.
|
|
154
167
|
const allMandatoryLocked = MANDATORY_GROUPS.every((g) => {
|
|
155
168
|
const row = Object.entries(card.phases).find(([key]) => phaseGroupOf(key) === g)
|
|
156
|
-
|
|
169
|
+
if (row === undefined) return false
|
|
170
|
+
const phase = row[1]
|
|
171
|
+
if (phase.position !== undefined) return phase.position === 'locked'
|
|
172
|
+
return phase.status === 'LOCKED'
|
|
157
173
|
})
|
|
158
174
|
const lockValidity: RecursiveCardFacts['lockValidity'] = tampered
|
|
159
175
|
? 'tampered'
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Closeout report (FU-12) — the closeout as a LINTER, not a writer.
|
|
3
|
+
*
|
|
4
|
+
* ⚠ THE DESIGN, in the user's words: *"closeout.ts is supposed to verify whether the phase artifact docs
|
|
5
|
+
* contain the scaffold information or not, then tell the agent to add it if not. It is not supposed to just
|
|
6
|
+
* insert section titles into the docs. It is like a linter checking if the agent missed anything. It is not
|
|
7
|
+
* supposed to edit files by itself."*
|
|
8
|
+
*
|
|
9
|
+
* ⚠ WHAT THIS MODULE THEREFORE DOES NOT DO: it opens no file for writing. The previous implementation called
|
|
10
|
+
* `mkdirSync` and `writeFileSync` to compose a stub receipt over the phase artifact — and a probe in a temp
|
|
11
|
+
* run folder showed what that costs: against a DRAFT `05-manual-qa.md` it RETURNED SUCCESS while the agent's
|
|
12
|
+
* real QA content was GONE. A closeout runs BEFORE a phase locks, so that was the ordinary case, not an edge.
|
|
13
|
+
*
|
|
14
|
+
* ⚠ AND WHERE THE STANDARD COMES FROM: `phase-rules.ts` and `ts-lint.ts` already own it — required sections
|
|
15
|
+
* (`getArtifactRequiredSections`, profile-aware), gate lines (`getGateStatus`), the artifact sequence
|
|
16
|
+
* (`RUN_ARTIFACT_SEQUENCE`), and the guidance text to hand the agent (`phaseLintRulesMessage`). This module
|
|
17
|
+
* adds no vocabulary of its own: three separate defects in this repo came from inventing a private copy of
|
|
18
|
+
* something that already had one home.
|
|
19
|
+
*/
|
|
20
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
21
|
+
import { dirname, join } from 'node:path'
|
|
22
|
+
import { getLockStatus } from './lock.ts'
|
|
23
|
+
import { CURRENT_WORKFLOW_PROFILE, phaseLintRulesMessage } from './phase-rules.ts'
|
|
24
|
+
import { RUN_ARTIFACT_SEQUENCE, getGateStatus, hasHeading } from './ts-lint.ts'
|
|
25
|
+
import { getPrerequisiteBlockers } from './lock.ts'
|
|
26
|
+
import { getRunTreeAddenda } from './ts-lint.ts'
|
|
27
|
+
|
|
28
|
+
/** Gate lines every phase document carries, written as `Coverage: PASS` / `Approval: PASS`. */
|
|
29
|
+
const UNIVERSAL_GATES = ['Coverage', 'Approval'] as const
|
|
30
|
+
|
|
31
|
+
/** The phase → artifact map. A LOOKUP, not a template: nothing here is ever rendered into a file. */
|
|
32
|
+
export const CLOSEOUT_PHASE_FILES: Record<string, { file: string; label: string }> = {
|
|
33
|
+
'04': { file: '04-test-summary.md', label: '04 Test Summary' },
|
|
34
|
+
'05': { file: '05-manual-qa.md', label: '05 Manual QA' },
|
|
35
|
+
'06': { file: '06-decisions-update.md', label: '06 Decisions Update' },
|
|
36
|
+
'07': { file: '07-state-update.md', label: '07 State Update' },
|
|
37
|
+
'08': { file: '08-memory-impact.md', label: '08 Memory Impact' },
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** One thing the agent must add or fix before the artifact can lock. */
|
|
41
|
+
export interface CloseoutFinding {
|
|
42
|
+
kind: 'missing-artifact' | 'missing-section' | 'gate-not-passing'
|
|
43
|
+
/** Directory-ready: what is wrong, in the words the agent should act on. */
|
|
44
|
+
detail: string
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface CloseoutReport {
|
|
48
|
+
phase: string
|
|
49
|
+
artifact: string
|
|
50
|
+
label: string
|
|
51
|
+
exists: boolean
|
|
52
|
+
/** `LOCKED`, `DRAFT`, `STALE_LOCK`, `MISSING` — from the same reader the lock tool uses. */
|
|
53
|
+
status: string
|
|
54
|
+
/** Empty means the artifact fits the standard; the caller tells the agent only when it is not. */
|
|
55
|
+
findings: CloseoutFinding[]
|
|
56
|
+
/**
|
|
57
|
+
* Advisory only: earlier artifacts that are not yet LOCKED. **Not a refusal** — the reference treats these
|
|
58
|
+
* as warnings (*"hard enforcement happens at lock time"*), and a closeout that refuses here cannot do the
|
|
59
|
+
* one thing it exists for.
|
|
60
|
+
*/
|
|
61
|
+
prerequisites: Array<{ artifact: string; status: string }>
|
|
62
|
+
/** The rules text to hand the agent, straight from the rules module. */
|
|
63
|
+
guidance: string[]
|
|
64
|
+
/** Addenda found anywhere in the run tree — cited as evidence, never written to. */
|
|
65
|
+
addenda: string[]
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface CloseoutReportOptions {
|
|
69
|
+
workflowProfile?: string
|
|
70
|
+
/** Include the advisory prerequisite list. Default true. */
|
|
71
|
+
checkPrerequisites?: boolean
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Examine one phase artifact and report what it is missing. **Reads only.**
|
|
76
|
+
*
|
|
77
|
+
* An absent artifact is a finding rather than an error: the closeout's job is to say what should be there,
|
|
78
|
+
* and "not written yet" is the most useful thing it can tell an agent at phase entry.
|
|
79
|
+
*/
|
|
80
|
+
export function closeoutReport(runDir: string, phase: string, options: CloseoutReportOptions = {}): CloseoutReport {
|
|
81
|
+
const entry = CLOSEOUT_PHASE_FILES[phase]
|
|
82
|
+
if (!entry) {
|
|
83
|
+
throw new Error('Unsupported closeout phase: ' + phase + ' (expected one of '
|
|
84
|
+
+ Object.keys(CLOSEOUT_PHASE_FILES).join(', ') + ')')
|
|
85
|
+
}
|
|
86
|
+
const profile = options.workflowProfile ?? CURRENT_WORKFLOW_PROFILE
|
|
87
|
+
const path = join(runDir, entry.file)
|
|
88
|
+
const exists = existsSync(path)
|
|
89
|
+
const findings: CloseoutFinding[] = []
|
|
90
|
+
let status = 'MISSING'
|
|
91
|
+
let content = ''
|
|
92
|
+
|
|
93
|
+
if (!exists) {
|
|
94
|
+
findings.push({
|
|
95
|
+
kind: 'missing-artifact',
|
|
96
|
+
detail: entry.file + ' does not exist yet; write it before this phase can lock',
|
|
97
|
+
})
|
|
98
|
+
} else {
|
|
99
|
+
status = getLockStatus(path)
|
|
100
|
+
try { content = readFileSync(path, 'utf8') } catch { content = '' }
|
|
101
|
+
// The standard, asked of the module that owns it — never a private copy.
|
|
102
|
+
const required = requiredSectionsFor(entry.file, profile)
|
|
103
|
+
for (const heading of required) {
|
|
104
|
+
if (!hasHeading(content, heading)) {
|
|
105
|
+
findings.push({ kind: 'missing-section', detail: 'add the `## ' + heading + '` section and fill it in' })
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
for (const gate of UNIVERSAL_GATES) {
|
|
109
|
+
const gateStatus = getGateStatus(content, gate)
|
|
110
|
+
if (gateStatus !== 'PASS') {
|
|
111
|
+
findings.push({ kind: 'gate-not-passing', detail: gate + ' gate reads ' + gateStatus + '; it must read PASS' })
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const prerequisites = (options.checkPrerequisites ?? true)
|
|
117
|
+
? getPrerequisiteBlockers(runDir, entry.file).map((b) => ({ artifact: b.artifact, status: b.status }))
|
|
118
|
+
: []
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
phase,
|
|
122
|
+
artifact: entry.file,
|
|
123
|
+
label: entry.label,
|
|
124
|
+
exists,
|
|
125
|
+
status,
|
|
126
|
+
findings,
|
|
127
|
+
prerequisites,
|
|
128
|
+
guidance: guidanceFor(entry.file, profile),
|
|
129
|
+
addenda: getRunTreeAddenda(runDir),
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* The required sections for an artifact, from the rules module.
|
|
135
|
+
*
|
|
136
|
+
* Kept behind a local indirection so the report has ONE place to change if the rules API moves, and so the
|
|
137
|
+
* import stays named where a reader looks for the standard.
|
|
138
|
+
*/
|
|
139
|
+
function requiredSectionsFor(file: string, profile: string): string[] {
|
|
140
|
+
return getArtifactRequiredSectionsImpl(file, profile)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** The guidance text for an artifact, from the rules module. */
|
|
144
|
+
function guidanceFor(file: string, profile: string): string[] {
|
|
145
|
+
const message = phaseLintRulesMessage(file, profile)
|
|
146
|
+
if (Array.isArray(message)) return message.map((line) => String(line))
|
|
147
|
+
return String(message).split('\n').filter((line) => line.trim() !== '')
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Imported this way so the rules dependency is visible as a single seam in the two helpers above.
|
|
151
|
+
import { getArtifactRequiredSections as getArtifactRequiredSectionsImpl } from './phase-rules.ts'
|
|
152
|
+
|
|
153
|
+
/** Anything in the run's artifact sequence that this report does not cover, for a whole-run sweep. */
|
|
154
|
+
export function uncoveredArtifacts(): string[] {
|
|
155
|
+
const covered = new Set(Object.values(CLOSEOUT_PHASE_FILES).map((e) => e.file))
|
|
156
|
+
return RUN_ARTIFACT_SEQUENCE.filter((name) => !covered.has(name))
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Where a closeout receipt lives: **its own file, beside the lock receipts, never over the artifact.**
|
|
161
|
+
*
|
|
162
|
+
* ⚠ FOLLOWS `lock.ts`'s `receiptPath`, which is the convention this plugin already has: a receipt is a JSON
|
|
163
|
+
* file under `<runDir>/locks/` named after the artifact stem. The closeout receipt deliberately does NOT
|
|
164
|
+
* reuse `<stem>.receipt.json` — that name belongs to the LOCK receipt, which carries a hash and is read by
|
|
165
|
+
* `getLockStatus`. A distinct suffix keeps them together without a collision.
|
|
166
|
+
*/
|
|
167
|
+
export function closeoutReceiptPath(runDir: string, phase: string): string {
|
|
168
|
+
const entry = CLOSEOUT_PHASE_FILES[phase]
|
|
169
|
+
if (!entry) throw new Error('Unsupported closeout phase: ' + phase)
|
|
170
|
+
return join(runDir, 'locks', entry.file.replace(/\.md$/, '') + '.closeout.receipt.json')
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Record the report as a closeout receipt. **The only write in this module, and it is never the artifact.**
|
|
175
|
+
*
|
|
176
|
+
* ⚠ WHY A RECEIPT AT ALL, in the user's words: a closeout *"should potentially create a close out receipt,
|
|
177
|
+
* that is ok and valuable, as long as it doesnt overwrite the phase docs."* So the durable trace of "the
|
|
178
|
+
* closeout examined this phase" is a file of its own, and the phase document is read and reported on but
|
|
179
|
+
* never touched.
|
|
180
|
+
*
|
|
181
|
+
* The JSON is a plain projection of the report — no timestamp, so the receipt is a pure function of the run
|
|
182
|
+
* state and two calls on the same run produce identical bytes.
|
|
183
|
+
*/
|
|
184
|
+
export function writeCloseoutReceipt(runDir: string, phase: string, options: CloseoutReportOptions = {}) {
|
|
185
|
+
const report = closeoutReport(runDir, phase, options)
|
|
186
|
+
const path = closeoutReceiptPath(runDir, phase)
|
|
187
|
+
mkdirSync(dirname(path), { recursive: true })
|
|
188
|
+
writeFileSync(path, JSON.stringify(report, null, 2) + '\n', 'utf8')
|
|
189
|
+
// ⚠ PHASE 08 IS THE RUN'S LAST PHASE, so its receipt is also where the RUN-level receipt belongs: a receipt
|
|
190
|
+
// that "represents what was done in the run" has to cover the whole run, and the early artifacts are most
|
|
191
|
+
// of what was done. The per-phase receipt above stays as it was; this one is the run's.
|
|
192
|
+
const runReceipt = isFinalPhase(phase) ? writeRunCloseoutReceipt(runDir, options) : null
|
|
193
|
+
return { path, report, runReceipt }
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Whether this phase is the run's final one — where the run-level receipt is written. */
|
|
197
|
+
function isFinalPhase(phase: string): boolean {
|
|
198
|
+
return phase === '08' || phase === '08-memory-impact.md'
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Where the RUN-level closeout receipt lives: the run root, one per run. */
|
|
202
|
+
export function runCloseoutReceiptPath(runDir: string): string {
|
|
203
|
+
return join(runDir, 'closeout.receipt.json')
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** One artifact's line in the run-level receipt. */
|
|
207
|
+
export interface RunArtifactState {
|
|
208
|
+
artifact: string
|
|
209
|
+
exists: boolean
|
|
210
|
+
/** `LOCKED`, `DRAFT`, `STALE_LOCK` — or `ABSENT` when the artifact was never written. */
|
|
211
|
+
status: string
|
|
212
|
+
/** Empty when this artifact fits the standard. */
|
|
213
|
+
findings: CloseoutFinding[]
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export interface RunCloseoutReport {
|
|
217
|
+
/** Every artifact in the run's sequence, in order — 00 through 08, not only the closeout phases. */
|
|
218
|
+
artifacts: RunArtifactState[]
|
|
219
|
+
/** How many of them fit the standard, for a one-line summary. */
|
|
220
|
+
conforming: number
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* The RUN-level report: every artifact in the run, not just the phases the closeout reports on.
|
|
225
|
+
*
|
|
226
|
+
* ⚠ WHY IT SPANS 00–08: the user's point, and it is the right one — a receipt *"represents what was done in
|
|
227
|
+
* the run"*, so it must include the early artifacts. The earlier correction still holds and is not undone by
|
|
228
|
+
* this: the closeout must never WRITE those documents (`recursive_init` owns them). It READS them, holds them
|
|
229
|
+
* to the same standard, and records their state. Reading a broad set and writing a narrow one is exactly the
|
|
230
|
+
* distinction that was missing.
|
|
231
|
+
*/
|
|
232
|
+
export function runCloseoutReport(runDir: string, options: CloseoutReportOptions = {}): RunCloseoutReport {
|
|
233
|
+
const profile = options.workflowProfile ?? CURRENT_WORKFLOW_PROFILE
|
|
234
|
+
const artifacts: RunArtifactState[] = []
|
|
235
|
+
|
|
236
|
+
for (const artifact of RUN_ARTIFACT_SEQUENCE) {
|
|
237
|
+
const path = join(runDir, artifact)
|
|
238
|
+
if (!existsSync(path)) {
|
|
239
|
+
artifacts.push({ artifact, exists: false, status: 'ABSENT', findings: [] })
|
|
240
|
+
continue
|
|
241
|
+
}
|
|
242
|
+
const findings: CloseoutFinding[] = []
|
|
243
|
+
let content = ''
|
|
244
|
+
try { content = readFileSync(path, 'utf8') } catch { content = '' }
|
|
245
|
+
for (const heading of requiredSectionsFor(artifact, profile)) {
|
|
246
|
+
if (!hasHeading(content, heading)) {
|
|
247
|
+
findings.push({ kind: 'missing-section', detail: 'missing required section: ## ' + heading })
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
for (const gate of UNIVERSAL_GATES) {
|
|
251
|
+
const gateStatus = getGateStatus(content, gate)
|
|
252
|
+
if (gateStatus !== 'PASS') {
|
|
253
|
+
findings.push({ kind: 'gate-not-passing', detail: gate + ': ' + gateStatus })
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
artifacts.push({ artifact, exists: true, status: getLockStatus(path), findings })
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
return { artifacts, conforming: artifacts.filter((a) => a.exists && a.findings.length === 0).length }
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Record the run-level receipt. **Its own file at the run root**, never a phase document.
|
|
264
|
+
*
|
|
265
|
+
* The JSON is a pure projection of the run state — no timestamp — so two calls on the same run produce
|
|
266
|
+
* identical bytes, which is what makes it comparable between runs.
|
|
267
|
+
*/
|
|
268
|
+
export function writeRunCloseoutReceipt(runDir: string, options: CloseoutReportOptions = {}) {
|
|
269
|
+
const report = runCloseoutReport(runDir, options)
|
|
270
|
+
const path = runCloseoutReceiptPath(runDir)
|
|
271
|
+
mkdirSync(dirname(path), { recursive: true })
|
|
272
|
+
writeFileSync(path, JSON.stringify(report, null, 2) + '\n', 'utf8')
|
|
273
|
+
return { path, report }
|
|
274
|
+
}
|