@orkestrel/scaffold 0.0.21 → 0.0.23

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 (68) hide show
  1. package/dist/bin/scaffold.js +371 -140
  2. package/dist/bin/scaffold.js.map +1 -1
  3. package/dist/host/AGENTS.md +61 -42
  4. package/dist/host/CLAUDE.md +39 -408
  5. package/dist/host/agents/orchestration.md +454 -0
  6. package/dist/host/agents/skills/enterprise-bootstrap/SKILL.md +25 -7
  7. package/dist/host/agents/skills/orkestrel-align-packages/SKILL.md +2 -2
  8. package/dist/host/agents/skills/orkestrel-build-application/SKILL.md +42 -31
  9. package/dist/host/agents/skills/orkestrel-build-application/references/application.md +129 -0
  10. package/dist/host/agents/skills/orkestrel-debrief/SKILL.md +3 -3
  11. package/dist/host/agents/skills/orkestrel-debrief/references/field-testing.md +2 -2
  12. package/dist/host/agents/skills/orkestrel-falsify/SKILL.md +167 -0
  13. package/dist/host/agents/skills/orkestrel-falsify/references/brief.md +98 -0
  14. package/dist/host/agents/skills/orkestrel-falsify/references/reconcile.md +148 -0
  15. package/dist/host/agents/skills/orkestrel-harden-package/SKILL.md +4 -2
  16. package/dist/host/agents/skills/orkestrel-harden-package/references/centralization.md +5 -5
  17. package/dist/host/agents/skills/orkestrel-harden-package/references/research.md +2 -0
  18. package/dist/host/agents/skills/orkestrel-polish-surface/SKILL.md +11 -6
  19. package/dist/host/claude/agents/analyst.md +59 -0
  20. package/dist/host/claude/agents/application.md +1 -1
  21. package/dist/host/claude/agents/builder.md +1 -1
  22. package/dist/host/claude/agents/checker.md +5 -1
  23. package/dist/host/claude/agents/codex.md +120 -94
  24. package/dist/host/claude/agents/grok.md +44 -32
  25. package/dist/host/claude/agents/implementer.md +1 -1
  26. package/dist/host/claude/agents/orkestrel.md +5 -5
  27. package/dist/host/claude/agents/planner.md +9 -3
  28. package/dist/host/claude/agents/researcher.md +6 -5
  29. package/dist/host/claude/agents/reviewer.md +12 -6
  30. package/dist/host/claude/agents/scout.md +7 -4
  31. package/dist/host/claude/agents/verifier.md +1 -1
  32. package/dist/host/claude/rules/application.md +17 -16
  33. package/dist/host/claude/rules/architecture.md +41 -22
  34. package/dist/host/claude/rules/documentation.md +2 -1
  35. package/dist/host/claude/rules/names.md +7 -6
  36. package/dist/host/claude/rules/quality.md +65 -18
  37. package/dist/host/claude/rules/styles.md +1 -1
  38. package/dist/host/claude/rules/tests.md +12 -6
  39. package/dist/host/claude/rules/typescript.md +1 -1
  40. package/dist/host/claude/rules/workspace.md +6 -3
  41. package/dist/host/claude/settings.json +2 -0
  42. package/dist/host/claude/skills/orkestrel-falsify/SKILL.md +10 -0
  43. package/dist/host/codex/agents/analyst.toml +13 -4
  44. package/dist/host/codex/agents/checker.toml +1 -1
  45. package/dist/host/codex/agents/grok.toml +2 -1
  46. package/dist/host/codex/agents/opus.toml +1 -1
  47. package/dist/host/codex/agents/planner.toml +1 -1
  48. package/dist/host/codex/agents/researcher.toml +2 -2
  49. package/dist/host/codex/agents/reviewer.toml +1 -1
  50. package/dist/host/codex/agents/scout.toml +2 -2
  51. package/dist/host/codex/config.toml +23 -70
  52. package/dist/host/cursor/rules/orchestration.mdc +33 -0
  53. package/dist/host/guides/src/scaffold.md +339 -125
  54. package/dist/host/manifest.json +47 -1
  55. package/dist/host/tests/setupPolicy.ts +210 -3
  56. package/dist/src/core/index.cjs +477 -69
  57. package/dist/src/core/index.cjs.map +1 -1
  58. package/dist/src/core/index.d.cts +132 -43
  59. package/dist/src/core/index.d.ts +132 -43
  60. package/dist/src/core/index.js +474 -70
  61. package/dist/src/core/index.js.map +1 -1
  62. package/dist/src/server/index.cjs +83 -33
  63. package/dist/src/server/index.cjs.map +1 -1
  64. package/dist/src/server/index.d.cts +43 -27
  65. package/dist/src/server/index.d.ts +43 -27
  66. package/dist/src/server/index.js +84 -35
  67. package/dist/src/server/index.js.map +1 -1
  68. package/package.json +4 -4
@@ -2,11 +2,11 @@
2
2
  import { existsSync, mkdirSync, writeFileSync } from "node:fs";
3
3
  import { basename, dirname, join, relative } from "node:path";
4
4
  import * as tls from "node:tls";
5
- import { DEPENDENCY_NAME_PATTERN, ENVIRONMENTS, GROUPS, MAX_ARTIFACT_BYTES, NAME_PATTERN, SERVICE_SCRIPT_PATH, ScaffoldError, blueprint, catalogNames, catalogToBlock, createCompiler, dependency, diffPlan, isScaffoldError, manifestToDependencies, manifestToName, planToSummary } from "../src/core/index.js";
5
+ import { CATALOG_AGENT_PATH, DEPENDENCY_NAME_PATTERN, ENVIRONMENTS, GROUPS, MAX_ARTIFACT_BYTES, NAME_PATTERN, SERVICE_SCRIPT_PATH, ScaffoldError, blueprint, catalogNames, catalogToBlock, createCompiler, dependency, diffPlan, isScaffoldError, manifestToDependencies, manifestToName, ownDataValue, planToSummary } from "../src/core/index.js";
6
6
  import { WriteTransaction, catalogPackages, commitWriteTransaction, createMaterializer, createSync, deriveBlueprint, digestFile, digestText, discardWriteTransaction, discoverPackages, hostRoot, hydratePlan, isFilesystemPath, isRealDirectory, isTerminalText, isVacant, locateHostSource, parseSyncOptions, pruneTargets, readFileText, readHostManifest, readManifest, readTarget, resolvePhysicalPath, validateWriteDirectories } from "../src/server/index.js";
7
7
  import { createReporter, createSpinner, createStyler } from "@orkestrel/console";
8
8
  import { createServerSink } from "@orkestrel/console/server";
9
- import { attempt } from "@orkestrel/contract";
9
+ import { attempt, isRecord, parseJSON } from "@orkestrel/contract";
10
10
  import { isTerminalError } from "@orkestrel/terminal";
11
11
  import { createTerminal } from "@orkestrel/terminal/server";
12
12
  import { parseArgs } from "node:util";
@@ -48,10 +48,10 @@ var ACTION_LABEL = Object.freeze({
48
48
  skipped: "unchanged",
49
49
  removed: "removed"
50
50
  });
51
- /** Repair's deliberately limited ownership boundary. */
52
- var REPAIR_SCOPE = "repair scope: shared host-owned artifacts only — starter and generated files are never touched";
53
- /** Repair's opt-in generated-canon ownership boundary. */
54
- var REPAIR_GENERATED_SCOPE = "repair scope: shared host-owned and generated artifacts — starter files and package.json are never touched";
51
+ /** The deliberately limited ownership boundary every write verb repairs within. */
52
+ var REPAIR_SCOPE = "shared host-owned artifacts and absent service seams missing files are restored; drifted files change only with --replace, which discards local changes; present starter and generated files are never touched";
53
+ /** The opt-in generated-canon ownership boundary `--generated` widens that write to. */
54
+ var REPAIR_GENERATED_SCOPE = "shared host-owned and generated artifacts plus service manifest scripts missing files are restored; drifted files change only with --replace, which discards local changes; present starter files and package publication metadata are never touched";
55
55
  /** The dry-run note for `new`. */
