genesis-compiler 1.2.24 → 1.2.26

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.
@@ -1,82 +1,60 @@
1
- import { readFile, rm } from 'node:fs/promises';
2
- import { tmpdir } from 'node:os';
1
+ import { readFile } from 'node:fs/promises';
3
2
  import path from 'node:path';
4
3
 
5
- import { readInstalledAsset } from './assets.js';
6
- import { buildProjectIndex } from './code-index.js';
7
- import { readEngineering, readEngineeringBaseline } from './engineering.js';
8
4
  import { GenesisError } from './errors.js';
9
5
  import { gitContext } from './git.js';
10
6
  import { isProjectContentPath } from './paths.js';
11
- import { runGit, runGitText } from './process.js';
12
- import { readStack } from './stack.js';
13
- import { normalizeRelative, pathState, sha256, stableJson, writeFileAtomic } from './utils.js';
7
+ import { runGit } from './process.js';
8
+ import { normalizeRelative, pathState, writeFileAtomic } from './utils.js';
14
9
 
15
10
  const HOOKS_PATH = '.codex/hooks.json';
16
- const HOOK_STATE_SCHEMA_VERSION = 2;
17
- const HOOK_LOCATOR_SCHEMA_VERSION = 1;
11
+ const PROJECT_HOOK_ACTIONS = ['session', 'begin', 'stop', 'end'];
12
+
18
13
  function hookCommand(action) {
19
14
  const local = `const {runCli}=await import('genesis-compiler/cli');process.exitCode=await runCli(['hook','${action}'])`;
20
15
  return `if command -v genesis >/dev/null 2>&1; then genesis hook ${action}; else node --input-type=module --eval "${local}"; fi`;
21
16
  }
22
17
 
