@ai-content-space/loopx 0.1.3 → 0.1.4

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