@orkestrel/scaffold 0.0.20 → 0.0.22

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 (69) hide show
  1. package/dist/bin/scaffold.js +385 -148
  2. package/dist/bin/scaffold.js.map +1 -1
  3. package/dist/host/AGENTS.md +61 -42
  4. package/dist/host/CLAUDE.md +39 -387
  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 +4 -4
  12. package/dist/host/agents/skills/orkestrel-debrief/references/instruction-audit.md +12 -0
  13. package/dist/host/agents/skills/orkestrel-falsify/SKILL.md +167 -0
  14. package/dist/host/agents/skills/orkestrel-falsify/references/brief.md +98 -0
  15. package/dist/host/agents/skills/orkestrel-falsify/references/reconcile.md +148 -0
  16. package/dist/host/agents/skills/orkestrel-harden-package/SKILL.md +4 -2
  17. package/dist/host/agents/skills/orkestrel-harden-package/references/centralization.md +5 -5
  18. package/dist/host/agents/skills/orkestrel-harden-package/references/research.md +2 -0
  19. package/dist/host/agents/skills/orkestrel-polish-surface/SKILL.md +11 -6
  20. package/dist/host/claude/agents/analyst.md +59 -0
  21. package/dist/host/claude/agents/application.md +1 -1
  22. package/dist/host/claude/agents/builder.md +1 -1
  23. package/dist/host/claude/agents/checker.md +5 -1
  24. package/dist/host/claude/agents/codex.md +120 -87
  25. package/dist/host/claude/agents/grok.md +44 -32
  26. package/dist/host/claude/agents/implementer.md +1 -1
  27. package/dist/host/claude/agents/orkestrel.md +3 -3
  28. package/dist/host/claude/agents/planner.md +9 -3
  29. package/dist/host/claude/agents/researcher.md +6 -5
  30. package/dist/host/claude/agents/reviewer.md +17 -6
  31. package/dist/host/claude/agents/scout.md +7 -4
  32. package/dist/host/claude/agents/verifier.md +1 -1
  33. package/dist/host/claude/rules/application.md +17 -16
  34. package/dist/host/claude/rules/architecture.md +41 -22
  35. package/dist/host/claude/rules/documentation.md +2 -1
  36. package/dist/host/claude/rules/names.md +7 -6
  37. package/dist/host/claude/rules/quality.md +71 -14
  38. package/dist/host/claude/rules/styles.md +1 -1
  39. package/dist/host/claude/rules/tests.md +18 -2
  40. package/dist/host/claude/rules/typescript.md +1 -1
  41. package/dist/host/claude/rules/workspace.md +6 -3
  42. package/dist/host/claude/settings.json +2 -0
  43. package/dist/host/claude/skills/orkestrel-falsify/SKILL.md +10 -0
  44. package/dist/host/codex/agents/analyst.toml +15 -2
  45. package/dist/host/codex/agents/checker.toml +1 -1
  46. package/dist/host/codex/agents/grok.toml +2 -1
  47. package/dist/host/codex/agents/opus.toml +1 -1
  48. package/dist/host/codex/agents/planner.toml +1 -1
  49. package/dist/host/codex/agents/researcher.toml +2 -2
  50. package/dist/host/codex/agents/reviewer.toml +4 -2
  51. package/dist/host/codex/agents/scout.toml +2 -2
  52. package/dist/host/codex/config.toml +23 -64
  53. package/dist/host/cursor/rules/orchestration.mdc +33 -0
  54. package/dist/host/guides/src/scaffold.md +419 -150
  55. package/dist/host/manifest.json +47 -1
  56. package/dist/host/tests/setupPolicy.ts +130 -12
  57. package/dist/src/core/index.cjs +2059 -913
  58. package/dist/src/core/index.cjs.map +1 -1
  59. package/dist/src/core/index.d.cts +202 -47
  60. package/dist/src/core/index.d.ts +202 -47
  61. package/dist/src/core/index.js +2050 -914
  62. package/dist/src/core/index.js.map +1 -1
  63. package/dist/src/server/index.cjs +83 -33
  64. package/dist/src/server/index.cjs.map +1 -1
  65. package/dist/src/server/index.d.cts +32 -16
  66. package/dist/src/server/index.d.ts +32 -16
  67. package/dist/src/server/index.js +84 -35
  68. package/dist/src/server/index.js.map +1 -1
  69. package/package.json +7 -5