56
56
  var NEW_DRY_RUN_NOTE = "dry run — pass --apply to write";
57
57
  /** The fallback message for a malformed command line without an error message. */
@@ -95,7 +95,7 @@ var VERB_SUMMARY = Object.freeze({
95
95
  pull: "refresh vendored guides/versions, report drift",
96
96
  mirror: "refresh every published Orkestrel package guide",
97
97
  audit: "whole-plan conformance report",
98
- repair: "restore host-owned files, plus generated canon with --generated",
98
+ repair: "restore missing host-owned files; replace drifted bytes only with --replace",
99
99
  fleet: "audit/repair every workspace under the cwd's immediate children",
100
100
  catalog: "regenerate the fleet package-catalog table"
101
101
  });
@@ -104,9 +104,9 @@ var VERB_FLAGS = Object.freeze({
104
104
  new: "--src a,b --app a,b --deps x,y --apply --yes --target <path> --from <path>",
105
105
  pull: "--target . --deps x,y --apply --yes --strict",
106
106
  mirror: "--target . --apply --yes --strict",
107
- audit: "--target . --live --generated --from <path> --groups a,b",
108
- repair: "--target . --generated --apply --yes --prune --from <path>",
109
- fleet: "--generated --apply --yes --prune --from <path>",
107
+ audit: "--target . --live --generated --replace --from <path> --groups a,b",
108
+ repair: "--target . --generated --replace --apply --yes --prune --from <path>",
109
+ fleet: "--generated --replace --apply --yes --prune --from <path>",
110
110
  catalog: "--from <path> ... --target <repo> --offline --apply --yes"
111
111
  });
112
112
  /** Plain-language command flag descriptions. */
