@orkestrel/scaffold 0.0.103 → 0.0.104

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.
Files changed (88) hide show
  1. package/dist/agents/skills/orkestrel-dispatch/scripts/helpers.js +84 -1
  2. package/dist/agents/skills/orkestrel-dispatch/scripts/launch.js +21 -6
  3. package/dist/agents/skills/orkestrel-harden/scripts/titles.js +338 -0
  4. package/dist/agents/skills/orkestrel-publish/scripts/swap.js +3 -39
  5. package/dist/agents/skills/orkestrel-publish/scripts/wave.js +433 -47
  6. package/dist/agents/skills/orkestrel-publish/scripts/window.js +2 -1
  7. package/dist/bin/main.js +16 -9
  8. package/dist/bin/main.js.map +1 -1
  9. package/dist/host/agents/orchestration.md +3 -3
  10. package/dist/host/agents/skills/enterprise-bootstrap/references/components.md +2 -2
  11. package/dist/host/agents/skills/enterprise-bootstrap/references/inspection.md +1 -1
  12. package/dist/host/agents/skills/orkestrel-build/SKILL.md +2 -2
  13. package/dist/host/agents/skills/orkestrel-debrief/SKILL.md +2 -2
  14. package/dist/host/agents/skills/orkestrel-debrief/references/field-testing.md +1 -1
  15. package/dist/host/agents/skills/orkestrel-debrief/references/instruction-audit.md +3 -3
  16. package/dist/host/agents/skills/orkestrel-dispatch/SKILL.md +10 -10
  17. package/dist/host/agents/skills/orkestrel-dispatch/references/launch.md +1 -0
  18. package/dist/host/agents/skills/orkestrel-dispatch/scripts/helpers.ts +100 -1
  19. package/dist/host/agents/skills/orkestrel-dispatch/scripts/launch.ts +27 -6
  20. package/dist/host/agents/skills/orkestrel-falsify/SKILL.md +4 -4
  21. package/dist/host/agents/skills/orkestrel-falsify/agents/openai.yaml +1 -1
  22. package/dist/host/agents/skills/orkestrel-falsify/references/brief.md +8 -7
  23. package/dist/host/agents/skills/orkestrel-falsify/references/reconcile.md +6 -6
  24. package/dist/host/agents/skills/orkestrel-harden/SKILL.md +6 -1
  25. package/dist/host/agents/skills/orkestrel-harden/references/bindings.md +1700 -0
  26. package/dist/host/agents/skills/orkestrel-harden/references/contract.md +2 -2
  27. package/dist/host/agents/skills/orkestrel-harden/references/equivalence.md +48 -0
  28. package/dist/host/agents/skills/orkestrel-harden/references/hardening.md +4 -2
  29. package/dist/host/agents/skills/orkestrel-harden/scripts/titles.ts +425 -0
  30. package/dist/host/agents/skills/orkestrel-journey/SKILL.md +2 -2
  31. package/dist/host/agents/skills/orkestrel-journey/references/captures.md +9 -8
  32. package/dist/host/agents/skills/orkestrel-polish/SKILL.md +3 -3
  33. package/dist/host/agents/skills/orkestrel-publish/SKILL.md +2 -2
  34. package/dist/host/agents/skills/orkestrel-publish/references/release.md +1 -0
  35. package/dist/host/agents/skills/orkestrel-publish/references/wave.md +56 -13
  36. package/dist/host/agents/skills/orkestrel-publish/references/window.md +6 -6
  37. package/dist/host/agents/skills/orkestrel-publish/scripts/swap.ts +5 -49
  38. package/dist/host/agents/skills/orkestrel-publish/scripts/wave.ts +646 -49
  39. package/dist/host/agents/skills/orkestrel-publish/scripts/window.ts +2 -1
  40. package/dist/host/agents/transports/codex.md +1 -1
  41. package/dist/host/claude/agents/orkestrel.md +54 -53
  42. package/dist/host/claude/agents/reviewer.md +4 -4
  43. package/dist/host/claude/rules/documentation.md +2 -1
  44. package/dist/host/claude/rules/patterns.md +1 -1
  45. package/dist/host/claude/rules/quality.md +2 -2
  46. package/dist/host/claude/rules/styles.md +1 -1
  47. package/dist/host/claude/rules/tests.md +34 -8
  48. package/dist/host/claude/rules/typescript.md +29 -0
  49. package/dist/host/claude/rules/workspace.md +32 -22
  50. package/dist/host/claude/rules/writing.md +34 -26
  51. package/dist/host/claude/settings.json +2 -0
  52. package/dist/host/claude/skills/orkestrel-debrief/SKILL.md +1 -1
  53. package/dist/host/claude/skills/orkestrel-falsify/SKILL.md +1 -1
  54. package/dist/host/codex/agents/analyst.toml +3 -3
  55. package/dist/host/codex/agents/reviewer.toml +4 -4
  56. package/dist/host/configs/policy.ts +13 -2
  57. package/dist/host/dotfiles/editorconfig +0 -1
  58. package/dist/host/dotfiles/gitattributes +0 -1
  59. package/dist/host/dotfiles/gitignore +0 -1
  60. package/dist/host/dotfiles/oxlintrc.json +24 -0
  61. package/dist/host/guides/agent.md +1638 -598
  62. package/dist/host/guides/budget.md +89 -29
  63. package/dist/host/guides/form.md +477 -91
  64. package/dist/host/guides/mcp.md +14 -2
  65. package/dist/host/guides/ollama.md +97 -73
  66. package/dist/host/guides/probe.md +1 -1
  67. package/dist/host/guides/scaffold.md +158 -101
  68. package/dist/host/guides/study.md +2635 -0
  69. package/dist/host/guides/table.md +225 -15
  70. package/dist/host/guides/terminal.md +22 -20
  71. package/dist/host/guides/test.md +689 -255
  72. package/dist/host/manifest.json +87 -63
  73. package/dist/host/tests/config.test.ts +136 -82
  74. package/dist/host/tests/policy.test.ts +39 -1
  75. package/dist/host/tests/setupPolicy.ts +86 -28
  76. package/dist/src/core/index.cjs +1387 -611
  77. package/dist/src/core/index.cjs.map +1 -1
  78. package/dist/src/core/index.d.cts +35 -20
  79. package/dist/src/core/index.d.ts +35 -20
  80. package/dist/src/core/index.js +1385 -612
  81. package/dist/src/core/index.js.map +1 -1
  82. package/dist/src/server/index.cjs +12 -12
  83. package/dist/src/server/index.cjs.map +1 -1
  84. package/dist/src/server/index.d.cts +12 -12
  85. package/dist/src/server/index.d.ts +12 -12
  86. package/dist/src/server/index.js +12 -12
  87. package/dist/src/server/index.js.map +1 -1
  88. package/package.json +4 -4
