@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,30 @@
1
+ export const CONTEXT_REQUEST_SCHEMA_VERSION = 'openprd.context-request.v1';
2
+ export const CONTEXT_CAPSULE_SCHEMA_VERSION = 'openprd.context-capsule.v1';
3
+ export const CONTEXT_CACHE_SCHEMA_VERSION = 'openprd.context-cache-entry.v1';
4
+ export const CONTEXT_TELEMETRY_SCHEMA_VERSION = 'openprd.context-telemetry.v1';
5
+
6
+ export const CONTEXT_COMPILER_VERSION = '1.0.0';
7
+ export const DEFAULT_CONTEXT_TOKEN_BUDGET = 800;
8
+ export const DEFAULT_CONTEXT_DELTA_TOKEN_BUDGET = 250;
9
+
10
+ // Authority is intentionally explicit. Callers can still provide a numeric score,
11
+ // but named values keep cross-tool adapters aligned on the same precedence rules.
12
+ export const CONTEXT_AUTHORITY_SCORES = Object.freeze({
13
+ 'user-confirmed': 100,
14
+ 'project-canonical': 90,
15
+ 'runtime-observed': 80,
16
+ 'project-derived': 70,
17
+ 'tool-derived': 60,
18
+ 'agent-inferred': 40,
19
+ external: 30,
20
+ unknown: 0,
21
+ });
22
+
23
+ export const CONTEXT_FRESHNESS_SCORES = Object.freeze({
24
+ current: 4,
25
+ latest: 3,
26
+ recent: 2,
27
+ historical: 1,
28
+ stale: 0,
29
+ unknown: 0,
30
+ });
@@ -0,0 +1,39 @@
1
+ export {
2
+ CONTEXT_AUTHORITY_SCORES,
3
+ CONTEXT_CACHE_SCHEMA_VERSION,
4
+ CONTEXT_CAPSULE_SCHEMA_VERSION,
5
+ CONTEXT_COMPILER_VERSION,
6
+ CONTEXT_FRESHNESS_SCORES,
7
+ CONTEXT_REQUEST_SCHEMA_VERSION,
8
+ CONTEXT_TELEMETRY_SCHEMA_VERSION,
9
+ DEFAULT_CONTEXT_DELTA_TOKEN_BUDGET,
10
+ DEFAULT_CONTEXT_TOKEN_BUDGET,
11
+ } from './constants.js';
12
+
13
+ export {
14
+ buildContextRequestFingerprint,
15
+ compileContextCapsule,
16
+ computeContextCapsuleDigest,
17
+ createContextRequest,
18
+ verifyContextCapsule,
19
+ } from './compiler.js';
20
+
21
+ export {
22
+ buildContextCacheKey,
23
+ compileContextWithCache,
24
+ contextCachePath,
25
+ createFileContextCache,
26
+ createMemoryContextCache,
27
+ invalidateContextCache,
28
+ readContextCache,
29
+ runContextCacheBenchmark,
30
+ writeContextCache,
31
+ } from './cache.js';
32
+
33
+ export {
34
+ createContextTelemetryEvent,
35
+ emitContextTelemetry,
36
+ } from './telemetry.js';
37
+
38
+ export { redactContextSecrets } from './redaction.js';
39
+ export { digestStable, digestText, stableStringify } from './stable.js';
@@ -0,0 +1,84 @@
1
+ const SECRET_KEY_PATTERN = /^(?:[a-z0-9]+[_-])*(?:api[_-]?key|access[_-]?token|refresh[_-]?token|token|auth(?:orization)?|client[_-]?secret|password|passwd|secret|private[_-]?key)$/i;
2
+
3
+ const TEXT_PATTERNS = [
4
+ {
5
+ reason: 'private-key',
6
+ pattern: /-----BEGIN(?: [A-Z0-9]+)? PRIVATE KEY-----[\s\S]*?-----END(?: [A-Z0-9]+)? PRIVATE KEY-----/g,
7
+ replacement: '[REDACTED:PRIVATE_KEY]',
8
+ },
9
+ {
10
+ reason: 'bearer-token',
11
+ pattern: /(\bBearer\s+)[A-Za-z0-9._~+/=-]{8,}/gi,
12
+ replacement: '$1[REDACTED]',
13
+ },
14
+ {
15
+ reason: 'credential-assignment',
16
+ pattern: /(\b(?:[a-z0-9]+[_-])*(?:api[_-]?key|access[_-]?token|refresh[_-]?token|token|authorization|client[_-]?secret|password|passwd|secret)\b\s*[:=]\s*)(?:"[^"]*"|'[^']*'|[^\s,;]+)/gi,
17
+ replacement: '$1[REDACTED]',
18
+ },
19
+ {
20
+ reason: 'provider-token',
21
+ pattern: /\b(?:sk-(?:proj-)?|gh[pousr]_|xox[baprs]-)[A-Za-z0-9_-]{8,}\b/g,
22
+ replacement: '[REDACTED:TOKEN]',
23
+ },
24
+ {
25
+ reason: 'jwt',
26
+ pattern: /\beyJ[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{6,}\b/g,
27
+ replacement: '[REDACTED:JWT]',
28
+ },
29
+ {
30
+ reason: 'url-credential',
31
+ pattern: /:\/\/[^/\s:@]+:[^@\s/]+@/g,
32
+ replacement: '://[REDACTED]@',
33
+ },
34
+ ];
35
+
36
+ function redactText(text, path, redactions) {
37
+ let output = String(text);
38
+ for (const item of TEXT_PATTERNS) {
39
+ output = output.replace(item.pattern, (...args) => {
40
+ redactions.push({ path, reason: item.reason });
41
+ if (typeof item.replacement === 'function') {
42
+ return item.replacement(...args);
43
+ }
44
+ return item.replacement.replace(/\$(\d+)/g, (_, index) => args[Number(index)] ?? '');
45
+ });
46
+ }
47
+ return output;
48
+ }
49
+
50
+ function redactValue(value, path, redactions, seen) {
51
+ if (typeof value === 'string') {
52
+ return redactText(value, path, redactions);
53
+ }
54
+ if (Array.isArray(value)) {
55
+ return value.map((item, index) => redactValue(item, `${path}[${index}]`, redactions, seen));
56
+ }
57
+ if (!value || typeof value !== 'object') {
58
+ return value;
59
+ }
60
+ if (seen.has(value)) {
61
+ throw new TypeError('Context values must not contain circular references.');
62
+ }
63
+ seen.add(value);
64
+ const output = {};
65
+ for (const key of Object.keys(value).sort()) {
66
+ const childPath = path ? `${path}.${key}` : key;
67
+ if (SECRET_KEY_PATTERN.test(key)) {
68
+ output[key] = '[REDACTED]';
69
+ redactions.push({ path: childPath, reason: 'secret-field' });
70
+ continue;
71
+ }
72
+ output[key] = redactValue(value[key], childPath, redactions, seen);
73
+ }
74
+ seen.delete(value);
75
+ return output;
76
+ }
77
+
78
+ export function redactContextSecrets(value) {
79
+ const redactions = [];
80
+ return {
81
+ value: redactValue(value, '$', redactions, new WeakSet()),
82
+ redactions,
83
+ };
84
+ }
@@ -0,0 +1,69 @@
1
+ import { createHash } from 'node:crypto';
2
+
3
+ function normalizeStableValue(value, seen = new WeakSet()) {
4
+ if (value === null || typeof value === 'string' || typeof value === 'boolean') {
5
+ return value;
6
+ }
7
+ if (typeof value === 'number') {
8
+ return Number.isFinite(value) ? value : String(value);
9
+ }
10
+ if (typeof value === 'bigint') {
11
+ return value.toString();
12
+ }
13
+ if (value instanceof Date) {
14
+ return value.toISOString();
15
+ }
16
+ if (Array.isArray(value)) {
17
+ if (seen.has(value)) {
18
+ throw new TypeError('Context values must not contain circular references.');
19
+ }
20
+ seen.add(value);
21
+ const normalized = value.map((item) => (
22
+ item === undefined || typeof item === 'function' || typeof item === 'symbol'
23
+ ? null
24
+ : normalizeStableValue(item, seen)
25
+ ));
26
+ seen.delete(value);
27
+ return normalized;
28
+ }
29
+ if (typeof value !== 'object' || value === undefined) {
30
+ return null;
31
+ }
32
+ if (seen.has(value)) {
33
+ throw new TypeError('Context values must not contain circular references.');
34
+ }
35
+ seen.add(value);
36
+ const normalized = {};
37
+ for (const key of Object.keys(value).sort()) {
38
+ const item = value[key];
39
+ if (item === undefined || typeof item === 'function' || typeof item === 'symbol') {
40
+ continue;
41
+ }
42
+ normalized[key] = normalizeStableValue(item, seen);
43
+ }
44
+ seen.delete(value);
45
+ return normalized;
46
+ }
47
+
48
+ export function stableStringify(value) {
49
+ return JSON.stringify(normalizeStableValue(value));
50
+ }
51
+
52
+ export function digestText(value) {
53
+ return `sha256:${createHash('sha256').update(String(value)).digest('hex')}`;
54
+ }
55
+
56
+ export function digestStable(value) {
57
+ return digestText(stableStringify(value));
58
+ }
59
+
60
+ export function compareStableText(left, right) {
61
+ const a = String(left ?? '');
62
+ const b = String(right ?? '');
63
+ if (a === b) return 0;
64
+ return a < b ? -1 : 1;
65
+ }
66
+
67
+ export function cloneStable(value) {
68
+ return JSON.parse(stableStringify(value));
69
+ }
@@ -0,0 +1,42 @@
1
+ import { CONTEXT_TELEMETRY_SCHEMA_VERSION } from './constants.js';
2
+
3
+ export function createContextTelemetryEvent({
4
+ capsule,
5
+ cacheStatus = 'not-used',
6
+ eventType = 'context.compiled',
7
+ } = {}) {
8
+ if (!capsule?.digest) {
9
+ throw new TypeError('Context telemetry requires a compiled capsule.');
10
+ }
11
+ return {
12
+ schemaVersion: CONTEXT_TELEMETRY_SCHEMA_VERSION,
13
+ eventType,
14
+ requestDigest: capsule.requestDigest,
15
+ capsuleDigest: capsule.digest,
16
+ workspaceId: capsule.workspaceId,
17
+ revision: capsule.revision,
18
+ taskId: capsule.taskId,
19
+ delivery: capsule.delivery,
20
+ cacheStatus,
21
+ metrics: {
22
+ candidateCount: capsule.summary.candidateCount,
23
+ includedCount: capsule.summary.includedCount,
24
+ excludedCount: capsule.summary.excludedCount,
25
+ redactionCount: capsule.summary.redactionCount,
26
+ estimatedTokens: capsule.budget.estimatedTokens,
27
+ limitTokens: capsule.budget.limitTokens,
28
+ },
29
+ };
30
+ }
31
+
32
+ export async function emitContextTelemetry(sink, event) {
33
+ if (typeof sink === 'function') {
34
+ await sink(event);
35
+ return;
36
+ }
37
+ if (sink && typeof sink.record === 'function') {
38
+ await sink.record(event);
39
+ return;
40
+ }
41
+ throw new TypeError('Context telemetry sink must be a function or expose record().');
42
+ }
@@ -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, writeJson } from './fs-utils.js';
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 (!markers.includes('.openprd') && await hasLegacyOpenPrdArtifacts(projectPath)) {
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
- async function collectFleetProjectHealth(projectPath, options, doctorWorkspace) {
302
- if (!doctorWorkspace) {
303
- return { ok: true, errors: [] };
304
- }
305
- try {
306
- // Batch refresh should focus on generated guidance and workspace skeleton health.
307
- // Legacy content-doc debt remains available in direct doctor/standards checks.
308
- const doctor = await doctorWorkspace(projectPath, {
309
- tools: options.tools ?? 'all',
310
- hookProfile: options.hookProfile,
311
- enableUserCodexConfig: Boolean(options.enableUserCodexConfig),
312
- codexHome: options.codexHome,
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 === 'update') {
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,2 @@
1
+ export * from './scoped-gates.js';
2
+ export * from './store.js';