@openprd/cli 0.1.18 → 0.1.21

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 (118) hide show
  1. package/.openprd/changes/openprd-control-plane-v020/.openprd.yaml +2 -0
  2. package/.openprd/changes/openprd-control-plane-v020/design.md +78 -0
  3. package/.openprd/changes/openprd-control-plane-v020/proposal.md +54 -0
  4. package/.openprd/changes/openprd-control-plane-v020/specs/agent-requirements/spec.md +16 -0
  5. package/.openprd/changes/openprd-control-plane-v020/task-events.jsonl +27 -0
  6. package/.openprd/changes/openprd-control-plane-v020/tasks-002.md +35 -0
  7. package/.openprd/changes/openprd-control-plane-v020/tasks.md +427 -0
  8. package/.openprd/changes/openprd-generated-change/.openprd.yaml +2 -0
  9. package/.openprd/changes/openprd-generated-change/design.md +50 -0
  10. package/.openprd/changes/openprd-generated-change/proposal.md +37 -0
  11. package/.openprd/changes/openprd-generated-change/specs/agent-requirements/spec.md +16 -0
  12. package/.openprd/changes/openprd-generated-change/task-events.jsonl +21 -0
  13. package/.openprd/changes/openprd-generated-change/tasks.md +357 -0
  14. package/.openprd/changes/remove-session-tracking-humane-approval/.openprd.yaml +2 -0
  15. package/.openprd/changes/remove-session-tracking-humane-approval/design.md +52 -0
  16. package/.openprd/changes/remove-session-tracking-humane-approval/proposal.md +35 -0
  17. package/.openprd/changes/remove-session-tracking-humane-approval/specs/agent-requirements/spec.md +16 -0
  18. package/.openprd/changes/remove-session-tracking-humane-approval/task-events.jsonl +1 -0
  19. package/.openprd/changes/remove-session-tracking-humane-approval/tasks.md +170 -0
  20. package/.openprd/design/active/asset-spec.md +19 -14
  21. package/.openprd/design/active/direction-plan.md +19 -3
  22. package/.openprd/design/active/facts-sheet.md +16 -7
  23. package/.openprd/design/active/image-preflight.md +6 -5
  24. package/.openprd/design/active/review-studio-v020-directions/compare-plan.json +34 -0
  25. package/.openprd/design/active/review-studio-v020-directions/contact-sheet.jpg +0 -0
  26. package/.openprd/design/active/review-studio-v020-directions/crops/01.png +0 -0
  27. package/.openprd/design/active/review-studio-v020-directions/crops/02.png +0 -0
  28. package/.openprd/design/active/review-studio-v020-directions/crops/03.png +0 -0
  29. package/.openprd/design/active/review-studio-v020-directions/focus-board.template.json +69 -0
  30. package/.openprd/design/active/review-studio-v020-directions/parallel-board.template.json +45 -0
  31. package/.openprd/design/active/review-studio-v020-directions/reference-set.json +143 -0
  32. package/.openprd/design/active/review-studio-v020-directions/source.png +0 -0
  33. package/.openprd/design/active/selected-direction.md +23 -9
  34. package/.openprd/engagements/active/control-plane-architecture.json +203 -0
  35. package/.openprd/engagements/active/control-plane-intake.json +418 -0
  36. package/.openprd/engagements/active/prd.md +186 -100
  37. package/.openprd/engagements/active/review-presentation-v0018.json +176 -0
  38. package/.openprd/i18n-config.json +12 -0
  39. package/.openprd/ledger/events.jsonl +6 -0
  40. package/.openprd/ledger/heads/ebea1a71a9daa566f1c91b53.json +10 -0
  41. package/.openprd/manifest.json +21 -0
  42. package/AGENTS.md +1 -0
  43. package/README.md +22 -0
  44. package/README_EN.md +27 -0
  45. package/package.json +1 -1
  46. package/skills/openprd-frontend-design/SKILL.md +16 -0
  47. package/skills/openprd-harness/SKILL.md +1 -1
  48. package/skills/openprd-quality/SKILL.md +1 -1
  49. package/skills/openprd-requirement-intake/SKILL.md +1 -1
  50. package/skills/openprd-shared/SKILL.md +1 -1
  51. package/src/adapters/adapter-spi.js +193 -0
  52. package/src/adapters/capability-envelope.js +98 -0
  53. package/src/adapters/event-normalizer.js +55 -0
  54. package/src/adapters/index.js +4 -0
  55. package/src/adapters/install-safety.js +249 -0
  56. package/src/agent-canonical-content.js +6 -2
  57. package/src/agent-integration.js +147 -42
  58. package/src/canvas-app-client-app.js +838 -0
  59. package/src/canvas-app-client-helpers.js +1138 -0
  60. package/src/canvas-app-styles.js +898 -0
  61. package/src/canvas-app.html.js +6 -2781
  62. package/src/canvas-binding.js +414 -0
  63. package/src/canvas-codex-relay.js +430 -0
  64. package/src/canvas-i18n.js +8 -0
  65. package/src/canvas-scene.js +377 -0
  66. package/src/canvas-session-store.js +148 -0
  67. package/src/canvas-workspace.js +59 -1066
  68. package/src/cli/args.js +63 -4
  69. package/src/cli/gate-print.js +17 -0
  70. package/src/cli/quality-commands.js +18 -0
  71. package/src/cli/quality-print.js +10 -0
  72. package/src/cli/runtime-print.js +24 -0
  73. package/src/codex-hook-runner-template.mjs +87 -77
  74. package/src/codex-runtime.js +48 -5
  75. package/src/context/cache.js +245 -0
  76. package/src/context/compiler.js +438 -0
  77. package/src/context/constants.js +30 -0
  78. package/src/context/index.js +39 -0
  79. package/src/context/redaction.js +84 -0
  80. package/src/context/stable.js +69 -0
  81. package/src/context/telemetry.js +42 -0
  82. package/src/dev-standards.js +57 -0
  83. package/src/fleet.js +112 -95
  84. package/src/gates/index.js +2 -0
  85. package/src/gates/scoped-gates.js +256 -0
  86. package/src/gates/store.js +126 -0
  87. package/src/gates/workspace.js +41 -0
  88. package/src/html-artifacts.js +725 -28
  89. package/src/kernel/atomic-store.js +299 -0
  90. package/src/kernel/event-envelope.js +166 -0
  91. package/src/kernel/index.js +4 -0
  92. package/src/kernel/project-ledger.js +467 -0
  93. package/src/kernel/project-manifest.js +205 -0
  94. package/src/knowledge-v3/index.js +1 -0
  95. package/src/knowledge-v3/lifecycle.js +290 -0
  96. package/src/knowledge.js +14 -7
  97. package/src/openprd.js +71 -2
  98. package/src/review-model.js +413 -0
  99. package/src/review-presentation.js +1 -1
  100. package/src/run-harness.js +431 -37
  101. package/src/runtime/cli_runtime_README.md +28 -0
  102. package/src/runtime/errors.js +66 -0
  103. package/src/runtime/index.js +44 -0
  104. package/src/runtime/lane-schema.js +141 -0
  105. package/src/runtime/lane-store.js +279 -0
  106. package/src/runtime/task-runtime.js +449 -0
  107. package/src/runtime/workspace.js +179 -0
  108. package/src/runtime/write-set.js +206 -0
  109. package/src/session-binding.js +16 -3
  110. package/src/session-registry.js +59 -1
  111. package/src/upgrade/fleet-mutation.js +166 -0
  112. package/src/upgrade/fleet-transaction.js +398 -0
  113. package/src/upgrade/transaction-store.js +416 -0
  114. package/src/visual-compare-core.js +66 -27
  115. package/src/visual-compare.js +18 -12
  116. package/src/workspace-core.js +109 -7
  117. package/src/workspace-registry.js +39 -1
  118. package/src/workspace-workflow.js +18 -15
