@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
package/src/loop.js CHANGED
@@ -42,6 +42,24 @@ const LOOP_SESSIONS = path.join('.openprd', 'harness', 'agent-sessions.jsonl');
42
42
  const LOOP_BOOTSTRAP = path.join('.openprd', 'harness', 'bootstrap.sh');
43
43
  const LOOP_PROMPTS_DIR = path.join('.openprd', 'harness', 'loop-prompts');
44
44
  const LOOP_TEST_REPORTS_DIR = path.join('.openprd', 'harness', 'test-reports');
45
+ const RELEASE_LEDGER = path.join('.openprd', 'state', 'release-ledger.json');
46
+ const LOOP_OPERATIONAL_ARTIFACT_PATTERNS = [
47
+ '.openprd/engagements/**',
48
+ '.openprd/growth/events.jsonl',
49
+ '.openprd/growth/ledger.json',
50
+ '.openprd/knowledge/**',
51
+ '.openprd/learning/**',
52
+ '.openprd/quality/reports/**',
53
+ '.openprd/state/changes.json',
54
+ '.openprd/state/current.json',
55
+ '.openprd/state/events.jsonl',
56
+ '.openprd/state/task-graph.json',
57
+ '.openprd/state/version-index.json',
58
+ ];
59
+ const LOOP_INTERNAL_STATE_PATTERNS = [
60
+ '.openprd/harness/**',
61
+ ...LOOP_OPERATIONAL_ARTIFACT_PATTERNS,
62
+ ];
45
63
  const LOOP_AGENT_VALUES = ['codex', 'claude'];
46
64
 
