@ai-content-space/loopx 0.1.3 → 0.1.5

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 (40) hide show
  1. package/README.md +123 -6
  2. package/README.zh-CN.md +143 -10
  3. package/assets/logo.svg +89 -0
  4. package/package.json +4 -2
  5. package/plugins/loopx/.codex-plugin/plugin.json +1 -1
  6. package/plugins/loopx/scripts/plugin-install.test.mjs +13 -0
  7. package/plugins/loopx/skills/archive/SKILL.md +14 -1
  8. package/plugins/loopx/skills/autopilot/SKILL.md +4 -1
  9. package/plugins/loopx/skills/build/SKILL.md +7 -1
  10. package/plugins/loopx/skills/clarify/SKILL.md +13 -9
  11. package/plugins/loopx/skills/debug/SKILL.md +4 -1
  12. package/plugins/loopx/skills/go-style/SKILL.md +4 -1
  13. package/plugins/loopx/skills/kratos/SKILL.md +4 -1
  14. package/plugins/loopx/skills/plan/SKILL.md +8 -4
  15. package/plugins/loopx/skills/review/SKILL.md +7 -1
  16. package/plugins/loopx/skills/tdd/SKILL.md +4 -1
  17. package/plugins/loopx/skills/verify/SKILL.md +4 -1
  18. package/scripts/codex-workflow-hook.mjs +101 -6
  19. package/scripts/verify-skills.mjs +166 -0
  20. package/skills/RESOLVER.md +45 -0
  21. package/skills/archive/SKILL.md +14 -1
  22. package/skills/autopilot/SKILL.md +4 -1
  23. package/skills/build/SKILL.md +7 -1
  24. package/skills/clarify/SKILL.md +13 -9
  25. package/skills/debug/SKILL.md +4 -1
  26. package/skills/go-style/SKILL.md +4 -1
  27. package/skills/kratos/SKILL.md +4 -1
  28. package/skills/plan/SKILL.md +8 -4
  29. package/skills/review/SKILL.md +7 -1
  30. package/skills/tdd/SKILL.md +4 -1
  31. package/skills/verify/SKILL.md +4 -1
  32. package/src/build-runtime.mjs +8 -0
  33. package/src/cli.mjs +10 -0
  34. package/src/context-manifest.mjs +3 -1
  35. package/src/html-views.mjs +316 -0
  36. package/src/plan-runtime.mjs +23 -0
  37. package/src/project-discovery.mjs +163 -0
  38. package/src/review-runtime.mjs +203 -23
  39. package/src/runtime-maintenance.mjs +1 -0
  40. package/src/workflow.mjs +499 -94