@@ -0,0 +1,416 @@
1
+ /*
2
+ * 核心功能
3
+ * 提供 fleet 项目事务所需的 managed-scope 快照、备份、staging 重定位、原子应用和校验和恢复。
4
+ *
5
+ * 输入
6
+ * 接收项目根目录、事务计划、staging 根目录和可选的 apply 故障注入回调。
7
+ *
8
+ * 输出
9
+ * 返回稳定的 SHA-256 文件树快照,并保证备份、应用和恢复结果都经过同一校验算法核对。
10
+ *
11
+ * 定位
12
+ * 位于 upgrade 基础设施层,不判断项目分类、版本策略或 fleet 动作。
13
+ *
14
+ * 依赖
15
+ * 使用 kernel atomic-store 写事务 manifest 和重定位后的文本文件。
16
+ *
17
+ * 维护规则
18
+ * 新增 updater 可写路径时同步维护 FLEET_MANAGED_SCOPES;禁止跟随符号链接读取或写入。
19
+ */
20
+ import { createHash, randomUUID } from 'node:crypto';
21
+ import fs from 'node:fs/promises';
22
+ import path from 'node:path';
23
+
24
+ import { CANONICAL_COMMANDS, CANONICAL_SKILLS } from '../agent-canonical-content.js';
25
+ import { atomicWriteJson, atomicWriteText } from '../kernel/index.js';
26
+ import { hasLegacyOpenPrdArtifacts } from '../openspec/migration.js';
27
+
28
+ const FLEET_TRANSACTION_SCHEMA = 'openprd.fleet-upgrade-transaction';
29
+ const FLEET_TRANSACTION_VERSION = 1;
30
+ const CHECKSUM_ALGORITHM = 'sha256';
31
+
32
+ const OPENPRD_AGENT_ARTIFACT_SCOPES = Object.freeze([
33
+ ...CANONICAL_SKILLS.flatMap((skill) => [
34
+ path.join('.codex', 'skills', skill.id),
35
+ path.join('.claude', 'skills', skill.id),
36
+ ]),
37
+ ...CANONICAL_COMMANDS.flatMap((command) => [
38
+ path.join('.codex', 'prompts', `openprd-${command.id}.md`),
39
+ path.join('.claude', 'commands', 'openprd', `${command.id}.md`),
40
+ path.join('.cursor', 'commands', `openprd-${command.id}.md`),
41
+ ]),
42
+ ]);
43
+ const FLEET_MANAGED_SCOPES = Object.freeze([
44
+ '.openprd',
45
+ 'AGENTS.md',
46
+ 'CLAUDE.md',
47
+ '.gitignore',
48
+ path.join('.codex', 'config.toml'),
49
+ path.join('.codex', 'hooks.json'),
50
+ path.join('.codex', 'hooks', 'openprd-hook.mjs'),
51
+ path.join('.cursor', 'rules', 'openprd.mdc'),
52
+ ...OPENPRD_AGENT_ARTIFACT_SCOPES,
53
+ ]);
54
+
55
+ async function managedScopesForRoot(projectRoot) {
56
+ const scopes = [...FLEET_MANAGED_SCOPES];
57
+ // `openprd/` is only managed when it is the legacy changes/specs tree. A
58
+ // normal nested repository named `openprd` must never enter the transaction.
59
+ if (await hasLegacyOpenPrdArtifacts(projectRoot)) scopes.push('openprd');
60
+ return scopes;
61
+ }
62
+
63
+ function sha256(value) {
64
+ return createHash('sha256').update(value).digest('hex');
65
+ }
66
+
67
+ function normalizeRelativePath(value) {
68
+ return value.split(path.sep).join('/');
69
+ }
70
+
71
+ async function collectSymlinkHazards(projectRoot) {
72
+ const hazards = [];
73
+ const hazardPaths = new Set();
74
+ function addHazard(relativePath) {
75
+ const normalizedPath = normalizeRelativePath(relativePath);
76
+ if (hazardPaths.has(normalizedPath)) return;
77
+ hazardPaths.add(normalizedPath);
78
+ hazards.push({
79
+ code: 'symlink-managed-path',
80
+ path: normalizedPath,
81
+ message: `Managed upgrade path is a symbolic link: ${normalizedPath}`,
82
+ });
83
+ }
84
+ async function findSymlinkAncestor(relativePath) {
85
+ const parts = relativePath.split(path.sep).filter(Boolean);
86
+ let cursor = path.resolve(projectRoot);
87
+ let cursorRelative = '';
88
+ for (const part of parts) {
89
+ cursor = path.join(cursor, part);
90
+ cursorRelative = path.join(cursorRelative, part);
91
+ const stat = await fs.lstat(cursor).catch((error) => {
92
+ if (error.code === 'ENOENT') return null;
93
+ throw error;
94
+ });
95
+ if (!stat) return false;
96
+ if (stat.isSymbolicLink()) {
97
+ addHazard(cursorRelative);
98
+ return true;
99
+ }
100
+ }
101
+ return false;
102
+ }
103
+ async function walk(absolutePath, relativePath) {
104
+ const stat = await fs.lstat(absolutePath).catch((error) => {
105
+ if (error.code === 'ENOENT') return null;
106
+ throw error;
107
+ });
108
+ if (!stat) return;
109
+ if (stat.isSymbolicLink()) {
110
+ addHazard(relativePath);
111
+ return;
112
+ }
113
+ if (!stat.isDirectory()) return;
114
+ const entries = await fs.readdir(absolutePath, { withFileTypes: true });
115
+ for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
116
+ await walk(path.join(absolutePath, entry.name), path.join(relativePath, entry.name));
117
+ }
118
+ }
119
+ for (const scope of await managedScopesForRoot(projectRoot)) {
120
+ if (await findSymlinkAncestor(scope)) continue;
121
+ await walk(path.join(projectRoot, scope), scope);
122
+ }
123
+ return hazards;
124
+ }
125
+
126
+ async function snapshotManagedState(projectRoot) {
127
+ const entries = [];
128
+ async function visit(absolutePath, relativePath) {
129
+ const stat = await fs.lstat(absolutePath).catch((error) => {
130
+ if (error.code === 'ENOENT') return null;
131
+ throw error;
132
+ });
133
+ if (!stat) return;
134
+ const normalizedPath = normalizeRelativePath(relativePath);
135
+ if (stat.isSymbolicLink()) {
136
+ const error = new Error(`Symbolic link cannot be included in a fleet transaction: ${normalizedPath}`);
137
+ error.code = 'OPENPRD_FLEET_SYMLINK_HAZARD';
138
+ throw error;
139
+ }
140
+ if (stat.isDirectory()) {
141
+ entries.push({ path: normalizedPath, type: 'directory', mode: stat.mode & 0o777 });
142
+ const children = await fs.readdir(absolutePath, { withFileTypes: true });
143
+ for (const child of children.sort((left, right) => left.name.localeCompare(right.name))) {
144
+ await visit(path.join(absolutePath, child.name), path.join(relativePath, child.name));
145
+ }
146
+ return;
147
+ }
148
+ if (stat.isFile()) {
149
+ const contents = await fs.readFile(absolutePath);
150
+ entries.push({
151
+ path: normalizedPath,
152
+ type: 'file',
153
+ mode: stat.mode & 0o777,
154
+ size: contents.byteLength,
155
+ checksum: sha256(contents),
156
+ });
157
+ return;
158
+ }
159
+ const error = new Error(`Unsupported filesystem entry in fleet transaction: ${normalizedPath}`);
160
+ error.code = 'OPENPRD_FLEET_UNSUPPORTED_ENTRY';
161
+ throw error;
162
+ }
163
+ for (const scope of await managedScopesForRoot(projectRoot)) await visit(path.join(projectRoot, scope), scope);
164
+ entries.sort((left, right) => left.path.localeCompare(right.path) || left.type.localeCompare(right.type));
165
+ return {
166
+ algorithm: CHECKSUM_ALGORITHM,
167
+ checksum: sha256(JSON.stringify(entries)),
168
+ fileCount: entries.filter((entry) => entry.type === 'file').length,
169
+ directoryCount: entries.filter((entry) => entry.type === 'directory').length,
170
+ entries,
171
+ };
172
+ }
173
+
174
+ async function copyScope(sourceRoot, targetRoot, scope) {
175
+ const sourcePath = path.join(sourceRoot, scope);
176
+ const sourceStat = await fs.lstat(sourcePath).catch((error) => {
177
+ if (error.code === 'ENOENT') return null;
178
+ throw error;
179
+ });
180
+ if (!sourceStat) return;
181
+ const targetPath = path.join(targetRoot, scope);
182
+ await fs.mkdir(path.dirname(targetPath), { recursive: true });
183
+ await fs.cp(sourcePath, targetPath, {
184
+ recursive: sourceStat.isDirectory(),
185
+ force: true,
186
+ errorOnExist: false,
187
+ preserveTimestamps: true,
188
+ verbatimSymlinks: true,
189
+ });
190
+ }
191
+
192
+ async function copyManagedScopes(sourceRoot, targetRoot) {
193
+ await fs.mkdir(targetRoot, { recursive: true });
194
+ for (const scope of await managedScopesForRoot(sourceRoot)) await copyScope(sourceRoot, targetRoot, scope);
195
+ }
196
+
197
+ async function createPreimageBackup(plan) {
198
+ const payloadRoot = path.join(plan.backup.path, 'preimage');
199
+ await fs.mkdir(plan.backup.path, { recursive: true, mode: 0o700 });
200
+ await fs.chmod(plan.backup.path, 0o700);
201
+ await copyManagedScopes(plan.projectRoot, payloadRoot);
202
+ const backupSnapshot = await snapshotManagedState(payloadRoot);
203
+ if (backupSnapshot.checksum !== plan.preimage.checksum) {
204
+ const error = new Error(`Preimage backup checksum mismatch: expected ${plan.preimage.checksum}, found ${backupSnapshot.checksum}`);
205
+ error.code = 'OPENPRD_FLEET_BACKUP_CHECKSUM_MISMATCH';
206
+ throw error;
207
+ }
208
+ const manifest = {
209
+ schema: FLEET_TRANSACTION_SCHEMA,
210
+ version: FLEET_TRANSACTION_VERSION,
211
+ transactionId: plan.transactionId,
212
+ projectRoot: plan.projectRoot,
213
+ action: plan.action,
214
+ versions: plan.versions,
215
+ checksum: plan.checksum,
216
+ preimage: plan.preimage,
217
+ createdAt: new Date().toISOString(),
218
+ };
219
+ await atomicWriteJson(plan.backup.manifestPath, manifest);
220
+ plan.backup.status = 'verified';
221
+ plan.backup.checksum = backupSnapshot.checksum;
222
+ return manifest;
223
+ }
224
+
225
+ async function removeManagedScopes(projectRoot) {
226
+ const scopes = await managedScopesForRoot(projectRoot);
227
+ for (const scope of scopes.sort((left, right) => right.length - left.length)) {
228
+ await fs.rm(path.join(projectRoot, scope), { recursive: true, force: true });
229
+ }
230
+ }
231
+
232
+ async function restorePreimageBackup(plan, reason) {
233
+ const payloadRoot = path.join(plan.backup.path, 'preimage');
234
+ plan.rollback.attempted = true;
235
+ plan.rollback.reason = reason;
236
+ try {
237
+ await removeManagedScopes(plan.projectRoot);
238
+ await copyManagedScopes(payloadRoot, plan.projectRoot);
239
+ const restored = await snapshotManagedState(plan.projectRoot);
240
+ plan.rollback.checksum = restored.checksum;
241
+ plan.rollback.ok = restored.checksum === plan.preimage.checksum;
242
+ if (!plan.rollback.ok) {
243
+ throw new Error(`Rollback checksum mismatch: expected ${plan.preimage.checksum}, found ${restored.checksum}`);
244
+ }
245
+ return restored;
246
+ } catch (error) {
247
+ plan.rollback.ok = false;
248
+ plan.rollback.error = error.message;
249
+ throw error;
250
+ }
251
+ }
252
+
253
+ async function assertNoSymlinkAncestor(projectRoot, relativePath) {
254
+ const resolvedRoot = path.resolve(projectRoot);
255
+ const target = path.resolve(projectRoot, relativePath);
256
+ const relation = path.relative(resolvedRoot, target);
257
+ if (relation.startsWith('..') || path.isAbsolute(relation)) {
258
+ const error = new Error(`Fleet transaction path escapes project root: ${relativePath}`);
259
+ error.code = 'OPENPRD_FLEET_PATH_ESCAPE';
260
+ throw error;
261
+ }
262
+ const parts = relation.split(path.sep).filter(Boolean);
263
+ let cursor = resolvedRoot;
264
+ for (const part of parts.slice(0, -1)) {
265
+ cursor = path.join(cursor, part);
266
+ const stat = await fs.lstat(cursor).catch((error) => {
267
+ if (error.code === 'ENOENT') return null;
268
+ throw error;
269
+ });
270
+ if (stat?.isSymbolicLink()) {
271
+ const error = new Error(`Symbolic link ancestor appeared during fleet transaction: ${path.relative(projectRoot, cursor)}`);
272
+ error.code = 'OPENPRD_FLEET_SYMLINK_ANCESTOR';
273
+ throw error;
274
+ }
275
+ }
276
+ }
277
+
278
+ async function atomicReplaceFileFromSource(sourcePath, targetPath, mode) {
279
+ await fs.mkdir(path.dirname(targetPath), { recursive: true });
280
+ const temporaryPath = path.join(path.dirname(targetPath), `.${path.basename(targetPath)}.${process.pid}.${randomUUID()}.fleet.tmp`);
281
+ try {
282
+ await fs.copyFile(sourcePath, temporaryPath);
283
+ await fs.chmod(temporaryPath, mode ?? 0o600);
284
+ const handle = await fs.open(temporaryPath, 'r');
285
+ try {
286
+ await handle.sync();
287
+ } finally {
288
+ await handle.close();
289
+ }
290
+ await fs.rename(temporaryPath, targetPath);
291
+ } catch (error) {
292
+ await fs.rm(temporaryPath, { force: true }).catch(() => {});
293
+ throw error;
294
+ }
295
+ }
296
+
297
+ function entryMap(snapshot) {
298
+ return new Map(snapshot.entries.map((entry) => [entry.path, entry]));
299
+ }
300
+
301
+ async function applyStagedState(plan, stagingRoot, stagedSnapshot, options = {}) {
302
+ const currentSnapshot = await snapshotManagedState(plan.projectRoot);
303
+ if (currentSnapshot.checksum !== plan.preimage.checksum) {
304
+ const error = new Error(`Project changed after planning: expected ${plan.preimage.checksum}, found ${currentSnapshot.checksum}`);
305
+ error.code = 'OPENPRD_FLEET_PREIMAGE_CONFLICT';
306
+ throw error;
307
+ }
308
+ if (options.beforeApply) await options.beforeApply({ plan, stagingRoot, stagedSnapshot });
309
+ const compareSnapshot = await snapshotManagedState(plan.projectRoot);
310
+ if (compareSnapshot.checksum !== plan.preimage.checksum) {
311
+ const error = new Error(`Project changed before atomic apply: expected ${plan.preimage.checksum}, found ${compareSnapshot.checksum}`);
312
+ error.code = 'OPENPRD_FLEET_PREIMAGE_CONFLICT';
313
+ throw error;
314
+ }
315
+
316
+ const beforeByPath = entryMap(plan.preimage);
317
+ const afterByPath = entryMap(stagedSnapshot);
318
+ const typeChanges = stagedSnapshot.entries
319
+ .filter((entry) => beforeByPath.has(entry.path) && beforeByPath.get(entry.path).type !== entry.type)
320
+ .sort((left, right) => right.path.split('/').length - left.path.split('/').length);
321
+ for (const entry of typeChanges) {
322
+ await assertNoSymlinkAncestor(plan.projectRoot, entry.path);
323
+ await fs.rm(path.join(plan.projectRoot, entry.path), { recursive: true, force: true });
324
+ }
325
+ const directoriesToCreate = stagedSnapshot.entries
326
+ .filter((entry) => entry.type === 'directory' && beforeByPath.get(entry.path)?.type !== 'directory')
327
+ .sort((left, right) => left.path.split('/').length - right.path.split('/').length);
328
+ for (const entry of directoriesToCreate) {
329
+ await assertNoSymlinkAncestor(plan.projectRoot, entry.path);
330
+ await fs.mkdir(path.join(plan.projectRoot, entry.path), { recursive: true, mode: entry.mode });
331
+ }
332
+ const directoriesToChmod = stagedSnapshot.entries.filter((entry) => {
333
+ const before = beforeByPath.get(entry.path);
334
+ return entry.type === 'directory' && before?.type === 'directory' && before.mode !== entry.mode;
335
+ });
336
+ for (const entry of directoriesToChmod) {
337
+ await fs.chmod(path.join(plan.projectRoot, entry.path), entry.mode);
338
+ }
339
+ const filesToWrite = stagedSnapshot.entries.filter((entry) => {
340
+ if (entry.type !== 'file') return false;
341
+ const before = beforeByPath.get(entry.path);
342
+ return !before || before.type !== 'file' || before.checksum !== entry.checksum || before.mode !== entry.mode;
343
+ });
344
+ for (const entry of filesToWrite) {
345
+ await assertNoSymlinkAncestor(plan.projectRoot, entry.path);
346
+ await atomicReplaceFileFromSource(path.join(stagingRoot, entry.path), path.join(plan.projectRoot, entry.path), entry.mode);
347
+ }
348
+ const filesToRemove = plan.preimage.entries
349
+ .filter((entry) => entry.type === 'file' && !afterByPath.has(entry.path))
350
+ .sort((left, right) => right.path.split('/').length - left.path.split('/').length);
351
+ for (const entry of filesToRemove) {
352
+ await assertNoSymlinkAncestor(plan.projectRoot, entry.path);
353
+ await fs.rm(path.join(plan.projectRoot, entry.path), { force: true });
354
+ }
355
+ const directoriesToRemove = plan.preimage.entries
356
+ .filter((entry) => entry.type === 'directory' && !afterByPath.has(entry.path))
357
+ .sort((left, right) => right.path.split('/').length - left.path.split('/').length);
358
+ for (const entry of directoriesToRemove) {
359
+ await fs.rmdir(path.join(plan.projectRoot, entry.path)).catch((error) => {
360
+ if (error.code !== 'ENOENT' && error.code !== 'ENOTEMPTY') throw error;
361
+ });
362
+ }
363
+ const appliedSnapshot = await snapshotManagedState(plan.projectRoot);
364
+ if (appliedSnapshot.checksum !== stagedSnapshot.checksum) {
365
+ const error = new Error(`Atomic apply checksum mismatch: expected ${stagedSnapshot.checksum}, found ${appliedSnapshot.checksum}`);
366
+ error.code = 'OPENPRD_FLEET_APPLY_CHECKSUM_MISMATCH';
367
+ throw error;
368
+ }
369
+ return appliedSnapshot;
370
+ }
371
+
372
+ async function relocateStagingPaths(stagingRoot, stagingHome, projectRoot, openprdHome) {
373
+ const replacements = [[stagingRoot, projectRoot], [stagingHome, openprdHome]]
374
+ .filter(([from, to]) => from && to && from !== to);
375
+ if (replacements.length === 0) return;
376
+ const snapshot = await snapshotManagedState(stagingRoot);
377
+ for (const entry of snapshot.entries.filter((item) => item.type === 'file')) {
378
+ const filePath = path.join(stagingRoot, entry.path);
379
+ const buffer = await fs.readFile(filePath);
380
+ if (buffer.includes(0)) continue;
381
+ const current = buffer.toString('utf8');
382
+ let next = current;
383
+ for (const [from, to] of replacements) next = next.split(from).join(to);
384
+ if (next !== current) await atomicWriteText(filePath, next, { mode: entry.mode });
385
+ }
386
+ const manifestPath = path.join(stagingRoot, '.openprd', 'harness', 'install-manifest.json');
387
+ const manifest = await fs.readFile(manifestPath, 'utf8').then(JSON.parse).catch(() => null);
388
+ if (!manifest?.managedFiles) return;
389
+ let changed = false;
390
+ for (const managedFile of manifest.managedFiles) {
391
+ if (!managedFile?.fileChecksum || managedFile.scope === 'user') continue;
392
+ const text = await fs.readFile(path.join(stagingRoot, managedFile.path), 'utf8').catch(() => null);
393
+ if (text === null) continue;
394
+ const nextChecksum = sha256(text).slice(0, 16);
395
+ if (managedFile.fileChecksum !== nextChecksum) {
396
+ managedFile.fileChecksum = nextChecksum;
397
+ changed = true;
398
+ }
399
+ }
400
+ if (changed) await atomicWriteJson(manifestPath, manifest);
401
+ }
402
+
403
+ export {
404
+ CHECKSUM_ALGORITHM,
405
+ FLEET_MANAGED_SCOPES,
406
+ FLEET_TRANSACTION_SCHEMA,
407
+ FLEET_TRANSACTION_VERSION,
408
+ applyStagedState,
409
+ collectSymlinkHazards,
410
+ copyManagedScopes,
411
+ createPreimageBackup,
412
+ relocateStagingPaths,
413
+ restorePreimageBackup,
414
+ sha256,
415
+ snapshotManagedState,
416
+ };
@@ -544,15 +544,26 @@ export function lineSvg(lines, {
544
544
  )).join('');
