@openprd/cli 0.1.11 → 0.1.19

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 (147) hide show
  1. package/.openprd/changes/adaptive-project-framing/.openprd.yaml +2 -0
  2. package/.openprd/changes/adaptive-project-framing/design.md +47 -0
  3. package/.openprd/changes/adaptive-project-framing/proposal.md +35 -0
  4. package/.openprd/changes/adaptive-project-framing/specs/agent-requirements/spec.md +16 -0
  5. package/.openprd/changes/adaptive-project-framing/task-events.jsonl +20 -0
  6. package/.openprd/changes/adaptive-project-framing/tasks.md +340 -0
  7. package/.openprd/changes/docs-anti-pollution-and-design-direction-review/.openprd.yaml +2 -0
  8. package/.openprd/changes/docs-anti-pollution-and-design-direction-review/design.md +34 -0
  9. package/.openprd/changes/docs-anti-pollution-and-design-direction-review/proposal.md +23 -0
  10. package/.openprd/changes/docs-anti-pollution-and-design-direction-review/specs/agent-requirements/spec.md +16 -0
  11. package/.openprd/changes/docs-anti-pollution-and-design-direction-review/task-events.jsonl +14 -0
  12. package/.openprd/changes/docs-anti-pollution-and-design-direction-review/tasks.md +238 -0
  13. package/.openprd/changes/loop-isolated-worktree-commit/.openprd.yaml +2 -0
  14. package/.openprd/changes/loop-isolated-worktree-commit/design.md +53 -0
  15. package/.openprd/changes/loop-isolated-worktree-commit/proposal.md +38 -0
  16. package/.openprd/changes/loop-isolated-worktree-commit/specs/agent-requirements/spec.md +16 -0
  17. package/.openprd/changes/loop-isolated-worktree-commit/task-events.jsonl +22 -0
  18. package/.openprd/changes/loop-isolated-worktree-commit/tasks.md +357 -0
  19. package/.openprd/changes/openprd/.openprd.yaml +2 -0
  20. package/.openprd/changes/openprd/design.md +46 -0
  21. package/.openprd/changes/openprd/proposal.md +36 -0
  22. package/.openprd/changes/openprd/specs/agent-requirements/spec.md +16 -0
  23. package/.openprd/changes/openprd/task-events.jsonl +23 -0
  24. package/.openprd/changes/openprd/tasks.md +242 -0
  25. package/.openprd/changes/openprd-generated-change/.openprd.yaml +2 -0
  26. package/.openprd/changes/openprd-generated-change/design.md +50 -0
  27. package/.openprd/changes/openprd-generated-change/proposal.md +37 -0
  28. package/.openprd/changes/openprd-generated-change/specs/agent-requirements/spec.md +16 -0
  29. package/.openprd/changes/openprd-generated-change/task-events.jsonl +21 -0
  30. package/.openprd/changes/openprd-generated-change/tasks.md +357 -0
  31. package/.openprd/changes/pm-human-in-the-loop/.openprd.yaml +2 -0
  32. package/.openprd/changes/pm-human-in-the-loop/design.md +38 -0
  33. package/.openprd/changes/pm-human-in-the-loop/proposal.md +29 -0
  34. package/.openprd/changes/pm-human-in-the-loop/specs/agent-requirements/spec.md +16 -0
  35. package/.openprd/changes/pm-human-in-the-loop/task-events.jsonl +18 -0
  36. package/.openprd/changes/pm-human-in-the-loop/tasks.md +306 -0
  37. package/.openprd/changes/test-strategy-router/.openprd.yaml +2 -0
  38. package/.openprd/changes/test-strategy-router/design.md +65 -0
  39. package/.openprd/changes/test-strategy-router/proposal.md +45 -0
  40. package/.openprd/changes/test-strategy-router/specs/agent-requirements/spec.md +16 -0
  41. package/.openprd/changes/test-strategy-router/task-events.jsonl +1 -0
  42. package/.openprd/changes/test-strategy-router/tasks.md +132 -0
  43. package/.openprd/changes/verify-boundaries-and-historical-refresh/.openprd.yaml +2 -0
  44. package/.openprd/changes/verify-boundaries-and-historical-refresh/design.md +28 -0
  45. package/.openprd/changes/verify-boundaries-and-historical-refresh/proposal.md +23 -0
  46. package/.openprd/changes/verify-boundaries-and-historical-refresh/specs/agent-requirements/spec.md +16 -0
  47. package/.openprd/changes/verify-boundaries-and-historical-refresh/task-events.jsonl +11 -0
  48. package/.openprd/changes/verify-boundaries-and-historical-refresh/tasks.md +66 -0
  49. package/.openprd/changes/vibe-coding-dev-check/.openprd.yaml +2 -0
  50. package/.openprd/changes/vibe-coding-dev-check/design.md +37 -0
  51. package/.openprd/changes/vibe-coding-dev-check/proposal.md +28 -0
  52. package/.openprd/changes/vibe-coding-dev-check/specs/agent-requirements/spec.md +16 -0
  53. package/.openprd/changes/vibe-coding-dev-check/task-events.jsonl +18 -0
  54. package/.openprd/changes/vibe-coding-dev-check/tasks.md +306 -0
  55. package/.openprd/design/README.md +11 -6
  56. package/.openprd/design/README_EN.md +4 -1
  57. package/.openprd/design/active/asset-spec.md +6 -0
  58. package/.openprd/design/active/direction-plan.md +5 -5
  59. package/.openprd/design/active/selected-direction.md +2 -0
  60. package/.openprd/design/anti-slop.md +26 -1
  61. package/.openprd/design/assets/surface-presets.md +6 -0
  62. package/.openprd/design/checklists/ui-quality-gate.md +7 -0
  63. package/.openprd/design/lenses/frontend-lenses.md +28 -0
  64. package/.openprd/design/recipes/content-experience.md +8 -1
  65. package/.openprd/design/recipes/operational-tool.md +7 -0
  66. package/.openprd/design/recipes/product-launch.md +8 -1
  67. package/.openprd/design/templates/README.md +6 -3
  68. package/.openprd/design/templates/README_EN.md +6 -3
  69. package/.openprd/design/templates/ops-dashboard.html +8 -1
  70. package/.openprd/design/templates/product-launch.html +2 -1
  71. package/.openprd/design/themes/theme-catalog.json +23 -5
  72. package/.openprd/engagements/active/prd.md +137 -112
  73. package/.openprd/standards/config.json +7 -1
  74. package/AGENTS.md +13 -3
  75. package/README.md +127 -14
  76. package/README_EN.md +156 -13
  77. package/package.json +1 -1
  78. package/scripts/dev-check-wrapup-copy.mjs +46 -21
  79. package/skills/openprd-diagram-review/SKILL.md +27 -2
  80. package/skills/openprd-diagram-review/references/explanation-svg-patterns.md +131 -0
  81. package/skills/openprd-discovery-loop/SKILL.md +2 -2
  82. package/skills/openprd-frontend-design/SKILL.md +44 -20
  83. package/skills/openprd-frontend-design/references/design-asset-contract.md +6 -0
  84. package/skills/openprd-frontend-design/references/direction-engine.md +29 -4
  85. package/skills/openprd-harness/SKILL.md +22 -16
  86. package/skills/openprd-quality/SKILL.md +10 -8
  87. package/skills/openprd-requirement-intake/SKILL.md +2 -0
  88. package/skills/openprd-router/SKILL.md +2 -1
  89. package/skills/openprd-shared/SKILL.md +26 -16
  90. package/skills/openprd-standards/SKILL.md +4 -2
  91. package/src/agent-canonical-content.js +810 -0
  92. package/src/agent-integration.js +258 -777
  93. package/src/canvas-app-client-app.js +838 -0
  94. package/src/canvas-app-client-helpers.js +1138 -0
  95. package/src/canvas-app-styles.js +898 -0
  96. package/src/canvas-app.html.js +135 -0
  97. package/src/canvas-binding.js +414 -0
  98. package/src/canvas-codex-relay.js +430 -0
  99. package/src/canvas-i18n.js +282 -0
  100. package/src/canvas-scene.js +377 -0
  101. package/src/canvas-session-store.js +148 -0
  102. package/src/canvas-workspace.js +1264 -0
  103. package/src/cli/args.js +72 -4
  104. package/src/cli/basic-print.js +3 -0
  105. package/src/cli/canvas-print.js +106 -0
  106. package/src/cli/doctor-print.js +20 -0
  107. package/src/cli/print.js +2 -0
  108. package/src/cli/quality-commands.js +222 -0
  109. package/src/cli/quality-print.js +28 -2
  110. package/src/cli/run-print.js +5 -0
  111. package/src/cli/workflow-print.js +3 -0
  112. package/src/codex-app-server-relay.js +251 -0
  113. package/src/codex-hook-runner-template.mjs +978 -58
  114. package/src/design-starter-support.js +2 -2
  115. package/src/design-starter.js +39 -3
  116. package/src/dev-standards.js +125 -10
  117. package/src/diagram-core.js +6 -20
  118. package/src/discovery.js +2 -8
  119. package/src/docs-compact.js +125 -0
  120. package/src/execution-strategy.js +1 -1
  121. package/src/fleet.js +9 -3
  122. package/src/guide.js +227 -0
  123. package/src/html-artifacts.js +428 -7
  124. package/src/knowledge.js +45 -11
  125. package/src/language-policy.js +63 -3
  126. package/src/learning-review.js +1 -1
  127. package/src/loop.js +1 -1
  128. package/src/openprd.js +68 -131
  129. package/src/openspec/change-validate.js +2 -2
  130. package/src/openspec/constants.js +6 -7
  131. package/src/openspec/execute.js +2 -2
  132. package/src/openspec/generate.js +318 -102
  133. package/src/openspec/migration.js +168 -0
  134. package/src/openspec/paths.js +1 -22
  135. package/src/prd-core.js +1 -1
  136. package/src/quality-visual-review.js +257 -14
  137. package/src/quality.js +46 -8
  138. package/src/review-presentation.js +60 -0
  139. package/src/run-harness.js +41 -3
  140. package/src/session-evidence.js +363 -0
  141. package/src/standards.js +64 -0
  142. package/src/test-strategy.js +7 -4
  143. package/src/visual-compare-centering.js +622 -0
  144. package/src/visual-compare-core.js +1123 -0
  145. package/src/visual-compare.js +620 -698
  146. package/src/workspace-core.js +94 -4
  147. package/src/workspace-workflow.js +46 -1
@@ -80,6 +80,37 @@ function ensureHarness(root) {
80
80
  if (!fs.existsSync(eventsPath)) {
81
81
  fs.writeFileSync(eventsPath, '');
82
82
  }
83
+ const runtimePath = path.join(dir, 'runtime-environment.json');
84
+ if (!fs.existsSync(runtimePath)) {
85
+ writeJsonSync(runtimePath, {
86
+ version: 1,
87
+ active: {
88
+ activeClient: 'unknown',
89
+ surface: 'unknown',
90
+ executionMode: 'interactive',
91
+ confidence: 0,
92
+ sessionId: null,
93
+ turnId: null,
94
+ agentType: null,
95
+ model: null,
96
+ evidence: [],
97
+ observedCapabilities: [],
98
+ },
99
+ runtimeDetection: {
100
+ version: 1,
101
+ activeEvidencePriority: [
102
+ 'hook-session-payload',
103
+ 'explicit-launcher',
104
+ 'sdk-or-headless-session',
105
+ 'child-process-env',
106
+ 'config-or-cli-probe',
107
+ 'agent-self-report',
108
+ ],
109
+ },
110
+ platformCapabilityPacks: [],
111
+ updatedAt: null,
112
+ });
113
+ }
83
114
  }
84
115
 