23
- const HOOK_SPECS = [
24
- {
25
- event: 'SessionStart',
26
- command: hookCommand('session'),
27
- group: {
28
- matcher: '^(startup|resume|clear|compact)$',
29
- hooks: [{
30
- type: 'command',
31
- command: hookCommand('session'),
32
- commandWindows: 'genesis hook session',
33
- timeout: 5,
34
- statusMessage: 'Loading Genesis guidance',
35
- additionalContextLimit: 4000,
36
- }],
37
- },
38
- },
39
- {
40
- event: 'UserPromptSubmit',
41
- command: hookCommand('begin'),
42
- group: {
43
- hooks: [{
44
- type: 'command',
45
- command: hookCommand('begin'),
46
- commandWindows: 'genesis hook begin',
47
- timeout: 10,
48
- }],
49
- },
50
- },
51
- {
52
- event: 'Stop',
53
- command: hookCommand('stop'),
54
- group: {
55
- hooks: [{
56
- type: 'command',
57
- command: hookCommand('stop'),
58
- commandWindows: 'genesis hook stop',
59
- timeout: 30,
60
- statusMessage: 'Checking whether Genesis follow-up work is needed',
61
- }],
62
- },
18
+ const SESSION_HOOK = {
19
+ event: 'SessionStart',
20
+ group: {
21
+ matcher: '^(startup|clear|compact)$',
22
+ hooks: [{
23
+ type: 'command',
24
+ command: hookCommand('session'),
25
+ commandWindows: 'genesis hook session',
26
+ timeout: 5,
27
+ statusMessage: 'Loading Genesis guidance',
28
+ additionalContextLimit: 4000,
29
+ }],
63
30
  },
64
- {
65
- event: 'SessionEnd',
66
- command: hookCommand('end'),
67
- group: {
68
- hooks: [{
69
- type: 'command',
70
- command: hookCommand('end'),
71
- commandWindows: 'genesis hook end',
72
- timeout: 3,
73
- }],
74
- },
75
- },
76
- ];
31
+ };
32
+
33
+ const OBSOLETE_PROJECT_HOOK_COMMANDS = new Set(PROJECT_HOOK_ACTIONS.flatMap((action) => [
34
+ hookCommand(action),
35
+ `genesis hook ${action}`,
36
+ ]));
77
37
 
78
- function genesisHookInstalled(groups, command) {
79
- return groups.some((group) => group?.hooks?.some((hook) => hook?.command === command));
38
+ function genesisProjectHook(hook) {
39
+ return hook && (
40
+ OBSOLETE_PROJECT_HOOK_COMMANDS.has(hook.command)
41
+ || OBSOLETE_PROJECT_HOOK_COMMANDS.has(hook.commandWindows)
42
+ );
43
+ }
44
+
45
+ function removeGenesisProjectHooks(hooks) {
46
+ for (const [event, groups] of Object.entries(hooks)) {
47
+ if (!Array.isArray(groups)) {
48
+ throw new GenesisError('CODEX_HOOKS_INVALID', `${HOOKS_PATH} hooks.${event} must be an array.`);
49
+ }
50
+ const retained = groups.flatMap((group) => {
51
+ if (!Array.isArray(group?.hooks)) return [group];
52
+ const groupHooks = group.hooks.filter((hook) => !genesisProjectHook(hook));
53
+ return groupHooks.length > 0 ? [{ ...group, hooks: groupHooks }] : [];
54
+ });
55
+ if (retained.length > 0) hooks[event] = retained;
56
+ else delete hooks[event];
57
+ }
80
58
  }
81
59
 
82
60
  async function installedHooksSource(location) {
@@ -101,77 +79,17 @@ export async function installCodexHooks({ projectRoot } = {}) {
101
79
  const location = path.join(root, HOOKS_PATH);
102
80
  const { source, value } = await installedHooksSource(location);
103
81
  value.hooks ||= {};
104
- for (const spec of HOOK_SPECS) {
105
- const groups = value.hooks[spec.event] ||= [];
106
- if (!Array.isArray(groups)) {
107
- throw new GenesisError('CODEX_HOOKS_INVALID', `${HOOKS_PATH} hooks.${spec.event} must be an array.`);
108
- }
109
- if (!genesisHookInstalled(groups, spec.command)) groups.push(spec.group);
110
- }
82
+ removeGenesisProjectHooks(value.hooks);
83
+ const groups = value.hooks[SESSION_HOOK.event] ||= [];
84
+ groups.push(SESSION_HOOK.group);
111
85
  const rendered = `${JSON.stringify(value, null, 2)}\n`;
112
86
  if (source === rendered) return { status: 'unchanged', changedFiles: [] };
113
87
  await writeFileAtomic(location, rendered);
114
88
  return { status: 'updated', changedFiles: [HOOKS_PATH] };
115
89
  }
116
90
 
117
- async function optionalStack(projectRoot) {
118
- try { return await readStack(projectRoot); } catch { return null; }
119
- }
120
-
121
- async function optionalEngineering(projectRoot) {
122
- try {
123
- return await readEngineering(projectRoot);
124
- } catch {
125
- return {
126
- guidance: [
127
- '## Universal complexity gate',
128
- '',
129
- await readEngineeringBaseline(),
130
- '',
131
- 'The project engineering profile is invalid. Do not infer a replacement; run the Genesis `check` operation and ask the user before implementation if the selected approach matters.',
132
- ].join('\n'),
133
- profile: null,
134
- status: 'invalid',
135
- };
136
- }
137
- }
138
-
139
- export async function codexSessionContext({ projectRoot } = {}) {
140
- const root = (await gitContext(projectRoot)).repositoryRoot;
141
- const [stack, engineering] = await Promise.all([
142
- optionalStack(root),
143
- optionalEngineering(root),
144
- ]);
145
- const selected = stack?.components.map(({ id }) => id) || [];
146
- const stackStatus = stack
147
- ? (selected.length > 0 ? selected.join(', ') : 'none')
148
- : 'unavailable; run the Genesis `check` operation';
149
- return {
150
- status: 'ready',
151
- output: [
152
- 'This is a Genesis-enriched project.',
153
- '- `genesis/blueprint.md` describes non-technical product intent.',
154
- '- `genesis/engineering.md` selects the project engineering approach and any project-specific requirements.',
155
- '- `genesis/stack.md` selects optional technology guidance and verification.',
156
- '- `genesis/program/<subsystem>/` explains public operations and useful internal seams.',
157
- '- Project Agent Skills live in `.agents/skills/`; load applicable skills progressively.',
158
- '- When this repository is Genesis itself or has local `genesis-compiler`, run every Genesis CLI operation as `npm exec --no -- genesis <arguments>` so the project-pinned version is used without fetching another package. Otherwise use `genesis <arguments>` only when that executable is already available on `PATH`. Never install or update Genesis merely to run a workflow instruction.',
159
- '- After locating relevant source files, run the Genesis `context <path...>` operation to load their Program and selected Stack material.',
160
- '- Before creating a helper or public operation, run the Genesis `index <name-or-path>` operation and reuse an existing function when it already owns the behavior.',
161
- '- `.genesis/machine-city.json` is the detailed code/function map; `.genesis/program-city.json` is the simpler subsystem/operation map.',
162
- '- Program is fallible explanation; code, tests, and runtime behavior remain evidence.',
163
- '- Before the first project-file edit in a user-requested implementation turn, silently run the Genesis `hook authorize` operation once in every Git repository you will edit. This is internal Genesis bookkeeping, not a request for user permission: do not ask the user, announce successful authorization, or include it in progress or final summaries. Mention it only if the command fails.',
164
- '- Authorization is turn-scoped. Run it again for a later code-changing turn after a user follow-up or interruption, but not again within the same active turn after it succeeds. The command must report `authorize: authorized` or `authorize: unchanged`; otherwise stop before editing. Never authorize answer, explanation, review, diagnosis-only, reconciliation, or Deslop turns.',
165
- '- Apply the engineering approach below to implementation and cleanup; the Stop hook owns optional Stack Post-change work, Blueprint/Program reconciliation, and Deslop turns.',
166
- '- After an authorized code-changing turn, Genesis may request separate post-change, explanation, and cleanup turns.',
167
- `Engineering profile: ${engineering.profile?.id || 'invalid; run the Genesis \`check\` operation'}.`,
168
- `Selected Stack components: ${stackStatus}.`,
169
- '',
170
- 'ENGINEERING APPROACH',
171
- '',
172
- engineering.guidance,
173
- ].join('\n'),
174
- };
91
+ function zeroPaths(buffer) {
92
+ return [...new Set(buffer.toString('utf8').split('\0').map(normalizeRelative).filter(Boolean))];
175
93
  }
176
94
 
177
95
  export async function codexAdoptionRecommendation({ projectRoot = process.cwd() } = {}) {
@@ -197,512 +115,3 @@ export async function codexAdoptionRecommendation({ projectRoot = process.cwd()
197
115
  ].join('\n'),
198
116
  };
199
117
  }
200
-
201
- function hookId(input, field) {
202
- const value = input?.[field];
203
- if (typeof value !== 'string' || !value || value.length > 512) {
204
- throw new GenesisError('CODEX_HOOK_INPUT_INVALID', `Codex hook input requires ${field}.`);
205
- }
206
- return value;
207
- }
208
-
209
- function normalizedPaths(values = []) {
210
- return [...new Set(values.map(normalizeRelative).filter(Boolean))];
211
- }
212
-
213
- function zeroPaths(buffer) {
214
- return normalizedPaths(buffer.toString('utf8').split('\0'));
215
- }
216
-
217
- function projectPathStates(projectRoot, files) {
218
- return Promise.all(files.map(async (file) => ({
219
- path: file,
220
- state: await pathState(path.join(projectRoot, file)),
221
- })));
222
- }
223
-
224
- async function repositorySnapshot(projectRoot) {
225
- const head = await runGitText(projectRoot, ['rev-parse', '--verify', 'HEAD']).catch(() => null);
226
- const results = await Promise.all([
227
- runGit(projectRoot, ['diff', '--no-ext-diff', '--no-renames', '--name-only', '-z']),
228
- runGit(projectRoot, ['diff', '--cached', '--no-ext-diff', '--no-renames', '--name-only', '-z']),
229
- runGit(projectRoot, ['ls-files', '-z', '--others', '--exclude-standard']),
230
- runGit(projectRoot, ['ls-files', '-z', '--deleted']),
231
- ]);
232
- const dirty = [...new Set(results.flatMap(({ stdout }) => zeroPaths(stdout)))]
233
- .filter(isProjectContentPath)
234
- .sort();
235
- return {
236
- head,
237
- dirty: await projectPathStates(projectRoot, dirty),
238
- };
239
- }
240
-
241
- async function hookStatePath(projectRoot, input) {
242
- const key = sha256(hookId(input, 'session_id')).slice('sha256:'.length);
243
- const gitPath = await runGitText(projectRoot, ['rev-parse', '--git-path', `genesis/hooks/${key}.json`]);
244
- return path.resolve(projectRoot, gitPath);
245
- }
246
-
247
- function hookLocatorPath(input) {
248
- const key = sha256(hookId(input, 'session_id')).slice('sha256:'.length);
249
- const user = typeof process.getuid === 'function' ? process.getuid() : 'user';
250
- return path.join(tmpdir(), `genesis-codex-hook-${user}-${key}.json`);
251
- }
252
-
253
- function writeTurnState(statePath, state) {
254
- return writeFileAtomic(statePath, stableJson(state), { mode: 0o600 });
255
- }
256
-
257
- async function refreshProjectIndex(projectRoot) {
258
- await buildProjectIndex({ projectRoot }).catch(() => null);
259
- }
260
-
261
- export async function recordCodexTurn({ input, projectRoot } = {}) {
262
- const root = (await gitContext(projectRoot || input?.cwd)).repositoryRoot;
263
- const statePath = await hookStatePath(root, input);
264
- const turnId = hookId(input, 'turn_id');
265
- const existing = await readTurnState(statePath);
266
- if (existing?.schemaVersion === HOOK_STATE_SCHEMA_VERSION && existing.turnId === turnId) {
267
- await writeHookLocator(input, root);
268
- return { status: 'preserved' };
269
- }
270
- const repositories = registeredRepositories(existing || {}, root);
271
- const state = {
272
- schemaVersion: HOOK_STATE_SCHEMA_VERSION,
273
- turnId,
274
- phase: 'implementation',
275
- implementationAuthorized: false,
276
- repositories,
277
- snapshot: repositories.length > 0 && existing?.snapshot
278
- ? existing.snapshot
279
- : await repositorySnapshot(root),
280
- };
281
- await writeTurnState(statePath, state);
282
- await writeHookLocator(input, root);
283
- return { status: 'recorded' };
284
- }
285
-
286
- async function readTurnState(statePath) {
287
- try { return JSON.parse(await readFile(statePath, 'utf8')); } catch (error) {
288
- if (['ENOENT', 'ENOTDIR'].includes(error?.code)) return null;
289
- throw error;
290
- }
291
- }
292
-
293
- async function readHookLocator(input) {
294
- try { return JSON.parse(await readFile(hookLocatorPath(input), 'utf8')); } catch (error) {
295
- if (['ENOENT', 'ENOTDIR'].includes(error?.code)) return null;
296
- throw error;
297
- }
298
- }
299
-
300
- function writeHookLocator(input, coordinatorRoot) {
301
- return writeFileAtomic(hookLocatorPath(input), stableJson({
302
- schemaVersion: HOOK_LOCATOR_SCHEMA_VERSION,
303
- coordinatorRoot,
304
- }), { mode: 0o600 });
305
- }
306
-
307
- async function removeTurnState(statePath, input) {
308
- await Promise.all([
309
- rm(statePath, { force: true }),
310
- rm(hookLocatorPath(input), { force: true }),
311
- ]);
312
- }
313
-
314
- function registeredRepositories(state, coordinatorRoot) {
315
- if (Array.isArray(state.repositories) && state.repositories.length > 0) {
316
- return state.repositories;
317
- }
318
- if (state.implementationAuthorized === true && state.snapshot) {
319
- return [{
320
- projectRoot: coordinatorRoot,
321
- snapshot: state.snapshot,
322
- changedPaths: normalizedPaths(state.changedPaths || []).sort(),
323
- }];
324
- }
325
- return [];
326
- }
327
-
328
- export async function authorizeCodexTurn({
329
- projectRoot = process.cwd(),
330
- sessionId = process.env.CODEX_SESSION_ID,
331
- } = {}) {
332
- if (!sessionId) {
333
- throw new GenesisError(
334
- 'CODEX_SESSION_ID_MISSING',
335
- 'genesis hook authorize requires the current Codex session.',
336
- );
337
- }
338
- const input = { session_id: sessionId };
339
- const root = (await gitContext(projectRoot)).repositoryRoot;
340
- const locator = await readHookLocator(input);
341
- if (locator && (
342
- locator.schemaVersion !== HOOK_LOCATOR_SCHEMA_VERSION
343
- || typeof locator.coordinatorRoot !== 'string'
344
- || !locator.coordinatorRoot
345
- )) {
346
- throw new GenesisError(
347
- 'CODEX_HOOK_COORDINATOR_INVALID',
348
- 'The current Codex turn has invalid Genesis coordination state.',
349
- );
350
- }
351
- const coordinatorRoot = locator
352
- ? (await gitContext(locator.coordinatorRoot)).repositoryRoot
353
- : root;
354
- const statePath = await hookStatePath(coordinatorRoot, input);
355
- const state = await readTurnState(statePath);
356
- if (!state || state.schemaVersion !== HOOK_STATE_SCHEMA_VERSION || state.phase !== 'implementation') {
357
- throw new GenesisError(
358
- 'CODEX_HOOK_TURN_UNAVAILABLE',
359
- 'No active Genesis implementation turn is available. Start from the session repository and authorize before editing.',
360
- );
361
- }
362
- const repositories = registeredRepositories(state, coordinatorRoot);
363
- const registered = repositories.some((repository) => repository.projectRoot === root);
364
- if (!registered) {
365
- repositories.push({
366
- projectRoot: root,
367
- snapshot: root === coordinatorRoot && state.snapshot
368
- ? state.snapshot
369
- : await repositorySnapshot(root),
370
- changedPaths: [],
371
- });
372
- }
373
- await writeTurnState(statePath, {
374
- ...state,
375
- implementationAuthorized: true,
376
- repositories,
377
- });
378
- await writeHookLocator(input, coordinatorRoot);
379
- return {
380
- status: registered && state.implementationAuthorized === true
381
- ? 'unchanged'
382
- : 'authorized',
383
- };
384
- }
385
-
386
- async function committedPaths(projectRoot, before, after) {
387
- if (before === after) return [];
388
- if (!after) return ['repository history changed'];
389
- try {
390
- const args = before
391
- ? ['diff', '--no-renames', '--name-only', '-z', before, after]
392
- : ['diff-tree', '--root', '--no-commit-id', '--no-renames', '--name-only', '-r', '-z', after];
393
- return zeroPaths((await runGit(projectRoot, args)).stdout).filter(isProjectContentPath);
394
- } catch {
395
- return ['repository history changed'];
396
- }
397
- }
398
-
399
- async function changedProjectPaths(projectRoot, before, after) {
400
- const previous = new Map(before.dirty.map(({ path: file, state }) => [file, state]));
401
- const current = new Map(after.dirty.map(({ path: file, state }) => [file, state]));
402
- const dirtyChanges = [...new Set([...previous.keys(), ...current.keys()])].filter((file) => (
403
- stableJson(previous.get(file)) !== stableJson(current.get(file))
404
- ));
405
- const candidates = [...new Set([
406
- ...dirtyChanges,
407
- ...await committedPaths(projectRoot, before.head, after.head),
408
- ])].sort();
409
- const changed = await Promise.all(candidates.map(async (file) => {
410
- if (!previous.has(file)) return file;
411
- // Moving pre-turn file bytes into HEAD is a save operation, not new implementation.
412
- const finalState = current.has(file)
413
- ? current.get(file)
414
- : await pathState(path.join(projectRoot, file));
415
- return stableJson(previous.get(file)) === stableJson(finalState) ? null : file;
416
- }));
417
- return changed.filter(Boolean);
418
- }
419
-
420
- function changedPathLines(changedPaths) {
421
- const shown = changedPaths.slice(0, 50);
422
- const omitted = changedPaths.length - shown.length;
423
- return [
424
- ...shown.map((file) => `- ${file}`),
425
- ...(omitted > 0 ? [`- ... and ${omitted} more`] : []),
426
- ];
427
- }
428
-
429
- function changedRepositoryLines(repositories) {
430
- return repositories.flatMap(({ projectRoot, changedPaths, driftedPaths = [] }) => [
431
- `## Repository: \`${projectRoot}\``,
432
- '',
433
- ...changedPathLines(changedPaths),
434
- ...(driftedPaths.length > 0 ? [
435
- '',
436
- '### Concurrent changes excluded from cleanup',
437
- '',
438
- 'These paths changed after the implementation scope was frozen. Report them, but do not edit them:',
439
- '',
440
- ...changedPathLines(driftedPaths),
441
- ] : []),
442
- '',
443
- ]);
444
- }
445
-
446
- async function changedRepositories(repositories, { snapshotField = 'snapshot' } = {}) {
447
- return (await Promise.all(repositories.map(async (repository) => {
448
- const currentPaths = await changedProjectPaths(
449
- repository.projectRoot,
450
- repository[snapshotField] || repository.snapshot,
451
- await repositorySnapshot(repository.projectRoot),
452
- );
453
- const changedPaths = normalizedPaths([
454
- ...(repository.changedPaths || []),
455
- ...currentPaths,
456
- ]).sort();
457
- return changedPaths.length > 0 ? { ...repository, changedPaths } : null;
458
- }))).filter(Boolean);
459
- }
460
-
461
- async function freezeChangedRepositories(repositories, { startPostChange = false } = {}) {
462
- return Promise.all(repositories.map(async (repository) => {
463
- const changedPaths = normalizedPaths(repository.changedPaths || []).sort();
464
- const [pathStates, phaseSnapshot] = await Promise.all([
465
- projectPathStates(repository.projectRoot, changedPaths),
466
- startPostChange ? repositorySnapshot(repository.projectRoot) : null,
467
- ]);
468
- const frozen = {
469
- ...repository,
470
- changedPaths,
471
- pathStates,
472
- };
473
- delete frozen.driftedPaths;
474
- delete frozen.phaseSnapshot;
475
- if (phaseSnapshot) frozen.phaseSnapshot = phaseSnapshot;
476
- return frozen;
477
- }));
478
- }
479
-
480
- async function cleanupScopeRepositories(repositories) {
481
- return (await Promise.all(repositories.map(async (repository) => {
482
- const ownedPaths = normalizedPaths(repository.changedPaths || []).sort();
483
- if (ownedPaths.length === 0) return null;
484
- const expected = new Map((repository.pathStates || [])
485
- .map(({ path: file, state }) => [file, state]));
486
- const changedPaths = [];
487
- const driftedPaths = [];
488
- for (const { path: file, state } of await projectPathStates(repository.projectRoot, ownedPaths)) {
489
- const destination = expected.has(file) && stableJson(expected.get(file)) !== stableJson(state)
490
- ? driftedPaths
491
- : changedPaths;
492
- destination.push(file);
493
- }
494
- return {
495
- ...repository,
496
- changedPaths,
497
- driftedPaths,
498
- };
499
- }))).filter(Boolean);
500
- }
501
-
502
- async function repositoryContexts(repositories) {
503
- return Promise.all(repositories.map(async (repository) => {
504
- const [stack, engineering] = await Promise.all([
505
- optionalStack(repository.projectRoot),
506
- optionalEngineering(repository.projectRoot),
507
- ]);
508
- return { ...repository, stack, engineering };
509
- }));
510
- }
511
-
512
- function repositoryGuidanceLines({ projectRoot, stack, engineering }, {
513
- deslop = false,
514
- postChange = false,
515
- } = {}) {
516
- return [
517
- `## Repository: \`${projectRoot}\``,
518
- '',
519
- 'ENGINEERING APPROACH',
520
- '',
521
- engineering.guidance,
522
- ...(stack?.guidance ? ['', 'SELECTED STACK GUIDANCE', '', stack.guidance] : []),
523
- ...(postChange && stack?.postChange
524
- ? ['', 'COMPOSED STACK POST-CHANGE WORK', '', stack.postChange]
525
- : []),
526
- ...(deslop && stack?.deslop
527
- ? ['', 'SELECTED STACK CLEANUP GUIDANCE', '', stack.deslop]
528
- : []),
529
- '',
530
- ];
531
- }
532
-
533
- async function reconciliationContinuation(repositories, knownContexts) {
534
- const [instructions, contexts] = await Promise.all([
535
- readInstalledAsset('reconcile'),
536
- knownContexts || repositoryContexts(repositories),
537
- ]);
538
- return [
539
- 'This is the automatic Genesis explanation turn for the preceding implementation turn.',
540
- 'Use the same conversation context and the Git-visible changes below in every listed repository. Do not perform Deslop yet.',
541
- '',
542
- 'REPOSITORIES AND CHANGED PROJECT PATHS',
543
- '',
544
- ...changedRepositoryLines(repositories),
545
- '',
546
- instructions.trim(),
547
- '',
548
- 'REPOSITORY GUIDANCE',
549
- '',
550
- ...contexts.flatMap((context) => repositoryGuidanceLines(context)),
551
- ].join('\n');
552
- }
553
-
554
- function postChangeContinuation(repositories, contexts) {
555
- return [
556
- 'This is the one automatic Stack Post-change turn for the preceding implementation.',
557
- 'Use the same conversation context and the Git-visible changes below in every listed repository.',
558
- 'Perform only the composed Post-change work. Do not broaden the task, reconcile Genesis explanations, or perform Deslop yet.',
559
- 'Do not repeat or schedule another Post-change turn. If none of the configured work applies, make no changes and respond minimally.',
560
- '',
561
- 'REPOSITORIES AND CHANGED PROJECT PATHS',
562
- '',
563
- ...changedRepositoryLines(repositories),
564
- '',
565
- 'REPOSITORY GUIDANCE AND POST-CHANGE WORK',
566
- '',
567
- ...contexts.flatMap((context) => repositoryGuidanceLines(context, { postChange: true })),
568
- ].join('\n');
569
- }
570
-
571
- async function deslopContinuation(repositories) {
572
- const [instructions, contexts] = await Promise.all([
573
- readInstalledAsset('deslop'),
574
- repositoryContexts(repositories),
575
- ]);
576
- return [
577
- 'This is the final automatic Genesis Deslop turn for the preceding implementation.',
578
- 'Review every listed repository and only that work and the paths listed below. Do not broaden the task.',
579
- '',
580
- 'REPOSITORIES AND CHANGED PROJECT PATHS',
581
- '',
582
- ...changedRepositoryLines(repositories),
583
- '',
584
- instructions.trim(),
585
- '',
586
- 'REPOSITORY GUIDANCE',
587
- '',
588
- ...contexts.flatMap((context) => repositoryGuidanceLines(context, { deslop: true })),
589
- '',
590
- 'PROGRAM CITATION MAINTENANCE',
591
- '',
592
- 'If this cleanup renames or moves a source or helper cited by an affected Program module,',
593
- 'update only that module\'s Sources or informational Implementation map. This narrow',
594
- 'exception overrides the default prohibition on editing `genesis/`; never change the',
595
- 'Blueprint or a Program public contract to excuse cleanup behavior.',
596
- ].join('\n');
597
- }
598
-
599
- async function refreshProjectIndexes(repositories) {
600
- await Promise.all(repositories.map(({ projectRoot }) => refreshProjectIndex(projectRoot)));
601
- }
602
-
603
- export async function completeCodexTurn({ input, projectRoot } = {}) {
604
- const root = (await gitContext(projectRoot || input?.cwd)).repositoryRoot;
605
- const statePath = await hookStatePath(root, input);
606
- const state = await readTurnState(statePath);
607
- if (!state || state.schemaVersion !== HOOK_STATE_SCHEMA_VERSION) return {};
608
-
609
- if (state.implementationAuthorized !== true) {
610
- const repositories = await changedRepositories(registeredRepositories(state, root));
611
- if (repositories.length === 0) {
612
- await removeTurnState(statePath, input);
613
- return {};
614
- }
615
- await writeTurnState(statePath, {
616
- ...state,
617
- phase: 'implementation',
618
- repositories,
619
- });
620
- return {};
621
- }
622
-
623
- if (state.phase === 'implementation') {
624
- if (input?.stop_hook_active === true || state.turnId !== hookId(input, 'turn_id')) {
625
- await removeTurnState(statePath, input);
626
- return {};
627
- }
628
- const changed = await changedRepositories(registeredRepositories(state, root));
629
- if (changed.length === 0) {
630
- await removeTurnState(statePath, input);
631
- return {};
632
- }
633
- const contexts = await repositoryContexts(changed);
634
- const postChange = contexts.some(({ stack }) => Boolean(stack?.postChange?.trim()));
635
- const repositories = await freezeChangedRepositories(changed, { startPostChange: postChange });
636
- await writeTurnState(statePath, {
637
- ...state,
638
- phase: postChange ? 'post-change' : 'reconcile',
639
- repositories,
640
- });
641
- return {
642
- decision: 'block',
643
- reason: postChange
644
- ? postChangeContinuation(repositories, contexts)
645
- : await reconciliationContinuation(repositories, contexts),
646
- };
647
- }
648
-
649
- if (input?.stop_hook_active !== true) {
650
- await removeTurnState(statePath, input);
651
- return {};
652
- }
653
-
654
- if (state.phase === 'post-change') {
655
- const changed = await changedRepositories(registeredRepositories(state, root), {
656
- snapshotField: 'phaseSnapshot',
657
- });
658
- if (changed.length === 0) {
659
- await removeTurnState(statePath, input);
660
- return {};
661
- }
662
- const repositories = await freezeChangedRepositories(changed);
663
- await writeTurnState(statePath, {
664
- ...state,
665
- phase: 'reconcile',
666
- repositories,
667
- });
668
- return {
669
- decision: 'block',
670
- reason: await reconciliationContinuation(repositories),
671
- };
672
- }
673
-
674
- if (state.phase === 'reconcile') {
675
- const registered = registeredRepositories(state, root);
676
- const repositories = await cleanupScopeRepositories(registered);
677
- if (repositories.length === 0) {
678
- await refreshProjectIndexes(registered);
679
- await removeTurnState(statePath, input);
680
- return {};
681
- }
682
- await refreshProjectIndexes(repositories);
683
- await writeTurnState(statePath, {
684
- ...state,
685
- phase: 'deslop',
686
- repositories,
687
- });
688
- return {
689
- decision: 'block',
690
- reason: await deslopContinuation(repositories),
691
- };
692
- }
693
-
694
- if (state.phase === 'deslop') {
695
- await refreshProjectIndexes(registeredRepositories(state, root));
696
- await removeTurnState(statePath, input);
697
- return {};
698
- }
699
-
700
- await removeTurnState(statePath, input);
701
- return {};
702
- }
703
-
704
- export async function discardCodexTurn({ input, projectRoot } = {}) {
705
- const root = (await gitContext(projectRoot || input?.cwd)).repositoryRoot;
706
- await removeTurnState(await hookStatePath(root, input), input);
707
- return { status: 'discarded' };
708
- }