@try-works/dsh-recursive-mode 0.3.1 → 0.4.1
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 +32 -12
- package/lib/index.js +9819 -3858
- 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/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 +29 -30
- package/preset/recursive/agent.cordis.yml +2 -2
- 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/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 +385 -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/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,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)
|
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
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Closeout standards check (FU-12b) — does every artifact in a run fit the required standard?
|
|
3
|
+
*
|
|
4
|
+
* ⚠ WHAT THIS IS FOR, in the user's words: *"phase 6 7 8 writes the state decisions memory artifacts, and
|
|
5
|
+
* closeout just makes sure that all the artifacts fit the required standards."* The closeout does not author
|
|
6
|
+
* the ledgers. It holds the run to the standard and reports what is missing.
|
|
7
|
+
*
|
|
8
|
+
* ⚠ WHY IT READS `phase-rules` INSTEAD OF KEEPING ITS OWN LIST: the required sections already have exactly
|
|
9
|
+
* one home. `phase-rules.ts` owns them and is PROFILE-AWARE — it appends `AUDIT_REQUIRED_HEADINGS` for
|
|
10
|
+
* audited artifacts under a strict workflow profile, and `Prior Recursive Evidence Reviewed` for the files
|
|
11
|
+
* that need it. A private copy cannot match that, and this session has already produced three separate
|
|
12
|
+
* defects from exactly that duplication (the closeout's own `REQUIRED_SECTIONS`, the 00–03 stubs, and the
|
|
13
|
+
* `meta` block). So this module invents nothing: it asks the rules and reports the difference.
|
|
14
|
+
*/
|
|
15
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
16
|
+
import { join } from 'node:path'
|
|
17
|
+
import { getLockStatus } from './lock.ts'
|
|
18
|
+
import { CURRENT_WORKFLOW_PROFILE, getArtifactRequiredSections } from './phase-rules.ts'
|
|
19
|
+
import { RUN_ARTIFACT_SEQUENCE, getGateStatus, hasHeading } from './ts-lint.ts'
|
|
20
|
+
|
|
21
|
+
/** Gate lines every phase document carries, as `Coverage: PASS|FAIL` / `Approval: PASS|FAIL`. */
|
|
22
|
+
const UNIVERSAL_GATES = ['Coverage', 'Approval'] as const
|
|
23
|
+
|
|
24
|
+
export interface StandardViolation {
|
|
25
|
+
/** Run-relative artifact name, or the sequence entry that was absent. */
|
|
26
|
+
artifact: string
|
|
27
|
+
kind: 'missing-artifact' | 'missing-section' | 'gate-not-passing' | 'not-locked'
|
|
28
|
+
/** What is wrong, spelled so a caller can print it without re-deriving anything. */
|
|
29
|
+
detail: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface StandardsReport {
|
|
33
|
+
/** How many artifacts from the sequence were present and examined. */
|
|
34
|
+
checked: number
|
|
35
|
+
/** Artifacts in the sequence that do not exist yet — informational, not necessarily a failure. */
|
|
36
|
+
absent: string[]
|
|
37
|
+
violations: StandardViolation[]
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface StandardsOptions {
|
|
41
|
+
/** Workflow profile whose rules apply; defaults to the current one, as every caller does. */
|
|
42
|
+
workflowProfile?: string
|
|
43
|
+
/**
|
|
44
|
+
* Report artifacts that are present but not LOCKED. **OFF by default**: a closeout runs mid-run and a DRAFT
|
|
45
|
+
* artifact is the normal state, so treating it as a violation would make the check useless exactly when it
|
|
46
|
+
* is most wanted. Callers that need an all-locked assertion ask for it.
|
|
47
|
+
*/
|
|
48
|
+
requireLocked?: boolean
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Examine every artifact in the run against the phase rules.
|
|
53
|
+
*
|
|
54
|
+
* Reports, per present artifact: required headings that are missing, gate lines that are missing or FAIL, and
|
|
55
|
+
* (only when `requireLocked`) a status that is not LOCKED. Absent artifacts are listed separately — during a
|
|
56
|
+
* closeout most of the sequence legitimately does not exist yet, and conflating "not written yet" with
|
|
57
|
+
* "written wrong" would bury the findings that matter.
|
|
58
|
+
*/
|
|
59
|
+
export function verifyRunStandards(runDir: string, options: StandardsOptions = {}): StandardsReport {
|
|
60
|
+
const profile = options.workflowProfile ?? CURRENT_WORKFLOW_PROFILE
|
|
61
|
+
const violations: StandardViolation[] = []
|
|
62
|
+
const absent: string[] = []
|
|
63
|
+
let checked = 0
|
|
64
|
+
|
|
65
|
+
for (const artifact of RUN_ARTIFACT_SEQUENCE) {
|
|
66
|
+
const path = join(runDir, artifact)
|
|
67
|
+
if (!existsSync(path)) { absent.push(artifact); continue }
|
|
68
|
+
checked += 1
|
|
69
|
+
let content = ''
|
|
70
|
+
try { content = readFileSync(path, 'utf8') } catch { continue }
|
|
71
|
+
|
|
72
|
+
for (const heading of getArtifactRequiredSections(artifact, profile)) {
|
|
73
|
+
if (!hasHeading(content, heading)) {
|
|
74
|
+
violations.push({ artifact, kind: 'missing-section', detail: 'missing required section: ## ' + heading })
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
for (const gate of UNIVERSAL_GATES) {
|
|
78
|
+
const status = getGateStatus(content, gate)
|
|
79
|
+
if (status !== 'PASS') {
|
|
80
|
+
violations.push({ artifact, kind: 'gate-not-passing', detail: gate + ': ' + status })
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
if (options.requireLocked === true) {
|
|
84
|
+
const status = getLockStatus(path)
|
|
85
|
+
if (status !== 'LOCKED') {
|
|
86
|
+
violations.push({ artifact, kind: 'not-locked', detail: 'status is ' + status })
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return { checked, absent, violations }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** One line per violation, for a tool result or a CLI print. Empty when the run fits the standard. */
|
|
95
|
+
export function formatStandardsReport(report: StandardsReport): string[] {
|
|
96
|
+
const lines = report.violations.map((v) => v.artifact + ': ' + v.detail)
|
|
97
|
+
if (lines.length === 0) {
|
|
98
|
+
return ['All ' + report.checked + ' present artifact(s) fit the required standards'
|
|
99
|
+
+ (report.absent.length > 0 ? ' (' + report.absent.length + ' not written yet)' : '') + '.']
|
|
100
|
+
}
|
|
101
|
+
return lines
|
|
102
|
+
}
|
package/src/closeout.ts
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Closeout receipt scaffolding (R2) — TS port of recursive-closeout.py.
|
|
3
|
-
* Creates/updates Phase
|
|
3
|
+
* Creates/updates Phase 0-8 receipt stubs with canonical headers
|
|
4
4
|
* and required sections, gated on prerequisite phases being LOCKED.
|
|
5
5
|
*/
|
|
6
6
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
7
7
|
import { join } from 'node:path'
|
|
8
|
-
import { getPrerequisiteBlockers } from './lock.ts'
|
|
8
|
+
import { getLockStatus, getPrerequisiteBlockers } from './lock.ts'
|
|
9
|
+
import { getRunTreeAddenda } from './ts-lint.ts'
|
|
9
10
|
|
|
10
11
|
export interface CloseoutPhaseConfig {
|
|
11
12
|
file: string
|
|
@@ -68,6 +69,7 @@ export const PHASE_CONFIG: Record<string, CloseoutPhaseConfig> = {
|
|
|
68
69
|
}
|
|
69
70
|
|
|
70
71
|
const REQUIRED_SECTIONS: Record<string, string[]> = {
|
|
72
|
+
// `recursive_lint` both know; these are the stub's minimum, not a replacement for them.
|
|
71
73
|
'04': ['TODO', 'Pre-Test Implementation Audit', 'Environment', 'Execution Mode', 'Commands Executed (Exact)', 'Results Summary', 'Evidence and Artifacts', 'Failures and Diagnostics (if any)', 'Flake/Rerun Notes', 'Requirement Completion Status', 'Traceability', 'Coverage Gate', 'Approval Gate'],
|
|
72
74
|
'05': ['TODO', 'Execution Mode', 'QA Execution Record', 'QA Scenarios and Results', 'User Sign-Off', 'Requirement Completion Status', 'Traceability', 'Coverage Gate', 'Approval Gate'],
|
|
73
75
|
'06': ['TODO', 'Decisions Changes Applied', 'Rationale', 'Resulting Decision Entry', 'Traceability', 'Coverage Gate', 'Approval Gate'],
|
|
@@ -126,6 +128,12 @@ export interface CloseoutResult {
|
|
|
126
128
|
file: string
|
|
127
129
|
created: string[]
|
|
128
130
|
existing: string[]
|
|
131
|
+
/**
|
|
132
|
+
* Every addendum found anywhere in the run tree, as run-relative paths — and cited in the scaffolded
|
|
133
|
+
* receipt. Optional on the TYPE so a caller that builds its own result literal still compiles; the
|
|
134
|
+
* closeout itself always populates it.
|
|
135
|
+
*/
|
|
136
|
+
addenda?: string[]
|
|
129
137
|
}
|
|
130
138
|
|
|
131
139
|
export interface CloseoutOptions {
|
|
@@ -137,6 +145,12 @@ export interface CloseoutOptions {
|
|
|
137
145
|
* Create or update a closeout receipt stub for the given phase.
|
|
138
146
|
* Returns the file + created/existing lists. When strict, refuses phases whose
|
|
139
147
|
* prerequisite artifacts are not all LOCKED (reuses lock.ts chain validation).
|
|
148
|
+
*
|
|
149
|
+
* ⚠ ADDENDA ARE PART OF THE CLOSEOUT WORK, and they are CITED, never written. An addendum closes a gap in
|
|
150
|
+
* an artifact that is already locked, which is precisely the kind of thing a receipt must account for — and
|
|
151
|
+
* they are not always in `addenda/`: the real ones sit beside the artifact they close, in the run ROOT. So
|
|
152
|
+
* the receipt lists every addendum in the run tree (see {@link getRunTreeAddenda}), and says so explicitly
|
|
153
|
+
* when there are none, because "we looked and found none" and "we never looked" must not read alike.
|
|
140
154
|
*/
|
|
141
155
|
export function closeoutPhase(runDir: string, phase: string, opts: CloseoutOptions = {}): CloseoutResult {
|
|
142
156
|
const config = PHASE_CONFIG[phase]
|
|
@@ -156,7 +170,25 @@ export function closeoutPhase(runDir: string, phase: string, opts: CloseoutOptio
|
|
|
156
170
|
const filePath = join(runDir, config.file)
|
|
157
171
|
const existed = existsSync(filePath)
|
|
158
172
|
|
|
173
|
+
// ⚠ FU-8 — A CLOSEOUT MUST NOT SILENTLY UNLOCK ITS OWN ARTIFACT. Found by the FU-1 harness: a
|
|
174
|
+
// closeout run after the phase had locked rewrote the file to `Status: \`DRAFT\`` while its RECEIPT
|
|
175
|
+
// still said LOCKED, leaving a run whose file and receipt disagree. The plugin publishes the rule this
|
|
176
|
+
// breaks, in the policy section a model reads every turn: *"Writes to a Status: LOCKED phase doc are
|
|
177
|
+
// denied/asked; reopen explicitly to edit."* The prerequisite check above guards EARLIER phases; this
|
|
178
|
+
// guards the artifact the closeout actually writes.
|
|
179
|
+
//
|
|
180
|
+
// ⚠ IT REFUSES RATHER THAN SKIPPING QUIETLY, and it names the remedy, because the caller's next move
|
|
181
|
+
// differs: a refusal means "reopen it first if you really mean to", while a silent skip would look
|
|
182
|
+
// like a successful closeout that changed nothing.
|
|
183
|
+
if (getLockStatus(filePath) === 'LOCKED') {
|
|
184
|
+
throw new Error(
|
|
185
|
+
'Artifact is LOCKED: ' + config.file + ' — the contract denies a write to a LOCKED phase doc; '
|
|
186
|
+
+ 'reopen it explicitly (recursive_lock with reopen) before scaffolding its receipt',
|
|
187
|
+
)
|
|
188
|
+
}
|
|
189
|
+
|
|
159
190
|
const runId = runDir.split(/[\\/]/).filter(Boolean).pop() ?? 'unknown-run'
|
|
191
|
+
const addenda = getRunTreeAddenda(runDir)
|
|
160
192
|
const lines: string[] = [
|
|
161
193
|
'Run: `/.recursive/run/' + runId + '/`',
|
|
162
194
|
'Phase: `' + config.label + '`',
|
|
@@ -167,6 +199,10 @@ export function closeoutPhase(runDir: string, phase: string, opts: CloseoutOptio
|
|
|
167
199
|
'Outputs:',
|
|
168
200
|
'- `/.recursive/run/' + runId + '/' + config.file + '`',
|
|
169
201
|
'Scope note: ' + config.scopeNote,
|
|
202
|
+
'Addenda:',
|
|
203
|
+
...(addenda.length > 0
|
|
204
|
+
? addenda.map(a => '- `/.recursive/run/' + runId + '/' + a + '`')
|
|
205
|
+
: ['- none found in the run tree']),
|
|
170
206
|
'',
|
|
171
207
|
]
|
|
172
208
|
for (const heading of REQUIRED_SECTIONS[phase]) {
|
|
@@ -179,5 +215,6 @@ export function closeoutPhase(runDir: string, phase: string, opts: CloseoutOptio
|
|
|
179
215
|
file: config.file,
|
|
180
216
|
created: existed ? [] : [config.file],
|
|
181
217
|
existing: existed ? [config.file] : [],
|
|
218
|
+
addenda,
|
|
182
219
|
}
|
|
183
220
|
}
|