@@ -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,47 +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 === "stale") if (owned) ownedDrifted += 1;
230
- else generatedDrifted += 1;
231
- else if (finding.drift === "missing") if (owned) ownedMissing += 1;
232
- else generatedMissing += 1;
233
- else if (owned) ownedForeign += 1;
234
- else generatedForeign += 1;
229
+ if (finding.drift === "foreign") {
230
+ foreign += 1;
231
+ continue;
232
+ }
233
+ if (finding.drift === "stale") {
234
+ if (owned) ownedDrifted += 1;
235
+ else generatedDrifted += 1;
236
+ } else if (finding.drift === "missing") {
237
+ if (owned) ownedMissing += 1;
238
+ else generatedMissing += 1;
239
+ }
235
240
  }
236
241
  return {
237
242
  owned: {
238
243
  drifted: ownedDrifted,
239
244
  missing: ownedMissing,
240
- foreign: ownedForeign
245
+ foreign: 0
241
246
  },
242
247
  generated: {
243
248
  drifted: generatedDrifted,
244
249
  missing: generatedMissing,
245
- foreign: generatedForeign
250
+ foreign: 0
251
+ },
252
+ foreign: {
253
+ drifted: 0,
254
+ missing: 0,
255
+ foreign
246
256
  }
247
257
  };
248
258
  }
249
259
  /** Create one deterministic fleet result entry. */
250
- function fleetEntryOf(name, counts, failed) {
260
+ function fleetEntryOf(name, counts, failed, outside = 0) {
251
261
  return {
252
262
  name,
253
263
  drifted: counts?.drifted ?? 0,
254
264
  missing: counts?.missing ?? 0,
255
265
  foreign: counts?.foreign ?? 0,
256
- failed
266
+ failed,
267
+ outside
257
268
  };
258
269
  }
259
270
  /** Create the command-line interface's machine-readable failure envelope. */
@@ -275,7 +286,14 @@ function summaryToNewResult(summary, applied) {
275
286
  applied
276
287
  };
277
288
  }
