genesis-compiler 1.10.11 → 1.10.13

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
@@ -736,9 +736,13 @@ The eight task types are deliberately explicit:
736
736
  - `describe` creates or refreshes both the non-technical Blueprint and the
737
737
  complete useful Program from the current codebase, optional user guidance,
738
738
  and selected Stack guidance where it affects accurate explanation.
739
- - `review` is read-only. It asks the agent to compare Blueprint, code, Program,
740
- and tests, identify contradictions and gaps, and distinguish evidence from
741
- inference.
739
+ - `review` asks the agent to compare Blueprint, code, Program, and tests,
740
+ identify contradictions and gaps, and distinguish evidence from inference.
741
+ It judges the tests too: the relevant ones run and pass, with what ran and
742
+ what was not verified reported; changed behavior has tests; no test was
743
+ weakened, skipped, deleted or loosened to pass; and the changed tests stay
744
+ fast against coarse sanity limits. It edits only tests, writing the missing
745
+ ones or reporting those it cannot.
742
746
 
743
747
  Prompt generation is read-only. Missing external resources are reported in
744
748
  the prompt but do not prevent its generation. Genesis never invents values.
@@ -1052,10 +1056,12 @@ The CLI can also prepare and verify parsers for an offline host; see
1052
1056
  [language parser management](docs/parsers.md).
1053
1057
  `migrate()` updates recognized older Genesis project-file formats and returns
1054
1058
  the resulting structural check for hosts.
1055
- `inspectProjectFormat({ projectRoot })` returns only the format marker's `path`,
1059
+ `inspectProjectFormat({ projectRoot, rootMode })` returns only the format marker's `path`,
1056
1060
  `status`, `projectVersion`, `supportedVersion`, `action`, and diagnostic when
1057
- invalid. It retains Git repository discovery and trust checks, but does not
1061
+ invalid. By default it retains Git repository discovery and trust checks, but does not
1058
1062
  enumerate application files, load Stack catalogs, or inspect project guidance.
1063
+ `rootMode: "directory"` reads an explicit absolute directory without Git, as for
1064
+ Stack sections below.
1059
1065
  Missing markers distinguish uninitialized projects from unversioned Genesis
1060
1066
  material. Each call reads current state; it neither migrates nor writes files.
1061
1067
  `adoptProject()` also returns the initial `adopt` prompt for an existing
@@ -1116,7 +1122,11 @@ hosts may pass `rootMode: "directory"` and an explicit absolute `projectRoot`.
1116
1122
  This reads that exact directory, including when it sits inside another Git
1117
1123
  repository. It retains the same Stack validation and performs no writes or
1118
1124
  commands. Missing/invalid roots fail; this is never an automatic fallback from
1119
- Git ownership or discovery errors.
1125
+ Git ownership or discovery errors. `inspectEnvironment()` and
1126
+ `inspectProjectFormat()` accept the same `rootMode` with the same rules. A host
1127
+ that already knows the project root and inspects it on every request, such as a
1128
+ preview status poll, can pass `rootMode: "directory"` to all three and start no
1129
+ Git process.
1120
1130
 
1121
1131
  `inspectStackSections({ names, projectRoot, rootMode, stackPackages })` returns
1122
1132
  an array of those same section results in the requested order. Names must be
@@ -161,8 +161,18 @@ before selection. For an existing uninitialized codebase it owns
161
161
  the adoption recommendation as part of the same portable prompt. A host sends this prompt on the
162
162
  first chat turn; it does not recreate the questions or Stack catalog.
163
163
  `describe` creates or refreshes the complete Blueprint and useful Program in one
164
- agent task. The host owns the agent and its interaction. Genesis owns only
165
- transparent prompt construction and deterministic project operations.
164
+ agent task. `review` is the generic review contract a host gives an agent after
165
+ a piece of work. Besides comparing Blueprint, code, Program and tests, it has
166
+ the agent judge the tests: the relevant ones run and pass, with what ran and
167
+ what was not verified reported; changed behavior has new or updated tests,
168
+ written in the review or reported when they cannot be; a test weakened,
169
+ skipped, deleted or loosened to pass is a finding; and the changed tests stay
170
+ fast against coarse sanity limits (a suite never runs for hours, a test needing
171
+ minutes is a smell, a test database is ready in seconds), never per-test
172
+ budgets. It points at the project skill's verification section and the sanity
173
+ checks guide rather than restating them, and edits only tests. The host owns
174
+ the agent and its interaction. Genesis owns only transparent prompt
175
+ construction and deterministic project operations.
166
176
 
167
177
  ## Instruction sources
168
178
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "genesis-compiler",
3
- "version": "1.10.11",
3
+ "version": "1.10.13",
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": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "genesis",
3
- "version": "1.10.11",
3
+ "version": "1.10.13",
4
4
  "description": "Makes Codex aware of optional Genesis adoption for existing projects.",