@@ -1,17 +1,17 @@
1
1
  // Run one repository's release visit, or print the fleet's layer order. Run from the checkout root:
2
- // node .agents/skills/orkestrel-publish/scripts/wave.ts --visit [--target DIR] [--offline] [--from STEP] [--to STEP] [--prior VERSION] [--dry-run] [--json] [--out FILE]
2
+ // node .agents/skills/orkestrel-publish/scripts/wave.ts --visit [--target DIR] [--offline] [--regenerate] [--from STEP] [--to STEP] [--prior VERSION] [--dry-run] [--json] [--out FILE]
3
3
  // node .agents/skills/orkestrel-publish/scripts/wave.ts --plan [--catalog PATH] [--json]
4
4
  // --visit runs the visit `wave.md` § Visit a repository fixes, in order, stopping at the first step
5
5
  // that fails: pin (read `scaffold audit --json`, whose drift exit is the reading the pin closes, then
6
- // re-pin every @orkestrel range to the registry caret and install), commit (`git commit --only` of
6
+ // re-pin every range the audit release readings name to the registry caret and install), commit (`git commit --only` of
7
7
  // the manifest and lockfile as the preparation commit), overwrite (`scaffold overwrite --json`, then
8
8
  // `scaffold audit` exiting 0; offline, an exit of 1 whose note names the catalog refusal is
9
9
  // accepted; on @orkestrel/scaffold itself both are skipped with a note, because the vendored host
10
10
  // is staged from that checkout), verify (`scaffold audit --json` prints the releases envelope and
11
- // every declared range matches the registry; on @orkestrel/scaffold itself an audit exit of 1 is
11
+ // the audit's writable dependency population matches its lookup bounds; on @orkestrel/scaffold itself an audit exit of 1 is
12
12
  // read as the expected foreign-path findings of its own canonical sources), install,
13
13
  // pins (the self-pin sweep with --prior or the manifest version and every range the visit moved; hits are reported, not
14
- // fatal), format, gates (format:check, lint:check, check, build, test), and compare (the rebuilt
14
+ // fatal), format, record (commit the overwrite paths), gates (format:check, lint:check, check, build, test), and compare (the rebuilt
15
15
  // dist against the published tarball, and the final runtime dependency set against the published
16
16
  // manifest `npm view NAME --json` serves; both readings are reported, not fatal). The summary
17
17
  // carries each step's exit and duration, a failed step's refusal (the `code: message` envelope or
@@ -19,14 +19,29 @@
19
19
  // Markdown row for `.orkestrel/release.md`. --dry-run prints the commands and runs nothing, so its
20
20
  // ruling is unanswered. --plan reads the marker-bounded catalog table in
21
21
  // `.claude/agents/orkestrel.md` and prints the packages by layer. Exit 0 when every step passed, 1
22
- // when a step failed, 2 when a reading failed, 64 on usage.
22
+ // when a step failed, 2 when a reading failed, 64 on usage, including --plan --regenerate.
23
23
  import type { SpawnSyncReturns } from 'node:child_process'
24
24
  import { spawnSync } from 'node:child_process'
25
- import { existsSync, readFileSync, mkdirSync, writeFileSync } from 'node:fs'
26
- import { basename, dirname, extname, join } from 'node:path'
25
+ import { createHash } from 'node:crypto'
26
+ import {
27
+ copyFileSync,
28
+ existsSync,
29
+ lstatSync,
30
+ readFileSync,
31
+ mkdirSync,
32
+ realpathSync,
33
+ renameSync,
34
+ unlinkSync,
35
+ writeFileSync,
36
+ } from 'node:fs'
37
+ import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from 'node:path'
27
38
  import { fileURLToPath } from 'node:url'