278
- /** 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. */
279
297
  function auditToRepairResult(audit, result) {
280
298
  return {
281
299
  ...audit,
@@ -302,9 +320,9 @@ function countPart(count, label) {
302
320
  /** Render nonzero audit buckets or `clean`. */
303
321
  function bucketText(counts) {
304
322
  const parts = [];
305
- if (counts.drifted > 0) parts.push(countPart(counts.drifted, "drifted"));
306
- if (counts.missing > 0) parts.push(countPart(counts.missing, "missing"));
307
- 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`);
308
326
  return parts.length > 0 ? parts.join(", ") : "clean";
309
327
  }
310
328
  /** Render audit's origin-aware verdict. */
@@ -312,7 +330,12 @@ function auditVerdict(audit, plan) {
312
330
  const count = audit.findings.length;
313
331
  if (audit.clean) return `audit: ${countPart(count, "artifact")} — clean`;
314
332
  const split = partitionFindings(audit.findings, plan);
315
- 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}`;
316
339
  }
317
340
  /** Render non-aligned audit findings as terminal table rows. */
318
341
  function findingRows(findings, plan) {
@@ -339,6 +362,43 @@ function auditTable(audit, plan) {
339
362
  };
340
363
  }
341
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
+ /**
342
402
  * Render drift outside repair's selected ownership boundary.
343
403
  *
344
404
  * @param count - The number of findings outside the selected scope.
@@ -347,24 +407,84 @@ function auditTable(audit, plan) {
347
407
  */
348
408
  function scopeNote(count, generated) {
349
409
  if (count === 0) return void 0;
350
- 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`;
351
411
  }
352
412
  /**
353
413
  * Render repair's dry-run verdict.
354
414
  *
355
415
  * @param audit - The audit over the selected repair plan.
356
- * @param generated - Whether generated canon was included in the repair scope.
416
+ * @param options - The selected scope and write authorizations.
357
417
  * @returns The scope-aware clean or drifted verdict.
358
418
  */
359
- function repairVerdict(audit, generated) {
360
- 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";
361
421
  if (audit.clean) return `repair: ${countPart(audit.findings.length, `${scope} artifact`)} aligned — nothing to write`;
362
- 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`;
363
426
  }
364
- /** Render repair's materialization tally. */
365
- function repairSuccess(result, removed) {
366
- const written = result.written.length + result.copied.length;
367
- 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}`;
368
488
  }
369
489
  /** Render synchronization freshness as table rows. */
370
490
  function syncRows(report) {
@@ -413,7 +533,7 @@ function fleetRepoLine(name, outcome) {
413
533
  }
414
534
  /** Render fleet's repository totals. */
415
535
  function fleetTotals(drifted, failed) {
416
- return `total: ${countPart(drifted, "drifted repo")}, ${countPart(failed, "failed")}`;
536
+ return `total: ${countPart(drifted, "dirty repo")}, ${failed} failed`;
417
537
  }
418
538
  /** Create catalog's terminal table. */
419
539
  function catalogTable(entries) {
@@ -445,13 +565,25 @@ function newPlanTable(summary) {
445
565
  ]
446
566
  };
447
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
+ }
448
580
  /** Render `new`'s dry-run destination. */
449
- function newPlanPreview(name) {
450
- return `will write into ./${name}`;
581
+ function newPlanPreview(destination) {
582
+ return `will write into ${destination}`;
451
583
  }
452
584
  /** Render `new`'s write result. */
453
- function newApplySuccess(count, name) {
454
- return `wrote ${countPart(count, "file")} into ./${name}`;
585
+ function newApplySuccess(count, destination) {
586
+ return `wrote ${countPart(count, "file")} into ${destination}`;
455
587
  }
456
588
  /** Render catalog's write result. */
457
589
  function catalogApplySuccess(path) {
@@ -466,12 +598,21 @@ function applyConfirmMessage(files, repos) {
466
598
  function pruneConfirmMessage(count) {
467
599
  return `Also delete ${countPart(count, "unexpected file")} under .claude/agents, .codex/agents, and scripts? `;
468
600
  }
469
- /** Render the interactive audit-to-repair handoff. */
470
- 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) {
471
611
  const parts = [];
472
- if (owned > 0) parts.push(`${countPart(owned, "host-owned file")} ${owned === 1 ? "has" : "have"} drift`);
473
- if (prune && foreign > 0) parts.push(`${countPart(foreign, "unexpected file")} will be deleted`);
474
- 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? `;
475
616
  }
476
617
  /** Render one unresolved Orkestrel dependency token. */
477
618
  function unknownOrkestrelToken(token, suggestion) {
@@ -565,9 +706,23 @@ function invalidName(name, pattern) {
565
706
  function unresolvedVersion(names) {
566
707
  return `could not resolve the latest version for ${names.map((name) => `"${name}"`).join(", ")} — check the name or pass name@range`;
567
708
  }
568
- /** 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
+ */
569
715
  function generatedNote(count) {
570
- 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`;
571
726
  }
572
727
  /**
573
728
  * Render honest repair guidance for computed and protected manifest drift.
@@ -587,16 +742,19 @@ function renderComputedNotes(findings, plan) {
587
742
  }
588
743
  const notes = [];
589
744
  if (computed > 0) notes.push(generatedNote(computed));
590
- 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`);
591
747
  return notes;
592
748
  }
593
749
  /** Render live dependency freshness tallies. */
594
750
  function auditLiveNote(current, behind, failed) {
595
- return `live: ${countPart(current, "current")}, ${countPart(behind, "behind")}, ${countPart(failed, "failed")}`;
751
+ return `live: ${current} current, ${behind} behind, ${failed} failed`;
596
752
  }
597
753
  /** Render whether an audit compared content or presence. */
598
- function comparisonLine(aware) {
599
- 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")}`;
600
758
  }
601
759
  /** Render catalog's final verdict. */
602
760
  function catalogVerdict(clean) {
@@ -660,6 +818,10 @@ function parseArguments(argv) {
660
818
  type: "boolean",
661
819
  default: false
662
820
  },
821
+ replace: {
822
+ type: "boolean",
823
+ default: false
824
+ },
663
825
  strict: {
664
826
  type: "boolean",
665
827
  default: false
@@ -822,8 +984,8 @@ var CLI = class {
822
984
  compiler.destroy();
823
985
  }
824
986
  }
825
- #prunePaths(target, host, service) {
826
- const seam = service ? SERVICE_SCRIPT_PATH : void 0;
987
+ #prunePaths(target, host, services) {
988
+ const seam = services.length > 0 ? SERVICE_SCRIPT_PATH : void 0;
827
989
  return pruneTargets(target, host).filter((path) => path !== seam);
828
990
  }
829
991
  /**
@@ -836,8 +998,8 @@ var CLI = class {
836
998
  * unclean, so an "unexpected file" is honestly counted as drift (exit 1)
837
999
  * instead of the structurally-always-zero `diffPlan.foreign`.
838
1000
  */
839
- #scan(audit, target, host, service) {
840
- const paths = this.#prunePaths(target, host, service);
1001
+ #scan(audit, target, host, services) {
1002
+ const paths = this.#prunePaths(target, host, services);
841
1003
  if (paths.length === 0) return audit;
842
1004
  const findings = paths.map((path) => ({
843
1005
  path,
@@ -856,8 +1018,8 @@ var CLI = class {
856
1018
  * When fail-closed allowlist discovery raises a coded `TARGET` failure, retain
857
1019
  * the existing findings and mark the audit incomplete instead of crashing.
858
1020
  */
859
- #scanSafe(audit, target, host, service) {
860
- 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));
861
1023
  if (scanned.success) return {
862
1024
  audit: scanned.value,
863
1025
  skipped: false
@@ -882,21 +1044,63 @@ var CLI = class {
882
1044
  * caller compiles the full plan anyway. Diff it too so a clean scoped verdict can point at drift
883
1045
  * outside the selected boundary. The count feeds the shared `scopeNote` renderer.
884
1046
  */
885
- #outside(compiled, target) {
886
- const full = diffPlan(compiled, readTarget(target, compiled.artifacts.map((artifact) => artifact.path)));
887
- 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;
888
1051
  }
889
- /** Compile and audit the selected repair ownership boundary for one structural snapshot. */
890
- #prepareRepair(spec, target, host, generated, json) {
891
- const [compiled] = this.#compile(spec, json);
892
- const scoped = {
893
- ...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,
894
1093
  blueprint: {
895
- ...compiled.blueprint,
1094
+ ...promoted.blueprint,
896
1095
  overrides: []
897
1096
  },
898
- artifacts: compiled.artifacts.filter((artifact) => artifact.origin === "host" || generated && artifact.origin === "computed" && artifact.path !== "package.json")
1097
+ artifacts
899
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);
900
1104
  let plan;
901
1105
  try {
902
1106
  plan = hydratePlan(scoped, host);
@@ -928,14 +1132,12 @@ var CLI = class {
928
1132
  }
929
1133
  /**
930
1134
  * The shared write-confirmation gate every verb calls before it touches disk.
931
- * `--apply` writes without asking; `--json` (without `--apply`) is a pure
932
- * dry-run and NEVER prompts; `--yes` auto-answers yes; otherwise a real
933
- * 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.
934
1137
  */
935
1138
  async #apply(terminal, message, values, json) {
936
- if (values.apply) return true;
937
- if (json) return false;
938
- if (values.yes) return true;
1139
+ if (!values.apply) return false;
1140
+ if (json || values.yes || !this.#tty) return true;
939
1141
  return this.#guard(terminal.confirm({
940
1142
  message,
941
1143
  default: false
@@ -943,18 +1145,16 @@ var CLI = class {
943
1145
  }
944
1146
  /**
945
1147
  * The SECOND, separate confirm for `--prune`-eligible deletions — never
946
- * bundled into `resolveApply`'s question. `--yes` only auto-answers this
947
- * 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.
948
1150
  */
949
1151
  async #prune(terminal, message, values, json) {
950
1152
  if (!values.prune) return false;
951
- if (values.apply) return true;
952
- if (json) return false;
953
- if (values.yes) return true;
954
- if (!this.#tty) {
955
- this.#reporter.line(PRUNE_SKIPPED);
1153
+ if (!values.apply) {
1154
+ if (!json && !this.#tty) this.#reporter.line(PRUNE_SKIPPED);
956
1155
  return false;
957
1156
  }
1157
+ if (json || values.yes || !this.#tty) return true;
958
1158
  return this.#guard(terminal.confirm({
959
1159
  message,
960
1160
  default: false
@@ -983,13 +1183,13 @@ var CLI = class {
983
1183
  * Best-effort vendored `@orkestrel` catalog names, resolved via `host`
984
1184
  * (`hostRoot()`, or the active `--from` override) through the host manifest
985
1185
  * — `undefined` when the catalog cannot be established (a missing/unreadable
986
- * manifest, no `.claude/agents/orkestrel.md` entry, or any other failure),
1186
+ * manifest, no `CATALOG_AGENT_PATH` entry, or any other failure),
987
1187
  * degrading the dependency prompt to shape-only validation instead of blocking on it.
988
1188
  */
989
1189
  #names(host) {
990
1190
  const names = attempt(() => {
991
1191
  const manifest = readHostManifest(host);
992
- const full = locateHostSource(manifest, ".claude/agents/orkestrel.md", host);
1192
+ const full = locateHostSource(manifest, CATALOG_AGENT_PATH, host);
993
1193
  if (full === void 0 || !existsSync(full)) return void 0;
994
1194
  const relative$1 = relative(host, full).replaceAll("\\", "/");
995
1195
  return catalogNames(readFileText(host, relative$1, "TARGET", "host"));
@@ -1086,10 +1286,11 @@ var CLI = class {
1086
1286
  dependencies: deps
1087
1287
  }), json);
1088
1288
  const summary = planToSummary(plan);
1289
+ const label = describeDestination(process.cwd(), destination);
1089
1290
  if (!json) {
1090
1291
  this.#reporter.section("Plan");
1091
1292
  this.#reporter.table(newPlanTable(summary));
1092
- this.#reporter.line(newPlanPreview(name));
1293
+ this.#reporter.line(newPlanPreview(label));
1093
1294
  }
1094
1295
  if (!await this.#apply(terminal, applyConfirmMessage(summary.host + summary.template + summary.computed), values, json)) {
1095
1296
  if (json) this.#write(summaryToNewResult(summary, false));
@@ -1105,7 +1306,7 @@ var CLI = class {
1105
1306
  const result = materializer.materialize(plan, destination);
1106
1307
  const count = result.written.length + result.copied.length;
1107
1308
  if (json) this.#write(summaryToNewResult(summary, true));
1108
- else this.#succeed(spinner, json, newApplySuccess(count, name));
1309
+ else this.#succeed(spinner, json, newApplySuccess(count, label));
1109
1310
  } catch (error) {
1110
1311
  this.#reject(spinner, json, error);
1111
1312
  } finally {
@@ -1173,7 +1374,7 @@ var CLI = class {
1173
1374
  this.#reject(spinner, json, error);
1174
1375
  }
1175
1376
  } else if (json) this.#write(report);
1176
- process.exitCode = report.clean ? 0 : proceed ? 0 : 1;
1377
+ process.exitCode = report.failed > 0 ? 1 : report.clean || proceed ? 0 : 1;
1177
1378
  }
1178
1379
  /** `scaffold audit` — whole-plan conformance report; offers a repair handoff on drift. */
1179
1380
  async #audit(values, json) {
@@ -1200,7 +1401,7 @@ var CLI = class {
1200
1401
  const host = values.from?.[0] ?? hostRoot();
1201
1402
  let plan;
1202
1403
  try {
1203
- plan = hydratePlan(compiled, host);
1404
+ plan = hydratePlan(this.#promoteServices(compiled, target), host);
1204
1405
  } catch (error) {
1205
1406
  this.#error(error, json);
1206
1407
  }
@@ -1209,7 +1410,7 @@ var CLI = class {
1209
1410
  ...diffPlan(plan, readTarget(target, artifactPaths)),
1210
1411
  questions
1211
1412
  };
1212
- const scanned = this.#scanSafe(rawAudit, target, host, spec.service);
1413
+ const scanned = this.#scanSafe(rawAudit, target, host, spec.services);
1213
1414
  const audit = scanned.audit;
1214
1415
  let drifted = !audit.clean;
1215
1416
  let live;
@@ -1243,19 +1444,22 @@ var CLI = class {
1243
1444
  }
1244
1445
  if (scanned.skipped) this.#reporter.line(SCAN_SKIPPED);
1245
1446
  for (const question of audit.questions) if (!question.blocking) this.#reporter.line(`warning: ${question.text}`);
1246
- 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));
1247
1450
  this.#reporter.table(auditTable(audit, plan));
1248
1451
  this.#reporter.line(auditVerdict(audit, plan));
1249
1452
  if (live !== void 0) this.#reporter.line(auditLiveNote(live.current, live.behind, live.failed));
1250
1453
  if (!audit.clean) {
1251
1454
  const split = partitionFindings(audit.findings, plan);
1252
- 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);
1253
1457
  const pruneRequested = values.prune === true;
1254
1458
  const offerHandoff = this.#tty && (ownedCount > 0 || audit.foreign > 0 && pruneRequested);
1255
1459
  let handoffAccepted = false;
1256
1460
  if (offerHandoff) {
1257
1461
  const terminal = createTerminal();
1258
- const message = repairHandoff(ownedCount, audit.foreign, pruneRequested);
1462
+ const message = repairHandoff(split.owned.missing, replace ? split.owned.drifted : 0, audit.foreign, pruneRequested);
1259
1463
  handoffAccepted = await this.#guard(terminal.confirm({
1260
1464
  message,
1261
1465
  default: false
@@ -1263,12 +1467,13 @@ var CLI = class {
1263
1467
  if (handoffAccepted) {
1264
1468
  await this.#repair(values, false);
1265
1469
  const rawFinal = diffPlan(plan, readTarget(target, artifactPaths));
1266
- const finalScanned = this.#scanSafe(rawFinal, target, host, spec.service);
1470
+ const finalScanned = this.#scanSafe(rawFinal, target, host, spec.services);
1267
1471
  process.exitCode = finalScanned.audit.clean ? 0 : 1;
1268
1472
  return;
1269
1473
  }
1270
1474
  }
1271
1475
  if (!handoffAccepted) {
1476
+ if (split.owned.drifted > 0 && !replace) this.#reporter.line(replacementNote(split.owned.drifted));
1272
1477
  if (audit.foreign > 0 && !pruneRequested) this.#reporter.line(FOREIGN_HINT);
1273
1478
  for (const note of renderComputedNotes(audit.findings, plan)) this.#reporter.line(note);
1274
1479
  }
@@ -1285,39 +1490,56 @@ var CLI = class {
1285
1490
  this.#error(error, json);
1286
1491
  }
1287
1492
  const generated = values.generated === true;
1493
+ const replace = values.replace === true;
1288
1494
  const host = values.from?.[0] ?? hostRoot();
1289
1495
  const prepared = this.#prepareRepair(spec, target, host, generated, json);
1290
- const compiled = prepared[0];
1496
+ let compiled = prepared[0];
1291
1497
  let plan = prepared[1];
1292
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
+ };
1293
1505
  if (!json) {
1294
- this.#reporter.line(generated ? REPAIR_GENERATED_SCOPE : REPAIR_SCOPE);
1506
+ if (!outcome.clean) this.#reporter.line(scopeLine("repair", generated));
1295
1507
  this.#reporter.section("Audit");
1296
- this.#reporter.table(auditTable(audit, plan));
1508
+ this.#reporter.table(auditTable(outcome, plan));
1297
1509
  }
1298
- const prunePaths = values.prune ? this.#prunePaths(target, host, spec.service) : [];
1510
+ const prunePaths = values.prune ? this.#prunePaths(target, host, spec.services) : [];
1299
1511
  const pruneSnapshot = readTarget(target, prunePaths);
1300
- if (audit.clean && prunePaths.length === 0) {
1301
- if (json) this.#write(audit);
1302
- else {
1303
- this.#reporter.line(repairVerdict(audit, generated));
1304
- const note = scopeNote(this.#outside(compiled, target), generated);
1305
- if (note !== void 0) this.#reporter.line(note);
1306
- }
1307
- 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;
1308
1529
  return;
1309
1530
  }
1310
- if (!json) this.#reporter.line(repairVerdict(audit, generated));
1311
1531
  const terminal = createTerminal();
1312
1532
  let proceed = true;
1313
- 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);
1314
1534
  if (!proceed) {
1315
- if (json) this.#write(audit);
1535
+ this.#emitRepair(outcome, generated, json);
1316
1536
  process.exitCode = 1;
1317
1537
  return;
1318
1538
  }
1319
- if (values.prune && !json) if (prunePaths.length === 0) this.#reporter.line(PRUNE_EMPTY);
1320
- else for (const line of prunePreview(prunePaths)) this.#reporter.line(line);
1539
+ if (values.prune && !json) {
1540
+ if (prunePaths.length === 0) this.#reporter.line(PRUNE_EMPTY);
1541
+ else for (const line of prunePreview(prunePaths)) this.#reporter.line(line);
1542
+ }
1321
1543
  const doPrune = prunePaths.length > 0 && await this.#prune(terminal, pruneConfirmMessage(prunePaths.length), values, json);
1322
1544
  let currentGlobal;
1323
1545
  try {
@@ -1331,31 +1553,41 @@ var CLI = class {
1331
1553
  global: currentGlobal
1332
1554
  };
1333
1555
  const refreshed = this.#prepareRepair(spec, target, host, generated, json);
1556
+ compiled = refreshed[0];
1334
1557
  plan = refreshed[1];
1335
1558
  audit = refreshed[2];
1559
+ outcome = repairAuditOf(this.#scanSafe(audit, target, host, spec.services).audit, this.#outside(compiled, plan, target, host));
1336
1560
  }
1337
1561
  const spinner = this.#spinner("repairing", json);
1338
1562
  spinner?.start();
1339
1563
  const materializer = createMaterializer({ host });
1340
1564
  try {
1341
- const result = materializer.repair(plan, audit, target);
1565
+ const result = materializer.repair(plan, audit, target, replace);
1342
1566
  const removed = doPrune ? materializer.prune(target, pruneSnapshot).removed : [];
1343
- 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, {
1344
1576
  ...result,
1345
1577
  removed
1346
- }));
1347
- else this.#succeed(spinner, json, repairSuccess(result, removed));
1578
+ });
1579
+ process.exitCode = hasFindings(finalAudit, finalAudit.outside) ? 1 : 0;
1348
1580
  } catch (error) {
1349
1581
  this.#reject(spinner, json, error);
1350
1582
  } finally {
1351
1583
  materializer.destroy();
1352
1584
  }
1353
- process.exitCode = 0;
1354
1585
  }
1355
1586
  /** `scaffold fleet` — audit/repair every `@orkestrel` package beneath the current directory's immediate children. */
1356
1587
  async #fleet(values, json) {
1357
1588
  const root = this.#contain(".", json);
1358
1589
  const generated = values.generated === true;
1590
+ const replace = values.replace === true;
1359
1591
  const packages = discoverPackages(root);
1360
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);
1361
1593
  const host = values.from?.[0] ?? hostRoot();
@@ -1365,6 +1597,7 @@ var CLI = class {
1365
1597
  const name = basename(directory);
1366
1598
  try {
1367
1599
  const compiler = createCompiler();
1600
+ let compiled;
1368
1601
  let scoped;
1369
1602
  let questions;
1370
1603
  try {
@@ -1374,14 +1607,8 @@ var CLI = class {
1374
1607
  const message = scaffolding.questions.map((question) => question.text).join("; ");
1375
1608
  throw new ScaffoldError("INVALID", message);
1376
1609
  }
1377
- scoped = {
1378
- ...scaffolding.plan,
1379
- blueprint: {
1380
- ...scaffolding.plan.blueprint,
1381
- overrides: []
1382
- },
1383
- artifacts: scaffolding.plan.artifacts.filter((artifact) => artifact.origin === "host" || generated && artifact.origin === "computed" && artifact.path !== "package.json")
1384
- };
1610
+ compiled = scaffolding.plan;
1611
+ scoped = this.#scopeRepair(compiled, directory, generated);
1385
1612
  questions = scaffolding.questions;
1386
1613
  } finally {
1387
1614
  compiler.destroy();
@@ -1392,12 +1619,15 @@ var CLI = class {
1392
1619
  ...diffPlan(plan, readTarget(directory, paths)),
1393
1620
  questions
1394
1621
  };
1395
- 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);
1396
1624
  repos.push({
1397
1625
  name,
1398
1626
  directory,
1399
1627
  plan,
1400
- audit
1628
+ rawAudit,
1629
+ audit,
1630
+ outside
1401
1631
  });
1402
1632
  } catch (error) {
1403
1633
  failures.push({
@@ -1415,6 +1645,8 @@ var CLI = class {
1415
1645
  foreign: repo.audit.foreign
1416
1646
  }));
1417
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}`);
1418
1650
  }
1419
1651
  for (const failure of failures) this.#reporter.line(fleetRepoLine(failure.name, {
1420
1652
  state: "failed",
@@ -1422,45 +1654,49 @@ var CLI = class {
1422
1654
  }));
1423
1655
  }
1424
1656
  const dirty = repos.filter((repo) => !repo.audit.clean);
1657
+ const reportedDirty = repos.filter((repo) => hasFindings(repo.audit, repo.outside));
1425
1658
  if (dirty.length === 0) {
1426
- if (json) this.#write([...repos.map((repo) => fleetEntryOf(repo.name, repo.audit, false)), ...failures.map((failure) => fleetEntryOf(failure.name, void 0, true))]);
1427
- else this.#reporter.line(fleetTotals(0, failures.length));
1428
- 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;
1429
1662
  return;
1430
1663
  }
1431
- 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));
1432
1666
  const terminal = createTerminal();
1433
1667
  const proceed = await this.#apply(terminal, applyConfirmMessage(fileCount, dirty.length), values, json);
1434
1668
  const pruneSets = proceed && values.prune ? new Map(dirty.map((repo) => {
1435
- const paths = this.#prunePaths(repo.directory, host, repo.plan.blueprint.service);
1669
+ const paths = this.#prunePaths(repo.directory, host, repo.plan.blueprint.services);
1436
1670
  return [repo.name, readTarget(repo.directory, paths)];
1437
1671
  })) : /* @__PURE__ */ new Map();
1438
1672
  const prunePaths = dirty.flatMap((repo) => Object.keys(pruneSets.get(repo.name) ?? {}).map((path) => `${repo.name}/${path}`));
1439
- if (proceed && values.prune && !json) if (prunePaths.length === 0) this.#reporter.line(PRUNE_EMPTY);
1440
- else for (const line of prunePreview(prunePaths)) this.#reporter.line(line);
1673
+ if (proceed && values.prune && !json) {
1674
+ if (prunePaths.length === 0) this.#reporter.line(PRUNE_EMPTY);
1675
+ else for (const line of prunePreview(prunePaths)) this.#reporter.line(line);
1676
+ }
1441
1677
  const doPrune = proceed && prunePaths.length > 0 && await this.#prune(terminal, pruneConfirmMessage(prunePaths.length), values, json);
1442
1678
  if (!proceed) {
1443
- if (json) this.#write([...repos.map((repo) => fleetEntryOf(repo.name, repo.audit, false)), ...failures.map((failure) => fleetEntryOf(failure.name, void 0, true))]);
1444
- 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));
1445
1681
  process.exitCode = 1;
1446
1682
  return;
1447
1683
  }
1448
1684
  const materializer = createMaterializer({ host });
1449
- let drifted = 0;
1685
+ let drifted = repos.filter((repo) => repo.audit.clean && repo.outside > 0).length;
1450
1686
  let failedCount = failures.length;
1451
- 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));
1452
1688
  try {
1453
1689
  for (const repo of dirty) try {
1454
- materializer.repair(repo.plan, repo.audit, repo.directory);
1690
+ materializer.repair(repo.plan, repo.rawAudit, repo.directory, replace);
1455
1691
  if (doPrune) materializer.prune(repo.directory, pruneSets.get(repo.name) ?? {});
1456
1692
  const paths = repo.plan.artifacts.map((artifact) => artifact.path);
1457
1693
  const rawFinal = diffPlan(repo.plan, readTarget(repo.directory, paths));
1458
- const finalAudit = this.#scan(rawFinal, repo.directory, host, repo.plan.blueprint.service);
1459
- if (!finalAudit.clean) drifted += 1;
1460
- 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));
1461
1697
  if (!json) this.#reporter.line(fleetRepoLine(repo.name, {
1462
1698
  state: "repaired",
1463
- remaining: finalAudit.drifted + finalAudit.missing + finalAudit.foreign
1699
+ remaining: finalAudit.drifted + finalAudit.missing + finalAudit.foreign + repo.outside
1464
1700
  }));
1465
1701
  } catch (error) {
1466
1702
  failedCount += 1;
@@ -1637,6 +1873,7 @@ var CLI = class {
1637
1873
  if (!isVerb(command)) this.#usage(didYouMean(command), json);
1638
1874
  if (command !== "catalog" && values.from !== void 0 && values.from.length > 1) this.#usage(`--from may be provided only once for '${command}'`, json);
1639
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);
1640
1877
  if (values.help) {
1641
1878
  process.stdout.write(`${verbHelp(command)}\n`);
1642
1879
  process.exitCode = 0;