47
65
  function cjoin(...parts) {
@@ -115,6 +133,42 @@ function normalizeTaskReference(value) {
115
133
  .toLowerCase();
116
134
  }
117
135
 
136
+ function toPosixPath(value) {
137
+ return String(value ?? '').split(path.sep).join('/');
138
+ }
139
+
140
+ function normalizeLoopModuleName(raw, fallback = 'project') {
141
+ const candidate = String(raw ?? '').trim().split('/').filter(Boolean).pop() ?? '';
142
+ const normalized = candidate.replace(/[^a-zA-Z0-9_-]+/g, '-').replace(/^-+|-+$/g, '');
143
+ return normalized || fallback;
144
+ }
145
+
146
+ function globToRegExp(pattern) {
147
+ const escaped = toPosixPath(pattern)
148
+ .replace(/[.+^${}()|[\]\\]/g, '\\$&')
149
+ .replace(/\*\*/g, '\u0000')
150
+ .replace(/\*/g, '[^/]*')
151
+ .replace(/\u0000/g, '.*');
152
+ return new RegExp(`^${escaped}$`);
153
+ }
154
+
155
+ function matchesPattern(relativePath, patterns = []) {
156
+ const normalized = toPosixPath(relativePath).replace(/^\/+/, '');
157
+ return patterns.some((pattern) => globToRegExp(pattern).test(normalized));
158
+ }
159
+
160
+ function uniquePaths(paths = []) {
161
+ const seen = new Set();
162
+ const ordered = [];
163
+ for (const item of paths) {
164
+ const normalized = toPosixPath(item).replace(/^\/+/, '');
165
+ if (!normalized || seen.has(normalized)) continue;
166
+ seen.add(normalized);
167
+ ordered.push(normalized);
168
+ }
169
+ return ordered;
170
+ }
171
+
118
172
  function taskReferenceCandidates(task) {
119
173
  return new Set([
120
174
  task.id,
@@ -169,6 +223,12 @@ async function ensureLoopFiles(projectRoot) {
169
223
  currentTaskId: null,
170
224
  currentTaskHandle: null,
171
225
  currentTaskTitle: null,
226
+ currentTaskBaselinePaths: [],
227
+ currentWorktreePath: path.resolve(projectRoot),
228
+ currentBranch: null,
229
+ lastWorktreePath: path.resolve(projectRoot),
230
+ lastBranch: null,
231
+ lastCommitSha: null,
172
232
  completedTaskIds: [],
173
233
  lastAgent: null,
174
234
  lastSessionAt: null,
@@ -294,6 +354,32 @@ async function readFeatureList(projectRoot) {
294
354
  return readJson(filePath);
295
355
  }
296
356
 
357
+ async function readLoopState(projectRoot) {
358
+ const filePath = harnessPath(projectRoot, LOOP_STATE);
359
+ if (!(await exists(filePath))) return null;
360
+ return readJson(filePath);
361
+ }
362
+
363
+ async function resolveLoopWorkspace(projectRoot, options = {}) {
364
+ const workspace = await createOrAttachLoopWorktree(projectRoot, options);
365
+ if (!workspace.ok) {
366
+ return {
367
+ ok: false,
368
+ projectRoot: path.resolve(projectRoot),
369
+ sourceProjectRoot: path.resolve(projectRoot),
370
+ created: false,
371
+ syncedPaths: [],
372
+ git: null,
373
+ errors: workspace.errors ?? ['无法准备 loop 工作区。'],
374
+ };
375
+ }
376
+ await ensureLoopFiles(workspace.projectRoot);
377
+ return {
378
+ ...workspace,
379
+ git: workspace.git?.ok ? workspace.git : null,
380
+ };
381
+ }
382
+
297
383
  function buildLoopSummary(featureList) {
298
384
  const tasks = featureList?.tasks ?? [];
299
385
  return {
@@ -371,6 +457,303 @@ async function updateLoopState(projectRoot, patch) {
371
457
  return next;
372
458
  }
373
459
 
460
+ function parseGitStatusLine(line) {
461
+ const status = line.slice(0, 2);
462
+ const payload = line.slice(3).trim();
463
+ const filePath = payload.includes(' -> ')
464
+ ? payload.split(' -> ').at(-1)
465
+ : payload;
466
+ return {
467
+ status,
468
+ path: toPosixPath(filePath),
469
+ };
470
+ }
471
+
472
+ async function gitStatusEntries(projectRoot) {
473
+ const status = await runCommand('git', ['status', '--porcelain=1', '--untracked-files=all'], { cwd: projectRoot });
474
+ if (!status.ok) {
475
+ return { ok: false, entries: [], status };
476
+ }
477
+ const entries = status.stdout
478
+ .split(/\r?\n/u)
479
+ .map((line) => line.trimEnd())
480
+ .filter(Boolean)
481
+ .map((line) => parseGitStatusLine(line));
482
+ return { ok: true, entries, status };
483
+ }
484
+
485
+ async function inspectGitWorkspace(projectRoot) {
486
+ const topLevel = await runCommand('git', ['rev-parse', '--show-toplevel'], { cwd: projectRoot });
487
+ if (!topLevel.ok) {
488
+ return {
489
+ ok: false,
490
+ projectRoot,
491
+ error: trimOutput(topLevel.stderr || topLevel.stdout) || '当前目录不是 Git 工作区。',
492
+ };
493
+ }
494
+ const branchResult = await runCommand('git', ['symbolic-ref', '--quiet', '--short', 'HEAD'], { cwd: projectRoot });
495
+ const gitDirResult = await runCommand('git', ['rev-parse', '--path-format=absolute', '--git-dir'], { cwd: projectRoot });
496
+ const commonDirResult = await runCommand('git', ['rev-parse', '--path-format=absolute', '--git-common-dir'], { cwd: projectRoot });
497
+ const headResult = await runCommand('git', ['rev-parse', '--short', 'HEAD'], { cwd: projectRoot });
498
+ const gitLinkPath = path.join(projectRoot, '.git');
499
+ let isMainWorktree = false;
500
+ try {
501
+ const stat = await fs.lstat(gitLinkPath);
502
+ isMainWorktree = stat.isDirectory();
503
+ } catch {
504
+ isMainWorktree = false;
505
+ }
506
+ const normalizeExistingPath = async (value) => {
507
+ const resolved = path.resolve(projectRoot, value);
508
+ return fs.realpath(resolved).catch(() => resolved);
509
+ };
510
+ const branch = branchResult.ok ? branchResult.stdout.trim() || null : null;
511
+ const repoRoot = await normalizeExistingPath(topLevel.stdout.trim());
512
+ const worktreePath = await normalizeExistingPath(projectRoot);
513
+ const gitDir = gitDirResult.ok ? await normalizeExistingPath(gitDirResult.stdout.trim()) : null;
514
+ const commonDir = commonDirResult.ok ? await normalizeExistingPath(commonDirResult.stdout.trim()) : null;
515
+ return {
516
+ ok: true,
517
+ projectRoot: worktreePath,
518
+ repoRoot,
519
+ worktreePath,
520
+ branch,
521
+ detachedHead: !branch,
522
+ headSha: headResult.ok ? headResult.stdout.trim() || null : null,
523
+ gitDir,
524
+ commonDir,
525
+ isMainWorktree,
526
+ };
527
+ }
528
+
529
+ async function copyRelativePath(sourceRoot, targetRoot, relativePath, options = {}) {
530
+ const sourcePath = harnessPath(sourceRoot, relativePath);
531
+ const targetPath = harnessPath(targetRoot, relativePath);
532
+ if (!(await exists(sourcePath))) return false;
533
+ if (!options.force && await exists(targetPath)) return false;
534
+ await fs.mkdir(path.dirname(targetPath), { recursive: true });
535
+ const stat = await fs.stat(sourcePath);
536
+ await fs.cp(sourcePath, targetPath, { recursive: stat.isDirectory(), force: true });
537
+ return true;
538
+ }
539
+
540
+ async function syncLoopIsolationArtifacts(sourceRoot, targetRoot, options = {}) {
541
+ if (path.resolve(sourceRoot) === path.resolve(targetRoot)) {
542
+ return [];
543
+ }
544
+ const copied = [];
545
+ const relativePaths = [
546
+ LOOP_FEATURE_LIST,
547
+ LOOP_PROGRESS,
548
+ LOOP_FAILED_APPROACHES,
549
+ LOOP_SESSIONS,
550
+ LOOP_STATE,
551
+ LOOP_BOOTSTRAP,
552
+ RELEASE_LEDGER,
553
+ options.change ? path.join('openprd', 'changes', options.change) : null,
554
+ ].filter(Boolean);
555
+ for (const relativePath of relativePaths) {
556
+ if (await copyRelativePath(sourceRoot, targetRoot, relativePath, { force: Boolean(options.force) })) {
557
+ copied.push(relativePath);
558
+ }
559
+ }
560
+ return copied;
561
+ }
562
+
563
+ async function createOrAttachLoopWorktree(projectRoot, options = {}) {
564
+ const sourceRoot = path.resolve(projectRoot);
565
+ const requestedWorktree = options.worktree
566
+ ? path.resolve(projectRoot, options.worktree)
567
+ : null;
568
+ if (options.branch && !requestedWorktree) {
569
+ return {
570
+ ok: false,
571
+ projectRoot: sourceRoot,
572
+ errors: ['--branch 需要和 --worktree 一起使用。'],
573
+ };
574
+ }
575
+ if (!requestedWorktree) {
576
+ return {
577
+ ok: true,
578
+ projectRoot: sourceRoot,
579
+ sourceProjectRoot: sourceRoot,
580
+ created: false,
581
+ syncedPaths: [],
582
+ git: await inspectGitWorkspace(sourceRoot),
583
+ };
584
+ }
585
+ if (!options.branch && !(await exists(requestedWorktree))) {
586
+ return {
587
+ ok: false,
588
+ projectRoot: sourceRoot,
589
+ errors: ['首次接入隔离 worktree 时,需要同时传入 --branch,或者先手动准备好目标 worktree。'],
590
+ };
591
+ }
592
+
593
+ const sourceGit = await inspectGitWorkspace(sourceRoot);
594
+ if (!sourceGit.ok) {
595
+ return {
596
+ ok: false,
597
+ projectRoot: sourceRoot,
598
+ errors: [sourceGit.error ?? '当前目录不是 Git 工作区,无法创建隔离 worktree。'],
599
+ };
600
+ }
601
+
602
+ let created = false;
603
+ if (!(await exists(requestedWorktree))) {
604
+ await fs.mkdir(path.dirname(requestedWorktree), { recursive: true });
605
+ const branchRef = `refs/heads/${options.branch}`;
606
+ const branchExists = await runCommand('git', ['show-ref', '--verify', '--quiet', branchRef], { cwd: sourceRoot });
607
+ const args = branchExists.ok
608
+ ? ['worktree', 'add', requestedWorktree, options.branch]
609
+ : ['worktree', 'add', '-b', options.branch, requestedWorktree, 'HEAD'];
610
+ const createdWorktree = await runCommand('git', args, { cwd: sourceRoot });
611
+ if (!createdWorktree.ok) {
612
+ return {
613
+ ok: false,
614
+ projectRoot: sourceRoot,
615
+ errors: [`创建隔离 worktree 失败: ${trimOutput(createdWorktree.stderr || createdWorktree.stdout)}`],
616
+ };
617
+ }
618
+ created = true;
619
+ }
620
+
621
+ const targetGit = await inspectGitWorkspace(requestedWorktree);
622
+ if (!targetGit.ok) {
623
+ return {
624
+ ok: false,
625
+ projectRoot: sourceRoot,
626
+ errors: [targetGit.error ?? '目标路径不是可用的 Git worktree。'],
627
+ };
628
+ }
629
+ if (sourceGit.commonDir && targetGit.commonDir && sourceGit.commonDir !== targetGit.commonDir) {
630
+ return {
631
+ ok: false,
632
+ projectRoot: sourceRoot,
633
+ errors: ['目标 worktree 不属于当前仓库,不能用于这次 loop 运行。'],
634
+ };
635
+ }
636
+ if (options.branch && targetGit.branch !== options.branch) {
637
+ return {
638
+ ok: false,
639
+ projectRoot: sourceRoot,
640
+ errors: [`目标 worktree 当前在分支 ${targetGit.branch ?? 'detached HEAD'},与请求的 ${options.branch} 不一致。`],
641
+ };
642
+ }
643
+ const syncedPaths = await syncLoopIsolationArtifacts(sourceRoot, requestedWorktree, {
644
+ change: options.change ?? null,
645
+ force: created,
646
+ });
647
+ return {
648
+ ok: true,
649
+ projectRoot: requestedWorktree,
650
+ sourceProjectRoot: sourceRoot,
651
+ created,
652
+ syncedPaths,
653
+ git: targetGit,
654
+ };
655
+ }
656
+
657
+ function buildCommitPlan(task, statusEntries, baselinePaths = [], options = {}) {
658
+ const changedPaths = uniquePaths(statusEntries.map((entry) => entry.path))
659
+ .filter((item) => !matchesPattern(item, LOOP_INTERNAL_STATE_PATTERNS));
660
+ const baseline = new Set(uniquePaths(baselinePaths).filter((item) => !matchesPattern(item, LOOP_INTERNAL_STATE_PATTERNS)));
661
+ const writeScope = task?.executionStrategy?.writeScope ?? taskExecutionStrategy(task ?? {}).writeScope ?? [];
662
+ const taskEventPath = task?.sourcePath
663
+ ? toPosixPath(path.join(path.dirname(task.sourcePath), 'task-events.jsonl'))
664
+ : null;
665
+ const alwaysInclude = new Set(uniquePaths([
666
+ task?.sourcePath,
667
+ taskEventPath,
668
+ ...(options.alwaysIncludePaths ?? []),
669
+ ]));
670
+ const touchedPaths = changedPaths.filter((item) => !baseline.has(item));
671
+ const outOfScopeTouched = touchedPaths.filter((item) => !alwaysInclude.has(item) && !matchesPattern(item, writeScope));
672
+ if (outOfScopeTouched.length > 0) {
673
+ return {
674
+ ok: false,
675
+ changedPaths,
676
+ touchedPaths,
677
+ stagedPaths: [],
678
+ excludedPaths: changedPaths,
679
+ outOfScopeTouched,
680
+ error: `发现超出当前任务 write-scope 的改动: ${outOfScopeTouched.join(', ')}。请先清理、拆分任务,或改用更明确的隔离 worktree。`,
681
+ };
682
+ }
683
+ const candidateSource = touchedPaths.length > 0 ? touchedPaths : changedPaths;
684
+ const stagedPaths = uniquePaths([
685
+ ...candidateSource.filter((item) => alwaysInclude.has(item) || writeScope.length === 0 || matchesPattern(item, writeScope)),
686
+ ...changedPaths.filter((item) => alwaysInclude.has(item)),
687
+ ]);
688
+ const excludedPaths = changedPaths.filter((item) => !stagedPaths.includes(item));
689
+ return {
690
+ ok: true,
691
+ changedPaths,
692
+ touchedPaths,
693
+ stagedPaths,
694
+ excludedPaths,
695
+ outOfScopeTouched: [],
696
+ };
697
+ }
698
+
699
+
700
+ async function gitAddPaths(projectRoot, filePaths) {
701
+ if (filePaths.length === 0) {
702
+ return { ok: true, skipped: true };
703
+ }
704
+ return runCommand('git', ['add', '--', ...filePaths], { cwd: projectRoot });
705
+ }
706
+
707
+ async function gitRestorePaths(projectRoot, filePaths) {
708
+ if (filePaths.length === 0) {
709
+ return { ok: true, skipped: true };
710
+ }
711
+ return runCommand('git', ['restore', '--source=HEAD', '--staged', '--worktree', '--', ...filePaths], {
712
+ cwd: projectRoot,
713
+ });
714
+ }
715
+
716
+ function resolveLoopFolderManualModuleName(workspace) {
717
+ const sourceRoot = workspace?.sourceProjectRoot ? path.resolve(workspace.sourceProjectRoot) : null;
718
+ const projectRoot = workspace?.projectRoot ? path.resolve(workspace.projectRoot) : null;
719
+ if (!sourceRoot || !projectRoot || sourceRoot === projectRoot) {
720
+ return '';
721
+ }
722
+ return normalizeLoopModuleName(path.basename(sourceRoot));
723
+ }
724
+
725
+ async function cleanupLoopOperationalArtifacts(projectRoot) {
726
+ const status = await gitStatusEntries(projectRoot);
727
+ if (!status.ok) {
728
+ return {
729
+ ok: false,
730
+ restoredPaths: [],
731
+ removedPaths: [],
732
+ error: trimOutput(status.status?.stderr || status.status?.stdout) || '无法读取 Git 状态。',
733
+ };
734
+ }
735
+ const candidates = status.entries.filter((entry) => matchesPattern(entry.path, LOOP_OPERATIONAL_ARTIFACT_PATTERNS));
736
+ const restoredPaths = uniquePaths(candidates.filter((entry) => entry.status !== '??').map((entry) => entry.path));
737
+ const removedPaths = uniquePaths(candidates.filter((entry) => entry.status === '??').map((entry) => entry.path));
738
+ const restore = await gitRestorePaths(projectRoot, restoredPaths);
739
+ if (!restore.ok) {
740
+ return {
741
+ ok: false,
742
+ restoredPaths: [],
743
+ removedPaths: [],
744
+ error: trimOutput(restore.stderr || restore.stdout) || '无法回滚 loop 运行态产物。',
745
+ };
746
+ }
747
+ for (const relativePath of removedPaths) {
748
+ await fs.rm(harnessPath(projectRoot, relativePath), { recursive: true, force: true });
749
+ }
750
+ return {
751
+ ok: true,
752
+ restoredPaths,
753
+ removedPaths,
754
+ };
755
+ }
756
+
374
757
  function renderProgressEntry(title, lines) {
375
758
  return `\n## ${title}\n\n${lines.filter(Boolean).map((line) => `- ${line}`).join('\n')}\n`;
376
759
  }
@@ -515,28 +898,140 @@ async function runCommand(command, args, options = {}) {
515
898
  });
516
899
  }
517
900
 
518
- async function gitCommit(projectRoot, message) {
519
- const status = await runCommand('git', ['status', '--porcelain'], { cwd: projectRoot });
901
+ async function planGitCommit(projectRoot, options = {}) {
902
+ const git = options.git ?? await inspectGitWorkspace(projectRoot);
903
+ const status = await gitStatusEntries(projectRoot);
904
+ const branch = git?.ok ? (git.branch ?? null) : null;
905
+ const worktreePath = git?.ok ? (git.worktreePath ?? path.resolve(projectRoot)) : path.resolve(projectRoot);
520
906
  if (!status.ok) {
521
- return { ok: false, skipped: false, message: 'git status 执行失败', status };
907
+ return {
908
+ ok: false,
909
+ skipped: false,
910
+ message: 'git status 执行失败',
911
+ status: status.status,
912
+ git: git?.ok ? git : null,
913
+ branch,
914
+ worktreePath,
915
+ };
916
+ }
917
+ if (status.entries.length === 0) {
918
+ return {
919
+ ok: true,
920
+ skipped: true,
921
+ message: '没有需要提交的 Git 变更。',
922
+ git: git?.ok ? git : null,
923
+ branch,
924
+ worktreePath,
925
+ commitPlan: {
926
+ ok: true,
927
+ changedPaths: [],
928
+ touchedPaths: [],
929
+ stagedPaths: [],
930
+ excludedPaths: [],
931
+ outOfScopeTouched: [],
932
+ },
933
+ };
934
+ }
935
+ if (!git?.ok) {
936
+ return {
937
+ ok: false,
938
+ skipped: false,
939
+ message: '当前目录不是 Git 工作区,无法自动提交。',
940
+ status: status.status,
941
+ git: null,
942
+ branch,
943
+ worktreePath,
944
+ };
945
+ }
946
+ if (git.detachedHead) {
947
+ return {
948
+ ok: false,
949
+ skipped: false,
950
+ message: '当前 worktree 处于 detached HEAD,不能为 loop 任务自动提交。请先切到命名分支后重试。',
951
+ git,
952
+ branch: null,
953
+ worktreePath,
954
+ };
955
+ }
956
+ const commitPlan = buildCommitPlan(options.task ?? null, status.entries, options.baselinePaths ?? [], {
957
+ alwaysIncludePaths: options.alwaysIncludePaths ?? [],
958
+ });
959
+ if (!commitPlan.ok) {
960
+ return {
961
+ ok: false,
962
+ skipped: false,
963
+ message: commitPlan.error,
964
+ git,
965
+ branch,
966
+ worktreePath,
967
+ commitPlan,
968
+ };
969
+ }
970
+ if (git.isMainWorktree && !options.allowDirtyMain && commitPlan.excludedPaths.length > 0) {
971
+ return {
972
+ ok: false,
973
+ skipped: false,
974
+ message: `当前主工作区还有未纳入本任务提交的改动: ${commitPlan.excludedPaths.join(', ')}。请改用 --worktree/--branch,或确认后显式传入 --allow-dirty-main。`,
975
+ git,
976
+ branch,
977
+ worktreePath,
978
+ commitPlan,
979
+ };
522
980
  }
523
- if (!status.stdout.trim()) {
524
- return { ok: true, skipped: true, message: '没有需要提交的 Git 变更。' };
981
+ if (commitPlan.stagedPaths.length === 0) {
982
+ return {
983
+ ok: true,
984
+ skipped: true,
985
+ message: '没有属于当前任务的可提交变更。',
986
+ git,
987
+ branch,
988
+ worktreePath,
989
+ commitPlan,
990
+ };
991
+ }
992
+ return {
993
+ ok: true,
994
+ skipped: false,
995
+ message: 'ready',
996
+ git,
997
+ branch,
998
+ worktreePath,
999
+ commitPlan,
1000
+ };
1001
+ }
1002
+
1003
+ async function gitCommit(projectRoot, message, options = {}) {
1004
+ const prepared = options.prepared ?? await planGitCommit(projectRoot, options);
1005
+ if (!prepared.ok || prepared.skipped) {
1006
+ return prepared;
525
1007
  }
526
- const add = await runCommand('git', ['add', '-A'], { cwd: projectRoot });
1008
+ const add = await gitAddPaths(projectRoot, prepared.commitPlan.stagedPaths);
527
1009
  if (!add.ok) {
528
- return { ok: false, skipped: false, message: 'git add 执行失败', add };
1010
+ return {
1011
+ ...prepared,
1012
+ ok: false,
1013
+ skipped: false,
1014
+ message: 'git add 执行失败',
1015
+ add,
1016
+ };
529
1017
  }
530
- const commit = await runCommand('git', ['commit', '-m', message], { cwd: projectRoot });
1018
+ const commit = await runCommand('git', ['commit', '-m', message, '--', ...prepared.commitPlan.stagedPaths], { cwd: projectRoot });
531
1019
  if (!commit.ok) {
532
- return { ok: false, skipped: false, message: 'git commit 执行失败', commit };
1020
+ return {
1021
+ ...prepared,
1022
+ ok: false,
1023
+ skipped: false,
1024
+ message: 'git commit 执行失败',
1025
+ commit,
1026
+ };
533
1027
  }
534
1028
  const rev = await runCommand('git', ['rev-parse', '--short', 'HEAD'], { cwd: projectRoot });
535
1029
  return {
1030
+ ...prepared,
536
1031
  ok: true,
537
1032
  skipped: false,
538
1033
  message: '已提交',
539
- sha: rev.stdout.trim() || null,
1034
+ sha: rev.ok ? (rev.stdout.trim() || null) : null,
540
1035
  commit,
541
1036
  };
542
1037
  }
@@ -669,6 +1164,18 @@ async function updateReleaseLedgerAfterFinish(projectRoot, task, commitSha = nul
669
1164
  ledger = appended.ledger;
670
1165
 
671
1166
  let tag = null;
1167
+ if (!commitSha && !current.tag?.name) {
1168
+ const tagged = updateReleaseTag(ledger, {
1169
+ version: current.version,
1170
+ name: current.version,
1171
+ localSha: null,
1172
+ remoteSha: null,
1173
+ remoteStatus: null,
1174
+ warning: null,
1175
+ updatedAt: timestamp(),
1176
+ });
1177
+ ledger = tagged.ledger;
1178
+ }
672
1179
  if (commitSha) {
673
1180
  tag = await syncLocalVersionTag(projectRoot, current.version, commitSha);
674
1181
  const tagged = updateReleaseTag(ledger, {
@@ -742,12 +1249,14 @@ function parseEvidenceArtifacts(projectRoot, evidenceText) {
742
1249
  return { screenshots, textualEvidence };
743
1250
  }
744
1251
 
745
- async function writeTestReport(projectRoot, { task, agent, advanced, change }) {
1252
+ async function writeTestReport(projectRoot, { task, agent, advanced, change, workspace = null, commit = null }) {
746
1253
  const relativePath = cjoin(LOOP_TEST_REPORTS_DIR, reportFileName(task.id));
747
- const htmlRelativePath = cjoin(LOOP_TEST_REPORTS_DIR, `${task.id.replace(/[^a-zA-Z0-9._-]/g, '_')}.html`);
748
1254
  const evidenceText = advanced.evidence ?? inferUiVerificationHint(task, agent);
749
1255
  const notesText = advanced.notes ?? '无';
750
1256
  const evidenceArtifacts = parseEvidenceArtifacts(projectRoot, evidenceText);
1257
+ const worktreePath = workspace?.path ?? path.resolve(projectRoot);
1258
+ const branch = workspace?.branch ?? null;
1259
+ const commitSha = commit?.sha ?? null;
751
1260
  const report = {
752
1261
  version: 1,
753
1262
  generatedAt: timestamp(),
@@ -755,6 +1264,9 @@ async function writeTestReport(projectRoot, { task, agent, advanced, change }) {
755
1264
  verifyCommand: advanced.verification?.command ?? task.verify ?? '未指定',
756
1265
  oracle: task.oracle ?? null,
757
1266
  testStrategy: task.testStrategy ?? taskTestStrategy(task),
1267
+ worktreePath,
1268
+ branch,
1269
+ commitSha,
758
1270
  summary: {
759
1271
  total: 1,
760
1272
  passed: advanced.verification?.ok ? 1 : 0,
@@ -780,6 +1292,9 @@ async function writeTestReport(projectRoot, { task, agent, advanced, change }) {
780
1292
  '',
781
1293
  `- 测试时间: ${timestamp()}`,
782
1294
  `- 变更: ${task.changeId}`,
1295
+ `- 工作区: ${worktreePath}`,
1296
+ `- 分支: ${branch ?? '未命名分支或 detached HEAD'}`,
1297
+ `- 提交: ${commitSha ?? '未提交'}`,
783
1298
  `- 完成条件: ${task.done ?? '未指定'}`,
784
1299
  `- 自测命令: ${advanced.verification?.command ?? task.verify ?? '未指定'}`,
785
1300
  `- 测试策略: ${task.testStrategyDescription ?? describeTestStrategy(task.testStrategy ?? taskTestStrategy(task))}`,
@@ -787,7 +1302,7 @@ async function writeTestReport(projectRoot, { task, agent, advanced, change }) {
787
1302
  `- 自测结果: ${advanced.verification?.ok ? '通过' : '失败或未运行'}`,
788
1303
  `- Change 校验: ${change.ok ? '通过' : '失败'}`,
789
1304
  `- EVO 冒烟证据: ${advanced.verification?.ok ? 'smoke pass via task verify command' : 'smoke failed or missing'}`,
790
- `- EVO 功能覆盖证据: feature coverage checked against OpenPrd task completion`,
1305
+ '- EVO 功能覆盖证据: feature coverage checked against OpenPrd task completion',
791
1306
  `- 界面验证策略: ${inferUiVerificationHint(task, agent)}`,
792
1307
  `- 补充证据: ${evidenceText}`,
793
1308
  `- 备注: ${notesText}`,
@@ -825,7 +1340,7 @@ async function writeTestReport(projectRoot, { task, agent, advanced, change }) {
825
1340
  await writeHtmlArtifact(htmlPath, renderRegressionArtifact({ task, report }));
826
1341
  return {
827
1342
  markdownPath: relativePath,
828
- htmlPath: htmlRelativePath,
1343
+ htmlPath: path.relative(projectRoot, htmlPath),
829
1344
  report,
830
1345
  };
831
1346
  }
@@ -965,18 +1480,36 @@ export async function nextLoopWorkspace(projectRoot, options = {}) {
965
1480
  }
966
1481
 
967
1482
  export async function promptLoopWorkspace(projectRoot, options = {}) {
968
- await ensureLoopFiles(projectRoot);
1483
+ const workspace = await resolveLoopWorkspace(projectRoot, options);
1484
+ if (!workspace.ok) {
1485
+ return {
1486
+ ok: false,
1487
+ action: 'loop-prompt',
1488
+ projectRoot: path.resolve(projectRoot),
1489
+ agent: options.agent ?? 'codex',
1490
+ errors: workspace.errors,
1491
+ };
1492
+ }
1493
+ const effectiveRoot = workspace.projectRoot;
969
1494
  const agent = normalizeAgent(options.agent ?? 'codex');
970
- const featureList = await readFeatureList(projectRoot);
1495
+ const featureList = await readFeatureList(effectiveRoot);
971
1496
  if (!featureList) {
972
1497
  throw new Error('Loop feature list is missing. Run openprd loop . --plan --change <id>.');
973
1498
  }
974
1499
  const { task, dependencyState: state } = nextLoopTask(featureList, options.item);
1500
+ const workspaceInfo = {
1501
+ path: workspace.git?.worktreePath ?? path.resolve(effectiveRoot),
1502
+ branch: workspace.git?.branch ?? null,
1503
+ created: workspace.created,
1504
+ syncedPaths: workspace.syncedPaths,
1505
+ };
975
1506
  if (!task) {
976
1507
  return {
977
1508
  ok: false,
978
1509
  action: 'loop-prompt',
979
- projectRoot,
1510
+ projectRoot: effectiveRoot,
1511
+ sourceProjectRoot: workspace.sourceProjectRoot,
1512
+ workspace: workspaceInfo,
980
1513
  agent,
981
1514
  errors: ['当前没有可执行的 loop 任务。'],
982
1515
  };
@@ -985,7 +1518,9 @@ export async function promptLoopWorkspace(projectRoot, options = {}) {
985
1518
  return {
986
1519
  ok: false,
987
1520
  action: 'loop-prompt',
988
- projectRoot,
1521
+ projectRoot: effectiveRoot,
1522
+ sourceProjectRoot: workspace.sourceProjectRoot,
1523
+ workspace: workspaceInfo,
989
1524
  agent,
990
1525
  task,
991
1526
  dependencyState: state,
@@ -994,58 +1529,111 @@ export async function promptLoopWorkspace(projectRoot, options = {}) {
994
1529
  }
995
1530
  const prompt = renderLoopPrompt({
996
1531
  agent,
997
- projectRoot,
1532
+ projectRoot: effectiveRoot,
998
1533
  featureList,
999
1534
  task,
1000
1535
  dependency: state,
1001
1536
  mode: options.mode ?? 'manual',
1002
1537
  });
1003
1538
  const promptFileName = `${task.id.replace(/[^a-zA-Z0-9._-]/g, '_')}-${agent}-${Date.now()}.md`;
1004
- const promptPath = harnessPath(projectRoot, cjoin(LOOP_PROMPTS_DIR, promptFileName));
1539
+ const promptPath = harnessPath(effectiveRoot, cjoin(LOOP_PROMPTS_DIR, promptFileName));
1005
1540
  await writeText(promptPath, prompt);
1006
- const invocation = defaultAgentInvocation(agent, projectRoot, path.relative(projectRoot, promptPath));
1541
+ const invocation = defaultAgentInvocation(agent, effectiveRoot, path.relative(effectiveRoot, promptPath));
1542
+ const baselineStatus = await gitStatusEntries(effectiveRoot);
1543
+ const baselinePaths = baselineStatus.ok
1544
+ ? uniquePaths(baselineStatus.entries.map((entry) => entry.path))
1545
+ .filter((item) => !matchesPattern(item, LOOP_INTERNAL_STATE_PATTERNS))
1546
+ : [];
1547
+ await updateLoopState(effectiveRoot, {
1548
+ currentTaskId: task.id,
1549
+ currentTaskHandle: task.taskHandle,
1550
+ currentTaskTitle: task.title,
1551
+ currentTaskBaselinePaths: baselinePaths,
1552
+ currentWorktreePath: workspaceInfo.path,
1553
+ currentBranch: workspaceInfo.branch,
1554
+ lastWorktreePath: workspaceInfo.path,
1555
+ lastBranch: workspaceInfo.branch,
1556
+ });
1007
1557
  return {
1008
1558
  ok: true,
1009
1559
  action: 'loop-prompt',
1010
- projectRoot,
1560
+ projectRoot: effectiveRoot,
1561
+ sourceProjectRoot: workspace.sourceProjectRoot,
1562
+ workspace: workspaceInfo,
1011
1563
  agent,
1012
1564
  task,
1013
1565
  dependencyState: state,
1014
1566
  prompt,
1015
- promptPath: path.relative(projectRoot, promptPath),
1567
+ promptPath: path.relative(effectiveRoot, promptPath),
1016
1568
  invocation,
1017
1569
  };
1018
1570
  }
1019
1571
 
1020
1572
  export async function verifyLoopWorkspace(projectRoot, options = {}) {
1021
- await ensureLoopFiles(projectRoot);
1022
- const featureList = await readFeatureList(projectRoot);
1573
+ const workspace = await resolveLoopWorkspace(projectRoot, options);
1574
+ if (!workspace.ok) {
1575
+ return {
1576
+ ok: false,
1577
+ action: 'loop-verify',
1578
+ projectRoot: path.resolve(projectRoot),
1579
+ errors: workspace.errors,
1580
+ };
1581
+ }
1582
+ const effectiveRoot = workspace.projectRoot;
1583
+ const featureList = await readFeatureList(effectiveRoot);
1023
1584
  if (!featureList) {
1024
1585
  throw new Error('Loop feature list is missing. Run openprd loop . --plan --change <id>.');
1025
1586
  }
1026
1587
  const { task, dependencyState: state } = nextLoopTask(featureList, options.item);
1588
+ const workspaceInfo = {
1589
+ path: workspace.git?.worktreePath ?? path.resolve(effectiveRoot),
1590
+ branch: workspace.git?.branch ?? null,
1591
+ created: workspace.created,
1592
+ syncedPaths: workspace.syncedPaths,
1593
+ };
1027
1594
  if (!task) {
1028
1595
  const summary = buildLoopSummary(featureList);
1029
1596
  if (summary.total > 0 && summary.done === summary.total) {
1030
1597
  return {
1031
1598
  ok: true,
1032
1599
  action: 'loop-verify',
1033
- projectRoot,
1600
+ projectRoot: effectiveRoot,
1601
+ sourceProjectRoot: workspace.sourceProjectRoot,
1602
+ workspace: workspaceInfo,
1034
1603
  summary,
1035
1604
  errors: [],
1036
1605
  checks: ['所有 OpenPrd loop 任务均已完成。'],
1037
1606
  };
1038
1607
  }
1039
- return { ok: false, action: 'loop-verify', projectRoot, summary, errors: ['当前没有可执行的 loop 任务。'] };
1608
+ return {
1609
+ ok: false,
1610
+ action: 'loop-verify',
1611
+ projectRoot: effectiveRoot,
1612
+ sourceProjectRoot: workspace.sourceProjectRoot,
1613
+ workspace: workspaceInfo,
1614
+ summary,
1615
+ errors: ['当前没有可执行的 loop 任务。'],
1616
+ };
1040
1617
  }
1041
1618
  if (!state.ready) {
1042
- return { ok: false, action: 'loop-verify', projectRoot, task, dependencyState: state, errors: [`任务 ${task.id} 尚未就绪。`] };
1619
+ return {
1620
+ ok: false,
1621
+ action: 'loop-verify',
1622
+ projectRoot: effectiveRoot,
1623
+ sourceProjectRoot: workspace.sourceProjectRoot,
1624
+ workspace: workspaceInfo,
1625
+ task,
1626
+ dependencyState: state,
1627
+ errors: [`任务 ${task.id} 尚未就绪。`],
1628
+ };
1043
1629
  }
1044
- const verify = await verifyOpenSpecTaskWorkspace(projectRoot, { change: task.changeId, item: task.sourceTaskId });
1630
+ const verify = await verifyOpenSpecTaskWorkspace(effectiveRoot, { change: task.changeId, item: task.sourceTaskId });
1045
1631
  return {
1046
1632
  ok: verify.ok,
1047
1633
  action: 'loop-verify',
1048
- projectRoot,
1634
+ projectRoot: effectiveRoot,
1635
+ sourceProjectRoot: workspace.sourceProjectRoot,
1636
+ workspace: workspaceInfo,
1049
1637
  task,
1050
1638
  dependencyState: state,
1051
1639
  verify,
@@ -1064,32 +1652,57 @@ function updateTask(featureList, taskId, patch) {
1064
1652
  }
1065
1653
 
1066
1654
  export async function finishLoopWorkspace(projectRoot, options = {}) {
1067
- await ensureLoopFiles(projectRoot);
1068
- const featureList = await readFeatureList(projectRoot);
1655
+ const workspace = await resolveLoopWorkspace(projectRoot, options);
1656
+ if (!workspace.ok) {
1657
+ return {
1658
+ ok: false,
1659
+ action: 'loop-finish',
1660
+ projectRoot: path.resolve(projectRoot),
1661
+ errors: workspace.errors,
1662
+ };
1663
+ }
1664
+ const effectiveRoot = workspace.projectRoot;
1665
+ const featureList = await readFeatureList(effectiveRoot);
1069
1666
  if (!featureList) {
1070
1667
  throw new Error('Loop feature list is missing. Run openprd loop . --plan --change <id>.');
1071
1668
  }
1072
1669
  const { task, dependencyState: state } = nextLoopTask(featureList, options.item);
1670
+ const workspaceInfo = {
1671
+ path: workspace.git?.worktreePath ?? path.resolve(effectiveRoot),
1672
+ branch: workspace.git?.branch ?? null,
1673
+ created: workspace.created,
1674
+ syncedPaths: workspace.syncedPaths,
1675
+ };
1073
1676
  if (!task) {
1074
- return { ok: false, action: 'loop-finish', projectRoot, errors: ['当前没有可执行的 loop 任务。'] };
1677
+ return { ok: false, action: 'loop-finish', projectRoot: effectiveRoot, sourceProjectRoot: workspace.sourceProjectRoot, workspace: workspaceInfo, errors: ['当前没有可执行的 loop 任务。'] };
1075
1678
  }
1076
1679
  if (!state.ready) {
1077
- return { ok: false, action: 'loop-finish', projectRoot, task, dependencyState: state, errors: [`任务 ${task.id} 尚未就绪。`] };
1680
+ return { ok: false, action: 'loop-finish', projectRoot: effectiveRoot, sourceProjectRoot: workspace.sourceProjectRoot, workspace: workspaceInfo, task, dependencyState: state, errors: [`任务 ${task.id} 尚未就绪。`] };
1078
1681
  }
1079
1682
 
1080
- const beforeChange = await validateOpenSpecChangeWorkspace(projectRoot, { change: task.changeId });
1683
+ const loopState = await readLoopState(effectiveRoot);
1684
+ const baselinePaths = loopState?.currentTaskId === task.id
1685
+ ? uniquePaths(loopState.currentTaskBaselinePaths ?? [])
1686
+ : [];
1687
+
1688
+ const beforeChange = await validateOpenSpecChangeWorkspace(effectiveRoot, {
1689
+ change: task.changeId,
1690
+ folderManualModuleName: resolveLoopFolderManualModuleName(workspace),
1691
+ });
1081
1692
  if (!beforeChange.ok) {
1082
1693
  return {
1083
1694
  ok: false,
1084
1695
  action: 'loop-finish',
1085
- projectRoot,
1696
+ projectRoot: effectiveRoot,
1697
+ sourceProjectRoot: workspace.sourceProjectRoot,
1698
+ workspace: workspaceInfo,
1086
1699
  task,
1087
1700
  change: beforeChange,
1088
1701
  errors: beforeChange.errors,
1089
1702
  };
1090
1703
  }
1091
1704
 
1092
- const verification = await verifyOpenSpecTaskWorkspace(projectRoot, {
1705
+ const verification = await verifyOpenSpecTaskWorkspace(effectiveRoot, {
1093
1706
  change: task.changeId,
1094
1707
  item: task.sourceTaskId,
1095
1708
  evidence: options.evidence,
@@ -1098,8 +1711,8 @@ export async function finishLoopWorkspace(projectRoot, options = {}) {
1098
1711
  if (!verification.ok) {
1099
1712
  const failureReason = verification.verification?.stderr || verification.verification?.stdout || '自测失败';
1100
1713
  const failedList = updateTask(featureList, task.id, { status: 'failed', lastError: failureReason });
1101
- await writeFeatureList(projectRoot, failedList);
1102
- await appendFailedApproach(projectRoot, {
1714
+ await writeFeatureList(effectiveRoot, failedList);
1715
+ await appendFailedApproach(effectiveRoot, {
1103
1716
  task,
1104
1717
  stage: 'task-verify',
1105
1718
  reason: failureReason,
@@ -1110,7 +1723,9 @@ export async function finishLoopWorkspace(projectRoot, options = {}) {
1110
1723
  return {
1111
1724
  ok: false,
1112
1725
  action: 'loop-finish',
1113
- projectRoot,
1726
+ projectRoot: effectiveRoot,
1727
+ sourceProjectRoot: workspace.sourceProjectRoot,
1728
+ workspace: workspaceInfo,
1114
1729
  task,
1115
1730
  verification,
1116
1731
  errors: [failureReason || `任务 ${task.id} 自测失败。`],
@@ -1120,8 +1735,8 @@ export async function finishLoopWorkspace(projectRoot, options = {}) {
1120
1735
  const finishEvidence = validateLoopFinishEvidence(task, options);
1121
1736
  if (!finishEvidence.ok) {
1122
1737
  const failedList = updateTask(featureList, task.id, { status: 'failed', lastError: finishEvidence.error });
1123
- await writeFeatureList(projectRoot, failedList);
1124
- await appendFailedApproach(projectRoot, {
1738
+ await writeFeatureList(effectiveRoot, failedList);
1739
+ await appendFailedApproach(effectiveRoot, {
1125
1740
  task,
1126
1741
  stage: 'finish-evidence',
1127
1742
  reason: finishEvidence.error,
@@ -1132,14 +1747,16 @@ export async function finishLoopWorkspace(projectRoot, options = {}) {
1132
1747
  return {
1133
1748
  ok: false,
1134
1749
  action: 'loop-finish',
1135
- projectRoot,
1750
+ projectRoot: effectiveRoot,
1751
+ sourceProjectRoot: workspace.sourceProjectRoot,
1752
+ workspace: workspaceInfo,
1136
1753
  task,
1137
1754
  verification,
1138
1755
  errors: [finishEvidence.error],
1139
1756
  };
1140
1757
  }
1141
1758
 
1142
- const advanced = await advanceOpenSpecTaskWorkspace(projectRoot, {
1759
+ const advanced = await advanceOpenSpecTaskWorkspace(effectiveRoot, {
1143
1760
  change: task.changeId,
1144
1761
  item: task.sourceTaskId,
1145
1762
  verify: false,
@@ -1149,8 +1766,8 @@ export async function finishLoopWorkspace(projectRoot, options = {}) {
1149
1766
  if (!advanced.ok) {
1150
1767
  const failureReason = advanced.errors?.[0] ?? `任务 ${task.id} 标记完成失败。`;
1151
1768
  const failedList = updateTask(featureList, task.id, { status: 'failed', lastError: failureReason });
1152
- await writeFeatureList(projectRoot, failedList);
1153
- await appendFailedApproach(projectRoot, {
1769
+ await writeFeatureList(effectiveRoot, failedList);
1770
+ await appendFailedApproach(effectiveRoot, {
1154
1771
  task,
1155
1772
  stage: 'task-advance',
1156
1773
  reason: failureReason,
@@ -1161,7 +1778,9 @@ export async function finishLoopWorkspace(projectRoot, options = {}) {
1161
1778
  return {
1162
1779
  ok: false,
1163
1780
  action: 'loop-finish',
1164
- projectRoot,
1781
+ projectRoot: effectiveRoot,
1782
+ sourceProjectRoot: workspace.sourceProjectRoot,
1783
+ workspace: workspaceInfo,
1165
1784
  task,
1166
1785
  verification,
1167
1786
  advanced,
@@ -1175,23 +1794,54 @@ export async function finishLoopWorkspace(projectRoot, options = {}) {
1175
1794
  };
1176
1795
 
1177
1796
  let commit = null;
1178
- const testReport = await writeTestReport(projectRoot, {
1179
- task,
1180
- agent: options.agent ?? 'codex',
1181
- advanced: {
1182
- ...finishResult,
1183
- evidence: options.evidence ?? null,
1184
- notes: options.notes ?? null,
1185
- },
1186
- change,
1187
- });
1797
+ let projectRelease = null;
1798
+ let pendingRelease = null;
1188
1799
  if (options.commit) {
1189
- commit = await gitCommit(projectRoot, options.message ?? task.commitMessage);
1800
+ const preparedCommit = await planGitCommit(effectiveRoot, {
1801
+ task,
1802
+ baselinePaths,
1803
+ allowDirtyMain: Boolean(options.allowDirtyMain),
1804
+ git: workspace.git,
1805
+ });
1806
+ if (!preparedCommit.ok) {
1807
+ return {
1808
+ ok: false,
1809
+ action: 'loop-finish',
1810
+ projectRoot: effectiveRoot,
1811
+ sourceProjectRoot: workspace.sourceProjectRoot,
1812
+ workspace: workspaceInfo,
1813
+ task,
1814
+ advanced: finishResult,
1815
+ change,
1816
+ commit: preparedCommit,
1817
+ errors: [preparedCommit.message],
1818
+ };
1819
+ }
1820
+ if (!preparedCommit.skipped) {
1821
+ pendingRelease = await updateReleaseLedgerAfterFinish(effectiveRoot, task, null).catch((error) => ({
1822
+ skipped: true,
1823
+ warnings: [error instanceof Error ? error.message : String(error)],
1824
+ tag: null,
1825
+ version: null,
1826
+ }));
1827
+ }
1828
+ const alwaysIncludePaths = pendingRelease?.version && !pendingRelease.skipped
1829
+ ? [RELEASE_LEDGER]
1830
+ : [];
1831
+ commit = await gitCommit(effectiveRoot, options.message ?? task.commitMessage, {
1832
+ task,
1833
+ baselinePaths,
1834
+ allowDirtyMain: Boolean(options.allowDirtyMain),
1835
+ git: preparedCommit.git ?? workspace.git,
1836
+ alwaysIncludePaths,
1837
+ });
1190
1838
  if (!commit.ok) {
1191
1839
  return {
1192
1840
  ok: false,
1193
1841
  action: 'loop-finish',
1194
- projectRoot,
1842
+ projectRoot: effectiveRoot,
1843
+ sourceProjectRoot: workspace.sourceProjectRoot,
1844
+ workspace: workspaceInfo,
1195
1845
  task,
1196
1846
  advanced: finishResult,
1197
1847
  change,
@@ -1199,17 +1849,32 @@ export async function finishLoopWorkspace(projectRoot, options = {}) {
1199
1849
  errors: [commit.message],
1200
1850
  };
1201
1851
  }
1852
+ projectRelease = pendingRelease;
1853
+ if (projectRelease?.version && !projectRelease.skipped && !commit.skipped) {
1854
+ const tag = await syncLocalVersionTag(effectiveRoot, projectRelease.version, commit.sha);
1855
+ projectRelease = {
1856
+ ...projectRelease,
1857
+ tag,
1858
+ warnings: [...(projectRelease.warnings ?? []), ...(tag?.warning ? [tag.warning] : [])],
1859
+ };
1860
+ }
1202
1861
  }
1203
1862
 
1204
- const projectRelease = await updateReleaseLedgerAfterFinish(
1205
- projectRoot,
1863
+ const testReport = await writeTestReport(effectiveRoot, {
1206
1864
  task,
1207
- commit && !commit.skipped ? commit.sha : null,
1208
- ).catch((error) => ({
1209
- skipped: true,
1210
- warnings: [error instanceof Error ? error.message : String(error)],
1211
- tag: null,
1212
- }));
1865
+ agent: options.agent ?? 'codex',
1866
+ advanced: {
1867
+ ...finishResult,
1868
+ evidence: options.evidence ?? null,
1869
+ notes: options.notes ?? null,
1870
+ },
1871
+ change,
1872
+ workspace: {
1873
+ path: commit?.worktreePath ?? workspaceInfo.path,
1874
+ branch: commit?.branch ?? workspaceInfo.branch,
1875
+ },
1876
+ commit,
1877
+ });
1213
1878
 
1214
1879
  const updatedList = updateTask(featureList, task.id, {
1215
1880
  status: 'done',
@@ -1221,7 +1886,7 @@ export async function finishLoopWorkspace(projectRoot, options = {}) {
1221
1886
  const nextAfterFinish = nextLoopTask(updatedList).task;
1222
1887
  let quality = null;
1223
1888
  if (!nextAfterFinish) {
1224
- quality = await verifyQualityWorkspace(projectRoot, { strict: true }).catch((error) => ({
1889
+ quality = await verifyQualityWorkspace(effectiveRoot, { strict: true }).catch((error) => ({
1225
1890
  ok: false,
1226
1891
  action: 'quality-verify',
1227
1892
  errors: [error instanceof Error ? error.message : String(error)],
@@ -1232,15 +1897,15 @@ export async function finishLoopWorkspace(projectRoot, options = {}) {
1232
1897
  const qualityError = [
1233
1898
  'Final EVO quality gate is not production-ready.',
1234
1899
  attentionGates.length > 0 ? `Attention gates: ${attentionGates.join(', ')}` : null,
1235
- quality.htmlPath ? `HTML report: ${path.relative(projectRoot, quality.htmlPath)}` : null,
1900
+ quality.htmlPath ? `HTML report: ${path.relative(effectiveRoot, quality.htmlPath)}` : null,
1236
1901
  ].filter(Boolean).join(' ');
1237
1902
  const failedList = updateTask(featureList, task.id, {
1238
1903
  status: 'failed',
1239
1904
  lastError: qualityError,
1240
1905
  lastTestReport: testReport.markdownPath,
1241
1906
  });
1242
- await writeFeatureList(projectRoot, failedList);
1243
- await appendFailedApproach(projectRoot, {
1907
+ await writeFeatureList(effectiveRoot, failedList);
1908
+ await appendFailedApproach(effectiveRoot, {
1244
1909
  task,
1245
1910
  stage: 'final-quality',
1246
1911
  reason: qualityError,
@@ -1248,10 +1913,20 @@ export async function finishLoopWorkspace(projectRoot, options = {}) {
1248
1913
  notes: options.notes ?? null,
1249
1914
  evidence: options.evidence ?? null,
1250
1915
  });
1916
+ const operationalCleanup = options.commit
1917
+ ? await cleanupLoopOperationalArtifacts(effectiveRoot).catch((error) => ({
1918
+ ok: false,
1919
+ restoredPaths: [],
1920
+ removedPaths: [],
1921
+ error: error instanceof Error ? error.message : String(error),
1922
+ }))
1923
+ : null;
1251
1924
  return {
1252
1925
  ok: false,
1253
1926
  action: 'loop-finish',
1254
- projectRoot,
1927
+ projectRoot: effectiveRoot,
1928
+ sourceProjectRoot: workspace.sourceProjectRoot,
1929
+ workspace: workspaceInfo,
1255
1930
  task,
1256
1931
  advanced: finishResult,
1257
1932
  change,
@@ -1259,14 +1934,15 @@ export async function finishLoopWorkspace(projectRoot, options = {}) {
1259
1934
  testReport: testReport.markdownPath,
1260
1935
  regressionHtml: testReport.htmlPath,
1261
1936
  quality,
1937
+ operationalCleanup,
1262
1938
  errors: [qualityError, ...(quality.errors ?? [])],
1263
1939
  };
1264
1940
  }
1265
1941
  }
1266
- await writeFeatureList(projectRoot, updatedList);
1942
+ await writeFeatureList(effectiveRoot, updatedList);
1267
1943
  let learningReview = null;
1268
1944
  try {
1269
- learningReview = await generateLearningReviewWorkspace(projectRoot, {
1945
+ learningReview = await generateLearningReviewWorkspace(effectiveRoot, {
1270
1946
  trigger: 'loop-finish',
1271
1947
  topic: `${task.id} ${task.title}`,
1272
1948
  sourceScope: 'loop',
@@ -1292,8 +1968,8 @@ export async function finishLoopWorkspace(projectRoot, options = {}) {
1292
1968
  ? [
1293
1969
  `复盘学习包: ${learningReview.packageId}。`,
1294
1970
  `复盘写作状态: ${learningReview.packageMeta?.authoringStatus ?? 'unknown'}。`,
1295
- `学习阅读器: ${path.relative(projectRoot, learningReview.packagePaths.readerHtml)}。`,
1296
- ...(learningReview.packagePaths?.agentPrompt ? [`Agent 写作提示: ${path.relative(projectRoot, learningReview.packagePaths.agentPrompt)}。`] : []),
1971
+ `学习阅读器: ${path.relative(effectiveRoot, learningReview.packagePaths.readerHtml)}。`,
1972
+ ...(learningReview.packagePaths?.agentPrompt ? [`Agent 写作提示: ${path.relative(effectiveRoot, learningReview.packagePaths.agentPrompt)}。`] : []),
1297
1973
  ]
1298
1974
  : [`复盘学习: 生成失败 (${learningReview?.errors?.[0] ?? 'unknown'})。`];
1299
1975
  const knowledgeSignal = {
@@ -1303,11 +1979,11 @@ export async function finishLoopWorkspace(projectRoot, options = {}) {
1303
1979
  attentionGates: quality?.report?.readiness?.attentionGates ?? [],
1304
1980
  summary: `loop finish ${task.id}: ${task.title}`,
1305
1981
  };
1306
- await recordKnowledgeReviewSignal(projectRoot, knowledgeSignal).catch(() => null);
1307
- const knowledgeReviewSource = (await exists(cjoin(projectRoot, OPENPRD_HARNESS_TURN_STATE)))
1982
+ await recordKnowledgeReviewSignal(effectiveRoot, knowledgeSignal).catch(() => null);
1983
+ const knowledgeReviewSource = (await exists(cjoin(effectiveRoot, OPENPRD_HARNESS_TURN_STATE)))
1308
1984
  ? OPENPRD_HARNESS_TURN_STATE
1309
1985
  : (quality?.reportPath ?? null);
1310
- const knowledgeReview = await reviewKnowledgeWorkspace(projectRoot, {
1986
+ const knowledgeReview = await reviewKnowledgeWorkspace(effectiveRoot, {
1311
1987
  from: knowledgeReviewSource,
1312
1988
  signal: knowledgeSignal,
1313
1989
  }).catch((error) => ({
@@ -1316,26 +1992,28 @@ export async function finishLoopWorkspace(projectRoot, options = {}) {
1316
1992
  skipped: false,
1317
1993
  errors: [error instanceof Error ? error.message : String(error)],
1318
1994
  }));
1319
- await appendText(harnessPath(projectRoot, LOOP_PROGRESS), renderProgressEntry(timestamp(), [
1995
+ await appendText(harnessPath(effectiveRoot, LOOP_PROGRESS), renderProgressEntry(timestamp(), [
1320
1996
  `已完成 ${task.id}: ${task.title}。`,
1321
1997
  `自测: ${finishResult.verification?.ok ? '通过' : '未运行'}。`,
1322
1998
  task.oracle ? `对照基准: ${task.oracle}。` : null,
1999
+ `工作区: ${workspaceInfo.path}。`,
2000
+ `分支: ${commit?.branch ?? workspaceInfo.branch ?? '未命名分支或 detached HEAD'}。`,
1323
2001
  `测试报告: ${testReport.markdownPath}。`,
1324
2002
  `HTML 回归报告: ${testReport.htmlPath}。`,
1325
2003
  ...(quality ? [
1326
2004
  `最终 EVO: ${quality.report?.readiness?.productionReady ? 'production-ready' : 'needs-attention'}。`,
1327
- ...(quality.htmlPath ? [`EVO 报告: ${path.relative(projectRoot, quality.htmlPath)}。`] : []),
2005
+ ...(quality.htmlPath ? [`EVO 报告: ${path.relative(effectiveRoot, quality.htmlPath)}。`] : []),
1328
2006
  ] : []),
1329
2007
  ...learningProgress,
1330
2008
  knowledgeReview?.skipped
1331
2009
  ? null
1332
- : `项目经验草案: ${path.relative(projectRoot, knowledgeReview.files?.draftSkill ?? knowledgeReview.files?.candidateDir ?? '') || '已生成'}。`,
2010
+ : `项目经验草案: ${path.relative(effectiveRoot, knowledgeReview.files?.draftSkill ?? knowledgeReview.files?.candidateDir ?? '') || '已生成'}。`,
1333
2011
  commit ? `Commit: ${commit.skipped ? '跳过' : commit.sha}` : 'Commit: 未请求。',
1334
2012
  projectRelease?.version ? `项目版本: ${projectRelease.version}。` : null,
1335
2013
  projectRelease?.tag?.tagName ? `版本 tag: ${projectRelease.tag.tagName}${projectRelease.tag.localSha ? ` -> ${projectRelease.tag.localSha}` : ''}。` : null,
1336
2014
  ...(projectRelease?.warnings ?? []).map((warning) => `版本轨道: ${warning}`),
1337
2015
  ]));
1338
- await appendJsonl(harnessPath(projectRoot, LOOP_SESSIONS), {
2016
+ await appendJsonl(harnessPath(effectiveRoot, LOOP_SESSIONS), {
1339
2017
  version: 1,
1340
2018
  at: timestamp(),
1341
2019
  action: 'finish',
@@ -1345,7 +2023,18 @@ export async function finishLoopWorkspace(projectRoot, options = {}) {
1345
2023
  changeId: task.changeId,
1346
2024
  ok: true,
1347
2025
  oracle: task.oracle ?? null,
1348
- commit: commit ? { ok: commit.ok, skipped: commit.skipped, sha: commit.sha ?? null } : null,
2026
+ worktreePath: commit?.worktreePath ?? workspaceInfo.path,
2027
+ branch: commit?.branch ?? workspaceInfo.branch,
2028
+ commitSha: commit?.sha ?? null,
2029
+ commit: commit ? {
2030
+ ok: commit.ok,
2031
+ skipped: commit.skipped,
2032
+ sha: commit.sha ?? null,
2033
+ branch: commit.branch ?? null,
2034
+ worktreePath: commit.worktreePath ?? null,
2035
+ stagedPaths: commit.commitPlan?.stagedPaths ?? [],
2036
+ excludedPaths: commit.commitPlan?.excludedPaths ?? [],
2037
+ } : null,
1349
2038
  projectRelease: projectRelease ?? null,
1350
2039
  testReport: testReport.markdownPath,
1351
2040
  regressionHtml: testReport.htmlPath,
@@ -1386,16 +2075,35 @@ export async function finishLoopWorkspace(projectRoot, options = {}) {
1386
2075
  errors: learningReview?.errors ?? [],
1387
2076
  },
1388
2077
  });
1389
- await updateLoopState(projectRoot, {
2078
+ await updateLoopState(effectiveRoot, {
1390
2079
  currentTaskId: nextAfterFinish?.id ?? null,
1391
2080
  currentTaskHandle: nextAfterFinish?.taskHandle ?? null,
1392
2081
  currentTaskTitle: nextAfterFinish?.title ?? null,
2082
+ currentTaskBaselinePaths: [],
2083
+ currentWorktreePath: commit?.worktreePath ?? workspaceInfo.path,
2084
+ currentBranch: commit?.branch ?? workspaceInfo.branch,
2085
+ lastWorktreePath: commit?.worktreePath ?? workspaceInfo.path,
2086
+ lastBranch: commit?.branch ?? workspaceInfo.branch,
2087
+ lastCommitSha: commit?.sha ?? null,
1393
2088
  completedTaskIds: updatedList.tasks.filter((item) => item.status === 'done').map((item) => item.id),
1394
2089
  });
2090
+ const operationalCleanup = options.commit
2091
+ ? await cleanupLoopOperationalArtifacts(effectiveRoot).catch((error) => ({
2092
+ ok: false,
2093
+ restoredPaths: [],
2094
+ removedPaths: [],
2095
+ error: error instanceof Error ? error.message : String(error),
2096
+ }))
2097
+ : null;
1395
2098
  return {
1396
2099
  ok: true,
1397
2100
  action: 'loop-finish',
1398
- projectRoot,
2101
+ projectRoot: effectiveRoot,
2102
+ sourceProjectRoot: workspace.sourceProjectRoot,
2103
+ workspace: {
2104
+ ...workspaceInfo,
2105
+ branch: commit?.branch ?? workspaceInfo.branch,
2106
+ },
1399
2107
  task,
1400
2108
  advanced,
1401
2109
  change,
@@ -1404,6 +2112,7 @@ export async function finishLoopWorkspace(projectRoot, options = {}) {
1404
2112
  testReport: testReport.markdownPath,
1405
2113
  regressionHtml: testReport.htmlPath,
1406
2114
  quality,
2115
+ operationalCleanup,
1407
2116
  knowledgeReview,
1408
2117
  learningReview,
1409
2118
  summary: buildLoopSummary(updatedList),
@@ -1416,7 +2125,8 @@ export async function runLoopWorkspace(projectRoot, options = {}) {
1416
2125
  const promptResult = await promptLoopWorkspace(projectRoot, { ...options, agent, mode: 'loop-run' });
1417
2126
  if (!promptResult.ok) return promptResult;
1418
2127
 
1419
- const absolutePromptPath = harnessPath(projectRoot, promptResult.promptPath);
2128
+ const effectiveRoot = promptResult.projectRoot;
2129
+ const absolutePromptPath = harnessPath(effectiveRoot, promptResult.promptPath);
1420
2130
  const prompt = await readText(absolutePromptPath);
1421
2131
  const invocation = options.agentCommand
1422
2132
  ? {
@@ -1426,10 +2136,10 @@ export async function runLoopWorkspace(projectRoot, options = {}) {
1426
2136
  display: `${options.agentCommand} < ${shellJoin([promptResult.promptPath])}`,
1427
2137
  shell: true,
1428
2138
  }
1429
- : defaultAgentInvocation(agent, projectRoot, promptResult.promptPath);
2139
+ : defaultAgentInvocation(agent, effectiveRoot, promptResult.promptPath);
1430
2140
  const codexPreflight = agent === 'codex' && !options.agentCommand && !options.dryRun
1431
2141
  ? await ensureCodexCliReady({
1432
- cwd: projectRoot,
2142
+ cwd: effectiveRoot,
1433
2143
  repair: Boolean(options.repairAgent),
1434
2144
  runCommand: options.codexRunCommand,
1435
2145
  packageManager: options.packageManager,
@@ -1437,7 +2147,7 @@ export async function runLoopWorkspace(projectRoot, options = {}) {
1437
2147
  : null;
1438
2148
 
1439
2149
  if (codexPreflight && !codexPreflight.ok) {
1440
- await appendJsonl(harnessPath(projectRoot, LOOP_SESSIONS), {
2150
+ await appendJsonl(harnessPath(effectiveRoot, LOOP_SESSIONS), {
1441
2151
  version: 1,
1442
2152
  at: timestamp(),
1443
2153
  action: codexPreflight.repairAttempted ? 'agent-preflight-repair-failed' : 'agent-preflight-failed',
@@ -1446,6 +2156,8 @@ export async function runLoopWorkspace(projectRoot, options = {}) {
1446
2156
  taskHandle: promptResult.task.taskHandle,
1447
2157
  taskTitle: promptResult.task.title,
1448
2158
  ok: false,
2159
+ worktreePath: promptResult.workspace?.path ?? effectiveRoot,
2160
+ branch: promptResult.workspace?.branch ?? null,
1449
2161
  preflight: {
1450
2162
  ok: codexPreflight.preflight.ok,
1451
2163
  diagnosticType: codexPreflight.preflight.diagnostic?.type ?? null,
@@ -1456,7 +2168,9 @@ export async function runLoopWorkspace(projectRoot, options = {}) {
1456
2168
  return {
1457
2169
  ok: false,
1458
2170
  action: 'loop-run',
1459
- projectRoot,
2171
+ projectRoot: effectiveRoot,
2172
+ sourceProjectRoot: promptResult.sourceProjectRoot,
2173
+ workspace: promptResult.workspace,
1460
2174
  agent,
1461
2175
  task: promptResult.task,
1462
2176
  promptPath: promptResult.promptPath,
@@ -1480,17 +2194,24 @@ export async function runLoopWorkspace(projectRoot, options = {}) {
1480
2194
  changeId: promptResult.task.changeId,
1481
2195
  promptPath: promptResult.promptPath,
1482
2196
  invocation: invocation.display,
2197
+ worktreePath: promptResult.workspace?.path ?? effectiveRoot,
2198
+ branch: promptResult.workspace?.branch ?? null,
2199
+ createdWorktree: Boolean(promptResult.workspace?.created),
1483
2200
  preflight: codexPreflight ? {
1484
2201
  ok: codexPreflight.preflight.ok,
1485
2202
  command: codexPreflight.preflight.command.display,
1486
2203
  repairAttempted: codexPreflight.repairAttempted,
1487
2204
  } : null,
1488
2205
  };
1489
- await appendJsonl(harnessPath(projectRoot, LOOP_SESSIONS), sessionEvent);
1490
- await updateLoopState(projectRoot, {
2206
+ await appendJsonl(harnessPath(effectiveRoot, LOOP_SESSIONS), sessionEvent);
2207
+ await updateLoopState(effectiveRoot, {
1491
2208
  currentTaskId: promptResult.task.id,
1492
2209
  currentTaskHandle: promptResult.task.taskHandle,
1493
2210
  currentTaskTitle: promptResult.task.title,
2211
+ currentWorktreePath: promptResult.workspace?.path ?? effectiveRoot,
2212
+ currentBranch: promptResult.workspace?.branch ?? null,
2213
+ lastWorktreePath: promptResult.workspace?.path ?? effectiveRoot,
2214
+ lastBranch: promptResult.workspace?.branch ?? null,
1494
2215
  lastAgent: agent,
1495
2216
  lastSessionAt: sessionEvent.at,
1496
2217
  });
@@ -1500,7 +2221,9 @@ export async function runLoopWorkspace(projectRoot, options = {}) {
1500
2221
  ok: true,
1501
2222
  action: 'loop-run',
1502
2223
  dryRun: true,
1503
- projectRoot,
2224
+ projectRoot: effectiveRoot,
2225
+ sourceProjectRoot: promptResult.sourceProjectRoot,
2226
+ workspace: promptResult.workspace,
1504
2227
  agent,
1505
2228
  task: promptResult.task,
1506
2229
  promptPath: promptResult.promptPath,
@@ -1513,9 +2236,9 @@ export async function runLoopWorkspace(projectRoot, options = {}) {
1513
2236
 
1514
2237
  const runAgentCommand = options.agentRunCommand ?? runCommand;
1515
2238
  const run = invocation.shell
1516
- ? await runAgentCommand(invocation.command, [], { cwd: projectRoot, shell: true, stdin: prompt })
1517
- : await runAgentCommand(invocation.command, invocation.args, { cwd: projectRoot, stdin: prompt });
1518
- await appendJsonl(harnessPath(projectRoot, LOOP_SESSIONS), {
2239
+ ? await runAgentCommand(invocation.command, [], { cwd: effectiveRoot, shell: true, stdin: prompt })
2240
+ : await runAgentCommand(invocation.command, invocation.args, { cwd: effectiveRoot, stdin: prompt });
2241
+ await appendJsonl(harnessPath(effectiveRoot, LOOP_SESSIONS), {
1519
2242
  version: 1,
1520
2243
  at: timestamp(),
1521
2244
  action: 'agent-exit',
@@ -1525,31 +2248,46 @@ export async function runLoopWorkspace(projectRoot, options = {}) {
1525
2248
  taskTitle: promptResult.task.title,
1526
2249
  ok: run.ok,
1527
2250
  status: run.status,
2251
+ worktreePath: promptResult.workspace?.path ?? effectiveRoot,
2252
+ branch: promptResult.workspace?.branch ?? null,
1528
2253
  });
1529
2254
  if (!run.ok) {
1530
2255
  return {
1531
2256
  ok: false,
1532
2257
  action: 'loop-run',
1533
- projectRoot,
2258
+ projectRoot: effectiveRoot,
2259
+ sourceProjectRoot: promptResult.sourceProjectRoot,
2260
+ workspace: promptResult.workspace,
1534
2261
  agent,
1535
2262
  task: promptResult.task,
2263
+ promptPath: promptResult.promptPath,
1536
2264
  run,
1537
2265
  errors: [run.stderr || run.stdout || 'Agent 命令执行失败。'],
1538
2266
  };
1539
2267
  }
1540
2268
 
1541
- const finish = await finishLoopWorkspace(projectRoot, {
2269
+ const finishNotes = [options.notes, `Finished by openprd loop run --agent ${agent}.`]
2270
+ .filter(Boolean)
2271
+ .join('\n');
2272
+ const finish = await finishLoopWorkspace(effectiveRoot, {
1542
2273
  item: promptResult.task.id,
1543
2274
  commit: options.commit,
1544
2275
  message: options.message ?? promptResult.task.commitMessage,
1545
- notes: `Finished by openprd loop run --agent ${agent}.`,
2276
+ notes: finishNotes,
2277
+ evidence: options.evidence,
2278
+ agent,
2279
+ allowDirtyMain: Boolean(options.allowDirtyMain),
1546
2280
  });
1547
2281
  return {
1548
2282
  ok: finish.ok,
1549
2283
  action: 'loop-run',
1550
- projectRoot,
2284
+ projectRoot: effectiveRoot,
2285
+ sourceProjectRoot: promptResult.sourceProjectRoot,
2286
+ workspace: promptResult.workspace,
1551
2287
  agent,
1552
2288
  task: promptResult.task,
2289
+ promptPath: promptResult.promptPath,
2290
+ invocation,
1553
2291
  run,
1554
2292
  codexRuntime: codexPreflight,
1555
2293
  preflight: codexPreflight?.preflight ?? null,