genesis-compiler 1.8.5 → 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
@@ -1005,6 +1007,13 @@ repository. It retains the same Stack validation and performs no writes or
1005
1007
  commands. Missing/invalid roots fail; this is never an automatic fallback from
1006
1008
  Git ownership or discovery errors.
1007
1009
 
1010
+ `inspectStackSections({ names, projectRoot, rootMode, stackPackages })` returns
1011
+ an array of those same section results in the requested order. Names must be
1012
+ distinct and the array must be nonempty. It discovers the root once and reads
1013
+ the Stack once, so related sections share one Stack identity. Each call reads
1014
+ current files; results are not cached across calls. Missing sections retain
1015
+ their ordinary `unconfigured` result and repository/directory rules are unchanged.
1016
+
1008
1017
  Hosts that already validate access to a shared worktree may wrap an operation
1009
1018
  in `withTrustedGitRepository(absoluteWorktreeRoot, () => inspectEnvironment({
1010
1019
  projectRoot: absoluteWorktreeRoot }))`. This explicitly trusts that one
@@ -99,6 +99,10 @@ The executable returns exactly one JSON result:
99
99
  means the host already delivers through a native system field. This result
100
100
  is valid only for session scope.
101
101
 
102
+ A validated complete host delivery is resolved before CLI project-format, Git
103
+ root, or Stack inspection. Unclaimed conversations and host contributions retain
104
+ the normal project diagnostics and composition. The resolver runs once per hook.
105
+
102
106
  Malformed results and configured-resolver failures fail the hook. An unavailable
103
107
  claimed owner must fail rather than returning `unclaimed`. Ownership belongs to
104
108
  the native conversation: these results do not change project hook files, so
@@ -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,
@@ -184,6 +195,13 @@ discovery. Default library and CLI inspection continue to use the enclosing
184
195
  Git repository and its trust checks. Invalid modes or directory roots fail;
185
196
  there is no automatic fallback.
186
197
 
198
+ When a consumer needs several related declarations, use
199
+ `inspectStackSections({ names: ['First operation', 'Second operation'], projectRoot })`.
200
+ It returns an array of the same `genesis.stack-section.v1` results in the requested
201
+ order, using one repository discovery and one fresh Stack read. All results have
202
+ the same Stack hash. The names array must be nonempty and contain no duplicates.
203
+ The explicit directory mode is also supported; no cross-call cache is retained.
204
+
187
205
  ## Selection and identity
188
206
 
189
207
  `genesis stack add` records selected packages and component dependency closure,
@@ -263,5 +281,32 @@ Project format 2 removes runtime inheritance of operation declarations from
263
281
  the catalog. `genesis migrate` upgrades format 1 projects by composing the
264
282
  previously effective declarations once and writing them into
