@openprd/cli 0.1.19 → 0.1.22
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.openprd/changes/openprd-control-plane-v020/.openprd.yaml +2 -0
- package/.openprd/changes/openprd-control-plane-v020/design.md +78 -0
- package/.openprd/changes/openprd-control-plane-v020/proposal.md +54 -0
- package/.openprd/changes/openprd-control-plane-v020/specs/agent-requirements/spec.md +16 -0
- package/.openprd/changes/openprd-control-plane-v020/task-events.jsonl +27 -0
- package/.openprd/changes/openprd-control-plane-v020/tasks-002.md +35 -0
- package/.openprd/changes/openprd-control-plane-v020/tasks.md +427 -0
- package/.openprd/changes/remove-session-tracking-humane-approval/.openprd.yaml +2 -0
- package/.openprd/changes/remove-session-tracking-humane-approval/design.md +52 -0
- package/.openprd/changes/remove-session-tracking-humane-approval/proposal.md +35 -0
- package/.openprd/changes/remove-session-tracking-humane-approval/specs/agent-requirements/spec.md +16 -0
- package/.openprd/changes/remove-session-tracking-humane-approval/task-events.jsonl +1 -0
- package/.openprd/changes/remove-session-tracking-humane-approval/tasks.md +170 -0
- package/.openprd/design/active/asset-spec.md +19 -14
- package/.openprd/design/active/direction-plan.md +19 -3
- package/.openprd/design/active/facts-sheet.md +16 -7
- package/.openprd/design/active/image-preflight.md +6 -5
- package/.openprd/design/active/review-studio-v020-directions/compare-plan.json +34 -0
- package/.openprd/design/active/review-studio-v020-directions/contact-sheet.jpg +0 -0
- package/.openprd/design/active/review-studio-v020-directions/crops/01.png +0 -0
- package/.openprd/design/active/review-studio-v020-directions/crops/02.png +0 -0
- package/.openprd/design/active/review-studio-v020-directions/crops/03.png +0 -0
- package/.openprd/design/active/review-studio-v020-directions/focus-board.template.json +69 -0
- package/.openprd/design/active/review-studio-v020-directions/parallel-board.template.json +45 -0
- package/.openprd/design/active/review-studio-v020-directions/reference-set.json +143 -0
- package/.openprd/design/active/review-studio-v020-directions/source.png +0 -0
- package/.openprd/design/active/selected-direction.md +23 -9
- package/.openprd/engagements/active/control-plane-architecture.json +203 -0
- package/.openprd/engagements/active/control-plane-intake.json +418 -0
- package/.openprd/engagements/active/prd.md +183 -119
- package/.openprd/engagements/active/review-presentation-v0018.json +176 -0
- package/.openprd/i18n-config.json +12 -0
- package/.openprd/ledger/events.jsonl +6 -0
- package/.openprd/ledger/heads/ebea1a71a9daa566f1c91b53.json +10 -0
- package/.openprd/manifest.json +21 -0
- package/AGENTS.md +7 -6
- package/README.md +31 -29
- package/README_EN.md +36 -39
- package/package.json +1 -1
- package/skills/openprd-frontend-design/SKILL.md +16 -0
- package/skills/openprd-harness/SKILL.md +7 -7
- package/skills/openprd-quality/SKILL.md +1 -1
- package/skills/openprd-requirement-intake/SKILL.md +1 -1
- package/skills/openprd-shared/SKILL.md +6 -6
- package/src/adapters/adapter-spi.js +193 -0
- package/src/adapters/capability-envelope.js +98 -0
- package/src/adapters/event-normalizer.js +55 -0
- package/src/adapters/index.js +4 -0
- package/src/adapters/install-safety.js +249 -0
- package/src/agent-canonical-content.js +4 -2
- package/src/agent-integration.js +169 -46
- package/src/cli/args.js +63 -4
- package/src/cli/gate-print.js +17 -0
- package/src/cli/quality-commands.js +18 -0
- package/src/cli/quality-print.js +10 -0
- package/src/cli/runtime-print.js +24 -0
- package/src/codex-hook-runner-template.mjs +129 -199
- package/src/codex-runtime.js +48 -5
- package/src/context/cache.js +245 -0
- package/src/context/compiler.js +438 -0
- package/src/context/constants.js +30 -0
- package/src/context/index.js +39 -0
- package/src/context/redaction.js +84 -0
- package/src/context/stable.js +69 -0
- package/src/context/telemetry.js +42 -0
- package/src/dev-standards.js +57 -0
- package/src/fleet.js +112 -95
- package/src/gates/index.js +2 -0
- package/src/gates/scoped-gates.js +256 -0
- package/src/gates/store.js +126 -0
- package/src/gates/workspace.js +41 -0
- package/src/html-artifacts.js +725 -28
- package/src/kernel/atomic-store.js +299 -0
- package/src/kernel/event-envelope.js +166 -0
- package/src/kernel/index.js +4 -0
- package/src/kernel/project-ledger.js +467 -0
- package/src/kernel/project-manifest.js +205 -0
- package/src/knowledge-v3/index.js +1 -0
- package/src/knowledge-v3/lifecycle.js +290 -0
- package/src/knowledge.js +14 -7
- package/src/openprd.js +71 -2
- package/src/review-model.js +413 -0
- package/src/review-presentation.js +1 -1
- package/src/run-harness.js +432 -38
- package/src/runtime/cli_runtime_README.md +28 -0
- package/src/runtime/errors.js +66 -0
- package/src/runtime/index.js +44 -0
- package/src/runtime/lane-schema.js +141 -0
- package/src/runtime/lane-store.js +279 -0
- package/src/runtime/task-runtime.js +449 -0
- package/src/runtime/workspace.js +179 -0
- package/src/runtime/write-set.js +206 -0
- package/src/session-binding.js +16 -3
- package/src/session-registry.js +59 -1
- package/src/upgrade/fleet-mutation.js +166 -0
- package/src/upgrade/fleet-transaction.js +398 -0
- package/src/upgrade/transaction-store.js +416 -0
- package/src/visual-compare-core.js +66 -27
- package/src/visual-compare.js +18 -12
- package/src/workspace-core.js +109 -7
- package/src/workspace-registry.js +39 -1
- 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 ??
|
|
553
|
-
const bgOpacity = options.backgroundOpacity ?? 0.
|
|
554
|
-
const stroke = options.stroke ??
|
|
555
|
-
const strokeOpacity = options.strokeOpacity ??
|
|
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="
|
|
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:
|
|
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:
|
|
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:
|
|
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="
|
|
630
|
-
<rect x="0.75" y="0.75" width="${contentWidth - 1.5}" height="${height - 1.5}" rx="17.25" fill="none" stroke="
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
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
|
-
|
|
1066
|
-
|
|
1067
|
-
{ input:
|
|
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
|
|
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:
|
|
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="
|
|
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,
|