@openprd/cli 0.1.9 → 0.1.10

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 (134) hide show
  1. package/.openprd/benchmarks/index.md +15 -1
  2. package/.openprd/config.yaml +10 -0
  3. package/.openprd/design/README.md +47 -0
  4. package/.openprd/design/README_EN.md +25 -0
  5. package/.openprd/design/active/asset-spec.md +15 -0
  6. package/.openprd/design/active/direction-plan.md +7 -0
  7. package/.openprd/design/active/facts-sheet.md +13 -0
  8. package/.openprd/design/active/image-preflight.md +9 -0
  9. package/.openprd/design/active/selected-direction.md +11 -0
  10. package/.openprd/design/anti-slop.md +16 -0
  11. package/.openprd/design/assets/dark-mesh.svg +14 -0
  12. package/.openprd/design/assets/paper-grid.svg +14 -0
  13. package/.openprd/design/assets/surface-presets.md +21 -0
  14. package/.openprd/design/assets/workbench-grid.svg +16 -0
  15. package/.openprd/design/checklists/ui-quality-gate.md +22 -0
  16. package/.openprd/design/components/component-catalog.md +42 -0
  17. package/.openprd/design/layouts/layout-catalog.json +45 -0
  18. package/.openprd/design/lenses/frontend-lenses.md +31 -0
  19. package/.openprd/design/recipes/content-experience.md +26 -0
  20. package/.openprd/design/recipes/operational-tool.md +25 -0
  21. package/.openprd/design/recipes/product-launch.md +26 -0
  22. package/.openprd/design/templates/README.md +36 -0
  23. package/.openprd/design/templates/README_EN.md +31 -0
  24. package/.openprd/design/templates/content-home.html +415 -0
  25. package/.openprd/design/templates/ops-dashboard.html +340 -0
  26. package/.openprd/design/templates/product-launch.html +360 -0
  27. package/.openprd/design/themes/theme-catalog.json +77 -0
  28. package/.openprd/engagements/active/prd.md +111 -100
  29. package/.openprd/schema/prd.schema.yaml +10 -0
  30. package/.openprd/templates/base/intake.md +4 -0
  31. package/.openprd/templates/base/prd.md +16 -6
  32. package/AGENTS.md +10 -7
  33. package/README.md +26 -28
  34. package/README_EN.md +3 -3
  35. package/package.json +41 -2
  36. package/scripts/openprd-codex-isolated-worker.mjs +799 -0
  37. package/skills/openprd-benchmark-router/SKILL.md +4 -0
  38. package/skills/openprd-frontend-design/SKILL.md +161 -0
  39. package/skills/openprd-frontend-design/references/design-asset-contract.md +34 -0
  40. package/skills/openprd-frontend-design/references/direction-engine.md +46 -0
  41. package/skills/openprd-harness/SKILL.md +37 -20
  42. package/skills/openprd-learning-review/SKILL.md +2 -1
  43. package/skills/openprd-learning-review/references/style-packs/xianxia-cultivation.prompt.md +1 -1
  44. package/skills/openprd-quality/SKILL.md +5 -3
  45. package/skills/openprd-requirement-intake/SKILL.md +27 -5
  46. package/skills/openprd-requirement-intake/references/prd-template-lenses.md +6 -3
  47. package/skills/openprd-requirement-intake/references/routing-rubric.md +9 -5
  48. package/skills/openprd-requirement-intake/references/startup-validation-lens.md +23 -0
  49. package/skills/openprd-router/SKILL.md +13 -5
  50. package/skills/openprd-shared/SKILL.md +38 -25
  51. package/src/agent-integration.js +120 -69
  52. package/src/brainstorm-artifacts.js +1657 -0
  53. package/src/brainstorm-presentation.js +223 -0
  54. package/src/brainstorm.js +603 -0
  55. package/src/cli/args.js +51 -3
  56. package/src/cli/doctor-print.js +34 -8
  57. package/src/cli/print.js +6 -0
  58. package/src/cli/quality-print.js +70 -0
  59. package/src/cli/run-print.js +43 -1
  60. package/src/cli/shared-print.js +33 -2
  61. package/src/cli/workflow-print.js +21 -0
  62. package/src/codex-hook-runner-template.mjs +838 -58
  63. package/src/design-starter-support.js +462 -0
  64. package/src/design-starter.js +1207 -0
  65. package/src/knowledge.js +232 -20
  66. package/src/learning-review.js +3 -3
  67. package/src/loop.js +839 -101
  68. package/src/openprd.js +86 -1
  69. package/src/openspec/change-validate.js +1 -0
  70. package/src/prd-core.js +18 -0
  71. package/src/quality-learning.js +50 -6
  72. package/src/quality.js +2 -2
  73. package/src/run-harness.js +191 -29
  74. package/src/self-update.js +301 -4
  75. package/src/standards.js +46 -5
  76. package/src/visual-prepare.js +940 -0
  77. package/src/workspace-core.js +137 -0
  78. package/src/workspace-workflow.js +190 -7
  79. package/.openprd/benchmarks/evidence/milvus-io-ai-code-review-gets-better-when-models-debate-claude-vs-gemini-vs-code.md +0 -14
  80. package/.openprd/benchmarks/evidence/nolanlawson-com-using-ai-to-write-better-code-more-slowly.md +0 -14
  81. package/.openprd/discovery/config.json +0 -35
  82. package/.openprd/engagements/active/flows.md +0 -35
  83. package/.openprd/engagements/active/handoff.md +0 -16
  84. package/.openprd/engagements/active/review.html +0 -61
  85. package/.openprd/engagements/active/roles.md +0 -22
  86. package/.openprd/engagements/work-units/wu-20260524015648-6d33ded7.json +0 -23
  87. package/.openprd/engagements/work-units/wu-20260602113956-a99b5b88.json +0 -18
  88. package/.openprd/engagements/work-units/wu-20260602122244-78656aaf.json +0 -18
  89. package/.openprd/engagements/work-units/wu-20260602122442-e96489e2.json +0 -18
  90. package/.openprd/engagements/work-units/wu-20260602132835-695429e8.json +0 -18
  91. package/.openprd/exports/.gitkeep +0 -0
  92. package/.openprd/knowledge/candidates/candidate-turn-1780116203372-5f266a79e968c758/candidate.json +0 -78
  93. package/.openprd/knowledge/candidates/candidate-turn-1780116203372-5f266a79e968c758/diagnostic-report.json +0 -129
  94. package/.openprd/knowledge/candidates/candidate-turn-1780116203372-5f266a79e968c758/root-cause-candidates.json +0 -41
  95. package/.openprd/knowledge/candidates/candidate-turn-1780116203372-5f266a79e968c758/timeline.json +0 -14
  96. package/.openprd/knowledge/drafts/openprd-experience-diagnostic-candidate-turn-1780116203372-5f266a79e968c758/SKILL.md +0 -49
  97. package/.openprd/knowledge/index.json +0 -47
  98. package/.openprd/reviews/v0001.html +0 -1322
  99. package/.openprd/reviews/v0002.html +0 -1150
  100. package/.openprd/reviews/v0003.html +0 -1150
  101. package/.openprd/reviews/v0004.html +0 -1150
  102. package/.openprd/reviews/v0005.html +0 -1150
  103. package/.openprd/sessions/.gitkeep +0 -0
  104. package/.openprd/state/changes.json +0 -27
  105. package/.openprd/state/current.json +0 -505
  106. package/.openprd/state/release-ledger.json +0 -387
  107. package/.openprd/state/version-index.json +0 -67
  108. package/.openprd/state/versions/.gitkeep +0 -0
  109. package/.openprd/state/versions/v0001.json +0 -121
  110. package/.openprd/state/versions/v0001.md +0 -161
  111. package/.openprd/state/versions/v0002.json +0 -264
  112. package/.openprd/state/versions/v0002.md +0 -183
  113. package/.openprd/state/versions/v0003.json +0 -269
  114. package/.openprd/state/versions/v0003.md +0 -188
  115. package/.openprd/state/versions/v0004.json +0 -274
  116. package/.openprd/state/versions/v0004.md +0 -193
  117. package/.openprd/state/versions/v0005.json +0 -299
  118. package/.openprd/state/versions/v0005.md +0 -189
  119. package/docs/assets/openprd-capability-overview-en.png +0 -0
  120. package/docs/assets/openprd-capability-overview-zh.png +0 -0
  121. package/docs/assets/openprd-learning-html.png +0 -0
  122. package/docs/assets/openprd-quality-html.png +0 -0
  123. package/docs/assets/openprd-requirement-routing-en.png +0 -0
  124. package/docs/assets/openprd-requirement-routing-en.svg +0 -102
  125. package/docs/assets/openprd-requirement-routing-zh-refined.png +0 -0
  126. package/docs/assets/openprd-requirement-routing-zh.png +0 -0
  127. package/docs/assets/openprd-requirement-routing-zh.svg +0 -102
  128. package/docs/assets/openprd-review-html.png +0 -0
  129. package/docs/assets/openprd-scenario-overview.png +0 -0
  130. package/docs/assets/openprd-scenario-overview.svg +0 -114
  131. package/docs/assets/openprd-self-evolving-mechanisms-en.png +0 -0
  132. package/docs/assets/openprd-self-evolving-mechanisms-zh.png +0 -0
  133. package/docs/assets/openprd-visual-compare-case-study-en.png +0 -0
  134. package/docs/assets/openprd-visual-compare-case-study-zh.png +0 -0
