@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
package/src/dev-standards.js
CHANGED
|
@@ -87,6 +87,11 @@ const UI_FILE_EXTENSIONS = new Set([
|
|
|
87
87
|
]);
|
|
88
88
|
const VISUAL_REVIEWS_DIR = cjoin('.openprd', 'harness', 'visual-reviews');
|
|
89
89
|
const VISUAL_EVIDENCE_FRESH_MS = 24 * 60 * 60 * 1000;
|
|
90
|
+
const I18N_CONFIG_PATH = cjoin('.openprd', 'i18n-config.json');
|
|
91
|
+
const I18N_TEXT_FILE_EXTENSIONS = new Set([
|
|
92
|
+
'.html', '.jsx', '.swift', '.tsx', '.vue', '.wxml', '.svelte',
|
|
93
|
+
'.kt', '.java', '.dart', '.xml',
|
|
94
|
+
]);
|
|
90
95
|
|
|
91
96
|
function normalizePathForReport(value) {
|
|
92
97
|
return String(value ?? '').split(path.sep).join('/');
|
|
@@ -461,12 +466,42 @@ async function buildVisualEvidenceReminder(projectRoot, files) {
|
|
|
461
466
|
suggestedCommands: [
|
|
462
467
|
'openprd visual-compare . --before <改前截图> --after <改后截图>',
|
|
463
468
|
'openprd visual-compare . --board <alignment-board.json>(同构列表/卡片/网格/表格对齐,含容器轨道与内容槽位)',
|
|
469
|
+
'网格与基线只进入 alignment-board;普通结果画布不得自动叠加',
|
|
464
470
|
'openprd visual-compare . --board <centering-board.json>(单素材内部居中/视觉重心)',
|
|
465
471
|
],
|
|
466
472
|
chatEmbedHint: '生成后把证据图用 `` 直接嵌入最终回复,让用户在对话流里看到对齐效果。',
|
|
467
473
|
};
|
|
468
474
|
}
|
|
469
475
|
|
|
476
|
+
async function buildI18nReminder(projectRoot, files) {
|
|
477
|
+
const textFiles = files
|
|
478
|
+
.filter((f) => f.status !== 'error' && I18N_TEXT_FILE_EXTENSIONS.has(path.extname(f.path).toLowerCase()))
|
|
479
|
+
.map((f) => f.path);
|
|
480
|
+
if (textFiles.length === 0) {
|
|
481
|
+
return { required: false, textFiles: [] };
|
|
482
|
+
}
|
|
483
|
+
const configPath = cjoin(projectRoot, I18N_CONFIG_PATH);
|
|
484
|
+
const config = await readJson(configPath).catch(() => null);
|
|
485
|
+
if (config && config.enforceOnDevCheck === false) {
|
|
486
|
+
return { required: false, textFiles, reason: 'i18n 检查已被用户关闭 (enforceOnDevCheck: false)。' };
|
|
487
|
+
}
|
|
488
|
+
const defaultLang = config?.defaultLanguage ?? 'zh-Hans';
|
|
489
|
+
const supported = Array.isArray(config?.supportedLanguages) ? config.supportedLanguages : ['zh-Hans', 'en'];
|
|
490
|
+
const platforms = Array.isArray(config?.platforms) ? config.platforms : [];
|
|
491
|
+
return {
|
|
492
|
+
required: true,
|
|
493
|
+
textFiles,
|
|
494
|
+
defaultLanguage: defaultLang,
|
|
495
|
+
supportedLanguages: supported,
|
|
496
|
+
platforms,
|
|
497
|
+
configExists: config !== null,
|
|
498
|
+
reason: config
|
|
499
|
+
? `本轮触达 ${textFiles.length} 个可能包含用户文案的文件;请检查新增/修改的文案是否已使用 i18n 框架,基准语言 ${defaultLang},需支持 ${supported.join(', ')}。`
|
|
500
|
+
: `本轮触达 ${textFiles.length} 个可能包含用户文案的文件,但项目尚未配置 .openprd/i18n-config.json。如需启用多语言检查,可运行 openprd i18n-init . 或手动创建配置文件。`,
|
|
501
|
+
guideline: 'docs/basic/i18n-guidelines.md',
|
|
502
|
+
};
|
|
503
|
+
}
|
|
504
|
+
|
|
470
505
|
async function analyzeDevelopmentFile(projectRoot, targetPath, lineConfig) {
|
|
471
506
|
const absolutePath = path.isAbsolute(targetPath)
|
|
472
507
|
? path.resolve(targetPath)
|
|
@@ -621,6 +656,7 @@ export async function checkDevelopmentStandardsWorkspace(projectRoot, options =
|
|
|
621
656
|
files,
|
|
622
657
|
wrapUp: buildDevCheckWrapUp(files, lineConfig),
|
|
623
658
|
visualEvidence: await buildVisualEvidenceReminder(projectRoot, files),
|
|
659
|
+
i18nReminder: await buildI18nReminder(projectRoot, files),
|
|
624
660
|
knowledgeReview,
|
|
625
661
|
errors,
|
|
626
662
|
};
|
|
@@ -658,3 +694,24 @@ export async function setDevCheckAutoRefactor(projectRoot, enabled) {
|
|
|
658
694
|
},
|
|
659
695
|
};
|
|
660
696
|
}
|
|
697
|
+
|
|
698
|
+
export async function initI18nConfig(projectRoot, options = {}) {
|
|
699
|
+
const configPath = cjoin(projectRoot, I18N_CONFIG_PATH);
|
|
700
|
+
const existing = await readJson(configPath).catch(() => null);
|
|
701
|
+
if (existing) {
|
|
702
|
+
return { created: false, configPath: normalizePathForReport(I18N_CONFIG_PATH), config: existing };
|
|
703
|
+
}
|
|
704
|
+
const defaultLang = options.defaultLanguage ?? 'zh-Hans';
|
|
705
|
+
const config = {
|
|
706
|
+
version: 1,
|
|
707
|
+
defaultLanguage: defaultLang,
|
|
708
|
+
supportedLanguages: [defaultLang, ...(defaultLang === 'en' ? ['zh-Hans'] : ['en'])],
|
|
709
|
+
enforceOnDevCheck: true,
|
|
710
|
+
platforms: [],
|
|
711
|
+
translationStrategy: 'key-value-file',
|
|
712
|
+
notes: '用户可随时修改此文件来调整默认语言、支持语种和检查策略',
|
|
713
|
+
};
|
|
714
|
+
await fs.mkdir(path.dirname(configPath), { recursive: true });
|
|
715
|
+
await fs.writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, 'utf8');
|
|
716
|
+
return { created: true, configPath: normalizePathForReport(I18N_CONFIG_PATH), config };
|
|
717
|
+
}
|
package/src/fleet.js
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import fs from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
-
import { cjoin, exists
|
|
3
|
+
import { cjoin, exists } from './fs-utils.js';
|
|
4
|
+
import { atomicWriteJson } from './kernel/index.js';
|
|
4
5
|
import { hasLegacyOpenPrdArtifacts } from './openspec/migration.js';
|
|
5
6
|
import { SOURCE_INVENTORY_IGNORE_DIRS } from './source-inventory.js';
|
|
6
7
|
import { timestamp } from './time.js';
|
|
8
|
+
import { createFleetTransactionContext, executeFleetProjectTransaction, planFleetProjectTransaction } from './upgrade/fleet-transaction.js';
|
|
9
|
+
import { applySuccessfulFleetMutation, runStagedFleetMutation } from './upgrade/fleet-mutation.js';
|
|
7
10
|
import { readWorkspaceRegistry, upsertWorkspaceRegistryEntry } from './workspace-registry.js';
|
|
8
11
|
|
|
9
12
|
const FLEET_DEFAULT_MAX_DEPTH = 4;
|
|
@@ -35,6 +38,7 @@ const FLEET_PROJECT_MARKERS = [
|
|
|
35
38
|
];
|
|
36
39
|
const FLEET_AGENT_MARKERS = ['.codex', '.claude', '.cursor', 'AGENTS.md', 'CLAUDE.md'];
|
|
37
40
|
const FLEET_LEGACY_OPENPRD_MARKER = 'openprd-legacy-artifacts';
|
|
41
|
+
const FLEET_TRANSACTIONAL_ACTIONS = new Set(['update', 'setup', 'backfill-work-units']);
|
|
38
42
|
|
|
39
43
|
function normalizeCsvList(value) {
|
|
40
44
|
if (!value) {
|
|
@@ -110,7 +114,7 @@ async function detectFleetMarkers(projectPath) {
|
|
|
110
114
|
const entries = await fs.readdir(projectPath, { withFileTypes: true }).catch(() => []);
|
|
111
115
|
const names = new Set(entries.map((entry) => entry.name));
|
|
112
116
|
const markers = FLEET_PROJECT_MARKERS.filter((marker) => names.has(marker));
|
|
113
|
-
if (
|
|
117
|
+
if (await hasLegacyOpenPrdArtifacts(projectPath)) {
|
|
114
118
|
markers.push(FLEET_LEGACY_OPENPRD_MARKER);
|
|
115
119
|
}
|
|
116
120
|
return markers;
|
|
@@ -121,6 +125,10 @@ async function scanFleetProjects(rootPath, options = {}) {
|
|
|
121
125
|
const maxDepth = parsePositiveInteger(options.maxDepth, FLEET_DEFAULT_MAX_DEPTH);
|
|
122
126
|
const include = normalizeCsvList(options.include);
|
|
123
127
|
const exclude = normalizeCsvList(options.exclude);
|
|
128
|
+
const infrastructureRoots = [options.openprdHome, options.backupRoot]
|
|
129
|
+
.filter(Boolean)
|
|
130
|
+
.map((candidate) => path.resolve(candidate))
|
|
131
|
+
.filter((candidate) => pathWithinRoot(root, candidate));
|
|
124
132
|
const projects = [];
|
|
125
133
|
const seenRealPaths = new Set();
|
|
126
134
|
|
|
@@ -128,6 +136,9 @@ async function scanFleetProjects(rootPath, options = {}) {
|
|
|
128
136
|
if (depth > maxDepth) {
|
|
129
137
|
return;
|
|
130
138
|
}
|
|
139
|
+
if (currentPath !== root && infrastructureRoots.some((candidate) => pathWithinRoot(candidate, currentPath))) {
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
131
142
|
|
|
132
143
|
const name = path.basename(currentPath);
|
|
133
144
|
if (depth > 0 && FLEET_IGNORE_DIRS.has(name)) {
|
|
@@ -141,6 +152,7 @@ async function scanFleetProjects(rootPath, options = {}) {
|
|
|
141
152
|
seenRealPaths.add(realPath);
|
|
142
153
|
|
|
143
154
|
const markers = await detectFleetMarkers(currentPath);
|
|
155
|
+
const containsLegacyArtifactTree = markers.includes(FLEET_LEGACY_OPENPRD_MARKER);
|
|
144
156
|
if (markers.length > 0) {
|
|
145
157
|
const category = classifyFleetMarkers(markers);
|
|
146
158
|
const included = projectPathMatches(root, currentPath, include);
|
|
@@ -171,6 +183,11 @@ async function scanFleetProjects(rootPath, options = {}) {
|
|
|
171
183
|
if (FLEET_IGNORE_DIRS.has(entry.name)) {
|
|
172
184
|
continue;
|
|
173
185
|
}
|
|
186
|
+
// A legacy `openprd/changes` tree belongs to the project just discovered
|
|
187
|
+
// above. It is migration input, never another fleet project root.
|
|
188
|
+
if (containsLegacyArtifactTree && entry.name === 'openprd') {
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
174
191
|
await walk(cjoin(currentPath, entry.name), depth + 1);
|
|
175
192
|
}
|
|
176
193
|
}
|
|
@@ -298,32 +315,18 @@ function summarizeFleetProjects(projects) {
|
|
|
298
315
|
return summary;
|
|
299
316
|
}
|
|
300
317
|
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
openprdHome: options.openprdHome,
|
|
314
|
-
docsContent: false,
|
|
315
|
-
sourceManuals: false,
|
|
316
|
-
});
|
|
317
|
-
return {
|
|
318
|
-
ok: doctor.ok,
|
|
319
|
-
errors: doctor.errors ?? [],
|
|
320
|
-
};
|
|
321
|
-
} catch (error) {
|
|
322
|
-
return {
|
|
323
|
-
ok: false,
|
|
324
|
-
errors: [error instanceof Error ? error.message : String(error)],
|
|
325
|
-
};
|
|
326
|
-
}
|
|
318
|
+
function attachFleetTransactionReport(item, plan, context) {
|
|
319
|
+
item.transaction = {
|
|
320
|
+
id: plan.transactionId,
|
|
321
|
+
reportVersion: plan.version,
|
|
322
|
+
strategy: context.applyStrategy,
|
|
323
|
+
};
|
|
324
|
+
item.version = { ...plan.versions };
|
|
325
|
+
item.checksum = { ...plan.checksum };
|
|
326
|
+
item.backup = { ...plan.backup };
|
|
327
|
+
item.migration = plan.migration;
|
|
328
|
+
item.rollback = { ...plan.rollback };
|
|
329
|
+
item.refusal = [...plan.refusal];
|
|
327
330
|
}
|
|
328
331
|
|
|
329
332
|
function buildFleetRegistrySummary(rootPath, options, registry) {
|
|
@@ -350,12 +353,7 @@ function buildFleetRegistrySummary(rootPath, options, registry) {
|
|
|
350
353
|
}
|
|
351
354
|
|
|
352
355
|
async function fleetWorkspaceImpl(rootPath, options = {}, dependencies = {}) {
|
|
353
|
-
const {
|
|
354
|
-
doctorWorkspace,
|
|
355
|
-
backfillWorkUnitsWorkspace,
|
|
356
|
-
setupAgentIntegrationWorkspace,
|
|
357
|
-
updateAgentIntegrationWorkspace,
|
|
358
|
-
} = dependencies;
|
|
356
|
+
const { doctorWorkspace, backfillWorkUnitsWorkspace } = dependencies;
|
|
359
357
|
const root = path.resolve(rootPath);
|
|
360
358
|
if (!(await exists(root))) {
|
|
361
359
|
throw new Error(`Fleet root does not exist: ${root}`);
|
|
@@ -374,6 +372,11 @@ async function fleetWorkspaceImpl(rootPath, options = {}, dependencies = {}) {
|
|
|
374
372
|
const scanned = await scanFleetProjects(root, options);
|
|
375
373
|
const mergedProjects = await mergeFleetProjects(scanned, registryScope.projects);
|
|
376
374
|
const projects = [];
|
|
375
|
+
const transactionContext = await createFleetTransactionContext({
|
|
376
|
+
openprdHome: options.openprdHome,
|
|
377
|
+
backupRoot: options.backupRoot,
|
|
378
|
+
targetVersion: options.targetVersion,
|
|
379
|
+
});
|
|
377
380
|
|
|
378
381
|
for (const project of mergedProjects) {
|
|
379
382
|
const plannedAction = plannedFleetAction(project.category, options);
|
|
@@ -393,6 +396,27 @@ async function fleetWorkspaceImpl(rootPath, options = {}, dependencies = {}) {
|
|
|
393
396
|
continue;
|
|
394
397
|
}
|
|
395
398
|
|
|
399
|
+
let transactionPlan = null;
|
|
400
|
+
if (FLEET_TRANSACTIONAL_ACTIONS.has(plannedAction)) {
|
|
401
|
+
try {
|
|
402
|
+
transactionPlan = await planFleetProjectTransaction({ ...project, plannedAction }, transactionContext, { dryRun });
|
|
403
|
+
attachFleetTransactionReport(item, transactionPlan, transactionContext);
|
|
404
|
+
} catch (error) {
|
|
405
|
+
item.status = 'failed';
|
|
406
|
+
item.ok = false;
|
|
407
|
+
item.errors = [error instanceof Error ? error.message : String(error)];
|
|
408
|
+
projects.push(item);
|
|
409
|
+
continue;
|
|
410
|
+
}
|
|
411
|
+
if (!transactionPlan.eligible) {
|
|
412
|
+
item.status = 'failed';
|
|
413
|
+
item.ok = false;
|
|
414
|
+
item.errors = transactionPlan.refusal.map((refusal) => refusal.message);
|
|
415
|
+
projects.push(item);
|
|
416
|
+
continue;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
396
420
|
if (dryRun) {
|
|
397
421
|
if (plannedAction === 'backfill-work-units' && backfillWorkUnitsWorkspace) {
|
|
398
422
|
const backfill = await backfillWorkUnitsWorkspace(project.path, { dryRun: true });
|
|
@@ -408,54 +432,49 @@ async function fleetWorkspaceImpl(rootPath, options = {}, dependencies = {}) {
|
|
|
408
432
|
continue;
|
|
409
433
|
}
|
|
410
434
|
|
|
435
|
+
if (transactionPlan) {
|
|
436
|
+
const transaction = await executeFleetProjectTransaction(transactionPlan, {
|
|
437
|
+
openprdHome: options.openprdHome,
|
|
438
|
+
beforeApply: dependencies.beforeFleetApply,
|
|
439
|
+
mutate: (stagingRoot, staging) => runStagedFleetMutation(
|
|
440
|
+
plannedAction,
|
|
441
|
+
stagingRoot,
|
|
442
|
+
options,
|
|
443
|
+
dependencies,
|
|
444
|
+
staging,
|
|
445
|
+
),
|
|
446
|
+
afterApply: plannedAction === 'update' || plannedAction === 'setup'
|
|
447
|
+
? async () => ({
|
|
448
|
+
registry: await upsertWorkspaceRegistryEntry(project.path, {
|
|
449
|
+
openprdHome: options.openprdHome,
|
|
450
|
+
action: plannedAction === 'update' ? 'fleet-update' : 'fleet-setup',
|
|
451
|
+
}),
|
|
452
|
+
})
|
|
453
|
+
: null,
|
|
454
|
+
});
|
|
455
|
+
attachFleetTransactionReport(item, transaction.plan, transactionContext);
|
|
456
|
+
if (!transaction.ok) {
|
|
457
|
+
item.status = 'failed';
|
|
458
|
+
item.ok = false;
|
|
459
|
+
item.errors = Array.from(new Set([
|
|
460
|
+
...(transaction.mutationResult?.errors ?? []),
|
|
461
|
+
transaction.error instanceof Error ? transaction.error.message : String(transaction.error),
|
|
462
|
+
].filter(Boolean)));
|
|
463
|
+
projects.push(item);
|
|
464
|
+
continue;
|
|
465
|
+
}
|
|
466
|
+
item.ok = true;
|
|
467
|
+
applySuccessfulFleetMutation(item, plannedAction, transaction);
|
|
468
|
+
item.errors = [
|
|
469
|
+
...(transaction.mutationResult?.actionResult?.doctor?.errors ?? []),
|
|
470
|
+
...(transaction.mutationResult?.backfill?.errors ?? []),
|
|
471
|
+
];
|
|
472
|
+
projects.push(item);
|
|
473
|
+
continue;
|
|
474
|
+
}
|
|
475
|
+
|
|
411
476
|
try {
|
|
412
|
-
if (plannedAction === '
|
|
413
|
-
const update = await updateAgentIntegrationWorkspace(project.path, {
|
|
414
|
-
tools: options.tools ?? 'all',
|
|
415
|
-
hookProfile: options.hookProfile,
|
|
416
|
-
force: Boolean(options.force),
|
|
417
|
-
enableUserCodexConfig: Boolean(options.enableUserCodexConfig),
|
|
418
|
-
codexHome: options.codexHome,
|
|
419
|
-
openprdHome: options.openprdHome,
|
|
420
|
-
});
|
|
421
|
-
const backfill = backfillWorkUnitsWorkspace
|
|
422
|
-
? await backfillWorkUnitsWorkspace(project.path, {})
|
|
423
|
-
: { ok: true, changes: [], errors: [], totalVersions: 0, changedVersions: 0 };
|
|
424
|
-
const health = await collectFleetProjectHealth(project.path, options, doctorWorkspace);
|
|
425
|
-
item.status = update.ok && backfill.ok ? 'updated' : 'failed';
|
|
426
|
-
item.ok = update.ok && backfill.ok;
|
|
427
|
-
item.registry = update.registry ?? null;
|
|
428
|
-
item.changes = [
|
|
429
|
-
...(update.migration?.changes ?? []).map((change) => ({ ...change, source: 'workspace' })),
|
|
430
|
-
...(update.changes ?? []).map((change) => ({ ...change, source: 'agent' })),
|
|
431
|
-
...(backfill.changes ?? []).map((change) => ({ ...change, source: 'work-unit' })),
|
|
432
|
-
];
|
|
433
|
-
item.doctorOk = health.ok;
|
|
434
|
-
item.healthOk = health.ok;
|
|
435
|
-
item.healthErrors = health.errors;
|
|
436
|
-
item.workUnits = {
|
|
437
|
-
totalVersions: backfill.totalVersions,
|
|
438
|
-
changedVersions: backfill.changedVersions,
|
|
439
|
-
};
|
|
440
|
-
item.errors = [...(update.doctor?.errors ?? []), ...(backfill.errors ?? [])];
|
|
441
|
-
} else if (plannedAction === 'setup') {
|
|
442
|
-
const setup = await setupAgentIntegrationWorkspace(project.path, {
|
|
443
|
-
tools: options.tools ?? 'all',
|
|
444
|
-
hookProfile: options.hookProfile,
|
|
445
|
-
force: Boolean(options.force),
|
|
446
|
-
enableUserCodexConfig: Boolean(options.enableUserCodexConfig),
|
|
447
|
-
codexHome: options.codexHome,
|
|
448
|
-
openprdHome: options.openprdHome,
|
|
449
|
-
});
|
|
450
|
-
item.status = setup.ok ? 'setup' : 'failed';
|
|
451
|
-
item.ok = setup.ok;
|
|
452
|
-
item.registry = setup.registry ?? null;
|
|
453
|
-
item.changes = [
|
|
454
|
-
...(setup.migration?.changes ?? []).map((change) => ({ ...change, source: 'workspace' })),
|
|
455
|
-
...(setup.changes ?? []).map((change) => ({ ...change, source: 'agent' })),
|
|
456
|
-
];
|
|
457
|
-
item.errors = setup.doctor?.errors ?? [];
|
|
458
|
-
} else if (plannedAction === 'doctor') {
|
|
477
|
+
if (plannedAction === 'doctor') {
|
|
459
478
|
const doctor = await doctorWorkspace(project.path, {
|
|
460
479
|
tools: options.tools ?? 'all',
|
|
461
480
|
hookProfile: options.hookProfile,
|
|
@@ -467,19 +486,6 @@ async function fleetWorkspaceImpl(rootPath, options = {}, dependencies = {}) {
|
|
|
467
486
|
item.ok = doctor.ok;
|
|
468
487
|
item.doctorOk = doctor.ok;
|
|
469
488
|
item.errors = doctor.errors ?? [];
|
|
470
|
-
} else if (plannedAction === 'backfill-work-units') {
|
|
471
|
-
if (!backfillWorkUnitsWorkspace) {
|
|
472
|
-
throw new Error('Missing fleet dependency: backfillWorkUnitsWorkspace');
|
|
473
|
-
}
|
|
474
|
-
const backfill = await backfillWorkUnitsWorkspace(project.path, {});
|
|
475
|
-
item.status = backfill.ok ? (backfill.totalVersions > 0 ? 'backfilled' : 'skipped') : 'failed';
|
|
476
|
-
item.ok = backfill.ok;
|
|
477
|
-
item.workUnits = {
|
|
478
|
-
totalVersions: backfill.totalVersions,
|
|
479
|
-
changedVersions: backfill.changedVersions,
|
|
480
|
-
};
|
|
481
|
-
item.changes = (backfill.changes ?? []).map((change) => ({ ...change, source: 'work-unit' }));
|
|
482
|
-
item.errors = backfill.errors ?? [];
|
|
483
489
|
} else if (plannedAction === 'sync-registry') {
|
|
484
490
|
const registrySync = await upsertWorkspaceRegistryEntry(project.path, {
|
|
485
491
|
openprdHome: options.openprdHome,
|
|
@@ -515,6 +521,17 @@ async function fleetWorkspaceImpl(rootPath, options = {}, dependencies = {}) {
|
|
|
515
521
|
backfillWorkUnits: Boolean(options.backfillWorkUnits),
|
|
516
522
|
syncRegistry: Boolean(options.syncRegistry),
|
|
517
523
|
},
|
|
524
|
+
upgrade: {
|
|
525
|
+
reportVersion: transactionContext.version,
|
|
526
|
+
transactionId: transactionContext.transactionId,
|
|
527
|
+
targetVersion: transactionContext.targetVersion,
|
|
528
|
+
checksumAlgorithm: transactionContext.checksumAlgorithm,
|
|
529
|
+
applyStrategy: transactionContext.applyStrategy,
|
|
530
|
+
backupRoot: transactionContext.backupRoot,
|
|
531
|
+
archivePolicy: 'refuse-write',
|
|
532
|
+
symlinkPolicy: 'refuse-write',
|
|
533
|
+
compatibilityPolicy: 'refuse-too-new',
|
|
534
|
+
},
|
|
518
535
|
scannedAt: timestamp(),
|
|
519
536
|
summary: summarizeFleetProjects(projects),
|
|
520
537
|
registry: registrySummary,
|
|
@@ -525,8 +542,8 @@ async function fleetWorkspaceImpl(rootPath, options = {}, dependencies = {}) {
|
|
|
525
542
|
|
|
526
543
|
if (options.report) {
|
|
527
544
|
const reportPath = path.resolve(options.report);
|
|
528
|
-
await writeJson(reportPath, result);
|
|
529
545
|
result.reportPath = reportPath;
|
|
546
|
+
await atomicWriteJson(reportPath, result);
|
|
530
547
|
}
|
|
531
548
|
|
|
532
549
|
return result;
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* 核心功能
|
|
3
|
+
* 定义 project/change/work-unit/task/lane/operation 分级门禁,并把证据与明确 approval ID
|
|
4
|
+
* 绑定到同一作用域和操作,避免一句泛化确认越权放行其他任务。
|
|
5
|
+
*
|
|
6
|
+
* 输入
|
|
7
|
+
* 接收 Gate 定义、Evidence 记录、Approval 记录、待执行操作摘要与可注入时钟。
|
|
8
|
+
*
|
|
9
|
+
* 输出
|
|
10
|
+
* 返回稳定、可审计的门禁决策;不执行写入,也不把评估行为混入项目事实。
|
|
11
|
+
*
|
|
12
|
+
* 定位
|
|
13
|
+
* 位于控制平面的纯策略层,可由 CLI、Hook、任务运行时和升级器共同复用。
|
|
14
|
+
*
|
|
15
|
+
* 维护规则
|
|
16
|
+
* 新增 scope 或决策码时必须保持旧码含义不变,并补跨作用域、过期和撤销负向测试。
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { createHash } from 'node:crypto';
|
|
20
|
+
import { canonicalJson } from '../kernel/event-envelope.js';
|
|
21
|
+
|
|
22
|
+
const GATE_SCHEMA_VERSION = 1;
|
|
23
|
+
const SCOPE_TYPES = Object.freeze(['project', 'change', 'work-unit', 'task', 'lane', 'operation']);
|
|
24
|
+
const EVIDENCE_STATUSES = Object.freeze(['valid', 'revoked', 'superseded']);
|
|
25
|
+
const APPROVAL_STATUSES = Object.freeze(['active', 'revoked', 'consumed']);
|
|
26
|
+
|
|
27
|
+
class ScopedGateValidationError extends Error {
|
|
28
|
+
constructor(message, details = {}) {
|
|
29
|
+
super(message);
|
|
30
|
+
this.name = 'ScopedGateValidationError';
|
|
31
|
+
this.code = 'OPENPRD_SCOPED_GATE_INVALID';
|
|
32
|
+
Object.assign(this, details);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function stableId(prefix, value) {
|
|
37
|
+
return `${prefix}_${createHash('sha256').update(canonicalJson(value)).digest('hex').slice(0, 24)}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function requiredString(value, label) {
|
|
41
|
+
const normalized = String(value ?? '').trim();
|
|
42
|
+
if (!normalized || /[\0\r\n]/.test(normalized)) {
|
|
43
|
+
throw new ScopedGateValidationError(`${label} 必须是安全的非空字符串。`, { field: label });
|
|
44
|
+
}
|
|
45
|
+
return normalized;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function normalizeTime(value, label) {
|
|
49
|
+
const date = value instanceof Date ? value : new Date(value);
|
|
50
|
+
if (Number.isNaN(date.getTime())) {
|
|
51
|
+
throw new ScopedGateValidationError(`${label} 必须是有效时间。`, { field: label });
|
|
52
|
+
}
|
|
53
|
+
return date.toISOString();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function normalizeScope(scope) {
|
|
57
|
+
const type = requiredString(scope?.type, 'scope.type');
|
|
58
|
+
if (!SCOPE_TYPES.includes(type)) {
|
|
59
|
+
throw new ScopedGateValidationError(`不支持的门禁作用域:${type}。`, { field: 'scope.type', type });
|
|
60
|
+
}
|
|
61
|
+
return { type, id: requiredString(scope?.id, 'scope.id') };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function sameScope(left, right) {
|
|
65
|
+
return left?.type === right?.type && left?.id === right?.id;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function createGateDefinition(input) {
|
|
69
|
+
const scope = normalizeScope(input?.scope);
|
|
70
|
+
const operation = requiredString(input?.operation, 'operation');
|
|
71
|
+
const requiredEvidence = (input?.requiredEvidence ?? []).map((requirement, index) => ({
|
|
72
|
+
kind: requiredString(requirement?.kind, `requiredEvidence[${index}].kind`),
|
|
73
|
+
minCount: Math.max(1, Number(requirement?.minCount ?? 1)),
|
|
74
|
+
maxAgeMs: requirement?.maxAgeMs === null || requirement?.maxAgeMs === undefined
|
|
75
|
+
? null
|
|
76
|
+
: Math.max(0, Number(requirement.maxAgeMs)),
|
|
77
|
+
}));
|
|
78
|
+
for (const requirement of requiredEvidence) {
|
|
79
|
+
if (!Number.isSafeInteger(requirement.minCount)) {
|
|
80
|
+
throw new ScopedGateValidationError('requiredEvidence.minCount 必须是正整数。');
|
|
81
|
+
}
|
|
82
|
+
if (requirement.maxAgeMs !== null && !Number.isFinite(requirement.maxAgeMs)) {
|
|
83
|
+
throw new ScopedGateValidationError('requiredEvidence.maxAgeMs 必须是非负数或 null。');
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
const approval = {
|
|
87
|
+
required: Boolean(input?.approval?.required),
|
|
88
|
+
subjectDigest: input?.approval?.subjectDigest
|
|
89
|
+
? requiredString(input.approval.subjectDigest, 'approval.subjectDigest')
|
|
90
|
+
: null,
|
|
91
|
+
consumeOnPass: Boolean(input?.approval?.consumeOnPass),
|
|
92
|
+
};
|
|
93
|
+
const body = {
|
|
94
|
+
schemaVersion: GATE_SCHEMA_VERSION,
|
|
95
|
+
gateId: input?.gateId ?? null,
|
|
96
|
+
scope,
|
|
97
|
+
operation,
|
|
98
|
+
risk: input?.risk ?? 'normal',
|
|
99
|
+
requiredEvidence,
|
|
100
|
+
approval,
|
|
101
|
+
metadata: structuredClone(input?.metadata ?? {}),
|
|
102
|
+
};
|
|
103
|
+
body.gateId = body.gateId
|
|
104
|
+
? requiredString(body.gateId, 'gateId')
|
|
105
|
+
: stableId('gate', { ...body, gateId: null });
|
|
106
|
+
return body;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function createEvidenceRecord(input, options = {}) {
|
|
110
|
+
const collectedAt = normalizeTime(input?.collectedAt ?? (options.clock?.() ?? new Date()), 'collectedAt');
|
|
111
|
+
const scope = normalizeScope(input?.scope);
|
|
112
|
+
const kind = requiredString(input?.kind, 'kind');
|
|
113
|
+
const contentDigest = requiredString(input?.contentDigest, 'contentDigest');
|
|
114
|
+
const status = input?.status ?? 'valid';
|
|
115
|
+
if (!EVIDENCE_STATUSES.includes(status)) {
|
|
116
|
+
throw new ScopedGateValidationError(`不支持的证据状态:${status}。`);
|
|
117
|
+
}
|
|
118
|
+
const body = {
|
|
119
|
+
schemaVersion: GATE_SCHEMA_VERSION,
|
|
120
|
+
evidenceId: input?.evidenceId ?? null,
|
|
121
|
+
scope,
|
|
122
|
+
operation: input?.operation ? requiredString(input.operation, 'operation') : null,
|
|
123
|
+
kind,
|
|
124
|
+
source: requiredString(input?.source ?? 'unknown', 'source'),
|
|
125
|
+
contentDigest,
|
|
126
|
+
collectedAt,
|
|
127
|
+
validUntil: input?.validUntil ? normalizeTime(input.validUntil, 'validUntil') : null,
|
|
128
|
+
status,
|
|
129
|
+
metadata: structuredClone(input?.metadata ?? {}),
|
|
130
|
+
};
|
|
131
|
+
body.evidenceId = body.evidenceId
|
|
132
|
+
? requiredString(body.evidenceId, 'evidenceId')
|
|
133
|
+
: stableId('evidence', { ...body, evidenceId: null });
|
|
134
|
+
return body;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function createApprovalRecord(input, options = {}) {
|
|
138
|
+
const approvalId = requiredString(input?.approvalId, 'approvalId');
|
|
139
|
+
const issuedAt = normalizeTime(input?.issuedAt ?? (options.clock?.() ?? new Date()), 'issuedAt');
|
|
140
|
+
const status = input?.status ?? 'active';
|
|
141
|
+
if (!APPROVAL_STATUSES.includes(status)) {
|
|
142
|
+
throw new ScopedGateValidationError(`不支持的审批状态:${status}。`);
|
|
143
|
+
}
|
|
144
|
+
return {
|
|
145
|
+
schemaVersion: GATE_SCHEMA_VERSION,
|
|
146
|
+
approvalId,
|
|
147
|
+
scope: normalizeScope(input?.scope),
|
|
148
|
+
operation: requiredString(input?.operation, 'operation'),
|
|
149
|
+
subjectDigest: input?.subjectDigest ? requiredString(input.subjectDigest, 'subjectDigest') : null,
|
|
150
|
+
actor: {
|
|
151
|
+
type: requiredString(input?.actor?.type ?? 'user', 'actor.type'),
|
|
152
|
+
id: input?.actor?.id ? requiredString(input.actor.id, 'actor.id') : null,
|
|
153
|
+
},
|
|
154
|
+
issuedAt,
|
|
155
|
+
expiresAt: input?.expiresAt ? normalizeTime(input.expiresAt, 'expiresAt') : null,
|
|
156
|
+
status,
|
|
157
|
+
metadata: structuredClone(input?.metadata ?? {}),
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function evidenceUsability(record, gate, now) {
|
|
162
|
+
if (record?.schemaVersion !== GATE_SCHEMA_VERSION) return { ok: false, code: 'evidence-schema-mismatch' };
|
|
163
|
+
if (!sameScope(record.scope, gate.scope)) return { ok: false, code: 'evidence-scope-mismatch' };
|
|
164
|
+
if (record.operation && record.operation !== gate.operation) return { ok: false, code: 'evidence-operation-mismatch' };
|
|
165
|
+
if (record.status !== 'valid') return { ok: false, code: `evidence-${record.status ?? 'invalid'}` };
|
|
166
|
+
const collectedAt = Date.parse(record.collectedAt);
|
|
167
|
+
if (Number.isNaN(collectedAt)) return { ok: false, code: 'evidence-time-invalid' };
|
|
168
|
+
if (record.validUntil && Date.parse(record.validUntil) <= now.getTime()) return { ok: false, code: 'evidence-expired' };
|
|
169
|
+
return { ok: true, collectedAt };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function approvalUsability(record, gate, approvalId, now) {
|
|
173
|
+
if (!approvalId) return { ok: false, code: 'approval-id-required' };
|
|
174
|
+
if (!record || record.approvalId !== approvalId) return { ok: false, code: 'approval-id-not-found' };
|
|
175
|
+
if (record.schemaVersion !== GATE_SCHEMA_VERSION) return { ok: false, code: 'approval-schema-mismatch' };
|
|
176
|
+
if (!sameScope(record.scope, gate.scope)) return { ok: false, code: 'approval-scope-mismatch' };
|
|
177
|
+
if (record.operation !== gate.operation) return { ok: false, code: 'approval-operation-mismatch' };
|
|
178
|
+
if (record.status !== 'active') return { ok: false, code: `approval-${record.status ?? 'invalid'}` };
|
|
179
|
+
if (record.expiresAt && Date.parse(record.expiresAt) <= now.getTime()) return { ok: false, code: 'approval-expired' };
|
|
180
|
+
if (gate.approval.subjectDigest && record.subjectDigest !== gate.approval.subjectDigest) {
|
|
181
|
+
return { ok: false, code: 'approval-subject-mismatch' };
|
|
182
|
+
}
|
|
183
|
+
return { ok: true };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function evaluateScopedGate(input, options = {}) {
|
|
187
|
+
const gate = createGateDefinition(input?.gate);
|
|
188
|
+
const now = options.clock?.() ?? new Date();
|
|
189
|
+
const nowDate = now instanceof Date ? now : new Date(now);
|
|
190
|
+
if (Number.isNaN(nowDate.getTime())) throw new ScopedGateValidationError('clock 返回了无效时间。');
|
|
191
|
+
const reasons = [];
|
|
192
|
+
const acceptedEvidence = [];
|
|
193
|
+
const rejectedEvidence = [];
|
|
194
|
+
|
|
195
|
+
for (const evidence of input?.evidence ?? []) {
|
|
196
|
+
const usability = evidenceUsability(evidence, gate, nowDate);
|
|
197
|
+
if (usability.ok) acceptedEvidence.push({ record: evidence, collectedAt: usability.collectedAt });
|
|
198
|
+
else rejectedEvidence.push({ evidenceId: evidence?.evidenceId ?? null, code: usability.code });
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
for (const requirement of gate.requiredEvidence) {
|
|
202
|
+
const matches = acceptedEvidence.filter(({ record, collectedAt }) => (
|
|
203
|
+
record.kind === requirement.kind
|
|
204
|
+
&& (requirement.maxAgeMs === null || nowDate.getTime() - collectedAt <= requirement.maxAgeMs)
|
|
205
|
+
));
|
|
206
|
+
if (matches.length < requirement.minCount) {
|
|
207
|
+
reasons.push({
|
|
208
|
+
code: 'evidence-required',
|
|
209
|
+
kind: requirement.kind,
|
|
210
|
+
required: requirement.minCount,
|
|
211
|
+
found: matches.length,
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
let approval = null;
|
|
217
|
+
if (gate.approval.required) {
|
|
218
|
+
approval = (input?.approvals ?? []).find((record) => record?.approvalId === input?.approvalId) ?? null;
|
|
219
|
+
const usability = approvalUsability(approval, gate, input?.approvalId, nowDate);
|
|
220
|
+
if (!usability.ok) reasons.push({ code: usability.code, approvalId: input?.approvalId ?? null });
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const evaluatedAt = nowDate.toISOString();
|
|
224
|
+
const result = {
|
|
225
|
+
schemaVersion: GATE_SCHEMA_VERSION,
|
|
226
|
+
gateId: gate.gateId,
|
|
227
|
+
scope: gate.scope,
|
|
228
|
+
operation: gate.operation,
|
|
229
|
+
status: reasons.length === 0 ? 'pass' : 'blocked',
|
|
230
|
+
evaluatedAt,
|
|
231
|
+
approvalId: approval?.approvalId ?? null,
|
|
232
|
+
approvalConsumption: reasons.length === 0 && gate.approval.consumeOnPass && approval
|
|
233
|
+
? { approvalId: approval.approvalId, nextStatus: 'consumed' }
|
|
234
|
+
: null,
|
|
235
|
+
acceptedEvidenceIds: acceptedEvidence.map(({ record }) => record.evidenceId).sort(),
|
|
236
|
+
rejectedEvidence,
|
|
237
|
+
reasons,
|
|
238
|
+
};
|
|
239
|
+
return {
|
|
240
|
+
...result,
|
|
241
|
+
decisionId: stableId('decision', result),
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export {
|
|
246
|
+
APPROVAL_STATUSES,
|
|
247
|
+
EVIDENCE_STATUSES,
|
|
248
|
+
GATE_SCHEMA_VERSION,
|
|
249
|
+
SCOPE_TYPES,
|
|
250
|
+
ScopedGateValidationError,
|
|
251
|
+
createApprovalRecord,
|
|
252
|
+
createEvidenceRecord,
|
|
253
|
+
createGateDefinition,
|
|
254
|
+
evaluateScopedGate,
|
|
255
|
+
normalizeScope,
|
|
256
|
+
};
|