265
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`.
266
287
  Current-format reads reject a selected component whose required contract was
267
288
  not materialized, so a tool upgrade cannot quietly substitute a new proposal.
289
+
290
+ `genesis migrate` does not materialize newly added catalog proposals when the
291
+ project already uses the current format. Reconcile those explicitly with the
292
+ existing `genesis stack add` operation for an already-selected component. For
293
+ example, if `nodejs` is selected and the missing heading is
294
+ `## Connected development`, run `genesis stack add nodejs` and review its
295
+ preparation prompt. This adds the Node `npm` default without choosing connected
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`.
306
+
307
+ Before rolling out a catalog that adds operation proposals, operators should
308
+ run read-only project inspection against representative existing projects with
309
+ the candidate compiler and catalog. Arrange explicit reconciliation of affected
310
+ projects before rollout. A format marker alone does not establish compatibility
311
+ with new catalog proposals. Do not repair project contracts through opening,
312
+ status reads or another lazy write path.
package/docs/templates.md CHANGED
@@ -14,6 +14,9 @@ application has been described or that an application has been created.
14
14
  `genesis inspect project --json` is a read-only opening inspection shared by
15
15
  the CLI, session guidance, and hosts such as Vibe64:
16
16
 
17
+ The diagnostics list reports each identical failure once, even when separate
18
+ inspectors encounter it. Distinct failures remain available for repair.
19
+
17
20
  | State | Evidence | Next step |
18
21
  | --- | --- | --- |
19
22
  | `new` | No content beyond known bootstrap files | Choose a template or create through conversation |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "genesis-compiler",
3
- "version": "1.8.5",
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": {
package/src/cli.js CHANGED
@@ -471,7 +471,7 @@ async function cliStackPackages(projectRoot, supplied) {
471
471
  return installedFirstPartyStackPackages({ projectRoot });
472
472
  }
473
473
 
474
- async function execute({ command, operands, options }, { signal } = {}) {
474
+ async function execute({ command, operands, options }, { signal, hostContext = null } = {}) {
475
475
  if (command === 'parsers') {
476
476
  const parserOptions = {
477
477
  languages: operands.slice(1), all: options.all, directory: options.directory, signal,
@@ -576,23 +576,6 @@ async function execute({ command, operands, options }, { signal } = {}) {
576
576
  return { kind: 'discover', ...await codexAdoptionRecommendation({ projectRoot }) };
577
577
  }
578
578
  const scope = operands[0];
579
- if (scope === 'turn' && process.env.GENESIS_TURN_CONTEXT_ENABLED === '0') {
580
- return { kind: 'turn', status: 'ready', output: '' };
581
- }
582
- if (scope === 'session' && process.env[SESSION_CONTEXT_INSTALLED_ENV] === '1') {
583
- return { kind: 'session', status: 'ready', output: '' };
584
- }
585
- const nativeSession = await hookSessionInput();
586
- const hostContext = await resolveConfiguredHostContext({
587
- projectRoot,
588
- ...nativeSession,
589
- scope,
590
- });
591
- // Ownership is resolved for this native conversation, not for the project.
592
- // A host-owned delivery already contains everything its runtime requires.
593
- if (hostContext?.kind === 'delivery') {
594
- return { kind: 'session', status: 'ready', output: hostContext.text };
595
- }
596
579
  if (scope === 'turn') {
597
580
  return hostContext === null
598
581
  ? { kind: 'turn', status: 'ready', output: '' }
@@ -669,7 +652,25 @@ export async function runCli(argv = process.argv.slice(2)) {
669
652
  return 0;
670
653
  }
671
654
  options = parsed.options;
672
- const projectFormat = parsed.command === 'parsers' ? null : await optionalProjectFormat(options.projectRoot || process.cwd());
655
+ const projectRoot = options.projectRoot || process.cwd();
656
+ let hookResult = null;
657
+ let hostContext = null;
658
+ if (parsed.command === 'hook' && parsed.operands[0] !== 'discover') {
659
+ const scope = parsed.operands[0];
660
+ if ((scope === 'turn' && process.env.GENESIS_TURN_CONTEXT_ENABLED === '0') ||
661
+ (scope === 'session' && process.env[SESSION_CONTEXT_INSTALLED_ENV] === '1')) {
662
+ hookResult = { kind: scope, status: 'ready', output: '' };
663
+ } else {
664
+ const nativeSession = await hookSessionInput();
665
+ hostContext = await resolveConfiguredHostContext({ projectRoot, ...nativeSession, scope });
666
+ // A validated owner supplies the complete delivery; project composition is unused.
667
+ if (hostContext?.kind === 'delivery') {
668
+ hookResult = { kind: 'session', status: 'ready', output: hostContext.text };
669
+ }
670
+ }
671
+ }
672
+ const projectFormat = hookResult || parsed.command === 'parsers'
673
+ ? null : await optionalProjectFormat(projectRoot);
673
674
  const formatDiagnostic = projectFormatDiagnostic(projectFormat);
674
675
  if (formatDiagnostic && !options.json) {
675
676
  line(process.stderr, `WARNING: ${formatDiagnostic.message}`);
@@ -680,7 +681,7 @@ export async function runCli(argv = process.argv.slice(2)) {
680
681
  process.once('SIGINT', abort);
681
682
  process.once('SIGTERM', abort);
682
683
  const result = withProjectFormatWarning(
683
- await withParserCancellation(controller.signal, () => execute(parsed, { signal: controller.signal })),
684
+ hookResult ?? await withParserCancellation(controller.signal, () => execute(parsed, { signal: controller.signal, hostContext })),
684
685
  projectFormat,
685
686
  formatDiagnostic,
686
687
  );
@@ -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
  }
@@ -1,5 +1,6 @@
1
1
  import { readFile } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
+ import { isDeepStrictEqual } from 'node:util';
3
4
 
4
5
  import { readBlueprint } from './blueprint.js';
5
6
  import { readCollaboration } from './collaboration.js';
@@ -125,6 +126,9 @@ export async function inspectProject({ projectRoot, stackPackages = [] } = {}) {
125
126
  try { await read(root); } catch (error) { result.diagnostics.push(asDiagnostic(error)); }
126
127
  }
127
128
  try { await inspectProgram(root); } catch (error) { result.diagnostics.push(asDiagnostic(error)); }
129
+ // Program resource validation can encounter the same Stack failure already reported above.
130
+ result.diagnostics = result.diagnostics.filter((diagnostic, index, diagnostics) =>
131
+ !diagnostics.slice(0, index).some((previous) => isDeepStrictEqual(previous, diagnostic)));
128
132
  if (result.diagnostics.length) return result;
129
133
  if (content.kind !== 'existing') {
130
134
  return { ...result, state: 'new', templateEligible: true, nextAction: 'create' };
@@ -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
  }
@@ -10,11 +10,25 @@ import { sha256, stableJson } from './utils.js';
10
10
  /** Inspect one consumer-owned Stack section without interpreting its contents. */
11
11
  export async function inspectProjectStackSection({
12
12
  name,
13
+ ...options
14
+ } = {}) {
15
+ return (await inspectProjectStackSections({ ...options, names: [name] }))[0];
16
+ }
17
+
18
+ /** Inspect several sections from one fresh Stack read, in the requested order. */
19
+ export async function inspectProjectStackSections({
20
+ names,
13
21
  projectRoot,
14
22
  rootMode = 'repository',
15
23
  stackPackages = [],
16
24
  } = {}) {
17
- const sectionName = normalizeStackSectionName(name);
25
+ if (!Array.isArray(names) || names.length === 0) {
26
+ throw new TypeError('Stack section inspection requires a nonempty names array.');
27
+ }
28
+ const sectionNames = names.map(normalizeStackSectionName);
29
+ if (new Set(sectionNames).size !== sectionNames.length) {
30
+ throw new TypeError('Stack section inspection requires distinct names.');
31
+ }
18
32
  let root;
19
33
  if (rootMode === 'repository') {
20
34
  root = (await gitContext(projectRoot)).repositoryRoot;
@@ -28,20 +42,22 @@ export async function inspectProjectStackSection({
28
42
  throw new TypeError('Stack section rootMode must be repository or directory.');
29
43
  }
30
44
  const stack = await readStack(root, { stackPackages });
31
- const section = stack.extensions.find((candidate) => candidate.name === sectionName);
32
- const diagnostics = [...(section?.diagnostics || [])];
33
- const status = diagnostics.length > 0 ? 'blocked' : section ? 'ready' : 'unconfigured';
34
- const lines = section ? [...section.lines] : [];
35
- return {
36
- contract: GENESIS_CONTRACTS.stackSection,
37
- status,
38
- stackHash: stack.identityHash,
39
- sectionHash: status === 'ready' ? sha256(stableJson({ name: sectionName, lines })) : '',
40
- components: stack.components.map(({ id }) => id),
41
- name: sectionName,
42
- source: section?.source || null,
43
- content: lines.join('\n'),
44
- lines,
45
- diagnostics,
46
- };
45
+ return sectionNames.map((sectionName) => {
46
+ const section = stack.extensions.find((candidate) => candidate.name === sectionName);
47
+ const diagnostics = [...(section?.diagnostics || [])];
48
+ const status = diagnostics.length > 0 ? 'blocked' : section ? 'ready' : 'unconfigured';
49
+ const lines = section ? [...section.lines] : [];
50
+ return {
51
+ contract: GENESIS_CONTRACTS.stackSection,
52
+ status,
53
+ stackHash: stack.identityHash,
54
+ sectionHash: status === 'ready' ? sha256(stableJson({ name: sectionName, lines })) : '',
55
+ components: stack.components.map(({ id }) => id),
56
+ name: sectionName,
57
+ source: section?.source || null,
58
+ content: lines.join('\n'),
59
+ lines,
60
+ diagnostics,
61
+ };
62
+ });
47
63
  }
@@ -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';
@@ -21,7 +22,7 @@ import { initializeProject } from './index/init.js';
21
22
  import { migrateProject } from './index/migration.js';
22
23
  import { installCodexPlugin } from './index/codex-plugin.js';
23
24
  import { inspectProjectEnvironment } from './index/environment-files.js';
24
- import { inspectProjectStackSection } from './index/stack-section-inspection.js';
25
+ import { inspectProjectStackSection, inspectProjectStackSections } from './index/stack-section-inspection.js';
25
26
  import { listStackCatalogPieces } from './index/stack-catalog.js';
26
27
  import { addStackPieces, readStack } from './index/stack.js';
27
28
  import { uniqueSorted } from './index/utils.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
 
@@ -161,6 +168,10 @@ export function inspectStackSection(options) {
161
168
  return inspectProjectStackSection(options);
162
169
  }
163
170
 
171
+ export function inspectStackSections(options) {
172
+ return inspectProjectStackSections(options);
173
+ }
174
+
164
175
  export function listEngineeringProfiles() {
165
176
  return listEngineeringProfileCatalog();
166
177
  }