85
116
  function readJsonSync(filePath, fallback) {
@@ -100,6 +131,98 @@ function appendEvent(root, event) {
100
131
  fs.appendFileSync(path.join(harnessDir(root), 'events.jsonl'), JSON.stringify({ at: now(), ...event }) + '\n');
101
132
  }
102
133
 
134
+ function runtimeEnvironmentPath(root) {
135
+ return path.join(harnessDir(root), 'runtime-environment.json');
136
+ }
137
+
138
+ function compactString(value) {
139
+ const text = String(value ?? '').trim();
140
+ return text || null;
141
+ }
142
+
143
+ function runtimeEvidenceForPayload(eventName, payload) {
144
+ const evidence = ['hook.event_name'];
145
+ const addIfPresent = (key, label = key) => {
146
+ if (payload && payload[key] !== undefined && payload[key] !== null && String(payload[key]).trim() !== '') {
147
+ evidence.push(`hook.${label}`);
148
+ }
149
+ };
150
+ addIfPresent('session_id', 'session_id');
151
+ addIfPresent('sessionId', 'sessionId');
152
+ addIfPresent('transcript_path', 'transcript_path');
153
+ addIfPresent('transcriptPath', 'transcriptPath');
154
+ addIfPresent('cwd', 'cwd');
155
+ addIfPresent('model', 'model');
156
+ addIfPresent('turn_id', 'turn_id');
157
+ addIfPresent('turnId', 'turnId');
158
+ addIfPresent('agent_type', 'agent_type');
159
+ addIfPresent('agentType', 'agentType');
160
+ addIfPresent('tool_name', 'tool_name');
161
+ addIfPresent('toolName', 'toolName');
162
+ if (eventName) {
163
+ evidence.push(`event.${eventName}`);
164
+ }
165
+ return [...new Set(evidence)];
166
+ }
167
+
168
+ function observedCodexCapabilities(payload) {
169
+ const observed = ['codex-hooks'];
170
+ const tool = toolName(payload).toLowerCase();
171
+ const haystack = `${tool}\n${payloadText(payload)}`;
172
+ if (/\b(imagegen|image_gen|image-generation|image_generation)\b/i.test(haystack)) {
173
+ observed.push('native-image-generation');
174
+ }
175
+ if (/\b(computer-use|computer_use|browser|chrome)\b/i.test(haystack)) {
176
+ observed.push('computer-use');
177
+ }
178
+ return [...new Set(observed)];
179
+ }
180
+
181
+ function recordRuntimeEnvironment(root, eventName, payload, sessionId) {
182
+ ensureHarness(root);
183
+ const current = readJsonSync(runtimeEnvironmentPath(root), {});
184
+ const executionSignals = detectExecutionSignals(eventName, payload);
185
+ const evidence = [...new Set([
186
+ ...runtimeEvidenceForPayload(eventName, payload),
187
+ ...executionSignals.evidence,
188
+ ...executionSignals.optInEvidence,
189
+ ])];
190
+ const active = {
191
+ activeClient: 'codex',
192
+ surface: 'codex-hook',
193
+ executionMode: executionSignals.executionMode,
194
+ confidence: evidence.some((item) => item === 'hook.session_id' || item === 'hook.sessionId' || item === 'hook.transcript_path' || item === 'hook.transcriptPath') ? 0.95 : 0.9,
195
+ sessionId: sessionId ?? null,
196
+ turnId: compactString(payload?.turn_id ?? payload?.turnId),
197
+ agentType: compactString(payload?.agent_type ?? payload?.agentType),
198
+ model: compactString(payload?.model),
199
+ evidence,
200
+ observedCapabilities: observedCodexCapabilities(payload),
201
+ };
202
+ const entry = {
203
+ version: 1,
204
+ ...current,
205
+ active,
206
+ lastEventName: eventName,
207
+ updatedAt: now(),
208
+ recentObservations: [
209
+ ...(Array.isArray(current.recentObservations) ? current.recentObservations : []),
210
+ {
211
+ at: now(),
212
+ eventName,
213
+ activeClient: active.activeClient,
214
+ surface: active.surface,
215
+ executionMode: active.executionMode,
216
+ sessionId: active.sessionId,
217
+ evidence: active.evidence,
218
+ observedCapabilities: active.observedCapabilities,
219
+ },
220
+ ].slice(-20),
221
+ };
222
+ writeJsonSync(runtimeEnvironmentPath(root), entry);
223
+ return entry;
224
+ }
225
+
103
226
  function turnStatePath(root) {
104
227
  return path.join(harnessDir(root), 'turn-state.json');
105
228
  }
@@ -117,6 +240,7 @@ function defaultTurnState() {
117
240
  startedAt: null,
118
241
  updatedAt: null,
119
242
  touchedFiles: [],
243
+ gateBlockCounts: {},
120
244
  reviewSignals: [],
121
245
  runtimeEvents: [],
122
246
  timeline: [],
@@ -132,6 +256,28 @@ function readTurnState(root) {
132
256
  };
133
257
  }
134
258
 
259
+ // 流程型门禁(需求入口、外部证据、设计预检、Patch Mode)在同一轮内
260
+ // 反复阻断超过该次数时自动熔断降级为提醒放行,避免 Agent 把整轮消耗在
261
+ // 与门禁系统搏斗上;安全型门禁(云端写入、业务写死、secrets)不参与熔断。
262
+ const PROCESS_GATE_FUSE_LIMIT = 3;
263
+
264
+ function bumpProcessGateBlockCount(root, gateKind) {
265
+ const turnState = readTurnState(root);
266
+ const counts = { ...(turnState.gateBlockCounts || {}) };
267
+ counts[gateKind] = Number(counts[gateKind] || 0) + 1;
268
+ writeTurnState(root, { ...turnState, gateBlockCounts: counts });
269
+ return counts[gateKind];
270
+ }
271
+
272
+ function processGateFuseMessage(gateKind, streak, originalReason) {
273
+ return [
274
+ `OpenPrd 门禁熔断: ${gateKind} 门禁本轮已阻断 ${streak - 1} 次,本次自动降级为提醒放行。`,
275
+ '继续按用户已确认的方向完成实际工作,不要再花动作调试门禁本身,也不要再向用户重复索取同一轮确认。',
276
+ '收口时补齐这个门禁原本要求的产物(例如需求确认稿、外部证据或设计合同),并在最终回复里说明本轮发生过门禁熔断降级。',
277
+ originalReason ? `原始门禁提示(供收口对照):\n${originalReason}` : null,
278
+ ].filter(Boolean).join('\n');
279
+ }
280
+
135
281
  function writeTurnState(root, next) {
136
282
  writeJsonSync(turnStatePath(root), {
137
283
  ...defaultTurnState(),
@@ -200,6 +346,7 @@ function beginTurnReview(root, baseEvent, prompt) {
200
346
  status: 'needs-attention',
201
347
  startedAt: now(),
202
348
  touchedFiles: [],
349
+ gateBlockCounts: {},
203
350
  reviewSignals: [],
204
351
  runtimeEvents: promptPreview ? [{
205
352
  eventName: 'user-prompt',
@@ -231,6 +378,96 @@ function recordTouchedFiles(root, payload) {
231
378
  return touchedFiles;
232
379
  }
233
380
 
381
+ function appendTurnReviewSignals(root, signals) {
382
+ const normalizedSignals = (Array.isArray(signals) ? signals : [signals])
383
+ .filter(Boolean)
384
+ .map((signal) => ({
385
+ at: now(),
386
+ ...signal,
387
+ }));
388
+ if (normalizedSignals.length === 0) {
389
+ return [];
390
+ }
391
+ const state = readTurnState(root);
392
+ const existing = Array.isArray(state.reviewSignals) ? state.reviewSignals : [];
393
+ writeTurnState(root, {
394
+ ...state,
395
+ reviewSignals: [...existing, ...normalizedSignals].slice(-80),
396
+ timeline: [
397
+ ...(Array.isArray(state.timeline) ? state.timeline : []),
398
+ ...normalizedSignals.map((signal) => ({
399
+ event: signal.kind,
400
+ status: 'pass',
401
+ message: signal.message ?? signal.preview ?? signal.kind,
402
+ at: signal.at,
403
+ })),
404
+ ].slice(-120),
405
+ });
406
+ return normalizedSignals;
407
+ }
408
+
409
+ function detectVisualEvidenceSignals(payload) {
410
+ const tool = toolName(payload).toLowerCase();
411
+ const command = commandText(payload);
412
+ const text = payloadText(payload);
413
+ const haystack = `${tool}\n${command}\n${text}`;
414
+ const signals = [];
415
+ if (/openprd\s+visual-prepare\b/i.test(haystack)) {
416
+ signals.push({
417
+ kind: 'visual-reference-prepared',
418
+ message: 'visual-prepare reference-set/contact sheet observed',
419
+ preview: preview(command || text, 180),
420
+ });
421
+ }
422
+ if (/openprd\s+visual-compare\b/i.test(haystack)
423
+ || /\.openprd\/harness\/visual-reviews\/(?:visual-|reference-sets\/)/i.test(haystack)
424
+ || /visual-(?:compare|before-after|focus-board|parallel-board|verification-board|alignment-board|centering-board)/i.test(haystack)
425
+ || /\b(?:reference-actual|before-after|focus-board|parallel-board|verification-board|alignment-board|centering-board|center-board)\b/i.test(haystack)) {
426
+ signals.push({
427
+ kind: 'visual-review-artifact',
428
+ message: 'visual-compare artifact observed',
429
+ preview: preview(command || text, 180),
430
+ });
431
+ }
432
+ if (/\bverification-board\b|截图实测证据板|visual-verification-board/i.test(haystack)) {
433
+ signals.push({
434
+ kind: 'visual-verification-board',
435
+ message: 'verification-board artifact observed',
436
+ preview: preview(command || text, 180),
437
+ });
438
+ }
439
+ if (/\balignment-board\b|对齐辅助线证据板|visual-alignment-board/i.test(haystack)) {
440
+ signals.push({
441
+ kind: 'visual-alignment-board',
442
+ message: 'alignment-board artifact observed',
443
+ preview: preview(command || text, 180),
444
+ });
445
+ }
446
+ if (/\b(?:centering-board|center-board)\b|内部居中证据板|visual-centering-board/i.test(haystack)) {
447
+ signals.push({
448
+ kind: 'visual-centering-board',
449
+ message: 'centering-board artifact observed',
450
+ preview: preview(command || text, 180),
451
+ });
452
+ }
453
+ const screenshotTool = /(?:^|[_-])(?:screenshot|view_image)(?:$|[_-])/i.test(tool)
454
+ || /(?:computer|browser|playwright|page|mp|miniprogram|weapp).*screenshot/i.test(tool);
455
+ const screenshotCommand = /\bscreencapture\b|page\.screenshot|\.screenshot\(|mp_screenshot|browser.*screenshot|computer.*screenshot|playwright.*screenshot/i.test(command);
456
+ const screenshotText = /(?:普通截图|实测截图|运行态截图|Computer\s*实测|Browser\s*实测|截图证据|screenshot captured|captured screenshot)/i.test(text);
457
+ if ((screenshotTool || screenshotCommand || screenshotText) && !/openprd\s+visual-compare\b/i.test(haystack)) {
458
+ signals.push({
459
+ kind: 'visual-raw-screenshot',
460
+ message: 'raw screenshot or Computer/Browser visual evidence observed',
461
+ preview: preview(command || text || tool, 180),
462
+ });
463
+ }
464
+ return signals;
465
+ }
466
+
467
+ function recordVisualEvidenceSignals(root, payload) {
468
+ return appendTurnReviewSignals(root, detectVisualEvidenceSignals(payload));
469
+ }
470
+
234
471
  function updateHookState(root, event) {
235
472
  ensureHarness(root);
236
473
  const statePath = path.join(harnessDir(root), 'hook-state.json');
@@ -248,6 +485,9 @@ function updateHookState(root, event) {
248
485
  if (event.fingerprint) {
249
486
  state.recentFingerprints[event.fingerprint] = Date.now();
250
487
  }
488
+ if (event.runtimeEnvironment) {
489
+ state.runtimeEnvironment = event.runtimeEnvironment;
490
+ }
251
491
  for (const [fingerprint, seenAt] of Object.entries(state.recentFingerprints)) {
252
492
  if (Date.now() - Number(seenAt) > 300000) {
253
493
  delete state.recentFingerprints[fingerprint];
@@ -390,6 +630,203 @@ function sessionRegistryPath() {
390
630
  return path.join(openprdHome(), 'registry', 'sessions.jsonl');
391
631
  }
392
632
 
633
+ function truthyFlag(value) {
634
+ return /^(1|true|yes|on)$/i.test(String(value ?? '').trim());
635
+ }
636
+
637
+ function normalizedMode(value) {
638
+ return String(value ?? '').trim().toLowerCase();
639
+ }
640
+
641
+ function compactPayloadField(payload, keys) {
642
+ for (const key of keys) {
643
+ const value = payload?.[key];
644
+ if (value === undefined || value === null || value === false) {
645
+ continue;
646
+ }
647
+ const text = typeof value === 'object' ? JSON.stringify(value) : String(value);
648
+ if (text.trim()) {
649
+ return { key, value: text.trim() };
650
+ }
651
+ }
652
+ return null;
653
+ }
654
+
655
+ function automationPromptHeader(prompt) {
656
+ const header = String(prompt || '').split(/\r?\n/).slice(0, 12).join('\n');
657
+ if (/^\s*Automation\s*:/im.test(header) && /^\s*Automation\s+(ID|memory)\s*:/im.test(header)) {
658
+ return 'prompt.automation-header';
659
+ }
660
+ if (/^\s*(Scheduled|Cron|Unattended)\s+(task|automation)\s*:/im.test(header)) {
661
+ return 'prompt.scheduled-header';
662
+ }
663
+ return null;
664
+ }
665
+
666
+ function explicitOpenPrdOptInEvidence(payload) {
667
+ const evidence = [];
668
+ const mode = normalizedMode(process.env.OPENPRD_EXECUTION_MODE);
669
+ if (['openprd', 'openprd-maintenance', 'maintenance'].includes(mode)) {
670
+ evidence.push(`env.OPENPRD_EXECUTION_MODE=${mode}`);
671
+ }
672
+ if (truthyFlag(process.env.OPENPRD_AUTOMATION_OPT_IN) || truthyFlag(process.env.OPENPRD_ENABLE_IN_AUTOMATION)) {
673
+ evidence.push('env.OPENPRD_AUTOMATION_OPT_IN');
674
+ }
675
+ const explicitField = compactPayloadField(payload, [
676
+ 'openprd_automation_opt_in',
677
+ 'openprdAutomationOptIn',
678
+ 'openprd_opt_in',
679
+ 'openprdOptIn',
680
+ 'enable_openprd',
681
+ 'enableOpenPrd',
682
+ ]);
683
+ if (explicitField) {
684
+ evidence.push(`payload.${explicitField.key}`);
685
+ }
686
+ const text = `${promptText(payload)}\n${payloadText(payload)}`;
687
+ const negative = /(不要|不需要|无需|禁止|别|不是|非|不要执行|不要调用|do\s+not|don't|never|without|no|not)\s+.{0,24}\bopen\s*prd\b/i
688
+ .test(text)
689
+ || /(不要|不需要|无需|禁止|别|不是|非).{0,24}OpenPrd/i.test(text);
690
+ if (!negative && /(OpenPrd\s*(维护任务|maintenance|maintain|opt[- ]?in)|显式启用\s*OpenPrd|启用\s*OpenPrd|使用\s*OpenPrd|运行\s*OpenPrd|enable\s+OpenPrd|use\s+OpenPrd|openprd-maintenance)/i.test(text)) {
691
+ evidence.push('prompt.openprd-opt-in');
692
+ }
693
+ return [...new Set(evidence)];
694
+ }
695
+
696
+ function detectExecutionSignals(eventName, payload) {
697
+ const evidence = [];
698
+ const mode = normalizedMode(process.env.OPENPRD_EXECUTION_MODE);
699
+ if (['automation', 'cron', 'scheduled', 'headless', 'unattended'].includes(mode)) {
700
+ evidence.push(`env.OPENPRD_EXECUTION_MODE=${mode}`);
701
+ }
702
+ if (truthyFlag(process.env.OPENPRD_AUTOMATION)) {
703
+ evidence.push('env.OPENPRD_AUTOMATION');
704
+ }
705
+ if (compactString(process.env.CODEX_AUTOMATION_ID)) {
706
+ evidence.push('env.CODEX_AUTOMATION_ID');
707
+ }
708
+ if (truthyFlag(process.env.CLAUDE_CODE_HEADLESS)) {
709
+ evidence.push('env.CLAUDE_CODE_HEADLESS');
710
+ }
711
+ const field = compactPayloadField(payload, [
712
+ 'automation_id',
713
+ 'automationId',
714
+ 'automation_name',
715
+ 'automationName',
716
+ 'automation',
717
+ 'scheduled_task',
718
+ 'scheduledTask',
719
+ 'cron',
720
+ 'rrule',
721
+ 'headless',
722
+ 'unattended',
723
+ 'is_automation',
724
+ 'isAutomation',
725
+ ]);
726
+ if (field) {
727
+ evidence.push(`payload.${field.key}`);
728
+ }
729
+ const runMode = compactPayloadField(payload, [
730
+ 'run_kind',
731
+ 'runKind',
732
+ 'execution_mode',
733
+ 'executionMode',
734
+ 'surface',
735
+ 'agent_surface',
736
+ 'agentSurface',
737
+ ]);
738
+ if (runMode && /(automation|cron|scheduled|headless|unattended)/i.test(runMode.value)) {
739
+ evidence.push(`payload.${runMode.key}=${runMode.value}`);
740
+ }
741
+ const agentType = compactString(payload?.agent_type ?? payload?.agentType);
742
+ if (agentType && /(automation|cron|scheduled|headless|unattended)/i.test(agentType)) {
743
+ evidence.push(`payload.agentType=${agentType}`);
744
+ }
745
+ const promptHeader = automationPromptHeader(promptText(payload));
746
+ if (promptHeader) {
747
+ evidence.push(promptHeader);
748
+ }
749
+ const transcript = compactString(payload?.transcript_path ?? payload?.transcriptPath);
750
+ if (transcript && /(?:^|[/-])(automation|automations|cron|scheduled|headless)(?:[/-]|$)/i.test(transcript)) {
751
+ evidence.push('payload.transcript_path.automation');
752
+ }
753
+ const optInEvidence = explicitOpenPrdOptInEvidence(payload);
754
+ return {
755
+ eventName,
756
+ isAutomation: evidence.length > 0,
757
+ explicitOpenPrdOptIn: optInEvidence.length > 0,
758
+ evidence: [...new Set(evidence)],
759
+ optInEvidence,
760
+ executionMode: optInEvidence.length > 0
761
+ ? 'automation-openprd-opt-in'
762
+ : evidence.length > 0
763
+ ? 'automation'
764
+ : 'interactive',
765
+ };
766
+ }
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
+ function rememberAutomationMode(root, sessionId, payload, signals, laneKind) {
798
+ if (!sessionId) {
799
+ return null;
800
+ }
801
+ return writeSessionRegistryEntry(root, sessionId, {
802
+ laneKind,
803
+ tool: 'openprd-hook',
804
+ promptPreview: preview(promptText(payload) || payloadText(payload), 500),
805
+ gateActive: false,
806
+ gateStatus: laneKind === 'automation' ? 'automation-safe-mode' : 'openprd-opt-in',
807
+ title: laneKind === 'automation'
808
+ ? 'Unattended automation bypassed OpenPrd'
809
+ : 'Unattended automation explicitly enabled OpenPrd',
810
+ executionEvidence: signals.evidence,
811
+ optInEvidence: signals.optInEvidence,
812
+ });
813
+ }
814
+
815
+ function automationBypassDecision(root, eventName, payload, sessionId) {
816
+ const signals = detectExecutionSignals(eventName, payload);
817
+ if (signals.explicitOpenPrdOptIn) {
818
+ return { bypass: false, optIn: true, remembered: false, signals };
819
+ }
820
+ if (signals.isAutomation) {
821
+ return { bypass: true, optIn: false, remembered: false, signals };
822
+ }
823
+ const remembered = eventName === 'UserPromptSubmit' ? null : latestSessionRegistryEntry(root, sessionId);
824
+ if (remembered?.laneKind === 'automation' && remembered?.gateStatus === 'automation-safe-mode') {
825
+ return { bypass: true, optIn: false, remembered: true, signals };
826
+ }
827
+ return { bypass: false, optIn: false, remembered: false, signals };
828
+ }
829
+
393
830
  function sessionStatePath(root, sessionId = null) {
394
831
  if (!sessionId) {
395
832
  return null;
@@ -579,7 +1016,28 @@ function readRequirementLaneReview(root, sessionId = null) {
579
1016
  artifact: binding.reviewPath || binding.stableReviewArtifact || binding.activeReviewPath || binding.reviewArtifact || null,
580
1017
  };
581
1018
  }
582
- return readCurrentPrdReview(root);
1019
+ const globalReview = readCurrentPrdReview(root);
1020
+ if (sessionId && globalReview.versionId) {
1021
+ // 全局 current.json 里的确认稿可能属于另一个会话的历史需求。
1022
+ // 如果 lane 归属明确不是当前会话,就按“本轮还没有确认稿”自愈,
1023
+ // 避免把其他需求的旧稿卡成本轮的待确认死锁。
1024
+ const laneSessionId = String(readJsonSync(currentStatePath(root), {})?.laneSessionId || '').trim() || null;
1025
+ if (laneSessionId && laneSessionId !== sessionId) {
1026
+ return {
1027
+ versionId: null,
1028
+ digest: null,
1029
+ workUnitId: null,
1030
+ status: 'missing',
1031
+ artifact: null,
1032
+ staleReview: {
1033
+ versionId: globalReview.versionId,
1034
+ workUnitId: globalReview.workUnitId,
1035
+ laneSessionId,
1036
+ },
1037
+ };
1038
+ }
1039
+ }
1040
+ return globalReview;
583
1041
  }
584
1042
 
585
1043
  function reviewCommandMatchesReview(command, review) {
@@ -873,9 +1331,8 @@ function isPatchModeFocusAttempt(root, payload, gate) {
873
1331
 
874
1332
  function findChangeDir(root, changeId) {
875
1333
  const candidates = [
876
- path.join(root, 'openprd', 'changes', changeId),
877
- path.join(root, 'openspec', 'changes', changeId),
878
- path.join(root, 'openprd', 'archive', 'changes', changeId),
1334
+ path.join(root, '.openprd', 'changes', changeId),
1335
+ path.join(root, '.openprd', 'archive', 'changes', changeId),
879
1336
  ];
880
1337
  return candidates.find((candidate) => fs.existsSync(candidate)) || candidates[0];
881
1338
  }
@@ -1186,6 +1643,54 @@ function renderRunContextText(result) {
1186
1643
  return lines.filter(Boolean).join('\n');
1187
1644
  }
1188
1645
 
1646
+ function inferLearningGenre(text) {
1647
+ const value = String(text || '');
1648
+ if (/仙侠|修仙|玄幻/.test(value)) {
1649
+ return 'xianxia';
1650
+ }
1651
+ if (/武侠/.test(value)) {
1652
+ return 'wuxia';
1653
+ }
1654
+ if (/童话|寓言/.test(value)) {
1655
+ return 'fairy-tale';
1656
+ }
1657
+ if (/科幻|赛博|cyberpunk|sci[-\s]?fi/i.test(value)) {
1658
+ return 'sci-fi';
1659
+ }
1660
+ if (/互联网产品|产品经理|PM/i.test(value)) {
1661
+ return 'internet-product';
1662
+ }
1663
+ return null;
1664
+ }
1665
+
1666
+ function detectLearningMaterialIntent(prompt) {
1667
+ const text = stripMarkdown(prompt).trim();
1668
+ if (!text) {
1669
+ return { requested: false, genre: null, reason: null };
1670
+ }
1671
+ const explicitChatOnly = /(只要|仅要|仅需|就|直接).{0,16}(在对话里|回复|回答|写一段|一段文字|短文|纯文本|普通文本)|不要.{0,18}(文件|阅读器|学习包|HTML|html|openprd)/i.test(text);
1672
+ const durableReaderSignal = /(可长期阅读|长期阅读|可回看|阅读器|reader|电子书|学习包|归档)/i.test(text);
1673
+ if (explicitChatOnly && !durableReaderSignal) {
1674
+ return { requested: false, genre: null, reason: 'chat-only-requested' };
1675
+ }
1676
+
1677
+ // This is an intent-shape detector, not a closed keyword table. The hook
1678
+ // looks for a requested learning deliverable, its durability/teaching goal,
1679
+ // and whether the expected output wants reader-like structure.
1680
+ const deliveryAction = /(生成|整理|产出|做一份|做一套|做个|做一个|创建|写一份|准备|沉淀|归档|转成|转换成|变成|输出|构建|给我)/i.test(text);
1681
+ const learningGoal = /(学习|教学|培训|复盘|教程|讲义|教材|课程|知识沉淀|经验沉淀|可回看|可复用|长期阅读|新人入门|onboarding)/i.test(text);
1682
+ const readerShape = /(学习(材料|资料|包|读本|文档)|复盘材料|教学材料|培训材料|教程|讲义|教材|课程|读本|电子书|阅读器|reader|ebook|章节|目录|证据锚点|图文讲解|检索练习|工作示例|可长期阅读|长期阅读)/i.test(text);
1683
+ const workToLearningArtifact = /(把|将|基于|围绕).{0,48}(工作|任务|过程|对话|会话|修复|需求|项目|实现|经验|记录|材料).{0,32}(转成|转换成|整理成|沉淀成|归档成|做成|写成).{0,40}(学习|复盘|教学|教程|讲义|材料|读本|可回看|可复用|长期阅读)/i.test(text);
1684
+ const requested = workToLearningArtifact
1685
+ || (readerShape && (deliveryAction || learningGoal || durableReaderSignal))
1686
+ || (learningGoal && durableReaderSignal);
1687
+ return {
1688
+ requested,
1689
+ genre: requested ? inferLearningGenre(text) : null,
1690
+ reason: workToLearningArtifact ? 'work-to-learning-artifact' : (readerShape ? 'reader-shaped-learning-artifact' : (requested ? 'durable-learning-goal' : null)),
1691
+ };
1692
+ }
1693
+
1189
1694
  function analyzePromptIntent(prompt) {
1190
1695
  const text = String(prompt || '').trim();
1191
1696
  const normalized = text.toLowerCase();
@@ -1267,7 +1772,7 @@ function analyzePromptIntent(prompt) {
1267
1772
  /copy/i,
1268
1773
  ];
1269
1774
  const simpleConcretePatterns = [
1270
- /按钮|文案|颜色|圆角|位置|间距|字号|图标|标题|空格|标点|label|copy/i,
1775
+ /按钮|文案|颜色|圆角|位置|间距|留白|边距|宽度|高度|卡片|字号|图标|标题|空格|标点|对齐|密度|label|copy|padding|margin|spacing|gap/i,
1271
1776
  /从.+(改到|移到|移动到|换到|变成|改成|改为).+/,
1272
1777
  ];
1273
1778
  const l2StructuralScopePatterns = [
@@ -1370,8 +1875,10 @@ function analyzePromptIntent(prompt) {
1370
1875
  ];
1371
1876
  const reviewConfirmPatterns = [
1372
1877
  /认可方案/,
1373
- /确认(?:当前|这个|这版|该)?(?:PRD|评审稿|评审页|review|需求稿|版本)/i,
1374
- /(?:PRD|评审稿|评审页|review|需求稿|版本).{0,12}(确认|通过|没问题|认可|可以)/i,
1878
+ // 容忍“确认 v0276 评审稿”这类中间插版本号/空格的自然语言确认。
1879
+ /确认(?:当前|这个|这版|该)?\s*(?:[vV]\d+\s*(?:的)?)?(?:PRD|评审稿|评审页|review|需求稿|版本)/i,
1880
+ /确认\s*[vV]\d+/,
1881
+ /(?:PRD|评审稿|评审页|review|需求稿|版本)\s*(?:[vV]\d+)?.{0,12}(确认|通过|没问题|认可|可以)/i,
1375
1882
  /按这版(?:PRD|评审|需求稿)/i,
1376
1883
  ];
1377
1884
  const reviewNeedsRevisionPatterns = [
@@ -1403,7 +1910,8 @@ function analyzePromptIntent(prompt) {
1403
1910
  || /失败.{0,20}(原因|根因|排查|定位|修|修复|处理|解决)|(?:原因|根因|排查|定位).{0,20}失败/.test(text);
1404
1911
  const tinyEdit = tinyEditPatterns.some((pattern) => pattern.test(text));
1405
1912
  const crossSystemRiskMatched = crossSystemRiskPatterns.some((pattern) => pattern.test(text));
1406
- const localUiScopeMatched = /(按钮|文案|颜色|圆角|位置|间距|字号|图标|标题|空格|标点|label|copy|toast|placeholder|样式|页面|界面|布局|信息架构|导航|列表|详情页|设置页)/i.test(text);
1913
+ const localUiScopeMatched = hasLightweightUiVisualSignal(text)
1914
+ || /(样式|页面|界面|布局|信息架构|导航|列表|详情页|设置页)/i.test(text);
1407
1915
  const reviewContinuationRequested = reviewContinuationPatterns.some((pattern) => pattern.test(text));
1408
1916
  const explicitExecution = internalOpenPrdExecution
1409
1917
  || continuationVerbMatched
@@ -1414,6 +1922,7 @@ function analyzePromptIntent(prompt) {
1414
1922
  const noReviewRequested = noReviewRequestedPatterns.some((pattern) => pattern.test(text));
1415
1923
  const noConfirmationRequested = noConfirmationRequestedPatterns.some((pattern) => pattern.test(text));
1416
1924
  const reviewDecision = promptReviewCommand?.mark
1925
+ ?? (reviewContinuationRequested ? 'confirmed' : null)
1417
1926
  ?? (reviewConfirmPatterns.some((pattern) => pattern.test(text)) ? 'confirmed' : null)
1418
1927
  ?? (reviewNeedsRevisionPatterns.some((pattern) => pattern.test(text)) ? 'needs-revision' : null);
1419
1928
  const confirmation = implementationConfirmation || Boolean(reviewDecision);
@@ -1424,6 +1933,10 @@ function analyzePromptIntent(prompt) {
1424
1933
  const visualMockupRequest = imageGenerationTerms.test(text)
1425
1934
  && imageGenerationAction.test(text)
1426
1935
  && !codeVisualArtifactRequested;
1936
+ const learningMaterialIntent = detectLearningMaterialIntent(text);
1937
+ const learningMaterialRequest = learningMaterialIntent.requested;
1938
+ const canvasCollaborationRequest = /(画布|canvas|excalidraw)/i.test(text)
1939
+ && /(标注|批注|协同|改图|回填|贴到画布|放到画布|插入画布|插到画布|画布评审|画布反馈|一起看|openprd\s*canvas|annotation)/i.test(text);
1427
1940
  const visualReview = /效果图|实现截图|视觉对比|视觉评审|对标效果图|复刻|不一致|好丑|没对齐|对不上|不像/i.test(text);
1428
1941
  const directBugfixExecution = explicitExecution && bugfixOrDiagnostic;
1429
1942
  const newFeatureVerbMatched = /(新增|增加|新建)/.test(text);
@@ -1441,19 +1954,22 @@ function analyzePromptIntent(prompt) {
1441
1954
  const requirementSignalMatched = requirementRoutingSignalsStrong.some((pattern) => pattern.test(text))
1442
1955
  || (requirementRoutingSignalsWeak.some((pattern) => pattern.test(text))
1443
1956
  && requirementChangeIntentSignals.some((pattern) => pattern.test(text)));
1444
- const l2FeatureExpansionMatched = !localUiScopeMatched
1445
- && (
1446
- (capabilityCreationMatched && (strategicL2ScopeMatched || structuralExpansionMatched))
1447
- || (newFeatureVerbMatched && text.length > 18)
1448
- );
1957
+ const l2FeatureExpansionMatched = (capabilityCreationMatched && (strategicL2ScopeMatched || structuralExpansionMatched))
1958
+ || (!localUiScopeMatched && newFeatureVerbMatched && text.length > 18);
1449
1959
  const l2PlanningRequestMatched = !readOnly
1450
1960
  && featurePlanningMatched
1451
1961
  && requirementChangeIntentSignals.some((pattern) => pattern.test(text))
1452
1962
  && (strategicL2ScopeMatched || structuralExpansionMatched);
1453
1963
  const l1OptimizationMatched = l1OptimizationPatterns.some((pattern) => pattern.test(text))
1454
- && /(页面|界面|视觉|样式|布局|信息架构|交互|体验|流程|入口|导航|表单|设置页|列表|详情页)/i.test(text);
1964
+ && (hasLightweightUiVisualSignal(text) || /(页面|界面|视觉|样式|布局|信息架构|交互|体验|流程|入口|导航|表单|设置页|列表|详情页)/i.test(text));
1965
+ // 改现有脚本、CLI、发布/构建流程、参数或文档这类既有资产优化,
1966
+ // 不应仅因出现“新增/增加/补一个”动词就升级成 L2 重流程;
1967
+ // 只有同时命中真正的结构/战略扩张信号时才保留 L2。
1968
+ const existingAssetOptimizationMatched = /(脚本|script|发布|上传|部署|构建|打包|导出|同步|清理|CLI|命令行|命令|参数|选项|flag|配置文件|文档|README)/i.test(text)
1969
+ && /(补|加|增加|支持|筛选|过滤|优化|调整|收敛|清理|改成|改为|升级|更新|修改|梳理)/.test(text)
1970
+ && !/(新产品|新模块|新平台|新系统|新页面|新界面|新客户端|全新|从零|从\s*0|重写整个|重构整个)/i.test(text);
1455
1971
  const l0AdjustmentMatched = l0AdjustmentPatterns.some((pattern) => pattern.test(text))
1456
- && /(按钮|文案|颜色|圆角|位置|间距|字号|图标|标题|空格|标点|label|copy|toast|placeholder|样式|一处)/i.test(text);
1972
+ && (hasLightweightUiVisualSignal(text) || /(样式|一处)/i.test(text));
1457
1973
  const publicRepoResearchRequest = githubRepoPattern.test(text)
1458
1974
  && /(github|仓库|repo|项目|参考|对标|复刻|review|学习|架构|模块|流程|构建|测试|扩展点)/i.test(text);
1459
1975
  const externalTechResearchRequest = /(第三方|library|framework|sdk|api|mcp|cli|依赖|包|版本|迁移|弃用|官方文档|参数|返回值|生命周期)/i.test(text)
@@ -1472,8 +1988,12 @@ function analyzePromptIntent(prompt) {
1472
1988
  const l2RequirementCandidate = (bugfixOrDiagnostic && crossSystemRiskMatched)
1473
1989
  || l2FeatureExpansionMatched
1474
1990
  || l2PlanningRequestMatched;
1991
+ const existingAssetDemotionApplies = existingAssetOptimizationMatched
1992
+ && !strategicL2ScopeMatched
1993
+ && structuralL2ScopeMatchCount === 0;
1475
1994
  const requirementTier = !internalOpenPrdExecution
1476
1995
  ? (l2RequirementCandidate
1996
+ && !existingAssetDemotionApplies
1477
1997
  && !tinyEdit
1478
1998
  && !simpleConcrete
1479
1999
  && !visualMockupRequest
@@ -1489,7 +2009,9 @@ function analyzePromptIntent(prompt) {
1489
2009
  ? 'l0'
1490
2010
  : (!visualMockupRequest
1491
2011
  && !readOnly
1492
- && (l1OptimizationMatched || requirementSignalMatched)
2012
+ && (l1OptimizationMatched
2013
+ || requirementSignalMatched
2014
+ || (l2RequirementCandidate && existingAssetDemotionApplies))
1493
2015
  ? 'l1'
1494
2016
  : null)))
1495
2017
  : null;
@@ -1520,6 +2042,10 @@ function analyzePromptIntent(prompt) {
1520
2042
  weappValidationRequest,
1521
2043
  browserSafetyRequest,
1522
2044
  productCopyRequest,
2045
+ learningMaterialRequest,
2046
+ learningGenre: learningMaterialIntent.genre,
2047
+ learningMaterialReason: learningMaterialIntent.reason,
2048
+ canvasCollaborationRequest,
1523
2049
  shouldInject: requiresIntake
1524
2050
  || requirementTier === 'l1'
1525
2051
  || explicitExecution
@@ -1535,6 +2061,8 @@ function analyzePromptIntent(prompt) {
1535
2061
  || weappValidationRequest
1536
2062
  || browserSafetyRequest
1537
2063
  || productCopyRequest
2064
+ || learningMaterialRequest
2065
+ || canvasCollaborationRequest
1538
2066
  || /openprd/i.test(normalized)
1539
2067
  || /\bprd\b/i.test(normalized),
1540
2068
  };
@@ -1542,7 +2070,7 @@ function analyzePromptIntent(prompt) {
1542
2070
 
1543
2071
  function isShortAffirmativeConfirmation(prompt) {
1544
2072
  const text = stripMarkdown(prompt).trim();
1545
- return /^(可以|好|行|确认|没问题|OK|ok|yes|Yes|yep|Yep)[。!!,.,s]*$/.test(text);
2073
+ return /^(可以|好|行|确认|确定|没问题|同意|就这样|OK|ok|yes|Yes|yep|Yep)[。!!,.,s]*$/.test(text);
1546
2074
  }
1547
2075
 
1548
2076
  function looksLikeClarificationFollowupReply(prompt, intent = null) {
@@ -1657,6 +2185,32 @@ function authorizeRequirementGateExecution(root, prompt, sessionId = null, optio
1657
2185
  return next;
1658
2186
  }
1659
2187
 
2188
+ function escalateRequirementGateApproval(root, prompt, gate, sessionId = null) {
2189
+ // 用户已经确认过一次需求摘要,又再次给出明确肯定/继续意图时,
2190
+ // 把当前 lane 升级为 silent-record + 自动执行授权:后续 review 记录、change、tasks
2191
+ // 都沿已确认范围静默推进,不再向用户反复索取同一轮确认。
2192
+ const approvalPolicy = normalizeRequirementApprovalPolicy({
2193
+ ...(gate?.approvalPolicy ?? {}),
2194
+ reviewPolicy: 'silent-record',
2195
+ executionMode: 'auto-after-review-and-tasks',
2196
+ suppressExtraConfirmation: true,
2197
+ });
2198
+ return updateRequirementGate(root, {
2199
+ approvalPolicy,
2200
+ intent: { ...(gate?.intent ?? {}), explicitExecution: true },
2201
+ executionIntent: {
2202
+ ...(gate?.executionIntent ?? {}),
2203
+ explicitRequested: true,
2204
+ suppressExtraConfirmation: true,
2205
+ source: 'repeated-user-confirmation',
2206
+ latchedAt: now(),
2207
+ },
2208
+ confirmationCount: Number(gate?.confirmationCount ?? 1) + 1,
2209
+ repeatedConfirmationAt: now(),
2210
+ confirmationPreview: preview(prompt, 500),
2211
+ }, sessionId);
2212
+ }
2213
+
1660
2214
  function updateRequirementGate(root, patch, sessionId = null) {
1661
2215
  const current = readRequirementGate(root, sessionId);
1662
2216
  if (!current?.active) {
@@ -1935,6 +2489,9 @@ function evaluateRequirementGateProgress(root, sessionId = null) {
1935
2489
  nextStep = 'task-breakdown-required';
1936
2490
  reason = '本次调整范围已经立起来了,但还没拆成可直接执行的后续任务。';
1937
2491
  }
2492
+ if (review.staleReview?.versionId) {
2493
+ reason += ` 另外检测到工作区里残留了一份属于其他会话的历史确认稿(${review.staleReview.versionId}),本轮已自动忽略它;不要尝试确认或复用那份旧稿,直接按当前需求生成新的确认稿。`;
2494
+ }
1938
2495
  return {
1939
2496
  runContext: parsed,
1940
2497
  binding,
@@ -2088,6 +2645,94 @@ function isMutationPayload(payload, risk) {
2088
2645
  || /\*\*\* Begin Patch/.test(text);
2089
2646
  }
2090
2647
 
2648
+ function isRuntimeAutonomyRiskFile(file) {
2649
+ const normalized = String(file || '').split(path.sep).join('/');
2650
+ if (!normalized) {
2651
+ return true;
2652
+ }
2653
+ if (/^(docs|test|tests|__tests__|fixtures|examples|templates?)\//i.test(normalized)) {
2654
+ return false;
2655
+ }
2656
+ if (/(^|\/)(AGENTS|CLAUDE|README|CHANGELOG|SKILL)\.md$/i.test(normalized)) {
2657
+ return false;
2658
+ }
2659
+ if (/\.(?:md|mdx|txt|rst)$/i.test(normalized)) {
2660
+ return false;
2661
+ }
2662
+ return true;
2663
+ }
2664
+
2665
+ function hasExplicitAutonomyRiskApproval(intent = null) {
2666
+ const text = String(intent?.promptText || '');
2667
+ if (!text.trim()) {
2668
+ return false;
2669
+ }
2670
+ const riskTerms = /(云端|线上|生产环境|远端|热修复|hotfix|兜底|临时兜底|fallback|fall\s*back|写死|硬编码|hardcode|hard-coded|hard coded)/i;
2671
+ if (!riskTerms.test(text)) {
2672
+ return false;
2673
+ }
2674
+ const negatedRisk = /(不要|禁止|不允许|不得|不能|避免|默认不|未明确|未经|除非).{0,48}(云端|线上|生产环境|远端|热修复|hotfix|兜底|临时兜底|fallback|fall\s*back|写死|硬编码|hardcode|hard-coded|hard coded)|(云端|线上|生产环境|远端|热修复|hotfix|兜底|临时兜底|fallback|fall\s*back|写死|硬编码|hardcode|hard-coded|hard coded).{0,48}(不要|禁止|不允许|不得|不能|避免|默认不|未明确|未经|除非)/i;
2675
+ if (negatedRisk.test(text)) {
2676
+ return false;
2677
+ }
2678
+ return /(明确)?(允许|批准|同意|确认|可以|请|直接|必须|这次|现在).{0,40}(云端|线上|生产环境|远端|热修复|hotfix|兜底|临时兜底|fallback|fall\s*back|写死|硬编码|hardcode|hard-coded|hard coded)|(云端|线上|生产环境|远端|热修复|hotfix|兜底|临时兜底|fallback|fall\s*back|写死|硬编码|hardcode|hard-coded|hard coded).{0,40}(允许|批准|同意|确认|可以|直接|必须|这次|现在)/i.test(text);
2679
+ }
2680
+
2681
+ function detectHighRiskAutonomyMutation(root, payload, intent, risk) {
2682
+ if (!isMutationPayload(payload, risk)) {
2683
+ return null;
2684
+ }
2685
+ const command = commandText(payload);
2686
+ const text = [
2687
+ toolName(payload),
2688
+ command,
2689
+ payloadText(payload),
2690
+ ].filter(Boolean).join('\n');
2691
+ const touched = extractTouchedFiles(root, payload);
2692
+ const runtimeTarget = touched.length === 0 || touched.some((file) => isRuntimeAutonomyRiskFile(file));
2693
+ const cloudMutation = /(?:\b(?:ssh|scp|rsync)\b[^\n]*(?:prod|production|线上|生产|云端|server|remote)|\b(?:kubectl|helm)\b[^\n]*(?:apply|patch|set|delete|rollout|scale|exec)[^\n]*(?:prod|production|线上|生产|云端|cluster)|\b(?:vercel|wrangler|fly|flyctl|railway|firebase)\b[^\n]*(?:deploy|up)[^\n]*(?:--prod|prod|production|线上|生产)|\bsupabase\s+db\s+push\b[^\n]*(?:--linked|--db-url|prod|production|线上|生产)|\bcurl\b[^\n]*(?:-X|--request)\s*(?:POST|PUT|PATCH|DELETE)\b[^\n]*(?:prod|production|线上|生产|云端|api\.))/i.test(text);
2694
+ if (cloudMutation) {
2695
+ return {
2696
+ kind: 'cloud-hotfix',
2697
+ title: '云端/生产远端写入',
2698
+ details: preview(command || text, 260),
2699
+ };
2700
+ }
2701
+ if (!runtimeTarget) {
2702
+ return null;
2703
+ }
2704
+ // 只保留真实业务权益/计费类词汇;配置、服务端、客户端、缓存、feature 这类
2705
+ // 基础设施通用词在正常代码里大量出现,叠加“fallback/兜底”会造成海量误拦。
2706
+ const businessTerms = /(业务|权益|会员|额度|价格|计费|订阅|套餐|entitlement|membership|billing|quota|price|paywall|subscription|localstorage)/i;
2707
+ const fallbackTerms = /(兜底|fallback|fall\s*back|临时补|临时插入|补行|补进|本地补|客户端补|服务端补|缓存补|默认插入|补一条|补数据)/i;
2708
+ const hardcodeTerms = /(写死|硬编码|hardcode|hard-coded|hard coded)/i;
2709
+ const fixedBusinessValue = /(unlimited|premium|pro|vip|admin|owner|free|trial|quota|limit|count_limit|free_limit|权益|会员).{0,80}(?:=|:).{0,80}(?:unlimited|premium|pro|vip|admin|owner|free|true|false|999999|Infinity)/i;
2710
+ if ((businessTerms.test(text) && (fallbackTerms.test(text) || hardcodeTerms.test(text))) || fixedBusinessValue.test(text)) {
2711
+ return {
2712
+ kind: 'hardcoded-fallback',
2713
+ title: '业务兜底/写死逻辑',
2714
+ details: preview(touched.length > 0 ? `${touched.join(', ')}\n${command || text}` : command || text, 260),
2715
+ };
2716
+ }
2717
+ return null;
2718
+ }
2719
+
2720
+ function formatHighRiskAutonomyBlock(detection) {
2721
+ return [
2722
+ `OpenPrd blocked a high-risk autonomy action: ${detection.title}.`,
2723
+ '当前用户消息只授权了本轮实现范围,不等于授权 Agent 私自做云端热修复、生产远端写入、业务兜底、写死逻辑或缓存补行。',
2724
+ detection.details ? `Matched action: ${detection.details}` : null,
2725
+ '默认路径:先改本地源码、配置、迁移或数据修复脚本,让本地与云端保持同一事实源;完成后再按正常发布/部署路径验证。',
2726
+ '如果这次确实必须临时处理,请先让用户明确说“允许这次云端热修复”或“允许这次临时兜底/写死”,并说明影响范围、回滚方式和如何同步回本地源码/配置/迁移。',
2727
+ ].filter(Boolean).join('\n');
2728
+ }
2729
+
2730
+ const LIGHTWEIGHT_UI_VISUAL_SIGNAL_PATTERN = /(按钮|文案|颜色|圆角|位置|间距|留白|边距|内边距|外边距|宽度|高度|卡片|字号|字体|行高|图标|标题|空格|标点|对齐|密度|阴影|边框|层级|组件|输入框|菜单|侧栏|表格|列表|头像|badge|label|copy|toast|placeholder|padding|margin|spacing|gap|太宽|太窄|太挤|拥挤|松散|留得多|留着有点多|没对齐|好看|美观|审美|气质|记忆点|高级|太丑|丑|视觉风格|上架图|截图优化)/i;
2731
+
2732
+ function hasLightweightUiVisualSignal(text = '') {
2733
+ return LIGHTWEIGHT_UI_VISUAL_SIGNAL_PATTERN.test(String(text || ''));
2734
+ }
2735
+
2091
2736
  function isFrontendTaskIntent(intent = null) {
2092
2737
  const text = String(intent?.promptText || '').trim();
2093
2738
  if (!text || intent?.visualMockupRequest) {
@@ -2162,7 +2807,7 @@ function frontendDesignPreflightIssues(root, intent, implementationFiles) {
2162
2807
  if (shouldRequireAssetSpec(intent) && designArtifactHasPlaceholder(root, 'asset-spec.md')) {
2163
2808
  issues.push({
2164
2809
  file: 'asset-spec.md',
2165
- reason: '进入实现前先冻结资产口径;没有官方资产也要写“缺失 / 暂无 / 不适用”,不要留模板占位。',
2810
+ reason: '进入实现前先冻结资产口径;没有官方资产也要写“缺失 / 暂无 / 不适用”,不要留模板占位。新功能出现新入口、按钮、tab、卡片或空态时,同步补“功能图标”行:先复用项目已有图标体系,缺失时按最佳实践路由(Phosphor / Lucide / Tabler / React Icons / LobeHub / Tech Icons / iconfont)选型并登记图标名、来源、用途,不等用户要求。',
2166
2811
  });
2167
2812
  }
2168
2813
  if (shouldRequireImagePreflight(intent) && designArtifactHasPlaceholder(root, 'image-preflight.md')) {
@@ -2458,7 +3103,9 @@ function productCopyMessage(intent) {
2458
3103
  return [
2459
3104
  'OpenPrd 产品内文案提醒:',
2460
3105
  '先检查项目是否已有 i18n、locales、translations、Localizable 或其他语言资源;若已有,多语言要同步维护。',
2461
- '用户可见文案默认面向普通用户,优先写结果和下一步,不要把 API、SDK、模型、数据库、缓存、错误码或其他实现细节直接暴露给用户。',
3106
+ '产品界面上用户能看到的文案必须站在当前用户视角写:说明用户现在能做什么、会发生什么、为什么值得点、下一步怎么走。',
3107
+ '除非这是面向开发者或专业技术人员的技术型产品,否则默认用普通人能看懂的语言写结果、状态、限制和行动,不要把 API、SDK、模型、数据库、缓存、错误码或其他实现细节直接暴露给用户。',
3108
+ '快速正反例:不要写“适合想保留全部工具入口的用户”,改成“首页会显示所有工具入口,你可以直接选择需要的功能”;不要写“API 请求失败,错误码 500”,改成“暂时保存失败,请稍后再试”。技术型产品可以保留必要术语,但仍要写清用户动作、影响和修复路径。',
2462
3109
  ].join('\n');
2463
3110
  }
2464
3111
 
@@ -2565,13 +3212,25 @@ function devCheckWrapUpMessage(root, turnState) {
2565
3212
  if (!parsed?.wrapUp?.required || !parsed.wrapUp.markdownTable) {
2566
3213
  return null;
2567
3214
  }
3215
+ const autoMode = parsed.wrapUp.mode === 'auto-refactor';
2568
3216
  return [
2569
- 'OpenPrd 后续建议:本轮有改动对象需要主动说明。',
2570
- '最终回复必须直接复用下面的 Markdown 表格,按 🔴 → 🟠 → 🟡 的顺序帮助产品或业务理解影响对象、本次处理结果和后续建议;不要只用工具名或一段话带过。',
2571
- '如果你改写了“预警原因 / 本次处理结果 / 后续建议”,先用 `node scripts/dev-check-wrapup-copy.mjs --validate` 校验每格不超过 20 字;若报错,按提示缩短后重试。',
2572
- '不要把“关注程度”列改写成纯 emoji;必须保留例如“🟠 中风险|建议优先关注”这类完整标签。',
3217
+ autoMode
3218
+ ? 'OpenPRD 自动优化报告:本轮有超行改动对象,自动优化默认开启。'
3219
+ : 'OpenPrd 后续建议:本轮有改动对象需要主动说明。',
3220
+ autoMode
3221
+ ? '先在本轮按 dev-check 给出的动作完成高内聚低耦合拆分(不丢任何功能、行为不变,拆完重跑测试与 dev-check),把每行“本次处理结果”回填为真实优化产出;最终回复必须以 **OpenPRD 自动优化报告** 为标题直接复用下面的 Markdown 表格,按 🔴 → 🟠 → 🟡 排序,并附一句:“如果下次不需要自动优化代码结构,回复关闭自动优化即可”。用户确认关闭后运行 `openprd dev-check . --auto-refactor off`。'
3222
+ : '最终回复必须以 **后续建议** 为标题直接复用下面的 Markdown 表格,按 🔴 → 🟠 → 🟡 的顺序帮助产品或业务理解影响对象、本次处理结果和后续建议;不要只用工具名或一段话带过。',
3223
+ autoMode
3224
+ ? '如果你改写了“源文件规模 / 优化原因 / 本次处理结果 / 后续建议”,先用 `node scripts/dev-check-wrapup-copy.mjs --validate` 校验每格不超过 20 字;若报错,按提示缩短后重试。'
3225
+ : '如果你改写了“规模信号 / 预警原因 / 本次处理结果 / 后续建议”,先用 `node scripts/dev-check-wrapup-copy.mjs --validate` 校验每格不超过 20 字;若报错,按提示缩短后重试。',
3226
+ autoMode
3227
+ ? '不要把“问题级别”列改写成纯 emoji;必须保留例如“🟠 中风险|建议优先关注”这类完整标签。'
3228
+ : '不要把“关注程度”列改写成纯 emoji;必须保留例如“🟠 中风险|建议优先关注”这类完整标签。',
2573
3229
  parsed.wrapUp.markdownBlock ?? parsed.wrapUp.markdownTable,
2574
- ].join('\n');
3230
+ parsed.visualEvidence?.required
3231
+ ? `视觉证据提醒:${parsed.visualEvidence.reason} 建议命令:${(parsed.visualEvidence.suggestedCommands ?? []).join(';')}。${parsed.visualEvidence.chatEmbedHint ?? ''}`
3232
+ : null,
3233
+ ].filter(Boolean).join('\n');
2575
3234
  }
2576
3235
 
2577
3236
  function shouldRunDoctorForHighRisk(payload) {
@@ -2721,8 +3380,10 @@ function requirementGateMessage(intent, gate) {
2721
3380
  'OpenPrd requirement intake gate is active only for implementation writes.',
2722
3381
  'The user is asking for an image asset such as a cover image, poster, illustration, icon, sticker, visual mockup, or effect image, not code implementation.',
2723
3382
  'For logo, icon, avatar, badge, and similar development assets, default to a standalone asset: full-frame single subject with no extra UI frame, card shell, device mockup, or presentation container unless the user explicitly asked for one.',
3383
+ 'If the final development asset should be transparent for UI use, use an icon-asset pipeline: generate three visibly different standalone candidates first, each on a solid #00ff00 chroma-key background with no text, no UI container, centered subject, and enough padding; do not write project files before the user chooses one. After selection, verify the selected source or contact sheet, keep the green-screen source, run remove_chroma_key.py to produce a transparent PNG/WebP, crop and center it, export 384px or required sizes, tune the display ratio per real UI placement, and record the source, transparent output, integration point, and verification in asset-spec.md / selected-direction.md before claiming completion.',
2724
3384
  'Do not create temporary HTML/SVG/CSS files for this image unless the user explicitly requested that format.',
2725
- 'Use `imagegen`, which is Codex native Image 2, to generate the image; keep implementation, PRD review, and visual-compare for later explicit confirmation of whether this generated image should become a reference.',
3385
+ 'Image tool routing: on a Codex surface use native `imagegen` (Image 2); on a Cursor surface use the built-in `GenerateImage` tool; both are free inside the conversation tool. If neither surface applies, read the confirmed preference in `.openprd/harness/image-generation-preference.json`; when no preference exists, ask the user which image-generation path to use and persist the answer to that file as the future default. NEVER silently call the user\'s own paid image API. The image tool is the tool path, not an aesthetic exemption, so first state the purpose, audience, intended tone, constraints, and memory point, then put them into the image prompt.',
3386
+ 'Before generation, do a quick anti-slop check: without brand or reference support, avoid default purple/blue-purple gradients, generic font taste, white card piles, and context-free decoration.',
2726
3387
  ].join('\n');
2727
3388
  }
2728
3389
  const status = gateBlocksImplementation ? 'active' : 'opened';
@@ -2767,28 +3428,78 @@ function lightweightRequirementMessage(intent) {
2767
3428
  return null;
2768
3429
  }
2769
3430
 
2770
- function visualMockupMessage(intent) {
3431
+ function imageGenerationPreferencePath(root) {
3432
+ return path.join(harnessDir(root), 'image-generation-preference.json');
3433
+ }
3434
+
3435
+ function imageGenerationRoutingLines(root) {
3436
+ const preference = readJsonSync(imageGenerationPreferencePath(root), null);
3437
+ const lines = [
3438
+ '生图路由: 第一层先判断当前对话工具面——Codex 环境用原生 `imagegen`(Image 2),Cursor 环境用内置 `GenerateImage` 工具;两者都是对话工具内免费能力,优先默认使用。',
3439
+ '如果当前既不是 Codex 也不是 Cursor,或内置生图工具面证据不存在,先读 `.openprd/harness/image-generation-preference.json` 里用户已确认的生图偏好;没有已确认偏好时必须先询问用户要用哪种生图方式,并把用户答复以 `{"provider","source":"user-confirmed","updatedAt"}` 写回该文件,下次直接作为默认。',
3440
+ '成本护栏: 绝对不要在用户未明确指定的情况下擅自调用用户本地或自有付费生图 API(如用户自己的 OpenAI / Stability key),避免产生额外成本;只有偏好文件或当前消息明确授权时才可以使用。',
3441
+ ];
3442
+ if (preference?.provider) {
3443
+ lines.push(`当前已保存的用户生图偏好: ${preference.provider}(来源: ${preference.source || 'user-confirmed'})。内置免费生图工具不可用时优先使用它,不要再重复询问。`);
3444
+ }
3445
+ return lines;
3446
+ }
3447
+
3448
+ function visualMockupMessage(intent, root = null) {
2771
3449
  if (!intent?.visualMockupRequest) {
2772
3450
  return null;
2773
3451
  }
2774
3452
  return [
2775
3453
  '当前用户要的是图片内容生成,例如图片、封面图、配图、海报、插画、图标、贴纸、头像、banner、主视觉/KV、运营图、效果图、视觉稿或 mockup。',
2776
- '默认直接调用 `imagegen`,也就是 Codex 原生 Image 2,来生成图片;除非用户明确指定 HTML、SVG、CSS、Canvas、代码稿或可编辑矢量/source artifact,不要改用临时 HTML/SVG/CSS 再截图。',
2777
- ' logo、icon、avatar、badge 等开发素材,如果用户没有明确要求 mockup、场景图、设备框、卡片承载、名片/包装展示或参考界面复刻,默认按独立素材输出(standalone asset)处理:使用全画布单主体,不额外添加 UI frame、卡片、设备壳、名片、桌面陈列、手持实拍或其他展示容器。',
3454
+ ...(root ? imageGenerationRoutingLines(root) : []),
3455
+ '按生图路由选定的内置生图工具生成图片(Codex=`imagegen`/Image 2,Cursor=`GenerateImage`);生图工具是工具路径,不是审美豁免;生图前先写清用途、受众、气质、约束和记忆点,并把这些写进 prompt。',
3456
+ '生图前用 `.openprd/design/anti-slop.md` 做快速自检:没有品牌或参考图依据时,不要默认紫白/蓝紫渐变、通用字体、白底卡片堆叠或无语境装饰。',
3457
+ '除非用户明确指定 HTML、SVG、CSS、Canvas、代码稿或可编辑矢量/source artifact,不要改用临时 HTML/SVG/CSS 再截图。',
3458
+ '对 logo、icon、avatar、badge 等开发素材,如果用户没有明确要求 mockup、场景图、设备框、卡片承载、名片/包装展示或参考界面复刻,默认按独立素材输出(standalone asset)处理:使用全画布单主体,不额外添加 UI frame、卡片、设备壳、名片、桌面陈列、手持实拍或其他展示容器;如果最终要接入 UI 并需要透明背景,先生成 3 个差异足够大的纯 `#00ff00` 绿幕候选方向,用户选定前不写项目文件,选定后再保留源图、抠透明、裁切居中、导出 384px 或多尺寸资产,按真实 UI 场景调显示比例,并把源图、透明产物、接入位置和验证结果写回 `asset-spec.md` / `selected-direction.md`。',
2778
3459
  '只有当用户明确要求 mockup、场景化效果图、容器化呈现,或参考图本身就包含这些承载结构时,才生成对应的容器或场景。',
2779
- '只有在实际发生 `imagegen` 调用后,才能汇报生图结果、失败或限流;未调用 `imagegen` 前,不要声称“生图限流”或“生图失败”。',
2780
- ' `imagegen` 结果先当成候选效果图,不要默认登记到 `.openprd/harness/visual-reviews/`,也不要自动当成后续验收参考。',
3460
+ '只有在实际发生生图工具调用后,才能汇报生图结果、失败或限流;未调用前,不要声称“生图限流”或“生图失败”。',
3461
+ '把生图结果先当成候选效果图,不要默认登记到 `.openprd/harness/visual-reviews/`,也不要自动当成后续验收参考;看图时不仅判断是否生成成功,还要判断气质、层级、字体/色彩/表面角色和记忆点是否成立。',
2781
3462
  '如果用户看完图后还要继续做实现,主动连问三件事:是否符合预期、是否纳入后续效果图/实现截图对比、是否按此继续后续实现。只有用户确认纳入对比或继续实现后,才把这张图、这组图或其中选定子图整理成后续 reference-set;如果用户只是认可图片但暂不实现,就继续把它当候选效果图。',
2782
3463
  '如果一张图里有多个子图、网格、多对象或多个方向,不要把整板直接当成单一参考图;先运行 `openprd visual-prepare . --reference <效果图> --grid <列>x<行>` 或 `--boxes <plan.json>` 生成 reference-set、contact sheet 和 compare-plan,确认 contact sheet 后再逐项对比或统一验收。',
2783
- 'OpenPrd review.html 只用于需求评审,visual-compare 只用于实现阶段视觉证据:已有参考图时做效果图/实现截图对比;没有参考图时先判断新建界面还是修改既有界面,新建界面回到实现前 3 方向方案评审,修改既有界面做修改前/修改后自检;局部细节优先补局部焦点证据板,并行优化方向优先补并行实验证据板。',
3464
+ 'OpenPrd review.html 只用于需求评审,visual-compare 只用于实现阶段视觉证据:已有参考图时做效果图/实现截图对比;没有参考图时先判断新建界面还是修改既有界面,新建界面回到实现前 3 方向方案评审,修改既有界面做修改前/修改后自检;局部细节优先补局部焦点证据板,并行优化方向优先补并行实验证据板;同构列表、卡片、网格或表格需要补对齐辅助线证据板,并同时检查容器轨道和内部内容槽位轨道;单个 logo、icon、avatar、badge、按钮图形或图片内部需要居中判定、视觉重心评估或偏心排查时,需要补内部居中证据板,并同时检查画布中心、主体外接框中心和视觉重心偏移。',
2784
3465
  ].join('\n');
2785
3466
  }
2786
3467
 
2787
- function learningReviewMessage() {
3468
+ function canvasCollaborationMessage(intent) {
3469
+ if (!intent?.canvasCollaborationRequest) {
3470
+ return null;
3471
+ }
2788
3472
  return [
3473
+ '当前请求涉及 OpenPrd 画布协同(生图上画布、人在画布上标注、标注结果回到对话继续改)。',
3474
+ '打开或复用画布:运行 `openprd canvas .`;无法自动识别当前对话时用 `--thread <id> --thread-title <name>` 或 `--session <id> --session-title <name>` 显式绑定,画布必须绑定当前对话。',
3475
+ '结构化读画布:需要知道用户正在圈哪些元素时,优先 `GET /api/selection` 读取选中元素的 ID、类型、坐标、尺寸和文本,不要只靠截图猜;需要全量场景再读 `GET /api/scene`。',
3476
+ '结构化写画布:把生成的新图放上画布时,优先 `POST /api/insert-image`,用 `anchorElementId` + `placement`(right/left/below/above)把新图放到指定元素旁边,不要覆盖原图;回填 AI 生图占位卡时继续用 `POST /api/images` 带 `placeholderId`。',
3477
+ '标注改图闭环:如果交接记录(handoff)的 `selection` 是 `annotation-edit`,或带有 `sourceImageElementIds` / `annotationElementIds`,说明用户在原图上画了标注要求改图。把截图里的标注当修改指令,生成修改后的新图,再用 `POST /api/insert-image` 以原图为 anchor 放到旁边,方便用户对比原图和新版。',
3478
+ '生图尺寸契约:handoff 或占位卡带 `sizeContract`(width/height/aspectRatio)时,生成图片必须按这个宽高比出图,避免回填时变形拉伸。',
3479
+ '每轮改图后主动确认:新图是否符合标注意图、是否继续下一轮标注迭代;用户可以继续在新图上标注,形成多轮改图闭环。',
3480
+ ].join('\n');
3481
+ }
3482
+
3483
+ function learningGenreFlag(intent = null) {
3484
+ return intent?.learningGenre ? ` --genre ${intent.learningGenre}` : '';
3485
+ }
3486
+
3487
+ function learningReviewCommand(intent = null) {
3488
+ return `openprd learn . --topic "<学习主题>"${learningGenreFlag(intent)} --open`;
3489
+ }
3490
+
3491
+ function learningReviewMessage(intent = null) {
3492
+ const command = learningReviewCommand(intent);
3493
+ const lines = [
2789
3494
  '如果当前交付目标是把一次工作转成可学习、可复用、可回看、可教学或可沉淀的材料,不要先按关键词判断,也不要只停留在普通说明文档。',
2790
3495
  '先判断期望产物形态:是否需要章节结构、证据锚点、图文讲解、检索练习、工作示例或阅读体验。需要这些形态时,优先使用 `openprd learn .` 生成学习包骨架、阅读器和证据清单,再补充正文内容;普通 Markdown 只能作为辅助讲义。',
2791
- ].join('\n');
3496
+ ];
3497
+ if (intent?.learningMaterialRequest) {
3498
+ lines.unshift('当前输入已被识别为学习型交付物请求:这是按产物形态和长期阅读/教学目的判断,不是固定关键词表。');
3499
+ lines.push(`建议先运行 \`${command}\`。如果用户指定了仙侠、科幻、童话等风格,把它当作学习包题材/文体参数,而不是绕过阅读器流程的理由。`);
3500
+ lines.push('生成骨架后,读取 `.openprd/learning/archive/<packageId>/agent-prompt.md`,由 Agent 基于证据写入 `learning-content.json`,再运行 `openprd learn . --content-json <file> --open` 渲染最终 reader;不要把聊天里的普通 Markdown 成稿当完成。');
3501
+ }
3502
+ return lines.join('\n');
2792
3503
  }
2793
3504
 
2794
3505
  function largeUiVisualDirectionMessage() {
@@ -2796,7 +3507,7 @@ function largeUiVisualDirectionMessage() {
2796
3507
  'OpenPrd 大界面改动视觉方案评审:',
2797
3508
  '位置: 需求分流之后、PRD 定稿或实现开工之前;它不同于 review.html,也不同于实现后的 visual-compare。',
2798
3509
  '判断: 先从用户目标、信息架构变化、视觉决策成本和验收风险判断是否需要方向评审,不用关键词触发。会决定首屏、核心布局、主视觉、关键路径、组件层级/密度,或用户需要先选设计方向时触发。',
2799
- '步骤: 已有界面时用 Codex Computer Use 进入产品内对应功能并截当前真实界面;冷启动没有现有界面时,基于已确认 PRD、用户画像、第一版切片和视觉目标写设计 brief。然后调用 `imagegen`(Codex 原生 Image 2)生成至少 3 个不同设计思想方向;把效果图横向拼成一张带 1/2/3 序号的大图,先作为候选效果图给用户评审,不要默认写入 `.openprd/harness/visual-reviews/` 当验收参考。',
3510
+ '步骤: 已有界面时用 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/` 当验收参考。',
2800
3511
  '交互: 主动追问三件事:是否符合预期、是否纳入后续效果图/实现截图对比、是否按此继续后续实现。只有用户确认纳入后续对比或继续实现后,才把选定方向、整张图或其中子图整理成 reference-set 并写入 `.openprd/harness/visual-reviews/`;用户确认前,不进入大 UI 实现,也不要声称界面方案已定。',
2801
3512
  '多对象参考处理: 如果候选效果图是一张图里包含多个子图、网格、多对象或多方向,不要拿整板直接硬比;先运行 `openprd visual-prepare . --reference <效果图> --grid <列>x<行>` 或 `--boxes <plan.json>`,检查 contact sheet,确认 reference-set 后再逐项进入实现和验收。',
2802
3513
  ].join('\n');
@@ -2805,15 +3516,16 @@ function largeUiVisualDirectionMessage() {
2805
3516
  function visualLocalAdjustmentMessage() {
2806
3517
  return [
2807
3518
  '如果这轮验收重点在局部变化,不要只改代码后凭主观判断收尾。',
2808
- '先判断这是新建界面还是修改既有界面:新建界面走实现前 3 方向方案评审;修改既有界面先保留修改前截图。完成后从同一入口、视口、账号和数据状态截修改后截图,再运行 `openprd visual-compare . --before <修改前截图> --after <修改后截图>`。',
2809
- '如果局部细节需要放到同一张证据板里审阅,再补一份 `openprd visual-compare . --board <focus-board.json>` 的局部焦点证据板,把局部变化组合到同一张证据板里统一验收。',
3519
+ '修改前先用一句话锁定本次审美意图和记忆点;收口的 visual-compare/focus-board 不只看位置,也要看气质、层级、色彩、字号、间距和表面角色是否被保住。',
3520
+ '先判断这是新建界面还是修改既有界面:新建界面走实现前 3 方向方案评审;修改既有界面先保留修改前截图。完成后从同一入口、视口、账号和数据状态截修改后截图,再运行 `openprd visual-compare . --before <修改前截图> --after <修改后截图> --locale <zh-CN|en>`。',
3521
+ '如果局部细节需要放到同一张证据板里审阅,再补一份 `openprd visual-compare . --board <focus-board.json> --locale <zh-CN|en>` 的局部焦点证据板,把局部变化组合到同一张证据板里统一验收;如果只是普通截图、Computer/Browser/Playwright 实测截图,也要用 `openprd visual-compare . --board <verification-board.json> --locale <zh-CN|en>` 拼成截图实测证据板;如果界面存在同构列表、卡片、网格或表格,或用户反馈元素没有对齐,要用 `openprd visual-compare . --board <alignment-board.json> --locale <zh-CN|en>` 拼成对齐辅助线证据板,且同一张板里同时覆盖容器轨道和标题/副标题/描述/标签/状态/价格/按钮/图标等内部内容槽位;如果单个素材/图标/头像/徽标/按钮图形/图片内部需要居中或视觉重心判定,要用 `openprd visual-compare . --board <centering-board.json> --locale <zh-CN|en>` 拼成内部居中证据板,且同一张板里同时覆盖红色画布中心、绿色主体外接框和黄色视觉重心点,不能把单张截图当最终视觉证据。',
2810
3522
  '如果参考图是一张整板、网格图或多对象组合图,先运行 `openprd visual-prepare . --reference <效果图> --grid <列>x<行>` 或 `--boxes <plan.json>`,确认 contact sheet 后,再决定是逐项 `--reference/--actual` 还是统一做 `focus-board` / `parallel-board`。',
2811
- '当用户后续说“跟效果图”“不一致”“好丑”“复刻”“没对齐”这类反馈时,不能只口头说已经对比过了;至少先产出一份 `openprd visual-compare`、`focus-board` 或 `parallel-board` 证据图再下结论。'
3523
+ '当用户后续说“跟效果图”“不一致”“好丑”“复刻”“没对齐”“偏心”“不居中”这类反馈时,不能只口头说已经对比过了;至少先产出一份 `openprd visual-compare`、`focus-board`、`parallel-board`、`verification-board`、`alignment-board` 或 `centering-board` 证据图再下结论。'
2812
3524
  ].join('\n');
2813
3525
  }
2814
3526
 
2815
3527
  function codexConfirmationReplyRule() {
2816
- return 'Codex 回复规则: 只有当前真的还缺人来拍板时,才在 final answer 里停下来请求确认。只要用户已经明确说了“认可并继续”,或已经确认了 mini-plan、范围边界、正式产品边界,就直接沿着已确认路径继续,不要再写“如果你认可”“确认的话我就继续”这类二次索取确认的话。面向用户时,不要直接抛 lane、change、tasks、review artifact、work unit、digest、worker shard、write-scope、worktree、内部版本号、命令或文件路径这些内部词;统一改说“需求确认稿”“本次调整”“后续任务”“继续落地”“完成后检查”。如果后面真的还需要额外授权,再用人话把会做什么、不会做什么、完成后怎么检查说清楚。如果当前仍在 L2 的首轮澄清或需求摘要确认阶段,不要写成“你回我一句我就开始实现”;只能承诺“我先整理需求摘要给你确认,确认后再继续”。如果当前 lane 已进入 silent-record 策略,只能说明用户已经明确说了“不需要进行任何确认”;单纯的“请帮我实现/继续实现”不能把 lane 切到 silent-record。';
3528
+ return 'Codex 回复规则: 只有当前真的还缺人来拍板时,才在 final answer 里停下来请求确认。停下请求确认时执行“一次确认合同”:把本条需求后续本来会出现的全部确认点合并成一段话一次性说清,并告诉用户只要回一句“确认,继续执行”,剩余环节会自动连续推进;同一条需求不允许出现第二个人工确认停顿。绝对不要让用户粘贴 digest、work unit、版本号或任何 openprd 命令——这些值 hook 会自动绑定,需要时由你自己填进命令。只要用户已经明确说了“认可并继续”,或已经确认了 mini-plan、范围边界、正式产品边界,就直接沿着已确认路径继续,不要再写“如果你认可”“确认的话我就继续”这类二次索取确认的话。面向用户时,不要直接抛 lane、change、tasks、review artifact、work unit、digest、worker shard、write-scope、worktree、内部版本号、命令或文件路径这些内部词;统一改说“需求确认稿”“本次调整”“后续任务”“继续落地”“完成后检查”。如果后面真的还需要额外授权,再用人话把会做什么、不会做什么、完成后怎么检查说清楚。如果当前仍在 L2 的首轮澄清或需求摘要确认阶段,不要写成“你回我一句我就开始实现”;只能承诺“我先整理需求摘要给你确认,确认后再继续”。如果当前 lane 已进入 silent-record 策略,只能说明用户已经明确说了“不需要进行任何确认”;单纯的“请帮我实现/继续实现”不能把 lane 切到 silent-record。';
2817
3529
  }
2818
3530
 
2819
3531
  function confirmationGateMessage(gate) {
@@ -2826,7 +3538,7 @@ function confirmationGateMessage(gate) {
2826
3538
  return [
2827
3539
  intro,
2828
3540
  'Implementation may proceed only within the confirmed scope, with docs/basic, file manuals, folder README docs, standards verification, and OpenPrd run verification kept up to date. For backend, script, agent, tooling, service, or data-processing changes, keep CLI and API surface review current in docs/basic/backend-structure.md.',
2829
- 'For UI or visual work with an existing reference image, first confirm the user has accepted that image as a later comparison reference; if one image contains multiple sub-images, grid cells, or objects, run openprd visual-prepare . --reference <effect-image> --grid <columns>x<rows> or --boxes <plan.json>, inspect the contact sheet, and use the generated reference-set / compare-plan before comparing. Then capture the implemented UI and run openprd visual-compare . --reference <effect-image> --actual <implementation-screenshot>; if local detail matters more than the whole screen, add openprd visual-compare . --board <focus-board.json> so the agent can review numbered zoom regions. When there is no reference image, capture the before screenshot first, implement, capture the after screenshot from the same entry, viewport, account, and data state, then run openprd visual-compare . --before <before-screenshot> --after <after-screenshot>; if the agent explored multiple optimization directions, add openprd visual-compare . --board <parallel-board.json> and inspect expected changes plus unintended drift before claiming completion.',
3541
+ 'For UI or visual work with an existing reference image, first confirm the user has accepted that image as a later comparison reference; if one image contains multiple sub-images, grid cells, or objects, run openprd visual-prepare . --reference <effect-image> --grid <columns>x<rows> or --boxes <plan.json>, inspect the contact sheet, and use the generated reference-set / compare-plan before comparing. Then capture the implemented UI and run openprd visual-compare . --reference <effect-image> --actual <implementation-screenshot>; if local detail matters more than the whole screen, add openprd visual-compare . --board <focus-board.json> --locale <zh-CN|en> so the agent can review numbered zoom regions. When there is no reference image, capture the before screenshot first, implement, capture the after screenshot from the same entry, viewport, account, and data state, then run openprd visual-compare . --before <before-screenshot> --after <after-screenshot>; if the agent explored multiple optimization directions, add openprd visual-compare . --board <parallel-board.json> --locale <zh-CN|en>. If ordinary screenshots or Computer/Browser/Playwright checks are used as evidence, assemble them with the checked path and checkpoints through openprd visual-compare . --board <verification-board.json> --locale <zh-CN|en> before claiming visual completion. If the UI contains repeated homogeneous cards, lists, grids, or tables, or the user reports misalignment, capture the real screen, overlay guide lines, measure both container-track and internal content-slot x/y/size/baseline spread, and assemble an alignment-board through openprd visual-compare . --board <alignment-board.json> --locale <zh-CN|en>. If a single logo, icon, avatar, badge, button graphic, or image crop needs internal-centering or visual-centroid judgment, crop the target and assemble a centering-board through openprd visual-compare . --board <centering-board.json> --locale <zh-CN|en> before claiming it is centered.',
2830
3542
  'If the user later says the implementation does not match the effect image, looks wrong, or asks for replication, do not rely on subjective narration alone; produce at least one visual evidence artifact before claiming completion.',
2831
3543
  ].join('\n');
2832
3544
  }
@@ -2842,7 +3554,7 @@ function currentRequirementStatusLine(gate, progress) {
2842
3554
  }
2843
3555
  switch (progress?.nextStep) {
2844
3556
  case 'clarification-confirmation-required':
2845
- return '当前卡点: 先让用户确认当前需求摘要;在此之前不要把核心需求写成既定事实,也不要直接往后推进。';
3557
+ return '当前卡点: 先让用户确认当前需求摘要;在此之前不要把核心需求写成既定事实,也不要直接往后推进。请求确认时按“一次确认合同”表达:明确告诉用户这是本条需求默认唯一的一次人工确认,只要回复“确认,继续执行”,后面的需求稿整理、调整范围、任务拆解和实现都会自动连续推进,不会再逐环节找他确认。';
2846
3558
  case 'clarification-writeback-required':
2847
3559
  return '当前卡点: 用户已经确认这轮需求摘要或默认方案,但 current.json 里还没有把确认后的事实稳定写回。先按 canonical 字段路径用 user-confirmed 回写,再继续 synthesize;不要再回退成泛化 clarify,也不要继续写 agent-inferred。';
2848
3560
  case 'product-type-classification-required':
@@ -2855,7 +3567,7 @@ function currentRequirementStatusLine(gate, progress) {
2855
3567
  : '当前卡点: 当前需求摘要已确认,下一步把已确认内容整理成这版需求确认稿。';
2856
3568
  case 'prd-review-required':
2857
3569
  return progress?.review?.versionId
2858
- ? '当前卡点: 先等用户确认这版需求确认稿;不要把“继续做”“开落地吧”或单纯的“请帮我实现/继续实现”当成已经认可这版内容。'
3570
+ ? '当前卡点: 先等用户确认这版需求确认稿;不要把“继续做”“开落地吧”或单纯的“请帮我实现/继续实现”当成已经认可这版内容。请求确认时只需要用户回一句自然语言,例如“确认这版,继续执行”;版本、digest、work unit 由 hook 自动绑定,绝对不要让用户粘贴 digest、work unit 或任何 openprd 命令。同时说明这一句确认之后整条链会自动执行到实现,不会再停。'
2859
3571
  : '当前卡点: 先整理出这版需求确认稿,再等待用户确认。';
2860
3572
  case 'review-recording-required':
2861
3573
  return '当前卡点: 这版需求确认稿已经生成,而且用户已明确表示不需要再停下来确认;可以先记录这次确认结果,再继续后面的整理。';
@@ -2957,16 +3669,17 @@ function contextMessage(cwd, intent = null, gate = null, progress = null) {
2957
3669
  knowledgeSkillReminder(run.parsed),
2958
3670
  'OpenPrd 上下文只是建议,不是自动执行指令。请先判断用户当前意图。',
2959
3671
  lightweightRequirementMessage(intent),
2960
- visualMockupMessage(intent),
2961
- learningReviewMessage(),
3672
+ visualMockupMessage(intent, cwd),
3673
+ canvasCollaborationMessage(intent),
3674
+ learningReviewMessage(intent),
2962
3675
  largeUiVisualDirectionMessage(),
2963
3676
  visualLocalAdjustmentMessage(),
2964
3677
  requirementRoutingSummary(),
2965
3678
  '如果用户只是要求看看、规划、分析、审查、解释影响或列出文件,请保持只读并基于证据回答;不要运行 OpenPrd loop、任务推进、discovery 推进、commit 或其他写入命令。',
2966
3679
  '只有当用户当前明确要求开发、实现、修复、继续任务、深度调研、对标复刻或提交时,才运行 openprd loop --run、openprd tasks --advance、openprd discovery --advance、commit/push 等执行命令。',
2967
- '代码修改完成后、最终回复前,针对本轮实际 touched code files 运行 openprd dev-check . <file...>;若出现需要关注的文件,最终回复必须以 **后续建议** 为标题,直接复用 dev-check 生成的 Markdown 表格,列出影响对象、关注程度、规模信号、预警原因、本次处理结果和后续建议,并按 🔴 → 🟠 → 🟡 排序;不要把“关注程度”列改写成纯 emoji,必须保留例如“🟠 中风险|建议优先关注”这类完整标签;如果你改写了“预警原因 / 本次处理结果 / 后续建议”,先用 `node scripts/dev-check-wrapup-copy.mjs --validate` 校验每格不超过 20 字;若报错,按提示缩短后重试。',
3680
+ '代码修改完成后、最终回复前,针对本轮实际 touched code files 运行 openprd dev-check . <file...>;若出现需要关注的文件:自动优化开启(默认)时,先在本轮完成高内聚低耦合拆分(不丢功能、行为不变、拆完重跑测试),最终回复以 **OpenPRD 自动优化报告** 为标题直接复用 dev-check 生成的 Markdown 表格(列:影响对象、问题级别、源文件规模、优化原因、本次处理结果、后续建议),并附自动优化开关确认一句话;自动优化关闭时,以 **后续建议** 为标题复用表格(列:影响对象、关注程度、规模信号、预警原因、本次处理结果、后续建议)。两种模式都按 🔴 → 🟠 → 🟡 排序;不要把“问题级别/关注程度”列改写成纯 emoji,必须保留例如“🟠 中风险|建议优先关注”这类完整标签;如果你改写了表格文案,先用 `node scripts/dev-check-wrapup-copy.mjs --validate` 校验每格不超过 20 字;若报错,按提示缩短后重试。',
2968
3681
  '大界面改动的效果图先当候选效果图;出图后主动确认是否符合预期、是否纳入后续效果图/实现截图对比、是否按此继续实现。只有确认后,才把选定方向、整张图或其中子图整理到 `.openprd/harness/visual-reviews/` 并进入大 UI 实现。',
2969
- '涉及界面、页面、视觉、样式或前端体验时,已有确认参考图才运行 openprd visual-compare . --reference <效果图> --actual <实现截图>;如果一张参考图里有多个子图/对象/网格,先运行 openprd visual-prepare . --reference <效果图> --grid <列>x<行> 或 --boxes <plan.json>,确认 contact sheet 后再逐项对比。没有明确参考图时先判断新建还是修改:新建界面走实现前 3 方向方案评审;修改既有界面则动手前先截修改前截图,完成后运行 openprd visual-compare . --before <修改前截图> --after <修改后截图>;如果并行试了多个优化方向,再补一份 openprd visual-compare . --board <parallel-board.json>。用户后续如果说“跟效果图”“不一致”“好丑”“复刻”,不能只口头说对比过了,至少产出一份 visual-compare / focus-board / parallel-board 证据图。',
3682
+ '涉及界面、页面、视觉、样式或前端体验时,已有确认参考图才运行 openprd visual-compare . --reference <效果图> --actual <实现截图> --locale <zh-CN|en>;如果一张参考图里有多个子图/对象/网格,先运行 openprd visual-prepare . --reference <效果图> --grid <列>x<行> 或 --boxes <plan.json>,确认 contact sheet 后再逐项对比。没有明确参考图时先判断新建还是修改:新建界面走实现前 3 方向方案评审;修改既有界面则动手前先截修改前截图,完成后运行 openprd visual-compare . --before <修改前截图> --after <修改后截图> --locale <zh-CN|en>;如果并行试了多个优化方向,再补一份 openprd visual-compare . --board <parallel-board.json> --locale <zh-CN|en>。普通截图、Computer/Browser/Playwright 实测截图只能作为原始素材,收口前要补 openprd visual-compare . --board <verification-board.json> --locale <zh-CN|en> 的截图实测证据板。新开发或修改同构列表、卡片、网格、表格时,或用户反馈没对齐/排版漂移时,还要补 openprd visual-compare . --board <alignment-board.json> --locale <zh-CN|en> 的对齐辅助线证据板,同时量测容器轨道和标题/副标题/描述/标签/状态/价格/按钮/图标等内部内容槽位的 x/y/宽高/baseline spread。单个素材/图标/头像/徽标/按钮图形/图片内部居中、偏心或视觉重心判断时,还要补 openprd visual-compare . --board <centering-board.json> --locale <zh-CN|en> 的内部居中证据板,同时量测画布中心、主体外接框中心和视觉重心偏移。用户后续如果说“跟效果图”“不一致”“好丑”“复刻”,不能只口头说对比过了,至少产出一份 visual-compare / focus-board / parallel-board / verification-board / alignment-board / centering-board 证据图。',
2970
3683
  '发现可沉淀项时不要中途打断任务:代码扩展识别这类白名单工具补全会自动应用并记录;用户偏好、项目协作规矩和 OpenPrd 默认行为先记录为候选,收工时运行 openprd grow . --review 集中确认。',
2971
3684
  '维护 OpenPrd 本身且涉及配置类能力时,先判断是否应纳入 openprd grow;高置信可成长默认纳入,不确定则主动询问用户。',
2972
3685
  '涉及后端、脚本、Agent、工具链、服务或数据处理变更时,把 CLI 与 API 视为同级接入面:同步检查命令入口、参数、输出契约、help/doctor/dry-run/status 与接口协议、返回结构、身份边界是否受影响,并更新 docs/basic/backend-structure.md 或明确写不适用原因。',
@@ -2979,16 +3692,17 @@ function contextMessage(cwd, intent = null, gate = null, progress = null) {
2979
3692
  knowledgeSkillReminder(run.parsed),
2980
3693
  'OpenPrd 上下文只是建议,不是自动执行指令。请先判断用户当前意图。',
2981
3694
  lightweightRequirementMessage(intent),
2982
- visualMockupMessage(intent),
2983
- learningReviewMessage(),
3695
+ visualMockupMessage(intent, cwd),
3696
+ canvasCollaborationMessage(intent),
3697
+ learningReviewMessage(intent),
2984
3698
  largeUiVisualDirectionMessage(),
2985
3699
  visualLocalAdjustmentMessage(),
2986
3700
  requirementRoutingSummary(),
2987
3701
  '如果用户只是要求看看、规划、分析、审查、解释影响或列出文件,请保持只读并基于证据回答;不要运行 OpenPrd loop、任务推进、discovery 推进、commit 或其他写入命令。',
2988
3702
  '只有当用户当前明确要求开发、实现、修复、继续任务、深度调研、对标复刻或提交时,才运行 openprd loop --run、openprd tasks --advance、openprd discovery --advance、commit/push 等执行命令。',
2989
- '代码修改完成后、最终回复前,针对本轮实际 touched code files 运行 openprd dev-check . <file...>;若出现需要关注的文件,最终回复必须以 **后续建议** 为标题,直接复用 dev-check 生成的 Markdown 表格,列出影响对象、关注程度、规模信号、预警原因、本次处理结果和后续建议,并按 🔴 → 🟠 → 🟡 排序;不要把“关注程度”列改写成纯 emoji,必须保留例如“🟠 中风险|建议优先关注”这类完整标签;如果你改写了“预警原因 / 本次处理结果 / 后续建议”,先用 `node scripts/dev-check-wrapup-copy.mjs --validate` 校验每格不超过 20 字;若报错,按提示缩短后重试。',
3703
+ '代码修改完成后、最终回复前,针对本轮实际 touched code files 运行 openprd dev-check . <file...>;若出现需要关注的文件:自动优化开启(默认)时,先在本轮完成高内聚低耦合拆分(不丢功能、行为不变、拆完重跑测试),最终回复以 **OpenPRD 自动优化报告** 为标题直接复用 dev-check 生成的 Markdown 表格(列:影响对象、问题级别、源文件规模、优化原因、本次处理结果、后续建议),并附自动优化开关确认一句话;自动优化关闭时,以 **后续建议** 为标题复用表格(列:影响对象、关注程度、规模信号、预警原因、本次处理结果、后续建议)。两种模式都按 🔴 → 🟠 → 🟡 排序;不要把“问题级别/关注程度”列改写成纯 emoji,必须保留例如“🟠 中风险|建议优先关注”这类完整标签;如果你改写了表格文案,先用 `node scripts/dev-check-wrapup-copy.mjs --validate` 校验每格不超过 20 字;若报错,按提示缩短后重试。',
2990
3704
  '大界面改动的效果图先当候选效果图;出图后主动确认是否符合预期、是否纳入后续效果图/实现截图对比、是否按此继续实现。只有确认后,才把选定方向、整张图或其中子图整理到 `.openprd/harness/visual-reviews/` 并进入大 UI 实现。',
2991
- '涉及界面、页面、视觉、样式或前端体验时,已有确认参考图才运行 openprd visual-compare . --reference <效果图> --actual <实现截图>;如果一张参考图里有多个子图/对象/网格,先运行 openprd visual-prepare . --reference <效果图> --grid <列>x<行> 或 --boxes <plan.json>,确认 contact sheet 后再逐项对比。没有明确参考图时先判断新建还是修改:新建界面走实现前 3 方向方案评审;修改既有界面则动手前先截修改前截图,完成后运行 openprd visual-compare . --before <修改前截图> --after <修改后截图>;如果并行试了多个优化方向,再补一份 openprd visual-compare . --board <parallel-board.json>。用户后续如果说“跟效果图”“不一致”“好丑”“复刻”,不能只口头说对比过了,至少产出一份 visual-compare / focus-board / parallel-board 证据图。',
3705
+ '涉及界面、页面、视觉、样式或前端体验时,已有确认参考图才运行 openprd visual-compare . --reference <效果图> --actual <实现截图> --locale <zh-CN|en>;如果一张参考图里有多个子图/对象/网格,先运行 openprd visual-prepare . --reference <效果图> --grid <列>x<行> 或 --boxes <plan.json>,确认 contact sheet 后再逐项对比。没有明确参考图时先判断新建还是修改:新建界面走实现前 3 方向方案评审;修改既有界面则动手前先截修改前截图,完成后运行 openprd visual-compare . --before <修改前截图> --after <修改后截图> --locale <zh-CN|en>;如果并行试了多个优化方向,再补一份 openprd visual-compare . --board <parallel-board.json> --locale <zh-CN|en>。普通截图、Computer/Browser/Playwright 实测截图只能作为原始素材,收口前要补 openprd visual-compare . --board <verification-board.json> --locale <zh-CN|en> 的截图实测证据板。新开发或修改同构列表、卡片、网格、表格时,或用户反馈没对齐/排版漂移时,还要补 openprd visual-compare . --board <alignment-board.json> --locale <zh-CN|en> 的对齐辅助线证据板,同时量测容器轨道和标题/副标题/描述/标签/状态/价格/按钮/图标等内部内容槽位的 x/y/宽高/baseline spread。单个素材/图标/头像/徽标/按钮图形/图片内部居中、偏心或视觉重心判断时,还要补 openprd visual-compare . --board <centering-board.json> --locale <zh-CN|en> 的内部居中证据板,同时量测画布中心、主体外接框中心和视觉重心偏移。用户后续如果说“跟效果图”“不一致”“好丑”“复刻”,不能只口头说对比过了,至少产出一份 visual-compare / focus-board / parallel-board / verification-board / alignment-board / centering-board 证据图。',
2992
3706
  '发现可沉淀项时不要中途打断任务:代码扩展识别这类白名单工具补全会自动应用并记录;用户偏好、项目协作规矩和 OpenPrd 默认行为先记录为候选,收工时运行 openprd grow . --review 集中确认。',
2993
3707
  '维护 OpenPrd 本身且涉及配置类能力时,先判断是否应纳入 openprd grow;高置信可成长默认纳入,不确定则主动询问用户。',
2994
3708
  '涉及后端、脚本、Agent、工具链、服务或数据处理变更时,把 CLI 与 API 视为同级接入面:同步检查命令入口、参数、输出契约、help/doctor/dry-run/status 与接口协议、返回结构、身份边界是否受影响,并更新 docs/basic/backend-structure.md 或明确写不适用原因。',
@@ -3006,11 +3720,13 @@ function contextMessage(cwd, intent = null, gate = null, progress = null) {
3006
3720
  next.ok ? next.stdout : '',
3007
3721
  gateMessage,
3008
3722
  lightweightRequirementMessage(intent),
3009
- visualMockupMessage(intent),
3723
+ visualMockupMessage(intent, cwd),
3724
+ canvasCollaborationMessage(intent),
3725
+ learningReviewMessage(intent),
3010
3726
  largeUiVisualDirectionMessage(intent),
3011
3727
  requirementRoutingSummary(),
3012
3728
  'OpenPrd 下一步只是建议。规划、分析、审查类请求保持只读;只有用户当前明确要求开发、深度调研、对标复刻或继续任务时才执行。',
3013
- '代码修改完成后、最终回复前,针对本轮实际 touched code files 运行 openprd dev-check . <file...>;若出现需要关注的文件,最终回复必须以 **后续建议** 为标题,直接复用 dev-check 生成的 Markdown 表格,列出影响对象、关注程度、规模信号、预警原因、本次处理结果和后续建议,并按 🔴 → 🟠 → 🟡 排序;不要把“关注程度”列改写成纯 emoji,必须保留例如“🟠 中风险|建议优先关注”这类完整标签;如果你改写了“预警原因 / 本次处理结果 / 后续建议”,先用 `node scripts/dev-check-wrapup-copy.mjs --validate` 校验每格不超过 20 字;若报错,按提示缩短后重试。',
3729
+ '代码修改完成后、最终回复前,针对本轮实际 touched code files 运行 openprd dev-check . <file...>;若出现需要关注的文件:自动优化开启(默认)时,先在本轮完成高内聚低耦合拆分(不丢功能、行为不变、拆完重跑测试),最终回复以 **OpenPRD 自动优化报告** 为标题直接复用 dev-check 生成的 Markdown 表格(列:影响对象、问题级别、源文件规模、优化原因、本次处理结果、后续建议),并附自动优化开关确认一句话;自动优化关闭时,以 **后续建议** 为标题复用表格(列:影响对象、关注程度、规模信号、预警原因、本次处理结果、后续建议)。两种模式都按 🔴 → 🟠 → 🟡 排序;不要把“问题级别/关注程度”列改写成纯 emoji,必须保留例如“🟠 中风险|建议优先关注”这类完整标签;如果你改写了表格文案,先用 `node scripts/dev-check-wrapup-copy.mjs --validate` 校验每格不超过 20 字;若报错,按提示缩短后重试。',
3014
3730
  '发现可沉淀项时不要中途打断任务:代码扩展识别这类白名单工具补全会自动应用并记录;用户偏好、项目协作规矩和 OpenPrd 默认行为先记录为候选,收工时运行 openprd grow . --review 集中确认。',
3015
3731
  '维护 OpenPrd 本身且涉及配置类能力时,先判断是否应纳入 openprd grow;高置信可成长默认纳入,不确定则主动询问用户。',
3016
3732
  '涉及后端、脚本、Agent、工具链、服务或数据处理变更时,把 CLI 与 API 视为同级接入面,并同步更新 docs/basic/backend-structure.md 或明确写不适用原因。',
@@ -3037,6 +3753,7 @@ function shouldInjectOpenPrdContext(payload) {
3037
3753
  && !intent.publicRepoResearchRequest
3038
3754
  && !intent.externalTechResearchRequest
3039
3755
  && !intent.skillWorkflowEditRequest
3756
+ && !intent.learningMaterialRequest
3040
3757
  ) {
3041
3758
  return false;
3042
3759
  }
@@ -3091,6 +3808,11 @@ function classifyRisk(payload) {
3091
3808
  /yarn\s+npm\s+publish/,
3092
3809
  /gh\s+release/,
3093
3810
  /rm\s+-rf/,
3811
+ /\b(?:ssh|scp|rsync)\b[^\n]*(?:prod|production|线上|生产|云端|server|remote)/,
3812
+ /\b(?:kubectl|helm)\b[^\n]*(?:apply|patch|set|delete|rollout|scale|exec)[^\n]*(?:prod|production|线上|生产|云端|cluster)/,
3813
+ /\b(?:vercel|wrangler|fly|flyctl|railway|firebase)\b[^\n]*(?:deploy|up)[^\n]*(?:--prod|prod|production|线上|生产)/,
3814
+ /\bsupabase\s+db\s+push\b[^\n]*(?:--linked|--db-url|prod|production|线上|生产)/,
3815
+ /\bcurl\b[^\n]*(?:-x|--request)\s*(?:post|put|patch|delete)\b[^\n]*(?:prod|production|线上|生产|云端|api\.)/,
3094
3816
  /openprd\s+freeze\b/,
3095
3817
  /openprd\s+handoff\b/,
3096
3818
  /openprd\s+change\s+.*--apply/,
@@ -3224,6 +3946,102 @@ function hasRecentArtifacts(root, relativeDir, turnState, matcher) {
3224
3946
  return collectRecentArtifactPaths(dir, startedAtMs, matcher, []).length > 0;
3225
3947
  }
3226
3948
 
3949
+ function hasRecentVisualReviewArtifact(root, turnState) {
3950
+ return hasRecentArtifacts(root, '.openprd/harness/visual-reviews', turnState, (artifactPath) => {
3951
+ const normalized = artifactPath.split(path.sep).join('/');
3952
+ return /\.(?:jpe?g|png|webp|json)$/i.test(normalized)
3953
+ && /visual-(?:compare|before-after|focus-board|parallel-board|verification-board|alignment-board|centering-board)|reference-actual|before-after|focus-board|parallel-board|verification-board|alignment-board|centering-board|center-board/i.test(normalized);
3954
+ });
3955
+ }
3956
+
3957
+ function isVisualEvidenceIntent(intent = null) {
3958
+ const text = String(intent?.promptText || '');
3959
+ return isFrontendTaskIntent(intent)
3960
+ || hasLightweightUiVisualSignal(text)
3961
+ || /(界面|页面|前端|视觉|样式|布局|信息架构|效果图|设计稿|参考图|实现截图|视觉对比|视觉评审|截图|实测|Computer\s*Use|Browser|Playwright|复刻|不一致|好丑|没对齐|没有对齐|不对齐|对不齐|对不上|不像|对齐有问题|对齐问题|排版漂移|布局漂移|左右偏差|上下偏差|横向偏差|竖向偏差|同构|重复单元|列表卡片|卡片列表|卡片网格|网格卡片|网格列表|列表网格|对齐辅助线|内容槽位|内部槽位|内部居中|视觉重心|主体外接框|画布中心|偏心|不居中|没居中|图标内部|图片内部|素材内部)/i.test(text);
3962
+ }
3963
+
3964
+ function visualEvidenceStopReminder(root, turnState, stopIntent) {
3965
+ if (stopIntent?.visualMockupRequest) {
3966
+ return null;
3967
+ }
3968
+ const touchedFiles = Array.isArray(turnState?.touchedFiles) ? turnState.touchedFiles : [];
3969
+ const frontendTouched = touchedFiles.some((file) => looksLikeFrontendImplementationFile(file));
3970
+ const rawScreenshotObserved = hasTurnReviewSignal(turnState, 'visual-raw-screenshot');
3971
+ const visualArtifactObserved = hasTurnReviewSignal(turnState, 'visual-review-artifact')
3972
+ || hasTurnReviewSignal(turnState, 'visual-verification-board')
3973
+ || hasTurnReviewSignal(turnState, 'visual-alignment-board')
3974
+ || hasTurnReviewSignal(turnState, 'visual-centering-board')
3975
+ || hasRecentVisualReviewArtifact(root, turnState);
3976
+ const needsVisualEvidence = isVisualEvidenceIntent(stopIntent)
3977
+ && (rawScreenshotObserved || frontendTouched || /截图|实测|Computer\s*Use|Browser|Playwright/i.test(String(stopIntent?.promptText || '')));
3978
+ if (!needsVisualEvidence || visualArtifactObserved) {
3979
+ return null;
3980
+ }
3981
+ const preferred = rawScreenshotObserved
3982
+ ? '`openprd visual-compare . --board <verification-board.json> --locale <zh-CN|en>`,把普通截图、Computer/Browser/Playwright 实测路径和检查点拼成截图实测证据板'
3983
+ : '有参考图时用 `--reference/--actual`,无参考且改既有界面用 `--before/--after`,普通截图实测用 `--board <verification-board.json>`,同构列表/卡片/网格/表格用 `--board <alignment-board.json>`,单元素内部居中/视觉重心用 `--board <centering-board.json>`';
3984
+ return [
3985
+ 'OpenPrd 在本轮收工回顾里发现 UI/视觉任务已有截图、实测或前端改动,但还没有本轮拼图证据。',
3986
+ '即便只是卡片宽度、间距、留白、对齐、颜色、圆角、字号这类轻量可视优化,也需要留下视觉证据;build、package 和 dev-check 不能替代视觉收口。',
3987
+ '如果这轮新开发或修改了同构列表、卡片、网格、表格,或用户反馈元素没对齐,要截真实页面、叠辅助线,同时量容器轨道和标题/副标题/描述/标签/状态/价格/按钮/图标等内部内容槽位的 spread,并生成 alignment-board;只量外框、列宽或行顶不算完整对齐验收。',
3988
+ '如果这轮判断的是单个素材、图标、头像、徽标、按钮图形或图片内部是否居中,要截取或裁出目标元素,用 centering-board 量画布中心、主体外接框中心和视觉重心偏移;单张截图或主观“看起来居中”不算完整居中验收。',
3989
+ '视觉证据还要检查本轮审美意图是否成立:气质、层级、颜色、字号、间距、表面角色和记忆点不能只靠口头描述。',
3990
+ `普通截图和 Computer 实测截图只能作为原始素材,不能单独替代视觉收口。请先运行 ${preferred}。`,
3991
+ '补齐前不要宣称界面视觉已经完成;可以如实说功能或代码已改,但视觉拼图证据还没补齐。',
3992
+ ].join('\n');
3993
+ }
3994
+
3995
+ function recentLearningArtifactPaths(root, turnState, matcher) {
3996
+ const dir = path.join(root, '.openprd/learning/archive');
3997
+ const startedAtMs = parseShanghaiTimestamp(turnState?.startedAt);
3998
+ return collectRecentArtifactPaths(dir, startedAtMs, matcher, []);
3999
+ }
4000
+
4001
+ function hasRecentLearningSkeletonArtifact(root, turnState) {
4002
+ return recentLearningArtifactPaths(root, turnState, (artifactPath) => {
4003
+ const normalized = artifactPath.split(path.sep).join('/');
4004
+ return /\/(?:agent-prompt\.md|reader\.html|learning-package\.json|learning-content\.json)$/i.test(normalized);
4005
+ }).length > 0;
4006
+ }
4007
+
4008
+ function hasRecentAuthoredLearningReaderArtifact(root, turnState) {
4009
+ const contentPaths = recentLearningArtifactPaths(root, turnState, (artifactPath) => artifactPath.endsWith('learning-content.json'));
4010
+ return contentPaths.some((contentPath) => {
4011
+ const content = readJsonSync(contentPath, null);
4012
+ if (!content || content.authoringStatus !== 'agent-authored') {
4013
+ return false;
4014
+ }
4015
+ if (!Array.isArray(content.chapters) || content.chapters.length === 0) {
4016
+ return false;
4017
+ }
4018
+ const readerPath = path.join(path.dirname(contentPath), 'reader.html');
4019
+ return fs.existsSync(readerPath);
4020
+ });
4021
+ }
4022
+
4023
+ function learningMaterialStopReminder(root, turnState, stopIntent) {
4024
+ if (!stopIntent?.learningMaterialRequest) {
4025
+ return null;
4026
+ }
4027
+ if (hasRecentAuthoredLearningReaderArtifact(root, turnState)) {
4028
+ return null;
4029
+ }
4030
+ const command = learningReviewCommand(stopIntent);
4031
+ if (!hasRecentLearningSkeletonArtifact(root, turnState)) {
4032
+ return [
4033
+ 'OpenPrd 在本轮收工回顾里识别到用户要的是学习型交付物,但还没有看到本轮新生成的学习包阅读器。',
4034
+ '请先运行 `' + command + '`,生成 `.openprd/learning/archive/<packageId>/` 下的证据清单、Agent 写作提示、learning-content.json 骨架和 reader.html。',
4035
+ '这类请求按产物形态和长期阅读/教学目的判断,不是固定关键词表;普通 Markdown 只能作为辅助讲义,不能替代 OpenPrd reader。',
4036
+ ].join('\n');
4037
+ }
4038
+ return [
4039
+ 'OpenPrd 在本轮收工回顾里发现学习包骨架已经出现,但还没有本轮 Agent-authored 的最终 reader 内容。',
4040
+ '请读取 `.openprd/learning/archive/<packageId>/agent-prompt.md`,基于证据写入 `learning-content.json`,再运行 `openprd learn . --content-json <file> --open` 重新渲染。',
4041
+ '完成前不要把聊天里的普通 Markdown 成稿当作学习材料交付完成。',
4042
+ ].join('\n');
4043
+ }
4044
+
3227
4045
  function closeoutVerificationReminder(root, turnState, stopIntent) {
3228
4046
  const touchedFiles = Array.isArray(turnState?.touchedFiles) ? turnState.touchedFiles : [];
3229
4047
  if (touchedFiles.length === 0) {
@@ -3287,21 +4105,32 @@ function blockHook(reason) {
3287
4105
 
3288
4106
  function handle(eventName, cwd, payload) {
3289
4107
  const root = findProjectRoot(cwd);
4108
+ const sessionId = sessionIdFor(payload);
4109
+ const automationDecision = automationBypassDecision(root, eventName, payload, sessionId);
4110
+ if (automationDecision.optIn) {
4111
+ rememberAutomationMode(root, sessionId, payload, automationDecision.signals, 'openprd-maintenance');
4112
+ } else if (automationDecision.bypass) {
4113
+ rememberAutomationMode(root, sessionId, payload, automationDecision.signals, 'automation');
4114
+ return allowHook();
4115
+ }
3290
4116
  ensureHarness(root);
3291
4117
  const risk = classifyRisk(payload);
3292
4118
  const fingerprint = fingerprintFor(eventName, payload, risk);
3293
4119
  const duplicate = isDuplicate(root, fingerprint);
3294
- const sessionId = sessionIdFor(payload);
4120
+ const runtimeEnvironment = recordRuntimeEnvironment(root, eventName, payload, sessionId);
3295
4121
  const baseEvent = {
3296
4122
  eventName,
3297
4123
  risk,
3298
4124
  fingerprint,
3299
4125
  duplicate,
3300
4126
  sessionId,
4127
+ runtimeEnvironment: runtimeEnvironment.active,
3301
4128
  preview: preview(payloadText(payload)),
3302
4129
  };
3303
4130
 
3304
4131
  if (eventName === 'SessionStart') {
4132
+ appendEvent(root, { ...baseEvent, outcome: 'session-start' });
4133
+ updateHookState(root, baseEvent);
3305
4134
  return allowHook();
3306
4135
  }
3307
4136
 
@@ -3371,12 +4200,45 @@ function handle(eventName, cwd, payload) {
3371
4200
  updateHookState(root, baseEvent);
3372
4201
  return allowHook(composeHookContext(root, intent, gate, progress, sessionId));
3373
4202
  }
4203
+ const repeatedConfirmationSignal = intent.confirmation
4204
+ || shortAffirmative
4205
+ || (clarificationFollowup && /(确认|继续|落地|开始|推进|按(这个|你的)|就这么|开做)/.test(stripMarkdown(prompt)));
4206
+ if (
4207
+ repeatedConfirmationSignal
4208
+ && intent.reviewDecision !== 'needs-revision'
4209
+ && !intent.reviewCommand
4210
+ && gateHasClarificationConfirmation(gate)
4211
+ && !reviewPolicyAllowsSilentRecord(requirementApprovalPolicy(gate))
4212
+ ) {
4213
+ gate = escalateRequirementGateApproval(root, prompt, gate, sessionId);
4214
+ progress = evaluateRequirementGateProgress(root, sessionId);
4215
+ if (canAutoAuthorizeRequirementExecution(gate, progress)) {
4216
+ gate = authorizeRequirementGateExecution(root, prompt, sessionId, {
4217
+ reason: 'repeated-user-confirmation-after-summary',
4218
+ });
4219
+ progress = null;
4220
+ }
4221
+ appendEvent(root, { ...baseEvent, outcome: 'requirement-gate-approval-escalated' });
4222
+ recordRunHook(root, baseEvent, 'requirement-gate-approval-escalated');
4223
+ updateHookState(root, baseEvent);
4224
+ return allowHook(composeHookContext(root, intent, gate, progress, sessionId));
4225
+ }
3374
4226
  if (intent.reviewDecision) {
3375
4227
  const authorization = reviewActionAuthorizationFor(intent, progress, prompt);
3376
4228
  gate = holdRequirementGate(root, prompt, progress, sessionId, {
3377
4229
  status: authorization ? 'review-confirmation-authorized' : progress.nextStep,
3378
4230
  reviewActionAuthorization: authorization,
3379
4231
  });
4232
+ if (
4233
+ authorization
4234
+ && intent.reviewDecision === 'confirmed'
4235
+ && (authorization.continueAfterReview || gateHasClarificationConfirmation(gate))
4236
+ ) {
4237
+ // 用户确认评审稿时要么同句要求继续执行,要么此前已确认过需求摘要(重复确认):
4238
+ // 一次确认解锁整条链,后续 change/tasks/实现不再停下索取确认。
4239
+ gate = escalateRequirementGateApproval(root, prompt, gate, sessionId);
4240
+ progress = evaluateRequirementGateProgress(root, sessionId);
4241
+ }
3380
4242
  const outcome = authorization ? 'requirement-gate-review-authorized' : 'requirement-gate-awaiting-review';
3381
4243
  appendEvent(root, {
3382
4244
  ...baseEvent,
@@ -3453,6 +4315,14 @@ function handle(eventName, cwd, payload) {
3453
4315
  ].join('\n'));
3454
4316
  }
3455
4317
  if (researchGate?.active && isMutationPayload(payload, risk) && !authorizedReviewMutation) {
4318
+ const researchBlockStreak = bumpProcessGateBlockCount(root, 'external-evidence');
4319
+ if (researchBlockStreak > PROCESS_GATE_FUSE_LIMIT) {
4320
+ appendEvent(root, { ...baseEvent, outcome: 'research-gate-fuse-open' });
4321
+ recordRunHook(root, baseEvent, 'research-gate-fuse-open');
4322
+ updateHookState(root, baseEvent);
4323
+ recordTouchedFiles(root, payload);
4324
+ return allowHook(processGateFuseMessage('external-evidence', researchBlockStreak, researchGateMessage(researchGate)));
4325
+ }
3456
4326
  appendEvent(root, { ...baseEvent, outcome: 'blocked-research-gate' });
3457
4327
  recordRunHook(root, baseEvent, 'blocked-research-gate');
3458
4328
  updateHookState(root, baseEvent);
@@ -3470,6 +4340,13 @@ function handle(eventName, cwd, payload) {
3470
4340
  'Use the `secrets-vault` skill first and only read the minimum required fields; do not read the original vault file directly.',
3471
4341
  ].join('\n'));
3472
4342
  }
4343
+ const autonomyRisk = detectHighRiskAutonomyMutation(root, payload, turnIntent, risk);
4344
+ if (autonomyRisk && !hasExplicitAutonomyRiskApproval(turnIntent)) {
4345
+ appendEvent(root, { ...baseEvent, outcome: 'blocked-high-risk-autonomy', autonomyRisk });
4346
+ recordRunHook(root, baseEvent, 'blocked-high-risk-autonomy');
4347
+ updateHookState(root, baseEvent);
4348
+ return blockHook(formatHighRiskAutonomyBlock(autonomyRisk));
4349
+ }
3473
4350
  if (isBlockingRequirementGate(gate) && isMutationPayload(payload, risk) && !isAllowedDuringRequirementGate(root, payload, gate, sessionId)) {
3474
4351
  const reviewMark = parseReviewMarkCommand(commandText(payload));
3475
4352
  const approvalPolicy = requirementApprovalPolicy(gate);
@@ -3482,13 +4359,13 @@ function handle(eventName, cwd, payload) {
3482
4359
  : 'OpenPrd blocked review status writing because the current user message did not explicitly confirm this exact PRD review artifact.',
3483
4360
  silentRecord
3484
4361
  ? 'This lane is in silent-record mode: you may record only the exact current version, digest, and work unit, then continue without another user stop.'
3485
- : 'Only record PRD review confirmation from the copied review command or an explicit review approval tied to the current version, digest, and work unit.',
4362
+ : 'A plain natural-language user confirmation of the current review (for example "确认这版,继续") is sufficient; the hook binds it to the exact version, digest, and work unit automatically. NEVER ask the user to paste a digest, work-unit id, or a full openprd command.',
3486
4363
  progress?.review?.versionId
3487
- ? `Current review artifact: version ${progress.review.versionId}, digest ${progress.review.digest}, work unit ${progress.review.workUnitId}.`
4364
+ ? `Current review artifact: version ${progress.review.versionId}, digest ${progress.review.digest}, work unit ${progress.review.workUnitId}. Fill these exact values into the review --mark command yourself; do not ask the user for them.`
3488
4365
  : 'Current review artifact has not been synthesized yet. Run openprd synthesize . --open first.',
3489
4366
  silentRecord
3490
4367
  ? 'Do not mark any stale or different review artifact; only the exact current artifact is allowed.'
3491
- : 'Implementation approval and review confirmation are different gates; do not treat "可以开做", plain "请帮我实现/继续实现", or similar wording as permission to run openprd review --mark confirmed.',
4368
+ : 'Implementation approval and review confirmation are different gates; do not treat "可以开做", plain "请帮我实现/继续实现", or similar wording as permission to run openprd review --mark confirmed. But if the latest user message did confirm this review, retry the mark command with the exact current artifact values above instead of asking the user again.',
3492
4369
  ].filter(Boolean).join('\n')
3493
4370
  : [
3494
4371
  'OpenPrd blocked a mutating action because the requirement gate is still active.',
@@ -3512,6 +4389,14 @@ function handle(eventName, cwd, payload) {
3512
4389
  ? 'Decision-point order: because the user explicitly waived any confirmation stop, you may skip requirement-summary confirmation, write back requirement facts, synthesize the PRD, record the exact stable review artifact, generate the OpenPrd change, prepare the task breakdown, then implement within the confirmed scope.'
3513
4390
  : 'Decision-point order: clarify the requirement, summarize it in chat using 需求判断 / 需求理解 / 功能范围 / 技术方案, prefer Markdown tables for 功能范围 and 技术方案, and if the ask still looks like 0-to-1 validation also surface 第一批最容易触达的社区或种子用户、你为什么算这个社区里的自己人、当前替代方案和痛点证据、先怎么手工交付、手工作战卡怎么写、能不能先用 spreadsheet / 表单 / no-code 跑起来、什么承诺才算真需求、有没有 10 个样本和更强付费信号、最低成本先验证什么、达到什么条件才允许产品化、增长阶段守什么纪律、以及验证阶段怎样先活下来; wait for the user to confirm that requirement summary, write back only confirmed facts, synthesize the PRD, wait for a human review decision on the stable artifact, generate the OpenPrd change, prepare the task breakdown, then implement within the confirmed scope.',
3514
4391
  ].join('\n');
4392
+ const requirementBlockStreak = bumpProcessGateBlockCount(root, 'requirement-intake');
4393
+ if (requirementBlockStreak > PROCESS_GATE_FUSE_LIMIT) {
4394
+ appendEvent(root, { ...baseEvent, outcome: 'requirement-gate-fuse-open' });
4395
+ recordRunHook(root, baseEvent, 'requirement-gate-fuse-open');
4396
+ updateHookState(root, baseEvent);
4397
+ recordTouchedFiles(root, payload);
4398
+ return allowHook(processGateFuseMessage('requirement-intake', requirementBlockStreak, reason));
4399
+ }
3515
4400
  appendEvent(root, { ...baseEvent, outcome: 'blocked-requirement-intake' });
3516
4401
  recordRunHook(root, baseEvent, 'blocked-requirement-intake');
3517
4402
  updateHookState(root, baseEvent);
@@ -3538,6 +4423,18 @@ function handle(eventName, cwd, payload) {
3538
4423
  lastFocusFiles: extractTouchedFiles(root, payload),
3539
4424
  }, sessionId) || patchModeGate;
3540
4425
  } else {
4426
+ const patchModeBlockStreak = bumpProcessGateBlockCount(root, 'patch-mode');
4427
+ if (patchModeBlockStreak > PROCESS_GATE_FUSE_LIMIT) {
4428
+ appendEvent(root, { ...baseEvent, outcome: 'patch-mode-gate-fuse-open' });
4429
+ recordRunHook(root, baseEvent, 'patch-mode-gate-fuse-open');
4430
+ updateHookState(root, baseEvent);
4431
+ recordTouchedFiles(root, payload);
4432
+ return allowHook(processGateFuseMessage(
4433
+ 'patch-mode',
4434
+ patchModeBlockStreak,
4435
+ `下一步仍应尽快真实写入 ${patchModeGate.targetFile} 或它的 sibling draft。`,
4436
+ ));
4437
+ }
3541
4438
  appendEvent(root, {
3542
4439
  ...baseEvent,
3543
4440
  outcome: 'blocked-patch-mode-hover',
@@ -3566,6 +4463,18 @@ function handle(eventName, cwd, payload) {
3566
4463
  const frontendTargets = frontendImplementationTargets(root, payload);
3567
4464
  const designIssues = frontendDesignPreflightIssues(root, turnIntent, frontendTargets);
3568
4465
  if (designIssues.length > 0) {
4466
+ const designBlockStreak = bumpProcessGateBlockCount(root, 'frontend-design-preflight');
4467
+ if (designBlockStreak > PROCESS_GATE_FUSE_LIMIT) {
4468
+ appendEvent(root, { ...baseEvent, outcome: 'frontend-design-gate-fuse-open' });
4469
+ recordRunHook(root, baseEvent, 'frontend-design-gate-fuse-open');
4470
+ updateHookState(root, baseEvent);
4471
+ recordTouchedFiles(root, payload);
4472
+ return allowHook(processGateFuseMessage(
4473
+ 'frontend-design-preflight',
4474
+ designBlockStreak,
4475
+ designIssues.map((issue) => `- ${issue.file}: ${issue.reason}`).join('\n'),
4476
+ ));
4477
+ }
3569
4478
  appendEvent(root, { ...baseEvent, outcome: 'blocked-frontend-design-preflight' });
3570
4479
  recordRunHook(root, baseEvent, 'blocked-frontend-design-preflight');
3571
4480
  updateHookState(root, baseEvent);
@@ -3598,7 +4507,7 @@ function handle(eventName, cwd, payload) {
3598
4507
  recordRunHook(root, baseEvent, 'allowed-medium-risk');
3599
4508
  updateHookState(root, baseEvent);
3600
4509
  recordTouchedFiles(root, payload);
3601
- return allowHook('OpenPrd 检测到写入动作。本轮写入完成后、最终回复前,请针对实际 touched code files 运行 openprd dev-check . <file...>;如出现需要关注的文件,最终回复必须以 **后续建议** 为标题,直接复用 dev-check 生成的 Markdown 表格,说明影响对象、关注程度、规模信号、预警原因、本次处理结果和后续建议,并按 🔴 → 🟠 → 🟡 排序;不要把“关注程度”列改写成纯 emoji,必须保留例如“🟠 中风险|建议优先关注”这类完整标签;如果你改写了“预警原因 / 本次处理结果 / 后续建议”,先用 `node scripts/dev-check-wrapup-copy.mjs --validate` 校验每格不超过 20 字;若报错,按提示缩短后重试;如涉及界面视觉且已有参考效果图并进入实现阶段,阶段性完成后运行 openprd visual-compare . --reference <效果图> --actual <实现截图> 并查看 JPG 对比图;如果参考图来自本轮或前序 `imagegen`,先确认用户已接受它作为后续对比参考;若一张图里有多个子图、对象或网格,先运行 openprd visual-prepare . --reference <效果图> --grid <列>x<行> 或 --boxes <plan.json>,确认 contact sheet 后再逐项对比。若局部细节更重要,再补 openprd visual-compare . --board <focus-board.json>;如无参考图,先判断新建界面还是修改既有界面:新建界面应先完成 3 方向方案评审,修改既有界面确认已先截修改前截图,并在完成后运行 openprd visual-compare . --before <修改前截图> --after <修改后截图> 查看 JPG 自检图;若并行试了多个优化方向,再补 openprd visual-compare . --board <parallel-board.json>;当用户说“跟效果图”“不一致”“好丑”“复刻”时,至少给出一份视觉证据图,不要只口头判断。发现可沉淀项时不要中途打断任务,代码扩展识别这类白名单工具补全会自动应用并记录,用户偏好、项目协作规矩和 OpenPrd 默认行为留到收工时用 openprd grow . --review 集中确认;维护 OpenPrd 本身且涉及配置类能力时,先判断是否应纳入 openprd grow;声明就绪前,请同步维护 docs/basic、文件说明书、文件夹 README,以及相关 OpenPrd change/task 状态;如果涉及后端、脚本、Agent、工具链、服务或数据处理变更,还要把 CLI 与 API 视为同级接入面并更新 docs/basic/backend-structure.md。');
4510
+ return allowHook('OpenPrd 检测到写入动作。本轮写入完成后、最终回复前,请针对实际 touched code files 运行 openprd dev-check . <file...>;如出现需要关注的文件:自动优化开启(默认)时先在本轮完成高内聚低耦合拆分(不丢功能、行为不变),最终回复以 **OpenPRD 自动优化报告** 为标题复用 dev-check 表格(列:影响对象、问题级别、源文件规模、优化原因、本次处理结果、后续建议)并附开关确认;关闭时以 **后续建议** 为标题复用表格(列:影响对象、关注程度、规模信号、预警原因、本次处理结果、后续建议)。两种模式都按 🔴 → 🟠 → 🟡 排序;不要把“问题级别/关注程度”列改写成纯 emoji,必须保留例如“🟠 中风险|建议优先关注”这类完整标签;如果你改写了表格文案,先用 `node scripts/dev-check-wrapup-copy.mjs --validate` 校验每格不超过 20 字;若报错,按提示缩短后重试;如涉及界面视觉,包括卡片宽度、间距、留白、对齐、颜色、圆角、字号等轻量可视优化,build、package 和 dev-check 只能证明代码或构建状态,不能替代视觉证据;写入前先说明本次审美意图和记忆点,视觉证据也要检查气质、层级、颜色、字号、间距和表面角色是否成立;已有参考效果图并进入实现阶段时,阶段性完成后运行 openprd visual-compare . --reference <效果图> --actual <实现截图> --locale <zh-CN|en> 并查看 JPG 对比图;如果参考图来自本轮或前序 `imagegen`,先确认用户已接受它作为后续对比参考;若一张图里有多个子图、对象或网格,先运行 openprd visual-prepare . --reference <效果图> --grid <列>x<行> 或 --boxes <plan.json>,确认 contact sheet 后再逐项对比。若局部细节更重要,再补 openprd visual-compare . --board <focus-board.json> --locale <zh-CN|en>;如无参考图,先判断新建界面还是修改既有界面:新建界面应先完成 3 方向方案评审,修改既有界面确认已先截修改前截图,并在完成后运行 openprd visual-compare . --before <修改前截图> --after <修改后截图> --locale <zh-CN|en> 查看 JPG 自检图;若并行试了多个优化方向,再补 openprd visual-compare . --board <parallel-board.json> --locale <zh-CN|en>;若普通截图、Computer/Browser/Playwright 实测截图被用作证据,再补 openprd visual-compare . --board <verification-board.json> --locale <zh-CN|en> 的截图实测证据板;若新开发或修改同构列表、卡片、网格、表格,或用户反馈没对齐/排版漂移,再补 openprd visual-compare . --board <alignment-board.json> --locale <zh-CN|en> 的对齐辅助线证据板,截真实页面、叠辅助线,同时量容器轨道和标题/副标题/描述/标签/状态/价格/按钮/图标等内部内容槽位的 x/y/宽高/baseline spread;只量外框、列宽或行顶不算完整对齐验收;当用户说“跟效果图”“不一致”“好丑”“复刻”时,至少给出一份视觉证据图,不要只口头判断。发现可沉淀项时不要中途打断任务,代码扩展识别这类白名单工具补全会自动应用并记录,用户偏好、项目协作规矩和 OpenPrd 默认行为留到收工时用 openprd grow . --review 集中确认;维护 OpenPrd 本身且涉及配置类能力时,先判断是否应纳入 openprd grow;声明就绪前,请同步维护 docs/basic、文件说明书、文件夹 README,以及相关 OpenPrd change/task 状态;如果涉及后端、脚本、Agent、工具链、服务或数据处理变更,还要把 CLI 与 API 视为同级接入面并更新 docs/basic/backend-structure.md。');
3602
4511
  }
3603
4512
  return allowHook();
3604
4513
  }
@@ -3606,6 +4515,9 @@ function handle(eventName, cwd, payload) {
3606
4515
  if (eventName === 'PostToolUse') {
3607
4516
  const text = payloadText(payload);
3608
4517
  const failed = /command not found|no such file|permission denied|failed|error|exception/i.test(text);
4518
+ if (!failed) {
4519
+ recordVisualEvidenceSignals(root, payload);
4520
+ }
3609
4521
  if (!failed) {
3610
4522
  return allowHook();
3611
4523
  }
@@ -3637,8 +4549,8 @@ function handle(eventName, cwd, payload) {
3637
4549
  }
3638
4550
  if (stopIntent.visualMockupRequest && (!Array.isArray(turnState.touchedFiles) || turnState.touchedFiles.length === 0)) {
3639
4551
  return allowHook([
3640
- 'OpenPrd 生图事实对齐提醒:如果这轮要汇报图片结果、失败或限流,请确保它来自一次实际的 `imagegen` 调用。',
3641
- '未实际调用 `imagegen` 前,不要声称“生图限流”“生图失败”或“已经生成图片结果”;若本轮还没调用,请如实说明仍未开始或尚未完成生图。',
4552
+ 'OpenPrd 生图事实对齐提醒:如果这轮要汇报图片结果、失败或限流,请确保它来自一次实际的生图工具调用(Codex=`imagegen`,Cursor=`GenerateImage`,或用户已确认的偏好工具)。',
4553
+ '未实际调用生图工具前,不要声称“生图限流”“生图失败”或“已经生成图片结果”;若本轮还没调用,请如实说明仍未开始或尚未完成生图。',
3642
4554
  '如果这轮确实已经生图,不要默认把结果登记到 `.openprd/harness/visual-reviews/` 或直接当成验收参考;先把它当候选效果图。',
3643
4555
  '若用户还要继续做实现,主动追问三件事:是否符合预期、是否纳入后续效果图/实现截图对比、是否按此继续后续实现;未确认前不要自动进入实现,也不要声称参考图已定。',
3644
4556
  ].join('\n'));
@@ -3664,6 +4576,14 @@ function handle(eventName, cwd, payload) {
3664
4576
  ].join('\n'));
3665
4577
  }
3666
4578
  }
4579
+ const visualReminder = visualEvidenceStopReminder(root, turnState, stopIntent);
4580
+ if (visualReminder) {
4581
+ return allowHook(visualReminder);
4582
+ }
4583
+ const learningReminder = learningMaterialStopReminder(root, turnState, stopIntent);
4584
+ if (learningReminder) {
4585
+ return allowHook(learningReminder);
4586
+ }
3667
4587
  const devCheckMessage = devCheckWrapUpMessage(root, turnState);
3668
4588
  if (devCheckMessage) {
3669
4589
  return allowHook(devCheckMessage);