genesis-compiler 1.8.6 → 1.9.0

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/README.md CHANGED
@@ -860,8 +860,10 @@ genesis check
860
860
  - Blueprint, engineering approach, and Stack validity;
861
861
  - selected Agent Skill presence and structural validity;
862
862
  - Program presence and structural validity;
863
- - environment declarations and consumer-owned Stack extensions as `ready`,
864
- `blocked`, or honestly `unconfigured`;
863
+ - environment declarations as `ready`, `missing-inputs`, or honestly
864
+ `unconfigured`, and consumer-owned Stack extensions as `ready` or
865
+ `unconfigured` (an undecided competing section makes the Stack invalid with
866
+ `STACK_SECTION_AMBIGUOUS` until the project authors it);
865
867
  - missing Stack-declared environment inputs (without claiming the service is
866
868
  reachable);
867
869
  - verification evidence as `current`, `stale`, `missing`, `invalid`, or
@@ -157,9 +157,20 @@ validate a private schema, map runtimes, execute commands, or prove success.
157
157
  Proposal composition is intentionally small:
158
158
 
159
159
  1. An existing project section remains authoritative and is never overwritten.
160
- 2. Without a project section, one component proposal is copied unchanged.
161
- 3. Competing component proposals produce `STACK_SECTION_AMBIGUOUS`; Genesis
162
- never merges their bodies or guesses a winner.
160
+ 2. A component's proposal supersedes the proposals of selected components it
161
+ requires, directly or transitively. Laravel's proposal therefore replaces
162
+ the PHP and Node proposals for the same heading.
163
+ 3. Without a project section, exactly one remaining proposal is copied
164
+ unchanged.
165
+ 4. Several remaining proposals from unrelated components are a project
166
+ decision. `genesis stack add` still succeeds, materializes every other
167
+ section, leaves that heading unwritten, and returns a
168
+ `STACK_SECTION_AMBIGUOUS` diagnostic whose details list each competing
169
+ source and its exact lines. Its preparation prompt quotes the proposals and
170
+ asks the agent to author one complete project section first. Current-format
171
+ reads, `genesis check`, inspection and context reject the Stack with the
172
+ same message until that section exists. Genesis never merges the bodies or
173
+ guesses a winner.
163
174
 
164
175
  After selection every effective opaque section is project-owned. The component
165
176
  catalog may improve later without silently changing the application's setup,
@@ -270,6 +281,9 @@ Project format 2 removes runtime inheritance of operation declarations from
270
281
  the catalog. `genesis migrate` upgrades format 1 projects by composing the
271
282
  previously effective declarations once and writing them into
272
283
  `genesis/stack.md`. Existing project sections win exactly as they did before.
284
+ Competing proposals that no requirement resolves do not fail the migration:
285
+ it completes, leaves that heading unwritten, and returns the same
286
+ `STACK_SECTION_AMBIGUOUS` diagnostic and decision prompt as `genesis stack add`.
273
287
  Current-format reads reject a selected component whose required contract was
274
288
  not materialized, so a tool upgrade cannot quietly substitute a new proposal.
275
289
 
@@ -279,10 +293,16 @@ existing `genesis stack add` operation for an already-selected component. For
279
293
  example, if `nodejs` is selected and the missing heading is
280
294
  `## Connected development`, run `genesis stack add nodejs` and review its
281
295
  preparation prompt. This adds the Node `npm` default without choosing connected
282
- repositories or installing dependencies. Existing complete project sections
283
- remain authoritative; reconcile any proposed operation against the real source.
284
- Use the project's pinned compiler invocation when it declares `genesis-compiler`:
285
- `npm exec --no -- genesis stack add nodejs`.
296
+ repositories or installing dependencies. Every package-ecosystem language piece
297
+ proposes its own default the same way (PHP `composer`, Python `pip`, Ruby
298
+ `bundler`, Go `gomod`, Rust `cargo`, Java `maven`, Kotlin `gradle`, C#
299
+ `nuget`); Laravel proposes `composer`, superseding the PHP and Node proposals it
300
+ requires. C/C++ and shell propose none. When several unrelated languages are
301
+ selected, the add reports the competing defaults and the project authors one
302
+ `## Connected development` section choosing its default. Existing complete
303
+ project sections remain authoritative; reconcile any proposed operation against
304
+ the real source. Use the project's pinned compiler invocation when it declares
305
+ `genesis-compiler`: `npm exec --no -- genesis stack add nodejs`.
286
306
 