@@ -136,20 +136,23 @@ var VERB_FLAG_HELP = Object.freeze({
136
136
  audit: [
137
137
  ["--target .", "directory to audit (default: current directory)"],
138
138
  ["--live", "also check upstream freshness over the network"],
139
- ["--generated", "include generated canon if the repair hand-off is accepted"],
139
+ ["--generated", "widen an accepted repair hand-off to generated files"],
140
+ ["--replace", "let the repair hand-off discard local changes in the drifted files it names"],
140
141
  ["--from <path>", "read the template from a local path instead of the bundled one"],
141
142
  ["--groups a,b", "limit the audit to these artifact groups"]
142
143
  ],
143
144
  repair: [
144
145
  ["--target .", "directory to repair (default: current directory)"],
145
- ["--generated", "also restore generated canon except package.json"],
146
+ ["--generated", "widen the scope to generated files, except package.json"],
147
+ ["--replace", "discard local changes in the drifted files named by the repair report"],
146
148
  ["--apply", "write the fixes (default is a dry run)"],
147
149
  ["--yes", "skip the confirmation question"],
148
150
  ["--prune", "also DELETE unexpected files under .claude/agents, .codex/agents, and scripts"],
149
151
  ["--from <path>", "read the template from a local path instead of the bundled one"]
150
152
  ],
151
153
  fleet: [
152
- ["--generated", "also restore generated canon except package.json in every package"],
154
+ ["--generated", "widen the scope to generated files in every package, except package.json"],
155
+ ["--replace", "discard local changes in the drifted files named in each package"],
153
156
  ["--apply", "write fixes across every package (default is a dry run)"],
154
157
  ["--yes", "skip the confirmation question"],
155
158
  ["--prune", "also DELETE unexpected files under .claude/agents, .codex/agents, and scripts, per package"],
@@ -186,19 +189,17 @@ var VERB_EXAMPLE = Object.freeze({
186
189
  /** Message used when a prune scan has no candidates. */
187
190
  var PRUNE_EMPTY = "no unexpected files to delete";
188
191
  /** Guidance for unexpected files outside a non-pruning repair handoff. */
189
- var FOREIGN_HINT = "unexpected files found — run 'scaffold repair --prune' to delete them";
192
+ var FOREIGN_HINT = "unexpected files are never deleted by default — run 'scaffold repair --prune --apply' to delete them; a file you added yourself is unexpected too, so check the paths above first";
190
193
  /** Interactive dependency prompt. */
191
194
  var ORKESTREL_DEPS_PROMPT = "@orkestrel dependencies (comma-separated short names, e.g. contract, emitter — installed as dependencies)";
192
195
  /** Catalog-degraded validation note. */
193
196
  var CATALOG_UNRESOLVED_NOTE = "couldn't resolve the vendored @orkestrel catalog — validating names by shape only";
194
- /** Catalog block destination in a scaffolded repository. */
195
- var CATALOG_AGENT_PATH = ".claude/agents/orkestrel.md";
196
197
  /** Opening marker for the generated package catalog block. */
197
198
  var CATALOG_START_MARKER = "<!-- catalog:start -->";
198
199
  /** Closing marker for the generated package catalog block. */
199
200
  var CATALOG_END_MARKER = "<!-- catalog:end -->";
200
201
  /** Non-terminal prune safety note. */
201
- var PRUNE_SKIPPED = "prune skipped — not a terminal; add --apply (or --yes) to delete non-interactively";
202
+ var PRUNE_SKIPPED = "prune skipped — pass --apply to authorize deletion; --yes only skips confirmation";
202
203
  /** Degraded unexpected-file scan note. */
203
204
  var SCAN_SKIPPED = "unexpected-file scanning skipped — couldn't establish the template source";
204
205
  //#endregion
@@ -213,49 +214,57 @@ var CLIExitError = class extends Error {
213
214
  };
214
215
  //#endregion
215
216
  //#region src/bin/shapers.ts
216
- /** Partition findings by whether repair owns their artifact origin. */
217
+ /** Partition findings by whether repair owns their artifact origin. The `owned` and `generated` buckets carry no foreign findings by construction. */
217
218
  function partitionFindings(findings, plan) {
218
219
  const origins = new Map(plan.artifacts.map((artifact) => [artifact.path, artifact.origin]));
219
220
  let ownedDrifted = 0;
220
221
  let ownedMissing = 0;
221
- let ownedForeign = 0;
222
222
  let generatedDrifted = 0;
223
223
  let generatedMissing = 0;
224
- let generatedForeign = 0;
224
+ let foreign = 0;
225
225
  for (const finding of findings) {
226
226
  const origin = origins.get(finding.path);
227
227
  const owned = origin === "host" || origin === "template";
228
228
  if (finding.drift === "aligned") continue;
229
+ if (finding.drift === "foreign") {
230
+ foreign += 1;
231
+ continue;
232
+ }
229
233
  if (finding.drift === "stale") {
230
234
  if (owned) ownedDrifted += 1;
231
235
  else generatedDrifted += 1;
232
236
  } else if (finding.drift === "missing") {
233
237
  if (owned) ownedMissing += 1;
234
238
  else generatedMissing += 1;
235
- } else if (owned) ownedForeign += 1;
236
- else generatedForeign += 1;
239
+ }
237
240
  }
238
241
  return {
239
242
  owned: {
240
243
  drifted: ownedDrifted,
241
244
  missing: ownedMissing,
242
- foreign: ownedForeign
245
+ foreign: 0
243
246
  },
244
247
  generated: {
245
248
  drifted: generatedDrifted,
246
249
  missing: generatedMissing,
247
- foreign: generatedForeign
250
+ foreign: 0
251
+ },
252
+ foreign: {
253
+ drifted: 0,
254
+ missing: 0,
255
+ foreign
248
256
  }
249
257
  };
250
258
  }
251
259
  /** Create one deterministic fleet result entry. */
252
- function fleetEntryOf(name, counts, failed) {
260
+ function fleetEntryOf(name, counts, failed, outside = 0) {
253
261
  return {
254
262
  name,
255
263
  drifted: counts?.drifted ?? 0,
256
264
  missing: counts?.missing ?? 0,
257
265
  foreign: counts?.foreign ?? 0,
258
- failed
266
+ failed,
267
+ outside
259
268
  };
260
269
  }
261
270
  /** Create the command-line interface's machine-readable failure envelope. */
@@ -277,7 +286,14 @@ function summaryToNewResult(summary, applied) {
277
286
  applied
278
287
  };
279
288
  }
280
- /** Add a materialization result to an audit. */
289
+ /** Add repair's outside-scope count to its selected audit. */
290
+ function repairAuditOf(audit, outside) {
291
+ return {
292
+ ...audit,
293
+ outside
294
+ };
295
+ }
296
+ /** Add a materialization result to a repair audit. */
281
297
  function auditToRepairResult(audit, result) {
282
298
  return {
283
299
  ...audit,
@@ -304,9 +320,9 @@ function countPart(count, label) {
304
320
  /** Render nonzero audit buckets or `clean`. */
305
321
  function bucketText(counts) {
306
322
  const parts = [];
307
- if (counts.drifted > 0) parts.push(countPart(counts.drifted, "drifted"));
308
- if (counts.missing > 0) parts.push(countPart(counts.missing, "missing"));
309
- if (counts.foreign > 0) parts.push(countPart(counts.foreign, "unexpected"));
323
+ if (counts.drifted > 0) parts.push(`${counts.drifted} drifted`);
324
+ if (counts.missing > 0) parts.push(`${counts.missing} missing`);
325
+ if (counts.foreign > 0) parts.push(`${counts.foreign} unexpected`);
310
326
  return parts.length > 0 ? parts.join(", ") : "clean";
311
327
  }
312
328
  /** Render audit's origin-aware verdict. */
@@ -314,7 +330,12 @@ function auditVerdict(audit, plan) {
314
330
  const count = audit.findings.length;
315
331
  if (audit.clean) return `audit: ${countPart(count, "artifact")} — clean`;
316
332
  const split = partitionFindings(audit.findings, plan);
317
- return split.owned.drifted === 0 && split.owned.missing === 0 && split.owned.foreign === 0 ? `audit: ${countPart(count, "artifact")} — host-owned clean; ${bucketText(split.generated)} (generated)` : `audit: ${countPart(count, "artifact")} — host-owned: ${bucketText(split.owned)}; generated: ${bucketText(split.generated)}`;
333
+ const owned = split.owned.drifted === 0 && split.owned.missing === 0;
334
+ const generated = bucketText(split.generated);
335
+ const foreign = bucketText(split.foreign);
336
+ if (owned && generated === "clean") return `audit: ${countPart(count, "artifact")} — host-owned clean; ${foreign}`;
337
+ const suffix = foreign === "clean" ? "" : `; unexpected: ${foreign}`;
338
+ return owned ? `audit: ${countPart(count, "artifact")} — host-owned clean; ${generated} (generated)${suffix}` : `audit: ${countPart(count, "artifact")} — host-owned: ${bucketText(split.owned)}; generated: ${generated}${suffix}`;
318
339
  }
319
340
  /** Render non-aligned audit findings as terminal table rows. */
320
341
  function findingRows(findings, plan) {
@@ -341,6 +362,43 @@ function auditTable(audit, plan) {
341
362
  };
342
363
  }
343
364
  /**
365
+ * Count the files one authorized write will create or overwrite.
366
+ *
367
+ * @param counts - The drift tallies of every target the write covers.
368
+ * @param replace - Whether stale byte replacement was explicitly authorized.
369
+ * @returns Every missing file, plus drifted files only under `replace`.
370
+ * @remarks
371
+ * The one figure every write confirmation asks about, so `repair` and `fleet`
372
+ * cannot drift apart on it. An unexpected file is never counted here: only
373
+ * `--prune` deletes one, and only after its own separate question.
374
+ */
375
+ function countWrites(counts, replace) {
376
+ let total = 0;
377
+ for (const count of counts) total += count.missing + (replace ? count.drifted : 0);
378
+ return total;
379
+ }
380
+ /**
381
+ * Test whether a reported repository remains dirty inside or outside the selected scope.
382
+ *
383
+ * @param audit - The selected-scope audit.
384
+ * @param outside - Findings outside that scope.
385
+ * @returns Whether either source reports drift.
386
+ */
387
+ function hasFindings(audit, outside) {
388
+ return !audit.clean || outside > 0;
389
+ }
390
+ /**
391
+ * Render one write verb's ownership boundary in that verb's own voice.
392
+ *
393
+ * @param verb - The command whose scope this is.
394
+ * @param generated - Whether generated canon was included in the scope.
395
+ * @param repos - The number of repositories the write covers, absent for a single target.
396
+ * @returns The scope line naming what the command restores, what it replaces only with `--replace`, and what it never touches.
397
+ */
398
+ function scopeLine(verb, generated, repos) {
399
+ return `${verb} scope${repos === void 0 ? "" : ` across ${countPart(repos, "repo")}`}: ${generated ? REPAIR_GENERATED_SCOPE : REPAIR_SCOPE}`;
400
+ }
401
+ /**
344
402
  * Render drift outside repair's selected ownership boundary.
345
403
  *
346
404
  * @param count - The number of findings outside the selected scope.
@@ -349,24 +407,84 @@ function auditTable(audit, plan) {
349
407
  */
350
408
  function scopeNote(count, generated) {
351
409
  if (count === 0) return void 0;
352
- return generated ? `note: ${countPart(count, "finding")} outside host-owned and generated repair scope — run 'audit' for the list; starter files and package.json remain protected` : `note: ${countPart(count, "finding")} outside host-owned repair scope — run 'audit' for the list`;
410
+ return generated ? `note: ${countPart(count, "finding")} outside host-owned and generated repair scope — run 'audit' for the list; present starter files and package publication metadata remain protected` : `note: ${countPart(count, "finding")} outside host-owned repair scope — run 'audit' for the list`;
353
411
  }
354
412
  /**
355
413
  * Render repair's dry-run verdict.
356
414
  *
357
415
  * @param audit - The audit over the selected repair plan.
358
- * @param generated - Whether generated canon was included in the repair scope.
416
+ * @param options - The selected scope and write authorizations.
359
417
  * @returns The scope-aware clean or drifted verdict.
360
418
  */
361
- function repairVerdict(audit, generated) {
362
- const scope = generated ? "host-owned and generated" : "host-owned";
419
+ function repairVerdict(audit, options) {
420
+ const scope = options.generated ? "host-owned and generated" : "host-owned";
363
421
  if (audit.clean) return `repair: ${countPart(audit.findings.length, `${scope} artifact`)} aligned — nothing to write`;
364
- return `repair: ${scope}: ${bucketText(audit)} — pass --apply to write`;
422
+ const head = `repair: ${scope}: ${bucketText(audit)}`;
423
+ if (audit.drifted === 0) return options.apply ? `${head} — missing files will be restored` : `${head} — pass --apply to write`;
424
+ if (audit.missing === 0) return options.replace ? `${head} — --apply overwrites drifted files, discarding local changes` : `${head} — drifted files change only with --replace, which discards local changes`;
425
+ return options.replace ? `${head} — --apply restores missing files and overwrites drifted ones, discarding local changes` : `${head} — --apply restores missing files; drifted files change only with --replace, which discards local changes`;
365
426
  }
366
- /** Render repair's materialization tally. */
367
- function repairSuccess(result, removed) {
368
- const written = result.written.length + result.copied.length;
369
- return `${ACTION_LABEL.written} ${written}, ${ACTION_LABEL.skipped} ${result.skipped.length}, ${ACTION_LABEL.removed} ${removed.length}`;
427
+ /**
428
+ * Merge only generated service scripts into an existing manifest.
429
+ *
430
+ * @param current - The existing consumer manifest text.
431
+ * @param generated - The canonical manifest text for the derived service blueprint.
432
+ * @param services - The declared service vendor names.
433
+ * @returns Formatter-stable manifest text preserving publication metadata and unrelated scripts.
434
+ */
435
+ function mergeServiceManifest(current, generated, services) {
436
+ const currentManifest = parseJSON(current);
437
+ const generatedManifest = parseJSON(generated);
438
+ if (!isRecord(currentManifest) || !isRecord(generatedManifest)) throw new ScaffoldError("INVALID", "Service adoption requires object package manifests");
439
+ const currentScriptsValue = ownDataValue(currentManifest, "scripts");
440
+ const generatedScriptsValue = ownDataValue(generatedManifest, "scripts");
441
+ if (!isRecord(currentScriptsValue) || !isRecord(generatedScriptsValue)) throw new ScaffoldError("INVALID", "Service adoption requires object package scripts");
442
+ const serviceKeys = ["test:service", ...services.map((service) => `test:service:${service}`)];
443
+ const serviceNames = new Set(serviceKeys);
444
+ const currentPublish = ownDataValue(currentScriptsValue, "prepublishOnly");
445
+ const generatedPublish = ownDataValue(generatedScriptsValue, "prepublishOnly");
446
+ if (typeof currentPublish !== "string" || typeof generatedPublish !== "string") throw new ScaffoldError("INVALID", "Service adoption requires a prepublishOnly script");
447
+ const suffix = " && npm run test:service";
448
+ const publish = currentPublish.endsWith(suffix) ? currentPublish : `${currentPublish}${suffix}`;
449
+ const scripts = {};
450
+ for (const name of Object.keys(generatedScriptsValue)) {
451
+ if (serviceNames.has(name)) {
452
+ const value = ownDataValue(generatedScriptsValue, name);
453
+ if (typeof value !== "string") throw new ScaffoldError("INVALID", `Generated service script is missing at ${name}`);
454
+ scripts[name] = value;
455
+ continue;
456
+ }
457
+ if (name === "prepublishOnly") {
458
+ scripts[name] = publish;
459
+ continue;
460
+ }
461
+ const value = ownDataValue(currentScriptsValue, name);
462
+ if (value !== void 0) scripts[name] = value;
463
+ }
464
+ for (const name of Object.keys(currentScriptsValue)) {
465
+ if (Object.hasOwn(scripts, name)) continue;
466
+ if (name === "test:service" || name.startsWith("test:service:")) continue;
467
+ const value = ownDataValue(currentScriptsValue, name);
468
+ if (value !== void 0) scripts[name] = value;
469
+ }
470
+ return `${JSON.stringify({
471
+ ...currentManifest,
472
+ scripts
473
+ }, void 0, " ")}\n`;
474
+ }
475
+ /**
476
+ * Render repair's closing tally.
477
+ *
478
+ * @param tally - The displayed counts of one repair run.
479
+ * @returns The tally line, in the same words the audit table above it used.
480
+ * @remarks
481
+ * A drifted file repair was not authorized to overwrite is counted on its own
482
+ * rather than folded in with the aligned files, because `unchanged` is already
483
+ * the audit table's word for a file that matches canon.
484
+ */
485
+ function repairTally(tally) {
486
+ const left = tally.drifted === 0 ? "" : `, ${countPart(tally.drifted, `${DRIFT_LABEL.stale} file`)} left alone`;
487
+ return `${ACTION_LABEL.written} ${tally.written}, ${ACTION_LABEL.skipped} ${tally.unchanged}${left}, ${ACTION_LABEL.removed} ${tally.removed}`;
370
488
  }
371
489
  /** Render synchronization freshness as table rows. */
372
490
  function syncRows(report) {
@@ -415,7 +533,7 @@ function fleetRepoLine(name, outcome) {
415
533
  }
416
534
  /** Render fleet's repository totals. */
417
535
  function fleetTotals(drifted, failed) {
418
- return `total: ${countPart(drifted, "drifted repo")}, ${countPart(failed, "failed")}`;
536
+ return `total: ${countPart(drifted, "dirty repo")}, ${failed} failed`;
419
537
  }
420
538
  /** Create catalog's terminal table. */
421
539
  function catalogTable(entries) {
@@ -447,13 +565,25 @@ function newPlanTable(summary) {
447
565
  ]
448
566
  };
449
567
  }
568
+ /**
569
+ * Render one contained write destination as the operator's own path to it.
570
+ *
571
+ * @param root - The invocation directory every write is confined beneath.
572
+ * @param destination - The contained physical destination.
573
+ * @returns The destination relative to the invocation directory, or the absolute path when it is not beneath it.
574
+ */
575
+ function describeDestination(root, destination) {
576
+ const path = relative(root, destination).replaceAll("\\", "/");
577
+ if (path === "") return ".";
578
+ return path.startsWith("..") ? destination : `./${path}`;
579
+ }
450
580
  /** Render `new`'s dry-run destination. */
451
- function newPlanPreview(name) {
452
- return `will write into ./${name}`;
581
+ function newPlanPreview(destination) {
582
+ return `will write into ${destination}`;
453
583
  }
454
584
  /** Render `new`'s write result. */
455
- function newApplySuccess(count, name) {
456
- return `wrote ${countPart(count, "file")} into ./${name}`;
585
+ function newApplySuccess(count, destination) {
586
+ return `wrote ${countPart(count, "file")} into ${destination}`;
457
587
  }
458
588
  /** Render catalog's write result. */
459
589
  function catalogApplySuccess(path) {
@@ -468,12 +598,21 @@ function applyConfirmMessage(files, repos) {
468
598
  function pruneConfirmMessage(count) {
469
599
  return `Also delete ${countPart(count, "unexpected file")} under .claude/agents, .codex/agents, and scripts? `;
470
600
  }
471
- /** Render the interactive audit-to-repair handoff. */
472
- function repairHandoff(owned, foreign, prune) {
601
+ /**
602
+ * Render the interactive audit-to-repair handoff as the list of actions it authorizes.
603
+ *
604
+ * @param missing - The number of missing host-owned files the inherited repair would restore.
605
+ * @param drifted - The number of drifted host-owned files it would overwrite, zero unless `--replace` authorized replacement.
606
+ * @param foreign - The number of unexpected files found.
607
+ * @param prune - Whether `--prune` authorized deletion, without which no unexpected file is touched.
608
+ * @returns The confirmation question, naming each authorized action and the cost of overwriting.
609
+ */
610
+ function repairHandoff(missing, drifted, foreign, prune) {
473
611
  const parts = [];
474
- if (owned > 0) parts.push(`${countPart(owned, "host-owned file")} ${owned === 1 ? "has" : "have"} drift`);
475
- if (prune && foreign > 0) parts.push(`${countPart(foreign, "unexpected file")} will be deleted`);
476
- return `${parts.join(" and ")} — run repair now? `;
612
+ if (missing > 0) parts.push(`restore ${countPart(missing, "missing host-owned file")}`);
613
+ if (drifted > 0) parts.push(`overwrite ${countPart(drifted, "drifted host-owned file")}, discarding local changes`);
614
+ if (prune && foreign > 0) parts.push(`delete ${countPart(foreign, "unexpected file")}`);
615
+ return `${parts.join("; ")} — run repair now? `;
477
616
  }
478
617
  /** Render one unresolved Orkestrel dependency token. */
479
618
  function unknownOrkestrelToken(token, suggestion) {
@@ -567,9 +706,23 @@ function invalidName(name, pattern) {
567
706
  function unresolvedVersion(names) {
568
707
  return `could not resolve the latest version for ${names.map((name) => `"${name}"`).join(", ")} — check the name or pass name@range`;
569
708
  }
570
- /** Render drift that belongs to generated artifacts. */
709
+ /**
710
+ * Render drift that belongs to generated artifacts.
711
+ *
712
+ * @param count - The number of findings on generated artifacts.
713
+ * @returns The guidance line, stating what each repair scope does rather than how the files differ.
714
+ */
571
715
  function generatedNote(count) {
572
- return `${countPart(count, "finding")} in generated files — these are regenerated, not hand-edited; run 'scaffold repair --generated' to restore them`;
716
+ return `${countPart(count, "finding")} in generated files — run 'scaffold repair --generated' to restore missing ones; add --replace to overwrite drifted ones, discarding local changes`;
717
+ }
718
+ /**
719
+ * Render the explicit destructive opt-in for stale host-owned files.
720
+ *
721
+ * @param count - The number of stale host-owned files.
722
+ * @returns The replacement guidance line, naming the safe default before its destructive opt-in.
723
+ */
724
+ function replacementNote(count) {
725
+ return `${countPart(count, "drifted host-owned file")} — repair leaves drifted files alone; 'scaffold repair --replace' overwrites them, discarding local changes`;
573
726
  }
574
727
  /**
575
728
  * Render honest repair guidance for computed and protected manifest drift.
@@ -589,16 +742,19 @@ function renderComputedNotes(findings, plan) {
589
742
  }
590
743
  const notes = [];
591
744
  if (computed > 0) notes.push(generatedNote(computed));
592
- if (manifest > 0) notes.push(`${countPart(manifest, "finding")} in package.json — repair does not rewrite protected publication metadata; review and edit it directly`);
745
+ if (manifest > 0 && plan.blueprint.services.length > 0) notes.push(`${countPart(manifest, "finding")} in package.json — 'scaffold repair --generated' repairs generated service scripts; review any remaining publication metadata directly`);
746
+ else if (manifest > 0) notes.push(`${countPart(manifest, "finding")} in package.json — repair does not rewrite protected publication metadata; review and edit it directly`);
593
747
  return notes;
594
748
  }
595
749
  /** Render live dependency freshness tallies. */
596
750
  function auditLiveNote(current, behind, failed) {
597
- return `live: ${countPart(current, "current")}, ${countPart(behind, "behind")}, ${countPart(failed, "failed")}`;
751
+ return `live: ${current} current, ${behind} behind, ${failed} failed`;
598
752
  }
599
753
  /** Render whether an audit compared content or presence. */
600
- function comparisonLine(aware) {
601
- return aware ? "comparing: file contents for host-owned files" : "comparing: file names only for host-owned files (no vendored source found)";
754
+ function comparisonLine(compared, presence) {
755
+ if (presence === 0) return "comparing: file contents for host-owned files";
756
+ if (compared === 0) return `comparing: file presence for ${countPart(presence, "presence-owned file")}`;
757
+ return `comparing: file contents for ${countPart(compared, "host-owned file")}; presence for ${countPart(presence, "presence-owned file")}`;
602
758
  }
603
759
  /** Render catalog's final verdict. */
604
760
  function catalogVerdict(clean) {
@@ -662,6 +818,10 @@ function parseArguments(argv) {
662
818
  type: "boolean",
663
819
  default: false
664
820
  },
821
+ replace: {
822
+ type: "boolean",
823
+ default: false
824
+ },
665
825
  strict: {
666
826
  type: "boolean",
667
827
  default: false
@@ -824,8 +984,8 @@ var CLI = class {
824
984
  compiler.destroy();
825
985
  }
826
986
  }
827
- #prunePaths(target, host, service) {
828
- const seam = service ? SERVICE_SCRIPT_PATH : void 0;
987
+ #prunePaths(target, host, services) {
988
+ const seam = services.length > 0 ? SERVICE_SCRIPT_PATH : void 0;
829
989
  return pruneTargets(target, host).filter((path) => path !== seam);
830
990
  }
831
991
  /**
@@ -838,8 +998,8 @@ var CLI = class {
838
998
  * unclean, so an "unexpected file" is honestly counted as drift (exit 1)
839
999
  * instead of the structurally-always-zero `diffPlan.foreign`.
840
1000
  */
841
- #scan(audit, target, host, service) {
842
- const paths = this.#prunePaths(target, host, service);
1001
+ #scan(audit, target, host, services) {
1002
+ const paths = this.#prunePaths(target, host, services);
843
1003
  if (paths.length === 0) return audit;
844
1004
  const findings = paths.map((path) => ({
845
1005
  path,
@@ -858,8 +1018,8 @@ var CLI = class {
858
1018
  * When fail-closed allowlist discovery raises a coded `TARGET` failure, retain
859
1019
  * the existing findings and mark the audit incomplete instead of crashing.
860
1020
  */
861
- #scanSafe(audit, target, host, service) {
862
- const scanned = attempt(() => this.#scan(audit, target, host, service));
1021
+ #scanSafe(audit, target, host, services) {
1022
+ const scanned = attempt(() => this.#scan(audit, target, host, services));
863
1023
  if (scanned.success) return {
864
1024
  audit: scanned.value,
865
1025
  skipped: false
@@ -884,21 +1044,63 @@ var CLI = class {
884
1044
  * caller compiles the full plan anyway. Diff it too so a clean scoped verdict can point at drift
885
1045
  * outside the selected boundary. The count feeds the shared `scopeNote` renderer.
886
1046
  */
887
- #outside(compiled, target) {
888
- const full = diffPlan(compiled, readTarget(target, compiled.artifacts.map((artifact) => artifact.path)));
889
- return full.drifted + full.missing + full.foreign;
1047
+ #outside(compiled, selected, target, host) {
1048
+ const full = hydratePlan(compiled, host);
1049
+ const selectedPaths = new Set(selected.artifacts.map((artifact) => artifact.path));
1050
+ return diffPlan(full, readTarget(target, full.artifacts.map((artifact) => artifact.path))).findings.filter((finding) => finding.drift !== "aligned" && !selectedPaths.has(finding.path)).length;
890
1051
  }
891
- /** Compile and audit the selected repair ownership boundary for one structural snapshot. */
892
- #prepareRepair(spec, target, host, generated, json) {
893
- const [compiled] = this.#compile(spec, json);
894
- const scoped = {
895
- ...compiled,
1052
+ #emitRepair(audit, generated, json, result) {
1053
+ if (json) {
1054
+ this.#write(result === void 0 ? audit : auditToRepairResult(audit, result));
1055
+ return;
1056
+ }
1057
+ const note = scopeNote(audit.outside, generated);
1058
+ if (note !== void 0) this.#reporter.line(note);
1059
+ }
1060
+ /** Promote only absent service-owned starter seams into repairable missing artifacts. */
1061
+ #promoteServices(plan, target) {
1062
+ if (plan.blueprint.services.length === 0) return plan;
1063
+ const paths = [SERVICE_SCRIPT_PATH, "tests/config/services.test.ts"];
1064
+ const current = readTarget(target, paths);
1065
+ return {
1066
+ ...plan,
1067
+ artifacts: plan.artifacts.map((artifact) => artifact.origin === "template" && paths.includes(artifact.path) && !Object.hasOwn(current, artifact.path) ? {
1068
+ ...artifact,
1069
+ origin: "computed"
1070
+ } : artifact)
1071
+ };
1072
+ }
1073
+ /** Select repair ownership and merge only the manifest's generated service-script keys. */
1074
+ #scopeRepair(compiled, target, generated) {
1075
+ const promoted = this.#promoteServices(compiled, target);
1076
+ const currentManifest = generated && promoted.blueprint.services.length > 0 ? readManifest(target) : void 0;
1077
+ const artifacts = [];
1078
+ for (const artifact of promoted.artifacts) {
1079
+ if (artifact.origin === "host" || artifact.path === SERVICE_SCRIPT_PATH || artifact.path === "tests/config/services.test.ts" || generated && artifact.origin === "computed" && artifact.path !== "package.json") {
1080
+ artifacts.push(artifact);
1081
+ continue;
1082
+ }
1083
+ if (currentManifest !== void 0 && artifact.origin === "computed" && artifact.path === "package.json") {
1084
+ const content = mergeServiceManifest(currentManifest, artifact.content, promoted.blueprint.services);
1085
+ if (content !== currentManifest) artifacts.push({
1086
+ ...artifact,
1087
+ content
1088
+ });
1089
+ }
1090
+ }
1091
+ return {
1092
+ ...promoted,
896
1093
  blueprint: {
897
- ...compiled.blueprint,
1094
+ ...promoted.blueprint,
898
1095
  overrides: []
899
1096
  },
900
- artifacts: compiled.artifacts.filter((artifact) => artifact.origin === "host" || generated && artifact.origin === "computed" && artifact.path !== "package.json")
1097
+ artifacts
901
1098
  };
1099
+ }
1100
+ /** Compile and audit the selected repair ownership boundary for one structural snapshot. */
1101
+ #prepareRepair(spec, target, host, generated, json) {
1102
+ const [compiled] = this.#compile(spec, json);
1103
+ const scoped = this.#scopeRepair(compiled, target, generated);
902
1104
  let plan;
903
1105
  try {
904
1106
  plan = hydratePlan(scoped, host);
@@ -930,14 +1132,12 @@ var CLI = class {
930
1132
  }
931
1133
  /**
932
1134
  * The shared write-confirmation gate every verb calls before it touches disk.
933
- * `--apply` writes without asking; `--json` (without `--apply`) is a pure
934
- * dry-run and NEVER prompts; `--yes` auto-answers yes; otherwise a real
935
- * confirm with `default: false` (EOF on stdin resolves to the default).
1135
+ * `--apply` is the sole write authorization. JSON and non-terminal calls do
1136
+ * not prompt once authorized; `--yes` skips the terminal confirmation.
936
1137
  */
937
1138
  async #apply(terminal, message, values, json) {
938
- if (values.apply) return true;
939
- if (json) return false;
940
- if (values.yes) return true;
1139
+ if (!values.apply) return false;
1140
+ if (json || values.yes || !this.#tty) return true;
941
1141
  return this.#guard(terminal.confirm({
942
1142
  message,
943
1143
  default: false
@@ -945,18 +1145,16 @@ var CLI = class {
945
1145
  }
946
1146
  /**
947
1147
  * The SECOND, separate confirm for `--prune`-eligible deletions — never
948
- * bundled into `resolveApply`'s question. `--yes` only auto-answers this
949
- * when `--prune` was also passed (it never enables pruning by itself).
1148
+ * bundled into the write question. Both `--prune` and `--apply` are required;
1149
+ * `--yes` only skips this confirmation and never authorizes deletion.
950
1150
  */
951
1151
  async #prune(terminal, message, values, json) {
952
1152
  if (!values.prune) return false;
953
- if (values.apply) return true;
954
- if (json) return false;
955
- if (values.yes) return true;
956
- if (!this.#tty) {
957
- this.#reporter.line(PRUNE_SKIPPED);
1153
+ if (!values.apply) {
1154
+ if (!json && !this.#tty) this.#reporter.line(PRUNE_SKIPPED);
958
1155
  return false;
959
1156
  }
1157
+ if (json || values.yes || !this.#tty) return true;
960
1158
  return this.#guard(terminal.confirm({
961
1159
  message,
962
1160
  default: false
@@ -985,13 +1183,13 @@ var CLI = class {
985
1183
  * Best-effort vendored `@orkestrel` catalog names, resolved via `host`
986
1184
  * (`hostRoot()`, or the active `--from` override) through the host manifest
987
1185
  * — `undefined` when the catalog cannot be established (a missing/unreadable
988
- * manifest, no `.claude/agents/orkestrel.md` entry, or any other failure),
1186
+ * manifest, no `CATALOG_AGENT_PATH` entry, or any other failure),
989
1187
  * degrading the dependency prompt to shape-only validation instead of blocking on it.
990
1188
  */
991
1189
  #names(host) {
992
1190
  const names = attempt(() => {
993
1191
  const manifest = readHostManifest(host);
994
- const full = locateHostSource(manifest, ".claude/agents/orkestrel.md", host);
1192
+ const full = locateHostSource(manifest, CATALOG_AGENT_PATH, host);
995
1193
  if (full === void 0 || !existsSync(full)) return void 0;
996
1194
  const relative$1 = relative(host, full).replaceAll("\\", "/");
997
1195
  return catalogNames(readFileText(host, relative$1, "TARGET", "host"));
@@ -1088,10 +1286,11 @@ var CLI = class {
1088
1286
  dependencies: deps
1089
1287
  }), json);
1090
1288
  const summary = planToSummary(plan);
1289
+ const label = describeDestination(process.cwd(), destination);
1091
1290
  if (!json) {
1092
1291
  this.#reporter.section("Plan");
1093
1292
  this.#reporter.table(newPlanTable(summary));
1094
- this.#reporter.line(newPlanPreview(name));
1293
+ this.#reporter.line(newPlanPreview(label));
1095
1294
  }
1096
1295
  if (!await this.#apply(terminal, applyConfirmMessage(summary.host + summary.template + summary.computed), values, json)) {
1097
1296
  if (json) this.#write(summaryToNewResult(summary, false));
@@ -1107,7 +1306,7 @@ var CLI = class {
1107
1306
  const result = materializer.materialize(plan, destination);
1108
1307
  const count = result.written.length + result.copied.length;
1109
1308
  if (json) this.#write(summaryToNewResult(summary, true));
1110
- else this.#succeed(spinner, json, newApplySuccess(count, name));
1309
+ else this.#succeed(spinner, json, newApplySuccess(count, label));
1111
1310
  } catch (error) {
1112
1311
  this.#reject(spinner, json, error);
1113
1312
  } finally {
@@ -1175,7 +1374,7 @@ var CLI = class {
1175
1374
  this.#reject(spinner, json, error);
1176
1375
  }
1177
1376
  } else if (json) this.#write(report);
1178
- process.exitCode = report.clean ? 0 : proceed ? 0 : 1;
1377
+ process.exitCode = report.failed > 0 ? 1 : report.clean || proceed ? 0 : 1;
1179
1378
  }
1180
1379
  /** `scaffold audit` — whole-plan conformance report; offers a repair handoff on drift. */
1181
1380
  async #audit(values, json) {
@@ -1202,7 +1401,7 @@ var CLI = class {
1202
1401
  const host = values.from?.[0] ?? hostRoot();
1203
1402
  let plan;
1204
1403
  try {
1205
- plan = hydratePlan(compiled, host);
1404
+ plan = hydratePlan(this.#promoteServices(compiled, target), host);
1206
1405
  } catch (error) {
1207
1406
  this.#error(error, json);
1208
1407
  }
@@ -1211,7 +1410,7 @@ var CLI = class {
1211
1410
  ...diffPlan(plan, readTarget(target, artifactPaths)),
1212
1411
  questions
1213
1412
  };
1214
- const scanned = this.#scanSafe(rawAudit, target, host, spec.service);
1413
+ const scanned = this.#scanSafe(rawAudit, target, host, spec.services);
1215
1414
  const audit = scanned.audit;
1216
1415
  let drifted = !audit.clean;
1217
1416
  let live;
@@ -1245,19 +1444,22 @@ var CLI = class {
1245
1444
  }
1246
1445
  if (scanned.skipped) this.#reporter.line(SCAN_SKIPPED);
1247
1446
  for (const question of audit.questions) if (!question.blocking) this.#reporter.line(`warning: ${question.text}`);
1248
- this.#reporter.line(comparisonLine(true));
1447
+ const hostArtifacts = plan.artifacts.filter((artifact) => artifact.origin === "host");
1448
+ const presenceOwned = hostArtifacts.filter((artifact) => artifact.hex === void 0).length;
1449
+ this.#reporter.line(comparisonLine(hostArtifacts.length - presenceOwned, presenceOwned));
1249
1450
  this.#reporter.table(auditTable(audit, plan));
1250
1451
  this.#reporter.line(auditVerdict(audit, plan));
1251
1452
  if (live !== void 0) this.#reporter.line(auditLiveNote(live.current, live.behind, live.failed));
1252
1453
  if (!audit.clean) {
1253
1454
  const split = partitionFindings(audit.findings, plan);
1254
- const ownedCount = split.owned.drifted + split.owned.missing;
1455
+ const replace = values.replace === true;
1456
+ const ownedCount = split.owned.missing + (replace ? split.owned.drifted : 0);
1255
1457
  const pruneRequested = values.prune === true;
1256
1458
  const offerHandoff = this.#tty && (ownedCount > 0 || audit.foreign > 0 && pruneRequested);
1257
1459
  let handoffAccepted = false;
1258
1460
  if (offerHandoff) {
1259
1461
  const terminal = createTerminal();
1260
- const message = repairHandoff(ownedCount, audit.foreign, pruneRequested);
1462
+ const message = repairHandoff(split.owned.missing, replace ? split.owned.drifted : 0, audit.foreign, pruneRequested);
1261
1463
  handoffAccepted = await this.#guard(terminal.confirm({
1262
1464
  message,
1263
1465
  default: false
@@ -1265,12 +1467,13 @@ var CLI = class {
1265
1467
  if (handoffAccepted) {
1266
1468
  await this.#repair(values, false);
1267
1469
  const rawFinal = diffPlan(plan, readTarget(target, artifactPaths));
1268
- const finalScanned = this.#scanSafe(rawFinal, target, host, spec.service);
1470
+ const finalScanned = this.#scanSafe(rawFinal, target, host, spec.services);
1269
1471
  process.exitCode = finalScanned.audit.clean ? 0 : 1;
1270
1472
  return;
1271
1473
  }
1272
1474
  }
1273
1475
  if (!handoffAccepted) {
1476
+ if (split.owned.drifted > 0 && !replace) this.#reporter.line(replacementNote(split.owned.drifted));
1274
1477
  if (audit.foreign > 0 && !pruneRequested) this.#reporter.line(FOREIGN_HINT);
1275
1478
  for (const note of renderComputedNotes(audit.findings, plan)) this.#reporter.line(note);
1276
1479
  }
@@ -1287,34 +1490,49 @@ var CLI = class {
1287
1490
  this.#error(error, json);
1288
1491
  }
1289
1492
  const generated = values.generated === true;
1493
+ const replace = values.replace === true;
1290
1494
  const host = values.from?.[0] ?? hostRoot();
1291
1495
  const prepared = this.#prepareRepair(spec, target, host, generated, json);
1292
- const compiled = prepared[0];
1496
+ let compiled = prepared[0];
1293
1497
  let plan = prepared[1];
1294
1498
  let audit = prepared[2];
1499
+ let outcome = repairAuditOf(this.#scanSafe(audit, target, host, spec.services).audit, this.#outside(compiled, plan, target, host));
1500
+ const verdict = {
1501
+ generated,
1502
+ replace,
1503
+ apply: values.apply === true
1504
+ };
1295
1505
  if (!json) {
1296
- this.#reporter.line(generated ? REPAIR_GENERATED_SCOPE : REPAIR_SCOPE);
1506
+ if (!outcome.clean) this.#reporter.line(scopeLine("repair", generated));
1297
1507
  this.#reporter.section("Audit");
1298
- this.#reporter.table(auditTable(audit, plan));
1508
+ this.#reporter.table(auditTable(outcome, plan));
1299
1509
  }
1300
- const prunePaths = values.prune ? this.#prunePaths(target, host, spec.service) : [];
1510
+ const prunePaths = values.prune ? this.#prunePaths(target, host, spec.services) : [];
1301
1511
  const pruneSnapshot = readTarget(target, prunePaths);
1302
- if (audit.clean && prunePaths.length === 0) {
1303
- if (json) this.#write(audit);
1304
- else {
1305
- this.#reporter.line(repairVerdict(audit, generated));
1306
- const note = scopeNote(this.#outside(compiled, target), generated);
1307
- if (note !== void 0) this.#reporter.line(note);
1308
- }
1309
- process.exitCode = 0;
1512
+ if (outcome.clean && prunePaths.length === 0) {
1513
+ if (!json) this.#reporter.line(repairVerdict(outcome, verdict));
1514
+ this.#emitRepair(outcome, generated, json);
1515
+ process.exitCode = hasFindings(outcome, outcome.outside) ? 1 : 0;
1516
+ return;
1517
+ }
1518
+ if (!json) this.#reporter.line(repairVerdict(outcome, verdict));
1519
+ const repairable = countWrites([audit], replace);
1520
+ if (repairable === 0 && prunePaths.length === 0) {
1521
+ if (!json) this.#reporter.line(repairTally({
1522
+ written: 0,
1523
+ unchanged: audit.findings.length - audit.drifted - audit.missing - audit.foreign,
1524
+ drifted: audit.drifted,
1525
+ removed: 0
1526
+ }));
1527
+ this.#emitRepair(outcome, generated, json);
1528
+ process.exitCode = 1;
1310
1529
  return;
1311
1530
  }
1312
- if (!json) this.#reporter.line(repairVerdict(audit, generated));
1313
1531
  const terminal = createTerminal();
1314
1532
  let proceed = true;
1315
- if (!audit.clean) proceed = await this.#apply(terminal, applyConfirmMessage(audit.drifted + audit.missing + audit.foreign), values, json);
1533
+ if (!audit.clean) proceed = await this.#apply(terminal, applyConfirmMessage(repairable), values, json);
1316
1534
  if (!proceed) {
1317
- if (json) this.#write(audit);
1535
+ this.#emitRepair(outcome, generated, json);
1318
1536
  process.exitCode = 1;
1319
1537
  return;
1320
1538
  }
@@ -1335,31 +1553,41 @@ var CLI = class {
1335
1553
  global: currentGlobal
1336
1554
  };
1337
1555
  const refreshed = this.#prepareRepair(spec, target, host, generated, json);
1556
+ compiled = refreshed[0];
1338
1557
  plan = refreshed[1];
1339
1558
  audit = refreshed[2];
1559
+ outcome = repairAuditOf(this.#scanSafe(audit, target, host, spec.services).audit, this.#outside(compiled, plan, target, host));
1340
1560
  }
1341
1561
  const spinner = this.#spinner("repairing", json);
1342
1562
  spinner?.start();
1343
1563
  const materializer = createMaterializer({ host });
1344
1564
  try {
1345
- const result = materializer.repair(plan, audit, target);
1565
+ const result = materializer.repair(plan, audit, target, replace);
1346
1566
  const removed = doPrune ? materializer.prune(target, pruneSnapshot).removed : [];
1347
- if (json) this.#write(auditToRepairResult(audit, {
1567
+ const finalAudit = repairAuditOf(this.#scanSafe(diffPlan(plan, readTarget(target, plan.artifacts.map((artifact) => artifact.path))), target, host, spec.services).audit, outcome.outside);
1568
+ const left = replace ? 0 : audit.drifted;
1569
+ if (!json) this.#succeed(spinner, json, repairTally({
1570
+ written: result.written.length + result.copied.length,
1571
+ unchanged: result.skipped.length - left,
1572
+ drifted: left,
1573
+ removed: removed.length
1574
+ }));
1575
+ this.#emitRepair(finalAudit, generated, json, {
1348
1576
  ...result,
1349
1577
  removed
1350
- }));
1351
- else this.#succeed(spinner, json, repairSuccess(result, removed));
1578
+ });
1579
+ process.exitCode = hasFindings(finalAudit, finalAudit.outside) ? 1 : 0;
1352
1580
  } catch (error) {
1353
1581
  this.#reject(spinner, json, error);
1354
1582
  } finally {
1355
1583
  materializer.destroy();
1356
1584
  }
1357
- process.exitCode = 0;
1358
1585
  }
1359
1586
  /** `scaffold fleet` — audit/repair every `@orkestrel` package beneath the current directory's immediate children. */
1360
1587
  async #fleet(values, json) {
1361
1588
  const root = this.#contain(".", json);
1362
1589
  const generated = values.generated === true;
1590
+ const replace = values.replace === true;
1363
1591
  const packages = discoverPackages(root);
1364
1592
  if (packages.length === 0) this.#fail(`no @orkestrel packages under "${root}" — fleet scans the immediate children of the current directory; stand in the folder that contains your checkouts (cd ..), or use 'repair' to true up just this repo.`, json);
1365
1593
  const host = values.from?.[0] ?? hostRoot();
@@ -1369,6 +1597,7 @@ var CLI = class {
1369
1597
  const name = basename(directory);
1370
1598
  try {
1371
1599
  const compiler = createCompiler();
1600
+ let compiled;
1372
1601
  let scoped;
1373
1602
  let questions;
1374
1603
  try {
@@ -1378,14 +1607,8 @@ var CLI = class {
1378
1607
  const message = scaffolding.questions.map((question) => question.text).join("; ");
1379
1608
  throw new ScaffoldError("INVALID", message);
1380
1609
  }
1381
- scoped = {
1382
- ...scaffolding.plan,
1383
- blueprint: {
1384
- ...scaffolding.plan.blueprint,
1385
- overrides: []
1386
- },
1387
- artifacts: scaffolding.plan.artifacts.filter((artifact) => artifact.origin === "host" || generated && artifact.origin === "computed" && artifact.path !== "package.json")
1388
- };
1610
+ compiled = scaffolding.plan;
1611
+ scoped = this.#scopeRepair(compiled, directory, generated);
1389
1612
  questions = scaffolding.questions;
1390
1613
  } finally {
1391
1614
  compiler.destroy();
@@ -1396,12 +1619,15 @@ var CLI = class {
1396
1619
  ...diffPlan(plan, readTarget(directory, paths)),
1397
1620
  questions
1398
1621
  };
1399
- const audit = this.#scan(rawAudit, directory, host, plan.blueprint.service);
1622
+ const audit = this.#scan(rawAudit, directory, host, plan.blueprint.services);
1623
+ const outside = this.#outside(compiled, plan, directory, host);
1400
1624
  repos.push({
1401
1625
  name,
1402
1626
  directory,
1403
1627
  plan,
1404
- audit
1628
+ rawAudit,
1629
+ audit,
1630
+ outside
1405
1631
  });
1406
1632
  } catch (error) {
1407
1633
  failures.push({
@@ -1419,6 +1645,8 @@ var CLI = class {
1419
1645
  foreign: repo.audit.foreign
1420
1646
  }));
1421
1647
  for (const question of repo.audit.questions) if (!question.blocking) this.#reporter.line(`${repo.name}: warning: ${question.text}`);
1648
+ const note = scopeNote(repo.outside, generated);
1649
+ if (note !== void 0) this.#reporter.line(`${repo.name}: ${note}`);
1422
1650
  }
1423
1651
  for (const failure of failures) this.#reporter.line(fleetRepoLine(failure.name, {
1424
1652
  state: "failed",
@@ -1426,17 +1654,19 @@ var CLI = class {
1426
1654
  }));
1427
1655
  }
1428
1656
  const dirty = repos.filter((repo) => !repo.audit.clean);
1657
+ const reportedDirty = repos.filter((repo) => hasFindings(repo.audit, repo.outside));
1429
1658
  if (dirty.length === 0) {
1430
- if (json) this.#write([...repos.map((repo) => fleetEntryOf(repo.name, repo.audit, false)), ...failures.map((failure) => fleetEntryOf(failure.name, void 0, true))]);
1431
- else this.#reporter.line(fleetTotals(0, failures.length));
1432
- process.exitCode = failures.length > 0 ? 1 : 0;
1659
+ if (json) this.#write([...repos.map((repo) => fleetEntryOf(repo.name, repo.audit, false, repo.outside)), ...failures.map((failure) => fleetEntryOf(failure.name, void 0, true))]);
1660
+ else this.#reporter.line(fleetTotals(reportedDirty.length, failures.length));
1661
+ process.exitCode = reportedDirty.length > 0 || failures.length > 0 ? 1 : 0;
1433
1662
  return;
1434
1663
  }
1435
- const fileCount = dirty.reduce((total, repo) => total + repo.audit.drifted + repo.audit.missing + repo.audit.foreign, 0);
1664
+ const fileCount = countWrites(dirty.map((repo) => repo.audit), replace);
1665
+ if (!json && values.apply === true) this.#reporter.line(scopeLine("fleet", generated, dirty.length));
1436
1666
  const terminal = createTerminal();
1437
1667
  const proceed = await this.#apply(terminal, applyConfirmMessage(fileCount, dirty.length), values, json);
1438
1668
  const pruneSets = proceed && values.prune ? new Map(dirty.map((repo) => {
1439
- const paths = this.#prunePaths(repo.directory, host, repo.plan.blueprint.service);
1669
+ const paths = this.#prunePaths(repo.directory, host, repo.plan.blueprint.services);
1440
1670
  return [repo.name, readTarget(repo.directory, paths)];
1441
1671
  })) : /* @__PURE__ */ new Map();
1442
1672
  const prunePaths = dirty.flatMap((repo) => Object.keys(pruneSets.get(repo.name) ?? {}).map((path) => `${repo.name}/${path}`));
@@ -1446,27 +1676,27 @@ var CLI = class {
1446
1676
  }
1447
1677
  const doPrune = proceed && prunePaths.length > 0 && await this.#prune(terminal, pruneConfirmMessage(prunePaths.length), values, json);
1448
1678
  if (!proceed) {
1449
- if (json) this.#write([...repos.map((repo) => fleetEntryOf(repo.name, repo.audit, false)), ...failures.map((failure) => fleetEntryOf(failure.name, void 0, true))]);
1450
- else this.#reporter.line(fleetTotals(dirty.length, failures.length));
1679
+ if (json) this.#write([...repos.map((repo) => fleetEntryOf(repo.name, repo.audit, false, repo.outside)), ...failures.map((failure) => fleetEntryOf(failure.name, void 0, true))]);
1680
+ else this.#reporter.line(fleetTotals(reportedDirty.length, failures.length));
1451
1681
  process.exitCode = 1;
1452
1682
  return;
1453
1683
  }
1454
1684
  const materializer = createMaterializer({ host });
1455
- let drifted = 0;
1685
+ let drifted = repos.filter((repo) => repo.audit.clean && repo.outside > 0).length;
1456
1686
  let failedCount = failures.length;
1457
- const entries = repos.filter((repo) => repo.audit.clean).map((repo) => fleetEntryOf(repo.name, repo.audit, false));
1687
+ const entries = repos.filter((repo) => repo.audit.clean).map((repo) => fleetEntryOf(repo.name, repo.audit, false, repo.outside));
1458
1688
  try {
1459
1689
  for (const repo of dirty) try {
1460
- materializer.repair(repo.plan, repo.audit, repo.directory);
1690
+ materializer.repair(repo.plan, repo.rawAudit, repo.directory, replace);
1461
1691
  if (doPrune) materializer.prune(repo.directory, pruneSets.get(repo.name) ?? {});
1462
1692
  const paths = repo.plan.artifacts.map((artifact) => artifact.path);
1463
1693
  const rawFinal = diffPlan(repo.plan, readTarget(repo.directory, paths));
1464
- const finalAudit = this.#scan(rawFinal, repo.directory, host, repo.plan.blueprint.service);
1465
- if (!finalAudit.clean) drifted += 1;
1466
- entries.push(fleetEntryOf(repo.name, finalAudit, false));
1694
+ const finalAudit = this.#scan(rawFinal, repo.directory, host, repo.plan.blueprint.services);
1695
+ if (hasFindings(finalAudit, repo.outside)) drifted += 1;
1696
+ entries.push(fleetEntryOf(repo.name, finalAudit, false, repo.outside));
1467
1697
  if (!json) this.#reporter.line(fleetRepoLine(repo.name, {
1468
1698
  state: "repaired",
1469
- remaining: finalAudit.drifted + finalAudit.missing + finalAudit.foreign
1699
+ remaining: finalAudit.drifted + finalAudit.missing + finalAudit.foreign + repo.outside
1470
1700
  }));
1471
1701
  } catch (error) {
1472
1702
  failedCount += 1;
@@ -1643,6 +1873,7 @@ var CLI = class {
1643
1873
  if (!isVerb(command)) this.#usage(didYouMean(command), json);
1644
1874
  if (command !== "catalog" && values.from !== void 0 && values.from.length > 1) this.#usage(`--from may be provided only once for '${command}'`, json);
1645
1875
  if (command === "mirror" && values.deps !== void 0) this.#usage("--deps is not supported for 'mirror'; use 'pull --deps'", json);
1876
+ if (command === "fleet" && values.target !== void 0) this.#usage("--target is not supported for 'fleet'; change into the fleet root", json);
1646
1877
  if (values.help) {
1647
1878
  process.stdout.write(`${verbHelp(command)}\n`);
1648
1879
  process.exitCode = 0;