@@ -0,0 +1,799 @@
1
+ #!/usr/bin/env node
2
+
3
+ import crypto from 'node:crypto';
4
+ import { spawn, spawnSync } from 'node:child_process';
5
+ import fs from 'node:fs/promises';
6
+ import os from 'node:os';
7
+ import path from 'node:path';
8
+ import process from 'node:process';
9
+
10
+ const AUTH_FILES = ['auth.json', 'auth-caco.json', 'auth-relay.json'];
11
+ const MAX_AUTOMATIC_CONTINUATIONS = 1;
12
+ const PATCH_MODE_MONITOR_INTERVAL_MS = parsePositiveInt(process.env.OPENPRD_PATCH_MODE_MONITOR_INTERVAL_MS, 500);
13
+ const PATCH_MODE_ENTRY_STALL_MS = parsePositiveInt(process.env.OPENPRD_PATCH_MODE_ENTRY_STALL_MS, 30000);
14
+ const PATCH_MODE_WRITE_ATTEMPT_STALL_MS = parsePositiveInt(process.env.OPENPRD_PATCH_MODE_WRITE_ATTEMPT_STALL_MS, 15000);
15
+ const DEBUG_PATCH_MODE_MONITOR = /^(1|true|yes)$/i.test(String(process.env.OPENPRD_DEBUG_PATCH_MODE || ''));
16
+
17
+ function usage() {
18
+ return [
19
+ 'Usage: node scripts/openprd-codex-isolated-worker.mjs [options]',
20
+ '',
21
+ 'Options:',
22
+ ' --cwd <dir> Working directory passed to `codex exec -C`.',
23
+ ' --prompt <text> Prompt text written to Codex stdin.',
24
+ ' --prompt-file <file> Read prompt text from a file.',
25
+ ' --source-codex-home <dir> Source Codex home used only to copy auth files.',
26
+ ' --model <name> Override the detected model name.',
27
+ ' --output-jsonl <file> Save raw `codex exec --json` output to a file.',
28
+ ' --output-last-message <file> Save the last Codex agent message to a file.',
29
+ ' --skip-git-repo-check Forward `--skip-git-repo-check` to Codex.',
30
+ ' --full-auto Forward approval and hook-trust bypass flags to Codex.',
31
+ ' --keep-codex-home Keep the temporary isolated CODEX_HOME after exit.',
32
+ ' --help Show this help message.',
33
+ '',
34
+ 'Any arguments after `--` are forwarded to `codex exec` before the stdin prompt marker.',
35
+ ].join('\n');
36
+ }
37
+
38
+ function parseArgs(argv) {
39
+ const options = {
40
+ cwd: process.cwd(),
41
+ prompt: null,
42
+ promptFile: null,
43
+ sourceCodexHome: null,
44
+ model: null,
45
+ outputJsonl: null,
46
+ outputLastMessage: null,
47
+ skipGitRepoCheck: false,
48
+ fullAuto: false,
49
+ keepCodexHome: false,
50
+ passthrough: [],
51
+ help: false,
52
+ };
53
+
54
+ for (let index = 0; index < argv.length; index += 1) {
55
+ const arg = argv[index];
56
+ if (arg === '--') {
57
+ options.passthrough = argv.slice(index + 1);
58
+ break;
59
+ }
60
+ if (arg === '--cwd' && argv[index + 1]) {
61
+ options.cwd = argv[index + 1];
62
+ index += 1;
63
+ continue;
64
+ }
65
+ if (arg === '--prompt' && argv[index + 1]) {
66
+ options.prompt = argv[index + 1];
67
+ index += 1;
68
+ continue;
69
+ }
70
+ if (arg === '--prompt-file' && argv[index + 1]) {
71
+ options.promptFile = argv[index + 1];
72
+ index += 1;
73
+ continue;
74
+ }
75
+ if (arg === '--source-codex-home' && argv[index + 1]) {
76
+ options.sourceCodexHome = argv[index + 1];
77
+ index += 1;
78
+ continue;
79
+ }
80
+ if (arg === '--model' && argv[index + 1]) {
81
+ options.model = argv[index + 1];
82
+ index += 1;
83
+ continue;
84
+ }
85
+ if (arg === '--output-jsonl' && argv[index + 1]) {
86
+ options.outputJsonl = argv[index + 1];
87
+ index += 1;
88
+ continue;
89
+ }
90
+ if (arg === '--output-last-message' && argv[index + 1]) {
91
+ options.outputLastMessage = argv[index + 1];
92
+ index += 1;
93
+ continue;
94
+ }
95
+ if (arg === '--skip-git-repo-check') {
96
+ options.skipGitRepoCheck = true;
97
+ continue;
98
+ }
99
+ if (arg === '--full-auto') {
100
+ options.fullAuto = true;
101
+ continue;
102
+ }
103
+ if (arg === '--keep-codex-home') {
104
+ options.keepCodexHome = true;
105
+ continue;
106
+ }
107
+ if (arg === '--help' || arg === '-h') {
108
+ options.help = true;
109
+ continue;
110
+ }
111
+ throw new Error(`Unknown argument: ${arg}`);
112
+ }
113
+
114
+ return options;
115
+ }
116
+
117
+ function parsePositiveInt(value, fallback) {
118
+ const parsed = Number.parseInt(String(value ?? ''), 10);
119
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
120
+ }
121
+
122
+ async function readStdin() {
123
+ const chunks = [];
124
+ for await (const chunk of process.stdin) {
125
+ chunks.push(chunk);
126
+ }
127
+ return Buffer.concat(chunks).toString('utf8');
128
+ }
129
+
130
+ async function resolvePrompt(options) {
131
+ if (options.prompt && options.promptFile) {
132
+ throw new Error('Use either --prompt or --prompt-file, not both.');
133
+ }
134
+ if (options.prompt) {
135
+ return options.prompt;
136
+ }
137
+ if (options.promptFile) {
138
+ return fs.readFile(path.resolve(options.promptFile), 'utf8');
139
+ }
140
+ if (!process.stdin.isTTY) {
141
+ return readStdin();
142
+ }
143
+ throw new Error('Provide --prompt, --prompt-file, or stdin input.');
144
+ }
145
+
146
+ function resolveSourceCodexHome(options) {
147
+ return path.resolve(
148
+ options.sourceCodexHome
149
+ ?? process.env.OPENPRD_CODEX_HOME
150
+ ?? process.env.CODEX_HOME
151
+ ?? path.join(os.homedir(), '.codex'),
152
+ );
153
+ }
154
+
155
+ async function fileExists(filePath) {
156
+ try {
157
+ await fs.stat(filePath);
158
+ return true;
159
+ } catch {
160
+ return false;
161
+ }
162
+ }
163
+
164
+ async function detectModel(sourceCodexHome) {
165
+ const configPath = path.join(sourceCodexHome, 'config.toml');
166
+ if (!(await fileExists(configPath))) {
167
+ return null;
168
+ }
169
+ const text = await fs.readFile(configPath, 'utf8');
170
+ const match = text.match(/^\s*model\s*=\s*["']([^"'\n]+)["']\s*$/m);
171
+ return match?.[1]?.trim() || null;
172
+ }
173
+
174
+ async function prepareOutputPath(filePath) {
175
+ if (!filePath) return null;
176
+ const resolved = path.resolve(filePath);
177
+ await fs.mkdir(path.dirname(resolved), { recursive: true });
178
+ return resolved;
179
+ }
180
+
181
+ async function prepareIsolatedCodexHome(sourceCodexHome) {
182
+ const isolatedHome = await fs.mkdtemp(path.join(os.tmpdir(), 'openprd-codex-home-'));
183
+ let copied = 0;
184
+ for (const name of AUTH_FILES) {
185
+ const sourcePath = path.join(sourceCodexHome, name);
186
+ if (!(await fileExists(sourcePath))) {
187
+ continue;
188
+ }
189
+ await fs.copyFile(sourcePath, path.join(isolatedHome, name));
190
+ copied += 1;
191
+ }
192
+ if (copied === 0) {
193
+ throw new Error(`No reusable Codex auth files found in ${sourceCodexHome}. Expected one of: ${AUTH_FILES.join(', ')}`);
194
+ }
195
+ return isolatedHome;
196
+ }
197
+
198
+ function buildCodexArgs(options) {
199
+ const args = ['exec', '--json', '-C', path.resolve(options.cwd)];
200
+ if (options.fullAuto) {
201
+ args.push('--dangerously-bypass-approvals-and-sandbox');
202
+ if (options.supportsHookTrustBypass) {
203
+ args.push('--dangerously-bypass-hook-trust');
204
+ }
205
+ }
206
+ if (options.skipGitRepoCheck) {
207
+ args.push('--skip-git-repo-check');
208
+ }
209
+ if (options.model) {
210
+ args.push('--model', options.model);
211
+ }
212
+ if (options.outputLastMessage) {
213
+ args.push('-o', options.outputLastMessage);
214
+ }
215
+ if (options.passthrough.length > 0) {
216
+ args.push(...options.passthrough);
217
+ }
218
+ args.push('-');
219
+ return args;
220
+ }
221
+
222
+ function buildCodexResumeArgs(options, sessionId) {
223
+ const args = ['exec', 'resume', sessionId, '--json'];
224
+ if (options.fullAuto) {
225
+ args.push('--dangerously-bypass-approvals-and-sandbox');
226
+ if (options.supportsHookTrustBypass) {
227
+ args.push('--dangerously-bypass-hook-trust');
228
+ }
229
+ }
230
+ if (options.skipGitRepoCheck) {
231
+ args.push('--skip-git-repo-check');
232
+ }
233
+ if (options.model) {
234
+ args.push('--model', options.model);
235
+ }
236
+ if (options.outputLastMessage) {
237
+ args.push('-o', options.outputLastMessage);
238
+ }
239
+ if (options.passthrough.length > 0) {
240
+ args.push(...options.passthrough);
241
+ }
242
+ args.push('-');
243
+ return args;
244
+ }
245
+
246
+ function codexSupportsHookTrustBypass(commandPath, runtimeEnv) {
247
+ const help = spawnSync(commandPath, ['exec', '--help'], {
248
+ encoding: 'utf8',
249
+ env: runtimeEnv,
250
+ });
251
+ const output = `${help.stdout || ''}\n${help.stderr || ''}`;
252
+ if (output.includes('--dangerously-bypass-hook-trust')) {
253
+ return true;
254
+ }
255
+ if (output.includes('Usage:')) {
256
+ return false;
257
+ }
258
+ return true;
259
+ }
260
+
261
+ function resolveCodexCommand(runtimeEnv) {
262
+ const candidates = [];
263
+ const attempts = process.platform === 'win32'
264
+ ? [['where', ['codex']]]
265
+ : [['which', ['-a', 'codex']], ['which', ['codex']]];
266
+ for (const [command, args] of attempts) {
267
+ const resolved = spawnSync(command, args, {
268
+ encoding: 'utf8',
269
+ env: runtimeEnv,
270
+ });
271
+ const found = resolved.stdout
272
+ .split(/\r?\n/)
273
+ .map((line) => line.trim())
274
+ .filter(Boolean);
275
+ for (const candidate of found) {
276
+ if (!candidates.includes(candidate)) {
277
+ candidates.push(candidate);
278
+ }
279
+ }
280
+ if (candidates.length > 0) {
281
+ break;
282
+ }
283
+ }
284
+ for (const candidate of candidates) {
285
+ if (codexSupportsHookTrustBypass(candidate, runtimeEnv)) {
286
+ return {
287
+ commandPath: candidate,
288
+ supportsHookTrustBypass: true,
289
+ };
290
+ }
291
+ }
292
+ const commandPath = candidates[0] || 'codex';
293
+ return {
294
+ commandPath,
295
+ supportsHookTrustBypass: codexSupportsHookTrustBypass(commandPath, runtimeEnv),
296
+ };
297
+ }
298
+
299
+ function extractSessionId(stdout) {
300
+ const lines = String(stdout || '').split(/\r?\n/).filter(Boolean);
301
+ for (const line of lines) {
302
+ try {
303
+ const parsed = JSON.parse(line);
304
+ if (parsed?.type === 'thread.started' && typeof parsed.thread_id === 'string' && parsed.thread_id.trim()) {
305
+ return parsed.thread_id.trim();
306
+ }
307
+ } catch {
308
+ // Ignore non-JSON lines mixed into stdout.
309
+ }
310
+ }
311
+ return null;
312
+ }
313
+
314
+ async function hashFile(filePath) {
315
+ try {
316
+ const buffer = await fs.readFile(filePath);
317
+ return crypto.createHash('sha256').update(buffer).digest('hex');
318
+ } catch {
319
+ return null;
320
+ }
321
+ }
322
+
323
+ function normalizeHash(value) {
324
+ const text = String(value ?? '').trim().toLowerCase();
325
+ return /^[0-9a-f]+$/.test(text) ? text : null;
326
+ }
327
+
328
+ function hashesMatch(currentHash, baselineHash) {
329
+ const current = normalizeHash(currentHash);
330
+ const baseline = normalizeHash(baselineHash);
331
+ if (!current || !baseline) {
332
+ return false;
333
+ }
334
+ return current === baseline || current.startsWith(baseline) || baseline.startsWith(current);
335
+ }
336
+
337
+ async function readLatestDesignStarterEvent(projectRoot) {
338
+ const eventsPath = path.join(projectRoot, '.openprd', 'state', 'events.jsonl');
339
+ if (!(await fileExists(eventsPath))) {
340
+ return null;
341
+ }
342
+ const text = await fs.readFile(eventsPath, 'utf8').catch(() => '');
343
+ const lines = text.split(/\r?\n/).filter(Boolean);
344
+ for (let index = lines.length - 1; index >= 0; index -= 1) {
345
+ try {
346
+ const event = JSON.parse(lines[index]);
347
+ if (event?.type === 'design_starter_created' && typeof event.output === 'string' && event.output.trim()) {
348
+ return event;
349
+ }
350
+ } catch {
351
+ // Ignore malformed lines in append-only logs.
352
+ }
353
+ }
354
+ return null;
355
+ }
356
+
357
+ function siblingDraftPaths(entryPath) {
358
+ const ext = path.extname(entryPath);
359
+ const name = path.basename(entryPath, ext);
360
+ return [
361
+ path.join(path.dirname(entryPath), `${name}.next${ext}`),
362
+ path.join(path.dirname(entryPath), `${name}.rewrite${ext}`),
363
+ ];
364
+ }
365
+
366
+ function createDesignStarterMonitor(projectRoot) {
367
+ const state = {
368
+ starterSeen: false,
369
+ outputPath: null,
370
+ draftPaths: [],
371
+ draftPath: null,
372
+ starterHash: null,
373
+ currentHash: null,
374
+ draftExists: false,
375
+ observedEntryRewrite: false,
376
+ };
377
+ let inFlight = null;
378
+
379
+ const poll = async () => {
380
+ const event = await readLatestDesignStarterEvent(projectRoot);
381
+ if (!event) {
382
+ return;
383
+ }
384
+ const outputPath = path.resolve(projectRoot, event.output);
385
+ const currentHash = await hashFile(outputPath);
386
+ if (!state.starterSeen || state.outputPath !== outputPath) {
387
+ state.starterSeen = true;
388
+ state.outputPath = outputPath;
389
+ state.draftPaths = siblingDraftPaths(outputPath);
390
+ state.draftPath = state.draftPaths[0] || null;
391
+ state.starterHash = currentHash;
392
+ state.currentHash = currentHash;
393
+ } else {
394
+ if (!state.starterHash && currentHash) {
395
+ state.starterHash = currentHash;
396
+ }
397
+ state.currentHash = currentHash;
398
+ }
399
+ if (state.starterHash && state.currentHash && state.starterHash !== state.currentHash) {
400
+ state.observedEntryRewrite = true;
401
+ }
402
+ state.draftExists = false;
403
+ for (const draftPath of state.draftPaths) {
404
+ if (await fileExists(draftPath)) {
405
+ state.draftExists = true;
406
+ break;
407
+ }
408
+ }
409
+ };
410
+
411
+ const timer = setInterval(() => {
412
+ if (inFlight) return;
413
+ inFlight = poll().finally(() => {
414
+ inFlight = null;
415
+ });
416
+ }, 250);
417
+
418
+ return {
419
+ async snapshot() {
420
+ if (inFlight) {
421
+ await inFlight;
422
+ }
423
+ await poll();
424
+ return { ...state };
425
+ },
426
+ async stop() {
427
+ clearInterval(timer);
428
+ return this.snapshot();
429
+ },
430
+ };
431
+ }
432
+
433
+ function shouldAutoResumeEntryWrite(state) {
434
+ return Boolean(
435
+ state?.starterSeen
436
+ && state.outputPath
437
+ && state.starterHash
438
+ && state.currentHash
439
+ && state.starterHash === state.currentHash,
440
+ );
441
+ }
442
+
443
+ async function readPatchModeGate(projectRoot) {
444
+ const gatePath = path.join(projectRoot, '.openprd', 'harness', 'patch-mode-gate.json');
445
+ if (!(await fileExists(gatePath))) {
446
+ return null;
447
+ }
448
+ try {
449
+ return JSON.parse(await fs.readFile(gatePath, 'utf8'));
450
+ } catch {
451
+ return null;
452
+ }
453
+ }
454
+
455
+ async function fileMtimeMs(filePath) {
456
+ try {
457
+ const stat = await fs.stat(filePath);
458
+ return Number(stat.mtimeMs || 0);
459
+ } catch {
460
+ return null;
461
+ }
462
+ }
463
+
464
+ function patchModeTargetFiles(gate) {
465
+ return [...new Set([
466
+ typeof gate?.targetFile === 'string' ? gate.targetFile.trim() : null,
467
+ ...((Array.isArray(gate?.draftFiles) ? gate.draftFiles : []).map((file) => String(file || '').trim())),
468
+ ].filter(Boolean))];
469
+ }
470
+
471
+ function patchModeAnchor(gate) {
472
+ if (!gate?.active) {
473
+ return null;
474
+ }
475
+ if (gate.status === 'write-attempted' && Number.isFinite(gate.lastWriteAttemptAtMs) && gate.lastWriteAttemptAtMs > 0) {
476
+ return {
477
+ atMs: gate.lastWriteAttemptAtMs,
478
+ timeoutMs: PATCH_MODE_WRITE_ATTEMPT_STALL_MS,
479
+ reason: 'patch-mode-write-attempt-stalled',
480
+ };
481
+ }
482
+ if (Number.isFinite(gate.armedAtMs) && gate.armedAtMs > 0) {
483
+ return {
484
+ atMs: gate.armedAtMs,
485
+ timeoutMs: PATCH_MODE_ENTRY_STALL_MS,
486
+ reason: 'patch-mode-entry-stalled',
487
+ };
488
+ }
489
+ return null;
490
+ }
491
+
492
+ function createPatchModeMonitor(projectRoot) {
493
+ return {
494
+ async snapshot() {
495
+ const gate = await readPatchModeGate(projectRoot);
496
+ if (!gate?.active) {
497
+ return {
498
+ active: false,
499
+ gate: null,
500
+ targetFiles: [],
501
+ writeObserved: false,
502
+ stalled: false,
503
+ reason: null,
504
+ };
505
+ }
506
+ const targetFile = typeof gate?.targetFile === 'string' ? gate.targetFile.trim() : null;
507
+ const targetFiles = patchModeTargetFiles(gate);
508
+ const fileHashesAtArm = gate?.fileHashesAtArm && typeof gate.fileHashesAtArm === 'object'
509
+ ? gate.fileHashesAtArm
510
+ : null;
511
+ const targetHashAtArm = typeof gate?.targetHashAtArm === 'string' ? gate.targetHashAtArm.trim() : null;
512
+ const writeObserved = await Promise.all(targetFiles.map(async (file) => {
513
+ const absolutePath = path.resolve(projectRoot, file);
514
+ const currentHash = await hashFile(absolutePath);
515
+ if (fileHashesAtArm && Object.prototype.hasOwnProperty.call(fileHashesAtArm, file)) {
516
+ const baselineHash = fileHashesAtArm[file];
517
+ if (typeof baselineHash === 'string' && baselineHash.trim()) {
518
+ return Boolean(currentHash && !hashesMatch(currentHash, baselineHash));
519
+ }
520
+ if (baselineHash == null) {
521
+ return currentHash != null;
522
+ }
523
+ }
524
+ if (file === targetFile && targetHashAtArm) {
525
+ return Boolean(currentHash && !hashesMatch(currentHash, targetHashAtArm));
526
+ }
527
+ const mtimeMs = await fileMtimeMs(absolutePath);
528
+ return Boolean(mtimeMs && mtimeMs > Number(gate?.armedAtMs ?? 0));
529
+ })).then((results) => results.some(Boolean));
530
+ const anchor = patchModeAnchor(gate);
531
+ const stalled = Boolean(
532
+ anchor
533
+ && !writeObserved
534
+ && Date.now() - anchor.atMs >= anchor.timeoutMs,
535
+ );
536
+ return {
537
+ ...gate,
538
+ active: true,
539
+ gate,
540
+ targetFiles,
541
+ writeObserved,
542
+ stalled,
543
+ reason: stalled ? anchor.reason : null,
544
+ };
545
+ },
546
+ async stop() {
547
+ return this.snapshot();
548
+ },
549
+ };
550
+ }
551
+
552
+ function buildResumePrompt(starterState, patchModeState = null, resumeReason = 'starter-output-unchanged') {
553
+ const entryPath = patchModeState?.targetFile || starterState?.outputPath || 'index.html';
554
+ const entryFile = path.basename(entryPath);
555
+ const draftFiles = [...new Set([
556
+ ...((starterState?.draftPaths || []).map((file) => path.basename(file))),
557
+ ...((Array.isArray(patchModeState?.draftFiles) ? patchModeState.draftFiles : []).map((file) => path.basename(String(file || '')))),
558
+ ].filter(Boolean))];
559
+ return [
560
+ resumeReason === 'patch-mode-write-attempt-stalled'
561
+ ? `继续当前实现。你上一轮已经进入 Patch Mode,而且对 ${entryFile} 的写尝试没有真正落盘。`
562
+ : resumeReason === 'patch-mode-entry-stalled'
563
+ ? `继续当前实现。你上一轮已经进入 Patch Mode,但 ${entryFile} 在监督窗口内仍没有出现真实写入。`
564
+ : `继续当前实现。你已经有 starter 生成的入口文件 ${entryFile}。`,
565
+ resumeReason.startsWith('patch-mode')
566
+ ? '现在不要继续搜集资料、不要再 web search、不要再读模板、也不要删除入口文件。'
567
+ : '现在不要继续搜集资料、不要再停留在说明或规划,直接把页面成品落到入口文件本体后再结束。',
568
+ draftFiles.length > 0
569
+ ? `如果你已经写了 sibling draft(例如 ${draftFiles.join(' / ')}),现在把它合并或覆盖回正式入口文件 ${entryFile}。`
570
+ : `现在直接修改 ${entryFile},把剩余页面实现真正落盘。`,
571
+ '如果你需要整页重写,先写 sibling draft,再覆盖回正式入口文件;不要 delete-first。',
572
+ patchModeState?.phase === 'strict'
573
+ ? `你已经宣布开始覆盖 ${entryFile},下一步必须是真实写入动作。`
574
+ : null,
575
+ '入口文件写完后再收尾回复。',
576
+ ].filter(Boolean).join('\n');
577
+ }
578
+
579
+ async function runCodex(codexCommand, prompt, args, runtimeEnv, workingDirectory, patchModeMonitor = null) {
580
+ return new Promise((resolve) => {
581
+ const runStartedAtMs = Date.now();
582
+ const child = spawn(codexCommand, args, {
583
+ cwd: workingDirectory,
584
+ env: runtimeEnv,
585
+ stdio: ['pipe', 'pipe', 'pipe'],
586
+ });
587
+ let stdout = '';
588
+ let stderr = '';
589
+ let monitorInFlight = null;
590
+ let forcedResume = null;
591
+ let hardKillTimer = null;
592
+
593
+ const timer = patchModeMonitor ? setInterval(() => {
594
+ if (forcedResume || monitorInFlight) {
595
+ return;
596
+ }
597
+ monitorInFlight = patchModeMonitor.snapshot()
598
+ .then((snapshot) => {
599
+ if (DEBUG_PATCH_MODE_MONITOR) {
600
+ console.error(`[openprd] patch mode snapshot: ${JSON.stringify(snapshot)}`);
601
+ }
602
+ const anchor = snapshot?.gate ? patchModeAnchor(snapshot.gate) : null;
603
+ const effectiveAnchorAtMs = anchor ? Math.max(anchor.atMs, runStartedAtMs) : null;
604
+ const stalled = Boolean(
605
+ snapshot?.active
606
+ && anchor
607
+ && !snapshot.writeObserved
608
+ && effectiveAnchorAtMs
609
+ && Date.now() - effectiveAnchorAtMs >= anchor.timeoutMs,
610
+ );
611
+ if (!stalled || forcedResume) {
612
+ return;
613
+ }
614
+ forcedResume = {
615
+ reason: anchor.reason,
616
+ snapshot: {
617
+ ...snapshot,
618
+ stalled: true,
619
+ reason: anchor.reason,
620
+ },
621
+ };
622
+ console.error(`[openprd] patch mode supervisor interrupt: ${anchor.reason}`);
623
+ child.kill('SIGTERM');
624
+ hardKillTimer = setTimeout(() => {
625
+ child.kill('SIGKILL');
626
+ }, 1000);
627
+ hardKillTimer.unref?.();
628
+ })
629
+ .catch((error) => {
630
+ console.error(`[openprd] patch mode monitor error: ${error instanceof Error ? error.message : String(error)}`);
631
+ })
632
+ .finally(() => {
633
+ monitorInFlight = null;
634
+ });
635
+ }, PATCH_MODE_MONITOR_INTERVAL_MS) : null;
636
+
637
+ child.stdout.on('data', (chunk) => {
638
+ const text = chunk.toString();
639
+ stdout += text;
640
+ process.stdout.write(text);
641
+ });
642
+ child.stderr.on('data', (chunk) => {
643
+ const text = chunk.toString();
644
+ stderr += text;
645
+ process.stderr.write(text);
646
+ });
647
+ child.on('error', (error) => {
648
+ if (timer) {
649
+ clearInterval(timer);
650
+ }
651
+ if (hardKillTimer) {
652
+ clearTimeout(hardKillTimer);
653
+ }
654
+ resolve({
655
+ ok: false,
656
+ exitCode: null,
657
+ stdout,
658
+ stderr,
659
+ error: error.message,
660
+ supervisorInterrupted: Boolean(forcedResume),
661
+ supervisorReason: forcedResume?.reason || null,
662
+ supervisorState: forcedResume?.snapshot || null,
663
+ });
664
+ });
665
+ child.on('close', async (exitCode, signal) => {
666
+ if (timer) {
667
+ clearInterval(timer);
668
+ }
669
+ if (hardKillTimer) {
670
+ clearTimeout(hardKillTimer);
671
+ }
672
+ if (monitorInFlight) {
673
+ await monitorInFlight;
674
+ }
675
+ resolve({
676
+ ok: exitCode === 0 && !forcedResume,
677
+ exitCode,
678
+ signal,
679
+ stdout,
680
+ stderr,
681
+ error: null,
682
+ supervisorInterrupted: Boolean(forcedResume),
683
+ supervisorReason: forcedResume?.reason || null,
684
+ supervisorState: forcedResume?.snapshot || null,
685
+ });
686
+ });
687
+ child.stdin.end(prompt);
688
+ });
689
+ }
690
+
691
+ async function main() {
692
+ const options = parseArgs(process.argv.slice(2));
693
+ if (options.help) {
694
+ console.log(usage());
695
+ return 0;
696
+ }
697
+
698
+ const prompt = await resolvePrompt(options);
699
+ const sourceCodexHome = resolveSourceCodexHome(options);
700
+ const outputJsonl = await prepareOutputPath(options.outputJsonl);
701
+ const outputLastMessage = await prepareOutputPath(options.outputLastMessage);
702
+ const isolatedHome = await prepareIsolatedCodexHome(sourceCodexHome);
703
+ const model = options.model ?? await detectModel(sourceCodexHome);
704
+ const runtimeEnv = {
705
+ ...process.env,
706
+ CODEX_HOME: isolatedHome,
707
+ OPENPRD_CODEX_HOME: isolatedHome,
708
+ };
709
+ const { commandPath: codexCommand, supportsHookTrustBypass } = resolveCodexCommand(runtimeEnv);
710
+ const args = buildCodexArgs({
711
+ ...options,
712
+ cwd: path.resolve(options.cwd),
713
+ model,
714
+ outputLastMessage,
715
+ supportsHookTrustBypass,
716
+ });
717
+ const workingDirectory = path.resolve(options.cwd);
718
+ const starterMonitor = createDesignStarterMonitor(workingDirectory);
719
+ const patchModeMonitor = createPatchModeMonitor(workingDirectory);
720
+
721
+ console.error(`[openprd] source CODEX_HOME: ${sourceCodexHome}`);
722
+ console.error(`[openprd] isolated CODEX_HOME: ${isolatedHome}`);
723
+ console.error(`[openprd] resolved codex command: ${codexCommand}`);
724
+ if (options.fullAuto && !supportsHookTrustBypass) {
725
+ console.error('[openprd] resolved Codex does not support --dangerously-bypass-hook-trust; continuing without that flag');
726
+ }
727
+ if (model) {
728
+ console.error(`[openprd] detected model: ${model}`);
729
+ } else {
730
+ console.error('[openprd] detected model: <none>');
731
+ }
732
+
733
+ try {
734
+ const stdoutChunks = [];
735
+ let sessionId = null;
736
+ let passPrompt = prompt;
737
+ let passArgs = args;
738
+
739
+ for (let attempt = 0; attempt <= MAX_AUTOMATIC_CONTINUATIONS; attempt += 1) {
740
+ const result = await runCodex(codexCommand, passPrompt, passArgs, runtimeEnv, workingDirectory, patchModeMonitor);
741
+ stdoutChunks.push(result.stdout);
742
+ sessionId = extractSessionId(result.stdout) || sessionId;
743
+ const starterState = await starterMonitor.snapshot();
744
+ const patchModeState = await patchModeMonitor.snapshot();
745
+ if (result.supervisorInterrupted) {
746
+ if (attempt === MAX_AUTOMATIC_CONTINUATIONS || !sessionId) {
747
+ const targetSummary = patchModeState.targetFiles?.length > 0
748
+ ? patchModeState.targetFiles.map((file) => path.basename(file)).join(', ')
749
+ : path.basename(starterState.outputPath || 'index.html');
750
+ throw new Error(`Patch Mode 已进入覆盖阶段,但 ${targetSummary} 在监督窗口内仍未出现真实写入。`);
751
+ }
752
+ console.error(`[openprd] patch mode stalled after pass ${attempt + 1}; auto-resuming session ${sessionId}`);
753
+ passPrompt = buildResumePrompt(starterState, patchModeState, result.supervisorReason);
754
+ passArgs = buildCodexResumeArgs({
755
+ ...options,
756
+ model,
757
+ outputLastMessage,
758
+ supportsHookTrustBypass,
759
+ }, sessionId);
760
+ continue;
761
+ }
762
+ if (!result.ok) {
763
+ throw new Error(result.stderr || result.stdout || result.error || `codex exec exited with code ${result.exitCode ?? 'unknown'}`);
764
+ }
765
+ if (attempt === MAX_AUTOMATIC_CONTINUATIONS || !shouldAutoResumeEntryWrite(starterState) || !sessionId) {
766
+ if (attempt === MAX_AUTOMATIC_CONTINUATIONS && shouldAutoResumeEntryWrite(starterState)) {
767
+ const entryFile = path.basename(starterState.outputPath);
768
+ throw new Error(`starter 已生成 ${entryFile},但入口文件仍未被真正覆盖落盘。`);
769
+ }
770
+ break;
771
+ }
772
+ console.error(`[openprd] starter output unchanged after pass ${attempt + 1}; auto-resuming session ${sessionId}`);
773
+ passPrompt = buildResumePrompt(starterState, patchModeState);
774
+ passArgs = buildCodexResumeArgs({
775
+ ...options,
776
+ model,
777
+ outputLastMessage,
778
+ supportsHookTrustBypass,
779
+ }, sessionId);
780
+ }
781
+
782
+ if (outputJsonl) {
783
+ await fs.writeFile(outputJsonl, stdoutChunks.join(''), 'utf8');
784
+ }
785
+ return 0;
786
+ } finally {
787
+ await starterMonitor.stop();
788
+ if (!options.keepCodexHome) {
789
+ await fs.rm(isolatedHome, { recursive: true, force: true }).catch(() => null);
790
+ }
791
+ }
792
+ }
793
+
794
+ try {
795
+ process.exitCode = await main();
796
+ } catch (error) {
797
+ console.error(`[openprd] ${error instanceof Error ? error.message : String(error)}`);
798
+ process.exitCode = 1;
799
+ }