@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
package/src/cli/args.js CHANGED
@@ -19,7 +19,7 @@
19
19
  */
20
20
  function parseCommandArgs(argv) {
21
21
  const args = [...argv];
22
- const flags = { json: false, force: false, fix: false, open: false, append: false, init: false, check: false, review: false, reject: false, resume: false, advance: false, verify: false, evidenceRequired: false, next: false, generate: false, validate: false, apply: false, archive: false, activate: false, close: false, keep: false, write: false, dryRun: false, repairAgent: false, allowDirtyMain: false, fleet: false, updateOpenprd: false, backfillWorkUnits: false, syncRegistry: false, setupMissing: false, doctor: false, context: false, recordHook: false, hookInject: false, plan: false, prompt: false, loopRun: false, finish: false, commit: false, html: false, template: false, failOnViolation: false, noExternalFacts: false, noBrandAssets: false, noRealImages: false, daemon: false, stop: false, bridgeOutbox: false, bridgeSent: false, bridgeFailed: false, mark: null, direction: null, autoRefactor: null, type: 'architecture', mode: 'auto', input: null, field: null, value: null, set: null, jsonFile: null, artifactMarkdown: null, contentJson: null, presentation: null, source: null, reference: null, actual: null, before: null, after: null, board: null, grid: null, boxes: null, out: null, format: null, quality: null, maxPanelWidth: null, referenceLabel: null, actualLabel: null, locale: null, classifyExternal: null, maxIterations: null, maxDepth: null, include: null, exclude: null, report: null, item: null, id: null, status: null, claim: null, notes: null, reason: null, confidence: null, threshold: null, change: null, starter: null, sections: null, brief: null, tools: 'all', hookProfile: null, templatePack: null, target: 'openprd', targetRoot: null, path: null, productType: null, title: null, owner: null, problem: null, whyNow: null, evidence: null, from: null, to: null, version: null, digest: null, workUnit: null, event: null, risk: null, outcome: null, preview: null, learn: null, genre: null, style: null, topic: null, enable: false, disable: false, agent: 'codex', agentCommand: null, worktree: null, branch: null, message: null, thread: null, threadTitle: null, conversationTitle: null, session: null, sessionTitle: null, host: null, port: null, openTarget: 'auto' };
22
+ const flags = { json: false, force: false, fix: false, open: false, append: false, init: false, check: false, review: false, reject: false, resume: false, advance: false, verify: false, evidenceRequired: false, next: false, generate: false, validate: false, apply: false, archive: false, activate: false, close: false, keep: false, write: false, dryRun: false, repairAgent: false, allowDirtyMain: false, fleet: false, updateOpenprd: false, backfillWorkUnits: false, syncRegistry: false, setupMissing: false, doctor: false, context: false, recordHook: false, hookInject: false, plan: false, prompt: false, loopRun: false, finish: false, commit: false, html: false, template: false, failOnViolation: false, noExternalFacts: false, noBrandAssets: false, noRealImages: false, daemon: false, stop: false, bridgeOutbox: false, bridgeSent: false, bridgeFailed: false, mark: null, direction: null, autoRefactor: null, type: 'architecture', mode: 'auto', input: null, field: null, value: null, set: null, jsonFile: null, artifactMarkdown: null, contentJson: null, presentation: null, source: null, reference: null, actual: null, before: null, after: null, board: null, grid: null, boxes: null, out: null, format: null, quality: null, maxPanelWidth: null, referenceLabel: null, actualLabel: null, locale: null, classifyExternal: null, maxIterations: null, maxDepth: null, include: null, exclude: null, report: null, item: null, id: null, status: null, claim: null, notes: null, reason: null, confidence: null, threshold: null, change: null, starter: null, sections: null, brief: null, tools: 'all', hookProfile: null, templatePack: null, target: 'openprd', targetRoot: null, path: null, productType: null, title: null, owner: null, problem: null, whyNow: null, evidence: null, from: null, to: null, version: null, digest: null, workUnit: null, event: null, risk: null, outcome: null, preview: null, learn: null, genre: null, style: null, topic: null, enable: false, disable: false, agent: 'codex', agentCommand: null, worktree: null, branch: null, message: null, thread: null, threadTitle: null, conversationTitle: null, session: null, sessionTitle: null, host: null, port: null, openTarget: 'auto', runtimeAction: null, lane: null, task: null, claimId: null, client: null, leaseMs: null, expectedRevision: null, expectedLaneRevision: null, writeSet: null, baseCommit: null, gateId: null, approvalId: null, symlinkStrategy: null, installSafetyBackupRoot: null };
23
23
  const positionals = [];
24
24
 
25
25
  while (args.length > 0) {
@@ -28,6 +28,62 @@ function parseCommandArgs(argv) {
28
28
  flags.json = true;
29
29
  continue;
30
30
  }
31
+ if (arg === '--runtime-action' || arg === '--action') {
32
+ flags.runtimeAction = args.shift() ?? null;
33
+ continue;
34
+ }
35
+ if (arg === '--lane') {
36
+ flags.lane = args.shift() ?? null;
37
+ continue;
38
+ }
39
+ if (arg === '--task') {
40
+ flags.task = args.shift() ?? null;
41
+ continue;
42
+ }
43
+ if (arg === '--claim-id') {
44
+ flags.claimId = args.shift() ?? null;
45
+ continue;
46
+ }
47
+ if (arg === '--client') {
48
+ flags.client = args.shift() ?? null;
49
+ continue;
50
+ }
51
+ if (arg === '--lease-ms') {
52
+ flags.leaseMs = args.shift() ?? null;
53
+ continue;
54
+ }
55
+ if (arg === '--expected-revision') {
56
+ flags.expectedRevision = args.shift() ?? null;
57
+ continue;
58
+ }
59
+ if (arg === '--expected-lane-revision') {
60
+ flags.expectedLaneRevision = args.shift() ?? null;
61
+ continue;
62
+ }
63
+ if (arg === '--write-set') {
64
+ flags.writeSet = args.shift() ?? null;
65
+ continue;
66
+ }
67
+ if (arg === '--base-commit') {
68
+ flags.baseCommit = args.shift() ?? null;
69
+ continue;
70
+ }
71
+ if (arg === '--gate' || arg === '--gate-id') {
72
+ flags.gateId = args.shift() ?? null;
73
+ continue;
74
+ }
75
+ if (arg === '--approval-id') {
76
+ flags.approvalId = args.shift() ?? null;
77
+ continue;
78
+ }
79
+ if (arg === '--symlink-strategy') {
80
+ flags.symlinkStrategy = args.shift() ?? null;
81
+ continue;
82
+ }
83
+ if (arg === '--install-safety-backup-root') {
84
+ flags.installSafetyBackupRoot = args.shift() ?? null;
85
+ continue;
86
+ }
31
87
  if (arg === '--force') {
32
88
  flags.force = true;
33
89
  continue;
@@ -581,14 +637,16 @@ function usage() {
581
637
  'OpenPrd CLI',
582
638
  '',
583
639
  'Usage:',
584
- ' openprd init [path] [--template-pack <base|consumer|b2b|agent>] [--tools <all|codex,claude,cursor>] [--hook-profile <lite|guarded|full>] [--force]',
585
- ' openprd setup [path] [--tools <all|codex,claude,cursor>] [--hook-profile <lite|guarded|full>] [--force] [--json]',
586
- ' openprd update [path] [--tools <all|codex,claude,cursor>] [--hook-profile <lite|guarded|full>] [--force] [--json]',
640
+ ' openprd init [path] [--template-pack <base|consumer|b2b|agent>] [--tools <all|codex,claude,cursor>] [--hook-profile <lite|guarded|full>] [--symlink-strategy <block|materialize>] [--install-safety-backup-root <path>] [--force]',
641
+ ' openprd setup [path] [--tools <all|codex,claude,cursor>] [--hook-profile <lite|guarded|full>] [--symlink-strategy <block|materialize>] [--install-safety-backup-root <path>] [--force] [--json]',
642
+ ' openprd update [path] [--tools <all|codex,claude,cursor>] [--hook-profile <lite|guarded|full>] [--symlink-strategy <block|materialize>] [--install-safety-backup-root <path>] [--force] [--json]',
587
643
  ' openprd self-update [--check] [--dry-run] [--json]',
588
644
  ' openprd upgrade [path] [--fleet] [--dry-run] [--tools <all|codex,claude,cursor>] [--hook-profile <lite|guarded|full>] [--max-depth <n>] [--include <csv>] [--exclude <csv>] [--report <file>] [--force] [--json]',
589
645
  ' openprd doctor [path] [--tools <all|codex,claude,cursor>] [--hook-profile <lite|guarded|full>] [--fix] [--json]',
590
646
  ' openprd fleet <root> [--dry-run|--doctor|--update-openprd|--backfill-work-units|--sync-registry|--setup-missing] [--hook-profile <lite|guarded|full>] [--max-depth <n>] [--include <csv>] [--exclude <csv>] [--report <file>] [--json]',
591
647
  ' openprd run [path] [--context|--verify|--record-hook --event <name> --risk <level> --outcome <text> --preview <text>] [--message <text>] [--json]',
648
+ ' openprd runtime [path] --action <status|create-lane|ensure-lane|add-task|claim|heartbeat|release|finish|recover> [--lane <id>] [--task <id>] [--claim-id <id>] [--write-set <csv>] [--lease-ms <n>] [--expected-revision <n>] [--expected-lane-revision <n>] [--client <codex|claude|cursor>] [--session <id>] [--thread <id>] [--worktree <path>] [--branch <name>] [--base-commit <sha>] [--evidence <ref>] [--json]',
649
+ ' openprd gate [path] --action <status|define|evidence|approve|evaluate> [--json-file <record.json>] [--gate <id>] [--approval-id <id>] [--json]',
592
650
  ' openprd loop [path] [--init|--plan|--next|--prompt|--run|--verify|--finish] [--change <id>] [--item <task-id-or-handle>] [--agent <codex|claude>] [--agent-command <cmd>] [--repair-agent] [--worktree <path>] [--branch <name>] [--allow-dirty-main] [--commit] [--dry-run] [--message <text>] [--json]',
593
651
  ' openprd canvas [path] [--thread <id> [--thread-title <name>]|--session <id> [--session-title <name>]] [--host <host>] [--port <number>] [--daemon|--stop] [--open] [--open-target <auto|system|codex-browser>] [--dry-run] [--json]',
594
652
  ' openprd canvas [path] --bridge-outbox [--thread <id>|--session <id>] [--claim <next|id>] [--json]',
@@ -607,6 +665,7 @@ function usage() {
607
665
  ' --board modes: focus-board, parallel-board, verification-board, alignment-board, centering-board',
608
666
  ' openprd design-starter [path] [--starter <content-home|product-launch|ops-dashboard>] [--out <index.html>] [--title <name>] [--brief <text>] [--sections <a|b|c>] [--no-external-facts] [--no-brand-assets] [--no-real-images] [--force] [--json]',
609
667
  ' openprd dev-check [path] <file...> [--auto-refactor <on|off>] [--json]',
668
+ ' openprd i18n-init [path] [--locale <zh-Hans|en|...>] [--json]',
610
669
  ' openprd grow [path] [--review|--apply --id <candidate-id>|--reject --id <candidate-id>|--init|--check] [--notes <text>] [--json]',
611
670
  ' openprd benchmark <add|observe|list|approve|verify> [target-or-id] [path-for-list-or-verify] [--path <project>] [--notes <text>] [--threshold <n>] [--id <benchmark-id>] [--json]',
612
671
  ' openprd synthesize [path] [--title <text>] [--owner <text>] [--problem <text>] [--why-now <text>] [--work-unit <id>] [--target-root <path>] [--open] [--json]',
@@ -0,0 +1,17 @@
1
+ function printGateResult(result, json) {
2
+ if (json) {
3
+ console.log(JSON.stringify(result, null, 2));
4
+ return;
5
+ }
6
+ if (!result.ok) {
7
+ console.log(`OpenPrd 操作门禁:未完成(${result.error?.code ?? 'unknown'})`);
8
+ console.log(result.error?.message ?? '门禁操作失败。');
9
+ return;
10
+ }
11
+ const status = result.result?.status;
12
+ console.log(`OpenPrd 操作门禁:${status === 'pass' ? '通过' : status === 'blocked' ? '已阻断' : '已记录'}`);
13
+ if (result.result?.decisionId) console.log(`决策编号:${result.result.decisionId}`);
14
+ for (const reason of result.result?.reasons ?? []) console.log(`- ${reason.code}`);
15
+ }
16
+
17
+ export { printGateResult };
@@ -218,5 +218,23 @@ export async function runQualityCommand(command, { projectPath, flags, positiona
218
218
  return result.ok ? 0 : 1;
219
219
  }
220
220
 
221
+ if (command === 'i18n-init') {
222
+ const initProjectPath = path.resolve(flags.path ?? positionals[0] ?? process.cwd());
223
+ const { initI18nConfig } = await import('../dev-standards.js');
224
+ const result = await initI18nConfig(initProjectPath, {
225
+ defaultLanguage: flags.locale ?? undefined,
226
+ });
227
+ if (flags.json) {
228
+ console.log(JSON.stringify(result, null, 2));
229
+ } else {
230
+ console.log(`OpenPrd i18n: ${result.created ? '已创建' : '配置已存在'} ${result.configPath}`);
231
+ console.log(`基准语言: ${result.config.defaultLanguage}`);
232
+ console.log(`支持语种: ${result.config.supportedLanguages.join(', ')}`);
233
+ console.log(`dev-check 检查: ${result.config.enforceOnDevCheck ? '开启' : '关闭'}`);
234
+ console.log(`最佳实践文档: docs/basic/i18n-guidelines.md`);
235
+ }
236
+ return 0;
237
+ }
238
+
221
239
  return null;
222
240
  }
@@ -123,6 +123,16 @@ function printDevelopmentStandardsResult(result, json) {
123
123
  } else if (result.visualEvidence?.uiFiles?.length > 0) {
124
124
  console.log(`- 视觉证据: 最近 24 小时已有证据板(${result.visualEvidence.freshEvidence.join(', ')});确认覆盖本轮改动并把证据图嵌入对话流。`);
125
125
  }
126
+ if (result.i18nReminder?.required) {
127
+ console.log(`- 多语言提醒: ${result.i18nReminder.reason}`);
128
+ console.log(` 涉及文件: ${result.i18nReminder.textFiles.join(', ')}`);
129
+ if (result.i18nReminder.guideline) {
130
+ console.log(` 最佳实践: ${result.i18nReminder.guideline}`);
131
+ }
132
+ if (!result.i18nReminder.configExists) {
133
+ console.log(' 建议: 创建 .openprd/i18n-config.json 启用项目级多语言检查。');
134
+ }
135
+ }
126
136
  printKnowledgeReview(result.knowledgeReview);
127
137
  for (const error of result.errors ?? []) {
128
138
  console.log(`- ${error}`);
@@ -0,0 +1,24 @@
1
+ function printTaskRuntimeResult(result, json) {
2
+ if (json) {
3
+ console.log(JSON.stringify(result, null, 2));
4
+ return;
5
+ }
6
+ if (!result.ok) {
7
+ console.log(`OpenPrd 并行任务运行时:未完成(${result.error?.code ?? 'unknown'})`);
8
+ console.log(result.error?.message ?? '运行时操作失败。');
9
+ return;
10
+ }
11
+ console.log(`OpenPrd 并行任务运行时:${result.action.replace(/^runtime-/, '')}`);
12
+ if (result.laneId) console.log(`处理通道:${result.laneId}`);
13
+ if (result.taskId) console.log(`任务:${result.taskId}`);
14
+ const task = result.result?.task;
15
+ if (task) {
16
+ console.log(`状态:${task.status}`);
17
+ console.log(`修订:${task.revision}`);
18
+ if (task.claim?.leaseExpiresAt) console.log(`占用有效期至:${task.claim.leaseExpiresAt}`);
19
+ } else if (Array.isArray(result.result)) {
20
+ console.log(`处理通道数:${result.result.length}`);
21
+ }
22
+ }
23
+
24
+ export { printTaskRuntimeResult };
@@ -765,45 +765,22 @@ function detectExecutionSignals(eventName, payload) {
765
765
  };
766
766
  }
767
767
 
768
- function latestSessionRegistryEntry(root, sessionId) {
769
- if (!sessionId) {
770
- return null;
771
- }
772
- const filePath = sessionRegistryPath();
773
- if (!fs.existsSync(filePath)) {
774
- return null;
775
- }
776
- const workspaceRoot = path.resolve(root);
777
- let latest = null;
778
- for (const line of fs.readFileSync(filePath, 'utf8').split(/\r?\n/)) {
779
- if (!line.trim()) {
780
- continue;
781
- }
782
- try {
783
- const entry = JSON.parse(line);
784
- if (entry?.sessionId !== sessionId) {
785
- continue;
786
- }
787
- const entryRoot = entry?.workspaceRoot ? path.resolve(String(entry.workspaceRoot)) : null;
788
- if (entryRoot && entryRoot !== workspaceRoot) {
789
- continue;
790
- }
791
- latest = entry;
792
- } catch {}
793
- }
794
- return latest;
795
- }
796
-
797
768
  function rememberAutomationMode(root, sessionId, payload, signals, laneKind) {
798
769
  if (!sessionId) {
799
770
  return null;
800
771
  }
801
- return writeSessionRegistryEntry(root, sessionId, {
772
+ const gateStatus = laneKind === 'automation' ? 'automation-safe-mode' : 'openprd-opt-in';
773
+ const existing = readSessionBinding(root, sessionId);
774
+ // 每个 hook 事件都会经过这里;同一会话的 lane 记忆只需要落一次盘。
775
+ if (existing?.laneKind === laneKind && existing?.gateStatus === gateStatus) {
776
+ return existing;
777
+ }
778
+ return writeSessionBinding(root, sessionId, {
802
779
  laneKind,
803
780
  tool: 'openprd-hook',
804
781
  promptPreview: preview(promptText(payload) || payloadText(payload), 500),
805
782
  gateActive: false,
806
- gateStatus: laneKind === 'automation' ? 'automation-safe-mode' : 'openprd-opt-in',
783
+ gateStatus,
807
784
  title: laneKind === 'automation'
808
785
  ? 'Unattended automation bypassed OpenPrd'
809
786
  : 'Unattended automation explicitly enabled OpenPrd',
@@ -820,7 +797,7 @@ function automationBypassDecision(root, eventName, payload, sessionId) {
820
797
  if (signals.isAutomation) {
821
798
  return { bypass: true, optIn: false, remembered: false, signals };
822
799
  }
823
- const remembered = eventName === 'UserPromptSubmit' ? null : latestSessionRegistryEntry(root, sessionId);
800
+ const remembered = eventName === 'UserPromptSubmit' ? null : readSessionBinding(root, sessionId);
824
801
  if (remembered?.laneKind === 'automation' && remembered?.gateStatus === 'automation-safe-mode') {
825
802
  return { bypass: true, optIn: false, remembered: true, signals };
826
803
  }
@@ -912,44 +889,68 @@ function readSessionBinding(root, sessionId = null) {
912
889
  return readJsonSync(sessionBindingPath(root, sessionId), null);
913
890
  }
914
891
 
915
- function appendJsonLine(filePath, value) {
892
+ function appendJsonLineAtomic(filePath, value) {
916
893
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
917
- fs.appendFileSync(filePath, JSON.stringify(value) + '\n');
894
+ const fd = fs.openSync(filePath, 'a', 0o600);
895
+ try {
896
+ fs.writeSync(fd, `${JSON.stringify(value)}\n`);
897
+ fs.fsyncSync(fd);
898
+ } finally {
899
+ fs.closeSync(fd);
900
+ }
918
901
  }
919
902
 
920
- function writeSessionRegistryEntry(root, sessionId, patch = {}) {
903
+ function sessionRegistryProjection(entry = {}) {
904
+ return {
905
+ sessionId: entry.sessionId ?? null,
906
+ workspaceRoot: entry.workspaceRoot ?? null,
907
+ laneKind: entry.laneKind ?? 'requirement',
908
+ tool: entry.tool ?? 'codex',
909
+ threadId: entry.threadId ?? null,
910
+ changeId: entry.changeId ?? null,
911
+ taskHandle: entry.taskHandle ?? null,
912
+ workUnitId: entry.workUnitId ?? null,
913
+ versionId: entry.versionId ?? null,
914
+ digest: entry.digest ?? null,
915
+ title: entry.title ?? null,
916
+ targetRoot: entry.targetRoot ?? null,
917
+ promptPreview: entry.promptPreview ?? null,
918
+ reviewStatus: entry.reviewStatus ?? null,
919
+ gateStatus: entry.gateStatus ?? null,
920
+ gateActive: entry.gateActive === true,
921
+ };
922
+ }
923
+
924
+ function writeSessionRegistryEntry(root, sessionId, binding, previous = null) {
921
925
  if (!sessionId) {
922
926
  return null;
923
927
  }
924
928
  const workspaceRoot = path.resolve(root);
925
- const recordedAt = patch.recordedAt || now();
926
- const entry = {
927
- version: 1,
929
+ const projection = sessionRegistryProjection({
930
+ ...binding,
928
931
  sessionId,
929
932
  workspaceRoot,
933
+ });
934
+ const previousProjection = previous
935
+ ? sessionRegistryProjection({ ...previous, sessionId, workspaceRoot })
936
+ : null;
937
+ if (previousProjection && JSON.stringify(previousProjection) === JSON.stringify(projection)) {
938
+ return null;
939
+ }
940
+ const recordedAt = binding.updatedAt || now();
941
+ const entry = {
942
+ version: 1,
943
+ ...projection,
930
944
  realpath: workspaceRoot,
931
- laneKind: patch.laneKind || 'requirement',
932
- tool: patch.tool || 'codex',
933
- threadId: patch.threadId || null,
934
- changeId: patch.changeId || null,
935
- taskHandle: patch.taskHandle || null,
936
- workUnitId: patch.workUnitId || null,
937
- versionId: patch.versionId || null,
938
- digest: patch.digest || null,
939
- title: patch.title || null,
940
- targetRoot: patch.targetRoot ? path.resolve(patch.targetRoot) : null,
941
- promptPreview: patch.promptPreview || null,
942
- reviewStatus: patch.reviewStatus || null,
943
- gateStatus: patch.gateStatus || null,
944
- gateActive: patch.gateActive === true,
945
- statePath: patch.statePath || sessionStatePath(root, sessionId),
946
- bindingPath: patch.bindingPath || sessionBindingPath(root, sessionId),
947
- firstRegisteredAt: patch.firstRegisteredAt || recordedAt,
945
+ targetRoot: projection.targetRoot ? path.resolve(projection.targetRoot) : null,
946
+ statePath: sessionStatePath(root, sessionId),
947
+ bindingPath: sessionBindingPath(root, sessionId),
948
+ firstRegisteredAt: previous?.createdAt || binding.createdAt || recordedAt,
948
949
  lastRegisteredAt: recordedAt,
949
- lastUpdatedAt: patch.updatedAt || recordedAt,
950
+ lastUpdatedAt: recordedAt,
950
951
  recordedAt,
951
952
  };
952
- appendJsonLine(sessionRegistryPath(), entry);
953
+ appendJsonLineAtomic(sessionRegistryPath(), entry);
953
954
  return entry;
954
955
  }
955
956
 
@@ -968,25 +969,7 @@ function writeSessionBinding(root, sessionId, patch = {}) {
968
969
  updatedAt: patch.updatedAt ?? now(),
969
970
  };
970
971
  writeJsonSync(filePath, next);
971
- writeSessionRegistryEntry(root, sessionId, {
972
- laneKind: patch.laneKind ?? previous?.laneKind ?? 'requirement',
973
- tool: patch.tool ?? previous?.tool ?? 'codex',
974
- threadId: patch.threadId ?? previous?.threadId ?? null,
975
- changeId: patch.changeId ?? next.changeId ?? null,
976
- taskHandle: patch.taskHandle ?? next.taskHandle ?? null,
977
- workUnitId: patch.workUnitId ?? next.workUnitId ?? null,
978
- versionId: patch.versionId ?? next.versionId ?? null,
979
- digest: patch.digest ?? next.digest ?? null,
980
- title: patch.title ?? next.title ?? null,
981
- targetRoot: patch.targetRoot ?? next.targetRoot ?? null,
982
- promptPreview: patch.promptPreview ?? next.promptPreview ?? null,
983
- reviewStatus: patch.reviewStatus ?? next.reviewStatus ?? null,
984
- gateStatus: patch.gateStatus ?? next.gateStatus ?? null,
985
- gateActive: patch.gateActive ?? next.gateActive ?? false,
986
- bindingPath: filePath,
987
- statePath: sessionStatePath(root, sessionId),
988
- updatedAt: next.updatedAt,
989
- });
972
+ writeSessionRegistryEntry(root, sessionId, next, previous);
990
973
  return next;
991
974
  }
992
975
 
@@ -1561,6 +1544,9 @@ function knowledgeSkillContextLines(knowledgeSkills) {
1561
1544
  }
1562
1545
 
1563
1546
  function renderRunContextText(result) {
1547
+ if (String(result?.contextText || '').trim()) {
1548
+ return String(result.contextText).trim();
1549
+ }
1564
1550
  const lines = [
1565
1551
  '当前进展参考',
1566
1552
  '当前项目: ' + result.projectRoot,
@@ -1935,6 +1921,8 @@ function analyzePromptIntent(prompt) {
1935
1921
  && !codeVisualArtifactRequested;
1936
1922
  const learningMaterialIntent = detectLearningMaterialIntent(text);
1937
1923
  const learningMaterialRequest = learningMaterialIntent.requested;
1924
+ const canvasCollaborationRequest = /(画布|canvas|excalidraw)/i.test(text)
1925
+ && /(标注|批注|协同|改图|回填|贴到画布|放到画布|插入画布|插到画布|画布评审|画布反馈|一起看|openprd\s*canvas|annotation)/i.test(text);
1938
1926
  const visualReview = /效果图|实现截图|视觉对比|视觉评审|对标效果图|复刻|不一致|好丑|没对齐|对不上|不像/i.test(text);
1939
1927
  const directBugfixExecution = explicitExecution && bugfixOrDiagnostic;
1940
1928
  const newFeatureVerbMatched = /(新增|增加|新建)/.test(text);
@@ -2043,6 +2031,7 @@ function analyzePromptIntent(prompt) {
2043
2031
  learningMaterialRequest,
2044
2032
  learningGenre: learningMaterialIntent.genre,
2045
2033
  learningMaterialReason: learningMaterialIntent.reason,
2034
+ canvasCollaborationRequest,
2046
2035
  shouldInject: requiresIntake
2047
2036
  || requirementTier === 'l1'
2048
2037
  || explicitExecution
@@ -2059,6 +2048,7 @@ function analyzePromptIntent(prompt) {
2059
2048
  || browserSafetyRequest
2060
2049
  || productCopyRequest
2061
2050
  || learningMaterialRequest
2051
+ || canvasCollaborationRequest
2062
2052
  || /openprd/i.test(normalized)
2063
2053
  || /\bprd\b/i.test(normalized),
2064
2054
  };
@@ -3461,6 +3451,21 @@ function visualMockupMessage(intent, root = null) {
3461
3451
  ].join('\n');
3462
3452
  }
3463
3453
 
3454
+ function canvasCollaborationMessage(intent) {
3455
+ if (!intent?.canvasCollaborationRequest) {
3456
+ return null;
3457
+ }
3458
+ return [
3459
+ '当前请求涉及 OpenPrd 画布协同(生图上画布、人在画布上标注、标注结果回到对话继续改)。',
3460
+ '打开或复用画布:运行 `openprd canvas .`;无法自动识别当前对话时用 `--thread <id> --thread-title <name>` 或 `--session <id> --session-title <name>` 显式绑定,画布必须绑定当前对话。',
3461
+ '结构化读画布:需要知道用户正在圈哪些元素时,优先 `GET /api/selection` 读取选中元素的 ID、类型、坐标、尺寸和文本,不要只靠截图猜;需要全量场景再读 `GET /api/scene`。',
3462
+ '结构化写画布:把生成的新图放上画布时,优先 `POST /api/insert-image`,用 `anchorElementId` + `placement`(right/left/below/above)把新图放到指定元素旁边,不要覆盖原图;回填 AI 生图占位卡时继续用 `POST /api/images` 带 `placeholderId`。',
3463
+ '标注改图闭环:如果交接记录(handoff)的 `selection` 是 `annotation-edit`,或带有 `sourceImageElementIds` / `annotationElementIds`,说明用户在原图上画了标注要求改图。把截图里的标注当修改指令,生成修改后的新图,再用 `POST /api/insert-image` 以原图为 anchor 放到旁边,方便用户对比原图和新版。',
3464
+ '生图尺寸契约:handoff 或占位卡带 `sizeContract`(width/height/aspectRatio)时,生成图片必须按这个宽高比出图,避免回填时变形拉伸。',
3465
+ '每轮改图后主动确认:新图是否符合标注意图、是否继续下一轮标注迭代;用户可以继续在新图上标注,形成多轮改图闭环。',
3466
+ ].join('\n');
3467
+ }
3468
+
3464
3469
  function learningGenreFlag(intent = null) {
3465
3470
  return intent?.learningGenre ? ` --genre ${intent.learningGenre}` : '';
3466
3471
  }
@@ -3488,7 +3493,9 @@ function largeUiVisualDirectionMessage() {
3488
3493
  'OpenPrd 大界面改动视觉方案评审:',
3489
3494
  '位置: 需求分流之后、PRD 定稿或实现开工之前;它不同于 review.html,也不同于实现后的 visual-compare。',
3490
3495
  '判断: 先从用户目标、信息架构变化、视觉决策成本和验收风险判断是否需要方向评审,不用关键词触发。会决定首屏、核心布局、主视觉、关键路径、组件层级/密度,或用户需要先选设计方向时触发。',
3491
- '步骤: 已有界面时用 Codex Computer Use 进入产品内对应功能并截当前真实界面;冷启动没有现有界面时,基于已确认 PRD、用户画像、第一版切片和视觉目标写设计 brief。brief 必须写清用途、受众、气质端点、约束和记忆点,3 个方向要分别说明审美主张和主动避开的模板味。然后按生图路由调用内置生图工具(Codex=`imagegen`/Image 2,Cursor=`GenerateImage`;都不可用时按 `.openprd/harness/image-generation-preference.json` 的用户已确认偏好,没有偏好先问用户)生成至少 3 个不同设计思想方向;把效果图横向拼成一张带 1/2/3 序号的大图,先作为候选效果图给用户评审,不要默认写入 `.openprd/harness/visual-reviews/` 当验收参考。',
3496
+ '自动性: 用户不需要额外提出“请生图”。只要语义判断命中大界面改动或视觉决策成本高,就主动完成 3 方向生成;卡片宽度、间距、对齐、颜色、圆角、字号等轻量微调不触发。',
3497
+ '步骤: 已有界面时,Codex 原生 App 用 Computer Use,Codex 网页环境用 Chrome 插件的任务专用窗口,Cursor 用当前可用的浏览器或项目预览能力,进入对应功能并截当前真实界面。三个方向必须把同一张截图作为 image-to-image 参考;除非用户明确要求换风格,否则锁定现有字体角色、色彩系统、明暗模式、表面语言、圆角/边框/阴影、图标语言、平台框架和信息密度,只探索布局、信息层级、导航、关键路径和空间构图。冷启动没有现有界面时,基于已确认 PRD、用户画像、第一版切片和视觉目标写 design brief,不伪造修改前截图。brief 必须写清用途、受众、气质端点、约束和记忆点,3 个方向要分别说明审美主张和主动避开的模板味。然后按生图路由调用内置生图工具(Codex=`imagegen`/Image 2,Cursor=`GenerateImage`;都不可用时按 `.openprd/harness/image-generation-preference.json` 的用户已确认偏好,没有偏好先问用户)生成至少 3 个不同设计思想方向;把效果图横向拼成一张带 1/2/3 序号的大图,先作为候选效果图给用户评审,不要默认写入 `.openprd/harness/visual-reviews/` 当验收参考。',
3498
+ '质量: 每个方向都要从用途、用户、业务约束、核心动作和唯一记忆点出发,同时检查字体角色、色彩、空间构图、信息层级、表面/图标语言和动效含义;拒绝无依据蓝紫渐变、默认字体、等权白卡堆叠、过量大圆角、无语境装饰和只换颜色的伪方向。',
3492
3499
  '交互: 主动追问三件事:是否符合预期、是否纳入后续效果图/实现截图对比、是否按此继续后续实现。只有用户确认纳入后续对比或继续实现后,才把选定方向、整张图或其中子图整理成 reference-set 并写入 `.openprd/harness/visual-reviews/`;用户确认前,不进入大 UI 实现,也不要声称界面方案已定。',
3493
3500
  '多对象参考处理: 如果候选效果图是一张图里包含多个子图、网格、多对象或多方向,不要拿整板直接硬比;先运行 `openprd visual-prepare . --reference <效果图> --grid <列>x<行>` 或 `--boxes <plan.json>`,检查 contact sheet,确认 reference-set 后再逐项进入实现和验收。',
3494
3501
  ].join('\n');
@@ -3651,6 +3658,7 @@ function contextMessage(cwd, intent = null, gate = null, progress = null) {
3651
3658
  'OpenPrd 上下文只是建议,不是自动执行指令。请先判断用户当前意图。',
3652
3659
  lightweightRequirementMessage(intent),
3653
3660
  visualMockupMessage(intent, cwd),
3661
+ canvasCollaborationMessage(intent),
3654
3662
  learningReviewMessage(intent),
3655
3663
  largeUiVisualDirectionMessage(),
3656
3664
  visualLocalAdjustmentMessage(),
@@ -3673,6 +3681,7 @@ function contextMessage(cwd, intent = null, gate = null, progress = null) {
3673
3681
  'OpenPrd 上下文只是建议,不是自动执行指令。请先判断用户当前意图。',
3674
3682
  lightweightRequirementMessage(intent),
3675
3683
  visualMockupMessage(intent, cwd),
3684
+ canvasCollaborationMessage(intent),
3676
3685
  learningReviewMessage(intent),
3677
3686
  largeUiVisualDirectionMessage(),
3678
3687
  visualLocalAdjustmentMessage(),
@@ -3700,6 +3709,7 @@ function contextMessage(cwd, intent = null, gate = null, progress = null) {
3700
3709
  gateMessage,
3701
3710
  lightweightRequirementMessage(intent),
3702
3711
  visualMockupMessage(intent, cwd),
3712
+ canvasCollaborationMessage(intent),
3703
3713
  learningReviewMessage(intent),
3704
3714
  largeUiVisualDirectionMessage(intent),
3705
3715
  requirementRoutingSummary(),
@@ -89,6 +89,29 @@ function runProcess(command, args = [], options = {}) {
89
89
  });
90
90
  let stdout = '';
91
91
  let stderr = '';
92
+ let settled = false;
93
+ let timedOut = false;
94
+ const timeoutMs = Number.isFinite(options.timeoutMs)
95
+ ? Math.max(1, Number(options.timeoutMs))
96
+ : 15000;
97
+ const killGraceMs = Number.isFinite(options.killGraceMs)
98
+ ? Math.max(1, Number(options.killGraceMs))
99
+ : 1000;
100
+ let killTimer = null;
101
+ const timeoutTimer = setTimeout(() => {
102
+ timedOut = true;
103
+ child.kill('SIGTERM');
104
+ killTimer = setTimeout(() => child.kill('SIGKILL'), killGraceMs);
105
+ killTimer.unref?.();
106
+ }, timeoutMs);
107
+ timeoutTimer.unref?.();
108
+ const finish = (payload) => {
109
+ if (settled) return;
110
+ settled = true;
111
+ clearTimeout(timeoutTimer);
112
+ if (killTimer) clearTimeout(killTimer);
113
+ resolve(payload);
114
+ };
92
115
  child.stdout.on('data', (chunk) => {
93
116
  stdout = `${stdout}${chunk}`.slice(-64000);
94
117
  });
@@ -96,7 +119,7 @@ function runProcess(command, args = [], options = {}) {
96
119
  stderr = `${stderr}${chunk}`.slice(-64000);
97
120
  });
98
121
  child.on('error', (error) => {
99
- resolve({
122
+ finish({
100
123
  ok: false,
101
124
  command,
102
125
  args,
@@ -104,19 +127,22 @@ function runProcess(command, args = [], options = {}) {
104
127
  exitCode: null,
105
128
  stdout,
106
129
  stderr,
130
+ timedOut,
107
131
  error: error.message,
108
132
  });
109
133
  });
110
- child.on('close', (exitCode) => {
111
- resolve({
112
- ok: exitCode === 0,
134
+ child.on('close', (exitCode, signal) => {
135
+ finish({
136
+ ok: !timedOut && exitCode === 0,
113
137
  command,
114
138
  args,
115
139
  display: invocation.display,
116
140
  exitCode,
141
+ signal,
117
142
  stdout,
118
143
  stderr,
119
- error: null,
144
+ timedOut,
145
+ error: timedOut ? `Command timed out after ${timeoutMs}ms.` : null,
120
146
  });
121
147
  });
122
148
  });
@@ -131,6 +157,8 @@ function normalizeRunResult(command, args, result = {}) {
131
157
  exitCode: result.exitCode ?? result.status ?? null,
132
158
  stdout: String(result.stdout ?? ''),
133
159
  stderr: String(result.stderr ?? ''),
160
+ timedOut: Boolean(result.timedOut),
161
+ signal: result.signal ? String(result.signal) : null,
134
162
  error: result.error ? String(result.error) : null,
135
163
  };
136
164
  }
@@ -178,6 +206,17 @@ export function diagnoseCodexVersionFailure(result, options = {}) {
178
206
  };
179
207
  }
180
208
 
209
+ if (result.timedOut) {
210
+ return {
211
+ type: 'version-check-timeout',
212
+ summary: `Codex CLI 健康检查超时:codex --version 在 ${options.timeoutMs ?? 15000}ms 内没有返回。`,
213
+ missingPackage: null,
214
+ repairCommand,
215
+ manualCommand: repairCommand.display,
216
+ output,
217
+ };
218
+ }
219
+
181
220
  if (
182
221
  result.exitCode === null
183
222
  && /(ENOENT|not found|command not found|no such file|spawn .*enoent)/i.test(output)
@@ -212,6 +251,8 @@ export async function checkCodexCliHealth(options = {}) {
212
251
  await runner(command, args, {
213
252
  cwd: options.cwd,
214
253
  env: options.env ?? process.env,
254
+ timeoutMs: options.timeoutMs ?? 15000,
255
+ killGraceMs: options.killGraceMs,
215
256
  }),
216
257
  );
217
258
  const repairCommand = buildCodexRepairCommand(options);
@@ -257,6 +298,8 @@ export async function repairCodexCli(options = {}) {
257
298
  await runner(commandInfo.command, commandInfo.args, {
258
299
  cwd: options.cwd,
259
300
  env: options.env ?? process.env,
301
+ timeoutMs: options.repairTimeoutMs ?? 300000,
302
+ killGraceMs: options.killGraceMs,
260
303
  }),
261
304
  );
262
305
  const recheck = result.ok ? await checkCodexCliHealth(options) : null;