@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,8 +1,9 @@
1
1
  import { spawnSync } from 'node:child_process';
2
- import { existsSync, readFileSync, mkdirSync, writeFileSync } from 'node:fs';
3
- import { basename, dirname, extname, join } from 'node:path';
2
+ import { createHash } from 'node:crypto';
3
+ import { copyFileSync, existsSync, lstatSync, readFileSync, mkdirSync, realpathSync, renameSync, unlinkSync, writeFileSync, } from 'node:fs';
4
+ import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from 'node:path';
4
5
  import { fileURLToPath } from 'node:url';
5
- import { parseJsonRecord, readJsonObject, readMissingFlags, readOption, readString, runNpm, } from "../../orkestrel-dispatch/scripts/helpers.js";
6
+ import { parseJsonRecord, isRecord, normalizeSlashes, removeTree, scanLocalSpecs, readJsonObject, readMissingFlags, readOption, readString, runNpm, } from "../../orkestrel-dispatch/scripts/helpers.js";
6
7
  const STEPS = [
7
8
  'pin',
8
9
  'commit',
@@ -11,6 +12,7 @@ const STEPS = [
11
12
  'install',
12
13
  'pins',
13
14
  'format',
15
+ 'record',
14
16
  'gates',
15
17
  'compare',
16
18
  ];
@@ -102,8 +104,8 @@ function describeFailure(result) {
102
104
  if (note !== undefined)
103
105
  return note;
104
106
  return result.stderr
105
- .trim()
106
107
  .split(/\r\n|\n/)
108
+ .filter((line) => line.trim() !== '')
107
109
  .slice(-3)
108
110
  .join(' | ');
109
111
  }
@@ -112,7 +114,7 @@ function readVerdict(result) {
112
114
  }
113
115
  // A judge reads the finished command and answers the exit and note its one log entry carries, so no
114
116
  // entry is amended after it is appended.
115
- function runStep(runner, step, file, args, cwd, judge = readVerdict) {
117
+ function runStep(runner, step, file, args, cwd, judge = readVerdict, input) {
116
118
  const command = describeCommand(file, args);
117
119
  if (runner.dry) {
118
120
  runner.steps.push({ step, command, exit: undefined, durationMs: 0, note: 'dry run' });
@@ -124,10 +126,11 @@ function runStep(runner, step, file, args, cwd, judge = readVerdict) {
124
126
  encoding: 'utf8',
125
127
  windowsHide: true,
126
128
  maxBuffer: 64 * 1024 * 1024,
129
+ ...(input === undefined ? {} : { input }),
127
130
  });
128
131
  const durationMs = Date.now() - started;
129
132
  const verdict = judge(result);
130
- runner.steps.push({ step, command, exit: verdict.exit, durationMs, note: verdict.note });
133
+ appendResult(runner, step, command, result, durationMs, verdict);
131
134
  return result;
132
135
  }
133
136
  // The audit exits 1 on the drift the pin step exists to close, so its exit is a reading here.
@@ -161,34 +164,361 @@ function judgeVerify(result, own) {
161
164
  ? { exit: 0, note: 'foreign-path findings are expected on the scaffold checkout' }
162
165
  : readVerdict(result);
163
166
  }
164
- function runNpmStep(runner, step, args) {
167
+ function runNpmStep(runner, step, args, judge = readVerdict) {
165
168
  const command = `npm ${args.join(' ')}`;
166
169
  if (runner.dry) {
167
- runner.steps.push({ step, command, exit: undefined, durationMs: 0, note: 'dry run' });
170
+ runner.steps.push({
171
+ step,
172
+ command,
173
+ exit: undefined,
174
+ durationMs: 0,
175
+ note: runner.regenerate && args[0] === 'install'
176
+ ? 'dry run (regenerates on ERESOLVE)'
177
+ : 'dry run',
178
+ });
168
179
  return undefined;
169
180
  }
170
181
  const started = Date.now();
171
182
  const result = runNpm(args, runner.target);
183
+ appendResult(runner, step, command, result, Date.now() - started, judge(result));
184
+ return result;
185
+ }
186
+ function writeJournal(runner, step, command, exit, stdout, stderr) {
187
+ if (runner.directory === undefined)
188
+ return {};
189
+ const path = join(runner.directory, `${String(runner.steps.length + 1).padStart(2, '0')}-${step}.log`);
190
+ try {
191
+ validateWavePath(runner.target, path);
192
+ mkdirSync(runner.directory, { recursive: true });
193
+ validateWavePath(runner.target, path);
194
+ writeFileSync(path, `command: ${command}\nexit: ${String(exit)}\nstdout:\n${stdout}\nstderr:\n${stderr}\n`);
195
+ return { path };
196
+ }
197
+ catch (error) {
198
+ return { failure: `journal failed: ${path}: ${String(error)}` };
199
+ }
200
+ }
201
+ function appendResult(runner, step, command, result, durationMs, verdict) {
202
+ const journal = writeJournal(runner, step, command, result.status ?? undefined, result.stdout ?? '', result.stderr ?? '');
172
203
  runner.steps.push({
173
204
  step,
174
205
  command,
175
- exit: result.status ?? undefined,
176
- durationMs: Date.now() - started,
177
- note: describeFailure(result),
206
+ durationMs,
207
+ ...(journal.path === undefined ? {} : { journal: journal.path }),
208
+ ...(journal.failure === undefined
209
+ ? verdict
210
+ : {
211
+ exit: 1,
212
+ note: [verdict.note, journal.failure].filter((value) => value !== undefined).join('; '),
213
+ }),
178
214
  });
179
- return result;
180
215
  }
181
- function recordStep(runner, step, command, note) {
182
- runner.steps.push({ step, command, exit: runner.dry ? undefined : 0, durationMs: 0, note });
216
+ function appendStep(runner, step, command, note, exit = 0) {
217
+ const journal = runner.dry ? {} : writeJournal(runner, step, command, exit, note, '');
218
+ runner.steps.push({
219
+ step,
220
+ command,
221
+ exit: runner.dry ? undefined : journal.failure === undefined ? exit : 1,
222
+ durationMs: 0,
223
+ note: journal.failure === undefined ? note : `${note}; ${journal.failure}`,
224
+ ...(journal.path === undefined ? {} : { journal: journal.path }),
225
+ });
183
226
  }
184
227
  function failed(runner) {
185
- return runner.steps.some((entry) => entry.exit !== undefined &&
228
+ return runner.steps.some((entry) => entry.superseded !== true &&
229
+ entry.exit !== undefined &&
186
230
  entry.exit !== 0 &&
187
231
  entry.step !== 'pins' &&
188
232
  entry.step !== 'compare');
189
233
  }
190
- // A release moves a declared range to its registry caret when the range differs, and a later release
191
- // of the same name reads the range the earlier one wrote.
234
+ function extractConflict(result) {
235
+ const output = `${result.stdout}\n${result.stderr}`;
236
+ if (!/\bcode ERESOLVE\b/u.test(output))
237
+ return undefined;
238
+ const lines = output.split(/\r\n|\n/);
239
+ return ((lines.find((line) => line.includes('Conflicting peer dependency:')) ??
240
+ lines.find((line) => line.includes('Could not resolve dependency:')))?.replace(/^npm (?:error|ERR!)\s*/u, '') ?? 'the install could not move a locked peer');
241
+ }
242
+ function runInstall(runner, step) {
243
+ // npm can unlink a node_modules junction before reporting its resolution failure.
244
+ if (!runner.dry &&
245
+ runner.regenerate &&
246
+ existsSync(join(runner.target, 'node_modules')) &&
247
+ lstatSync(join(runner.target, 'node_modules')).isSymbolicLink()) {
248
+ appendStep(runner, step, 'regenerate lock and modules', 'ERESOLVE; regeneration refused: node_modules is a link', 1);
249
+ return;
250
+ }
251
+ const installed = runNpmStep(runner, step, ['install'], (result) => {
252
+ const conflict = extractConflict(result);
253
+ return conflict === undefined || result.status === 0
254
+ ? readVerdict(result)
255
+ : {
256
+ exit: result.status ?? 1,
257
+ note: `ERESOLVE (${conflict}): pass --regenerate and resume with --from ${step} to replace package-lock.json and node_modules from the manifest`,
258
+ ...(runner.regenerate ? { superseded: true } : {}),
259
+ };
260
+ });
261
+ if (installed !== undefined &&
262
+ installed.status !== 0 &&
263
+ extractConflict(installed) !== undefined &&
264
+ runner.regenerate)
265
+ regenerateLock(runner, step, installed);
266
+ }
267
+ function refuseLocalSpecs(runner, step) {
268
+ const specs = scanLocalSpecs(readManifest(runner.target) ?? {});
269
+ if (specs.length === 0)
270
+ return false;
271
+ appendStep(runner, step, 'scan manifest specs', `local dependency specs: ${specs.map(([path, spec]) => `${path}: ${spec}`).join(', ')}; restore the pins first`, 1);
272
+ return true;
273
+ }
274
+ function readRegenerationRefusal(runner) {
275
+ if (runStep(runner, 'regenerate', 'git', ['ls-files', '--error-unmatch', 'package-lock.json'], runner.target)?.status !== 0)
276
+ return 'package-lock.json is untracked';
277
+ const status = runStep(runner, 'regenerate', 'git', ['status', '--porcelain', '--', 'package-lock.json'], runner.target);
278
+ if (status?.status !== 0 || status.stdout.trim() !== '')
279
+ return 'package-lock.json carries uncommitted changes';
280
+ const manifest = readManifest(runner.target);
281
+ const specs = scanLocalSpecs(manifest ?? {});
282
+ if (specs.length > 0)
283
+ return `local dependency specs: ${specs.map(([path, spec]) => `${path}: ${spec}`).join(', ')}`;
284
+ if (manifest !== undefined && 'workspaces' in manifest)
285
+ return 'the manifest declares workspaces';
286
+ if (runStep(runner, 'regenerate', 'git', ['check-ignore', '-q', 'tmp/units/wave/probe'], runner.target)?.status !== 0)
287
+ return 'tmp/ is not git-ignored';
288
+ const root = runStep(runner, 'regenerate', 'git', ['rev-parse', '--show-toplevel'], runner.target);
289
+ if (root?.status !== 0 ||
290
+ normalizeSlashes(resolve(root.stdout.trim())) !== normalizeSlashes(resolve(runner.target)))
291
+ return 'the target is not the root of its work tree';
292
+ return undefined;
293
+ }
294
+ function moveModules(source, target, attempt = 0) {
295
+ try {
296
+ renameSync(source, target);
297
+ }
298
+ catch (error) {
299
+ if (!isRecord(error) || (error.code !== 'EBUSY' && error.code !== 'EPERM') || attempt >= 9)
300
+ throw error;
301
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 100);
302
+ moveModules(source, target, attempt + 1);
303
+ }
304
+ }
305
+ function regenerateLock(runner, step, conflict) {
306
+ const refusal = readRegenerationRefusal(runner);
307
+ if (refusal !== undefined || runner.directory === undefined) {
308
+ appendStep(runner, step, 'regenerate lock and modules', `ERESOLVE; regeneration refused: ${refusal ?? 'tmp/ is not git-ignored'}`, 1);
309
+ return;
310
+ }
311
+ const lock = join(runner.target, 'package-lock.json');
312
+ const modules = join(runner.target, 'node_modules');
313
+ const backup = join(runner.directory, 'package-lock.before.json');
314
+ const moved = join(runner.directory, 'node_modules.before');
315
+ const manifest = readFileSync(join(runner.target, 'package.json'));
316
+ const before = createHash('sha256').update(readFileSync(lock)).digest('hex');
317
+ try {
318
+ for (const path of [backup, moved, lock, modules])
319
+ validateWavePath(runner.target, path);
320
+ mkdirSync(runner.directory, { recursive: true });
321
+ for (const path of [backup, moved, lock, modules])
322
+ validateWavePath(runner.target, path);
323
+ copyFileSync(lock, backup);
324
+ moveModules(modules, moved);
325
+ }
326
+ catch (error) {
327
+ appendStep(runner, step, 'regenerate lock and modules', `ERESOLVE; regeneration refused: ${isRecord(error) ? String(error.code) : ''} in use; stop the processes that hold it; ${String(error)}`, 1);
328
+ return;
329
+ }
330
+ const result = regenerateTree(runner.target, manifest, before);
331
+ const restored = result.exit === 0 ? undefined : restoreLock(runner.target, moved, backup, before);
332
+ const cleanup = result.exit === 0 ? discardBackup(runner.target, moved, backup) : undefined;
333
+ const names = [
334
+ ...new Set([
335
+ ...`${conflict.stdout}\n${conflict.stderr}`.matchAll(/(?:node_modules\/)((?:@[^/\s]+\/)?[^/\s]+)/gu),
336
+ ].flatMap((match) => (match[1] === undefined ? [] : [match[1]]))),
337
+ ];
338
+ const versions = result.exit === 0
339
+ ? names
340
+ .map((name) => `${name}@${readString(readManifest(join(modules, name)), 'version') ?? 'absent'}`)
341
+ .join(', ')
342
+ : undefined;
343
+ appendStep(runner, step, 'regenerate lock and modules', [result.note, restored, versions === undefined ? undefined : `installed ${versions}`, cleanup]
344
+ .filter((value) => value !== undefined)
345
+ .join('; '), cleanup === undefined ? result.exit : 1);
346
+ if (result.installed !== undefined)
347
+ appendResult(runner, step, 'npm install (regenerated)', result.installed, result.duration ?? 0, readVerdict(result.installed));
348
+ if (result.listed !== undefined)
349
+ appendResult(runner, step, 'npm ls --all --json', result.listed, 0, readVerdict(result.listed));
350
+ }
351
+ // Journal writes and backup disposal cannot enter the transaction's rollback boundary.
352
+ function regenerateTree(target, manifest, before) {
353
+ const lock = join(target, 'package-lock.json');
354
+ try {
355
+ unlinkSync(lock);
356
+ const started = Date.now();
357
+ const installed = runNpm(['install'], target);
358
+ const duration = Date.now() - started;
359
+ const listed = installed.status === 0 ? runNpm(['ls', '--all', '--json'], target) : undefined;
360
+ const unchanged = manifest.equals(readFileSync(join(target, 'package.json')));
361
+ const proved = installed.status === 0 && existsSync(lock) && listed?.status === 0 && unchanged;
362
+ const after = existsSync(lock)
363
+ ? createHash('sha256').update(readFileSync(lock)).digest('hex')
364
+ : 'absent';
365
+ return {
366
+ exit: proved ? 0 : 1,
367
+ note: `${proved ? '' : 'ERESOLVE; '}lock SHA-256 ${before} -> ${after}${unchanged ? '' : '; manifest bytes changed'}`,
368
+ installed,
369
+ ...(listed === undefined ? {} : { listed }),
370
+ duration,
371
+ };
372
+ }
373
+ catch (error) {
374
+ return { exit: 1, note: `ERESOLVE; ${String(error)}` };
375
+ }
376
+ }
377
+ function discardBackup(target, moved, backup) {
378
+ try {
379
+ for (const path of [moved, backup, join(target, 'node_modules')])
380
+ validateWavePath(target, path);
381
+ removeTree(moved);
382
+ return undefined;
383
+ }
384
+ catch (error) {
385
+ return `backup cleanup failed: ${String(error)}; proved installation retained; backups: ${backup}; ${moved}`;
386
+ }
387
+ }
388
+ function restoreLock(target, moved, backup, before) {
389
+ try {
390
+ for (const path of [
391
+ moved,
392
+ backup,
393
+ join(target, 'node_modules'),
394
+ join(target, 'package-lock.json'),
395
+ ])
396
+ validateWavePath(target, path);
397
+ if (!lstatSync(moved).isDirectory() || !lstatSync(backup).isFile())
398
+ throw new Error('no recoverable backup');
399
+ const saved = readFileSync(backup);
400
+ if (createHash('sha256').update(saved).digest('hex') !== before)
401
+ throw new Error('backup lock digest changed');
402
+ removeTree(join(target, 'node_modules'));
403
+ moveModules(moved, join(target, 'node_modules'));
404
+ writeFileSync(join(target, 'package-lock.json'), saved);
405
+ return 'tree and lock restored';
406
+ }
407
+ catch (error) {
408
+ return `rollback failed: ${String(error)}; backups: ${backup}; ${moved}`;
409
+ }
410
+ }
411
+ function validateWavePath(target, destination) {
412
+ const root = resolve(target);
413
+ const path = resolve(destination);
414
+ const location = relative(root, path);
415
+ if (location === '..' || location.startsWith(`..${sep}`) || isAbsolute(location))
416
+ throw new Error(`unsafe wave path: ${path} resolves outside ${root}`);
417
+ const physical = realpathSync(root);
418
+ const segments = location === '' ? [] : location.split(sep);
419
+ for (const entry of [
420
+ root,
421
+ ...segments.map((_, index) => join(root, ...segments.slice(0, index + 1))),
422
+ ]) {
423
+ const status = lstatSync(entry, { throwIfNoEntry: false });
424
+ if (status === undefined)
425
+ continue;
426
+ if (status.isSymbolicLink())
427
+ throw new Error(`unsafe wave path: ${entry} is a link`);
428
+ if (entry !== path && !status.isDirectory())
429
+ throw new Error(`unsafe wave path: ${entry} is not a directory`);
430
+ const actual = relative(physical, realpathSync(entry));
431
+ if (actual === '..' || actual.startsWith(`..${sep}`) || isAbsolute(actual))
432
+ throw new Error(`unsafe wave path: ${entry} resolves outside ${physical}`);
433
+ }
434
+ }
435
+ function readDirtyPaths(text) {
436
+ const [entry, ...rest] = text.split('\0');
437
+ if (entry === undefined || entry === '')
438
+ return [];
439
+ const renamed = /[RC]/u.test(entry.slice(0, 2));
440
+ const original = renamed ? rest[0] : undefined;
441
+ return [
442
+ entry.slice(3),
443
+ ...(original === undefined ? [] : [original]),
444
+ ...readDirtyPaths((renamed ? rest.slice(1) : rest).join('\0')),
445
+ ];
446
+ }
447
+ function readReceipt(target) {
448
+ try {
449
+ const value = parseJsonRecord(readFileSync(join(target, 'tmp', 'units', 'wave', 'overwrite.json'), 'utf8'));
450
+ if (value === undefined ||
451
+ typeof value.head !== 'string' ||
452
+ typeof value.version !== 'string' ||
453
+ typeof value.offline !== 'boolean' ||
454
+ !Array.isArray(value.written) ||
455
+ !value.written.every((path) => typeof path === 'string') ||
456
+ !Array.isArray(value.removed) ||
457
+ !value.removed.every((path) => typeof path === 'string'))
458
+ return undefined;
459
+ return {
460
+ head: value.head,
461
+ version: value.version,
462
+ offline: value.offline,
463
+ written: value.written,
464
+ removed: value.removed,
465
+ };
466
+ }
467
+ catch {
468
+ return undefined;
469
+ }
470
+ }
471
+ function runRecord(runner, name) {
472
+ if (name === SCAFFOLD) {
473
+ appendStep(runner, 'record', 'git commit', `skipped: ${SCAFFOLD}`);
474
+ return;
475
+ }
476
+ if (refuseLocalSpecs(runner, 'record'))
477
+ return;
478
+ if (runner.dry) {
479
+ runStep(runner, 'record', 'git', ['add', '-A', '--pathspec-from-file=-', '--pathspec-file-nul'], runner.target);
480
+ runStep(runner, 'record', 'git', ['commit', '-m', 'Overwrite with scaffold <version>'], runner.target);
481
+ return;
482
+ }
483
+ const status = runStep(runner, 'record', 'git', ['status', '--porcelain=v1', '-z', '--untracked-files=all'], runner.target);
484
+ if (status?.status !== 0)
485
+ return;
486
+ const dirty = [...new Set(readDirtyPaths(status.stdout))];
487
+ if (dirty.length === 0) {
488
+ appendStep(runner, 'record', 'git commit', 'nothing to commit');
489
+ return;
490
+ }
491
+ const receipt = readReceipt(runner.target);
492
+ const head = runStep(runner, 'record', 'git', ['rev-parse', 'HEAD'], runner.target);
493
+ if (receipt === undefined || head?.status !== 0 || receipt.head !== head.stdout.trim()) {
494
+ appendStep(runner, 'record', 'attribute overwrite paths', `uncommitted changes the visit cannot attribute to an overwrite at HEAD: ${dirty.join(', ')}`, 1);
495
+ return;
496
+ }
497
+ if (receipt.offline) {
498
+ appendStep(runner, 'record', 'git commit', 'an offline overwrite stays uncommitted until scaffold publishes');
499
+ return;
500
+ }
501
+ const allowed = [...receipt.written, ...receipt.removed, 'package.json', 'package-lock.json'].map((path) => normalizeSlashes(relative(runner.target, resolve(runner.target, path))));
502
+ const outside = dirty.filter((path) => !allowed.some((entry) => entry !== '' &&
503
+ entry !== '..' &&
504
+ !entry.startsWith('../') &&
505
+ !isAbsolute(entry) &&
506
+ path === entry));
507
+ if (outside.length > 0) {
508
+ appendStep(runner, 'record', 'contain overwrite paths', `dirty paths outside the overwrite: ${outside.join(', ')}`, 1);
509
+ return;
510
+ }
511
+ runStep(runner, 'record', 'git', ['--literal-pathspecs', 'add', '-A', '--pathspec-from-file=-', '--pathspec-file-nul'], runner.target, readVerdict, `${dirty.join('\0')}\0`);
512
+ if (failed(runner))
513
+ return;
514
+ runStep(runner, 'record', 'git', ['commit', '-m', `Overwrite with scaffold ${receipt.version}`], runner.target);
515
+ if (failed(runner))
516
+ return;
517
+ runStep(runner, 'record', 'git', ['status', '--porcelain'], runner.target, (result) => result.status === 0 && result.stdout.trim() !== ''
518
+ ? { exit: 1, note: `tree remains dirty: ${result.stdout}` }
519
+ : readVerdict(result));
520
+ }
521
+ // A later release of the same name reads the range the earlier reading wrote.
192
522
  function computeRanges(ranges, releases) {
193
523
  return releases.reduce((state, release) => {
194
524
  if (release.latest === undefined || !(release.name in state.ranges))
@@ -229,14 +559,16 @@ function runPin(runner, scaffoldFile, scaffoldPrefix, offline) {
229
559
  if (releases === undefined || (audit.status !== 0 && audit.status !== 1))
230
560
  return;
231
561
  const moved = writeRanges(runner.target, releases);
232
- recordStep(runner, 'pin', 'rewrite @orkestrel ranges to the registry caret', moved.length === 0 ? 'no range moved' : moved.join(', '));
562
+ appendStep(runner, 'pin', 'rewrite audit release ranges to the registry caret', moved.length === 0 ? 'no range moved' : moved.join(', '));
233
563
  }
234
564
  else {
235
- recordStep(runner, 'pin', 'rewrite @orkestrel ranges to the registry caret', 'dry run');
565
+ appendStep(runner, 'pin', 'rewrite audit release ranges to the registry caret', 'dry run');
236
566
  }
237
- runNpmStep(runner, 'pin', ['install']);
567
+ runInstall(runner, 'pin');
238
568
  }
239
569
  function runCommit(runner) {
570
+ if (refuseLocalSpecs(runner, 'commit'))
571
+ return;
240
572
  const status = runStep(runner, 'commit', 'git', ['status', '--porcelain', '--', 'package.json', 'package-lock.json'], runner.target);
241
573
  if (status === undefined || status.stdout.trim() !== '') {
242
574
  runStep(runner, 'commit', 'git', [
@@ -250,11 +582,34 @@ function runCommit(runner) {
250
582
  ], runner.target);
251
583
  }
252
584
  else {
253
- recordStep(runner, 'commit', 'git commit', 'manifest and lockfile already committed');
585
+ appendStep(runner, 'commit', 'git commit', 'manifest and lockfile already committed');
254
586
  }
255
587
  }
256
588
  function runOverwrite(runner, scaffoldFile, scaffoldPrefix, offline) {
257
- runStep(runner, 'overwrite', scaffoldFile, [...scaffoldPrefix, 'overwrite', '--json', ...offline], runner.target, (result) => judgeOverwrite(result, runner.offline));
589
+ const overwrite = runStep(runner, 'overwrite', scaffoldFile, [...scaffoldPrefix, 'overwrite', '--json', ...offline], runner.target, (result) => judgeOverwrite(result, runner.offline));
590
+ if (overwrite !== undefined && !failed(runner) && runner.directory !== undefined) {
591
+ const receipt = readJsonObject(overwrite.stdout);
592
+ const head = runStep(runner, 'overwrite', 'git', ['rev-parse', 'HEAD'], runner.target);
593
+ const installed = readManifest(join(runner.target, 'node_modules', '@orkestrel', 'scaffold'));
594
+ const version = readString(installed, 'version');
595
+ if (head?.status === 0 &&
596
+ version !== undefined &&
597
+ Array.isArray(receipt?.written) &&
598
+ receipt.written.every((path) => typeof path === 'string') &&
599
+ Array.isArray(receipt.removed) &&
600
+ receipt.removed.every((path) => typeof path === 'string')) {
601
+ const path = join(runner.target, 'tmp', 'units', 'wave', 'overwrite.json');
602
+ try {
603
+ validateWavePath(runner.target, path);
604
+ mkdirSync(dirname(path), { recursive: true });
605
+ validateWavePath(runner.target, path);
606
+ writeFileSync(path, `${JSON.stringify({ head: head.stdout.trim(), version, offline: runner.offline, written: receipt.written, removed: receipt.removed })}\n`);
607
+ }
608
+ catch (error) {
609
+ appendStep(runner, 'overwrite', 'write overwrite receipt', String(error), 1);
610
+ }
611
+ }
612
+ }
258
613
  if (!failed(runner)) {
259
614
  runStep(runner, 'overwrite', scaffoldFile, [...scaffoldPrefix, 'audit', ...offline], runner.target);
260
615
  }
@@ -267,17 +622,11 @@ function runVerify(runner, scaffoldFile, scaffoldPrefix, offline, own) {
267
622
  if (releases === undefined)
268
623
  return;
269
624
  const drift = releases.filter((release) => release.latest !== undefined && release.range !== `^${release.latest}`);
270
- runner.steps.push({
271
- step: 'verify',
272
- command: 'compare every declared range with the registry',
273
- exit: drift.length === 0 ? 0 : 1,
274
- durationMs: 0,
275
- note: drift.length === 0
276
- ? undefined
277
- : drift
278
- .map((release) => `${release.name} ${release.range} vs ${String(release.latest)}`)
279
- .join(', '),
280
- });
625
+ appendStep(runner, 'verify', 'compare every declared range with the registry', drift.length === 0
626
+ ? 'all declared ranges match'
627
+ : drift
628
+ .map((release) => `${release.name} ${release.range} vs ${String(release.latest)}`)
629
+ .join(', '), drift.length === 0 ? 0 : 1);
281
630
  }
282
631
  function runPins(runner, before, version) {
283
632
  const after = readRanges(readManifest(runner.target) ?? {}, RANGE_FIELDS);
@@ -285,7 +634,7 @@ function runPins(runner, before, version) {
285
634
  .filter(([name, range]) => after.get(name) !== range)
286
635
  .flatMap(([, range]) => ['--range', range]);
287
636
  if (version === undefined) {
288
- recordStep(runner, 'pins', basename(PINS), 'no prior version to sweep for; pass --prior');
637
+ appendStep(runner, 'pins', basename(PINS), 'no prior version to sweep for; pass --prior');
289
638
  return undefined;
290
639
  }
291
640
  const swept = runStep(runner, 'pins', process.execPath, [PINS, '--version', version, ...prior], runner.target);
@@ -303,7 +652,7 @@ function runCompare(runner, name) {
303
652
  ? false
304
653
  : undefined;
305
654
  if (name === undefined) {
306
- recordStep(runner, 'compare', 'npm view', 'the manifest names no package to read');
655
+ appendStep(runner, 'compare', 'npm view', 'the manifest names no package to read');
307
656
  return { distMoved, rangesMoved: undefined, ranges: undefined, hits: undefined };
308
657
  }
309
658
  const view = runNpmStep(runner, 'compare', ['view', name, '--json']);
@@ -331,12 +680,6 @@ function runVisit(argv) {
331
680
  const [scaffoldFile, ...scaffoldPrefix] = scaffold;
332
681
  if (scaffoldFile === undefined)
333
682
  return undefined;
334
- const runner = {
335
- target,
336
- dry: argv.includes('--dry-run'),
337
- offline: argv.includes('--offline'),
338
- steps: [],
339
- };
340
683
  const from = STEPS.indexOf(readOption(argv, '--from') ?? 'pin');
341
684
  const to = STEPS.indexOf(readOption(argv, '--to') ?? 'compare');
342
685
  const selected = new Set(STEPS.slice(from, to + 1));
@@ -347,15 +690,34 @@ function runVisit(argv) {
347
690
  }
348
691
  const name = readString(manifest, 'name');
349
692
  const version = readString(manifest, 'version');
693
+ const runner = {
694
+ target: resolve(target),
695
+ dry: argv.includes('--dry-run'),
696
+ offline: argv.includes('--offline'),
697
+ regenerate: argv.includes('--regenerate'),
698
+ directory: argv.includes('--dry-run') ||
699
+ (name === SCAFFOLD && from === STEPS.indexOf('record') && to === from)
700
+ ? undefined
701
+ : readJournalDirectory(resolve(target)),
702
+ steps: [],
703
+ };
704
+ if (runner.directory !== undefined) {
705
+ try {
706
+ validateWavePath(runner.target, runner.directory);
707
+ }
708
+ catch (error) {
709
+ appendStep(runner, 'journal', 'validate wave destination', String(error), 1);
710
+ }
711
+ }
350
712
  const before = readRanges(manifest, RANGE_FIELDS);
351
713
  const offline = runner.offline ? ['--offline'] : [];
352
- if (selected.has('pin'))
714
+ if (selected.has('pin') && !failed(runner))
353
715
  runPin(runner, scaffoldFile, scaffoldPrefix, offline);
354
716
  if (selected.has('commit') && !failed(runner))
355
717
  runCommit(runner);
356
718
  if (selected.has('overwrite') && !failed(runner)) {
357
719
  if (name === SCAFFOLD) {
358
- recordStep(runner, 'overwrite', describeCommand(scaffoldFile, [...scaffoldPrefix, 'overwrite', '--json', ...offline]), `skipped with its audit: ${SCAFFOLD} is the package whose checkout the vendored host is staged from`);
720
+ appendStep(runner, 'overwrite', describeCommand(scaffoldFile, [...scaffoldPrefix, 'overwrite', '--json', ...offline]), `skipped with its audit: ${SCAFFOLD} is the package whose checkout the vendored host is staged from`);
359
721
  }
360
722
  else
361
723
  runOverwrite(runner, scaffoldFile, scaffoldPrefix, offline);
@@ -364,12 +726,14 @@ function runVisit(argv) {
364
726
  runVerify(runner, scaffoldFile, scaffoldPrefix, offline, name === SCAFFOLD);
365
727
  }
366
728
  if (selected.has('install') && !failed(runner))
367
- runNpmStep(runner, 'install', ['install']);
729
+ runInstall(runner, 'install');
368
730
  const hits = selected.has('pins') && !failed(runner)
369
731
  ? runPins(runner, before, readOption(argv, '--prior') ?? version)
370
732
  : undefined;
371
733
  if (selected.has('format') && !failed(runner))
372
734
  runNpmStep(runner, 'format', ['run', 'format']);
735
+ if (selected.has('record') && !failed(runner))
736
+ runRecord(runner, name);
373
737
  if (selected.has('gates') && !failed(runner)) {
374
738
  for (const gate of GATES) {
375
739
  if (failed(runner))
@@ -396,6 +760,23 @@ function runVisit(argv) {
396
760
  row: `| ${name ?? target} | ${version ?? '?'} | ${bump} | ${reading} | round |`,
397
761
  };
398
762
  }
763
+ function readJournalDirectory(target) {
764
+ const inside = spawnSync('git', ['rev-parse', '--is-inside-work-tree'], {
765
+ cwd: target,
766
+ encoding: 'utf8',
767
+ windowsHide: true,
768
+ });
769
+ if (inside.status !== 0 || inside.stdout.trim() !== 'true')
770
+ return undefined;
771
+ const ignored = spawnSync('git', ['check-ignore', '-q', 'tmp/units/wave/probe'], {
772
+ cwd: target,
773
+ encoding: 'utf8',
774
+ windowsHide: true,
775
+ });
776
+ return ignored.status === 0
777
+ ? join(target, 'tmp', 'units', 'wave', new Date().toISOString().replace(/[:.]/gu, '-'))
778
+ : undefined;
779
+ }
399
780
  function readLayers(catalogPath) {
400
781
  if (!existsSync(catalogPath))
401
782
  return undefined;
@@ -419,7 +800,7 @@ function readLayers(catalogPath) {
419
800
  }
420
801
  function renderVisit(visit) {
421
802
  const lines = [
422
- ...visit.steps.map((entry) => `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})`}`),
803
+ ...visit.steps.map((entry) => `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}`}`),
423
804
  `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)`}`,
424
805
  ...(visit.ruling.ranges ?? []).map((range) => `wave: range ${range}`),
425
806
  visit.row,
@@ -444,6 +825,10 @@ function main(argv) {
444
825
  return 64;
445
826
  }
446
827
  if (argv.includes('--plan')) {
828
+ if (argv.includes('--regenerate')) {
829
+ console.error('wave: --regenerate requires --visit, not --plan');
830
+ return 64;
831
+ }
447
832
  const layers = readLayers(readOption(argv, '--catalog') ?? CATALOG);
448
833
  if (layers === undefined) {
449
834
  console.error('wave: no marker-bounded catalog table; run scaffold catalog first');
@@ -457,7 +842,7 @@ function main(argv) {
457
842
  return 0;
458
843
  }
459
844
  if (!argv.includes('--visit')) {
460
- console.error('usage: wave.ts --visit [--target DIR] [--offline] [--from STEP] [--to STEP] [--prior VERSION] [--dry-run] [--json] [--out FILE] | --plan [--catalog PATH] [--json]');
845
+ console.error('usage: wave.ts --visit [--target DIR] [--offline] [--regenerate] [--from STEP] [--to STEP] [--prior VERSION] [--dry-run] [--json] [--out FILE] | --plan [--catalog PATH] [--json]');
461
846
  return 64;
462
847
  }
463
848
  const from = STEPS.indexOf(readOption(argv, '--from') ?? 'pin');
@@ -483,7 +868,8 @@ function main(argv) {
483
868
  writeFileSync(out, output);
484
869
  console.log(`wave: wrote ${out}`);
485
870
  }
486
- return visit.steps.some((entry) => entry.exit !== undefined &&
871
+ return visit.steps.some((entry) => entry.superseded !== true &&
872
+ entry.exit !== undefined &&
487
873
  entry.exit !== 0 &&
488
874
  entry.step !== 'pins' &&
489
875
  entry.step !== 'compare')
@@ -72,7 +72,8 @@ function readManifestName(directory) {
72
72
  function publishPackage(directory, otp) {
73
73
  const manifest = readManifestName(directory);
74
74
  const journal = `tmp/units/publish-${(manifest.name ?? directory).replace(/[^A-Za-z0-9.-]+/gu, '-')}.log`;
75
- const result = runNpm(['publish', '--ignore-scripts', '--browser=false', `--otp=${otp}`], directory);
75
+ // The refusal code must survive an inherited silent npm log level.
76
+ const result = runNpm(['publish', '--ignore-scripts', '--browser=false', '--loglevel=error', `--otp=${otp}`], directory);
76
77
  writeFileSync(journal, `${result.stdout}\n${result.stderr}\nexit=${result.status ?? 'null'}\n`);
77
78
  const match = `${result.stdout}\n${result.stderr}`.match(ACCEPTED);
78
79
  const accepted = result.status === 0 && match !== null;