@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.
Files changed (120) 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 +33 -12
  17. package/lib/index.js +10017 -3969
  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/skills.d.ts +70 -0
  46. package/lib/status.d.ts +53 -1
  47. package/lib/teams-loop.d.ts +91 -2
  48. package/lib/training.d.ts +211 -0
  49. package/lib/ts-lint.d.ts +15 -0
  50. package/lib/types.d.ts +48 -0
  51. package/lib/workflow-audit.d.ts +207 -0
  52. package/package.json +31 -31
  53. package/preset/recursive.patch.yml +312 -0
  54. package/scripts/e2e-run.mjs +51 -0
  55. package/scripts/link-dsh.mjs +233 -0
  56. package/scripts/live/fake-llm.mjs +150 -0
  57. package/scripts/live-session-plugin.mjs +179 -0
  58. package/scripts/live-session-stock.mjs +106 -0
  59. package/scripts/live-session.mjs +139 -0
  60. package/skills/recursive-mode/SKILL.md +66 -0
  61. package/src/client/derive.ts +18 -2
  62. package/src/closeout-report.ts +274 -0
  63. package/src/closeout-standards.ts +102 -0
  64. package/src/closeout.ts +39 -2
  65. package/src/commands.ts +116 -4
  66. package/src/config.ts +113 -0
  67. package/src/delegation.ts +336 -18
  68. package/src/enforcement.ts +262 -72
  69. package/src/errors.ts +197 -0
  70. package/src/git-context.ts +33 -2
  71. package/src/guard-log.ts +134 -0
  72. package/src/handoff.ts +62 -0
  73. package/src/hooks.ts +316 -0
  74. package/src/identity.ts +230 -0
  75. package/src/index.ts +394 -20
  76. package/src/job-log.ts +112 -0
  77. package/src/jobs-runner.ts +222 -0
  78. package/src/json-safe.ts +75 -0
  79. package/src/lock.ts +153 -16
  80. package/src/memory-feedback.ts +185 -0
  81. package/src/memory-select.ts +187 -0
  82. package/src/memory.ts +309 -0
  83. package/src/model-inventory.ts +196 -0
  84. package/src/phase-graph.ts +191 -0
  85. package/src/phase-rules.ts +236 -0
  86. package/src/plan-gate.ts +111 -0
  87. package/src/policy-globs.ts +636 -0
  88. package/src/policy-write.ts +210 -0
  89. package/src/policy.ts +70 -5
  90. package/src/recursive_ask.tool.ts +276 -0
  91. package/src/recursive_audit_team.tool.ts +7 -3
  92. package/src/recursive_closeout.tool.ts +36 -35
  93. package/src/recursive_delegate.tool.ts +194 -0
  94. package/src/recursive_init.tool.ts +4 -3
  95. package/src/recursive_lint.tool.ts +81 -6
  96. package/src/recursive_lock.tool.ts +21 -4
  97. package/src/recursive_phase.tool.ts +3 -2
  98. package/src/recursive_preview.tool.ts +142 -0
  99. package/src/recursive_review.tool.ts +190 -0
  100. package/src/recursive_scratch.tool.ts +5 -4
  101. package/src/recursive_status.tool.ts +3 -2
  102. package/src/recursive_worktree.tool.ts +6 -5
  103. package/src/result-cap.ts +130 -0
  104. package/src/review-round.ts +335 -0
  105. package/src/review.ts +17 -3
  106. package/src/role-route.ts +230 -0
  107. package/src/router.ts +128 -2
  108. package/src/runtime.ts +968 -39
  109. package/src/settlement.ts +355 -0
  110. package/src/skills-phase.ts +143 -0
  111. package/src/skills.ts +151 -0
  112. package/src/snapshot.ts +39 -8
  113. package/src/status.ts +209 -4
  114. package/src/teams-loop.ts +223 -9
  115. package/src/training.ts +565 -0
  116. package/src/ts-lint.ts +38 -4
  117. package/src/types.ts +51 -0
  118. package/src/workflow-audit.ts +288 -0
  119. package/scripts/install-preset.cmd +0 -7
  120. 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.
@@ -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
+ }