package/src/workflow.mjs CHANGED
@@ -16,6 +16,7 @@ import {
16
16
  import { doctorRuntime, ensureLoopxRoot, resolveLoopxRoot } from './runtime-maintenance.mjs';
17
17
  import { DEFAULT_BUILD_MAX_ITERATIONS, createDefaultBuildAdapter } from './build-runtime.mjs';
18
18
  import { DEFAULT_MAX_ITERATIONS, createDefaultPlanAdapter } from './plan-runtime.mjs';
19
+ import { inspectProjectConventions } from './project-discovery.mjs';
19
20
  import { createDefaultReviewAdapter } from './review-runtime.mjs';
20
21
  import { appendWorkspaceJournal } from './workspace-memory.mjs';
21
22
  import { inspectWorkspaceContext, setupWorkspaceContext } from './workspace-context.mjs';
@@ -251,6 +252,10 @@ function resolveSpecsRoot(cwd) {
251
252
  return join(resolveWorkspaceRoot(cwd), 'specs');
252
253
  }
253
254
 
255
+ function resolveIntakeRoot(cwd) {
256
+ return join(resolveWorkspaceRoot(cwd), 'intake');
257
+ }
258
+
254
259
  function resolveChangesRoot(cwd) {
255
260
  return join(resolveWorkspaceRoot(cwd), 'changes');
256
261
  }
@@ -299,7 +304,7 @@ function resolveBuildSupportPaths(root, iteration) {
299
304
  }
300
305
 
301
306
  function canonicalClarifySpecPath(cwd, slug, stamp) {
302
- return join(resolveSpecsRoot(cwd), `clarify-${normalizeSlug(slug)}-${stamp}.md`);
307
+ return join(resolveIntakeRoot(cwd), `clarify-${normalizeSlug(slug)}-${stamp}.md`);
303
308
  }
304
309
 
305
310
  export async function readWorkspaceConfig(cwd) {
@@ -336,11 +341,39 @@ function buildWorkspaceReadme() {
336
341
  '- `loopx plan <slug>`',
337
342
  '- `loopx build <slug>`',
338
343
  '- `loopx review <slug> [--reviewer <name>]`',
344
+ '- `loopx archive <slug>`',
339
345
  '- `loopx autopilot <slug> [--reviewer <name>]`',
346
+ '- `loopx render [slug|--all]`',
340
347
  '- `loopx status [slug] [--json]`',
348
+ '- `loopx setup-context`',
341
349
  '- `loopx doctor`',
342
350
  '- `loopx migrate`',
343
351
  '- `loopx repair-install`',
352
+ '',
353
+ '## Document Boundaries',
354
+ '',
355
+ 'User-facing documents to watch:',
356
+ '',
357
+ '- `workflows/<slug>/spec.md`',
358
+ '- `workflows/<slug>/plan.md`, `architecture.md`, `development-plan.md`, and `test-plan.md`',
359
+ '- `workflows/<slug>/execution-record.md` and `review-report.md`',
360
+ '- `views/index.html` and `workflows/<slug>/view/index.html` after `loopx render`',
361
+ '',
362
+ 'Documents users may read and edit as workflow fact sources:',
363
+ '',
364
+ '- `workflows/<slug>/*.md` for the active workflow working copy',
365
+ '- `context/domain.md` and `agents/*.md` for project context and collaboration guidance',
366
+ '- `changes/active/<change-id>/*.md` for proposal, design, tasks, and spec delta',
367
+ '- `specs/<domain>/spec.md` for archived long-lived behavior specs',
368
+ '',
369
+ 'Tool-owned or derived files:',
370
+ '',
371
+ '- `workflows/<slug>/state.json`, `build-context.jsonl`, and `review-context.jsonl`',
372
+ '- `workflows/<slug>/plan-reviews/`, `build-support/`, and `review-support/`',
373
+ '- `intake/clarify-*.md` clarify snapshots',
374
+ '- `changes/active/<change-id>/slices.json` and `artifact-graph.json`',
375
+ '- `autopilot/<slug>/run.json` and `build-active.json`',
376
+ '- `views/` and `workflows/<slug>/view/` generated HTML views',
344
377
  ].join('\n');
345
378
  }
346
379
 
@@ -384,6 +417,7 @@ function createInitialState(slug, profile) {
384
417
  plan_docs_status: 'missing',
385
418
  plan_docs_artifact_paths: null,
386
419
  plan_review_artifact_paths: [],
420
+ plan_review_history: [],
387
421
  plan_blockers: [],
388
422
  plan_source_spec_path: null,
389
423
  change_id: changeIdForWorkflowSlug(slug),
@@ -576,28 +610,217 @@ function targetDomainsForChange(slug, sourceText) {
576
610
  return ['general'];
577
611
  }
578
612
 
579
- function sectionTextForHeading(text, heading, level = 2) {
580
- const hashes = '#'.repeat(level);
581
- const pattern = new RegExp(`${hashes} ${heading}\\n\\n([\\s\\S]*?)(?=\\n${hashes} |$)`, 'i');
582
- return text.match(pattern)?.[1] || '';
613
+ function declaredTargetDomainsForDelta(sourceText) {
614
+ const explicit = bulletsFromSectionText(sourceText, 'Target Spec Domains');
615
+ if (explicit.length > 0) {
616
+ return dedupeStrings(explicit.map((item) => item.replace(/`/g, '')));
617
+ }
618
+ const frontmatterDomains = frontmatterList(sourceText, 'target_domains');
619
+ if (frontmatterDomains.length > 0) {
620
+ return dedupeStrings(frontmatterDomains.map((item) => item.replace(/`/g, '')));
621
+ }
622
+ return [];
583
623
  }
584
624
 
585
- function parseLegacyDomainDeltas(text) {
586
- const domains = targetDomainsForChange('general', text);
587
- const entries = new Map();
588
- for (const domain of domains) {
589
- const domainText = sectionTextForHeading(text, domain, 2);
590
- if (!domainText.trim()) {
591
- continue;
625
+ function stripFrontmatter(text) {
626
+ if (!text.startsWith('---\n')) {
627
+ return text;
628
+ }
629
+ const end = text.indexOf('\n---\n', 4);
630
+ return end === -1 ? text : text.slice(end + 5);
631
+ }
632
+
633
+ function normalizeRequirementName(raw) {
634
+ return String(raw || '').trim().replace(/\s+/g, ' ').toLowerCase();
635
+ }
636
+
637
+ function requirementDisplayName(raw) {
638
+ return String(raw || '').trim().replace(/\s+/g, ' ');
639
+ }
640
+
641
+ function sentenceToRequirementName(text, fallback) {
642
+ const cleaned = String(text || '')
643
+ .replace(/[`*_#]/g, '')
644
+ .replace(/\s+/g, ' ')
645
+ .trim()
646
+ .replace(/[.。::]+$/, '');
647
+ if (!cleaned) {
648
+ return fallback;
649
+ }
650
+ const withoutModal = cleaned
651
+ .replace(/\bSHALL\b.*$/i, '')
652
+ .replace(/\bMUST\b.*$/i, '')
653
+ .trim();
654
+ const value = withoutModal || cleaned;
655
+ return value.length > 80 ? value.slice(0, 77).trim() : value;
656
+ }
657
+
658
+ function normativeRequirementText(text, slug, index) {
659
+ const cleaned = String(text || '').replace(/\s+/g, ' ').trim().replace(/[.。]+$/, '');
660
+ if (/\b(SHALL|MUST)\b/i.test(cleaned)) {
661
+ return `${cleaned}.`;
662
+ }
663
+ return `Workflow ${slug} SHALL satisfy: ${cleaned || `approved requirement ${index + 1}`}.`;
664
+ }
665
+
666
+ function scenarioNameForRequirement(name) {
667
+ const cleaned = requirementDisplayName(name).replace(/[.。]+$/, '');
668
+ return cleaned.length > 70 ? cleaned.slice(0, 67).trim() : cleaned;
669
+ }
670
+
671
+ function requirementBlockFromText({ slug, text, index }) {
672
+ const name = sentenceToRequirementName(text, `Approved requirement ${index + 1}`);
673
+ return [
674
+ `### Requirement: ${name}`,
675
+ normativeRequirementText(text, slug, index),
676
+ '',
677
+ `#### Scenario: ${scenarioNameForRequirement(name)}`,
678
+ `- GIVEN workflow ${slug} has an approved plan`,
679
+ `- WHEN the accepted implementation is archived`,
680
+ `- THEN the system satisfies: ${String(text || '').replace(/\s+/g, ' ').trim() || name}`,
681
+ ].join('\n');
682
+ }
683
+
684
+ function splitDeltaSections(text) {
685
+ const body = stripFrontmatter(text);
686
+ const pattern = /^##\s+(ADDED|MODIFIED|REMOVED|RENAMED)\s+Requirements\s*$/gim;
687
+ const matches = [...body.matchAll(pattern)];
688
+ const sections = new Map();
689
+ for (let index = 0; index < matches.length; index += 1) {
690
+ const match = matches[index];
691
+ const kind = match[1].toUpperCase();
692
+ const start = match.index + match[0].length;
693
+ const end = index + 1 < matches.length ? matches[index + 1].index : body.length;
694
+ sections.set(kind, body.slice(start, end).trim());
695
+ }
696
+ return sections;
697
+ }
698
+
699
+ function parseRequirementBlocks(sectionText) {
700
+ const pattern = /^###\s+Requirement:\s*(.+?)\s*$/gm;
701
+ const matches = [...String(sectionText || '').matchAll(pattern)];
702
+ return matches.map((match, index) => {
703
+ const start = match.index;
704
+ const end = index + 1 < matches.length ? matches[index + 1].index : sectionText.length;
705
+ return {
706
+ name: requirementDisplayName(match[1]),
707
+ raw: sectionText.slice(start, end).trim(),
708
+ };
709
+ }).filter((block) => block.name && block.raw);
710
+ }
711
+
712
+ function parseRenamedRequirement(block) {
713
+ const inline = block.name.match(/^(.*?)\s*(?:->|=>)\s*(.*?)$/);
714
+ if (inline) {
715
+ return {
716
+ from: requirementDisplayName(inline[1]),
717
+ to: requirementDisplayName(inline[2]),
718
+ };
719
+ }
720
+ const from = block.raw.match(/^FROM:\s*(.+?)\s*$/im)?.[1];
721
+ const to = block.raw.match(/^TO:\s*(.+?)\s*$/im)?.[1];
722
+ return {
723
+ from: requirementDisplayName(from || block.name),
724
+ to: requirementDisplayName(to || ''),
725
+ };
726
+ }
727
+
728
+ function countRequirementScenarios(raw) {
729
+ return (String(raw || '').match(/^####\s+Scenario:\s*.+$/gim) || []).length;
730
+ }
731
+
732
+ function requirementTextBeforeScenarios(raw) {
733
+ const lines = String(raw || '').split('\n').slice(1);
734
+ const scenarioIndex = lines.findIndex((line) => /^####\s+Scenario:/i.test(line.trim()));
735
+ const requirementLines = scenarioIndex === -1 ? lines : lines.slice(0, scenarioIndex);
736
+ return requirementLines.map((line) => line.trim()).filter(Boolean).join(' ');
737
+ }
738
+
739
+ function parseRequirementDelta(text) {
740
+ const sections = splitDeltaSections(text);
741
+ const added = parseRequirementBlocks(sections.get('ADDED') || '');
742
+ const modified = parseRequirementBlocks(sections.get('MODIFIED') || '');
743
+ const removed = parseRequirementBlocks(sections.get('REMOVED') || '').map((block) => block.name);
744
+ const renamed = parseRequirementBlocks(sections.get('RENAMED') || '').map(parseRenamedRequirement);
745
+ return { added, modified, removed, renamed };
746
+ }
747
+
748
+ function deltaOperationCount(delta) {
749
+ return delta.added.length + delta.modified.length + delta.removed.length + delta.renamed.length;
750
+ }
751
+
752
+ function validateRequirementDelta(text) {
753
+ const delta = parseRequirementDelta(text);
754
+ const blockers = [];
755
+ if (deltaOperationCount(delta) === 0) {
756
+ blockers.push('spec_delta_missing_requirement_operations');
757
+ return { delta, blockers };
758
+ }
759
+ const seenBySection = {
760
+ added: new Set(),
761
+ modified: new Set(),
762
+ removed: new Set(),
763
+ renamedFrom: new Set(),
764
+ renamedTo: new Set(),
765
+ };
766
+ for (const [section, blocks] of [['added', delta.added], ['modified', delta.modified]]) {
767
+ for (const block of blocks) {
768
+ const key = normalizeRequirementName(block.name);
769
+ if (seenBySection[section].has(key)) {
770
+ blockers.push(`spec_delta_duplicate_${section}_${key}`);
771
+ }
772
+ seenBySection[section].add(key);
773
+ const requirementText = requirementTextBeforeScenarios(block.raw);
774
+ if (!requirementText) {
775
+ blockers.push(`spec_delta_${section}_${key}_missing_text`);
776
+ }
777
+ if (!/\b(SHALL|MUST)\b/i.test(requirementText)) {
778
+ blockers.push(`spec_delta_${section}_${key}_missing_shall_must`);
779
+ }
780
+ if (countRequirementScenarios(block.raw) === 0) {
781
+ blockers.push(`spec_delta_${section}_${key}_missing_scenario`);
782
+ }
592
783
  }
593
- entries.set(domain, {
594
- added: bulletsFromSectionText(domainText, 'Added Requirements').filter((item) => item !== 'none'),
595
- modified: bulletsFromSectionText(domainText, 'Modified Requirements').filter((item) => item !== 'none'),
596
- removed: bulletsFromSectionText(domainText, 'Removed Requirements').filter((item) => item !== 'none'),
597
- scenarios: bulletsFromSectionText(domainText, 'Scenarios'),
598
- });
599
784
  }
600
- return entries;
785
+ for (const name of delta.removed) {
786
+ const key = normalizeRequirementName(name);
787
+ if (seenBySection.removed.has(key)) {
788
+ blockers.push(`spec_delta_duplicate_removed_${key}`);
789
+ }
790
+ seenBySection.removed.add(key);
791
+ }
792
+ for (const item of delta.renamed) {
793
+ const from = normalizeRequirementName(item.from);
794
+ const to = normalizeRequirementName(item.to);
795
+ if (!from || !to) {
796
+ blockers.push('spec_delta_renamed_missing_from_or_to');
797
+ }
798
+ if (seenBySection.renamedFrom.has(from)) {
799
+ blockers.push(`spec_delta_duplicate_renamed_from_${from}`);
800
+ }
801
+ if (seenBySection.renamedTo.has(to)) {
802
+ blockers.push(`spec_delta_duplicate_renamed_to_${to}`);
803
+ }
804
+ seenBySection.renamedFrom.add(from);
805
+ seenBySection.renamedTo.add(to);
806
+ }
807
+ for (const name of seenBySection.added) {
808
+ if (seenBySection.modified.has(name)) {
809
+ blockers.push(`spec_delta_conflict_added_modified_${name}`);
810
+ }
811
+ if (seenBySection.removed.has(name)) {
812
+ blockers.push(`spec_delta_conflict_added_removed_${name}`);
813
+ }
814
+ }
815
+ for (const name of seenBySection.modified) {
816
+ if (seenBySection.removed.has(name)) {
817
+ blockers.push(`spec_delta_conflict_modified_removed_${name}`);
818
+ }
819
+ if (seenBySection.renamedFrom.has(name)) {
820
+ blockers.push(`spec_delta_conflict_modified_renamed_from_${name}`);
821
+ }
822
+ }
823
+ return { delta, blockers: dedupeStrings(blockers) };
601
824
  }
602
825
 
603
826
  function requirementsForDelta(slug, plannerDraft) {
@@ -724,29 +947,18 @@ async function writeChangeArtifacts(cwd, root, slug, sourceText, plannerDraft, c
724
947
  ].join('\n'));
725
948
 
726
949
  await writeText(paths.specDelta, [
727
- `# loopx Spec Delta: ${normalizedChangeId}`,
728
- '',
729
- '## Target Spec Domains',
730
- '',
731
- ...domains.map((domain) => `- ${domain}`),
732
- '',
733
- '## Added Requirements',
734
- '',
735
- ...requirements.map((item) => `- ${item}`),
736
- '',
737
- '## Modified Requirements',
950
+ '---',
951
+ `change_id: ${normalizedChangeId}`,
952
+ `slug: ${slug}`,
953
+ 'target_domains:',
954
+ ...domains.map((domain) => ` - ${domain}`),
955
+ '---',
738
956
  '',
739
- '- none',
740
- '',
741
- '## Removed Requirements',
742
- '',
743
- '- none',
957
+ `# loopx Spec Delta: ${normalizedChangeId}`,
744
958
  '',
745
- '## Scenarios',
959
+ '## ADDED Requirements',
746
960
  '',
747
- `- GIVEN workflow ${slug} has an approved plan`,
748
- '- WHEN build and review complete successfully',
749
- '- THEN the accepted behavior is merged into long-lived loopx specs during archive',
961
+ ...requirements.flatMap((item, index) => [requirementBlockFromText({ slug, text: item, index }), '']),
750
962
  ].join('\n'));
751
963
 
752
964
  await writeText(paths.design, [
@@ -805,26 +1017,43 @@ async function readChangeArtifactStatus(paths) {
805
1017
  let specDeltaStatus = 'missing';
806
1018
  if (paths.specDelta && existsSync(paths.specDelta)) {
807
1019
  const text = await readFile(paths.specDelta, 'utf8');
808
- const parsedDelta = parseSpecDelta(text);
809
- const domainDeltas = Array.from(parsedDelta.domainDeltas?.values() || []);
810
- const hasDomains = parsedDelta.domains.length > 0;
811
- const hasRequirements = parsedDelta.added.length > 0
812
- || parsedDelta.modified.length > 0
813
- || domainDeltas.some((delta) => delta.added.length > 0 || delta.modified.length > 0);
1020
+ const parsedDelta = validateRequirementDelta(text);
1021
+ const declaredDomains = declaredTargetDomainsForDelta(text);
1022
+ const hasDomains = declaredDomains.length > 0;
814
1023
  if (!text.trim()) {
815
1024
  specDeltaStatus = 'partial';
816
1025
  blockers.push('spec_delta_empty');
817
- } else if (!hasDomains || !hasRequirements) {
1026
+ } else if (!hasDomains || parsedDelta.blockers.length > 0) {
818
1027
  specDeltaStatus = 'partial';
819
1028
  if (!hasDomains) {
820
1029
  blockers.push('spec_delta_missing_domains');
821
1030
  }
822
- if (!hasRequirements) {
823
- blockers.push('spec_delta_missing_requirements');
824
- }
1031
+ blockers.push(...parsedDelta.blockers);
825
1032
  } else {
826
1033
  specDeltaStatus = 'complete';
827
1034
  }
1035
+ const specsRoot = paths.root ? join(paths.root, 'specs') : null;
1036
+ if (specsRoot && existsSync(specsRoot)) {
1037
+ const entries = await readdir(specsRoot, { withFileTypes: true });
1038
+ const declaredDomainSet = new Set(declaredDomains);
1039
+ for (const entry of entries) {
1040
+ if (!entry.isDirectory() || declaredDomainSet.has(entry.name)) {
1041
+ continue;
1042
+ }
1043
+ const candidate = join(specsRoot, entry.name, 'spec.md');
1044
+ if (!existsSync(candidate)) {
1045
+ continue;
1046
+ }
1047
+ const domainDelta = await readFile(candidate, 'utf8');
1048
+ const validation = validateRequirementDelta(domainDelta);
1049
+ if (validation.blockers.length > 0) {
1050
+ specDeltaStatus = 'partial';
1051
+ blockers.push(
1052
+ ...validation.blockers.map((blocker) => `spec_delta_${entry.name}_${blocker.replace(/^spec_delta_/, '')}`),
1053
+ );
1054
+ }
1055
+ }
1056
+ }
828
1057
  }
829
1058
  if (paths.slices && existsSync(paths.slices)) {
830
1059
  try {
@@ -891,14 +1120,10 @@ async function ensureArchiveSlicesArtifact(cwd, root, slug, state) {
891
1120
  }
892
1121
 
893
1122
  function parseSpecDelta(text) {
894
- const domainDeltas = parseLegacyDomainDeltas(text);
1123
+ const parsed = parseRequirementDelta(text);
895
1124
  return {
896
- domains: targetDomainsForChange('general', text),
897
- added: bulletsFromSectionText(text, 'Added Requirements').filter((item) => item !== 'none'),
898
- modified: bulletsFromSectionText(text, 'Modified Requirements').filter((item) => item !== 'none'),
899
- removed: bulletsFromSectionText(text, 'Removed Requirements').filter((item) => item !== 'none'),
900
- scenarios: bulletsFromSectionText(text, 'Scenarios'),
901
- domainDeltas,
1125
+ domains: declaredTargetDomainsForDelta(text),
1126
+ ...parsed,
902
1127
  };
903
1128
  }
904
1129
 
@@ -942,28 +1167,146 @@ async function writeAdrCandidate(cwd, changeId, state, archivedSpecPaths) {
942
1167
  return path;
943
1168
  }
944
1169
 
945
- function replaceChangeBlock(existing, slug, nextBlock) {
946
- if (!existing) {
947
- return nextBlock;
1170
+ function splitSpecRequirements(existing) {
1171
+ const text = String(existing || '');
1172
+ const match = text.match(/^##\s+Requirements\s*$/im);
1173
+ if (!match) {
1174
+ return {
1175
+ before: text.trimEnd(),
1176
+ header: '## Requirements',
1177
+ body: '',
1178
+ after: '',
1179
+ };
948
1180
  }
949
- const marker = `### Change: ${slug}`;
950
- const start = existing.indexOf(marker);
951
- if (start === -1) {
952
- return [existing.replace(/\s+$/, ''), '', nextBlock].join('\n');
1181
+ const headerStart = match.index;
1182
+ const bodyStart = headerStart + match[0].length;
1183
+ const rest = text.slice(bodyStart);
1184
+ const nextTopHeading = rest.search(/\n##\s+/);
1185
+ const body = nextTopHeading === -1 ? rest : rest.slice(0, nextTopHeading);
1186
+ const after = nextTopHeading === -1 ? '' : rest.slice(nextTopHeading);
1187
+ return {
1188
+ before: text.slice(0, headerStart).trimEnd(),
1189
+ header: match[0],
1190
+ body: body.trim(),
1191
+ after: after.trimEnd(),
1192
+ };
1193
+ }
1194
+
1195
+ function requirementMapFromSpec(existing) {
1196
+ const parts = splitSpecRequirements(existing);
1197
+ const blocks = parseRequirementBlocks(parts.body);
1198
+ const map = new Map();
1199
+ const order = [];
1200
+ for (const block of blocks) {
1201
+ const key = normalizeRequirementName(block.name);
1202
+ if (!map.has(key)) {
1203
+ order.push(key);
1204
+ }
1205
+ map.set(key, block);
953
1206
  }
954
- const before = existing.slice(0, start).replace(/\s+$/, '');
955
- const rest = existing.slice(start);
956
- const nextStart = rest.slice(marker.length).search(/\n### Change: /);
957
- const after = nextStart === -1 ? '' : rest.slice(marker.length + nextStart).replace(/^\s+/, '\n');
958
- return [before, nextBlock, after.trimEnd()].filter(Boolean).join('\n\n');
1207
+ return { parts, map, order };
1208
+ }
1209
+
1210
+ function applyRequirementDelta(existing, delta, domain) {
1211
+ const { parts, map, order } = requirementMapFromSpec(existing);
1212
+ const ensureExisting = (key, label, name) => {
1213
+ if (!map.has(key)) {
1214
+ throw new Error(`${domain} ${label} failed for "### Requirement: ${name}" - not found`);
1215
+ }
1216
+ };
1217
+
1218
+ for (const item of delta.renamed) {
1219
+ const fromKey = normalizeRequirementName(item.from);
1220
+ const toKey = normalizeRequirementName(item.to);
1221
+ ensureExisting(fromKey, 'RENAMED', item.from);
1222
+ if (map.has(toKey)) {
1223
+ throw new Error(`${domain} RENAMED failed for "### Requirement: ${item.to}" - target already exists`);
1224
+ }
1225
+ const block = map.get(fromKey);
1226
+ const rawLines = block.raw.split('\n');
1227
+ rawLines[0] = `### Requirement: ${item.to}`;
1228
+ map.delete(fromKey);
1229
+ map.set(toKey, { name: item.to, raw: rawLines.join('\n') });
1230
+ const orderIndex = order.indexOf(fromKey);
1231
+ if (orderIndex !== -1) {
1232
+ order[orderIndex] = toKey;
1233
+ }
1234
+ }
1235
+
1236
+ for (const name of delta.removed) {
1237
+ const key = normalizeRequirementName(name);
1238
+ ensureExisting(key, 'REMOVED', name);
1239
+ map.delete(key);
1240
+ const orderIndex = order.indexOf(key);
1241
+ if (orderIndex !== -1) {
1242
+ order.splice(orderIndex, 1);
1243
+ }
1244
+ }
1245
+
1246
+ for (const block of delta.modified) {
1247
+ const key = normalizeRequirementName(block.name);
1248
+ ensureExisting(key, 'MODIFIED', block.name);
1249
+ map.set(key, block);
1250
+ }
1251
+
1252
+ for (const block of delta.added) {
1253
+ const key = normalizeRequirementName(block.name);
1254
+ if (map.has(key)) {
1255
+ if (map.get(key).raw.trim() === block.raw.trim()) {
1256
+ continue;
1257
+ }
1258
+ throw new Error(`${domain} ADDED failed for "### Requirement: ${block.name}" - already exists`);
1259
+ }
1260
+ map.set(key, block);
1261
+ order.push(key);
1262
+ }
1263
+
1264
+ const requirementBody = order.map((key) => map.get(key)?.raw).filter(Boolean).join('\n\n').trimEnd();
1265
+ return [
1266
+ parts.before,
1267
+ parts.header,
1268
+ requirementBody,
1269
+ parts.after,
1270
+ ].filter((part) => String(part || '').trim()).join('\n\n').replace(/\n{3,}/g, '\n\n');
1271
+ }
1272
+
1273
+ async function specDeltaFilesForArchive(cwd, specDeltaPath) {
1274
+ const changeRoot = dirname(specDeltaPath);
1275
+ const mainText = await readFile(specDeltaPath, 'utf8');
1276
+ const files = new Map();
1277
+ const declaredDomains = declaredTargetDomainsForDelta(mainText);
1278
+ if (declaredDomains.length === 0) {
1279
+ throw new Error('archive_blocked:spec_delta_missing_domains');
1280
+ }
1281
+ for (const domain of declaredDomains) {
1282
+ files.set(domain, specDeltaPath);
1283
+ }
1284
+ const specsRoot = join(changeRoot, 'specs');
1285
+ if (existsSync(specsRoot)) {
1286
+ const entries = await readdir(specsRoot, { withFileTypes: true });
1287
+ for (const entry of entries) {
1288
+ if (!entry.isDirectory()) {
1289
+ continue;
1290
+ }
1291
+ const candidate = join(specsRoot, entry.name, 'spec.md');
1292
+ if (existsSync(candidate) && !files.has(entry.name)) {
1293
+ files.set(entry.name, candidate);
1294
+ }
1295
+ }
1296
+ }
1297
+ return files;
959
1298
  }
960
1299
 
961
1300
  async function mergeSpecDeltaIntoLongLivedSpecs(cwd, slug, specDeltaPath) {
962
- const deltaText = await readFile(specDeltaPath, 'utf8');
963
- const delta = parseSpecDelta(deltaText);
1301
+ const deltaFiles = await specDeltaFilesForArchive(cwd, specDeltaPath);
964
1302
  const updated = [];
965
- for (const domain of delta.domains) {
966
- const domainDelta = delta.domainDeltas?.get(domain) || delta;
1303
+ for (const [domain, deltaPath] of deltaFiles.entries()) {
1304
+ const deltaText = await readFile(deltaPath, 'utf8');
1305
+ const validation = validateRequirementDelta(deltaText);
1306
+ if (validation.blockers.length > 0) {
1307
+ throw new Error(`archive_blocked:${domain}:${validation.blockers.join(',')}`);
1308
+ }
1309
+ const domainDelta = parseSpecDelta(deltaText);
967
1310
  const path = specDomainPath(cwd, domain);
968
1311
  await ensureDir(dirname(path));
969
1312
  const existing = await readTextIfExists(path);
@@ -976,15 +1319,7 @@ async function mergeSpecDeltaIntoLongLivedSpecs(cwd, slug, specDeltaPath) {
976
1319
  '',
977
1320
  '## Requirements',
978
1321
  ].join('\n');
979
- const changeBlock = [
980
- `### Change: ${slug}`,
981
- '',
982
- ...(domainDelta.added.length > 0 ? ['#### Added Requirements', '', ...domainDelta.added.map((item) => `- ${item}`), ''] : []),
983
- ...(domainDelta.modified.length > 0 ? ['#### Modified Requirements', '', ...domainDelta.modified.map((item) => `- ${item}`), ''] : []),
984
- ...(domainDelta.removed.length > 0 ? ['#### Removed Requirements', '', ...domainDelta.removed.map((item) => `- ${item}`), ''] : []),
985
- ...(domainDelta.scenarios.length > 0 ? ['#### Scenarios', '', ...domainDelta.scenarios.map((item) => `- ${item}`)] : []),
986
- ].join('\n');
987
- const next = replaceChangeBlock(base, slug, changeBlock);
1322
+ const next = applyRequirementDelta(base, domainDelta, domain);
988
1323
  await writeText(path, next);
989
1324
  updated.push(path);
990
1325
  }
@@ -1109,6 +1444,34 @@ async function writePlanReviewArtifacts(root, iteration, plannerDraft, architect
1109
1444
  return paths;
1110
1445
  }
1111
1446
 
1447
+ function planReviewSummary(iteration, architectReview, criticReview) {
1448
+ return {
1449
+ iteration,
1450
+ architectReview: {
1451
+ status: architectReview.status,
1452
+ verdict: architectReview.verdict,
1453
+ findings: Array.isArray(architectReview.findings) ? architectReview.findings : [],
1454
+ strongestObjection: architectReview.strongestObjection || null,
1455
+ tradeoffTension: architectReview.tradeoffTension || null,
1456
+ },
1457
+ criticReview: {
1458
+ verdict: criticReview.verdict,
1459
+ findings: Array.isArray(criticReview.findings) ? criticReview.findings : [],
1460
+ acceptanceCriteriaTestable: Boolean(criticReview.acceptanceCriteriaTestable),
1461
+ verificationStepsResolved: Boolean(criticReview.verificationStepsResolved),
1462
+ executionInputsResolved: Boolean(criticReview.executionInputsResolved),
1463
+ },
1464
+ };
1465
+ }
1466
+
1467
+ function initialPlanReviewHistory(state) {
1468
+ const history = Array.isArray(state.plan_review_history) ? state.plan_review_history : [];
1469
+ if (state.current_stage !== STAGES.PLAN || state.stage_status !== 'blocked' || history.length === 0) {
1470
+ return [];
1471
+ }
1472
+ return [history[history.length - 1]];
1473
+ }
1474
+
1112
1475
  async function readPlanCompletion(cwd, root, slug, state) {
1113
1476
  const blockers = [];
1114
1477
  if (state.plan_architect_review_status !== 'complete') {
@@ -1260,6 +1623,10 @@ async function readJsonIfExists(path) {
1260
1623
 
1261
1624
  async function buildCompletionAudit({ cwd, root, slug, state, reviewReworkArtifactPath = null, iterationData, ledger, baseBlockers }) {
1262
1625
  const checklist = [];
1626
+ const iterationEvidence = [
1627
+ ...(iterationData.executionEvidence || []),
1628
+ ...(iterationData.verificationEvidence || []),
1629
+ ].filter(Boolean).map(String);
1263
1630
  const addChecklistItem = (item) => {
1264
1631
  checklist.push({
1265
1632
  status: 'covered',
@@ -1297,15 +1664,17 @@ async function buildCompletionAudit({ cwd, root, slug, state, reviewReworkArtifa
1297
1664
  const slicesPayload = await readJsonIfExists(state.change_artifact_paths?.slices);
1298
1665
  const slices = Array.isArray(slicesPayload?.slices) ? slicesPayload.slices : [];
1299
1666
  for (const slice of slices) {
1667
+ const signal = String(slice.verification_signal || '').trim();
1668
+ const usesLegacyGenericSignal = signal === 'execution-record.md verification evidence';
1669
+ const sliceEvidence = usesLegacyGenericSignal
1670
+ ? iterationEvidence
1671
+ : iterationEvidence.filter((item) => item.includes(signal));
1300
1672
  addChecklistItem({
1301
1673
  id: slice.id || `slice-${checklist.length + 1}`,
1302
1674
  source: 'vertical-slice',
1303
- requirement: slice.behavior || slice.verification_signal || 'vertical slice',
1304
- evidence: [
1305
- slice.verification_signal,
1306
- ...(iterationData.executionEvidence || []),
1307
- ...(iterationData.verificationEvidence || []),
1308
- ].filter(Boolean),
1675
+ status: sliceEvidence.length > 0 ? 'covered' : 'missing-evidence',
1676
+ requirement: slice.behavior || signal || 'vertical slice',
1677
+ evidence: sliceEvidence,
1309
1678
  });
1310
1679
  }
1311
1680
 
@@ -1362,6 +1731,7 @@ function buildExecutionRecordContent({ slug, iterationData, complete }) {
1362
1731
  completed_at: nowIso(),
1363
1732
  checkpoint_count: iterationData.lanes.length,
1364
1733
  evidence_manifest: iterationData.lanes.flatMap((lane) => lane.evidence || []),
1734
+ changed_files: iterationData.changedFiles || [],
1365
1735
  }),
1366
1736
  `# loopx Execution Record: ${slug}`,
1367
1737
  '',
@@ -2140,8 +2510,10 @@ async function refreshExecutionStatus(root, state) {
2140
2510
 
2141
2511
  export async function initWorkspace(cwd, { slug } = {}) {
2142
2512
  const workspaceRoot = resolveWorkspaceRoot(cwd);
2513
+ const projectConventions = await inspectProjectConventions(cwd);
2143
2514
  await ensureLoopxRoot(cwd);
2144
2515
  await ensureDir(join(workspaceRoot, 'context'));
2516
+ await ensureDir(join(workspaceRoot, 'intake'));
2145
2517
  await ensureDir(join(workspaceRoot, 'workflows'));
2146
2518
  await ensureDir(join(workspaceRoot, 'specs'));
2147
2519
  await ensureDir(join(workspaceRoot, 'changes'));
@@ -2157,6 +2529,12 @@ export async function initWorkspace(cwd, { slug } = {}) {
2157
2529
  product_contract: 'skill-first-v1',
2158
2530
  default_flow: ['clarify', 'plan', 'build', 'review', 'done', 'archive'],
2159
2531
  preferred_surface: ['clarify', 'plan', 'build', 'review', 'archive', 'autopilot'],
2532
+ source_of_truth_policy: projectConventions.source_of_truth_policy,
2533
+ project_conventions: {
2534
+ existing_ai_rules: projectConventions.existing_ai_rules,
2535
+ existing_spec_sources: projectConventions.existing_spec_sources,
2536
+ },
2537
+ verification_commands: projectConventions.verification_commands,
2160
2538
  };
2161
2539
 
2162
2540
  if (!existsSync(workspaceConfigPath(workspaceRoot))) {
@@ -2497,13 +2875,17 @@ export async function planStage(cwd, slug, options = {}) {
2497
2875
  const resumesConsumedReviewPlan = state.current_stage === STAGES.PLAN
2498
2876
  && state.last_confirmed_transition === TRANSITIONS.REVIEW_TO_PLAN
2499
2877
  && state.approval.rollback === APPROVAL_STATES.APPROVED;
2878
+ const resumesClarifyPlan = state.current_stage === STAGES.PLAN
2879
+ && state.stage_status === 'blocked'
2880
+ && state.last_confirmed_transition === TRANSITIONS.CLARIFY_TO_PLAN
2881
+ && state.approval.plan === APPROVAL_STATES.APPROVED;
2500
2882
  if (!options.directSpecPath) {
2501
- if (consumesReviewPlan || resumesConsumedReviewPlan) {
2502
- // A no-go review may route back to plan; the printed Next command is $plan.
2883
+ if (consumesReviewPlan || resumesConsumedReviewPlan || resumesClarifyPlan) {
2884
+ // A no-go review or a blocked planning run may route back to plan; the printed Next command is $plan.
2503
2885
  } else {
2504
2886
  ensureApprovedTransition(state, TRANSITIONS.CLARIFY_TO_PLAN, 'plan');
2505
2887
  }
2506
- if (!consumesReviewPlan && !resumesConsumedReviewPlan && state.spec_artifact_path) {
2888
+ if (!consumesReviewPlan && !resumesConsumedReviewPlan && !resumesClarifyPlan && state.spec_artifact_path) {
2507
2889
  await copyArtifact(root, state.spec_artifact_path, 'spec.md');
2508
2890
  }
2509
2891
  }
@@ -2516,6 +2898,7 @@ export async function planStage(cwd, slug, options = {}) {
2516
2898
  let architectReview = null;
2517
2899
  let criticReview = null;
2518
2900
  const reviewArtifactPaths = [];
2901
+ const reviewHistory = initialPlanReviewHistory(state);
2519
2902
 
2520
2903
  while (iteration <= maxIterations) {
2521
2904
  const plannerDraft = await adapter.planner({
@@ -2524,6 +2907,7 @@ export async function planStage(cwd, slug, options = {}) {
2524
2907
  slug: normalized,
2525
2908
  sourceText,
2526
2909
  iteration,
2910
+ reviewHistory: [...reviewHistory],
2527
2911
  deliberateMode: Boolean(options.deliberate),
2528
2912
  interactiveMode: Boolean(options.interactive),
2529
2913
  });
@@ -2554,6 +2938,7 @@ export async function planStage(cwd, slug, options = {}) {
2554
2938
  });
2555
2939
  const reviewPaths = await writePlanReviewArtifacts(root, iteration, plannerDraft, architectReview, criticReview);
2556
2940
  reviewArtifactPaths.push(reviewPaths);
2941
+ reviewHistory.push(planReviewSummary(iteration, architectReview, criticReview));
2557
2942
 
2558
2943
  state = {
2559
2944
  ...state,
@@ -2573,6 +2958,7 @@ export async function planStage(cwd, slug, options = {}) {
2573
2958
  plan_package_status: 'complete',
2574
2959
  plan_docs_artifact_paths: null,
2575
2960
  plan_review_artifact_paths: reviewArtifactPaths,
2961
+ plan_review_history: reviewHistory,
2576
2962
  plan_artifact_path: artifactPaths.planPath,
2577
2963
  test_spec_artifact_path: artifactPaths.testSpecPath,
2578
2964
  change_id: normalizeSlug(changeId),
@@ -2662,6 +3048,7 @@ export async function buildStage(cwd, slug, options = {}) {
2662
3048
  const ownerId = buildOwnerId(normalized);
2663
3049
  let iteration = 1;
2664
3050
  let current = null;
3051
+ let accumulatedChangedFiles = [];
2665
3052
  let blockers = ['build_not_started'];
2666
3053
  let delegationLedger = null;
2667
3054
  let completionAudit = null;
@@ -2736,6 +3123,14 @@ export async function buildStage(cwd, slug, options = {}) {
2736
3123
  contextManifestRows: buildManifest.rows,
2737
3124
  contextManifestStatus,
2738
3125
  });
3126
+ accumulatedChangedFiles = dedupeStrings([
3127
+ ...accumulatedChangedFiles,
3128
+ ...(Array.isArray(current.changedFiles) ? current.changedFiles : []),
3129
+ ]);
3130
+ current = {
3131
+ ...current,
3132
+ changedFiles: accumulatedChangedFiles,
3133
+ };
2739
3134
  const supportPaths = resolveBuildSupportPaths(root, current.iteration);
2740
3135
  delegationLedgerPath = supportPaths.delegationLedger;
2741
3136
  completionAuditPath = supportPaths.completionAudit;
@@ -2903,14 +3298,14 @@ function reviewFindings({ executionMeta, executionStatus, reviewer, codeReview,
2903
3298
  if (executionStatus !== 'complete') {
2904
3299
  findings.push('execution-record.md 缺少必要的执行或验证证据。');
2905
3300
  verdict = 'REQUEST CHANGES';
2906
- rollbackTarget = 'plan';
2907
- rollbackRationale = '执行证据不完整,工作流需要回退到计划阶段后再重新执行。';
3301
+ rollbackTarget = STAGES.BUILD;
3302
+ rollbackRationale = '执行证据不完整,工作流需要回到 build 阶段补齐执行和验证证据后重新 review。';
2908
3303
  }
2909
3304
  if (!Array.isArray(executionMeta.evidence_manifest) || executionMeta.evidence_manifest.length === 0) {
2910
3305
  findings.push('execution-record.md 缺少必需的 evidence_manifest 结构。');
2911
3306
  verdict = 'REQUEST CHANGES';
2912
- rollbackTarget = 'plan';
2913
- rollbackRationale = '执行证据结构不完整,review 不能接受本次运行。';
3307
+ rollbackTarget = STAGES.BUILD;
3308
+ rollbackRationale = '执行证据结构不完整,review 不能接受本次运行,需要回到 build 阶段补齐 evidence_manifest。';
2914
3309
  }
2915
3310
  if (executionMeta.actor_id === reviewer) {
2916
3311
  findings.push('Reviewer 来源与执行者一致,不满足独立审查要求。');
@@ -3133,6 +3528,12 @@ export async function reviewStage(cwd, slug, { reviewer = 'independent-reviewer'
3133
3528
  ensureApprovedTransition(state, TRANSITIONS.BUILD_TO_REVIEW, 'review');
3134
3529
  }
3135
3530
  const { state: refreshed, executionSummary } = await refreshExecutionStatus(root, state);
3531
+ const buildOwnedChangedFilesStatus = Object.hasOwn(executionSummary.meta, 'changed_files')
3532
+ ? 'present'
3533
+ : 'unavailable';
3534
+ const buildOwnedChangedFiles = buildOwnedChangedFilesStatus === 'present' && Array.isArray(executionSummary.meta.changed_files)
3535
+ ? executionSummary.meta.changed_files
3536
+ : [];
3136
3537
  const reviewManifest = await readContextManifest(reviewContextManifestPath(root), { cwd });
3137
3538
  ensureValidContextManifest(reviewManifest, STAGES.REVIEW);
3138
3539
  const reviewAdapter = adapter || createDefaultReviewAdapter();
@@ -3146,9 +3547,11 @@ export async function reviewStage(cwd, slug, { reviewer = 'independent-reviewer'
3146
3547
  executionRecordPath: artifactPath(root, 'execution-record.md'),
3147
3548
  planArtifactPath: refreshed.plan_artifact_path,
3148
3549
  testSpecArtifactPath: refreshed.test_spec_artifact_path,
3550
+ buildOwnedChangedFiles,
3149
3551
  contextManifestStatus: reviewManifest.status,
3150
3552
  contextManifestPath: reviewContextManifestPath(root),
3151
3553
  contextManifestRows: reviewManifest.rows,
3554
+ buildOwnedChangedFilesStatus,
3152
3555
  });
3153
3556
  } catch (error) {
3154
3557
  codeReview = codeReviewFailureResult(error);
@@ -3168,9 +3571,11 @@ export async function reviewStage(cwd, slug, { reviewer = 'independent-reviewer'
3168
3571
  planArtifactPath: refreshed.plan_artifact_path,
3169
3572
  testSpecArtifactPath: refreshed.test_spec_artifact_path,
3170
3573
  changeArtifactPaths: refreshed.change_artifact_paths,
3574
+ buildOwnedChangedFiles,
3171
3575
  contextManifestStatus: reviewManifest.status,
3172
3576
  contextManifestPath: reviewContextManifestPath(root),
3173
3577
  contextManifestRows: reviewManifest.rows,
3578
+ buildOwnedChangedFilesStatus,
3174
3579
  });
3175
3580
  } catch (error) {
3176
3581
  architectureReview = architectureReviewFailureResult(error);