287
307
  Before rolling out a catalog that adds operation proposals, operators should
288
308
  run read-only project inspection against representative existing projects with
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "genesis-compiler",
3
- "version": "1.8.6",
3
+ "version": "1.9.0",
4
4
  "type": "module",
5
5
  "description": "An agent-independent prompt, multi-language code-index, cleanup, and verification companion with project agent guidance.",
6
6
  "repository": {
@@ -124,12 +124,10 @@ export async function checkProject({
124
124
  projectRoot: root,
125
125
  stackPackages,
126
126
  });
127
- const extensionDiagnostics = stack.extensions.flatMap(({ diagnostics }) => diagnostics);
128
127
  const diagnostics = [
129
128
  ...(program.diagnostic ? [program.diagnostic] : []),
130
129
  ...skills.diagnostics,
131
130
  ...missingResources,
132
- ...extensionDiagnostics,
133
131
  ...(verification.status === 'invalid' ? [{
134
132
  code: 'VERIFICATION_EVIDENCE_INVALID',
135
133
  message: 'Saved verification evidence is malformed.',
@@ -145,7 +143,6 @@ export async function checkProject({
145
143
  if (missingResources.length > 0) {
146
144
  guidance.push(`${missingResources.map(({ message }) => message).join(' ')} Prompt generation remains available.`);
147
145
  }
148
- if (extensionDiagnostics.length > 0) guidance.push('Resolve the competing consumer-owned Stack sections.');
149
146
  if (['missing', 'stale'].includes(verification.status)) guidance.push('Run genesis verify to refresh concrete evidence.');
150
147
  if (verification.status === 'unconfigured') guidance.push('Add project verification commands to genesis/stack.md.');
151
148
  guidance.push('Use genesis prompt --task review for a semantic, evidence-based comparison.');
@@ -154,7 +151,6 @@ export async function checkProject({
154
151
  || engineering.status === 'defaulted'
155
152
  || ['missing', 'outdated'].includes(skills.status)
156
153
  || missingResources.length > 0
157
- || extensionDiagnostics.length > 0
158
154
  || ['missing', 'stale', 'unconfigured'].includes(verification.status);
159
155
 
160
156
  return {
@@ -169,9 +165,7 @@ export async function checkProject({
169
165
  skills: skills.status,
170
166
  program: program.status,
171
167
  environment: projectEnvironment.status,
172
- extensions: extensionDiagnostics.length > 0
173
- ? 'blocked'
174
- : stack.extensions.length > 0 ? 'ready' : 'unconfigured',
168
+ extensions: stack.extensions.length > 0 ? 'ready' : 'unconfigured',
175
169
  resources: missingResources.length > 0 ? 'missing' : 'inputs-present',
176
170
  verification: verification.status,
177
171
  programFiles: program.files,
@@ -253,6 +253,7 @@ function formatCount(value, singular) {
253
253
  }
254
254
 
255
255
  export async function buildProjectIndex({
256
+ allowUnresolvedSections = false,
256
257
  projectRoot,
257
258
  queries = [],
258
259
  stackPackages = [],
@@ -264,7 +265,7 @@ export async function buildProjectIndex({
264
265
  const observationReader = typeof observations === 'function' ? observations : null;
265
266
  if (observationReader) observations = null;
266
267
  const root = (await gitContext(projectRoot)).repositoryRoot;
267
- const stack = await readStack(root, { stackPackages });
268
+ const stack = await readStack(root, { allowUnresolvedSections, stackPackages });
268
269
  const packageRoots = stack.packageDiscovery.discoveries.flatMap(discovery => discovery.manifests.map(manifest => path.posix.join(path.posix.dirname(manifest), discovery.packageRoot)));
269
270
  const includePath = filePath => isProjectContentPath(filePath) && !packageRoots.some(directory => filePath === directory || filePath.startsWith(`${directory}/`));
270
271
  const states = await gitVisibleFileStates(root, { includePath });
@@ -322,7 +323,7 @@ export async function buildProjectIndex({
322
323
  // valid artifact after an inspector/parser read racing a source change.
323
324
  const finalStates = await gitVisibleFileStates(root, { includePath });
324
325
  const finalFiles = [...finalStates].filter(([filePath, state]) => state.exists && !state.symlink && !state.special && state.hash && !cityPathExcluded(filePath, stack.cityExclusions)).map(([filePath, state]) => ({ path: filePath, hash: state.hash, mode: state.mode }));
325
- const finalStack = await readStack(root, { stackPackages });
326
+ const finalStack = await readStack(root, { allowUnresolvedSections, stackPackages });
326
327
  const finalMap = await inspectSubsystems({ projectRoot: root }).catch(() => null);
327
328
  if (stableJson(finalFiles) !== stableJson(files) || finalStack.identityHash !== stack.identityHash || (finalMap?.identity || null) !== (subsystemMap?.identity || null)) {
328
329
  throw new GenesisError('ARCHITECTURE_SOURCE_CHANGED', 'Source or Stack changed during architecture analysis. The previous index is retained; refresh after the changes settle.');
package/src/index/init.js CHANGED
@@ -21,7 +21,7 @@ import {
21
21
  inspectProjectFormatAtRoot,
22
22
  requireCurrentProjectFormat,
23
23
  } from './project-format.js';
24
- import { EMPTY_STACK_SOURCE, readStack } from './stack.js';
24
+ import { EMPTY_STACK_SOURCE, readStack, stackDecisionResult } from './stack.js';
25
25
 
26
26
  async function createIfMissing(projectRoot, relativePath, source) {
27
27
  const location = path.join(projectRoot, relativePath);
@@ -35,7 +35,11 @@ async function createIfMissing(projectRoot, relativePath, source) {
35
35
  }
36
36
  }
37
37
 
38
- export async function initializeProject({ projectRoot, stackPackages = [] } = {}) {
38
+ export async function initializeProject({
39
+ allowUnresolvedSections = false,
40
+ projectRoot,
41
+ stackPackages = [],
42
+ } = {}) {
39
43
  const root = (await gitContext(projectRoot)).repositoryRoot;
40
44
  const projectFormat = await inspectProjectFormatAtRoot(root);
41
45
  requireCurrentProjectFormat(projectFormat, { allowUninitialized: true });
@@ -47,7 +51,8 @@ export async function initializeProject({ projectRoot, stackPackages = [] } = {}
47
51
  createIfMissing(root, STACK_PATH, EMPTY_STACK_SOURCE),
48
52
  ])).filter(Boolean);
49
53
  await mkdir(path.join(root, PROGRAM_ROOT), { recursive: true });
50
- const stack = await readStack(root, { stackPackages });
54
+ const stack = await readStack(root, { allowUnresolvedSections, stackPackages });
55
+ const decisions = stackDecisionResult(stack);
51
56
  const skills = await syncProjectSkills({ projectRoot: root, stack });
52
57
  const integrations = await syncAgentIntegrations({ projectRoot: root });
53
58
  const version = projectFormat.status === 'uninitialized'
@@ -67,7 +72,8 @@ export async function initializeProject({ projectRoot, stackPackages = [] } = {}
67
72
  ? 'Initialized Genesis, Agent Skills, and project guidance for Codex, Claude Code and OpenCode.'
68
73
  : 'Genesis, its Agent Skills, and project guidance for Codex, Claude Code and OpenCode are already initialized.',
69
74
  changedFiles,
70
- diagnostics: skills.diagnostics,
75
+ diagnostics: [...decisions.diagnostics, ...skills.diagnostics],
76
+ ...(decisions.prompt ? { prompt: decisions.prompt } : {}),
71
77
  ...(integrations.warnings ? { warnings: integrations.warnings } : {}),
72
78
  guidance: [
73
79
  'Open Codex and use /hooks to review and trust the project guidance hook.',
@@ -79,7 +79,13 @@ export async function migrateProject({ projectRoot, stackPackages = [] } = {}) {
79
79
  migratedFiles.push(PROJECT_VERSION_PATH);
80
80
  }
81
81
 
82
- const initialized = await initializeProject({ projectRoot: root, stackPackages });
82
+ // Like selection, migration completes and reports competing Stack sections
83
+ // as project decisions; ordinary reads keep rejecting until they are authored.
84
+ const initialized = await initializeProject({
85
+ allowUnresolvedSections: true,
86
+ projectRoot: root,
87
+ stackPackages,
88
+ });
83
89
  const changedFiles = uniqueSorted([...migratedFiles, ...initialized.changedFiles]);
84
90
  const migrated = initial.status !== 'current';
85
91
  return {
@@ -92,5 +98,6 @@ export async function migrateProject({ projectRoot, stackPackages = [] } = {}) {
92
98
  projectFormat: await inspectProjectFormatAtRoot(root),
93
99
  changedFiles,
94
100
  diagnostics: initialized.diagnostics,
101
+ ...(initialized.prompt ? { prompt: initialized.prompt } : {}),
95
102
  };
96
103
  }
@@ -112,10 +112,12 @@ function contractSections({ components, knownSectionNames, sections }) {
112
112
  components,
113
113
  opaqueStackSections(sections, knownSectionNames),
114
114
  );
115
+ const unresolvedSections = [];
115
116
  for (const extension of extensions) {
116
117
  const [diagnostic] = extension.diagnostics;
117
118
  if (diagnostic) {
118
- throw new GenesisError(diagnostic.code, diagnostic.message, diagnostic.details);
119
+ unresolvedSections.push({ name: extension.name, diagnostic });
120
+ continue;
119
121
  }
120
122
  projectLines.set(extension.name, trimStackSectionLines(extension.lines));
121
123
  if (!sections.has(extension.name)) materializedSections.push(extension.name);
@@ -130,15 +132,36 @@ function contractSections({ components, knownSectionNames, sections }) {
130
132
  materializedSections,
131
133
  projectContracts: [...projectLines].map(([name, lines]) => ({ name, lines })),
132
134
  resourceLines: projectLines.get('Resources') ?? null,
135
+ unresolvedSections,
133
136
  verificationLines: projectLines.get('Verification') ?? null,
134
137
  };
135
138
  }
136
139
 
140
+ function unresolvedSectionsError(unresolvedSections) {
141
+ if (unresolvedSections.length === 1) {
142
+ const [{ diagnostic }] = unresolvedSections;
143
+ return new GenesisError(diagnostic.code, diagnostic.message, diagnostic.details);
144
+ }
145
+ return new GenesisError(
146
+ 'STACK_SECTION_AMBIGUOUS',
147
+ unresolvedSections.map(({ diagnostic }) => diagnostic.message).join('\n\n'),
148
+ { sections: unresolvedSections.map(({ diagnostic }) => diagnostic.details) },
149
+ );
150
+ }
151
+
137
152
  export function materializeStackProjectContracts(options) {
138
153
  return contractSections(options);
139
154
  }
140
155
 
141
- export function requireMaterializedStackProjectContracts(options) {
156
+ /**
157
+ * Require every selected proposal to be project-owned. A competing proposal
158
+ * is a project decision; only the add operation that reports it may continue
159
+ * without it.
160
+ */
161
+ export function requireMaterializedStackProjectContracts({
162
+ allowUnresolvedSections = false,
163
+ ...options
164
+ }) {
142
165
  const result = contractSections(options);
143
166
  if (result.materializedSections.length > 0) {
144
167
  const headings = result.materializedSections.map((name) => `\`## ${name}\``);
@@ -148,11 +171,15 @@ export function requireMaterializedStackProjectContracts(options) {
148
171
  { sections: result.materializedSections },
149
172
  );
150
173
  }
174
+ if (!allowUnresolvedSections && result.unresolvedSections.length > 0) {
175
+ throw unresolvedSectionsError(result.unresolvedSections);
176
+ }
151
177
  return result;
152
178
  }
153
179
 
154
- function contractBlocks(projectContracts) {
180
+ function contractBlocks(projectContracts, hasDecisions) {
155
181
  if (projectContracts.length === 0) {
182
+ if (hasDecisions) return ['No other operation contract was proposed by these components.'];
156
183
  return [
157
184
  'No operation contract was proposed by these components. Inspect the application you are creating and declare only its real setup, verification, output, and other consumer operations before calling it ready.',
158
185
  ];
@@ -167,14 +194,43 @@ function contractBlocks(projectContracts) {
167
194
  ]);
168
195
  }
169
196
 
197
+ function decisionBlocks(unresolvedSections) {
198
+ return unresolvedSections.flatMap(({ name, diagnostic }) => [
199
+ `### \`## ${name}\``,
200
+ '',
201
+ ...diagnostic.details.proposals.flatMap(({ source, lines }) => [
202
+ `Proposal from \`${source}\`:`,
203
+ '',
204
+ '~~~markdown',
205
+ ...lines,
206
+ '~~~',
207
+ '',
208
+ ]),
209
+ ]);
210
+ }
211
+
170
212
  export function renderStackPreparationPrompt({
171
213
  components,
172
214
  materializedSections = [],
173
215
  newlySelected = [],
174
216
  projectContracts = [],
175
217
  reviewSections = [],
218
+ unresolvedSections = [],
176
219
  } = {}) {
177
- if (newlySelected.length === 0 && materializedSections.length === 0) return '';
220
+ if (newlySelected.length === 0 && materializedSections.length === 0
221
+ && unresolvedSections.length === 0) return '';
222
+ const decisionHeadings = unresolvedSections.map(({ name }) => `\`## ${name}\``).join(', ');
223
+ const steps = [
224
+ ...(unresolvedSections.length > 0 ? [
225
+ `Decide ${decisionHeadings} first. Selected components propose competing bodies and none requires the other, so Genesis left ${unresolvedSections.length === 1 ? 'that heading' : 'those headings'} unwritten. Author one complete project-owned section per heading in \`genesis/stack.md\`, choosing between the proposals quoted under “Project decisions required” for this application. Until then current-format Genesis reads, including \`genesis check\` and \`genesis context\`, reject the Stack with \`STACK_SECTION_AMBIGUOUS\`.`,
226
+ ] : []),
227
+ 'Read `genesis/blueprint.md`, `genesis/engineering.md`, and the complete `genesis/stack.md`, then run `genesis context .` (or the relevant source paths).',
228
+ 'Load every applicable installed Agent Skill and follow the selected technology’s authoritative guidance before creating dependencies or source.',
229
+ 'For a new project, implement the source and commands so every project contract below is true. For existing source, inspect reality first and change a complete project section when the inherited proposal is not already true; do not add compatibility code merely to imitate a proposal.',
230
+ 'Keep each consumer-owned section opaque to Genesis. Validate it with the consumer or framework that owns its grammar and behavior.',
231
+ 'Establish or update the non-technical Blueprint and affected Program explanations as the product becomes concrete.',
232
+ 'Run `genesis check`, resolve structural or incomplete-contract diagnostics, and run `genesis verify` only after the declared verification commands exist. Passing verification is evidence for those commands, not proof of every consumer operation.',
233
+ ];
178
234
  const selected = components.map(({ id }) => `\`${id}\``).join(', ');
179
235
  return [
180
236
  '# Prepare the selected Stack',
@@ -188,18 +244,23 @@ export function renderStackPreparationPrompt({
188
244
  '',
189
245
  `These existing project contracts remained authoritative while the new components were added: ${reviewSections.map((name) => `\`## ${name}\``).join(', ')}. Reconcile them against the newly selected technology using source evidence; Genesis deliberately did not overwrite them.`,
190
246
  ] : []),
247
+ ...(unresolvedSections.length > 0 ? [
248
+ '',
249
+ `Unresolved project decisions: ${decisionHeadings}.`,
250
+ ] : []),
191
251
  '',
192
252
  'Continue this same task now:',
193
253
  '',
194
- '1. Read `genesis/blueprint.md`, `genesis/engineering.md`, and the complete `genesis/stack.md`, then run `genesis context .` (or the relevant source paths).',
195
- '2. Load every applicable installed Agent Skill and follow the selected technology’s authoritative guidance before creating dependencies or source.',
196
- '3. For a new project, implement the source and commands so every project contract below is true. For existing source, inspect reality first and change a complete project section when the inherited proposal is not already true; do not add compatibility code merely to imitate a proposal.',
197
- '4. Keep each consumer-owned section opaque to Genesis. Validate it with the consumer or framework that owns its grammar and behavior.',
198
- '5. Establish or update the non-technical Blueprint and affected Program explanations as the product becomes concrete.',
199
- '6. Run `genesis check`, resolve structural or incomplete-contract diagnostics, and run `genesis verify` only after the declared verification commands exist. Passing verification is evidence for those commands, not proof of every consumer operation.',
254
+ ...steps.map((step, index) => `${index + 1}. ${step}`),
255
+ ...(unresolvedSections.length > 0 ? [
256
+ '',
257
+ '## Project decisions required',
258
+ '',
259
+ ...trimStackSectionLines(decisionBlocks(unresolvedSections)),
260
+ ] : []),
200
261
  '',
201
262
  '## Project contracts to satisfy',
202
263
  '',
203
- ...contractBlocks(projectContracts),
264
+ ...contractBlocks(projectContracts, unresolvedSections.length > 0),
204
265
  ].join('\n').trimEnd();
205
266
  }
@@ -29,19 +29,55 @@ export function opaqueStackSections(sections, knownNames) {
29
29
  }));
30
30
  }
31
31
 
32
+ function requirementClosures(components) {
33
+ const byId = new Map(components.map((component) => [component.id, component]));
34
+ const closures = new Map();
35
+ const closure = (id) => {
36
+ if (closures.has(id)) return closures.get(id);
37
+ const required = new Set();
38
+ closures.set(id, required);
39
+ for (const dependency of byId.get(id)?.requires || []) {
40
+ if (!byId.has(dependency) || dependency === id) continue;
41
+ required.add(dependency);
42
+ for (const transitive of closure(dependency)) required.add(transitive);
43
+ }
44
+ return required;
45
+ };
46
+ for (const { id } of components) closure(id);
47
+ return closures;
48
+ }
49
+
50
+ function quotedProposal({ source, lines }) {
51
+ return [`- \`${source}\` proposes:`, '', ' ~~~markdown', ...lines.map((line) => (line ? ` ${line}` : '')), ' ~~~'];
52
+ }
53
+
54
+ /** Render the actionable decision message for one unresolved section. */
55
+ export function unresolvedStackSectionMessage(name, proposals) {
56
+ return [
57
+ `Selected Stack components propose competing \`## ${name}\` sections and none supersedes the others: ${proposals.map(({ source }) => source).join(', ')}.`,
58
+ `Author one complete project \`## ${name}\` section in genesis/stack.md that chooses between these proposals; Genesis never merges them or picks one.`,
59
+ '',
60
+ ...proposals.flatMap(quotedProposal),
61
+ ].join('\n');
62
+ }
63
+
32
64
  /**
33
65
  * Compose consumer-owned Stack sections without interpreting their contents.
34
- * An existing project declaration wins; otherwise exactly one selected
35
- * component may propose a section for materialization under a given name.
66
+ * An existing project declaration wins. Otherwise a component's proposal
67
+ * supersedes the proposals of selected components it transitively requires;
68
+ * exactly one remaining proposal is materialized, and several remaining
69
+ * proposals are an unresolved project decision.
36
70
  */
37
71
  export function composeStackSections(components = [], projectSections = []) {
38
72
  const projectByName = new Map(projectSections.map((section) => [section.name, section]));
73
+ const closures = requirementClosures(components);
39
74
  const componentByName = new Map();
40
75
  for (const component of components) {
41
76
  for (const section of component.extensions || []) {
42
77
  const declarations = componentByName.get(section.name) || [];
43
78
  declarations.push({
44
79
  ...section,
80
+ component: component.id,
45
81
  source: `component:${component.id}`,
46
82
  });
47
83
  componentByName.set(section.name, declarations);
@@ -59,23 +95,29 @@ export function composeStackSections(components = [], projectSections = []) {
59
95
  };
60
96
  }
61
97
  const declarations = componentByName.get(name) || [];
62
- if (declarations.length === 1) {
98
+ const remaining = declarations.filter(({ component }) => !declarations.some((other) => (
99
+ other.component !== component && closures.get(other.component)?.has(component)
100
+ )));
101
+ if (remaining.length === 1) {
63
102
  return {
64
103
  name,
65
- lines: [...declarations[0].lines],
66
- source: declarations[0].source,
104
+ lines: [...remaining[0].lines],
105
+ source: remaining[0].source,
67
106
  diagnostics: [],
68
107
  };
69
108
  }
70
- const sources = declarations.map(({ source }) => source).sort();
109
+ const proposals = remaining
110
+ .map(({ source, lines }) => ({ source, lines: trimStackSectionLines(lines) }))
111
+ .sort((left, right) => left.source.localeCompare(right.source));
112
+ const sources = proposals.map(({ source }) => source);
71
113
  return {
72
114
  name,
73
115
  lines: [],
74
116
  source: null,
75
117
  diagnostics: [{
76
118
  code: 'STACK_SECTION_AMBIGUOUS',
77
- message: `Selected Stack components provide competing \`${name}\` sections: ${sources.join(', ')}. Add one project \`## ${name}\` section to select the exact declaration.`,
78
- details: { name, sources },
119
+ message: unresolvedStackSectionMessage(name, proposals),
120
+ details: { name, sources, proposals },
79
121
  }],
80
122
  };
81
123
  });
@@ -230,7 +230,11 @@ export async function addStackPieces({ pieces, projectRoot, stackPackages = [] }
230
230
  });
231
231
  const stackChanged = rendered !== source;
232
232
  if (stackChanged) await writeFileAtomic(location, rendered);
233
- const stack = await readStack(projectRoot, { stackPackages: availablePackages });
233
+ const stack = await readStack(projectRoot, {
234
+ allowUnresolvedSections: true,
235
+ stackPackages: availablePackages,
236
+ });
237
+ const { unresolvedSections } = stack;
234
238
  const skills = await syncProjectSkills({
235
239
  projectRoot,
236
240
  stack,
@@ -246,7 +250,10 @@ export async function addStackPieces({ pieces, projectRoot, stackPackages = [] }
246
250
  : 'Every requested Stack component and Agent Skill is already selected.',
247
251
  components: selected,
248
252
  changedFiles,
249
- diagnostics: skills.diagnostics,
253
+ diagnostics: [
254
+ ...unresolvedSections.map(({ diagnostic }) => diagnostic),
255
+ ...skills.diagnostics,
256
+ ],
250
257
  prompt: renderStackPreparationPrompt({
251
258
  components: stack.components,
252
259
  materializedSections: projectContracts.materializedSections,
@@ -254,6 +261,7 @@ export async function addStackPieces({ pieces, projectRoot, stackPackages = [] }
254
261
  projectContracts: stack.projectContracts,
255
262
  reviewSections: [...new Set(newlySelectedPieces.flatMap(stackPieceContractNames))]
256
263
  .filter((name) => sections.has(name)),
264
+ unresolvedSections,
257
265
  }),
258
266
  };
259
267
  }
@@ -295,6 +303,7 @@ async function customizePiece(projectRoot, piece) {
295
303
 
296
304
  async function readStackWithContractMode(projectRoot, {
297
305
  allowComponentContracts,
306
+ allowUnresolvedSections = false,
298
307
  stackPackages = [],
299
308
  }) {
300
309
  const location = path.join(projectRoot, STACK_PATH);
@@ -324,6 +333,7 @@ async function readStackWithContractMode(projectRoot, {
324
333
  const projectContracts = (allowComponentContracts
325
334
  ? materializeStackProjectContracts
326
335
  : requireMaterializedStackProjectContracts)({
336
+ allowUnresolvedSections,
327
337
  components,
328
338
  knownSectionNames: STACK_SECTIONS,
329
339
  sections,
@@ -386,6 +396,7 @@ async function readStackWithContractMode(projectRoot, {
386
396
  environmentFiles,
387
397
  extensions,
388
398
  projectContracts: projectContracts.projectContracts,
399
+ ...(allowUnresolvedSections ? { unresolvedSections: projectContracts.unresolvedSections } : {}),
389
400
  resources,
390
401
  guidance: composedProse(components, 'guidance'),
391
402
  adoption: composedProse(components, 'adoption'),
@@ -394,13 +405,32 @@ async function readStackWithContractMode(projectRoot, {
394
405
  };
395
406
  }
396
407
 
397
- export function readStack(projectRoot, { stackPackages = [] } = {}) {
408
+ /**
409
+ * Read the current-format Stack. Only operations that report pending project
410
+ * decisions themselves (selection and migration) may allow unresolved
411
+ * competing sections; the returned Stack then lists them.
412
+ */
413
+ export function readStack(projectRoot, { allowUnresolvedSections = false, stackPackages = [] } = {}) {
398
414
  return readStackWithContractMode(projectRoot, {
399
415
  allowComponentContracts: false,
416
+ allowUnresolvedSections,
400
417
  stackPackages,
401
418
  });
402
419
  }
403
420
 
421
+ /** Diagnostics and the preparation prompt for a Stack read with unresolved sections allowed. */
422
+ export function stackDecisionResult(stack) {
423
+ const unresolvedSections = stack.unresolvedSections || [];
424
+ return {
425
+ diagnostics: unresolvedSections.map(({ diagnostic }) => diagnostic),
426
+ prompt: renderStackPreparationPrompt({
427
+ components: stack.components,
428
+ projectContracts: stack.projectContracts,
429
+ unresolvedSections,
430
+ }),
431
+ };
432
+ }
433
+
404
434
  export function readLegacyStack(projectRoot, { stackPackages = [] } = {}) {
405
435
  return readStackWithContractMode(projectRoot, {
406
436
  allowComponentContracts: true,
package/src/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import path from 'node:path';
2
+ import { isDeepStrictEqual } from 'node:util';
2
3
 
3
4
  import { checkProject } from './index/check.js';
4
5
  import { inspectProjectSkills, syncProjectSkills } from './index/agent-skills.js';
@@ -84,14 +85,16 @@ export async function initialize({ projectRoot = process.cwd(), stackPackages =
84
85
 
85
86
  export async function migrate({ projectRoot = process.cwd(), stackPackages = [] } = {}) {
86
87
  const migrated = await migrateProject({ projectRoot, stackPackages });
87
- const index = await buildProjectIndex({ projectRoot, stackPackages });
88
+ const index = await buildProjectIndex({ allowUnresolvedSections: true, projectRoot, stackPackages });
88
89
  const result = withIndexResult(migrated, index);
89
90
  const checked = await checkProject({ projectRoot, stackPackages });
90
91
  return {
91
92
  ...result,
92
93
  projectFormat: checked.projectFormat,
93
94
  check: checked,
94
- diagnostics: [...result.diagnostics, ...checked.diagnostics],
95
+ diagnostics: [...result.diagnostics, ...checked.diagnostics].filter((diagnostic, index, all) => (
96
+ !all.slice(0, index).some((previous) => isDeepStrictEqual(previous, diagnostic))
97
+ )),
95
98
  guidance: checked.guidance,
96
99
  };
97
100
  }
@@ -127,7 +130,11 @@ export function installCodex({ environment = process.env } = {}) {
127
130
  export async function addStack({ pieces, projectRoot = process.cwd(), stackPackages = [] } = {}) {
128
131
  const root = path.resolve(projectRoot);
129
132
  const selected = await addStackPieces({ pieces, projectRoot: root, stackPackages });
130
- const index = await buildProjectIndex({ projectRoot: root, stackPackages });
133
+ const index = await buildProjectIndex({
134
+ allowUnresolvedSections: true,
135
+ projectRoot: root,
136
+ stackPackages,
137
+ });
131
138
  return withIndexResult(selected, index);
132
139
  }
133
140