545
545
  }
546
546
 
547
+ export const VISUAL_BOARD_PALETTE = Object.freeze({
548
+ canvas: '#f4efe6',
549
+ surface: '#fffdf8',
550
+ ink: '#292722',
551
+ muted: '#6f6a61',
552
+ accent: '#c65f3f',
553
+ success: '#39795c',
554
+ border: '#d7cfc2',
555
+ });
556
+
547
557
  export function labelSvg(label, options = {}) {
548
558
  const fontSize = options.fontSize ?? 22;
549
559
  const height = options.height ?? 46;
550
560
  const paddingX = options.paddingX ?? 21;
551
561
  const radius = options.radius ?? 14;
552
- const bg = options.background ?? '#111827';
553
- const bgOpacity = options.backgroundOpacity ?? 0.82;
554
- const stroke = options.stroke ?? '#ffffff';
555
- const strokeOpacity = options.strokeOpacity ?? 0.22;
562
+ const bg = options.background ?? VISUAL_BOARD_PALETTE.surface;
563
+ const bgOpacity = options.backgroundOpacity ?? 0.94;
564
+ const stroke = options.stroke ?? VISUAL_BOARD_PALETTE.border;
565
+ const strokeOpacity = options.strokeOpacity ?? 1;
566
+ const textColor = options.textColor ?? VISUAL_BOARD_PALETTE.ink;
556
567
  const maxWidth = Number.isFinite(Number(options.maxWidth)) ? Math.max(1, Number(options.maxWidth)) : Infinity;
557
568
  const minWidth = Math.min(options.minWidth ?? 126, maxWidth);
558
569
  const availableChars = Number.isFinite(maxWidth)
@@ -565,10 +576,34 @@ export function labelSvg(label, options = {}) {
565
576
  <svg width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" xmlns="http://www.w3.org/2000/svg">
566
577
  <rect x="0" y="0" width="${width}" height="${height}" rx="${radius}" fill="${bg}" fill-opacity="${bgOpacity}"/>
567
578
  <rect x="0.75" y="0.75" width="${width - 1.5}" height="${height - 1.5}" rx="${Math.max(radius - 0.75, 1)}" fill="none" stroke="${stroke}" stroke-opacity="${strokeOpacity}" stroke-width="1.5"/>
568
- <text x="${paddingX}" y="${Math.round(height * 0.64)}" fill="#ffffff" font-size="${fontSize}" font-weight="700" font-family="PingFang SC, Noto Sans CJK SC, Microsoft YaHei, Arial Unicode MS, sans-serif">${text}</text>
579
+ <text x="${paddingX}" y="${Math.round(height * 0.64)}" fill="${textColor}" font-size="${fontSize}" font-weight="700" font-family="PingFang SC, Noto Sans CJK SC, Microsoft YaHei, Arial Unicode MS, sans-serif">${text}</text>
569
580
  </svg>`);
570
581
  }
571
582
 
583
+ function compactEvidenceHeaderSvg(width, title, verdict = '') {
584
+ const contentWidth = Math.max(Number(width) || 0, 1);
585
+ const hasVerdict = Boolean(String(verdict).trim());
586
+ const verdictWidth = hasVerdict ? Math.min(132, Math.max(64, charCount(verdict) * 18 + 28)) : 0;
587
+ const titleWidth = Math.max(120, contentWidth - verdictWidth - (hasVerdict ? 16 : 0));
588
+ const titleLines = wrapText(title, Math.max(8, Math.floor(titleWidth / 18)));
589
+ const height = Math.max(48, titleLines.length * 28 + 12);
590
+ return {
591
+ height,
592
+ input: Buffer.from(`
593
+ <svg width="${contentWidth}" height="${height}" viewBox="0 0 ${contentWidth} ${height}" xmlns="http://www.w3.org/2000/svg">
594
+ ${lineSvg(titleLines, {
595
+ x: 0,
596
+ y: 28,
597
+ lineHeight: 28,
598
+ fontSize: 23,
599
+ fill: VISUAL_BOARD_PALETTE.ink,
600
+ fontWeight: 800,
601
+ })}
602
+ ${hasVerdict ? `<rect x="${contentWidth - verdictWidth}" y="6" width="${verdictWidth}" height="32" rx="16" fill="${VISUAL_BOARD_PALETTE.success}" fill-opacity="0.12"/><text x="${contentWidth - verdictWidth + 14}" y="28" fill="${VISUAL_BOARD_PALETTE.success}" font-size="16" font-weight="800" font-family="PingFang SC, Noto Sans CJK SC, Microsoft YaHei, Arial Unicode MS, sans-serif">${escapeXml(verdict)}</text>` : ''}
603
+ </svg>`),
604
+ };
605
+ }
606
+
572
607
  export function titleBlockSvg(width, title, subtitle, eyebrow = null) {
573
608
  const contentWidth = Math.max(Number(width) || 0, 1);
574
609
  const titleLines = wrapText(title, Math.max(12, Math.floor((contentWidth - 48) / 18)));
@@ -582,7 +617,7 @@ export function titleBlockSvg(width, title, subtitle, eyebrow = null) {
582
617
  y: y,
583
618
  lineHeight: 20,
584
619
  fontSize: 16,
585
- fill: '#93c5fd',
620
+ fill: VISUAL_BOARD_PALETTE.accent,
586
621
  fontWeight: 700,
587
622
  }));
588
623
  y += eyebrowLines.length * 20 + 14;
@@ -592,7 +627,7 @@ export function titleBlockSvg(width, title, subtitle, eyebrow = null) {
592
627
  y: y + 30,
593
628
  lineHeight: 34,
594
629
  fontSize: 30,
595
- fill: '#f8fafc',
630
+ fill: VISUAL_BOARD_PALETTE.ink,
596
631
  fontWeight: 800,
597
632
  }));
598
633
  y += titleLines.length * 34 + 10;
@@ -602,7 +637,7 @@ export function titleBlockSvg(width, title, subtitle, eyebrow = null) {
602
637
  y: y + 24,
603
638
  lineHeight: 24,
604
639
  fontSize: 18,
605
- fill: '#cbd5e1',
640
+ fill: VISUAL_BOARD_PALETTE.muted,
606
641
  fontWeight: 500,
607
642
  }));
608
643
  y += subtitleLines.length * 24 + 6;
@@ -626,14 +661,14 @@ export function sectionHeaderSvg(width, index, label, reason = '') {
626
661
  height,
627
662
  input: Buffer.from(`
628
663
  <svg width="${contentWidth}" height="${height}" viewBox="0 0 ${contentWidth} ${height}" xmlns="http://www.w3.org/2000/svg">
629
- <rect x="0" y="0" width="${contentWidth}" height="${height}" rx="18" fill="#0f172a" fill-opacity="0.92"/>
630
- <rect x="0.75" y="0.75" width="${contentWidth - 1.5}" height="${height - 1.5}" rx="17.25" fill="none" stroke="#475569" stroke-opacity="0.45" stroke-width="1.5"/>
664
+ <rect x="0" y="0" width="${contentWidth}" height="${height}" rx="18" fill="${VISUAL_BOARD_PALETTE.surface}"/>
665
+ <rect x="0.75" y="0.75" width="${contentWidth - 1.5}" height="${height - 1.5}" rx="17.25" fill="none" stroke="${VISUAL_BOARD_PALETTE.border}" stroke-width="1.5"/>
631
666
  ${lineSvg(titleLines, {
632
667
  x: 20,
633
668
  y: 30,
634
669
  lineHeight: 28,
635
670
  fontSize: 24,
636
- fill: '#f8fafc',
671
+ fill: VISUAL_BOARD_PALETTE.ink,
637
672
  fontWeight: 800,
638
673
  })}
639
674
  ${reasonLines.length > 0 ? lineSvg(reasonLines, {
@@ -641,7 +676,7 @@ export function sectionHeaderSvg(width, index, label, reason = '') {
641
676
  y: 30 + titleLines.length * 28 + 10,
642
677
  lineHeight: 22,
643
678
  fontSize: 16,
644
- fill: '#cbd5e1',
679
+ fill: VISUAL_BOARD_PALETTE.muted,
645
680
  fontWeight: 500,
646
681
  }) : ''}
647
682
  </svg>`),
@@ -671,7 +706,7 @@ export function metricsSvg(width, metrics = [], notes = null, locale = OPENPRD_F
671
706
  y: 20,
672
707
  lineHeight: 22,
673
708
  fontSize: 16,
674
- fill: '#cbd5e1',
709
+ fill: VISUAL_BOARD_PALETTE.muted,
675
710
  fontWeight: 500,
676
711
  })}
677
712
  </svg>`),
@@ -1040,12 +1075,15 @@ export async function renderParallelCard(projectRoot, item, index, options = {})
1040
1075
  const mediaList = normalizeMediaList(projectRoot, item.media, locale);
1041
1076
  const metrics = normalizeMetricList(item.metrics ?? item.metricMap, locale);
1042
1077
  const notes = String(item.notes ?? item.note ?? '').trim();
1043
- const header = titleBlockSvg(
1044
- contentWidth,
1045
- `${index + 1}. ${title}`,
1046
- subtitle,
1047
- verdict ? `${copy.resultPrefix}${copy.separator}${verdict}` : (options.eyebrow ?? copy.parallelEyebrow),
1048
- );
1078
+ const compactEvidence = options.compactEvidence === true;
1079
+ const header = compactEvidence
1080
+ ? compactEvidenceHeaderSvg(contentWidth, title, verdict)
1081
+ : titleBlockSvg(
1082
+ contentWidth,
1083
+ `${index + 1}. ${title}`,
1084
+ subtitle,
1085
+ verdict ? `${copy.resultPrefix}${copy.separator}${verdict}` : (options.eyebrow ?? copy.parallelEyebrow),
1086
+ );
1049
1087
  const composites = [
1050
1088
  { input: header.input, left: 18, top: 18 },
1051
1089
  ];
@@ -1053,7 +1091,7 @@ export async function renderParallelCard(projectRoot, item, index, options = {})
1053
1091
  let currentTop = 18 + header.height + 12;
1054
1092
 
1055
1093
  for (const media of mediaList) {
1056
- const label = labelSvg(media.label, {
1094
+ const label = compactEvidence ? null : labelSvg(media.label, {
1057
1095
  fontSize: 16,
1058
1096
  height: 36,
1059
1097
  minWidth: 96,
@@ -1062,10 +1100,11 @@ export async function renderParallelCard(projectRoot, item, index, options = {})
1062
1100
  radius: 12,
1063
1101
  });
1064
1102
  const mediaPanel = await resizePanel(media.path, contentWidth);
1065
- composites.push(
1066
- { input: label, left: 18, top: currentTop },
1067
- { input: mediaPanel.input, left: 18 + Math.round((contentWidth - mediaPanel.width) / 2), top: currentTop + 42 },
1068
- );
1103
+ const mediaTop = compactEvidence ? currentTop : currentTop + 42;
1104
+ if (label) {
1105
+ composites.push({ input: label, left: 18, top: currentTop });
1106
+ }
1107
+ composites.push({ input: mediaPanel.input, left: 18 + Math.round((contentWidth - mediaPanel.width) / 2), top: mediaTop });
1069
1108
  renderedMedia.push({
1070
1109
  path: toWorkspacePath(projectRoot, media.path),
1071
1110
  label: media.label,
@@ -1075,7 +1114,7 @@ export async function renderParallelCard(projectRoot, item, index, options = {})
1075
1114
  height: mediaPanel.height,
1076
1115
  },
1077
1116
  });
1078
- currentTop += 42 + mediaPanel.height + 16;
1117
+ currentTop = mediaTop + mediaPanel.height + (compactEvidence ? 12 : 16);
1079
1118
  }
1080
1119
 
1081
1120
  const metricsBlock = metricsSvg(contentWidth, metrics, notes, locale);
@@ -1090,14 +1129,14 @@ export async function renderParallelCard(projectRoot, item, index, options = {})
1090
1129
  width: cardWidth,
1091
1130
  height: cardHeight,
1092
1131
  channels: 3,
1093
- background: '#0f172a',
1132
+ background: VISUAL_BOARD_PALETTE.surface,
1094
1133
  },
1095
1134
  }).composite([
1096
1135
  ...composites,
1097
1136
  {
1098
1137
  input: Buffer.from(`
1099
1138
  <svg width="${cardWidth}" height="${cardHeight}" viewBox="0 0 ${cardWidth} ${cardHeight}" xmlns="http://www.w3.org/2000/svg">
1100
- <rect x="0.75" y="0.75" width="${cardWidth - 1.5}" height="${cardHeight - 1.5}" rx="22" fill="none" stroke="#334155" stroke-width="1.5"/>
1139
+ <rect x="0.75" y="0.75" width="${cardWidth - 1.5}" height="${cardHeight - 1.5}" rx="18" fill="none" stroke="${VISUAL_BOARD_PALETTE.border}" stroke-width="1.5"/>
1101
1140
  </svg>`),
1102
1141
  left: 0,
1103
1142
  top: 0,