28
39
  import {
29
40
  parseJsonRecord,
41
+ isRecord,
42
+ normalizeSlashes,
43
+ removeTree,
44
+ scanLocalSpecs,
30
45
  readJsonObject,
31
46
  readMissingFlags,
32
47
  readOption,
@@ -42,6 +57,7 @@ const STEPS: readonly string[] = [
42
57
  'install',
43
58
  'pins',
44
59
  'format',
60
+ 'record',
45
61
  'gates',
46
62
  'compare',
47
63
  ]
@@ -71,11 +87,14 @@ interface StepResult {
71
87
  readonly exit: number | undefined
72
88
  readonly durationMs: number
73
89
  readonly note: string | undefined
90
+ readonly journal?: string
91
+ readonly superseded?: true
74
92
  }
75
93
 
76
94
  interface Verdict {
77
95
  readonly exit: number | undefined
78
96
  readonly note: string | undefined
97
+ readonly superseded?: true
79
98
  }
80
99
 
81
100
  interface RangeMove {
@@ -103,9 +122,32 @@ interface Runner {
103
122
  readonly target: string
104
123
  readonly dry: boolean
105
124
  readonly offline: boolean
125
+ readonly regenerate: boolean
126
+ readonly directory: string | undefined
106
127
  readonly steps: StepResult[]
107
128
  }
108
129
 
130
+ interface OverwriteReceipt {
131
+ readonly head: string
132
+ readonly version: string
133
+ readonly offline: boolean
134
+ readonly written: readonly string[]
135
+ readonly removed: readonly string[]
136
+ }
137
+
138
+ interface Journal {
139
+ readonly path?: string
140
+ readonly failure?: string
141
+ }
142
+
143
+ interface Regeneration {
144
+ readonly exit: number
145
+ readonly note: string
146
+ readonly installed?: SpawnSyncReturns<string>
147
+ readonly listed?: SpawnSyncReturns<string>
148
+ readonly duration?: number
149
+ }
150
+
109
151
  function readManifest(target: string): Record<string, unknown> | undefined {
110
152
  try {
111
153
  return parseJsonRecord(readFileSync(join(target, 'package.json'), 'utf8'))
@@ -184,8 +226,8 @@ function describeFailure(result: SpawnSyncReturns<string>): string | undefined {
184
226
  const note = readString(value, 'note')
185
227
  if (note !== undefined) return note
186
228
  return result.stderr
187
- .trim()
188
229
  .split(/\r\n|\n/)
230
+ .filter((line) => line.trim() !== '')
189
231
  .slice(-3)
190
232
  .join(' | ')
191
233
  }
@@ -203,6 +245,7 @@ function runStep(
203
245
  args: readonly string[],
204
246
  cwd: string,
205
247
  judge: (result: SpawnSyncReturns<string>) => Verdict = readVerdict,
248
+ input?: string,
206
249
  ): SpawnSyncReturns<string> | undefined {
207
250
  const command = describeCommand(file, args)
208
251
  if (runner.dry) {
@@ -215,10 +258,11 @@ function runStep(
215
258
  encoding: 'utf8',
216
259
  windowsHide: true,
217
260
  maxBuffer: 64 * 1024 * 1024,
261
+ ...(input === undefined ? {} : { input }),
218
262
  })
219
263
  const durationMs = Date.now() - started
220
264
  const verdict = judge(result)
221
- runner.steps.push({ step, command, exit: verdict.exit, durationMs, note: verdict.note })
265
+ appendResult(runner, step, command, result, durationMs, verdict)
222
266
  return result
223
267
  }
224
268
 
@@ -260,31 +304,101 @@ function runNpmStep(
260
304
  runner: Runner,
261
305
  step: string,
262
306
  args: readonly string[],
307
+ judge: (result: SpawnSyncReturns<string>) => Verdict = readVerdict,
263
308
  ): SpawnSyncReturns<string> | undefined {
264
309
  const command = `npm ${args.join(' ')}`
265
310
  if (runner.dry) {
266
- runner.steps.push({ step, command, exit: undefined, durationMs: 0, note: 'dry run' })
311
+ runner.steps.push({
312
+ step,
313
+ command,
314
+ exit: undefined,
315
+ durationMs: 0,
316
+ note:
317
+ runner.regenerate && args[0] === 'install'
318
+ ? 'dry run (regenerates on ERESOLVE)'
319
+ : 'dry run',
320
+ })
267
321
  return undefined
268
322
  }
269
323
  const started = Date.now()
270
324
  const result = runNpm(args, runner.target)
325
+ appendResult(runner, step, command, result, Date.now() - started, judge(result))
326
+ return result
327
+ }
328
+
329
+ function writeJournal(
330
+ runner: Runner,
331
+ step: string,
332
+ command: string,
333
+ exit: number | undefined,
334
+ stdout: string,
335
+ stderr: string,
336
+ ): Journal {
337
+ if (runner.directory === undefined) return {}
338
+ const path = join(
339
+ runner.directory,
340
+ `${String(runner.steps.length + 1).padStart(2, '0')}-${step}.log`,
341
+ )
342
+ try {
343
+ validateWavePath(runner.target, path)
344
+ mkdirSync(runner.directory, { recursive: true })
345
+ validateWavePath(runner.target, path)
346
+ writeFileSync(
347
+ path,
348
+ `command: ${command}\nexit: ${String(exit)}\nstdout:\n${stdout}\nstderr:\n${stderr}\n`,
349
+ )
350
+ return { path }
351
+ } catch (error) {
352
+ return { failure: `journal failed: ${path}: ${String(error)}` }
353
+ }
354
+ }
355
+
356
+ function appendResult(
357
+ runner: Runner,
358
+ step: string,
359
+ command: string,
360
+ result: SpawnSyncReturns<string>,
361
+ durationMs: number,
362
+ verdict: Verdict,
363
+ ): void {
364
+ const journal = writeJournal(
365
+ runner,
366
+ step,
367
+ command,
368
+ result.status ?? undefined,
369
+ result.stdout ?? '',
370
+ result.stderr ?? '',
371
+ )
271
372
  runner.steps.push({
272
373
  step,
273
374
  command,
274
- exit: result.status ?? undefined,
275
- durationMs: Date.now() - started,
276
- note: describeFailure(result),
375
+ durationMs,
376
+ ...(journal.path === undefined ? {} : { journal: journal.path }),
377
+ ...(journal.failure === undefined
378
+ ? verdict
379
+ : {
380
+ exit: 1,
381
+ note: [verdict.note, journal.failure].filter((value) => value !== undefined).join('; '),
382
+ }),
277
383
  })
278
- return result
279
384
  }
280
385
 
281
- function recordStep(runner: Runner, step: string, command: string, note: string): void {
282
- runner.steps.push({ step, command, exit: runner.dry ? undefined : 0, durationMs: 0, note })
386
+ function appendStep(runner: Runner, step: string, command: string, note: string, exit = 0): void {
387
+ const journal = runner.dry ? {} : writeJournal(runner, step, command, exit, note, '')
388
+ runner.steps.push({
389
+ step,
390
+ command,
391
+ exit: runner.dry ? undefined : journal.failure === undefined ? exit : 1,
392
+ durationMs: 0,
393
+ note: journal.failure === undefined ? note : `${note}; ${journal.failure}`,
394
+ ...(journal.path === undefined ? {} : { journal: journal.path }),
395
+ })
283
396
  }
284
397
 
285
398
  function failed(runner: Runner): boolean {
286
399
  return runner.steps.some(
287
400
  (entry) =>
401
+ entry.superseded !== true &&
288
402
  entry.exit !== undefined &&
289
403
  entry.exit !== 0 &&
290
404
  entry.step !== 'pins' &&
@@ -292,8 +406,428 @@ function failed(runner: Runner): boolean {
292
406
  )
293
407
  }
294
408
 
295
- // A release moves a declared range to its registry caret when the range differs, and a later release
296
- // of the same name reads the range the earlier one wrote.
409
+ function extractConflict(result: SpawnSyncReturns<string>): string | undefined {
410
+ const output = `${result.stdout}\n${result.stderr}`
411
+ if (!/\bcode ERESOLVE\b/u.test(output)) return undefined
412
+ const lines = output.split(/\r\n|\n/)
413
+ return (
414
+ (
415
+ lines.find((line) => line.includes('Conflicting peer dependency:')) ??
416
+ lines.find((line) => line.includes('Could not resolve dependency:'))
417
+ )?.replace(/^npm (?:error|ERR!)\s*/u, '') ?? 'the install could not move a locked peer'
418
+ )
419
+ }
420
+
421
+ function runInstall(runner: Runner, step: string): void {
422
+ // npm can unlink a node_modules junction before reporting its resolution failure.
423
+ if (
424
+ !runner.dry &&
425
+ runner.regenerate &&
426
+ existsSync(join(runner.target, 'node_modules')) &&
427
+ lstatSync(join(runner.target, 'node_modules')).isSymbolicLink()
428
+ ) {
429
+ appendStep(
430
+ runner,
431
+ step,
432
+ 'regenerate lock and modules',
433
+ 'ERESOLVE; regeneration refused: node_modules is a link',
434
+ 1,
435
+ )
436
+ return
437
+ }
438
+ const installed = runNpmStep(runner, step, ['install'], (result) => {
439
+ const conflict = extractConflict(result)
440
+ return conflict === undefined || result.status === 0
441
+ ? readVerdict(result)
442
+ : {
443
+ exit: result.status ?? 1,
444
+ note: `ERESOLVE (${conflict}): pass --regenerate and resume with --from ${step} to replace package-lock.json and node_modules from the manifest`,
445
+ ...(runner.regenerate ? { superseded: true } : {}),
446
+ }
447
+ })
448
+ if (
449
+ installed !== undefined &&
450
+ installed.status !== 0 &&
451
+ extractConflict(installed) !== undefined &&
452
+ runner.regenerate
453
+ )
454
+ regenerateLock(runner, step, installed)
455
+ }
456
+
457
+ function refuseLocalSpecs(runner: Runner, step: string): boolean {
458
+ const specs = scanLocalSpecs(readManifest(runner.target) ?? {})
459
+ if (specs.length === 0) return false
460
+ appendStep(
461
+ runner,
462
+ step,
463
+ 'scan manifest specs',
464
+ `local dependency specs: ${specs.map(([path, spec]) => `${path}: ${spec}`).join(', ')}; restore the pins first`,
465
+ 1,
466
+ )
467
+ return true
468
+ }
469
+
470
+ function readRegenerationRefusal(runner: Runner): string | undefined {
471
+ if (
472
+ runStep(
473
+ runner,
474
+ 'regenerate',
475
+ 'git',
476
+ ['ls-files', '--error-unmatch', 'package-lock.json'],
477
+ runner.target,
478
+ )?.status !== 0
479
+ )
480
+ return 'package-lock.json is untracked'
481
+ const status = runStep(
482
+ runner,
483
+ 'regenerate',
484
+ 'git',
485
+ ['status', '--porcelain', '--', 'package-lock.json'],
486
+ runner.target,
487
+ )
488
+ if (status?.status !== 0 || status.stdout.trim() !== '')
489
+ return 'package-lock.json carries uncommitted changes'
490
+ const manifest = readManifest(runner.target)
491
+ const specs = scanLocalSpecs(manifest ?? {})
492
+ if (specs.length > 0)
493
+ return `local dependency specs: ${specs.map(([path, spec]) => `${path}: ${spec}`).join(', ')}`
494
+ if (manifest !== undefined && 'workspaces' in manifest) return 'the manifest declares workspaces'
495
+ if (
496
+ runStep(
497
+ runner,
498
+ 'regenerate',
499
+ 'git',
500
+ ['check-ignore', '-q', 'tmp/units/wave/probe'],
501
+ runner.target,
502
+ )?.status !== 0
503
+ )
504
+ return 'tmp/ is not git-ignored'
505
+ const root = runStep(runner, 'regenerate', 'git', ['rev-parse', '--show-toplevel'], runner.target)
506
+ if (
507
+ root?.status !== 0 ||
508
+ normalizeSlashes(resolve(root.stdout.trim())) !== normalizeSlashes(resolve(runner.target))
509
+ )
510
+ return 'the target is not the root of its work tree'
511
+ return undefined
512
+ }
513
+
514
+ function moveModules(source: string, target: string, attempt = 0): void {
515
+ try {
516
+ renameSync(source, target)
517
+ } catch (error) {
518
+ if (!isRecord(error) || (error.code !== 'EBUSY' && error.code !== 'EPERM') || attempt >= 9)
519
+ throw error
520
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 100)
521
+ moveModules(source, target, attempt + 1)
522
+ }
523
+ }
524
+
525
+ function regenerateLock(runner: Runner, step: string, conflict: SpawnSyncReturns<string>): void {
526
+ const refusal = readRegenerationRefusal(runner)
527
+ if (refusal !== undefined || runner.directory === undefined) {
528
+ appendStep(
529
+ runner,
530
+ step,
531
+ 'regenerate lock and modules',
532
+ `ERESOLVE; regeneration refused: ${refusal ?? 'tmp/ is not git-ignored'}`,
533
+ 1,
534
+ )
535
+ return
536
+ }
537
+ const lock = join(runner.target, 'package-lock.json')
538
+ const modules = join(runner.target, 'node_modules')
539
+ const backup = join(runner.directory, 'package-lock.before.json')
540
+ const moved = join(runner.directory, 'node_modules.before')
541
+ const manifest = readFileSync(join(runner.target, 'package.json'))
542
+ const before = createHash('sha256').update(readFileSync(lock)).digest('hex')
543
+ try {
544
+ for (const path of [backup, moved, lock, modules]) validateWavePath(runner.target, path)
545
+ mkdirSync(runner.directory, { recursive: true })
546
+ for (const path of [backup, moved, lock, modules]) validateWavePath(runner.target, path)
547
+ copyFileSync(lock, backup)
548
+ moveModules(modules, moved)
549
+ } catch (error) {
550
+ appendStep(
551
+ runner,
552
+ step,
553
+ 'regenerate lock and modules',
554
+ `ERESOLVE; regeneration refused: ${isRecord(error) ? String(error.code) : ''} in use; stop the processes that hold it; ${String(error)}`,
555
+ 1,
556
+ )
557
+ return
558
+ }
559
+ const result = regenerateTree(runner.target, manifest, before)
560
+ const restored = result.exit === 0 ? undefined : restoreLock(runner.target, moved, backup, before)
561
+ const cleanup = result.exit === 0 ? discardBackup(runner.target, moved, backup) : undefined
562
+ const names = [
563
+ ...new Set(
564
+ [
565
+ ...`${conflict.stdout}\n${conflict.stderr}`.matchAll(
566
+ /(?:node_modules\/)((?:@[^/\s]+\/)?[^/\s]+)/gu,
567
+ ),
568
+ ].flatMap((match) => (match[1] === undefined ? [] : [match[1]])),
569
+ ),
570
+ ]
571
+ const versions =
572
+ result.exit === 0
573
+ ? names
574
+ .map(
575
+ (name) =>
576
+ `${name}@${readString(readManifest(join(modules, name)), 'version') ?? 'absent'}`,
577
+ )
578
+ .join(', ')
579
+ : undefined
580
+ appendStep(
581
+ runner,
582
+ step,
583
+ 'regenerate lock and modules',
584
+ [result.note, restored, versions === undefined ? undefined : `installed ${versions}`, cleanup]
585
+ .filter((value) => value !== undefined)
586
+ .join('; '),
587
+ cleanup === undefined ? result.exit : 1,
588
+ )
589
+ if (result.installed !== undefined)
590
+ appendResult(
591
+ runner,
592
+ step,
593
+ 'npm install (regenerated)',
594
+ result.installed,
595
+ result.duration ?? 0,
596
+ readVerdict(result.installed),
597
+ )
598
+ if (result.listed !== undefined)
599
+ appendResult(runner, step, 'npm ls --all --json', result.listed, 0, readVerdict(result.listed))
600
+ }
601
+
602
+ // Journal writes and backup disposal cannot enter the transaction's rollback boundary.
603
+ function regenerateTree(target: string, manifest: Buffer, before: string): Regeneration {
604
+ const lock = join(target, 'package-lock.json')
605
+ try {
606
+ unlinkSync(lock)
607
+ const started = Date.now()
608
+ const installed = runNpm(['install'], target)
609
+ const duration = Date.now() - started
610
+ const listed = installed.status === 0 ? runNpm(['ls', '--all', '--json'], target) : undefined
611
+ const unchanged = manifest.equals(readFileSync(join(target, 'package.json')))
612
+ const proved = installed.status === 0 && existsSync(lock) && listed?.status === 0 && unchanged
613
+ const after = existsSync(lock)
614
+ ? createHash('sha256').update(readFileSync(lock)).digest('hex')
615
+ : 'absent'
616
+ return {
617
+ exit: proved ? 0 : 1,
618
+ note: `${proved ? '' : 'ERESOLVE; '}lock SHA-256 ${before} -> ${after}${unchanged ? '' : '; manifest bytes changed'}`,
619
+ installed,
620
+ ...(listed === undefined ? {} : { listed }),
621
+ duration,
622
+ }
623
+ } catch (error) {
624
+ return { exit: 1, note: `ERESOLVE; ${String(error)}` }
625
+ }
626
+ }
627
+
628
+ function discardBackup(target: string, moved: string, backup: string): string | undefined {
629
+ try {
630
+ for (const path of [moved, backup, join(target, 'node_modules')]) validateWavePath(target, path)
631
+ removeTree(moved)
632
+ return undefined
633
+ } catch (error) {
634
+ return `backup cleanup failed: ${String(error)}; proved installation retained; backups: ${backup}; ${moved}`
635
+ }
636
+ }
637
+
638
+ function restoreLock(target: string, moved: string, backup: string, before: string): string {
639
+ try {
640
+ for (const path of [
641
+ moved,
642
+ backup,
643
+ join(target, 'node_modules'),
644
+ join(target, 'package-lock.json'),
645
+ ])
646
+ validateWavePath(target, path)
647
+ if (!lstatSync(moved).isDirectory() || !lstatSync(backup).isFile())
648
+ throw new Error('no recoverable backup')
649
+ const saved = readFileSync(backup)
650
+ if (createHash('sha256').update(saved).digest('hex') !== before)
651
+ throw new Error('backup lock digest changed')
652
+ removeTree(join(target, 'node_modules'))
653
+ moveModules(moved, join(target, 'node_modules'))
654
+ writeFileSync(join(target, 'package-lock.json'), saved)
655
+ return 'tree and lock restored'
656
+ } catch (error) {
657
+ return `rollback failed: ${String(error)}; backups: ${backup}; ${moved}`
658
+ }
659
+ }
660
+
661
+ function validateWavePath(target: string, destination: string): void {
662
+ const root = resolve(target)
663
+ const path = resolve(destination)
664
+ const location = relative(root, path)
665
+ if (location === '..' || location.startsWith(`..${sep}`) || isAbsolute(location))
666
+ throw new Error(`unsafe wave path: ${path} resolves outside ${root}`)
667
+ const physical = realpathSync(root)
668
+ const segments = location === '' ? [] : location.split(sep)
669
+ for (const entry of [
670
+ root,
671
+ ...segments.map((_, index) => join(root, ...segments.slice(0, index + 1))),
672
+ ]) {
673
+ const status = lstatSync(entry, { throwIfNoEntry: false })
674
+ if (status === undefined) continue
675
+ if (status.isSymbolicLink()) throw new Error(`unsafe wave path: ${entry} is a link`)
676
+ if (entry !== path && !status.isDirectory())
677
+ throw new Error(`unsafe wave path: ${entry} is not a directory`)
678
+ const actual = relative(physical, realpathSync(entry))
679
+ if (actual === '..' || actual.startsWith(`..${sep}`) || isAbsolute(actual))
680
+ throw new Error(`unsafe wave path: ${entry} resolves outside ${physical}`)
681
+ }
682
+ }
683
+
684
+ function readDirtyPaths(text: string): readonly string[] {
685
+ const [entry, ...rest] = text.split('\0')
686
+ if (entry === undefined || entry === '') return []
687
+ const renamed = /[RC]/u.test(entry.slice(0, 2))
688
+ const original = renamed ? rest[0] : undefined
689
+ return [
690
+ entry.slice(3),
691
+ ...(original === undefined ? [] : [original]),
692
+ ...readDirtyPaths((renamed ? rest.slice(1) : rest).join('\0')),
693
+ ]
694
+ }
695
+
696
+ function readReceipt(target: string): OverwriteReceipt | undefined {
697
+ try {
698
+ const value = parseJsonRecord(
699
+ readFileSync(join(target, 'tmp', 'units', 'wave', 'overwrite.json'), 'utf8'),
700
+ )
701
+ if (
702
+ value === undefined ||
703
+ typeof value.head !== 'string' ||
704
+ typeof value.version !== 'string' ||
705
+ typeof value.offline !== 'boolean' ||
706
+ !Array.isArray(value.written) ||
707
+ !value.written.every((path) => typeof path === 'string') ||
708
+ !Array.isArray(value.removed) ||
709
+ !value.removed.every((path) => typeof path === 'string')
710
+ )
711
+ return undefined
712
+ return {
713
+ head: value.head,
714
+ version: value.version,
715
+ offline: value.offline,
716
+ written: value.written,
717
+ removed: value.removed,
718
+ }
719
+ } catch {
720
+ return undefined
721
+ }
722
+ }
723
+
724
+ function runRecord(runner: Runner, name: string | undefined): void {
725
+ if (name === SCAFFOLD) {
726
+ appendStep(runner, 'record', 'git commit', `skipped: ${SCAFFOLD}`)
727
+ return
728
+ }
729
+ if (refuseLocalSpecs(runner, 'record')) return
730
+ if (runner.dry) {
731
+ runStep(
732
+ runner,
733
+ 'record',
734
+ 'git',
735
+ ['add', '-A', '--pathspec-from-file=-', '--pathspec-file-nul'],
736
+ runner.target,
737
+ )
738
+ runStep(
739
+ runner,
740
+ 'record',
741
+ 'git',
742
+ ['commit', '-m', 'Overwrite with scaffold <version>'],
743
+ runner.target,
744
+ )
745
+ return
746
+ }
747
+ const status = runStep(
748
+ runner,
749
+ 'record',
750
+ 'git',
751
+ ['status', '--porcelain=v1', '-z', '--untracked-files=all'],
752
+ runner.target,
753
+ )
754
+ if (status?.status !== 0) return
755
+ const dirty = [...new Set(readDirtyPaths(status.stdout))]
756
+ if (dirty.length === 0) {
757
+ appendStep(runner, 'record', 'git commit', 'nothing to commit')
758
+ return
759
+ }
760
+ const receipt = readReceipt(runner.target)
761
+ const head = runStep(runner, 'record', 'git', ['rev-parse', 'HEAD'], runner.target)
762
+ if (receipt === undefined || head?.status !== 0 || receipt.head !== head.stdout.trim()) {
763
+ appendStep(
764
+ runner,
765
+ 'record',
766
+ 'attribute overwrite paths',
767
+ `uncommitted changes the visit cannot attribute to an overwrite at HEAD: ${dirty.join(', ')}`,
768
+ 1,
769
+ )
770
+ return
771
+ }
772
+ if (receipt.offline) {
773
+ appendStep(
774
+ runner,
775
+ 'record',
776
+ 'git commit',
777
+ 'an offline overwrite stays uncommitted until scaffold publishes',
778
+ )
779
+ return
780
+ }
781
+ const allowed = [...receipt.written, ...receipt.removed, 'package.json', 'package-lock.json'].map(
782
+ (path) => normalizeSlashes(relative(runner.target, resolve(runner.target, path))),
783
+ )
784
+ const outside = dirty.filter(
785
+ (path) =>
786
+ !allowed.some(
787
+ (entry) =>
788
+ entry !== '' &&
789
+ entry !== '..' &&
790
+ !entry.startsWith('../') &&
791
+ !isAbsolute(entry) &&
792
+ path === entry,
793
+ ),
794
+ )
795
+ if (outside.length > 0) {
796
+ appendStep(
797
+ runner,
798
+ 'record',
799
+ 'contain overwrite paths',
800
+ `dirty paths outside the overwrite: ${outside.join(', ')}`,
801
+ 1,
802
+ )
803
+ return
804
+ }
805
+ runStep(
806
+ runner,
807
+ 'record',
808
+ 'git',
809
+ ['--literal-pathspecs', 'add', '-A', '--pathspec-from-file=-', '--pathspec-file-nul'],
810
+ runner.target,
811
+ readVerdict,
812
+ `${dirty.join('\0')}\0`,
813
+ )
814
+ if (failed(runner)) return
815
+ runStep(
816
+ runner,
817
+ 'record',
818
+ 'git',
819
+ ['commit', '-m', `Overwrite with scaffold ${receipt.version}`],
820
+ runner.target,
821
+ )
822
+ if (failed(runner)) return
823
+ runStep(runner, 'record', 'git', ['status', '--porcelain'], runner.target, (result) =>
824
+ result.status === 0 && result.stdout.trim() !== ''
825
+ ? { exit: 1, note: `tree remains dirty: ${result.stdout}` }
826
+ : readVerdict(result),
827
+ )
828
+ }
829
+
830
+ // A later release of the same name reads the range the earlier reading wrote.
297
831
  function computeRanges(ranges: Record<string, unknown>, releases: readonly Release[]): RangeMove {
298
832
  return releases.reduce<RangeMove>(
299
833
  (state, release) => {
@@ -347,19 +881,20 @@ function runPin(
347
881
  const releases = readReleases(audit.stdout)
348
882
  if (releases === undefined || (audit.status !== 0 && audit.status !== 1)) return
349
883
  const moved = writeRanges(runner.target, releases)
350
- recordStep(
884
+ appendStep(
351
885
  runner,
352
886
  'pin',
353
- 'rewrite @orkestrel ranges to the registry caret',
887
+ 'rewrite audit release ranges to the registry caret',
354
888
  moved.length === 0 ? 'no range moved' : moved.join(', '),
355
889
  )
356
890
  } else {
357
- recordStep(runner, 'pin', 'rewrite @orkestrel ranges to the registry caret', 'dry run')
891
+ appendStep(runner, 'pin', 'rewrite audit release ranges to the registry caret', 'dry run')
358
892
  }
359
- runNpmStep(runner, 'pin', ['install'])
893
+ runInstall(runner, 'pin')
360
894
  }
361
895
 
362
896
  function runCommit(runner: Runner): void {
897
+ if (refuseLocalSpecs(runner, 'commit')) return
363
898
  const status = runStep(
364
899
  runner,
365
900
  'commit',
@@ -384,7 +919,7 @@ function runCommit(runner: Runner): void {
384
919
  runner.target,
385
920
  )
386
921
  } else {
387
- recordStep(runner, 'commit', 'git commit', 'manifest and lockfile already committed')
922
+ appendStep(runner, 'commit', 'git commit', 'manifest and lockfile already committed')
388
923
  }
389
924
  }
390
925
 
@@ -394,7 +929,7 @@ function runOverwrite(
394
929
  scaffoldPrefix: readonly string[],
395
930
  offline: readonly string[],
396
931
  ): void {
397
- runStep(
932
+ const overwrite = runStep(
398
933
  runner,
399
934
  'overwrite',
400
935
  scaffoldFile,
@@ -402,6 +937,33 @@ function runOverwrite(
402
937
  runner.target,
403
938
  (result) => judgeOverwrite(result, runner.offline),
404
939
  )
940
+ if (overwrite !== undefined && !failed(runner) && runner.directory !== undefined) {
941
+ const receipt = readJsonObject(overwrite.stdout)
942
+ const head = runStep(runner, 'overwrite', 'git', ['rev-parse', 'HEAD'], runner.target)
943
+ const installed = readManifest(join(runner.target, 'node_modules', '@orkestrel', 'scaffold'))
944
+ const version = readString(installed, 'version')
945
+ if (
946
+ head?.status === 0 &&
947
+ version !== undefined &&
948
+ Array.isArray(receipt?.written) &&
949
+ receipt.written.every((path) => typeof path === 'string') &&
950
+ Array.isArray(receipt.removed) &&
951
+ receipt.removed.every((path) => typeof path === 'string')
952
+ ) {
953
+ const path = join(runner.target, 'tmp', 'units', 'wave', 'overwrite.json')
954
+ try {
955
+ validateWavePath(runner.target, path)
956
+ mkdirSync(dirname(path), { recursive: true })
957
+ validateWavePath(runner.target, path)
958
+ writeFileSync(
959
+ path,
960
+ `${JSON.stringify({ head: head.stdout.trim(), version, offline: runner.offline, written: receipt.written, removed: receipt.removed })}\n`,
961
+ )
962
+ } catch (error) {
963
+ appendStep(runner, 'overwrite', 'write overwrite receipt', String(error), 1)
964
+ }
965
+ }
966
+ }
405
967
  if (!failed(runner)) {
406
968
  runStep(
407
969
  runner,
@@ -434,18 +996,17 @@ function runVerify(
434
996
  const drift = releases.filter(
435
997
  (release) => release.latest !== undefined && release.range !== `^${release.latest}`,
436
998
  )
437
- runner.steps.push({
438
- step: 'verify',
439
- command: 'compare every declared range with the registry',
440
- exit: drift.length === 0 ? 0 : 1,
441
- durationMs: 0,
442
- note:
443
- drift.length === 0
444
- ? undefined
445
- : drift
446
- .map((release) => `${release.name} ${release.range} vs ${String(release.latest)}`)
447
- .join(', '),
448
- })
999
+ appendStep(
1000
+ runner,
1001
+ 'verify',
1002
+ 'compare every declared range with the registry',
1003
+ drift.length === 0
1004
+ ? 'all declared ranges match'
1005
+ : drift
1006
+ .map((release) => `${release.name} ${release.range} vs ${String(release.latest)}`)
1007
+ .join(', '),
1008
+ drift.length === 0 ? 0 : 1,
1009
+ )
449
1010
  }
450
1011
 
451
1012
  function runPins(
@@ -458,7 +1019,7 @@ function runPins(
458
1019
  .filter(([name, range]) => after.get(name) !== range)
459
1020
  .flatMap(([, range]) => ['--range', range])
460
1021
  if (version === undefined) {
461
- recordStep(runner, 'pins', basename(PINS), 'no prior version to sweep for; pass --prior')
1022
+ appendStep(runner, 'pins', basename(PINS), 'no prior version to sweep for; pass --prior')
462
1023
  return undefined
463
1024
  }
464
1025
  const swept = runStep(
@@ -484,7 +1045,7 @@ function runCompare(runner: Runner, name: string | undefined): Ruling {
484
1045
  ? false
485
1046
  : undefined
486
1047
  if (name === undefined) {
487
- recordStep(runner, 'compare', 'npm view', 'the manifest names no package to read')
1048
+ appendStep(runner, 'compare', 'npm view', 'the manifest names no package to read')
488
1049
  return { distMoved, rangesMoved: undefined, ranges: undefined, hits: undefined }
489
1050
  }
490
1051
  const view = runNpmStep(runner, 'compare', ['view', name, '--json'])
@@ -517,12 +1078,6 @@ function runVisit(argv: readonly string[]): Visit | undefined {
517
1078
  }
518
1079
  const [scaffoldFile, ...scaffoldPrefix] = scaffold
519
1080
  if (scaffoldFile === undefined) return undefined
520
- const runner: Runner = {
521
- target,
522
- dry: argv.includes('--dry-run'),
523
- offline: argv.includes('--offline'),
524
- steps: [],
525
- }
526
1081
  const from = STEPS.indexOf(readOption(argv, '--from') ?? 'pin')
527
1082
  const to = STEPS.indexOf(readOption(argv, '--to') ?? 'compare')
528
1083
  const selected = new Set(STEPS.slice(from, to + 1))
@@ -533,13 +1088,32 @@ function runVisit(argv: readonly string[]): Visit | undefined {
533
1088
  }
534
1089
  const name = readString(manifest, 'name')
535
1090
  const version = readString(manifest, 'version')
1091
+ const runner: Runner = {
1092
+ target: resolve(target),
1093
+ dry: argv.includes('--dry-run'),
1094
+ offline: argv.includes('--offline'),
1095
+ regenerate: argv.includes('--regenerate'),
1096
+ directory:
1097
+ argv.includes('--dry-run') ||
1098
+ (name === SCAFFOLD && from === STEPS.indexOf('record') && to === from)
1099
+ ? undefined
1100
+ : readJournalDirectory(resolve(target)),
1101
+ steps: [],
1102
+ }
1103
+ if (runner.directory !== undefined) {
1104
+ try {
1105
+ validateWavePath(runner.target, runner.directory)
1106
+ } catch (error) {
1107
+ appendStep(runner, 'journal', 'validate wave destination', String(error), 1)
1108
+ }
1109
+ }
536
1110
  const before = readRanges(manifest, RANGE_FIELDS)
537
1111
  const offline = runner.offline ? ['--offline'] : []
538
- if (selected.has('pin')) runPin(runner, scaffoldFile, scaffoldPrefix, offline)
1112
+ if (selected.has('pin') && !failed(runner)) runPin(runner, scaffoldFile, scaffoldPrefix, offline)
539
1113
  if (selected.has('commit') && !failed(runner)) runCommit(runner)
540
1114
  if (selected.has('overwrite') && !failed(runner)) {
541
1115
  if (name === SCAFFOLD) {
542
- recordStep(
1116
+ appendStep(
543
1117
  runner,
544
1118
  'overwrite',
545
1119
  describeCommand(scaffoldFile, [...scaffoldPrefix, 'overwrite', '--json', ...offline]),
@@ -550,12 +1124,13 @@ function runVisit(argv: readonly string[]): Visit | undefined {
550
1124
  if (selected.has('verify') && !failed(runner)) {
551
1125
  runVerify(runner, scaffoldFile, scaffoldPrefix, offline, name === SCAFFOLD)
552
1126
  }
553
- if (selected.has('install') && !failed(runner)) runNpmStep(runner, 'install', ['install'])
1127
+ if (selected.has('install') && !failed(runner)) runInstall(runner, 'install')
554
1128
  const hits =
555
1129
  selected.has('pins') && !failed(runner)
556
1130
  ? runPins(runner, before, readOption(argv, '--prior') ?? version)
557
1131
  : undefined
558
1132
  if (selected.has('format') && !failed(runner)) runNpmStep(runner, 'format', ['run', 'format'])
1133
+ if (selected.has('record') && !failed(runner)) runRecord(runner, name)
559
1134
  if (selected.has('gates') && !failed(runner)) {
560
1135
  for (const gate of GATES) {
561
1136
  if (failed(runner)) break
@@ -584,6 +1159,23 @@ function runVisit(argv: readonly string[]): Visit | undefined {
584
1159
  }
585
1160
  }
586
1161
 
1162
+ function readJournalDirectory(target: string): string | undefined {
1163
+ const inside = spawnSync('git', ['rev-parse', '--is-inside-work-tree'], {
1164
+ cwd: target,
1165
+ encoding: 'utf8',
1166
+ windowsHide: true,
1167
+ })
1168
+ if (inside.status !== 0 || inside.stdout.trim() !== 'true') return undefined
1169
+ const ignored = spawnSync('git', ['check-ignore', '-q', 'tmp/units/wave/probe'], {
1170
+ cwd: target,
1171
+ encoding: 'utf8',
1172
+ windowsHide: true,
1173
+ })
1174
+ return ignored.status === 0
1175
+ ? join(target, 'tmp', 'units', 'wave', new Date().toISOString().replace(/[:.]/gu, '-'))
1176
+ : undefined
1177
+ }
1178
+
587
1179
  function readLayers(catalogPath: string): ReadonlyMap<string, readonly string[]> | undefined {
588
1180
  if (!existsSync(catalogPath)) return undefined
589
1181
  const text = readFileSync(catalogPath, 'utf8')
@@ -612,7 +1204,7 @@ function renderVisit(visit: Visit): string {
612
1204
  const lines = [
613
1205
  ...visit.steps.map(
614
1206
  (entry) =>
615
- `wave: ${entry.step.padEnd(9)} exit=${entry.exit === undefined ? 'dry' : String(entry.exit)} ${String(entry.durationMs).padStart(7)} ms ${entry.command}${entry.note === undefined ? '' : ` (${entry.note})`}`,
1207
+ `wave: ${entry.step.padEnd(9)} exit=${entry.exit === undefined ? 'dry' : String(entry.exit)} ${String(entry.durationMs).padStart(7)} ms ${entry.command}${entry.note === undefined ? '' : ` (${entry.note})`}${entry.journal === undefined ? '' : ` journal=${entry.journal}`}`,
616
1208
  ),
617
1209
  `wave: ruling dist ${describeReading(visit.ruling.distMoved, 'moved', 'same')}, ranges ${describeReading(visit.ruling.rangesMoved, 'moved', 'same')}${visit.ruling.hits === undefined ? '' : `, ${visit.ruling.hits} self-pin hit(s)`}`,
618
1210
  ...(visit.ruling.ranges ?? []).map((range) => `wave: range ${range}`),
@@ -639,6 +1231,10 @@ function main(argv: readonly string[]): number {
639
1231
  return 64
640
1232
  }
641
1233
  if (argv.includes('--plan')) {
1234
+ if (argv.includes('--regenerate')) {
1235
+ console.error('wave: --regenerate requires --visit, not --plan')
1236
+ return 64
1237
+ }
642
1238
  const layers = readLayers(readOption(argv, '--catalog') ?? CATALOG)
643
1239
  if (layers === undefined) {
644
1240
  console.error('wave: no marker-bounded catalog table; run scaffold catalog first')
@@ -650,7 +1246,7 @@ function main(argv: readonly string[]): number {
650
1246
  }
651
1247
  if (!argv.includes('--visit')) {
652
1248
  console.error(
653
- 'usage: wave.ts --visit [--target DIR] [--offline] [--from STEP] [--to STEP] [--prior VERSION] [--dry-run] [--json] [--out FILE] | --plan [--catalog PATH] [--json]',
1249
+ 'usage: wave.ts --visit [--target DIR] [--offline] [--regenerate] [--from STEP] [--to STEP] [--prior VERSION] [--dry-run] [--json] [--out FILE] | --plan [--catalog PATH] [--json]',
654
1250
  )
655
1251
  return 64
656
1252
  }
@@ -679,6 +1275,7 @@ function main(argv: readonly string[]): number {
679
1275
  }
680
1276
  return visit.steps.some(
681
1277
  (entry) =>
1278
+ entry.superseded !== true &&
682
1279
  entry.exit !== undefined &&
683
1280
  entry.exit !== 0 &&
684
1281
  entry.step !== 'pins' &&