@try-works/dsh-recursive-mode 0.3.1 → 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.
Files changed (117) hide show
  1. package/README.md +959 -0
  2. package/lib/client.js +9 -2
  3. package/lib/closeout-report.d.ts +113 -0
  4. package/lib/closeout-standards.d.ts +35 -0
  5. package/lib/closeout.d.ts +12 -0
  6. package/lib/commands.d.ts +1 -1
  7. package/lib/config.d.ts +202 -0
  8. package/lib/delegation.d.ts +123 -3
  9. package/lib/enforcement.d.ts +90 -1
  10. package/lib/errors.d.ts +168 -0
  11. package/lib/git-context.d.ts +17 -0
  12. package/lib/guard-log.d.ts +39 -0
  13. package/lib/handoff.d.ts +29 -0
  14. package/lib/hooks.d.ts +103 -0
  15. package/lib/identity.d.ts +61 -0
  16. package/lib/index.d.ts +32 -12
  17. package/lib/index.js +9819 -3858
  18. package/lib/job-log.d.ts +34 -0
  19. package/lib/jobs-runner.d.ts +105 -0
  20. package/lib/json-safe.d.ts +33 -0
  21. package/lib/lock.d.ts +42 -0
  22. package/lib/memory-feedback.d.ts +52 -0
  23. package/lib/memory-select.d.ts +78 -0
  24. package/lib/memory.d.ts +137 -0
  25. package/lib/model-inventory.d.ts +106 -0
  26. package/lib/phase-graph.d.ts +111 -0
  27. package/lib/phase-rules.d.ts +67 -8
  28. package/lib/plan-gate.d.ts +68 -0
  29. package/lib/policy-globs.d.ts +222 -0
  30. package/lib/policy-write.d.ts +42 -0
  31. package/lib/policy.d.ts +39 -0
  32. package/lib/recursive_ask.tool.d.ts +88 -0
  33. package/lib/recursive_closeout.tool.d.ts +1 -1
  34. package/lib/recursive_delegate.tool.d.ts +22 -0
  35. package/lib/recursive_preview.tool.d.ts +48 -0
  36. package/lib/recursive_review.tool.d.ts +28 -0
  37. package/lib/result-cap.d.ts +70 -0
  38. package/lib/review-round.d.ts +82 -0
  39. package/lib/review.d.ts +9 -0
  40. package/lib/role-route.d.ts +122 -0
  41. package/lib/router.d.ts +90 -5
  42. package/lib/runtime.d.ts +252 -12
  43. package/lib/settlement.d.ts +132 -0
  44. package/lib/skills-phase.d.ts +71 -0
  45. package/lib/status.d.ts +53 -1
  46. package/lib/teams-loop.d.ts +91 -2
  47. package/lib/training.d.ts +211 -0
  48. package/lib/ts-lint.d.ts +15 -0
  49. package/lib/types.d.ts +48 -0
  50. package/lib/workflow-audit.d.ts +207 -0
  51. package/package.json +29 -30
  52. package/preset/recursive.patch.yml +312 -0
  53. package/scripts/e2e-run.mjs +51 -0
  54. package/scripts/link-dsh.mjs +233 -0
  55. package/scripts/live/fake-llm.mjs +150 -0
  56. package/scripts/live-session-plugin.mjs +179 -0
  57. package/scripts/live-session-stock.mjs +106 -0
  58. package/scripts/live-session.mjs +139 -0
  59. package/src/client/derive.ts +18 -2
  60. package/src/closeout-report.ts +274 -0
  61. package/src/closeout-standards.ts +102 -0
  62. package/src/closeout.ts +39 -2
  63. package/src/commands.ts +116 -4
  64. package/src/config.ts +113 -0
  65. package/src/delegation.ts +336 -18
  66. package/src/enforcement.ts +262 -72
  67. package/src/errors.ts +197 -0
  68. package/src/git-context.ts +33 -2
  69. package/src/guard-log.ts +134 -0
  70. package/src/handoff.ts +62 -0
  71. package/src/hooks.ts +316 -0
  72. package/src/identity.ts +230 -0
  73. package/src/index.ts +385 -20
  74. package/src/job-log.ts +112 -0
  75. package/src/jobs-runner.ts +222 -0
  76. package/src/json-safe.ts +75 -0
  77. package/src/lock.ts +153 -16
  78. package/src/memory-feedback.ts +185 -0
  79. package/src/memory-select.ts +187 -0
  80. package/src/memory.ts +309 -0
  81. package/src/model-inventory.ts +196 -0
  82. package/src/phase-graph.ts +191 -0
  83. package/src/phase-rules.ts +236 -0
  84. package/src/plan-gate.ts +111 -0
  85. package/src/policy-globs.ts +636 -0
  86. package/src/policy-write.ts +210 -0
  87. package/src/policy.ts +70 -5
  88. package/src/recursive_ask.tool.ts +276 -0
  89. package/src/recursive_audit_team.tool.ts +7 -3
  90. package/src/recursive_closeout.tool.ts +36 -35
  91. package/src/recursive_delegate.tool.ts +194 -0
  92. package/src/recursive_init.tool.ts +4 -3
  93. package/src/recursive_lint.tool.ts +81 -6
  94. package/src/recursive_lock.tool.ts +21 -4
  95. package/src/recursive_phase.tool.ts +3 -2
  96. package/src/recursive_preview.tool.ts +142 -0
  97. package/src/recursive_review.tool.ts +190 -0
  98. package/src/recursive_scratch.tool.ts +5 -4
  99. package/src/recursive_status.tool.ts +3 -2
  100. package/src/recursive_worktree.tool.ts +6 -5
  101. package/src/result-cap.ts +130 -0
  102. package/src/review-round.ts +335 -0
  103. package/src/review.ts +17 -3
  104. package/src/role-route.ts +230 -0
  105. package/src/router.ts +128 -2
  106. package/src/runtime.ts +968 -39
  107. package/src/settlement.ts +355 -0
  108. package/src/skills-phase.ts +143 -0
  109. package/src/snapshot.ts +39 -8
  110. package/src/status.ts +209 -4
  111. package/src/teams-loop.ts +223 -9
  112. package/src/training.ts +565 -0
  113. package/src/ts-lint.ts +38 -4
  114. package/src/types.ts +51 -0
  115. package/src/workflow-audit.ts +288 -0
  116. package/scripts/install-preset.cmd +0 -7
  117. 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)
@@ -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
- return row !== undefined && row[1].status === 'LOCKED'
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 4/5/6/7/8 delta-receipt stubs with canonical headers
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
  }