5
5
  "author": {
6
6
  "name": "Mobily Enterprises"
@@ -6,11 +6,11 @@ import { promisify } from "node:util";
6
6
  const execute = promisify(execFile);
7
7
 
8
8
  const HOST_CONTEXT_INPUT_ENV = "GENESIS_HOST_CONTEXT_INPUT";
9
+ const HOOK_TIMEOUT_MS = 5_000;
9
10
 
10
- async function renderContext(projectRoot, sessionId, scope, parentSessionIds = []) {
11
+ async function renderContext(projectRoot, sessionId, scope, parentSessionIds = [], timeoutMs = HOOK_TIMEOUT_MS) {
11
12
  if (scope === "turn" && process.env.GENESIS_TURN_CONTEXT_ENABLED === "0") return "";
12
13
  const started = Date.now();
13
- const timeoutMs = 5_000;
14
14
  try {
15
15
  const { stdout } = await execute(
16
16
  "genesis",
@@ -54,7 +54,8 @@ function eventSessionId(event = {}) {
54
54
  ).trim();
55
55
  }
56
56
 
57
- export const GenesisProjectGuidance = async ({ client, directory, worktree } = {}) => {
57
+ // OpenCode passes no hookTimeoutMs, so its hooks get five seconds; a test passes a shorter deadline to prove it.
58
+ export const GenesisProjectGuidance = async ({ client, directory, worktree, hookTimeoutMs = HOOK_TIMEOUT_MS } = {}) => {
58
59
  const projectRoot = path.resolve(directory || worktree || process.cwd());
59
60
  const contexts = new Map();
60
61
  const turnContexts = new Map();
@@ -72,7 +73,7 @@ export const GenesisProjectGuidance = async ({ client, directory, worktree } = {
72
73
  if (!id || id === sessionId || parents.includes(id)) break;
73
74
  parents.push(id);
74
75
  }
75
- return renderContext(projectRoot, sessionId, "session", parents);
76
+ return renderContext(projectRoot, sessionId, "session", parents, hookTimeoutMs);
76
77
  });
77
78
  contexts.set(sessionId, context);
78
79
  context.catch(() => {
@@ -108,7 +109,7 @@ export const GenesisProjectGuidance = async ({ client, directory, worktree } = {
108
109
  if (!sessionId || !messageId || !Array.isArray(message.parts)) return;
109
110
  let current = turnContexts.get(sessionId);
110
111
  if (current?.messageId !== messageId) {
111
- current = { messageId, context: renderContext(projectRoot, sessionId, "turn") };
112
+ current = { messageId, context: renderContext(projectRoot, sessionId, "turn", [], hookTimeoutMs) };
112
113
  turnContexts.set(sessionId, current);
113
114
  current.context.catch(() => {
114
115
  if (turnContexts.get(sessionId) === current) turnContexts.delete(sessionId);
@@ -1,6 +1,5 @@
1
1
  Read `.agents/skills/genesis-project/SKILL.md` and
2
- `.agents/skills/genesis-program/SKILL.md` completely, then perform a read-only
3
- review.
2
+ `.agents/skills/genesis-program/SKILL.md` completely, then review the work.
4
3
 
5
4
  Compare Blueprint intent, observable implementation, tests, Program public
6
5
  contracts, informational implementation maps, and recorded verification in both
@@ -8,6 +7,21 @@ directions. Identify missing or contradictory behavior, misleading explanation,
8
7
  unnecessarily difficult public shape, and verification gaps. Distinguish direct
9
8
  evidence from inference and state questions that require human intent.
10
9
 
10
+ Judge the tests as part of the work, with the project skill's "Verify and
11
+ report" section and the test sections of the sanity checks guide:
12
+ - Run the tests relevant to the change, through the Stack's verification
13
+ commands or focused checks; a failure is a finding. Report what actually ran
14
+ and anything not verified; never report an unrun check as passing.
15
+ - Changed behavior has tests, new or updated, that would fail without it. Write
16
+ the missing ones as part of this review; when that is not possible, report
17
+ each with the behavior it should prove.
18
+ - A test weakened, skipped, deleted or loosened just to make things pass is a
19
+ finding, reported like a product defect.
20
+ - Tests stay fast. Look at what the changed tests cost and where their time
21
+ goes. Coarse sanity limits, never per-test budgets: a whole suite never runs
22
+ for hours, one test that needs minutes is a smell, and a test database is
23
+ ready in seconds.
24
+
11
25
  Report what makes requests or tests slower than their work, citing a count or
12
26
  timing where one exists and the code path otherwise: a list or batch whose
13
27
  query count grows with its rows, an access check whose cost grows with stored
@@ -21,7 +35,9 @@ access decided from ids the client sends rather than the trusted context.
21
35
  Judge each against the matching section and outer limit of
22
36
  `.agents/skills/genesis-project/references/sanity-checks.md`.
23
37
 
24
- Do not edit any file. Return a concise report ordered by impact with exact file
25
- references, the evidence inspected, and the smallest sensible correction.
38
+ Edit only tests: add or update the tests the changed behavior lacks, and change
39
+ no product code, Blueprint, Program, Stack or other file. Return a concise
40
+ report ordered by impact with exact file references, the evidence inspected,
41
+ the tests run and written, and the smallest sensible correction.
26
42
 
27
43
  Review `genesis/subsystems.md` responsibilities, Program membership, and data ownership/use against source and schema. Report missing, stale, or conflicting associations without editing them.
@@ -71,14 +71,14 @@ Program module.
71
71
  ## Task boundaries
72
72
 
73
73
  The caller determines whether this is initial description, complete Program
74
- refresh, focused post-change reconciliation, Blueprint-only work, or read-only
75
- review. Respect the caller's edit boundary. Report ambiguity rather than
74
+ refresh, focused post-change reconciliation, Blueprint-only work, or review.
75
+ Respect the caller's edit boundary. Report ambiguity rather than
76
76
  inventing intent.
77
77
 
78
78
  ## Subsystems
79
79
 
80
80
  Maintain `genesis/subsystems.md` alongside Program in description and refresh
81
- work. Blueprint-only work does not change the map; review remains read-only.
81
+ work. Blueprint-only work and review do not change the map.
82
82
  The map is the authored association authority, but responsibilities and table
83
83
  references remain fallible explanations of application source and schema.
84
84
 
@@ -0,0 +1,193 @@
1
+ import process from 'node:process';
2
+
3
+ import {
4
+ addStack,
5
+ adoptProject,
6
+ applyTemplate,
7
+ check,
8
+ generatePrompt,
9
+ getContext,
10
+ indexCodebase,
11
+ initialize,
12
+ inspectCollaboration,
13
+ inspectEngineering,
14
+ inspectEnvironment,
15
+ inspectStackSection,
16
+ inspectSubsystems,
17
+ inspectProject,
18
+ inspectSkills,
19
+ installCodex,
20
+ listEngineeringProfiles,
21
+ listStackPieces,
22
+ listTemplates,
23
+ listParsers,
24
+ installParsers,
25
+ verifyParsers,
26
+ migrate,
27
+ setCollaboration,
28
+ setEngineeringProfile,
29
+ syncSkills,
30
+ verify,
31
+ } from './index.js';
32
+ import {
33
+ codexAdoptionRecommendation,
34
+ } from './index/codex-hooks.js';
35
+ import { engineeringProfile, readEngineeringBaseline } from './index/engineering.js';
36
+ import { fail } from './index/errors.js';
37
+ import { projectSessionContext } from './index/session-context.js';
38
+ import { installedFirstPartyStackPackages } from './index/stack-catalog.js';
39
+ import { readStack } from './index/stack.js';
40
+ import { withParserCancellation } from './index/code-indexers/parsers.js';
41
+
42
+ // The CLI's commands, which load every project operation. cli.js imports this module for every command except
43
+ // `genesis hook turn`, which runs on every agent turn and composes only the host's context.
44
+
45
+ async function cliStackPackages(projectRoot, supplied) {
46
+ if (supplied.length > 0) return supplied;
47
+ try {
48
+ const stack = await readStack(projectRoot);
49
+ if (stack.stackPackages.length > 0) return [];
50
+ } catch (error) {
51
+ if (error?.code !== 'STACK_REQUIRED') return [];
52
+ }
53
+ return installedFirstPartyStackPackages({ projectRoot });
54
+ }
55
+
56
+ async function execute({ command, operands, options }, { signal, hostContext = null, progress }) {
57
+ if (command === 'parsers') {
58
+ const parserOptions = {
59
+ languages: operands.slice(1), all: options.all, directory: options.directory, signal,
60
+ onProgress: progress,
61
+ };
62
+ return { list: listParsers, install: installParsers, verify: verifyParsers }[operands[0]](parserOptions);
63
+ }
64
+ const projectRoot = options.projectRoot || process.cwd();
65
+ const stackPackages = await cliStackPackages(projectRoot, options.stackPackages || []);
66
+ if (command === 'init') return initialize({ projectRoot, stackPackages });
67
+ if (command === 'templates') {
68
+ const templateOptions = { projectRoot, stackPackages, templateSources: options.templateSources };
69
+ return operands[0] === 'list' ? listTemplates(templateOptions) : applyTemplate({ ...templateOptions, templateId: operands[1] });
70
+ }
71
+ if (command === 'migrate') return migrate({ projectRoot, stackPackages });
72
+ if (command === 'skills') return syncSkills({ projectRoot, stackPackages });
73
+ if (command === 'adopt') {
74
+ return adoptProject({ projectRoot, request: operands.join(' '), stackPackages });
75
+ }
76
+ if (command === 'codex') return installCodex();
77
+ if (command === 'collaboration') {
78
+ if (operands[0] === 'show') {
79
+ return { action: 'show', ...await inspectCollaboration({ projectRoot }) };
80
+ }
81
+ return {
82
+ action: 'set',
83
+ ...await setCollaboration({
84
+ projectRoot,
85
+ tone: operands[1],
86
+ responseLength: operands[2],
87
+ experience: operands[3],
88
+ explanationStyle: operands[4],
89
+ longChecks: operands[5],
90
+ ...(operands.length > 6 ? { requirements: operands.slice(6).join(' ') } : {}),
91
+ }),
92
+ };
93
+ }
94
+ if (command === 'engineering') {
95
+ if (operands[0] === 'list') {
96
+ return { action: 'list', status: 'ok', profiles: await listEngineeringProfiles() };
97
+ }
98
+ if (operands[0] === 'show') {
99
+ if (!operands[1]) return { action: 'show', ...await inspectEngineering({ projectRoot }) };
100
+ const [baseline, profile] = await Promise.all([
101
+ readEngineeringBaseline(),
102
+ engineeringProfile(operands[1]),
103
+ ]);
104
+ return {
105
+ action: 'show',
106
+ status: 'ready',
107
+ profile,
108
+ guidance: [
109
+ '## Universal complexity gate',
110
+ '',
111
+ baseline,
112
+ '',
113
+ '## Profile guidance',
114
+ '',
115
+ profile.guidance,
116
+ ].join('\n'),
117
+ };
118
+ }
119
+ return {
120
+ action: 'set',
121
+ ...await setEngineeringProfile({ profile: operands[1], projectRoot }),
122
+ };
123
+ }
124
+ if (command === 'stack') {
125
+ if (operands[0] === 'list') {
126
+ return { status: 'ok', pieces: await listStackPieces({ projectRoot, stackPackages }) };
127
+ }
128
+ return addStack({ pieces: operands.slice(1), projectRoot, stackPackages });
129
+ }
130
+ if (command === 'prompt') {
131
+ return generatePrompt({
132
+ projectRoot,
133
+ task: options.task || 'work',
134
+ request: operands.join(' '),
135
+ stackPackages,
136
+ });
137
+ }
138
+ if (command === 'context') return getContext({ paths: operands, projectRoot, stackPackages });
139
+ if (command === 'index') return indexCodebase({ projectRoot, queries: operands, stackPackages, callWeb: options.callWeb });
140
+ if (command === 'inspect') {
141
+ if (operands[0] === 'section') {
142
+ return {
143
+ inspection: 'section',
144
+ ...await inspectStackSection({
145
+ name: operands.slice(1).join(' '),
146
+ projectRoot,
147
+ stackPackages,
148
+ }),
149
+ };
150
+ }
151
+ const inspections = { subsystems: inspectSubsystems, environment: inspectEnvironment, project: inspectProject, skills: inspectSkills };
152
+ return {
153
+ inspection: operands[0],
154
+ ...await inspections[operands[0]]({ projectRoot, stackPackages }),
155
+ };
156
+ }
157
+ if (command === 'hook') {
158
+ if (operands[0] === 'discover') {
159
+ return { kind: 'discover', ...await codexAdoptionRecommendation({ projectRoot }) };
160
+ }
161
+ return {
162
+ kind: 'session',
163
+ ...await projectSessionContext({
164
+ ...(hostContext === null ? {} : {
165
+ hostDriver: () => hostContext.text,
166
+ hostDriverInput: { scope: 'session' },
167
+ }),
168
+ projectRoot,
169
+ stackPackages,
170
+ }),
171
+ };
172
+ }
173
+ if (command === 'verify') {
174
+ return verify({
175
+ projectRoot,
176
+ signal,
177
+ stackPackages,
178
+ onEvent: (event) => {
179
+ if (!options.json) progress(event.message);
180
+ },
181
+ });
182
+ }
183
+ if (command === 'check') return check({ projectRoot, stackPackages });
184
+ fail('CLI_UNKNOWN_COMMAND', `Unknown command: ${command}`);
185
+ }
186
+
187
+ /**
188
+ * Runs a parsed CLI command other than the turn hook. `progress` writes one line of progress for the person.
189
+ * @returns {Promise<object>} The command's result.
190
+ */
191
+ export function runCommand(parsed, { signal, hostContext = null, progress }) {
192
+ return withParserCancellation(signal, () => execute(parsed, { signal, hostContext, progress }));
193
+ }
package/src/cli.js CHANGED
@@ -1,40 +1,8 @@
1
1
  import process from 'node:process';
2
2
  import { parseArgs } from 'node:util';
3
3
 
4
- import {
5
- addStack,
6
- adoptProject,
7
- applyTemplate,
8
- check,
9
- generatePrompt,
10
- getContext,
11
- indexCodebase,
12
- initialize,
13
- inspectCollaboration,
14
- inspectEngineering,
15
- inspectEnvironment,
16
- inspectStackSection,
17
- inspectSubsystems,
18
- inspectProject,
19
- inspectSkills,
20
- installCodex,
21
- listEngineeringProfiles,
22
- listStackPieces,
23
- listTemplates,
24
- listParsers,
25
- installParsers,
26
- verifyParsers,
27
- migrate,
28
- setCollaboration,
29
- setEngineeringProfile,
30
- syncSkills,
31
- verify,
32
- } from './index.js';
33
- import {
34
- codexAdoptionRecommendation,
35
- } from './index/codex-hooks.js';
36
- import { engineeringProfile, readEngineeringBaseline } from './index/engineering.js';
37
4
  import { asDiagnostic, fail } from './index/errors.js';
5
+ import { projectTurnContext } from './index/host-context.js';
38
6
  import {
39
7
  HOST_CONTEXT_INPUT_ENV,
40
8
  HOST_CONTEXT_RESOLVER_ENV,
@@ -45,10 +13,6 @@ import {
45
13
  inspectProjectFormat,
46
14
  projectFormatDiagnostic,
47
15
  } from './index/project-format.js';
48
- import { projectSessionContext, projectTurnContext } from './index/session-context.js';
49
- import { installedFirstPartyStackPackages } from './index/stack-catalog.js';
50
- import { readStack } from './index/stack.js';
51
- import { withParserCancellation } from './index/code-indexers/parsers.js';
52
16
 
53
17
  const USAGE = `Usage:
54
18
  genesis init
@@ -473,158 +437,22 @@ async function hookSessionInput(environment = process.env) {
473
437
  };
474
438
  }
475
439
 
476
- async function cliStackPackages(projectRoot, supplied) {
477
- if (supplied.length > 0) return supplied;
478
- try {
479
- const stack = await readStack(projectRoot);
480
- if (stack.stackPackages.length > 0) return [];
481
- } catch (error) {
482
- if (error?.code !== 'STACK_REQUIRED') return [];
483
- }
484
- return installedFirstPartyStackPackages({ projectRoot });
485
- }
486
-
487
- async function execute({ command, operands, options }, { signal, hostContext = null } = {}) {
488
- if (command === 'parsers') {
489
- const parserOptions = {
490
- languages: operands.slice(1), all: options.all, directory: options.directory, signal,
491
- onProgress: (message) => line(process.stderr, message),
492
- };
493
- return { list: listParsers, install: installParsers, verify: verifyParsers }[operands[0]](parserOptions);
494
- }
495
- const projectRoot = options.projectRoot || process.cwd();
496
- const stackPackages = await cliStackPackages(projectRoot, options.stackPackages || []);
497
- if (command === 'init') return initialize({ projectRoot, stackPackages });
498
- if (command === 'templates') {
499
- const templateOptions = { projectRoot, stackPackages, templateSources: options.templateSources };
500
- return operands[0] === 'list' ? listTemplates(templateOptions) : applyTemplate({ ...templateOptions, templateId: operands[1] });
501
- }
502
- if (command === 'migrate') return migrate({ projectRoot, stackPackages });
503
- if (command === 'skills') return syncSkills({ projectRoot, stackPackages });
504
- if (command === 'adopt') {
505
- return adoptProject({ projectRoot, request: operands.join(' '), stackPackages });
506
- }
507
- if (command === 'codex') return installCodex();
508
- if (command === 'collaboration') {
509
- if (operands[0] === 'show') {
510
- return { action: 'show', ...await inspectCollaboration({ projectRoot }) };
511
- }
512
- return {
513
- action: 'set',
514
- ...await setCollaboration({
515
- projectRoot,
516
- tone: operands[1],
517
- responseLength: operands[2],
518
- experience: operands[3],
519
- explanationStyle: operands[4],
520
- longChecks: operands[5],
521
- ...(operands.length > 6 ? { requirements: operands.slice(6).join(' ') } : {}),
522
- }),
523
- };
524
- }
525
- if (command === 'engineering') {
526
- if (operands[0] === 'list') {
527
- return { action: 'list', status: 'ok', profiles: await listEngineeringProfiles() };
528
- }
529
- if (operands[0] === 'show') {
530
- if (!operands[1]) return { action: 'show', ...await inspectEngineering({ projectRoot }) };
531
- const [baseline, profile] = await Promise.all([
532
- readEngineeringBaseline(),
533
- engineeringProfile(operands[1]),
534
- ]);
535
- return {
536
- action: 'show',
537
- status: 'ready',
538
- profile,
539
- guidance: [
540
- '## Universal complexity gate',
541
- '',
542
- baseline,
543
- '',
544
- '## Profile guidance',
545
- '',
546
- profile.guidance,
547
- ].join('\n'),
548
- };
549
- }
550
- return {
551
- action: 'set',
552
- ...await setEngineeringProfile({ profile: operands[1], projectRoot }),
553
- };
554
- }
555
- if (command === 'stack') {
556
- if (operands[0] === 'list') {
557
- return { status: 'ok', pieces: await listStackPieces({ projectRoot, stackPackages }) };
558
- }
559
- return addStack({ pieces: operands.slice(1), projectRoot, stackPackages });
560
- }
561
- if (command === 'prompt') {
562
- return generatePrompt({
563
- projectRoot,
564
- task: options.task || 'work',
565
- request: operands.join(' '),
566
- stackPackages,
567
- });
568
- }
569
- if (command === 'context') return getContext({ paths: operands, projectRoot, stackPackages });
570
- if (command === 'index') return indexCodebase({ projectRoot, queries: operands, stackPackages, callWeb: options.callWeb });
571
- if (command === 'inspect') {
572
- if (operands[0] === 'section') {
573
- return {
574
- inspection: 'section',
575
- ...await inspectStackSection({
576
- name: operands.slice(1).join(' '),
577
- projectRoot,
578
- stackPackages,
579
- }),
580
- };
581
- }
582
- const inspections = { subsystems: inspectSubsystems, environment: inspectEnvironment, project: inspectProject, skills: inspectSkills };
583
- return {
584
- inspection: operands[0],
585
- ...await inspections[operands[0]]({ projectRoot, stackPackages }),
586
- };
587
- }
588
- if (command === 'hook') {
589
- if (operands[0] === 'discover') {
590
- return { kind: 'discover', ...await codexAdoptionRecommendation({ projectRoot }) };
591
- }
592
- const scope = operands[0];
593
- if (scope === 'turn') {
594
- return hostContext === null
595
- ? { kind: 'turn', status: 'ready', output: '' }
596
- : {
597
- kind: 'turn',
598
- ...await projectTurnContext({
599
- hostDriver: () => hostContext.text,
600
- hostDriverInput: { scope: 'turn' },
601
- }),
602
- };
603
- }
604
- return {
605
- kind: 'session',
606
- ...await projectSessionContext({
607
- ...(hostContext === null ? {} : {
608
- hostDriver: () => hostContext.text,
609
- hostDriverInput: { scope: 'session' },
610
- }),
611
- projectRoot,
612
- stackPackages,
613
- }),
614
- };
615
- }
616
- if (command === 'verify') {
617
- return verify({
618
- projectRoot,
619
- signal,
620
- stackPackages,
621
- onEvent: (event) => {
622
- if (!options.json) line(process.stderr, event.message);
623
- },
624
- });
625
- }
626
- if (command === 'check') return check({ projectRoot, stackPackages });
627
- fail('CLI_UNKNOWN_COMMAND', `Unknown command: ${command}`);
440
+ // `genesis hook turn` runs on every agent turn and composes only the host's context, so it loads none of the project
441
+ // operations; every other command loads them with cli-commands.js when it runs.
442
+ async function executeCommand(parsed, { signal, hostContext }) {
443
+ if (parsed.command === 'hook' && parsed.operands[0] === 'turn') {
444
+ return hostContext === null
445
+ ? { kind: 'turn', status: 'ready', output: '' }
446
+ : {
447
+ kind: 'turn',
448
+ ...await projectTurnContext({
449
+ hostDriver: () => hostContext.text,
450
+ hostDriverInput: { scope: 'turn' },
451
+ }),
452
+ };
453
+ }
454
+ const { runCommand } = await import('./cli-commands.js');
455
+ return runCommand(parsed, { signal, hostContext, progress: (message) => line(process.stderr, message) });
628
456
  }
629
457
 
630
458
  async function optionalProjectFormat(projectRoot) {
@@ -695,7 +523,7 @@ export async function runCli(argv = process.argv.slice(2)) {
695
523
  process.once('SIGINT', abort);
696
524
  process.once('SIGTERM', abort);
697
525
  const result = withProjectFormatWarning(
698
- hookResult ?? await withParserCancellation(controller.signal, () => execute(parsed, { signal: controller.signal, hostContext })),
526
+ hookResult ?? await executeCommand(parsed, { signal: controller.signal, hostContext }),
699
527
  projectFormat,
700
528
  formatDiagnostic,
701
529
  );
@@ -85,11 +85,32 @@ function markdownDestinations(tree) {
85
85
  while (nodes.length > 0) {
86
86
  const node = nodes.pop();
87
87
  if (['definition', 'image', 'link'].includes(node?.type) && typeof node.url === 'string') {
88
- destinations.push({ line: node.position?.start?.line, url: node.url });
88
+ destinations.push(Object.freeze({ line: node.position?.start?.line, url: node.url }));
89
89
  }
90
90
  if (Array.isArray(node?.children)) nodes.push(...node.children);
91
91
  }
92
- return destinations.sort((left, right) => (left.line || 0) - (right.line || 0));
92
+ return Object.freeze(destinations.sort((left, right) => (left.line || 0) - (right.line || 0)));
93
+ }
94
+
95
+ // Link destinations by Markdown content hash. A skill plan validates every Markdown file of every selected skill,
96
+ // and one operation builds several plans (synchronization, inspection, context, prompts), so the same skill text was
97
+ // parsed again each time; the full CommonMark parse was 40% of the test suite's CPU. The destinations depend only on
98
+ // the text, so each distinct text is parsed once per process. Whether each local target exists is still checked on
99
+ // every validation.
100
+ const MAX_PARSED_MARKDOWN = 256;
101
+ const parsedMarkdownDestinations = new Map();
102
+
103
+ function markdownDestinationsOf(source) {
104
+ const key = sha256(source);
105
+ let destinations = parsedMarkdownDestinations.get(key);
106
+ if (!destinations) {
107
+ destinations = markdownDestinations(fromMarkdown(source));
108
+ if (parsedMarkdownDestinations.size >= MAX_PARSED_MARKDOWN) {
109
+ parsedMarkdownDestinations.delete(parsedMarkdownDestinations.keys().next().value);
110
+ }
111
+ parsedMarkdownDestinations.set(key, destinations);
112
+ }
113
+ return destinations;
93
114
  }
94
115
 
95
116
  function localReferencePath(url) {
@@ -121,16 +142,16 @@ async function validateMarkdownReferences(directory, files) {
121
142
  const problems = [];
122
143
  for (const file of files.filter(({ path: filePath }) => filePath.endsWith('.md'))) {
123
144
  const location = path.join(root, file.path);
124
- let tree;
145
+ let destinations;
125
146
  try {
126
- tree = fromMarkdown(await readFile(location, 'utf8'));
147
+ destinations = markdownDestinationsOf(await readFile(location, 'utf8'));
127
148
  } catch (error) {
128
149
  invalidSkill(`Agent Skill Markdown is invalid: ${location}.`, {
129
150
  cause: error.message,
130
151
  path: file.path,
131
152
  });
132
153
  }
133
- for (const destination of markdownDestinations(tree)) {
154
+ for (const destination of destinations) {
134
155
  const local = localReferencePath(destination.url);
135
156
  if (!local) continue;
136
157
  const problem = {
@@ -1,4 +1,4 @@
1
- import { gitContext } from './git.js';
1
+ import { inspectionRoot } from './git.js';
2
2
  import { readStack } from './stack.js';
3
3
  import { withStackEnvironmentDefaults } from './stack-environment-defaults.js';
4
4
  import { missingStackResources } from './stack-preflight.js';
@@ -8,9 +8,10 @@ import { GENESIS_CONTRACTS } from './contracts.js';
8
8
  export async function inspectProjectEnvironment({
9
9
  environment = process.env,
10
10
  projectRoot,
11
+ rootMode = 'repository',
11
12
  stackPackages = [],
12
13
  } = {}) {
13
- const root = (await gitContext(projectRoot)).repositoryRoot;
14
+ const root = await inspectionRoot({ projectRoot, rootMode, subject: 'Environment' });
14
15
  const stack = await readStack(root, { stackPackages });
15
16
  const diagnostics = missingStackResources({
16
17
  environment: withStackEnvironmentDefaults(environment, stack.environmentDefaults),
package/src/index/git.js CHANGED
@@ -1,4 +1,4 @@
1
- import { realpath } from 'node:fs/promises';
1
+ import { realpath, stat } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
 
4
4
  import { fail } from './errors.js';
@@ -51,3 +51,19 @@ export async function gitContext(projectRoot = process.cwd()) {
51
51
  }
52
52
  return { workingDirectory: root, repositoryRoot };
53
53
  }
54
+
55
+ /**
56
+ * The root a read-only inspection reads. The default `repository` mode discovers the enclosing Git worktree with
57
+ * its trust checks. `directory` reads exactly an explicit absolute directory without starting Git: a source
58
+ * snapshot without Git metadata, or a host that already knows the project root. There is no fallback between them.
59
+ */
60
+ export async function inspectionRoot({ projectRoot, rootMode = 'repository', subject }) {
61
+ if (rootMode === 'repository') return (await gitContext(projectRoot)).repositoryRoot;
62
+ if (rootMode !== 'directory') throw new TypeError(`${subject} rootMode must be repository or directory.`);
63
+ if (typeof projectRoot !== 'string' || !path.isAbsolute(projectRoot)) {
64
+ throw new TypeError('Directory inspection requires an explicit absolute projectRoot.');
65
+ }
66
+ const root = await realpath(projectRoot);
67
+ if (!(await stat(root)).isDirectory()) throw new TypeError('Directory inspection requires a directory.');
68
+ return root;
69
+ }
@@ -0,0 +1,70 @@
1
+ import { GENESIS_CONTRACTS } from './contracts.js';
2
+ import { GenesisError } from './errors.js';
3
+ import { normalizeSource, sha256, stableJson } from './utils.js';
4
+
5
+ // The host's contribution to the session and turn context lanes, and the turn lane itself. The turn lane composes only
6
+ // that contribution, and `genesis hook turn` runs it on every agent turn, so this module imports nothing that reads the
7
+ // project; the session lane (session-context.js) adds the project's guidance.
8
+ const HOST_CONTEXT_MAX_BYTES = Object.freeze({
9
+ session: 24 * 1024,
10
+ turn: 512,
11
+ });
12
+
13
+ export async function hostContextContribution({ hostDriver, hostDriverInput, scope }) {
14
+ if (hostDriver === undefined || hostDriver === null) {
15
+ if (hostDriverInput !== undefined) {
16
+ throw new GenesisError(
17
+ 'HOST_CONTEXT_INVALID',
18
+ 'Host context input requires an explicitly registered host driver.',
19
+ );
20
+ }
21
+ return '';
22
+ }
23
+ if (typeof hostDriver !== 'function') {
24
+ throw new GenesisError('HOST_CONTEXT_INVALID', 'The registered host driver must be a function.');
25
+ }
26
+ if (
27
+ !hostDriverInput
28
+ || typeof hostDriverInput !== 'object'
29
+ || Array.isArray(hostDriverInput)
30
+ || hostDriverInput.scope !== scope
31
+ ) {
32
+ throw new GenesisError(
33
+ 'HOST_CONTEXT_INVALID',
34
+ `The ${scope} context lane requires host input with scope \`${scope}\`.`,
35
+ );
36
+ }
37
+ const value = await hostDriver(hostDriverInput);
38
+ if (typeof value !== 'string') {
39
+ throw new GenesisError('HOST_CONTEXT_INVALID', 'The registered host driver must return plain text.');
40
+ }
41
+ const output = normalizeSource(value).trim();
42
+ if (Buffer.byteLength(output, 'utf8') > HOST_CONTEXT_MAX_BYTES[scope]) {
43
+ throw new GenesisError(
44
+ 'HOST_CONTEXT_INVALID',
45
+ `The registered host driver's ${scope} contribution exceeds ${HOST_CONTEXT_MAX_BYTES[scope]} bytes.`,
46
+ );
47
+ }
48
+ return output;
49
+ }
50
+
51
+ export function composedContext(contract, output) {
52
+ return {
53
+ contract,
54
+ status: 'ready',
55
+ output,
56
+ identity: sha256(stableJson({ contract, output })),
57
+ };
58
+ }
59
+
60
+ export async function projectTurnContext({ hostDriver, hostDriverInput } = {}) {
61
+ const output = await hostContextContribution({
62
+ hostDriver,
63
+ hostDriverInput,
64
+ scope: 'turn',
65
+ });
66
+ return {
67
+ ...composedContext(GENESIS_CONTRACTS.turnContext, output),
68
+ trust: 'untrusted',
69
+ };
70
+ }
@@ -170,8 +170,13 @@ export async function inspectProgram(projectRoot) {
170
170
  const contents = moduleContents(source, programPath);
171
171
  if (!assignments.has(programPath)) throw new GenesisError('SUBSYSTEMS_PROGRAM_UNASSIGNED', `Program module has no declared subsystem: ${programPath}.`);
172
172
  const { sources } = contents;
173
- for (const sourcePath of sources) {
174
- if (!sourcePath || !isProjectContentPath(sourcePath) || !await sourceExists(projectRoot, sourcePath)) {
173
+ // A module cites dozens of sources; check them all at once and report the first problem in citation order.
174
+ const checks = await Promise.allSettled(sources.map((sourcePath) => (
175
+ sourcePath && isProjectContentPath(sourcePath) && sourceExists(projectRoot, sourcePath)
176
+ )));
177
+ for (const [index, sourcePath] of sources.entries()) {
178
+ if (checks[index].status === 'rejected') throw checks[index].reason;
179
+ if (!checks[index].value) {
175
180
  throw new GenesisError(
176
181
  'PROGRAM_SOURCE_MISSING',
177
182
  `Program module cites a missing or ineligible source file: ${sourcePath}.`,
@@ -30,6 +30,6 @@ export async function gitVisibleFileStates(projectRoot, { includePath = () => tr
30
30
 
31
31
  /** Classifies opening behavior from cheap Git-visible paths and selected Stack state. */
32
32
  export async function classifyProjectKind({ projectRoot } = {}) {
33
- const content = await inspectProjectContent(projectRoot);
34
- return content.kind === 'existing' ? 'existing' : 'new';
33
+ const { kind } = await inspectProjectContent(projectRoot, { kindOnly: true });
34
+ return kind === 'existing' ? 'existing' : 'new';
35
35
  }
@@ -2,7 +2,7 @@ import { readFile } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
 
4
4
  import { asDiagnostic, GenesisError } from './errors.js';
5
- import { gitContext } from './git.js';
5
+ import { inspectionRoot } from './git.js';
6
6
  import {
7
7
  BLUEPRINT_PATH,
8
8
  COLLABORATION_PATH,
@@ -90,9 +90,8 @@ export async function inspectProjectFormatAtRoot(projectRoot) {
90
90
  return projectFormat('current', version, null);
91
91
  }
92
92
 
93
- export async function inspectProjectFormat({ projectRoot } = {}) {
94
- const root = (await gitContext(projectRoot)).repositoryRoot;
95
- return inspectProjectFormatAtRoot(root);
93
+ export async function inspectProjectFormat({ projectRoot, rootMode = 'repository' } = {}) {
94
+ return inspectProjectFormatAtRoot(await inspectionRoot({ projectRoot, rootMode, subject: 'Project format' }));
96
95
  }
97
96
 
98
97
  export function projectFormatDiagnostic(format) {
@@ -51,22 +51,39 @@ async function isBootstrapGitignore(projectRoot, paths) {
51
51
  }
52
52
  }
53
53
 
54
- export async function inspectProjectContent(projectRoot) {
54
+ async function listFiles(projectRoot, options) {
55
55
  // Exclude dependency paths in Git before collecting and sorting its output.
56
- const sourcePaths = ['--', '.', ':(exclude,glob)**/node_modules/**'];
56
+ const { stdout } = await runGit(projectRoot, ['ls-files', '-z', ...options, '--', '.', ':(exclude,glob)**/node_modules/**']);
57
+ return stdout.toString('utf8').split('\0').filter(Boolean);
58
+ }
59
+
60
+ /**
61
+ * With kindOnly, only `kind` is returned. Listing ignored files walks ignored dependency trees such as node_modules
62
+ * despite the exclusion, which dominates the inspection of an installed application, so it is skipped when Git's
63
+ * visible files already include project content: ignored files can add existing content, never remove it.
64
+ */
65
+ export async function inspectProjectContent(projectRoot, { kindOnly = false } = {}) {
66
+ const listIgnored = () => listFiles(projectRoot, ['--others', '--ignored', '--exclude-standard']);
57
67
  const [visible, deleted, ignored] = await Promise.all([
58
- runGit(projectRoot, ['ls-files', '-z', '--cached', '--others', '--exclude-standard', ...sourcePaths]),
59
- runGit(projectRoot, ['ls-files', '-z', '--deleted', ...sourcePaths]),
60
- runGit(projectRoot, ['ls-files', '-z', '--others', '--ignored', '--exclude-standard', ...sourcePaths]),
68
+ listFiles(projectRoot, ['--cached', '--others', '--exclude-standard']),
69
+ listFiles(projectRoot, ['--deleted']),
70
+ kindOnly ? [] : listIgnored(),
61
71
  ]);
62
- const absent = new Set(deleted.stdout.toString('utf8').split('\0').filter(Boolean));
63
- const paths = [...new Set([visible, ignored].flatMap(({ stdout }) => stdout.toString('utf8').split('\0').filter(Boolean)))]
72
+ if (!kindOnly) return classifyContent(projectRoot, visible, deleted, ignored);
73
+ const { kind } = await classifyContent(projectRoot, visible, deleted, []);
74
+ if (kind === 'existing') return { kind };
75
+ return { kind: (await classifyContent(projectRoot, visible, deleted, await listIgnored())).kind };
76
+ }
77
+
78
+ async function classifyContent(projectRoot, visible, deleted, ignored) {
79
+ const absent = new Set(deleted);
80
+ const paths = [...new Set([...visible, ...ignored])]
64
81
  .filter((file) => !absent.has(file) && file !== '.git' && !file.startsWith('.git/'))
65
82
  .sort();
66
83
  // OpenCode installs its plugin dependency beside the generated guidance plugin.
67
84
  // Recognize only its exact ignored scaffolding, never an authored config tree.
68
85
  const opencodePaths = ['.opencode/.gitignore', '.opencode/package.json', '.opencode/package-lock.json'];
69
- const ignoredPaths = new Set(ignored.stdout.toString('utf8').split('\0').filter(Boolean));
86
+ const ignoredPaths = new Set(ignored);
70
87
  let opencodeBootstrap = [];
71
88
  if (opencodePaths.every((file) => ignoredPaths.has(file))) {
72
89
  try {
@@ -100,15 +117,19 @@ export async function inspectProjectContent(projectRoot) {
100
117
 
101
118
  /** Read-only opening inspection: no index generation, verification, or application execution. */
102
119
  export async function inspectProject({ projectRoot, stackPackages = [] } = {}) {
103
- const root = (await gitContext(projectRoot)).repositoryRoot;
120
+ return inspectProjectAtRoot((await gitContext(projectRoot)).repositoryRoot, { stackPackages });
121
+ }
122
+
123
+ /** The same inspection of a resolved repository root; without `count`, content reports only its kind. */
124
+ export async function inspectProjectAtRoot(root, { stackPackages = [], count = true } = {}) {
104
125
  const [content, format] = await Promise.all([
105
- inspectProjectContent(root),
126
+ inspectProjectContent(root, { kindOnly: !count }),
106
127
  inspectProjectFormatAtRoot(root),
107
128
  ]);
108
129
  const result = {
109
130
  contract: GENESIS_CONTRACTS.projectInspection,
110
131
  state: 'attention',
111
- content: { kind: content.kind, count: content.existingPaths.length },
132
+ content: count ? { kind: content.kind, count: content.existingPaths.length } : { kind: content.kind },
112
133
  projectFormat: format,
113
134
  templateEligible: false,
114
135
  stackComponents: [],
@@ -2,17 +2,12 @@ import { readCollaboration } from './collaboration.js';
2
2
  import { inspectProjectSkills, renderAgentSkillCatalog } from './agent-skills.js';
3
3
  import { GENESIS_CONTRACTS } from './contracts.js';
4
4
  import { readEngineering, readEngineeringBaseline } from './engineering.js';
5
- import { asDiagnostic, GenesisError } from './errors.js';
5
+ import { asDiagnostic } from './errors.js';
6
6
  import { gitContext } from './git.js';
7
- import { inspectProject } from './project-inspection.js';
7
+ import { inspectProjectAtRoot } from './project-inspection.js';
8
8
  import { readStack } from './stack.js';
9
9
  import { listStackCatalogPieces } from './stack-catalog.js';
10
- import { normalizeSource, sha256, stableJson } from './utils.js';
11
-
12
- const HOST_CONTEXT_MAX_BYTES = Object.freeze({
13
- session: 24 * 1024,
14
- turn: 512,
15
- });
10
+ import { composedContext, hostContextContribution } from './host-context.js';
16
11
 
17
12
  async function optionalStack(projectRoot, stackPackages) {
18
13
  try { return await readStack(projectRoot, { stackPackages }); } catch { return null; }
@@ -47,53 +42,6 @@ async function optionalCollaboration(projectRoot) {
47
42
  }
48
43
  }
49
44
 
50
- async function hostContextContribution({ hostDriver, hostDriverInput, scope }) {
51
- if (hostDriver === undefined || hostDriver === null) {
52
- if (hostDriverInput !== undefined) {
53
- throw new GenesisError(
54
- 'HOST_CONTEXT_INVALID',
55
- 'Host context input requires an explicitly registered host driver.',
56
- );
57
- }
58
- return '';
59
- }
60
- if (typeof hostDriver !== 'function') {
61
- throw new GenesisError('HOST_CONTEXT_INVALID', 'The registered host driver must be a function.');
62
- }
63
- if (
64
- !hostDriverInput
65
- || typeof hostDriverInput !== 'object'
66
- || Array.isArray(hostDriverInput)
67
- || hostDriverInput.scope !== scope
68
- ) {
69
- throw new GenesisError(
70
- 'HOST_CONTEXT_INVALID',
71
- `The ${scope} context lane requires host input with scope \`${scope}\`.`,
72
- );
73
- }
74
- const value = await hostDriver(hostDriverInput);
75
- if (typeof value !== 'string') {
76
- throw new GenesisError('HOST_CONTEXT_INVALID', 'The registered host driver must return plain text.');
77
- }
78
- const output = normalizeSource(value).trim();
79
- if (Buffer.byteLength(output, 'utf8') > HOST_CONTEXT_MAX_BYTES[scope]) {
80
- throw new GenesisError(
81
- 'HOST_CONTEXT_INVALID',
82
- `The registered host driver's ${scope} contribution exceeds ${HOST_CONTEXT_MAX_BYTES[scope]} bytes.`,
83
- );
84
- }
85
- return output;
86
- }
87
-
88
- function composedContext(contract, output) {
89
- return {
90
- contract,
91
- status: 'ready',
92
- output,
93
- identity: sha256(stableJson({ contract, output })),
94
- };
95
- }
96
-
97
45
  async function optionalStackComponentIds(projectRoot, stack, stackPackages) {
98
46
  try {
99
47
  const packages = [...new Set([...(stack?.stackPackages || []), ...stackPackages])];
@@ -116,7 +64,8 @@ export async function projectSessionContext({
116
64
  optionalCollaboration(root),
117
65
  optionalEngineering(root),
118
66
  hostContextContribution({ hostDriver, hostDriverInput, scope: 'session' }),
119
- inspectProject({ projectRoot: root, stackPackages }),
67
+ // The opening state needs only the content kind, not a count of every ignored file.
68
+ inspectProjectAtRoot(root, { stackPackages, count: false }),
120
69
  ]);
121
70
  const availableComponents = await optionalStackComponentIds(root, stack, stackPackages);
122
71
  const skills = stack && inspection.projectFormat.status === 'current'
@@ -172,14 +121,4 @@ export async function projectSessionContext({
172
121
  return { ...composedContext(GENESIS_CONTRACTS.sessionContext, output), warnings: skills.diagnostics };
173
122
  }
174
123
 
175
- export async function projectTurnContext({ hostDriver, hostDriverInput } = {}) {
176
- const output = await hostContextContribution({
177
- hostDriver,
178
- hostDriverInput,
179
- scope: 'turn',
180
- });
181
- return {
182
- ...composedContext(GENESIS_CONTRACTS.turnContext, output),
183
- trust: 'untrusted',
184
- };
185
- }
124
+ export { projectTurnContext } from './host-context.js';
@@ -7,11 +7,39 @@ import {
7
7
  normalizeStackPieceId,
8
8
  parseStackPieceSource,
9
9
  } from './stack-piece.js';
10
+ import { sha256 } from './utils.js';
10
11
 
11
12
  const require = createRequire(import.meta.url);
12
13
  const PACKAGE_NAME = /^(?:@[a-z0-9._-]+\/)?[a-z0-9._-]+$/u;
13
14
  const FIRST_PARTY_STACK_PACKAGE = 'genesis-stack';
14
15
 
16
+ // Parsed catalogue pieces by source text. One operation reads the catalogue several times (the Stack, then the code
17
+ // index, context and prompts each read it again), and every read parsed every piece. A parse depends only on the text,
18
+ // the expected id and the path its errors name, so each distinct piece is parsed once per process; the result is
19
+ // frozen so no reader can change what another reads. Every read still reads each file, so an edited piece is parsed
20
+ // again, and a piece that fails to parse is never kept.
21
+ const MAX_PARSED_PIECES = 512;
22
+ const parsedPieces = new Map();
23
+
24
+ function deepFreeze(value) {
25
+ if (value && typeof value === 'object' && !Object.isFrozen(value)) {
26
+ Object.freeze(value);
27
+ for (const child of Object.values(value)) deepFreeze(child);
28
+ }
29
+ return value;
30
+ }
31
+
32
+ function parsedStackPiece(source, expectedId, piecePath) {
33
+ const key = `${sha256(source)}\0${expectedId}\0${piecePath}`;
34
+ let piece = parsedPieces.get(key);
35
+ if (!piece) {
36
+ piece = deepFreeze(parseStackPieceSource(source, { expectedId, path: piecePath }));
37
+ if (parsedPieces.size >= MAX_PARSED_PIECES) parsedPieces.delete(parsedPieces.keys().next().value);
38
+ parsedPieces.set(key, piece);
39
+ }
40
+ return piece;
41
+ }
42
+
15
43
  export function normalizeStackPackageName(value) {
16
44
  const name = String(value ?? '').trim();
17
45
  if (!PACKAGE_NAME.test(name)) {
@@ -94,7 +122,7 @@ async function readPieceDirectory(
94
122
  return Promise.all(files.map(async (file) => {
95
123
  const id = normalizeStackPieceId(file.slice(0, -3));
96
124
  const source = await readFile(path.join(directory, file), 'utf8');
97
- const piece = parseStackPieceSource(source, { expectedId: id, path: `${sourcePrefix}:${id}` });
125
+ const piece = parsedStackPiece(source, id, `${sourcePrefix}:${id}`);
98
126
  return {
99
127
  ...piece,
100
128
  skill: piece.skill === null ? null : { ...piece.skill, resolveFrom: stackPackageRoot },
@@ -1,8 +1,5 @@
1
- import { realpath, stat } from 'node:fs/promises';
2
- import path from 'node:path';
3
-
4
1
  import { GENESIS_CONTRACTS } from './contracts.js';
5
- import { gitContext } from './git.js';
2
+ import { inspectionRoot } from './git.js';
6
3
  import { readStack } from './stack.js';
7
4
  import { normalizeStackSectionName } from './stack-section.js';
8
5
  import { sha256, stableJson } from './utils.js';
@@ -29,18 +26,7 @@ export async function inspectProjectStackSections({
29
26
  if (new Set(sectionNames).size !== sectionNames.length) {
30
27
  throw new TypeError('Stack section inspection requires distinct names.');
31
28
  }
32
- let root;
33
- if (rootMode === 'repository') {
34
- root = (await gitContext(projectRoot)).repositoryRoot;
35
- } else if (rootMode === 'directory') {
36
- if (typeof projectRoot !== 'string' || !path.isAbsolute(projectRoot)) {
37
- throw new TypeError('Directory inspection requires an explicit absolute projectRoot.');
38
- }
39
- root = await realpath(projectRoot);
40
- if (!(await stat(root)).isDirectory()) throw new TypeError('Directory inspection requires a directory.');
41
- } else {
42
- throw new TypeError('Stack section rootMode must be repository or directory.');
43
- }
29
+ const root = await inspectionRoot({ projectRoot, rootMode, subject: 'Stack section' });
44
30
  const stack = await readStack(root, { stackPackages });
45
31
  return sectionNames.map((sectionName) => {
46
32
  const section = stack.extensions.find((candidate) => candidate.name === sectionName);