@orkestrel/scaffold 0.0.2 → 0.0.4

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 (82) hide show
  1. package/README.md +29 -15
  2. package/dist/bin/scaffold.js +1162 -1200
  3. package/dist/bin/scaffold.js.map +1 -1
  4. package/dist/host/AGENTS.md +124 -925
  5. package/dist/host/CLAUDE.md +335 -495
  6. package/dist/host/agents/skills/orkestrel-align-packages/SKILL.md +53 -0
  7. package/dist/host/agents/skills/orkestrel-align-packages/agents/openai.yaml +4 -0
  8. package/dist/host/agents/skills/orkestrel-align-packages/references/fleet.md +50 -0
  9. package/dist/host/agents/skills/orkestrel-align-packages/references/integration.md +56 -0
  10. package/dist/host/agents/skills/orkestrel-build-application/SKILL.md +64 -0
  11. package/dist/host/agents/skills/orkestrel-build-application/agents/openai.yaml +4 -0
  12. package/dist/host/agents/skills/orkestrel-build-application/references/application.md +129 -0
  13. package/dist/host/agents/skills/orkestrel-harden-package/SKILL.md +64 -0
  14. package/dist/host/agents/skills/orkestrel-harden-package/agents/openai.yaml +4 -0
  15. package/dist/host/agents/skills/orkestrel-harden-package/references/centralization.md +85 -0
  16. package/dist/host/agents/skills/orkestrel-harden-package/references/contract.md +57 -0
  17. package/dist/host/agents/skills/orkestrel-harden-package/references/hardening.md +93 -0
  18. package/dist/host/agents/skills/orkestrel-harden-package/references/research.md +61 -0
  19. package/dist/host/claude/agents/application.md +30 -0
  20. package/dist/host/claude/agents/builder.md +10 -7
  21. package/dist/host/claude/agents/checker.md +14 -8
  22. package/dist/host/claude/agents/codex.md +94 -0
  23. package/dist/host/claude/agents/grok.md +37 -34
  24. package/dist/host/claude/agents/implementer.md +40 -0
  25. package/dist/host/claude/agents/orkestrel.md +88 -223
  26. package/dist/host/claude/agents/planner.md +13 -34
  27. package/dist/host/claude/agents/reviewer.md +38 -24
  28. package/dist/host/claude/agents/verifier.md +14 -10
  29. package/dist/host/claude/rules/application.md +57 -0
  30. package/dist/host/claude/rules/architecture.md +180 -0
  31. package/dist/host/claude/rules/browser.md +28 -0
  32. package/dist/host/claude/rules/documentation.md +64 -0
  33. package/dist/host/claude/rules/names.md +209 -0
  34. package/dist/host/claude/rules/patterns.md +130 -0
  35. package/dist/host/claude/rules/quality.md +45 -0
  36. package/dist/host/claude/rules/styles.md +64 -0
  37. package/dist/host/claude/rules/tests.md +123 -0
  38. package/dist/host/claude/rules/typescript.md +78 -0
  39. package/dist/host/claude/rules/workspace.md +180 -0
  40. package/dist/host/claude/settings.json +112 -0
  41. package/dist/host/claude/skills/orkestrel-align-packages/SKILL.md +10 -0
  42. package/dist/host/claude/skills/orkestrel-build-application/SKILL.md +12 -0
  43. package/dist/host/claude/skills/orkestrel-harden-package/SKILL.md +10 -0
  44. package/dist/host/codex/agents/analyst.toml +15 -0
  45. package/dist/host/codex/agents/application.toml +25 -0
  46. package/dist/host/codex/agents/builder.toml +24 -0
  47. package/dist/host/codex/agents/checker.toml +15 -0
  48. package/dist/host/codex/agents/grok.toml +17 -0
  49. package/dist/host/codex/agents/implementer.toml +17 -0
  50. package/dist/host/codex/agents/opus.toml +25 -0
  51. package/dist/host/codex/agents/orkestrel.toml +16 -0
  52. package/dist/host/codex/agents/planner.toml +20 -0
  53. package/dist/host/codex/agents/reviewer.toml +18 -0
  54. package/dist/host/codex/agents/verifier.toml +14 -0
  55. package/dist/host/codex/config.toml +66 -0
  56. package/dist/host/cursor/mcp.json +12 -0
  57. package/dist/host/dotfiles/mcp.json +8 -0
  58. package/dist/host/dotfiles/oxlintrc.json +307 -1
  59. package/dist/host/guides/src/scaffold.md +2059 -2007
  60. package/dist/host/manifest.json +376 -137
  61. package/dist/host/scripts/codex.sh +49 -0
  62. package/dist/host/scripts/cursor.sh +33 -47
  63. package/dist/host/scripts/deps.sh +34 -16
  64. package/dist/host/scripts/ollama.sh +6 -149
  65. package/dist/host/tests/setupPolicy.ts +390 -0
  66. package/dist/src/core/index.cjs +6603 -1200
  67. package/dist/src/core/index.cjs.map +1 -1
  68. package/dist/src/core/index.d.cts +802 -267
  69. package/dist/src/core/index.d.ts +802 -267
  70. package/dist/src/core/index.js +6521 -1192
  71. package/dist/src/core/index.js.map +1 -1
  72. package/dist/src/server/index.cjs +3132 -593
  73. package/dist/src/server/index.cjs.map +1 -1
  74. package/dist/src/server/index.d.cts +643 -95
  75. package/dist/src/server/index.d.ts +643 -95
  76. package/dist/src/server/index.js +3054 -594
  77. package/dist/src/server/index.js.map +1 -1
  78. package/package.json +16 -12
  79. package/dist/host/claude/agents/composer.md +0 -64
  80. package/dist/host/claude/agents/researcher.md +0 -38
  81. package/dist/host/claude/agents/scout.md +0 -35
  82. package/dist/host/github/workflows/ci.yml +0 -64
@@ -1,16 +1,17 @@
1
1
  #!/usr/bin/env node
2
- import { existsSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
3
- import { basename, dirname, join, relative, resolve, sep } from "node:path";
2
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
3
+ import { basename, dirname, join, relative } from "node:path";
4
4
  import * as tls from "node:tls";
5
- import { parseArgs } from "node:util";
6
- import { DEPENDENCY_NAME_PATTERN, GROUPS, NAME_PATTERN, SURFACES, ScaffoldError, blueprint, blueprintToPlan, catalogNames, catalogToBlock, createCompiler, dependency, diffPlan, isScaffoldError, manifestToDependencies, planToSummary } from "../src/core/index.js";
7
- import { catalogPackages, createMaterializer, createSync, deriveBlueprint, discoverPackages, hostRoot, hydratePlan, locateHostSource, pruneTargets, readHostManifest, readManifest, readTarget } from "../src/server/index.js";
5
+ import { DEPENDENCY_NAME_PATTERN, ENVIRONMENTS, GROUPS, MAX_ARTIFACT_BYTES, NAME_PATTERN, ScaffoldError, blueprint, blueprintToPlan, catalogNames, catalogToBlock, createCompiler, dependency, diffPlan, isScaffoldError, manifestToDependencies, planToSummary } from "../src/core/index.js";
6
+ import { WriteTransaction, catalogPackages, commitWriteTransaction, createMaterializer, createSync, deriveBlueprint, digestFile, digestText, discardWriteTransaction, discoverPackages, hostRoot, hydratePlan, isFilesystemPath, isRealDirectory, isTerminalText, isVacant, locateHostSource, pruneTargets, readFileText, readHostManifest, readManifest, readTarget, resolvePhysicalPath, validateWriteDirectories } from "../src/server/index.js";
8
7
  import { createReporter, createSpinner, createStyler } from "@orkestrel/console";
9
8
  import { createServerSink } from "@orkestrel/console/server";
9
+ import { attempt } from "@orkestrel/contract";
10
10
  import { isTerminalError } from "@orkestrel/terminal";
11
11
  import { createTerminal } from "@orkestrel/terminal/server";
12
- //#region src/bin/render.ts
13
- /** The bin's closed verb vocabulary. */
12
+ import { parseArgs } from "node:util";
13
+ //#region src/bin/constants.ts
14
+ /** The command-line interface's closed command vocabulary. */
14
15
  var KNOWN_VERBS = Object.freeze([
15
16
  "new",
16
17
  "pull",
@@ -19,51 +20,185 @@ var KNOWN_VERBS = Object.freeze([
19
20
  "fleet",
20
21
  "catalog"
21
22
  ]);
22
- /** The jargon translation table internal `Origin`/`Drift`/scope vocabulary → one user-facing register. */
23
+ /** Internal artifact origins translated into user-facing labels. */
23
24
  var ORIGIN_LABEL = Object.freeze({
24
- host: "template-owned",
25
- template: "template-owned",
25
+ host: "host-owned",
26
+ template: "starter",
26
27
  computed: "generated"
27
28
  });
28
- /** A `Finding`'s `Drift`, translated `'aligned'` never reaches a rendered table (callers filter it first). */
29
+ /** Internal drift states translated into user-facing labels. */
29
30
  var DRIFT_LABEL = Object.freeze({
30
31
  aligned: "unchanged",
31
32
  stale: "drifted",
32
33
  missing: "missing",
33
34
  foreign: "unexpected file"
34
35
  });
35
- /** A `Freshness` outcome, translated for `pull`'s per-entry cause notes. */
36
+ /** Dependency freshness states translated into user-facing labels. */
36
37
  var FRESHNESS_LABEL = Object.freeze({
37
38
  current: "unchanged",
38
39
  behind: "behind",
39
40
  missing: "missing upstream",
40
41
  failed: "fetch failed"
41
42
  });
42
- /** The materializer's per-entry action words, translated (`copied` `wrote`, `skipped` → `unchanged`). */
43
+ /** Materializer actions translated into user-facing labels. */
43
44
  var ACTION_LABEL = Object.freeze({
44
45
  written: "wrote",
45
46
  copied: "wrote",
46
47
  skipped: "unchanged",
47
48
  removed: "removed"
48
49
  });
49
- /** One `{count} {label}` part, pluralized; used by every bucket/tally line. */
50
- function countPart(count, label) {
51
- return `${count} ${label}${count === 1 ? "" : "s"}`;
52
- }
53
- /** Nonzero `{count} {label}` parts joined by `, `; `'clean'` when every count is zero — the shared bucket-text primitive every verdict line reuses. */
54
- function bucketText(counts) {
55
- const parts = [];
56
- if (counts.drifted > 0) parts.push(countPart(counts.drifted, "drifted"));
57
- if (counts.missing > 0) parts.push(countPart(counts.missing, "missing"));
58
- if (counts.foreign > 0) parts.push(countPart(counts.foreign, "unexpected"));
59
- return parts.length > 0 ? parts.join(", ") : "clean";
60
- }
61
- /**
62
- * Split `findings` by their `plan` artifact's `origin` — `host`/`template` lumped as
63
- * `template-owned` vs `computed` as `generated` (a `foreign` finding names no plan artifact,
64
- * so it counts as `generated`: it is never template-owned).
65
- */
66
- function partitionOrigin(findings, plan) {
50
+ /** Repair's deliberately limited ownership boundary. */
51
+ var REPAIR_SCOPE = "repair scope: shared host-owned artifacts only — starter and generated files are never touched";
52
+ /** The dry-run note for `new`. */
53
+ var NEW_DRY_RUN_NOTE = "dry run — pass --apply to write";
54
+ /** The fallback message for a malformed command line without an error message. */
55
+ var INVALID_ARGUMENTS_MESSAGE = "invalid arguments";
56
+ /** Shared prompt-cancellation message. */
57
+ var CANCELLED_MESSAGE = "cancelled nothing written";
58
+ /** Terminal choices for a new workspace's src and app environments. */
59
+ var ENVIRONMENT_CHOICES = Object.freeze([
60
+ {
61
+ name: "core",
62
+ value: "core",
63
+ description: "the pure engine"
64
+ },
65
+ {
66
+ name: "browser",
67
+ value: "browser",
68
+ description: "DOM-facing environment"
69
+ },
70
+ {
71
+ name: "server",
72
+ value: "server",
73
+ description: "node-facing environment"
74
+ }
75
+ ]);
76
+ /** The safety model included in full help. */
77
+ var SAFETY_BANNER = [
78
+ "safety: every verb is a dry run by default.",
79
+ "on a terminal, a write prompts for confirmation; in a script, pass --apply (and --yes to skip the confirm).",
80
+ "every write is confined to the current working directory — cd there first.",
81
+ "when Node exposes system-CA controls, TLS adds the OS certificate store; earlier supported Node 22 releases use default roots. NODE_EXTRA_CA_CERTS adds custom PEMs."
82
+ ].join("\n");
83
+ /** Stable command exit-code meanings. */
84
+ var EXIT_CODES = Object.freeze([
85
+ ["0", "clean / success"],
86
+ ["1", "drift or failure"],
87
+ ["2", "usage error"]
88
+ ]);
89
+ /** One-line command summaries. */
90
+ var VERB_SUMMARY = Object.freeze({
91
+ new: "scaffold a workspace into ./<name>",
92
+ pull: "refresh vendored guides/versions, report drift",
93
+ audit: "whole-plan conformance report",
94
+ repair: "restore the shared host-owned set",
95
+ fleet: "audit/repair every workspace under the cwd's immediate children",
96
+ catalog: "regenerate the fleet package-catalog table"
97
+ });
98
+ /** Compact command flag references. */
99
+ var VERB_FLAGS = Object.freeze({
100
+ new: "--src a,b --app a,b --deps x,y --apply --yes --target <path> --from <path>",
101
+ pull: "--target . --deps x,y --apply --yes --strict",
102
+ audit: "--target . --live --from <path> --groups a,b",
103
+ repair: "--target . --apply --yes --prune --from <path>",
104
+ fleet: "--apply --yes --prune --from <path>",
105
+ catalog: "--from <path> ... --target <repo> --offline --apply --yes"
106
+ });
107
+ /** Plain-language command flag descriptions. */
108
+ var VERB_FLAG_HELP = Object.freeze({
109
+ new: [
110
+ ["--src a,b", "which src environments to include (core, browser, server)"],
111
+ ["--app a,b", "which app environments to include (core, browser, server)"],
112
+ ["--deps x,y", "@orkestrel/* dependencies to add (installed as dependencies)"],
113
+ ["--apply", "write the files (default is a dry run)"],
114
+ ["--yes", "skip the confirmation question"],
115
+ ["--target <path>", "destination directory (default: ./<name>)"],
116
+ ["--from <path>", "read the template from a local path instead of the bundled one"]
117
+ ],
118
+ pull: [
119
+ ["--target .", "directory to refresh (default: current directory)"],
120
+ ["--deps x,y", "limit the refresh to these dependencies"],
121
+ ["--apply", "write the refreshed files (default is a dry run)"],
122
+ ["--yes", "skip the confirmation question"],
123
+ ["--strict", "fail (exit 1) on any drift, even non-fatal"]
124
+ ],
125
+ audit: [
126
+ ["--target .", "directory to audit (default: current directory)"],
127
+ ["--live", "also check upstream freshness over the network"],
128
+ ["--from <path>", "read the template from a local path instead of the bundled one"],
129
+ ["--groups a,b", "limit the audit to these artifact groups"]
130
+ ],
131
+ repair: [
132
+ ["--target .", "directory to repair (default: current directory)"],
133
+ ["--apply", "write the fixes (default is a dry run)"],
134
+ ["--yes", "skip the confirmation question"],
135
+ ["--prune", "also DELETE unexpected files under .claude/agents, .codex/agents, and scripts"],
136
+ ["--from <path>", "read the template from a local path instead of the bundled one"]
137
+ ],
138
+ fleet: [
139
+ ["--apply", "write fixes across every package (default is a dry run)"],
140
+ ["--yes", "skip the confirmation question"],
141
+ ["--prune", "also DELETE unexpected files under .claude/agents, .codex/agents, and scripts, per package"],
142
+ ["--from <path>", "read the template from a local path instead of the bundled one"]
143
+ ],
144
+ catalog: [
145
+ ["--from <path> ...", "one or more local package paths to include"],
146
+ ["--target <repo>", "the repository whose Orkestrel agent catalog table gets updated"],
147
+ ["--offline", "skip network lookups (npm registry) for package descriptions"],
148
+ ["--apply", "write the updated table (default is a dry run)"],
149
+ ["--yes", "skip the confirmation question"]
150
+ ]
151
+ });
152
+ /** Dry-run and confirmation notes per command. */
153
+ var VERB_DRY_RUN_NOTE = Object.freeze({
154
+ new: "dry run by default — add --apply to write the files, --yes to skip the question",
155
+ pull: "dry run by default — add --apply to write the refreshed files, --yes to skip the question",
156
+ audit: "read-only — audit never writes; pass --live to also check upstream freshness",
157
+ repair: "dry run by default — add --apply to write, --yes to skip the question",
158
+ fleet: "dry run by default — add --apply to write across every package, --yes to skip the question",
159
+ catalog: "dry run by default — add --apply to write, --yes to skip the question"
160
+ });
161
+ /** One concrete invocation per command. */
162
+ var VERB_EXAMPLE = Object.freeze({
163
+ new: "example: scaffold new widget --src core,server --app core,browser --apply",
164
+ pull: "example: scaffold pull --apply",
165
+ audit: "example: scaffold audit --live",
166
+ repair: "example: scaffold repair --apply",
167
+ fleet: "example: scaffold fleet --apply --yes",
168
+ catalog: "example: scaffold catalog --apply"
169
+ });
170
+ /** Message used when a prune scan has no candidates. */
171
+ var PRUNE_EMPTY = "no unexpected files to delete";
172
+ /** Guidance for unexpected files outside a non-pruning repair handoff. */
173
+ var FOREIGN_HINT = "unexpected files found — run 'scaffold repair --prune' to delete them";
174
+ /** Interactive dependency prompt. */
175
+ var ORKESTREL_DEPS_PROMPT = "@orkestrel dependencies (comma-separated short names, e.g. contract, emitter — installed as dependencies)";
176
+ /** Catalog-degraded validation note. */
177
+ var CATALOG_UNRESOLVED_NOTE = "couldn't resolve the vendored @orkestrel catalog — validating names by shape only";
178
+ /** Catalog block destination in a scaffolded repository. */
179
+ var CATALOG_AGENT_PATH = ".claude/agents/orkestrel.md";
180
+ /** Opening marker for the generated package catalog block. */
181
+ var CATALOG_START_MARKER = "<!-- catalog:start -->";
182
+ /** Closing marker for the generated package catalog block. */
183
+ var CATALOG_END_MARKER = "<!-- catalog:end -->";
184
+ /** Non-terminal prune safety note. */
185
+ var PRUNE_SKIPPED = "prune skipped — not a terminal; add --apply (or --yes) to delete non-interactively";
186
+ /** Degraded unexpected-file scan note. */
187
+ var SCAN_SKIPPED = "unexpected-file scanning skipped — couldn't establish the template source";
188
+ //#endregion
189
+ //#region src/bin/errors.ts
190
+ /** Internal sentinel that unwinds command dispatch without bypassing cleanup. */
191
+ var CLIExitError = class extends Error {
192
+ code;
193
+ constructor(code) {
194
+ super(`cli-exit:${String(code)}`);
195
+ this.code = code;
196
+ }
197
+ };
198
+ //#endregion
199
+ //#region src/bin/shapers.ts
200
+ /** Partition findings by whether repair owns their artifact origin. */
201
+ function partitionFindings(findings, plan) {
67
202
  const origins = new Map(plan.artifacts.map((artifact) => [artifact.path, artifact.origin]));
68
203
  let ownedDrifted = 0;
69
204
  let ownedMissing = 0;
@@ -73,13 +208,13 @@ function partitionOrigin(findings, plan) {
73
208
  let generatedForeign = 0;
74
209
  for (const finding of findings) {
75
210
  const origin = origins.get(finding.path);
76
- const isOwned = origin === "host" || origin === "template";
211
+ const owned = origin === "host" || origin === "template";
77
212
  if (finding.drift === "aligned") continue;
78
- else if (finding.drift === "stale") if (isOwned) ownedDrifted += 1;
213
+ if (finding.drift === "stale") if (owned) ownedDrifted += 1;
79
214
  else generatedDrifted += 1;
80
- else if (finding.drift === "missing") if (isOwned) ownedMissing += 1;
215
+ else if (finding.drift === "missing") if (owned) ownedMissing += 1;
81
216
  else generatedMissing += 1;
82
- else if (isOwned) ownedForeign += 1;
217
+ else if (owned) ownedForeign += 1;
83
218
  else generatedForeign += 1;
84
219
  }
85
220
  return {
@@ -95,27 +230,88 @@ function partitionOrigin(findings, plan) {
95
230
  }
96
231
  };
97
232
  }
98
- /** `audit`'s verdict line lowercase, verb-led, origin-split so template-owned health is immediately visible. */
233
+ /** Create one deterministic fleet result entry. */
234
+ function fleetEntryOf(name, counts, failed) {
235
+ return {
236
+ name,
237
+ drifted: counts?.drifted ?? 0,
238
+ missing: counts?.missing ?? 0,
239
+ foreign: counts?.foreign ?? 0,
240
+ failed
241
+ };
242
+ }
243
+ /** Create the command-line interface's machine-readable failure envelope. */
244
+ function errorEnvelopeOf(code, message) {
245
+ return { error: {
246
+ code,
247
+ message
248
+ } };
249
+ }
250
+ /** Project a plan summary to the stable machine-readable `new` result. */
251
+ function summaryToNewResult(summary, applied) {
252
+ return {
253
+ name: summary.name,
254
+ src: summary.src,
255
+ app: summary.app,
256
+ host: summary.host,
257
+ template: summary.template,
258
+ computed: summary.computed,
259
+ applied
260
+ };
261
+ }
262
+ /** Add a materialization result to an audit. */
263
+ function auditToRepairResult(audit, result) {
264
+ return {
265
+ ...audit,
266
+ result
267
+ };
268
+ }
269
+ /** Create the stable machine-readable catalog result. */
270
+ function catalogResultOf(entries, drift, shrink) {
271
+ return shrink === void 0 ? {
272
+ entries,
273
+ drift
274
+ } : {
275
+ entries,
276
+ drift,
277
+ shrink
278
+ };
279
+ }
280
+ //#endregion
281
+ //#region src/bin/helpers.ts
282
+ /** Render one pluralized count. */
283
+ function countPart(count, label) {
284
+ return `${count} ${label}${count === 1 ? "" : "s"}`;
285
+ }
286
+ /** Render nonzero audit buckets or `clean`. */
287
+ function bucketText(counts) {
288
+ const parts = [];
289
+ if (counts.drifted > 0) parts.push(countPart(counts.drifted, "drifted"));
290
+ if (counts.missing > 0) parts.push(countPart(counts.missing, "missing"));
291
+ if (counts.foreign > 0) parts.push(countPart(counts.foreign, "unexpected"));
292
+ return parts.length > 0 ? parts.join(", ") : "clean";
293
+ }
294
+ /** Render audit's origin-aware verdict. */
99
295
  function auditVerdict(audit, plan) {
100
- const total = audit.findings.length;
101
- if (audit.clean) return `audit: ${countPart(total, "artifact")} — clean`;
102
- const split = partitionOrigin(audit.findings, plan);
103
- return split.owned.drifted === 0 && split.owned.missing === 0 && split.owned.foreign === 0 ? `audit: ${countPart(total, "artifact")} — template-owned clean; ${bucketText(split.generated)} (generated)` : `audit: ${countPart(total, "artifact")} — template-owned: ${bucketText(split.owned)}; generated: ${bucketText(split.generated)}`;
296
+ const count = audit.findings.length;
297
+ if (audit.clean) return `audit: ${countPart(count, "artifact")} — clean`;
298
+ const split = partitionFindings(audit.findings, plan);
299
+ 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)}`;
104
300
  }
105
- /** One aligned-column row per non-`aligned` `Finding` `[status, kind, path]`, translated labels, ready for `reporter.table`. */
301
+ /** Render non-aligned audit findings as terminal table rows. */
106
302
  function findingRows(findings, plan) {
107
303
  const origins = new Map(plan.artifacts.map((artifact) => [artifact.path, artifact.origin]));
108
304
  return findings.filter((finding) => finding.drift !== "aligned").map((finding) => {
109
305
  const origin = origins.get(finding.path);
110
- const kind = origin === void 0 ? "unexpected file" : ORIGIN_LABEL[origin];
306
+ const category = origin === void 0 ? "unexpected file" : ORIGIN_LABEL[origin];
111
307
  return [
112
308
  DRIFT_LABEL[finding.drift],
113
- kind,
309
+ category,
114
310
  finding.path
115
311
  ];
116
312
  });
117
313
  }
118
- /** The audit findings table — columns Status/Kind/Path, translated labels, via `reporter.table`. */
314
+ /** Create audit's terminal table. */
119
315
  function auditTable(audit, plan) {
120
316
  return {
121
317
  columns: [
@@ -126,42 +322,36 @@ function auditTable(audit, plan) {
126
322
  rows: findingRows(audit.findings, plan)
127
323
  };
128
324
  }
129
- /** The banner `repair` opens with (dry-run AND `--apply`) — its scope is the template-owned set only. */
130
- var REPAIR_SCOPE = "repair scope: shared template-owned artifacts only — generated source/tests/configs are never touched";
131
- /** The `repair` note pointing at drift outside its scope — `undefined` when there is none. */
132
- function scopeNote(outsideCount) {
133
- if (outsideCount === 0) return void 0;
134
- return `note: ${countPart(outsideCount, "finding")} outside repair's scope — run 'audit' for the list; generated files are yours to edit`;
325
+ /** Render drift outside repair's ownership boundary. */
326
+ function scopeNote(count) {
327
+ if (count === 0) return void 0;
328
+ return `note: ${countPart(count, "finding")} outside repair's scope — run 'audit' for the list; generated files are yours to edit`;
135
329
  }
136
- /** `repair`'s dry-run verdict line. */
330
+ /** Render repair's dry-run verdict. */
137
331
  function repairVerdict(audit) {
138
- if (audit.clean) return `repair: ${countPart(audit.findings.length, "template-owned artifact")} aligned — nothing to write`;
139
- return `repair: ${bucketText({
140
- drifted: audit.drifted,
141
- missing: audit.missing,
142
- foreign: audit.foreign
143
- })} — pass --apply to write`;
332
+ if (audit.clean) return `repair: ${countPart(audit.findings.length, "host-owned artifact")} aligned — nothing to write`;
333
+ return `repair: ${bucketText(audit)} — pass --apply to write`;
144
334
  }
145
- /** `repair --apply`'s success line — the materializer tally, translated action words. */
335
+ /** Render repair's materialization tally. */
146
336
  function repairSuccess(result, removed) {
147
337
  const written = result.written.length + result.copied.length;
148
338
  return `${ACTION_LABEL.written} ${written}, ${ACTION_LABEL.skipped} ${result.skipped.length}, ${ACTION_LABEL.removed} ${removed.length}`;
149
339
  }
150
- /** `pull`'s freshness table rows — `[name, kind, freshness]`, translated. */
340
+ /** Render pull freshness as table rows. */
151
341
  function pullRows(report) {
152
- const guideRows = report.guides.map((guide) => [
342
+ const guides = report.guides.map((guide) => [
153
343
  guide.name,
154
344
  "guide",
155
345
  FRESHNESS_LABEL[guide.freshness] ?? guide.freshness
156
346
  ]);
157
- const versionRows = report.versions.map((version) => [
347
+ const versions = report.versions.map((version) => [
158
348
  version.name,
159
349
  "version",
160
350
  FRESHNESS_LABEL[version.freshness] ?? version.freshness
161
351
  ]);
162
- return [...guideRows, ...versionRows];
352
+ return [...guides, ...versions];
163
353
  }
164
- /** `pull`'s freshness table — columns Name/Kind/Freshness. */
354
+ /** Create pull's terminal table. */
165
355
  function pullTable(report) {
166
356
  return {
167
357
  columns: [
@@ -172,162 +362,93 @@ function pullTable(report) {
172
362
  rows: pullRows(report)
173
363
  };
174
364
  }
175
- /** Per-entry cause notes for a non-`current` `pull` entry that carries a `note`. */
365
+ /** Render cause notes from non-current pull entries. */
176
366
  function pullCauseNotes(report) {
177
367
  return [...report.guides, ...report.versions].filter((entry) => entry.note !== void 0).map((entry) => ` ${entry.name}: ${FRESHNESS_LABEL[entry.freshness] ?? entry.freshness} — ${entry.note}`);
178
368
  }
179
- /** `pull`'s tally line. */
369
+ /** Render pull's tally. */
180
370
  function pullVerdict(report) {
181
371
  return `pull: ${countPart(report.guides.length + report.versions.length, "entry")} — ${countPart(report.failed, "failed")}`;
182
372
  }
183
- /** `pull --apply`'s success line. */
373
+ /** Render pull's success tally. */
184
374
  function pullSuccess(count) {
185
375
  return `wrote ${countPart(count, "guide")}`;
186
376
  }
187
- /** One `fleet` per-repo line — clean, drifted (dry-run), or repaired (`--apply`). */
377
+ /** Render one fleet repository outcome. */
188
378
  function fleetRepoLine(name, outcome) {
189
- if (outcome.kind === "clean") return `${name}: clean`;
190
- if (outcome.kind === "drifted") return `${name}: ${bucketText({
191
- drifted: outcome.drifted,
192
- missing: outcome.missing,
193
- foreign: outcome.foreign
194
- })}`;
195
- if (outcome.kind === "repaired") return `${name}: repaired (${countPart(outcome.remaining, "finding")} remaining)`;
379
+ if (outcome.state === "clean") return `${name}: clean`;
380
+ if (outcome.state === "drifted") return `${name}: ${bucketText(outcome)}`;
381
+ if (outcome.state === "repaired") return `${name}: repaired (${countPart(outcome.remaining, "finding")} remaining)`;
196
382
  return `${name}: ${outcome.message}`;
197
383
  }
198
- /** `fleet`'s blast-radius totals line. */
384
+ /** Render fleet's repository totals. */
199
385
  function fleetTotals(drifted, failed) {
200
386
  return `total: ${countPart(drifted, "drifted repo")}, ${countPart(failed, "failed")}`;
201
387
  }
202
- /** The `catalog` terminal preview table — columns Package/Version (descriptions live only in the written table and `--json`). */
388
+ /** Create catalog's terminal table. */
203
389
  function catalogTable(entries) {
204
390
  return {
205
391
  columns: [{ label: "Package" }, { label: "Version" }],
206
392
  rows: entries.map((entry) => [entry.name, entry.version])
207
393
  };
208
394
  }
209
- /** The `catalog` shrink warning — `undefined` when the table did not shrink. */
395
+ /** Render catalog shrink risk when present. */
210
396
  function catalogShrinkWarning(oldRows, newRows) {
211
397
  if (newRows >= oldRows) return void 0;
212
398
  return `warning: catalog shrinks from ${countPart(oldRows, "row")} to ${newRows}`;
213
399
  }
214
- /** `catalog`'s counts line. */
215
- function catalogCounts(published, localOnly) {
216
- return `catalog: ${countPart(published, "published package")}, ${countPart(localOnly, "local-only")}`;
400
+ /** Render catalog source tallies. */
401
+ function catalogCounts(published, local) {
402
+ return `catalog: ${countPart(published, "published package")}, ${countPart(local, "local-only")}`;
217
403
  }
218
- /** `new`'s dry-run plan preview — origin counts table description + the destination line. */
219
- function newPlanTable(scaffolding) {
404
+ /** Create `new`'s plan summary table. */
405
+ function newPlanTable(summary) {
220
406
  return {
221
407
  columns: [{ label: "Origin" }, {
222
408
  label: "Count",
223
409
  align: "right"
224
410
  }],
225
- rows: [["template-owned", String(scaffolding.host + scaffolding.template)], ["generated", String(scaffolding.computed)]]
411
+ rows: [
412
+ ["host-owned", String(summary.host)],
413
+ ["starter", String(summary.template)],
414
+ ["generated", String(summary.computed)]
415
+ ]
226
416
  };
227
417
  }
228
- /** `new`'s dry-run destination line. */
418
+ /** Render `new`'s dry-run destination. */
229
419
  function newPlanPreview(name) {
230
420
  return `will write into ./${name}`;
231
421
  }
232
- /** `new --apply`'s success line. */
422
+ /** Render `new`'s write result. */
233
423
  function newApplySuccess(count, name) {
234
424
  return `wrote ${countPart(count, "file")} into ./${name}`;
235
425
  }
236
- /** `new`'s dry-run declined-apply note. */
237
- var NEW_DRY_RUN_NOTE = "dry run — pass --apply to write";
238
- /** `catalog --apply`'s success line. */
426
+ /** Render catalog's write result. */
239
427
  function catalogApplySuccess(path) {
240
428
  return `wrote ${path}`;
241
429
  }
242
- /** The fallback line for a `parseArgs` failure that carries no `Error` message of its own. */
243
- var INVALID_ARGUMENTS_MESSAGE = "invalid arguments";
244
- /** The apply-confirm prompt message singular repo, or fleet-wide across `repos` when given. */
245
- function applyConfirmMessage(fileCount, repoCount) {
246
- const scope = repoCount === void 0 ? "" : ` across ${countPart(repoCount, "repo")}`;
247
- return `Apply — write ${countPart(fileCount, "file")}${scope}? `;
430
+ /** Render the shared write confirmation. */
431
+ function applyConfirmMessage(files, repos) {
432
+ const scope = repos === void 0 ? "" : ` across ${countPart(repos, "repo")}`;
433
+ return `Apply — write ${countPart(files, "file")}${scope}? `;
248
434
  }
249
- /** The prune double-confirm prompt message. */
435
+ /** Render the separate prune confirmation. */
250
436
  function pruneConfirmMessage(count) {
251
- return `Also delete ${countPart(count, "unexpected file")} under .claude/agents and scripts? `;
437
+ return `Also delete ${countPart(count, "unexpected file")} under .claude/agents, .codex/agents, and scripts? `;
252
438
  }
253
- /**
254
- * The audit→repair handoff prompt message — names what will actually be
255
- * acted on: `owned` template-owned files with drift, and (only when `prune`
256
- * is active) `foreign` unexpected files that will be deleted. Never promises
257
- * a deletion the handoff will not perform — `prune` gates the foreign clause.
258
- */
439
+ /** Render the interactive audit-to-repair handoff. */
259
440
  function repairHandoff(owned, foreign, prune) {
260
441
  const parts = [];
261
- if (owned > 0) parts.push(`${countPart(owned, "template-owned file")} ${owned === 1 ? "has" : "have"} drift`);
442
+ if (owned > 0) parts.push(`${countPart(owned, "host-owned file")} ${owned === 1 ? "has" : "have"} drift`);
262
443
  if (prune && foreign > 0) parts.push(`${countPart(foreign, "unexpected file")} will be deleted`);
263
444
  return `${parts.join(" and ")} — run repair now? `;
264
445
  }
265
- /** Printed when unexpected files exist but the handoff cannot help them (no `--prune`, or no handoff offered at all) — points at the one command that can. */
266
- function foreignHint() {
267
- return "unexpected files found — run 'scaffold repair --prune' to delete them";
268
- }
269
- /** `new`'s interactive Q1 prompt (TTY only) — `@orkestrel` short-name deps, landing in `dependencies`. */
270
- function orkestrelDepsPrompt() {
271
- return "@orkestrel dependencies (comma-separated short names, e.g. contract, emitter — installed as dependencies)";
272
- }
273
- /**
274
- * Re-ask wording for a Q1 token that does not resolve against the vendored
275
- * `@orkestrel` catalog — names the offending token and, when one was found,
276
- * the nearest catalog name (`render.ts`'s own `nearest`).
277
- */
446
+ /** Render one unresolved Orkestrel dependency token. */
278
447
  function unknownOrkestrelToken(token, suggestion) {
279
- const base = `"${token}" is not a published @orkestrel package`;
280
- return suggestion === void 0 ? `${base} — try again` : `${base} — did you mean "${suggestion}"? try again`;
281
- }
282
- /** Printed once when the vendored `@orkestrel` catalog cannot be resolved — Q1 degrades to shape-only (`DEPENDENCY_NAME_PATTERN`) validation instead of blocking on it. */
283
- function catalogUnresolvedNote() {
284
- return "couldn't resolve the vendored @orkestrel catalog — validating names by shape only";
285
- }
286
- /** The line printed when a confirm prompt is declined. */
287
- var CANCELLED_MESSAGE = "cancelled — nothing written";
288
- /** `new`'s surface checkbox choices — per-choice descriptions grounded on the terminal guide's surface semantics. */
289
- function surfaceChoices() {
290
- return [
291
- {
292
- name: "core",
293
- value: "core",
294
- description: "the pure engine"
295
- },
296
- {
297
- name: "browser",
298
- value: "browser",
299
- description: "DOM-facing surface"
300
- },
301
- {
302
- name: "server",
303
- value: "server",
304
- description: "node-facing surface"
305
- }
306
- ];
448
+ const message = `"${token}" is not a published @orkestrel package`;
449
+ return suggestion === void 0 ? `${message} — try again` : `${message} — did you mean "${suggestion}"? try again`;
307
450
  }
308
- /** The safety-model banner every full-help tier includes. */
309
- var SAFETY_BANNER = [
310
- "safety: every verb is a dry run by default.",
311
- "on a terminal, a write prompts for confirmation; in a script, pass --apply (and --yes to skip the confirm).",
312
- "every write is confined to the current working directory — cd there first.",
313
- "TLS trusts the system certificate store automatically (corporate proxies); NODE_EXTRA_CA_CERTS adds custom PEMs."
314
- ].join("\n");
315
- /** The exit-code reference table every full-help tier includes. */
316
- var EXIT_CODES = [
317
- ["0", "clean / success"],
318
- ["1", "drift or failure"],
319
- ["2", "usage error"]
320
- ];
321
- /** One-line-per-verb summaries — the short and full help tiers share this table. */
322
- var VERB_SUMMARY = Object.freeze({
323
- new: "scaffold a package into ./<name>",
324
- pull: "refresh vendored guides/versions, report drift",
325
- audit: "whole-plan conformance report",
326
- repair: "restore the shared template-owned set",
327
- fleet: "audit/repair every package under the cwd's immediate children",
328
- catalog: "regenerate the fleet package-catalog table"
329
- });
330
- /** ≤10 lines: one-liner per verb plus the escape hatch to `verbHelp`. */
451
+ /** Render compact command usage. */
331
452
  function shortUsage() {
332
453
  return [
333
454
  "scaffold <verb> [options]",
@@ -337,383 +458,129 @@ function shortUsage() {
337
458
  "run 'scaffold <verb> --help' for a verb's full reference"
338
459
  ].join("\n");
339
460
  }
340
- /** Each verb's flag reference — the fullHelp reference and verbHelp share this table. */
341
- var VERB_FLAGS = Object.freeze({
342
- new: "--surfaces a,b --deps x,y --apply --yes --target <path> --from <path>",
343
- pull: "--target . --deps x,y --apply --yes --strict",
344
- audit: "--target . --live --from <path> --groups a,b",
345
- repair: "--target . --apply --yes --prune --from <path>",
346
- fleet: "--apply --yes --prune --from <path>",
347
- catalog: "--from <path> ... --target <repo> --offline --apply --yes"
348
- });
349
- /** Per-verb, per-flag plain-language descriptions — `verbHelp`'s one-line-per-flag body. `--prune` is marked destructive. */
350
- var VERB_FLAG_HELP = Object.freeze({
351
- new: [
352
- ["--surfaces a,b", "which surfaces to include (core, browser, server)"],
353
- ["--deps x,y", "@orkestrel/* dependencies to add (installed as dependencies)"],
354
- ["--apply", "write the files (default is a dry run)"],
355
- ["--yes", "skip the confirmation question"],
356
- ["--target <path>", "destination directory (default: ./<name>)"],
357
- ["--from <path>", "read the template from a local path instead of the bundled one"]
358
- ],
359
- pull: [
360
- ["--target .", "directory to refresh (default: current directory)"],
361
- ["--deps x,y", "limit the refresh to these dependencies"],
362
- ["--apply", "write the refreshed files (default is a dry run)"],
363
- ["--yes", "skip the confirmation question"],
364
- ["--strict", "fail (exit 1) on any drift, even non-fatal"]
365
- ],
366
- audit: [
367
- ["--target .", "directory to audit (default: current directory)"],
368
- ["--live", "also check upstream freshness over the network"],
369
- ["--from <path>", "read the template from a local path instead of the bundled one"],
370
- ["--groups a,b", "limit the audit to these artifact groups"]
371
- ],
372
- repair: [
373
- ["--target .", "directory to repair (default: current directory)"],
374
- ["--apply", "write the fixes (default is a dry run)"],
375
- ["--yes", "skip the confirmation question"],
376
- ["--prune", "also DELETE unexpected files under .claude/agents and scripts"],
377
- ["--from <path>", "read the template from a local path instead of the bundled one"]
378
- ],
379
- fleet: [
380
- ["--apply", "write fixes across every package (default is a dry run)"],
381
- ["--yes", "skip the confirmation question"],
382
- ["--prune", "also DELETE unexpected files under .claude/agents and scripts, per package"],
383
- ["--from <path>", "read the template from a local path instead of the bundled one"]
384
- ],
385
- catalog: [
386
- ["--from <path> ...", "one or more local package paths to include"],
387
- ["--target <repo>", "the repo whose README catalog table gets updated"],
388
- ["--offline", "skip network lookups (npm registry) for package descriptions"],
389
- ["--apply", "write the updated table (default is a dry run)"],
390
- ["--yes", "skip the confirmation question"]
391
- ]
392
- });
393
- /** The dry-run/confirm note per verb — `audit` never writes, so its note says so instead. */
394
- var VERB_DRY_RUN_NOTE = Object.freeze({
395
- new: "dry run by default — add --apply to write the files, --yes to skip the question",
396
- pull: "dry run by default — add --apply to write the refreshed files, --yes to skip the question",
397
- audit: "read-only — audit never writes; pass --live to also check upstream freshness",
398
- repair: "dry run by default — add --apply to write, --yes to skip the question",
399
- fleet: "dry run by default — add --apply to write across every package, --yes to skip the question",
400
- catalog: "dry run by default — add --apply to write, --yes to skip the question"
401
- });
402
- /** One concrete example invocation per verb — `verbHelp`'s closing line. */
403
- var VERB_EXAMPLE = Object.freeze({
404
- new: "example: scaffold new widget --surfaces core,server --apply",
405
- pull: "example: scaffold pull --apply",
406
- audit: "example: scaffold audit --live",
407
- repair: "example: scaffold repair --apply",
408
- fleet: "example: scaffold fleet --apply --yes",
409
- catalog: "example: scaffold catalog --apply"
410
- });
411
- /** The full reference help tier — every verb's summary + flags, the safety banner, and the exit-code table. */
461
+ /** Render the full command reference. */
412
462
  function fullHelp() {
413
- const verbLines = KNOWN_VERBS.map((verb) => ` ${verb} ${VERB_FLAGS[verb]}\n ${VERB_SUMMARY[verb]}`);
414
- const exitLines = EXIT_CODES.map(([code, meaning]) => ` ${code} ${meaning}`);
463
+ const verbs = KNOWN_VERBS.map((verb) => ` ${verb} ${VERB_FLAGS[verb]}\n ${VERB_SUMMARY[verb]}`);
464
+ const exits = EXIT_CODES.map(([code, meaning]) => ` ${code} ${meaning}`);
415
465
  return [
416
466
  "scaffold <verb> [options]",
417
467
  "",
418
- ...verbLines,
468
+ ...verbs,
419
469
  "",
420
470
  SAFETY_BANNER,
421
471
  "",
422
472
  "exit codes:",
423
- ...exitLines
473
+ ...exits
424
474
  ].join("\n");
425
475
  }
426
- /** One verb's help section — summary, dry-run note, one line per flag, and a concrete example. */
476
+ /** Render one command's help reference. */
427
477
  function verbHelp(verb) {
428
- const flagLines = VERB_FLAG_HELP[verb].map(([flag, meaning]) => ` ${flag.padEnd(20)}${meaning}`);
478
+ const flags = VERB_FLAG_HELP[verb].map(([flag, meaning]) => ` ${flag.padEnd(20)}${meaning}`);
429
479
  return [
430
480
  `scaffold ${verb} ${VERB_FLAGS[verb]}`,
431
481
  "",
432
482
  VERB_SUMMARY[verb],
433
483
  VERB_DRY_RUN_NOTE[verb],
434
484
  "",
435
- ...flagLines,
485
+ ...flags,
436
486
  "",
437
487
  VERB_EXAMPLE[verb]
438
488
  ].join("\n");
439
489
  }
440
- /** Levenshtein edit distance between two strings — the did-you-mean primitive. */
441
- function editDistance(a, b) {
442
- const rows = a.length + 1;
443
- const cols = b.length + 1;
444
- const table = Array.from({ length: rows }, () => new Array(cols).fill(0));
445
- for (let i = 0; i < rows; i += 1) table[i][0] = i;
446
- for (let j = 0; j < cols; j += 1) table[0][j] = j;
447
- for (let i = 1; i < rows; i += 1) for (let j = 1; j < cols; j += 1) {
448
- const cost = a[i - 1] === b[j - 1] ? 0 : 1;
449
- table[i][j] = Math.min(table[i - 1][j] + 1, table[i][j - 1] + 1, table[i - 1][j - 1] + cost);
490
+ /** Compute Levenshtein edit distance. */
491
+ function editDistance(left, right) {
492
+ const rows = left.length + 1;
493
+ const columns = right.length + 1;
494
+ const table = new Uint32Array(rows * columns);
495
+ for (let row = 0; row < rows; row += 1) table[row * columns] = row;
496
+ for (let column = 0; column < columns; column += 1) table[column] = column;
497
+ for (let row = 1; row < rows; row += 1) for (let column = 1; column < columns; column += 1) {
498
+ const cost = left[row - 1] === right[column - 1] ? 0 : 1;
499
+ const index = row * columns + column;
500
+ table[index] = Math.min((table[index - columns] ?? 0) + 1, (table[index - 1] ?? 0) + 1, (table[index - columns - 1] ?? 0) + cost);
450
501
  }
451
- return table[rows - 1][cols - 1];
502
+ return table[rows * columns - 1] ?? 0;
452
503
  }
453
- /** The nearest candidate to `input` by `editDistance` — `undefined` when `set` is empty. */
504
+ /** Find the nearest string by edit distance. */
454
505
  function nearest(input, set) {
455
506
  let best;
456
- let bestDistance = Number.POSITIVE_INFINITY;
507
+ let distance = Number.POSITIVE_INFINITY;
457
508
  for (const candidate of set) {
458
- const distance = editDistance(input, candidate);
459
- if (distance < bestDistance) {
460
- bestDistance = distance;
509
+ const candidateDistance = editDistance(input, candidate);
510
+ if (candidateDistance < distance) {
511
+ distance = candidateDistance;
461
512
  best = candidate;
462
513
  }
463
514
  }
464
515
  return best;
465
516
  }
466
- /** Retired verb names redirected to their replacement checked before fuzzy matching in `didYouMean`. */
467
- var RETIRED_VERBS = Object.freeze({
468
- sync: "pull",
469
- mirror: "fleet"
470
- });
471
- /** The unknown-command message — a retired-verb redirect when recognized, otherwise the nearest `KNOWN_VERBS` guess. */
517
+ /** Render an unknown command with a nearest-command hint. */
472
518
  function didYouMean(command) {
473
- const retired = RETIRED_VERBS[command];
474
- if (retired !== void 0) return `'${command}' has been renameduse 'scaffold ${retired}'`;
475
- const guess = nearest(command, [...KNOWN_VERBS]);
476
- return guess === void 0 ? `unknown command "${command}"` : `unknown command "${command}" — did you mean "${guess}"?`;
519
+ const suggestion = nearest(command, KNOWN_VERBS);
520
+ return suggestion === void 0 ? `unknown command "${command}"` : `unknown command "${command}" did you mean "${suggestion}"?`;
477
521
  }
478
- /** One JSON error envelope the single shape every `--json` failure returns. */
479
- function errorEnvelope(code, message) {
480
- return { error: {
481
- code,
482
- message
483
- } };
522
+ /** Render one prune preview line per exact path. */
523
+ function prunePreview(paths) {
524
+ return paths.map((path) => ` delete ${path}`);
484
525
  }
485
- /** `new --json`'s value the plan summary, deterministic key order. */
486
- function newJson(summary, applied) {
487
- return {
488
- name: summary.name,
489
- surfaces: summary.surfaces,
490
- host: summary.host,
491
- template: summary.template,
492
- computed: summary.computed,
493
- applied
494
- };
526
+ /** Render non-terminal guidance for missing input. */
527
+ function missingInput(input, verb) {
528
+ return `missing ${input} — pass it as a flag/argument, or run 'scaffold ${verb}' on a terminal to be guided`;
495
529
  }
496
- /** `pull --json`'s value the `SyncReport` verbatim (already deterministic + JSON-safe). */
497
- function pullJson(report) {
498
- return report;
530
+ /** Render an invalid package name. */
531
+ function invalidName(name, pattern) {
532
+ return `Package name "${name}" must match ${pattern}`;
499
533
  }
500
- /** `audit --json`'s value the `Audit` verbatim. */
501
- function auditJson(audit) {
502
- return audit;
534
+ /** Render dependencies whose latest versions could not be resolved. */
535
+ function unresolvedVersion(names) {
536
+ return `could not resolve the latest version for ${names.map((name) => `"${name}"`).join(", ")} — check the name or pass name@range`;
503
537
  }
504
- /** `repair --json`'s value the `Audit` plus the `MaterializeResult` when `--apply` ran. */
505
- function repairJson(audit, result) {
506
- return result === void 0 ? audit : {
507
- ...audit,
508
- result
509
- };
510
- }
511
- /** `fleet --json`'s value — a top-level ARRAY of per-repo objects, one entry per package. */
512
- function fleetJson(entries) {
513
- return entries;
514
- }
515
- /** `catalog --json`'s value — the entries, drift flag, and optional shrink count. */
516
- function catalogJson(entries, drifted, shrink) {
517
- return shrink === void 0 ? {
518
- entries,
519
- drift: drifted
520
- } : {
521
- entries,
522
- drift: drifted,
523
- shrink
524
- };
525
- }
526
- /** `enabled` for `createStyler` — off under `NO_COLOR` or when the sink is not a TTY (both read by the caller). */
527
- function chooseStyler(sinkIsTTY, noColor) {
528
- return createStyler({ enabled: !noColor && sinkIsTTY });
529
- }
530
- /** Whether a long-running step should animate a spinner (TTY) or fall back to a one-line `status` (piped/non-TTY). */
531
- function shouldSpin(sinkIsTTY) {
532
- return sinkIsTTY;
533
- }
534
- /** One line per exact relative path that WOULD be deleted — printed before the prune confirm. */
535
- function prunePreview(paths) {
536
- return paths.map((path) => ` delete ${path}`);
537
- }
538
- /** Printed when the prune scan finds nothing to delete. */
539
- var PRUNE_EMPTY = "no unexpected files to delete";
540
- /** Printed on a non-TTY session when the prune question cannot be asked. */
541
- function pruneSkipped() {
542
- return "prune skipped — not a terminal; add --apply (or --yes) to delete non-interactively";
543
- }
544
- /** Non-TTY usage-error guidance for a missing required input. */
545
- function missingInput(what, verb) {
546
- return `missing ${what} — pass it as a flag/argument, or run 'scaffold ${verb}' on a terminal to be guided`;
547
- }
548
- /** Printed when `audit` cannot establish the template source for the unexpected-file scan — the audit degrades to the un-scanned findings instead of crashing. */
549
- function scanSkipped() {
550
- return "unexpected-file scanning skipped — couldn't establish the template source";
551
- }
552
- /** Usage-error message for a `new` package name that fails `PACKAGE_NAME_PATTERN` — same shape the interactive prompt enforces. */
553
- function invalidName(name, pattern) {
554
- return `Package name "${name}" must match ${pattern}`;
555
- }
556
- /** `new`'s hard failure when `sync.versions` cannot resolve a latest version for one or more `--deps` names — names every unresolved package plainly so a `^` (unresolved-latest) range can never be silently written. */
557
- function unresolvedVersion(names) {
558
- return `could not resolve the latest version for ${names.map((name) => `"${name}"`).join(", ")} — check the name or pass name@range`;
559
- }
560
- /** Printed when every finding is `generated` drift — repair does not touch generated files. */
538
+ /** Render drift that belongs to generated artifacts. */
561
539
  function generatedNote(count) {
562
540
  return `${countPart(count, "finding")} in generated files — these are regenerated, not hand-edited; repair does not touch them`;
563
541
  }
564
- /** `audit --live`'s freshness summary line. */
542
+ /** Render live dependency freshness tallies. */
565
543
  function auditLiveNote(current, behind, failed) {
566
544
  return `live: ${countPart(current, "current")}, ${countPart(behind, "behind")}, ${countPart(failed, "failed")}`;
567
545
  }
568
- /** Translated vocabulary for whether the audit compared file contents or only file names, for template-owned files. */
546
+ /** Render whether an audit compared content or presence. */
569
547
  function comparisonLine(aware) {
570
- return aware ? "comparing: file contents for template-owned files" : "comparing: file names only for template-owned files (no vendored source found)";
548
+ return aware ? "comparing: file contents for host-owned files" : "comparing: file names only for host-owned files (no vendored source found)";
571
549
  }
572
- /** `fleet`'s ci.yml note — each package customizes its own CI, so `fleet` leaves it unchanged. */
573
- function fleetCiSkipped() {
574
- return "ci.yml: left unchanged — each package customizes its own CI; run 'scaffold repair --apply' inside that package to update it";
575
- }
576
- /** `catalog`'s verdict line — clean or drifted. */
550
+ /** Render catalog's final verdict. */
577
551
  function catalogVerdict(clean) {
578
552
  return clean ? "catalog: clean" : "catalog: drifted — pass --apply to write";
579
553
  }
580
- //#endregion
581
- //#region src/bin/scaffold.ts
582
- var sink = createServerSink();
583
- var sinkIsTTY = process.stdout.isTTY === true;
584
- var styler = chooseStyler(sinkIsTTY, process.env.NO_COLOR !== void 0);
585
- var reporter = createReporter({
586
- sink,
587
- width: sink.columns,
588
- styler
589
- });
590
- /**
591
- * Widen Node's default trusted-issuer set to include the OS certificate
592
- * store, so `fetch` behind a corporate TLS-inspecting proxy behaves like npm
593
- * (`cafile`) and browsers (OS trust store) instead of failing with
594
- * `UNABLE_TO_GET_ISSUER_CERT_LOCALLY` against Node's bundled CA list alone.
595
- * Feature-detected (`tls.getCACertificates` / `tls.setDefaultCACertificates`
596
- * ship on Node ≈22.16+/24.5+; this package's floor is `>=22`) and wrapped in
597
- * try/catch — any failure is a silent no-op, never a crash. This only ADDS
598
- * trusted issuers; it never touches `rejectUnauthorized` or
599
- * `NODE_TLS_REJECT_UNAUTHORIZED`, so certificate verification stays on.
600
- */
601
- function trustSystemCertificates() {
602
- if (typeof tls.getCACertificates !== "function" || typeof tls.setDefaultCACertificates !== "function") return;
603
- try {
604
- const merged = /* @__PURE__ */ new Set([...tls.getCACertificates("default"), ...tls.getCACertificates("system")]);
605
- tls.setDefaultCACertificates([...merged]);
606
- } catch {}
607
- }
608
- /**
609
- * The one sentinel that unwinds the whole command dispatch to a chosen exit
610
- * code (H4/Windows) — thrown instead of calling `process.exit`, so every
611
- * `finally` between the throw site and the top-level driver still runs
612
- * (entity teardown drains naturally instead of racing process teardown).
613
- * Caught exactly once, at the bottom of this file.
614
- */
615
- var CliExit = class extends Error {
616
- code;
617
- constructor(code) {
618
- super(`cli-exit:${String(code)}`);
619
- this.code = code;
620
- }
621
- };
622
- /** Halt command dispatch with `code` (H4) — never `process.exit`; unwinds through every `finally` first. */
623
- function halt(code) {
624
- throw new CliExit(code);
625
- }
626
- /** Render a caught error as a clean one-line message — a `ScaffoldError`'s code, or a bare message otherwise. */
627
- function describe(error) {
554
+ /** Render a caught error without leaking implementation detail. */
555
+ function describeError(error) {
628
556
  if (isScaffoldError(error)) return `[${error.code}] ${error.message}`;
629
557
  return error instanceof Error ? error.message : "unknown error";
630
558
  }
631
- /** Write ONE machine-readable JSON value to stdout the entire `--json` output contract. */
632
- function writeJson(value) {
633
- process.stdout.write(`${JSON.stringify(value)}\n`);
634
- }
635
- /**
636
- * Whether the current invocation carries `--json` — set once `parseArguments`
637
- * has run (`main`'s first act), so the OUTERMOST catch (bottom of this file)
638
- * can still honor `--json` for an error that escapes every verb runner's own
639
- * handling, emitting exactly one JSON error envelope instead of prose.
640
- */
641
- var sessionJson = false;
642
- /** A general operation failure (H1: exit 1) — a prose status line, or the one JSON error envelope under `--json`. */
643
- function fail(message, json) {
644
- if (json) writeJson(errorEnvelope("ERROR", message));
645
- else reporter.status("error", message);
646
- halt(1);
647
- }
648
- /**
649
- * A general operation failure FROM A CAUGHT ERROR (H1: exit 1) — the real
650
- * `ScaffoldError` code when available ('ERROR' last resort), prose (via
651
- * `describe`, which still carries the bracketed code for a human reader) or
652
- * the one JSON error envelope under `--json` (code and message kept
653
- * SEPARATE — never double-encoding the code into the message text).
654
- */
655
- function failError(error, json) {
656
- if (json) writeJson(errorEnvelope(isScaffoldError(error) ? error.code : "ERROR", isScaffoldError(error) ? error.message : describe(error)));
657
- else reporter.status("error", describe(error));
658
- halt(1);
659
- }
660
- /** A usage error (bad flag value, unknown verb — exit 2) — stderr prose, or the one JSON error envelope under `--json`. */
661
- function usageFail(message, json) {
662
- if (json) writeJson(errorEnvelope("USAGE", message));
663
- else process.stderr.write(`${message}\n`);
664
- halt(2);
665
- }
666
- function resolveReal(path) {
667
- if (existsSync(path)) return realpathSync(path);
668
- const parent = dirname(path);
669
- if (parent === path) return path;
670
- return join(resolveReal(parent), relative(parent, path));
671
- }
672
- /**
673
- * Confine a WRITE destination to the current working directory (global-CLI
674
- * safety): `new`'s resolved target, `--target` on pull/audit/repair/catalog,
675
- * and `fleet`'s always-cwd root all pass through here before use. A
676
- * READ-ONLY source (`--from`) is exempt — sibling-repo sourcing from outside
677
- * the cwd is legitimate. Equal to the cwd or nested beneath it passes;
678
- * anything else is a coded `INVALID` failure (AGENTS §12), never a silent clamp.
679
- *
680
- * @returns The resolved (non-realpath'd) absolute path, for use as the
681
- * verb's destination.
682
- */
683
- function containDestination(candidate) {
684
- const resolvedCwd = resolveReal(resolve(process.cwd()));
685
- const resolvedCandidate = resolveReal(resolve(candidate));
686
- if (resolvedCandidate !== resolvedCwd && !resolvedCandidate.startsWith(resolvedCwd + sep)) throw new ScaffoldError("INVALID", `Target "${candidate}" escapes the working directory — run scaffold from the directory you want to write beneath.`, { path: candidate });
687
- return resolve(candidate);
688
- }
689
- /** `containDestination`, halting (H1/`fail`) on a coded escape instead of throwing — the shared entry every runner's target resolution goes through. */
690
- function containOrFail(candidate, json) {
691
- try {
692
- return containDestination(candidate);
693
- } catch (error) {
694
- failError(error, json);
695
- }
559
+ /** Resolve and confine one write destination to its invocation root. */
560
+ function containDestination(root, candidate) {
561
+ if (!isFilesystemPath(root) || !isFilesystemPath(candidate)) throw new ScaffoldError("INVALID", "Target paths must not contain control characters");
562
+ const contained = attempt(() => resolvePhysicalPath(root, candidate, "INVALID", "working directory"));
563
+ if (contained.success) return contained.value;
564
+ if (isScaffoldError(contained.error) && contained.error.code === "INVALID") throw new ScaffoldError("INVALID", `Target "${candidate}" is outside or traverses a linked parent of the working directory run scaffold from the physical directory you want to write beneath.`, { path: candidate });
565
+ throw contained.error;
696
566
  }
697
- /** Compile `spec` and unwrap its `plan`, halting (H1/`fail`) with the joined open-question text when compilation could not resolve one — the shared entry every runner's compile step goes through. */
698
- function compileOrFail(spec, json) {
699
- const compiler = createCompiler();
700
- try {
701
- const scaffolding = compiler.compile(spec);
702
- if (!scaffolding.plan) fail(scaffolding.questions.map((question) => question.text).join("; "), json);
703
- return scaffolding.plan;
704
- } finally {
705
- compiler.destroy();
706
- }
567
+ /** Return one dependency-token issue, or absence when it is valid. */
568
+ function orkestrelTokenIssue(normalized, catalog) {
569
+ if (catalog === void 0) return DEPENDENCY_NAME_PATTERN.test(normalized) ? void 0 : unknownOrkestrelToken(normalized, void 0);
570
+ if (catalog.includes(normalized)) return void 0;
571
+ return unknownOrkestrelToken(normalized, nearest(normalized, catalog));
707
572
  }
708
- /** `node:util`'s strict `parseArgs`, isolated so its throw on an unknown/malformed flag is catchable (H3). */
709
- function parseArguments() {
710
- const args = process.argv.slice(2);
711
- if (args[0] === "--") args.shift();
573
+ //#endregion
574
+ //#region src/bin/parsers.ts
575
+ /** Parse a strict command-line argument vector. */
576
+ function parseArguments(argv) {
577
+ if (argv.some((argument) => !isTerminalText(argument))) throw new ScaffoldError("INVALID", "Command arguments must not contain control characters");
712
578
  return parseArgs({
713
- args,
579
+ args: argv[0] === "--" ? argv.slice(1) : [...argv],
714
580
  allowPositionals: true,
715
581
  options: {
716
- surfaces: { type: "string" },
582
+ src: { type: "string" },
583
+ app: { type: "string" },
717
584
  deps: { type: "string" },
718
585
  groups: { type: "string" },
719
586
  target: { type: "string" },
@@ -757,783 +624,878 @@ function parseArguments() {
757
624
  }
758
625
  });
759
626
  }
760
- /** Narrow a positional command to the closed `Verb` vocabulary (render.ts's `KNOWN_VERBS`). */
761
- function isVerb(value) {
762
- return KNOWN_VERBS.some((verb) => verb === value);
763
- }
764
- /**
765
- * Best-effort `hydratePlan` — used by `audit` / `repair` / `fleet` so a
766
- * missing DEFAULT vendored-source root degrades to presence-only auditing
767
- * instead of failing the verb. An EXPLICITLY-passed `--from` that does not
768
- * resolve to a usable directory is NOT downgraded silently — that is a coded
769
- * `TARGET` failure (M1), since the caller named that source on purpose.
770
- */
771
- function hydrateBestEffort(plan, host, explicit) {
772
- if (!existsSync(host)) {
773
- if (explicit) throw new ScaffoldError("TARGET", `--from does not resolve to a directory: ${host}`, { host });
774
- return {
775
- plan,
776
- aware: false
777
- };
778
- }
779
- try {
780
- return {
781
- plan: hydratePlan(plan, host),
782
- aware: true
783
- };
784
- } catch (error) {
785
- if (isScaffoldError(error) && error.code === "TARGET") return {
786
- plan,
787
- aware: false
788
- };
789
- throw error;
790
- }
791
- }
792
- /**
793
- * Merge a `pruneTargets` scan into `audit` as `foreign` findings — pure
794
- * object-spread composition in the BIN only (`src/core`'s `diffPlan` is never
795
- * modified/reimplemented). Every unexpected path becomes one
796
- * `'orchestration'`-group `foreign` finding (the `Group` every `PRUNE_DIRECTORIES`
797
- * entry — `.claude/agents`, `scripts` — belongs to); any scan hit makes the
798
- * merged audit unclean, so an "unexpected file" is honestly counted as drift
799
- * (exit 1) instead of the structurally-always-zero `diffPlan.foreign`.
800
- */
801
- function withForeignScan(audit, target, host) {
802
- const paths = pruneTargets(target, host);
803
- if (paths.length === 0) return audit;
804
- const findings = paths.map((path) => ({
805
- path,
806
- group: "orchestration",
807
- drift: "foreign"
808
- }));
809
- return {
810
- ...audit,
811
- clean: false,
812
- foreign: audit.foreign + paths.length,
813
- findings: [...audit.findings, ...findings]
814
- };
815
- }
816
- /**
817
- * `withForeignScan`, degrading instead of crashing (F3): `pruneTargets` throws
818
- * a coded `TARGET` failure when a prune directory exists under `target` but
819
- * `host` cannot positively establish its allowlist (`vendoredPruneSet`'s
820
- * fail-closed contract) — an audit should report the un-scanned findings and
821
- * say scanning was skipped, never crash on this alone (`runFleet` already
822
- * shields its own per-repo audit the same way).
823
- */
824
- function withForeignScanSafe(audit, target, host) {
825
- try {
826
- return {
827
- audit: withForeignScan(audit, target, host),
828
- skipped: false
829
- };
830
- } catch (error) {
831
- if (isScaffoldError(error) && error.code === "TARGET") return {
832
- audit,
833
- skipped: true
834
- };
835
- throw error;
836
- }
837
- }
838
- /**
839
- * `repair`'s own scope is host-only, but the caller compiles the FULL plan anyway — diff it too
840
- * (cheap, local, no network) so a clean host verdict can point at drift OUTSIDE repair's reach.
841
- * The count feeds render.ts's `scopeNote`.
842
- */
843
- function repairOutsideCount(compiled, target) {
844
- const full = diffPlan(compiled, readTarget(target, compiled.artifacts.map((artifact) => artifact.path)));
845
- return full.drifted + full.missing + full.foreign;
846
- }
847
- /** One `fleet --json` entry — `undefined` counts (a failed repo never got an audit) fall back to zero. */
848
- function fleetEntry(name, counts, failed) {
849
- return {
850
- name,
851
- drifted: counts?.drifted ?? 0,
852
- missing: counts?.missing ?? 0,
853
- foreign: counts?.foreign ?? 0,
854
- failed
855
- };
856
- }
857
- /** Reject a cancelled prompt (ctrl-c) with the shared `CANCELLED_MESSAGE` — exit 1, nothing written. */
858
- async function guarded(promise) {
859
- try {
860
- return await promise;
861
- } catch (error) {
862
- if (isTerminalError(error) && error.code === "CANCEL") {
863
- reporter.line(CANCELLED_MESSAGE);
864
- halt(1);
865
- }
866
- throw error;
867
- }
868
- }
869
- /**
870
- * The shared write-confirmation gate every verb calls before it touches disk.
871
- * `--apply` writes without asking; `--json` (without `--apply`) is a pure
872
- * dry-run and NEVER prompts; `--yes` auto-answers yes; otherwise a real
873
- * confirm with `default: false` (EOF on stdin resolves to the default).
874
- */
875
- async function resolveApply(terminal, message, values, json) {
876
- if (values.apply) return true;
877
- if (json) return false;
878
- if (values.yes) return true;
879
- return guarded(terminal.confirm({
880
- message,
881
- default: false
882
- }));
883
- }
884
- /**
885
- * The SECOND, separate confirm for `--prune`-eligible deletions — never
886
- * bundled into `resolveApply`'s question. `--yes` only auto-answers this
887
- * when `--prune` was also passed (it never enables pruning by itself).
888
- */
889
- async function resolvePrune(terminal, message, values, json) {
890
- if (!values.prune) return false;
891
- if (values.apply) return true;
892
- if (json) return false;
893
- if (values.yes) return true;
894
- if (!sinkIsTTY) {
895
- reporter.line(pruneSkipped());
896
- return false;
897
- }
898
- return guarded(terminal.confirm({
899
- message,
900
- default: false
901
- }));
902
- }
903
- /** A spinner for a long-running write step — `undefined` under `--json` or off a TTY sink (render.ts's `shouldSpin`). */
904
- function createSpinnerMaybe(message, json) {
905
- return json || !shouldSpin(sinkIsTTY) ? void 0 : createSpinner({
906
- message,
907
- sink,
908
- styler
909
- });
910
- }
911
- /** Announce a successful write — the spinner's own success line, or a plain `reporter.status` without one (never under `--json`). */
912
- function announceApply(spinner, json, message) {
913
- if (spinner) spinner.success(message);
914
- else if (!json) reporter.status("success", message);
915
- }
916
- /** Announce a failed write and halt(1) — the spinner's own failure line (if any), then the shared `fail`. */
917
- function announceFailure(spinner, json, error) {
918
- const message = describe(error);
919
- if (spinner) spinner.failure(message);
920
- fail(message, json);
921
- }
922
- /** Split a comma-separated token list, trimming and dropping empties — the parse `new`'s dependency prompt goes through. */
627
+ /** Split a comma-separated token list, trimming and dropping empty entries. */
923
628
  function splitTokens(raw) {
924
629
  return raw.split(",").map((token) => token.trim()).filter((token) => token.length > 0);
925
630
  }
926
- /** Normalize a Q1 token to a full `@orkestrel/<name>` — an already-prefixed token passes through unchanged. */
631
+ /** Normalize an Orkestrel dependency token to its full package name. */
927
632
  function normalizeOrkestrelToken(token) {
928
633
  return token.startsWith("@orkestrel/") ? token : `@orkestrel/${token}`;
929
634
  }
930
635
  /**
931
- * Best-effort vendored `@orkestrel` catalog names, resolved via `host`
932
- * (`hostRoot()`, or the active `--from` override) through the host manifest
933
- * `undefined` when the catalog cannot be established (a missing/unreadable
934
- * manifest, no `.claude/agents/orkestrel.md` entry, or any other failure),
935
- * degrading Q1 to shape-only validation instead of blocking on it.
636
+ * Parse and resolve a pull dependency selection against the target manifest.
637
+ *
638
+ * @param raw - The comma-separated full package names, or `undefined` for all.
639
+ * @param declared - The target's declared Orkestrel dependencies.
640
+ * @returns `undefined` for all dependencies, otherwise the exact selected records.
641
+ * @throws `ScaffoldError('INVALID')` for empty, malformed, repeated, or undeclared names.
936
642
  */
937
- function resolveCatalogNames(host) {
938
- try {
939
- const full = locateHostSource(readHostManifest(host), ".claude/agents/orkestrel.md", host);
940
- if (full === void 0 || !existsSync(full)) return void 0;
941
- return catalogNames(readFileSync(full, "utf8"));
942
- } catch {
943
- return;
944
- }
643
+ function parsePullDependencies(raw, declared) {
644
+ if (raw === void 0) return void 0;
645
+ const names = raw.split(",").map((name) => name.trim());
646
+ if (names.length === 0 || names.some((name) => !DEPENDENCY_NAME_PATTERN.test(name)) || new Set(names).size !== names.length) throw new ScaffoldError("INVALID", "Pull dependencies must be unique, comma-separated @orkestrel/* package names");
647
+ const selected = declared.filter((dependency) => names.includes(dependency.name));
648
+ const found = new Set(selected.map((dependency) => dependency.name));
649
+ const missing = names.filter((name) => !found.has(name));
650
+ if (missing.length > 0) throw new ScaffoldError("INVALID", `Pull dependencies are not declared by the target: ${missing.join(", ")}`, { dependencies: missing });
651
+ return selected;
945
652
  }
946
- /** One Q1 token's issue against `catalog` (`undefined` = valid) — shape-only (`DEPENDENCY_NAME_PATTERN`) when `catalog` itself could not be resolved. */
947
- function orkestrelTokenIssue(normalized, catalog) {
948
- if (catalog === void 0) return DEPENDENCY_NAME_PATTERN.test(normalized) ? void 0 : unknownOrkestrelToken(normalized, void 0);
949
- if (catalog.includes(normalized)) return void 0;
950
- return unknownOrkestrelToken(normalized, nearest(normalized, catalog));
653
+ //#endregion
654
+ //#region src/bin/validators.ts
655
+ /** Narrow a positional command to the command-line interface's vocabulary. */
656
+ function isVerb(value) {
657
+ return KNOWN_VERBS.some((verb) => verb === value);
951
658
  }
952
- /** Q1 (TTY only) — `@orkestrel` short-name deps, re-asking on any unresolved token until the input is clean or empty. */
953
- async function promptOrkestrelDeps(terminal, catalog) {
954
- for (;;) {
955
- const tokens = splitTokens(await guarded(terminal.input({
956
- message: orkestrelDepsPrompt(),
957
- default: ""
958
- })));
959
- if (tokens.length === 0) return [];
960
- const normalized = tokens.map(normalizeOrkestrelToken);
961
- const issue = normalized.map((token) => orkestrelTokenIssue(token, catalog)).find((message) => message !== void 0);
962
- if (issue === void 0) return normalized;
963
- reporter.line(issue);
659
+ //#endregion
660
+ //#region src/bin/CLI.ts
661
+ /** Stateful command-line orchestration and its process boundary. */
662
+ var CLI = class {
663
+ #sink = createServerSink();
664
+ #tty = process.stdout.isTTY === true;
665
+ #styler = createStyler({ enabled: process.env.NO_COLOR === void 0 && this.#tty });
666
+ #reporter = createReporter({
667
+ sink: this.#sink,
668
+ width: this.#sink.columns,
669
+ styler: this.#styler
670
+ });
671
+ #json = false;
672
+ /** Execute one command-line argument vector. */
673
+ async run(argv) {
674
+ this.#trust();
675
+ try {
676
+ await this.#dispatch(argv);
677
+ } catch (error) {
678
+ if (error instanceof CLIExitError) process.exitCode = error.code;
679
+ else if (this.#json) {
680
+ const code = isScaffoldError(error) ? error.code : "ERROR";
681
+ const message = isScaffoldError(error) ? error.message : describeError(error);
682
+ this.#write(errorEnvelopeOf(code, message));
683
+ process.exitCode = 1;
684
+ } else {
685
+ this.#reporter.status("error", describeError(error));
686
+ process.exitCode = 1;
687
+ }
688
+ }
964
689
  }
965
- }
966
- /** `scaffold new` scaffold a package into `./<name>` (or `--target`). */
967
- async function runNew(values, argument, json) {
968
- const terminal = createTerminal();
969
- let name;
970
- if (argument !== void 0) name = argument;
971
- else if (json) usageFail("a package name is required with --json", json);
972
- else if (!sinkIsTTY) usageFail(missingInput("a package name", "new"), json);
973
- else name = await guarded(terminal.input({
974
- message: "Package name",
975
- validate: { pattern: NAME_PATTERN.source }
976
- }));
977
- if (!NAME_PATTERN.test(name)) usageFail(invalidName(name, NAME_PATTERN.source), json);
978
- let surfaceInput;
979
- if (values.surfaces !== void 0) surfaceInput = values.surfaces.split(",");
980
- else if (json) usageFail("--surfaces is required with --json", json);
981
- else if (!sinkIsTTY) usageFail(missingInput("--surfaces", "new"), json);
982
- else surfaceInput = await guarded(terminal.checkbox({
983
- message: "Surfaces",
984
- choices: surfaceChoices(),
985
- min: 1
986
- }));
987
- const unrecognizedSurface = surfaceInput.filter((candidate) => !SURFACES.some((surface) => surface === candidate));
988
- if (unrecognizedSurface.length > 0) usageFail(`Surface "${unrecognizedSurface.join("\", \"")}" is not recognized`, json);
989
- const surfaces = SURFACES.filter((surface) => surfaceInput.includes(surface));
990
- const destination = containOrFail(values.target ?? `./${name}`, json);
991
- let depNames;
992
- if (values.deps !== void 0) {
993
- depNames = values.deps.split(",").filter((depName) => depName.length > 0);
994
- const badDep = depNames.find((depName) => !DEPENDENCY_NAME_PATTERN.test(depName));
995
- if (badDep !== void 0) usageFail(`Dependency name "${badDep}" must match ${DEPENDENCY_NAME_PATTERN.source}`, json);
996
- } else if (json || !sinkIsTTY) depNames = [];
997
- else {
998
- const catalog = resolveCatalogNames(values.from?.[0] ?? hostRoot());
999
- if (catalog === void 0) reporter.line(catalogUnresolvedNote());
1000
- depNames = await promptOrkestrelDeps(terminal, catalog);
690
+ /**
691
+ * Widen Node's default trusted-issuer set to include the OS certificate
692
+ * store, so `fetch` behind a corporate TLS-inspecting proxy behaves like npm
693
+ * (`cafile`) and browsers (OS trust store) instead of failing with
694
+ * `UNABLE_TO_GET_ISSUER_CERT_LOCALLY` against Node's bundled CA list alone.
695
+ * Feature-detected (`tls.getCACertificates` / `tls.setDefaultCACertificates`
696
+ * ship on Node ≈22.16+/24.5+; this package's floor is `>=22.12.0`) and captured
697
+ * through `attempt` — any failure is a silent no-op, never a crash. This only ADDS
698
+ * trusted issuers; it never touches `rejectUnauthorized` or
699
+ * `NODE_TLS_REJECT_UNAUTHORIZED`, so certificate verification stays on.
700
+ */
701
+ #trust() {
702
+ if (typeof tls.getCACertificates !== "function" || typeof tls.setDefaultCACertificates !== "function") return;
703
+ attempt(() => {
704
+ const merged = /* @__PURE__ */ new Set([...tls.getCACertificates("default"), ...tls.getCACertificates("system")]);
705
+ tls.setDefaultCACertificates([...merged]);
706
+ });
1001
707
  }
1002
- const sync = createSync();
1003
- let versions;
1004
- try {
1005
- versions = await sync.versions(depNames.map((depName) => dependency(depName, "*")));
1006
- } finally {
1007
- sync.destroy();
708
+ /** Write ONE machine-readable JSON value to stdout — the entire `--json` output contract. */
709
+ #write(value) {
710
+ process.stdout.write(`${JSON.stringify(value)}\n`);
1008
711
  }
1009
- const unresolved = versions.filter((version) => version.freshness !== "current" && version.freshness !== "behind" || version.latest === "").map((version) => version.name);
1010
- if (unresolved.length > 0) fail(unresolvedVersion(unresolved), json);
1011
- const deps = versions.map((version) => dependency(version.name, `^${version.latest}`));
1012
- const plan = compileOrFail(blueprint(name, {
1013
- surfaces,
1014
- dependencies: deps
1015
- }), json);
1016
- const summary = planToSummary(plan);
1017
- if (!json) {
1018
- reporter.section("Plan");
1019
- reporter.table(newPlanTable(summary));
1020
- reporter.line(newPlanPreview(name));
712
+ /** Report a general operation failure with exit 1 as prose or one JSON error envelope. */
713
+ #fail(message, json) {
714
+ if (json) this.#write(errorEnvelopeOf("ERROR", message));
715
+ else this.#reporter.status("error", message);
716
+ throw new CLIExitError(1);
1021
717
  }
1022
- if (!await resolveApply(terminal, applyConfirmMessage(summary.host + summary.template + summary.computed), values, json)) {
1023
- if (json) writeJson(newJson(summary, false));
1024
- else reporter.line(NEW_DRY_RUN_NOTE);
1025
- process.exitCode = 0;
1026
- return;
718
+ /**
719
+ * A general operation failure from a caught error — the real
720
+ * `ScaffoldError` code when available ('ERROR' last resort), prose (via
721
+ * `describe`, which still carries the bracketed code for a human reader) or
722
+ * the one JSON error envelope under `--json` (code and message kept
723
+ * SEPARATE — never double-encoding the code into the message text).
724
+ */
725
+ #error(error, json) {
726
+ if (json) {
727
+ const code = isScaffoldError(error) ? error.code : "ERROR";
728
+ const message = isScaffoldError(error) ? error.message : describeError(error);
729
+ this.#write(errorEnvelopeOf(code, message));
730
+ } else this.#reporter.status("error", describeError(error));
731
+ throw new CLIExitError(1);
1027
732
  }
1028
- const spinner = createSpinnerMaybe("materializing", json);
1029
- spinner?.start();
1030
- const materializer = createMaterializer({ host: values.from?.[0] });
1031
- try {
1032
- const result = materializer.materialize(plan, destination);
1033
- const count = result.written.length + result.copied.length;
1034
- if (json) writeJson(newJson(summary, true));
1035
- else announceApply(spinner, json, newApplySuccess(count, name));
1036
- } catch (error) {
1037
- announceFailure(spinner, json, error);
1038
- } finally {
1039
- materializer.destroy();
733
+ /** A usage error (bad flag value, unknown verb — exit 2) — stderr prose, or the one JSON error envelope under `--json`. */
734
+ #usage(message, json) {
735
+ if (json) this.#write(errorEnvelopeOf("USAGE", message));
736
+ else process.stderr.write(`${message}\n`);
737
+ throw new CLIExitError(2);
1040
738
  }
1041
- process.exitCode = 0;
1042
- }
1043
- /** `scaffold pull` refresh vendored dependency mirrors and report range drift. */
1044
- async function runPull(values, json) {
1045
- const target = containOrFail(values.target ?? ".", json);
1046
- const sync = createSync({ strict: values.strict });
1047
- try {
1048
- const wanted = values.deps?.split(",");
1049
- let report;
739
+ /** Contain a destination and report any coded escape through the shared CLI error path. */
740
+ #contain(candidate, json) {
741
+ const contained = attempt(() => containDestination(process.cwd(), candidate));
742
+ if (contained.success) return contained.value;
743
+ this.#error(contained.error, json);
744
+ }
745
+ /** Compile a spec and report unresolved blocking questions through the shared CLI error path. */
746
+ #compile(spec, json) {
747
+ const compiler = createCompiler();
1050
748
  try {
1051
- const declared = manifestToDependencies(readManifest(target));
1052
- const deps = wanted ? declared.filter((dep) => wanted.includes(dep.name)) : declared;
1053
- if (wanted) {
1054
- const guides = await sync.guides(deps);
1055
- const versions = await sync.versions(deps);
1056
- const failed = [...guides, ...versions].filter((entry) => entry.freshness === "missing" || entry.freshness === "failed").length;
1057
- report = {
1058
- target,
1059
- guides,
1060
- versions,
1061
- clean: failed === 0 && guides.every((guide) => guide.freshness === "current") && versions.every((version) => version.freshness === "current"),
1062
- failed
1063
- };
1064
- } else report = await sync.pull(target);
1065
- } catch (error) {
1066
- failError(error, json);
1067
- }
1068
- if (!json) {
1069
- reporter.table(pullTable(report));
1070
- for (const line of pullCauseNotes(report)) reporter.line(line);
1071
- reporter.line(pullVerdict(report));
1072
- }
1073
- const toWrite = [...report.guides, ...report.versions].filter((entry) => entry.freshness !== "current").length;
1074
- const terminal = createTerminal();
1075
- const proceed = toWrite > 0 ? await resolveApply(terminal, applyConfirmMessage(toWrite), values, json) : false;
1076
- if (proceed) {
1077
- const spinner = createSpinnerMaybe("writing mirrors", json);
1078
- spinner?.start();
1079
- try {
1080
- const written = await sync.write(report, target);
1081
- if (json) writeJson(pullJson(report));
1082
- else announceApply(spinner, json, pullSuccess(written.length));
1083
- } catch (error) {
1084
- announceFailure(spinner, json, error);
749
+ const scaffolding = compiler.compile(spec);
750
+ if (!scaffolding.plan) {
751
+ const message = scaffolding.questions.map((question) => question.text).join("; ");
752
+ this.#fail(message, json);
1085
753
  }
1086
- } else if (json) writeJson(pullJson(report));
1087
- process.exitCode = report.clean ? 0 : proceed ? 0 : 1;
1088
- } finally {
1089
- sync.destroy();
754
+ return scaffolding.plan;
755
+ } finally {
756
+ compiler.destroy();
757
+ }
1090
758
  }
1091
- }
1092
- /** `scaffold audit` whole-plan conformance report; offers a repair handoff on drift. */
1093
- async function runAudit(values, json) {
1094
- const target = containOrFail(values.target ?? ".", json);
1095
- let spec;
1096
- try {
1097
- spec = deriveBlueprint(target);
1098
- } catch (error) {
1099
- failError(error, json);
759
+ /**
760
+ * Merge a `pruneTargets` scan into `audit` as `foreign` findings pure
761
+ * object-spread composition in the BIN only (`src/core`'s `diffPlan` is never
762
+ * modified/reimplemented). Every unexpected path becomes one
763
+ * `'orchestration'`-group `foreign` finding (the `Group` every `PRUNE_DIRECTORIES`
764
+ * entry—`.claude/agents`, `.codex/agents`, `scripts`—belongs to); any scan hit makes the
765
+ * merged audit unclean, so an "unexpected file" is honestly counted as drift
766
+ * (exit 1) instead of the structurally-always-zero `diffPlan.foreign`.
767
+ */
768
+ #scan(audit, target, host) {
769
+ const paths = pruneTargets(target, host);
770
+ if (paths.length === 0) return audit;
771
+ const findings = paths.map((path) => ({
772
+ path,
773
+ group: "orchestration",
774
+ drift: "foreign"
775
+ }));
776
+ return {
777
+ ...audit,
778
+ clean: false,
779
+ foreign: audit.foreign + paths.length,
780
+ findings: [...audit.findings, ...findings]
781
+ };
1100
782
  }
1101
- const deps = [
1102
- ...spec.dependencies,
1103
- ...spec.peers,
1104
- ...spec.extras
1105
- ];
1106
- const groupsInput = values.groups?.split(",");
1107
- let groups;
1108
- if (groupsInput !== void 0) {
1109
- const unrecognized = groupsInput.filter((name) => !GROUPS.some((group) => group === name));
1110
- if (unrecognized.length > 0) usageFail(`Group "${unrecognized.join("\", \"")}" is not recognized`, json);
1111
- groups = GROUPS.filter((group) => groupsInput.includes(group));
783
+ /**
784
+ * Add unexpected-file findings when the vendored allowlist can be established.
785
+ * When fail-closed allowlist discovery raises a coded `TARGET` failure, retain
786
+ * the existing findings and mark the audit incomplete instead of crashing.
787
+ */
788
+ #scanSafe(audit, target, host) {
789
+ const scanned = attempt(() => this.#scan(audit, target, host));
790
+ if (scanned.success) return {
791
+ audit: scanned.value,
792
+ skipped: false
793
+ };
794
+ if (isScaffoldError(scanned.error) && scanned.error.code === "TARGET") return {
795
+ audit: {
796
+ ...audit,
797
+ clean: false,
798
+ complete: false,
799
+ questions: [...audit.questions, {
800
+ field: "host",
801
+ text: scanned.error.message,
802
+ blocking: true
803
+ }]
804
+ },
805
+ skipped: true
806
+ };
807
+ throw scanned.error;
1112
808
  }
1113
- const compiled = blueprintToPlan(spec, groups);
1114
- const from = values.from?.[0];
1115
- const host = from ?? hostRoot();
1116
- let hydrated;
1117
- try {
1118
- hydrated = hydrateBestEffort(compiled, host, from !== void 0);
1119
- } catch (error) {
1120
- failError(error, json);
809
+ /**
810
+ * `repair`'s own scope is host-only, but the caller compiles the FULL plan anyway — diff it too
811
+ * (cheap, local, no network) so a clean host verdict can point at drift OUTSIDE repair's reach.
812
+ * The count feeds the shared `scopeNote` renderer.
813
+ */
814
+ #outside(compiled, target) {
815
+ const full = diffPlan(compiled, readTarget(target, compiled.artifacts.map((artifact) => artifact.path)));
816
+ return full.drifted + full.missing + full.foreign;
1121
817
  }
1122
- const plan = hydrated.plan;
1123
- const artifactPaths = plan.artifacts.map((artifact) => artifact.path);
1124
- const rawAudit = diffPlan(plan, readTarget(target, artifactPaths));
1125
- const scanned = hydrated.aware ? withForeignScanSafe(rawAudit, target, host) : {
1126
- audit: rawAudit,
1127
- skipped: false
1128
- };
1129
- const audit = scanned.audit;
1130
- let drifted = !audit.clean;
1131
- let live;
1132
- if (values.live) {
1133
- const sync = createSync();
818
+ /** Reject a cancelled prompt (ctrl-c) with the shared `CANCELLED_MESSAGE` — exit 1, nothing written. */
819
+ async #guard(promise) {
1134
820
  try {
1135
- const guides = await sync.guides(deps);
1136
- const versions = await sync.versions(deps);
1137
- const entries = [...guides, ...versions];
1138
- drifted ||= entries.some((entry) => entry.freshness !== "current");
1139
- const current = entries.filter((entry) => entry.freshness === "current").length;
1140
- const behind = entries.filter((entry) => entry.freshness === "behind").length;
1141
- live = {
1142
- current,
1143
- behind,
1144
- failed: entries.length - current - behind
1145
- };
1146
- } finally {
1147
- sync.destroy();
821
+ return await promise;
822
+ } catch (error) {
823
+ if (isTerminalError(error) && error.code === "CANCEL") {
824
+ this.#reporter.line(CANCELLED_MESSAGE);
825
+ throw new CLIExitError(1);
826
+ }
827
+ throw error;
1148
828
  }
1149
829
  }
1150
- if (json) {
1151
- writeJson(live === void 0 ? auditJson(audit) : {
1152
- ...auditJson(audit),
1153
- live
1154
- });
1155
- process.exitCode = drifted ? 1 : 0;
1156
- return;
830
+ /**
831
+ * The shared write-confirmation gate every verb calls before it touches disk.
832
+ * `--apply` writes without asking; `--json` (without `--apply`) is a pure
833
+ * dry-run and NEVER prompts; `--yes` auto-answers yes; otherwise a real
834
+ * confirm with `default: false` (EOF on stdin resolves to the default).
835
+ */
836
+ async #apply(terminal, message, values, json) {
837
+ if (values.apply) return true;
838
+ if (json) return false;
839
+ if (values.yes) return true;
840
+ return this.#guard(terminal.confirm({
841
+ message,
842
+ default: false
843
+ }));
1157
844
  }
1158
- if (scanned.skipped) reporter.line(scanSkipped());
1159
- reporter.line(comparisonLine(hydrated.aware));
1160
- reporter.table(auditTable(audit, plan));
1161
- reporter.line(auditVerdict(audit, plan));
1162
- if (live !== void 0) reporter.line(auditLiveNote(live.current, live.behind, live.failed));
1163
- if (!audit.clean) {
1164
- const origins = new Map(plan.artifacts.map((artifact) => [artifact.path, artifact.origin]));
1165
- const isOwned = (path) => origins.get(path) === "host" || origins.get(path) === "template";
1166
- const ownedCount = audit.findings.filter((finding) => finding.drift !== "aligned" && finding.drift !== "foreign" && isOwned(finding.path)).length;
1167
- const computedCount = audit.findings.filter((finding) => finding.drift !== "aligned" && finding.drift !== "foreign" && !isOwned(finding.path)).length;
1168
- const pruneRequested = values.prune;
1169
- const offerHandoff = sinkIsTTY && (ownedCount > 0 || audit.foreign > 0 && pruneRequested);
1170
- let handoffAccepted = false;
1171
- if (offerHandoff) {
1172
- const terminal = createTerminal();
1173
- const message = repairHandoff(ownedCount, audit.foreign, pruneRequested);
1174
- handoffAccepted = await guarded(terminal.confirm({
1175
- message,
1176
- default: false
1177
- }));
1178
- if (handoffAccepted) {
1179
- await runRepair(values, false);
1180
- const rawFinal = diffPlan(plan, readTarget(target, artifactPaths));
1181
- const finalScanned = hydrated.aware ? withForeignScanSafe(rawFinal, target, host) : {
1182
- audit: rawFinal,
1183
- skipped: false
1184
- };
1185
- process.exitCode = finalScanned.audit.clean ? 0 : 1;
1186
- return;
1187
- }
1188
- }
1189
- if (!handoffAccepted) {
1190
- if (audit.foreign > 0 && !pruneRequested) reporter.line(foreignHint());
1191
- if (computedCount > 0) reporter.line(generatedNote(computedCount));
845
+ /**
846
+ * The SECOND, separate confirm for `--prune`-eligible deletions — never
847
+ * bundled into `resolveApply`'s question. `--yes` only auto-answers this
848
+ * when `--prune` was also passed (it never enables pruning by itself).
849
+ */
850
+ async #prune(terminal, message, values, json) {
851
+ if (!values.prune) return false;
852
+ if (values.apply) return true;
853
+ if (json) return false;
854
+ if (values.yes) return true;
855
+ if (!this.#tty) {
856
+ this.#reporter.line(PRUNE_SKIPPED);
857
+ return false;
1192
858
  }
859
+ return this.#guard(terminal.confirm({
860
+ message,
861
+ default: false
862
+ }));
1193
863
  }
1194
- process.exitCode = drifted ? 1 : 0;
1195
- }
1196
- /** `scaffold repair` restore the shared template-owned set for ONE target. */
1197
- async function runRepair(values, json) {
1198
- const target = containOrFail(values.target ?? ".", json);
1199
- let spec;
1200
- try {
1201
- spec = deriveBlueprint(target);
1202
- } catch (error) {
1203
- failError(error, json);
864
+ /** A spinner for a long-running write step, absent under `--json` or off a TTY sink. */
865
+ #spinner(message, json) {
866
+ return json || !this.#tty ? void 0 : createSpinner({
867
+ message,
868
+ sink: this.#sink,
869
+ styler: this.#styler
870
+ });
1204
871
  }
1205
- const compiled = compileOrFail(spec, json);
1206
- const scoped = {
1207
- ...compiled,
1208
- artifacts: compiled.artifacts.filter((artifact) => artifact.origin === "host")
1209
- };
1210
- const from = values.from?.[0];
1211
- const host = from ?? hostRoot();
1212
- let plan;
1213
- try {
1214
- plan = hydrateBestEffort(scoped, host, from !== void 0).plan;
1215
- } catch (error) {
1216
- failError(error, json);
872
+ /** Announce a successful write — the spinner's own success line, or a plain `reporter.status` without one (never under `--json`). */
873
+ #succeed(spinner, json, message) {
874
+ if (spinner) spinner.success(message);
875
+ else if (!json) this.#reporter.status("success", message);
1217
876
  }
1218
- let audit;
1219
- try {
1220
- audit = diffPlan(plan, readTarget(target, plan.artifacts.map((artifact) => artifact.path)));
1221
- } catch (error) {
1222
- failError(error, json);
877
+ /** Announce a failed write and halt(1) — the spinner's own failure line (if any), then the shared `fail`. */
878
+ #reject(spinner, json, error) {
879
+ const message = describeError(error);
880
+ if (spinner) spinner.failure(message);
881
+ this.#fail(message, json);
1223
882
  }
1224
- if (!json) {
1225
- reporter.line(REPAIR_SCOPE);
1226
- reporter.section("Audit");
1227
- reporter.table(auditTable(audit, plan));
883
+ /**
884
+ * Best-effort vendored `@orkestrel` catalog names, resolved via `host`
885
+ * (`hostRoot()`, or the active `--from` override) through the host manifest
886
+ * — `undefined` when the catalog cannot be established (a missing/unreadable
887
+ * manifest, no `.claude/agents/orkestrel.md` entry, or any other failure),
888
+ * degrading the dependency prompt to shape-only validation instead of blocking on it.
889
+ */
890
+ #names(host) {
891
+ const names = attempt(() => {
892
+ const full = locateHostSource(readHostManifest(host), ".claude/agents/orkestrel.md", host);
893
+ if (full === void 0 || !existsSync(full)) return void 0;
894
+ return catalogNames(readFileText(host, relative(host, full).replaceAll("\\", "/"), "TARGET", "host"));
895
+ });
896
+ return names.success ? names.value : void 0;
897
+ }
898
+ /** Prompt for `@orkestrel` short-name dependencies until every token resolves or input is empty. */
899
+ async #prompt(terminal, catalog) {
900
+ for (;;) {
901
+ const tokens = splitTokens(await this.#guard(terminal.input({
902
+ message: ORKESTREL_DEPS_PROMPT,
903
+ default: ""
904
+ })));
905
+ if (tokens.length === 0) return [];
906
+ const normalized = tokens.map(normalizeOrkestrelToken);
907
+ const issue = normalized.map((token) => orkestrelTokenIssue(token, catalog)).find((message) => message !== void 0);
908
+ if (issue === void 0) return normalized;
909
+ this.#reporter.line(issue);
910
+ }
1228
911
  }
1229
- const prunePaths = values.prune && existsSync(host) ? pruneTargets(target, host) : [];
1230
- if (audit.clean && prunePaths.length === 0) {
1231
- if (json) writeJson(repairJson(audit));
912
+ /** `scaffold new` scaffold a package into `./<name>` (or `--target`). */
913
+ async #new(values, argument, json) {
914
+ const terminal = createTerminal();
915
+ let name;
916
+ if (argument !== void 0) name = argument;
917
+ else if (json) this.#usage("a package name is required with --json", json);
918
+ else if (!this.#tty) this.#usage(missingInput("a package name", "new"), json);
919
+ else name = await this.#guard(terminal.input({
920
+ message: "Package name",
921
+ validate: { pattern: NAME_PATTERN.source }
922
+ }));
923
+ if (!NAME_PATTERN.test(name)) this.#usage(invalidName(name, NAME_PATTERN.source), json);
924
+ let srcInput;
925
+ let appInput;
926
+ if (values.src !== void 0) srcInput = values.src.split(",").map((candidate) => candidate.trim());
927
+ else if (values.app !== void 0) srcInput = [];
928
+ else if (json || !this.#tty) this.#usage("at least one of --src or --app is required", json);
929
+ else srcInput = await this.#guard(terminal.checkbox({
930
+ message: "Published src environments",
931
+ choices: ENVIRONMENT_CHOICES,
932
+ min: 0
933
+ }));
934
+ if (values.app !== void 0) appInput = values.app.split(",").map((candidate) => candidate.trim());
935
+ else if (values.src !== void 0 || json || !this.#tty) appInput = [];
936
+ else appInput = await this.#guard(terminal.checkbox({
937
+ message: "Application environments",
938
+ choices: ENVIRONMENT_CHOICES,
939
+ min: 0
940
+ }));
941
+ const unrecognizedSrcEnvironment = srcInput.filter((candidate) => !ENVIRONMENTS.some((environment) => environment === candidate));
942
+ if (unrecognizedSrcEnvironment.length > 0) this.#usage(`Environment "${unrecognizedSrcEnvironment.join("\", \"")}" is not recognized`, json);
943
+ if (new Set(srcInput).size !== srcInput.length) this.#usage("Published src environments must not repeat", json);
944
+ const src = ENVIRONMENTS.filter((environment) => srcInput.includes(environment));
945
+ const unrecognizedAppEnvironment = appInput.filter((candidate) => !ENVIRONMENTS.some((environment) => environment === candidate));
946
+ if (unrecognizedAppEnvironment.length > 0) this.#usage(`Application environment "${unrecognizedAppEnvironment.join("\", \"")}" is not recognized`, json);
947
+ if (new Set(appInput).size !== appInput.length) this.#usage("Application environments must not repeat", json);
948
+ const app = ENVIRONMENTS.filter((environment) => appInput.includes(environment));
949
+ if (src.length === 0 && app.length === 0) this.#usage("at least one source or application environment is required", json);
950
+ const destination = this.#contain(values.target ?? `./${name}`, json);
951
+ if (!isVacant(destination)) this.#error(new ScaffoldError("TARGET", "new requires a vacant target", { target: destination }), json);
952
+ const explicitHost = values.from?.[0];
953
+ if (explicitHost !== void 0) {
954
+ if (!isRealDirectory(explicitHost)) this.#error(new ScaffoldError("TARGET", `Host root is not a physical directory at ${explicitHost}`, { host: explicitHost }), json);
955
+ readHostManifest(explicitHost);
956
+ }
957
+ let depNames;
958
+ if (values.deps !== void 0) {
959
+ depNames = values.deps.split(",").filter((depName) => depName.length > 0);
960
+ const badDep = depNames.find((depName) => !DEPENDENCY_NAME_PATTERN.test(depName));
961
+ if (badDep !== void 0) this.#usage(`Dependency name "${badDep}" must match ${DEPENDENCY_NAME_PATTERN.source}`, json);
962
+ } else if (json || !this.#tty) depNames = [];
1232
963
  else {
1233
- reporter.line(repairVerdict(audit));
1234
- const note = scopeNote(repairOutsideCount(compiled, target));
1235
- if (note !== void 0) reporter.line(note);
964
+ const catalogHost = values.from?.[0] ?? hostRoot();
965
+ const catalog = this.#names(catalogHost);
966
+ if (catalog === void 0) this.#reporter.line(CATALOG_UNRESOLVED_NOTE);
967
+ depNames = await this.#prompt(terminal, catalog);
968
+ }
969
+ const sync = createSync();
970
+ let versions;
971
+ try {
972
+ versions = await sync.versions(depNames.map((depName) => dependency(depName, "*")));
973
+ } finally {
974
+ sync.destroy();
975
+ }
976
+ const unresolved = versions.filter((version) => version.freshness !== "current" && version.freshness !== "behind" || version.latest === "").map((version) => version.name);
977
+ if (unresolved.length > 0) this.#fail(unresolvedVersion(unresolved), json);
978
+ const deps = versions.map((version) => dependency(version.name, `^${version.latest}`));
979
+ const plan = this.#compile(blueprint(name, {
980
+ src,
981
+ app,
982
+ dependencies: deps
983
+ }), json);
984
+ const summary = planToSummary(plan);
985
+ if (!json) {
986
+ this.#reporter.section("Plan");
987
+ this.#reporter.table(newPlanTable(summary));
988
+ this.#reporter.line(newPlanPreview(name));
989
+ }
990
+ if (!await this.#apply(terminal, applyConfirmMessage(summary.host + summary.template + summary.computed), values, json)) {
991
+ if (json) this.#write(summaryToNewResult(summary, false));
992
+ else this.#reporter.line(NEW_DRY_RUN_NOTE);
993
+ process.exitCode = 0;
994
+ return;
995
+ }
996
+ const spinner = this.#spinner("materializing", json);
997
+ spinner?.start();
998
+ const host = values.from?.[0];
999
+ const materializer = createMaterializer(host === void 0 ? void 0 : { host });
1000
+ try {
1001
+ const result = materializer.materialize(plan, destination);
1002
+ const count = result.written.length + result.copied.length;
1003
+ if (json) this.#write(summaryToNewResult(summary, true));
1004
+ else this.#succeed(spinner, json, newApplySuccess(count, name));
1005
+ } catch (error) {
1006
+ this.#reject(spinner, json, error);
1007
+ } finally {
1008
+ materializer.destroy();
1236
1009
  }
1237
1010
  process.exitCode = 0;
1238
- return;
1239
1011
  }
1240
- if (!json) reporter.line(repairVerdict(audit));
1241
- const terminal = createTerminal();
1242
- let proceed = true;
1243
- if (!audit.clean) proceed = await resolveApply(terminal, applyConfirmMessage(audit.drifted + audit.missing + audit.foreign), values, json);
1244
- if (!proceed) {
1245
- if (json) writeJson(repairJson(audit));
1246
- process.exitCode = 1;
1247
- return;
1248
- }
1249
- if (values.prune && !json) if (prunePaths.length === 0) reporter.line(PRUNE_EMPTY);
1250
- else for (const line of prunePreview(prunePaths)) reporter.line(line);
1251
- const doPrune = prunePaths.length > 0 && await resolvePrune(terminal, pruneConfirmMessage(prunePaths.length), values, json);
1252
- const spinner = createSpinnerMaybe("repairing", json);
1253
- spinner?.start();
1254
- const materializer = createMaterializer({ host: values.from?.[0] });
1255
- try {
1256
- const result = materializer.repair(plan, audit, target);
1257
- const removed = doPrune ? materializer.prune(target).removed : [];
1258
- if (json) writeJson(repairJson(audit, {
1259
- ...result,
1260
- removed
1261
- }));
1262
- else announceApply(spinner, json, repairSuccess(result, removed));
1263
- } catch (error) {
1264
- announceFailure(spinner, json, error);
1265
- } finally {
1266
- materializer.destroy();
1012
+ /** `scaffold pull` — refresh vendored dependency mirrors and report range drift. */
1013
+ async #pull(values, json) {
1014
+ const target = this.#contain(values.target ?? ".", json);
1015
+ const sync = createSync(values.strict === void 0 ? void 0 : { strict: values.strict });
1016
+ try {
1017
+ let report;
1018
+ try {
1019
+ const declared = manifestToDependencies(readManifest(target));
1020
+ const selected = parsePullDependencies(values.deps, declared);
1021
+ report = await sync.pull(target, selected);
1022
+ } catch (error) {
1023
+ this.#error(error, json);
1024
+ }
1025
+ if (!json) {
1026
+ this.#reporter.table(pullTable(report));
1027
+ for (const line of pullCauseNotes(report)) this.#reporter.line(line);
1028
+ this.#reporter.line(pullVerdict(report));
1029
+ }
1030
+ const toWrite = [...report.guides, ...report.versions].filter((entry) => entry.freshness !== "current").length;
1031
+ const terminal = createTerminal();
1032
+ const proceed = toWrite > 0 ? await this.#apply(terminal, applyConfirmMessage(toWrite), values, json) : false;
1033
+ if (proceed) {
1034
+ const spinner = this.#spinner("writing mirrors", json);
1035
+ spinner?.start();
1036
+ try {
1037
+ const written = await sync.write(report, target);
1038
+ if (json) this.#write(report);
1039
+ else this.#succeed(spinner, json, pullSuccess(written.length));
1040
+ } catch (error) {
1041
+ this.#reject(spinner, json, error);
1042
+ }
1043
+ } else if (json) this.#write(report);
1044
+ process.exitCode = report.clean ? 0 : proceed ? 0 : 1;
1045
+ } finally {
1046
+ sync.destroy();
1047
+ }
1267
1048
  }
1268
- process.exitCode = 0;
1269
- }
1270
- /** `scaffold fleet` audit/repair every `@orkestrel` package beneath the current directory's immediate children. */
1271
- async function runFleet(values, json) {
1272
- const root = containOrFail(".", json);
1273
- const packages = discoverPackages(root);
1274
- if (packages.length === 0) 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);
1275
- const from = values.from?.[0];
1276
- const host = from ?? hostRoot();
1277
- const explicit = from !== void 0;
1278
- const repos = [];
1279
- const failures = [];
1280
- let ciExcluded = false;
1281
- for (const directory of packages) {
1282
- const name = basename(directory);
1049
+ /** `scaffold audit` — whole-plan conformance report; offers a repair handoff on drift. */
1050
+ async #audit(values, json) {
1051
+ const target = this.#contain(values.target ?? ".", json);
1052
+ let spec;
1053
+ try {
1054
+ spec = deriveBlueprint(target);
1055
+ } catch (error) {
1056
+ this.#error(error, json);
1057
+ }
1058
+ const deps = [
1059
+ ...spec.dependencies,
1060
+ ...spec.peers,
1061
+ ...spec.extras
1062
+ ];
1063
+ const groupsInput = values.groups?.split(",");
1064
+ let groups;
1065
+ if (groupsInput !== void 0) {
1066
+ const unrecognized = groupsInput.filter((name) => !GROUPS.some((group) => group === name));
1067
+ if (unrecognized.length > 0) this.#usage(`Group "${unrecognized.join("\", \"")}" is not recognized`, json);
1068
+ groups = GROUPS.filter((group) => groupsInput.includes(group));
1069
+ }
1070
+ const compiled = blueprintToPlan(spec, groups);
1071
+ const host = values.from?.[0] ?? hostRoot();
1072
+ let plan;
1283
1073
  try {
1284
- const compiler = createCompiler();
1285
- let scoped;
1074
+ plan = hydratePlan(compiled, host);
1075
+ } catch (error) {
1076
+ this.#error(error, json);
1077
+ }
1078
+ const artifactPaths = plan.artifacts.map((artifact) => artifact.path);
1079
+ const rawAudit = diffPlan(plan, readTarget(target, artifactPaths));
1080
+ const scanned = this.#scanSafe(rawAudit, target, host);
1081
+ const audit = scanned.audit;
1082
+ let drifted = !audit.clean;
1083
+ let live;
1084
+ if (values.live) {
1085
+ const sync = createSync();
1286
1086
  try {
1287
- const spec = deriveBlueprint(directory);
1288
- const scaffolding = compiler.compile(spec);
1289
- if (!scaffolding.plan) throw new ScaffoldError("INVALID", scaffolding.questions.map((question) => question.text).join("; "));
1290
- scoped = {
1291
- ...scaffolding.plan,
1292
- artifacts: scaffolding.plan.artifacts.filter((artifact) => artifact.origin === "host" && artifact.path !== ".github/workflows/ci.yml")
1087
+ const guides = await sync.guides(deps);
1088
+ const versions = await sync.versions(deps);
1089
+ const entries = [...guides, ...versions];
1090
+ drifted ||= entries.some((entry) => entry.freshness !== "current");
1091
+ const current = entries.filter((entry) => entry.freshness === "current").length;
1092
+ const behind = entries.filter((entry) => entry.freshness === "behind").length;
1093
+ live = {
1094
+ current,
1095
+ behind,
1096
+ failed: entries.length - current - behind
1293
1097
  };
1294
- if (!ciExcluded && scaffolding.plan.artifacts.some((artifact) => artifact.path === ".github/workflows/ci.yml")) {
1295
- if (!json) reporter.line(fleetCiSkipped());
1296
- ciExcluded = true;
1297
- }
1298
1098
  } finally {
1299
- compiler.destroy();
1099
+ sync.destroy();
1300
1100
  }
1301
- const hydrated = hydrateBestEffort(scoped, host, explicit);
1302
- const plan = hydrated.plan;
1303
- const rawAudit = diffPlan(plan, readTarget(directory, plan.artifacts.map((artifact) => artifact.path)));
1304
- const audit = hydrated.aware ? withForeignScan(rawAudit, directory, host) : rawAudit;
1305
- repos.push({
1306
- name,
1307
- directory,
1308
- plan,
1309
- audit,
1310
- aware: hydrated.aware
1311
- });
1312
- } catch (error) {
1313
- failures.push({
1314
- name,
1315
- message: describe(error)
1101
+ }
1102
+ if (json) {
1103
+ this.#write(live === void 0 ? audit : {
1104
+ ...audit,
1105
+ live
1316
1106
  });
1107
+ process.exitCode = drifted ? 1 : 0;
1108
+ return;
1317
1109
  }
1318
- }
1319
- if (!json) {
1320
- for (const repo of repos) reporter.line(fleetRepoLine(repo.name, repo.audit.clean ? { kind: "clean" } : {
1321
- kind: "drifted",
1322
- drifted: repo.audit.drifted,
1323
- missing: repo.audit.missing,
1324
- foreign: repo.audit.foreign
1325
- }));
1326
- for (const failure of failures) reporter.line(fleetRepoLine(failure.name, {
1327
- kind: "failed",
1328
- message: failure.message
1329
- }));
1330
- }
1331
- const dirty = repos.filter((repo) => !repo.audit.clean);
1332
- if (dirty.length === 0) {
1333
- if (json) writeJson(fleetJson([...repos.map((repo) => fleetEntry(repo.name, repo.audit, false)), ...failures.map((failure) => fleetEntry(failure.name, void 0, true))]));
1334
- else reporter.line(fleetTotals(0, failures.length));
1335
- process.exitCode = failures.length > 0 ? 1 : 0;
1336
- return;
1337
- }
1338
- const fileCount = dirty.reduce((total, repo) => total + repo.audit.drifted + repo.audit.missing + repo.audit.foreign, 0);
1339
- const terminal = createTerminal();
1340
- const proceed = await resolveApply(terminal, applyConfirmMessage(fileCount, dirty.length), values, json);
1341
- const prunePaths = proceed && values.prune && existsSync(host) ? dirty.flatMap((repo) => pruneTargets(repo.directory, host).map((path) => `${repo.name}/${path}`)) : [];
1342
- if (proceed && values.prune && !json) if (prunePaths.length === 0) reporter.line(PRUNE_EMPTY);
1343
- else for (const line of prunePreview(prunePaths)) reporter.line(line);
1344
- const doPrune = proceed && prunePaths.length > 0 && await resolvePrune(terminal, pruneConfirmMessage(prunePaths.length), values, json);
1345
- if (!proceed) {
1346
- if (json) writeJson(fleetJson([...repos.map((repo) => fleetEntry(repo.name, repo.audit, false)), ...failures.map((failure) => fleetEntry(failure.name, void 0, true))]));
1347
- else reporter.line(fleetTotals(dirty.length, failures.length));
1348
- process.exitCode = 1;
1349
- return;
1350
- }
1351
- const materializer = createMaterializer({ host });
1352
- let drifted = 0;
1353
- let failedCount = failures.length;
1354
- const entries = repos.filter((repo) => repo.audit.clean).map((repo) => fleetEntry(repo.name, repo.audit, false));
1355
- try {
1356
- for (const repo of dirty) try {
1357
- materializer.repair(repo.plan, repo.audit, repo.directory);
1358
- if (doPrune) materializer.prune(repo.directory);
1359
- const paths = repo.plan.artifacts.map((artifact) => artifact.path);
1360
- const rawFinal = diffPlan(repo.plan, readTarget(repo.directory, paths));
1361
- const finalAudit = repo.aware ? withForeignScan(rawFinal, repo.directory, host) : rawFinal;
1362
- if (!finalAudit.clean) drifted += 1;
1363
- entries.push(fleetEntry(repo.name, finalAudit, false));
1364
- if (!json) reporter.line(fleetRepoLine(repo.name, {
1365
- kind: "repaired",
1366
- remaining: finalAudit.drifted + finalAudit.missing + finalAudit.foreign
1367
- }));
1368
- } catch (error) {
1369
- failedCount += 1;
1370
- entries.push(fleetEntry(repo.name, void 0, true));
1371
- if (!json) reporter.line(fleetRepoLine(repo.name, {
1372
- kind: "failed",
1373
- message: describe(error)
1374
- }));
1110
+ if (scanned.skipped) this.#reporter.line(SCAN_SKIPPED);
1111
+ this.#reporter.line(comparisonLine(true));
1112
+ this.#reporter.table(auditTable(audit, plan));
1113
+ this.#reporter.line(auditVerdict(audit, plan));
1114
+ if (live !== void 0) this.#reporter.line(auditLiveNote(live.current, live.behind, live.failed));
1115
+ if (!audit.clean) {
1116
+ const split = partitionFindings(audit.findings, plan);
1117
+ const ownedCount = split.owned.drifted + split.owned.missing;
1118
+ const computedCount = split.generated.drifted + split.generated.missing;
1119
+ const pruneRequested = values.prune === true;
1120
+ const offerHandoff = this.#tty && (ownedCount > 0 || audit.foreign > 0 && pruneRequested);
1121
+ let handoffAccepted = false;
1122
+ if (offerHandoff) {
1123
+ const terminal = createTerminal();
1124
+ const message = repairHandoff(ownedCount, audit.foreign, pruneRequested);
1125
+ handoffAccepted = await this.#guard(terminal.confirm({
1126
+ message,
1127
+ default: false
1128
+ }));
1129
+ if (handoffAccepted) {
1130
+ await this.#repair(values, false);
1131
+ const rawFinal = diffPlan(plan, readTarget(target, artifactPaths));
1132
+ const finalScanned = this.#scanSafe(rawFinal, target, host);
1133
+ process.exitCode = finalScanned.audit.clean ? 0 : 1;
1134
+ return;
1135
+ }
1136
+ }
1137
+ if (!handoffAccepted) {
1138
+ if (audit.foreign > 0 && !pruneRequested) this.#reporter.line(FOREIGN_HINT);
1139
+ if (computedCount > 0) this.#reporter.line(generatedNote(computedCount));
1140
+ }
1375
1141
  }
1376
- } finally {
1377
- materializer.destroy();
1142
+ process.exitCode = drifted ? 1 : 0;
1378
1143
  }
1379
- if (json) writeJson(fleetJson([...entries, ...failures.map((failure) => fleetEntry(failure.name, void 0, true))]));
1380
- else reporter.line(fleetTotals(drifted, failedCount));
1381
- process.exitCode = drifted > 0 || failedCount > 0 ? 1 : 0;
1382
- }
1383
- /** `scaffold catalog` — regenerate the fleet package catalog table embedded in `.claude/agents/orkestrel.md`. */
1384
- async function runCatalog(values, json) {
1385
- const target = containOrFail(values.target ?? ".", json);
1386
- const explicitRoots = values.from;
1387
- let entries;
1388
- let published = 0;
1389
- let localOnly = 0;
1390
- const notes = /* @__PURE__ */ new Map();
1391
- if (values.offline) {
1392
- const roots = explicitRoots ?? [process.cwd()];
1144
+ /** `scaffold repair` restore the shared host-owned set for one target. */
1145
+ async #repair(values, json) {
1146
+ const target = this.#contain(values.target ?? ".", json);
1147
+ let spec;
1393
1148
  try {
1394
- entries = catalogPackages(roots);
1149
+ spec = deriveBlueprint(target);
1395
1150
  } catch (error) {
1396
- failError(error, json);
1151
+ this.#error(error, json);
1397
1152
  }
1398
- } else {
1399
- const sync = createSync({ on: { package: (name, note) => {
1400
- if (note !== "") notes.set(name, note);
1401
- } } });
1402
- let registryEntries;
1153
+ const compiled = this.#compile(spec, json);
1154
+ const scoped = {
1155
+ ...compiled,
1156
+ artifacts: compiled.artifacts.filter((artifact) => artifact.origin === "host")
1157
+ };
1158
+ const host = values.from?.[0] ?? hostRoot();
1159
+ let plan;
1403
1160
  try {
1404
- registryEntries = await sync.catalog();
1161
+ plan = hydratePlan(scoped, host);
1405
1162
  } catch (error) {
1406
- failError(error, json);
1407
- } finally {
1408
- sync.destroy();
1163
+ this.#error(error, json);
1409
1164
  }
1410
- published = registryEntries.length;
1411
- let localEntries = [];
1412
- if (explicitRoots !== void 0) try {
1413
- localEntries = catalogPackages(explicitRoots);
1165
+ let audit;
1166
+ try {
1167
+ audit = diffPlan(plan, readTarget(target, plan.artifacts.map((artifact) => artifact.path)));
1414
1168
  } catch (error) {
1415
- failError(error, json);
1169
+ this.#error(error, json);
1416
1170
  }
1417
- const merged = /* @__PURE__ */ new Map();
1418
- for (const entry of registryEntries) merged.set(entry.name, entry);
1419
- for (const local of localEntries) {
1420
- const existing = merged.get(local.name);
1421
- if (existing === void 0) {
1422
- merged.set(local.name, local);
1423
- localOnly += 1;
1424
- } else if (local.description.length > 0) merged.set(local.name, {
1425
- ...existing,
1426
- description: local.description
1427
- });
1171
+ if (!json) {
1172
+ this.#reporter.line(REPAIR_SCOPE);
1173
+ this.#reporter.section("Audit");
1174
+ this.#reporter.table(auditTable(audit, plan));
1175
+ }
1176
+ const prunePaths = values.prune ? pruneTargets(target, host) : [];
1177
+ const pruneSnapshot = readTarget(target, prunePaths);
1178
+ if (audit.clean && prunePaths.length === 0) {
1179
+ if (json) this.#write(audit);
1180
+ else {
1181
+ this.#reporter.line(repairVerdict(audit));
1182
+ const note = scopeNote(this.#outside(compiled, target));
1183
+ if (note !== void 0) this.#reporter.line(note);
1184
+ }
1185
+ process.exitCode = 0;
1186
+ return;
1187
+ }
1188
+ if (!json) this.#reporter.line(repairVerdict(audit));
1189
+ const terminal = createTerminal();
1190
+ let proceed = true;
1191
+ if (!audit.clean) proceed = await this.#apply(terminal, applyConfirmMessage(audit.drifted + audit.missing + audit.foreign), values, json);
1192
+ if (!proceed) {
1193
+ if (json) this.#write(audit);
1194
+ process.exitCode = 1;
1195
+ return;
1196
+ }
1197
+ if (values.prune && !json) if (prunePaths.length === 0) this.#reporter.line(PRUNE_EMPTY);
1198
+ else for (const line of prunePreview(prunePaths)) this.#reporter.line(line);
1199
+ const doPrune = prunePaths.length > 0 && await this.#prune(terminal, pruneConfirmMessage(prunePaths.length), values, json);
1200
+ const spinner = this.#spinner("repairing", json);
1201
+ spinner?.start();
1202
+ const materializer = createMaterializer({ host });
1203
+ try {
1204
+ const result = materializer.repair(plan, audit, target);
1205
+ const removed = doPrune ? materializer.prune(target, pruneSnapshot).removed : [];
1206
+ if (json) this.#write(auditToRepairResult(audit, {
1207
+ ...result,
1208
+ removed
1209
+ }));
1210
+ else this.#succeed(spinner, json, repairSuccess(result, removed));
1211
+ } catch (error) {
1212
+ this.#reject(spinner, json, error);
1213
+ } finally {
1214
+ materializer.destroy();
1428
1215
  }
1429
- entries = [...merged.values()].sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
1430
- }
1431
- const block = catalogToBlock(entries);
1432
- const agentPath = containOrFail(join(target, ".claude", "agents", "orkestrel.md"), json);
1433
- let current;
1434
- try {
1435
- current = readFileSync(agentPath, "utf8");
1436
- } catch (error) {
1437
- failError(new ScaffoldError("TARGET", `Failed to read ${agentPath}`, {
1438
- path: agentPath,
1439
- error
1440
- }), json);
1441
- }
1442
- const startMarker = "<!-- catalog:start -->";
1443
- const endMarker = "<!-- catalog:end -->";
1444
- const startIndex = current.indexOf(startMarker);
1445
- const endIndex = current.indexOf(endMarker);
1446
- if (startIndex === -1 || endIndex === -1 || endIndex < startIndex) failError(new ScaffoldError("TARGET", `Markers "${startMarker}" / "${endMarker}" not found in ${agentPath}`, { path: agentPath }), json);
1447
- const updated = `${current.slice(0, startIndex + 22)}\n\n${block}\n${current.slice(endIndex)}`;
1448
- const oldRows = catalogNames(current.slice(startIndex + 22, endIndex)).length;
1449
- const shrink = entries.length < oldRows ? oldRows - entries.length : void 0;
1450
- if (updated === current) {
1451
- if (json) writeJson(catalogJson(entries, false));
1452
- else reporter.line(catalogVerdict(true));
1453
1216
  process.exitCode = 0;
1454
- return;
1455
1217
  }
1456
- if (!json) {
1457
- reporter.table(catalogTable(entries));
1458
- const warning = catalogShrinkWarning(oldRows, entries.length);
1459
- if (warning !== void 0) reporter.line(warning);
1218
+ /** `scaffold fleet` — audit/repair every `@orkestrel` package beneath the current directory's immediate children. */
1219
+ async #fleet(values, json) {
1220
+ const root = this.#contain(".", json);
1221
+ const packages = discoverPackages(root);
1222
+ 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);
1223
+ const host = values.from?.[0] ?? hostRoot();
1224
+ const repos = [];
1225
+ const failures = [];
1226
+ for (const directory of packages) {
1227
+ const name = basename(directory);
1228
+ try {
1229
+ const compiler = createCompiler();
1230
+ let scoped;
1231
+ try {
1232
+ const spec = deriveBlueprint(directory);
1233
+ const scaffolding = compiler.compile(spec);
1234
+ if (!scaffolding.plan) throw new ScaffoldError("INVALID", scaffolding.questions.map((question) => question.text).join("; "));
1235
+ scoped = {
1236
+ ...scaffolding.plan,
1237
+ artifacts: scaffolding.plan.artifacts.filter((artifact) => artifact.origin === "host")
1238
+ };
1239
+ } finally {
1240
+ compiler.destroy();
1241
+ }
1242
+ const plan = hydratePlan(scoped, host);
1243
+ const rawAudit = diffPlan(plan, readTarget(directory, plan.artifacts.map((artifact) => artifact.path)));
1244
+ const audit = this.#scan(rawAudit, directory, host);
1245
+ repos.push({
1246
+ name,
1247
+ directory,
1248
+ plan,
1249
+ audit
1250
+ });
1251
+ } catch (error) {
1252
+ failures.push({
1253
+ name,
1254
+ message: describeError(error)
1255
+ });
1256
+ }
1257
+ }
1258
+ if (!json) {
1259
+ for (const repo of repos) this.#reporter.line(fleetRepoLine(repo.name, repo.audit.clean ? { state: "clean" } : {
1260
+ state: "drifted",
1261
+ drifted: repo.audit.drifted,
1262
+ missing: repo.audit.missing,
1263
+ foreign: repo.audit.foreign
1264
+ }));
1265
+ for (const failure of failures) this.#reporter.line(fleetRepoLine(failure.name, {
1266
+ state: "failed",
1267
+ message: failure.message
1268
+ }));
1269
+ }
1270
+ const dirty = repos.filter((repo) => !repo.audit.clean);
1271
+ if (dirty.length === 0) {
1272
+ if (json) this.#write([...repos.map((repo) => fleetEntryOf(repo.name, repo.audit, false)), ...failures.map((failure) => fleetEntryOf(failure.name, void 0, true))]);
1273
+ else this.#reporter.line(fleetTotals(0, failures.length));
1274
+ process.exitCode = failures.length > 0 ? 1 : 0;
1275
+ return;
1276
+ }
1277
+ const fileCount = dirty.reduce((total, repo) => total + repo.audit.drifted + repo.audit.missing + repo.audit.foreign, 0);
1278
+ const terminal = createTerminal();
1279
+ const proceed = await this.#apply(terminal, applyConfirmMessage(fileCount, dirty.length), values, json);
1280
+ const pruneSets = proceed && values.prune ? new Map(dirty.map((repo) => {
1281
+ const paths = pruneTargets(repo.directory, host);
1282
+ return [repo.name, readTarget(repo.directory, paths)];
1283
+ })) : /* @__PURE__ */ new Map();
1284
+ const prunePaths = dirty.flatMap((repo) => Object.keys(pruneSets.get(repo.name) ?? {}).map((path) => `${repo.name}/${path}`));
1285
+ if (proceed && values.prune && !json) if (prunePaths.length === 0) this.#reporter.line(PRUNE_EMPTY);
1286
+ else for (const line of prunePreview(prunePaths)) this.#reporter.line(line);
1287
+ const doPrune = proceed && prunePaths.length > 0 && await this.#prune(terminal, pruneConfirmMessage(prunePaths.length), values, json);
1288
+ if (!proceed) {
1289
+ if (json) this.#write([...repos.map((repo) => fleetEntryOf(repo.name, repo.audit, false)), ...failures.map((failure) => fleetEntryOf(failure.name, void 0, true))]);
1290
+ else this.#reporter.line(fleetTotals(dirty.length, failures.length));
1291
+ process.exitCode = 1;
1292
+ return;
1293
+ }
1294
+ const materializer = createMaterializer({ host });
1295
+ let drifted = 0;
1296
+ let failedCount = failures.length;
1297
+ const entries = repos.filter((repo) => repo.audit.clean).map((repo) => fleetEntryOf(repo.name, repo.audit, false));
1298
+ try {
1299
+ for (const repo of dirty) try {
1300
+ materializer.repair(repo.plan, repo.audit, repo.directory);
1301
+ if (doPrune) materializer.prune(repo.directory, pruneSets.get(repo.name) ?? {});
1302
+ const paths = repo.plan.artifacts.map((artifact) => artifact.path);
1303
+ const rawFinal = diffPlan(repo.plan, readTarget(repo.directory, paths));
1304
+ const finalAudit = this.#scan(rawFinal, repo.directory, host);
1305
+ if (!finalAudit.clean) drifted += 1;
1306
+ entries.push(fleetEntryOf(repo.name, finalAudit, false));
1307
+ if (!json) this.#reporter.line(fleetRepoLine(repo.name, {
1308
+ state: "repaired",
1309
+ remaining: finalAudit.drifted + finalAudit.missing + finalAudit.foreign
1310
+ }));
1311
+ } catch (error) {
1312
+ failedCount += 1;
1313
+ entries.push(fleetEntryOf(repo.name, void 0, true));
1314
+ if (!json) this.#reporter.line(fleetRepoLine(repo.name, {
1315
+ state: "failed",
1316
+ message: describeError(error)
1317
+ }));
1318
+ }
1319
+ } finally {
1320
+ materializer.destroy();
1321
+ }
1322
+ if (json) this.#write([...entries, ...failures.map((failure) => fleetEntryOf(failure.name, void 0, true))]);
1323
+ else this.#reporter.line(fleetTotals(drifted, failedCount));
1324
+ process.exitCode = drifted > 0 || failedCount > 0 ? 1 : 0;
1325
+ }
1326
+ /** `scaffold catalog` — regenerate the fleet package catalog table embedded in `.claude/agents/orkestrel.md`. */
1327
+ async #catalog(values, json) {
1328
+ const target = this.#contain(values.target ?? ".", json);
1329
+ const explicitRoots = values.from;
1330
+ let entries;
1331
+ let published = 0;
1332
+ let localOnly = 0;
1333
+ const notes = /* @__PURE__ */ new Map();
1460
1334
  if (values.offline) {
1461
- const missingDescription = entries.filter((entry) => entry.description.length === 0).map((entry) => entry.name);
1462
- if (missingDescription.length > 0) reporter.line(`${missingDescription.length} without guide description: ${missingDescription.join(", ")}`);
1335
+ const roots = explicitRoots ?? [process.cwd()];
1336
+ try {
1337
+ entries = catalogPackages(roots);
1338
+ } catch (error) {
1339
+ this.#error(error, json);
1340
+ }
1463
1341
  } else {
1464
- reporter.line(catalogCounts(published, localOnly));
1465
- for (const [name, note] of notes) reporter.line(` ${name}: ${note}`);
1342
+ const sync = createSync();
1343
+ sync.emitter.on("package", (name, note) => {
1344
+ if (note !== "") notes.set(name, note);
1345
+ });
1346
+ let registryEntries;
1347
+ try {
1348
+ registryEntries = await sync.catalog();
1349
+ } catch (error) {
1350
+ this.#error(error, json);
1351
+ } finally {
1352
+ sync.destroy();
1353
+ }
1354
+ published = registryEntries.length;
1355
+ let localEntries = [];
1356
+ if (explicitRoots !== void 0) try {
1357
+ localEntries = catalogPackages(explicitRoots);
1358
+ } catch (error) {
1359
+ this.#error(error, json);
1360
+ }
1361
+ const merged = /* @__PURE__ */ new Map();
1362
+ for (const entry of registryEntries) merged.set(entry.name, entry);
1363
+ for (const local of localEntries) {
1364
+ const existing = merged.get(local.name);
1365
+ if (existing === void 0) {
1366
+ merged.set(local.name, local);
1367
+ localOnly += 1;
1368
+ } else if (local.description.length > 0) merged.set(local.name, {
1369
+ ...existing,
1370
+ description: local.description
1371
+ });
1372
+ }
1373
+ entries = [...merged.values()].sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
1466
1374
  }
1467
- }
1468
- if (!await resolveApply(createTerminal(), applyConfirmMessage(1), values, json)) {
1469
- if (json) writeJson(catalogJson(entries, true, shrink));
1470
- else reporter.line(catalogVerdict(false));
1471
- process.exitCode = 1;
1472
- return;
1473
- }
1474
- try {
1475
- writeFileSync(agentPath, updated, "utf8");
1476
- } catch (error) {
1477
- failError(new ScaffoldError("TARGET", `Failed to write ${agentPath}`, {
1375
+ const block = catalogToBlock(entries);
1376
+ const agentPath = this.#contain(join(target, CATALOG_AGENT_PATH), json);
1377
+ let current;
1378
+ let baseline;
1379
+ try {
1380
+ current = readFileText(target, CATALOG_AGENT_PATH, "TARGET", "target");
1381
+ baseline = digestText(current);
1382
+ } catch (error) {
1383
+ this.#error(new ScaffoldError("TARGET", `Failed to read ${agentPath}`, {
1384
+ path: agentPath,
1385
+ error
1386
+ }), json);
1387
+ }
1388
+ const startIndex = current.indexOf(CATALOG_START_MARKER);
1389
+ const endIndex = current.indexOf(CATALOG_END_MARKER);
1390
+ const startParts = current.split(CATALOG_START_MARKER);
1391
+ const endParts = current.split(CATALOG_END_MARKER);
1392
+ if (startIndex === -1 || endIndex === -1 || endIndex < startIndex || startParts.length !== 2 || endParts.length !== 2) this.#error(new ScaffoldError("TARGET", `Expected exactly one ordered "${CATALOG_START_MARKER}" / "${CATALOG_END_MARKER}" pair in ${agentPath}`, { path: agentPath }), json);
1393
+ const updated = `${current.slice(0, startIndex + CATALOG_START_MARKER.length)}\n\n${block}\n${current.slice(endIndex)}`;
1394
+ const oldRows = catalogNames(current.slice(startIndex + CATALOG_START_MARKER.length, endIndex)).length;
1395
+ const shrink = entries.length < oldRows ? oldRows - entries.length : void 0;
1396
+ if (updated === current) {
1397
+ if (json) this.#write(catalogResultOf(entries, false));
1398
+ else this.#reporter.line(catalogVerdict(true));
1399
+ process.exitCode = 0;
1400
+ return;
1401
+ }
1402
+ if (!json) {
1403
+ this.#reporter.table(catalogTable(entries));
1404
+ const warning = catalogShrinkWarning(oldRows, entries.length);
1405
+ if (warning !== void 0) this.#reporter.line(warning);
1406
+ if (values.offline) {
1407
+ const missingDescription = entries.filter((entry) => entry.description.length === 0).map((entry) => entry.name);
1408
+ if (missingDescription.length > 0) this.#reporter.line(`${missingDescription.length} without guide description: ${missingDescription.join(", ")}`);
1409
+ } else {
1410
+ this.#reporter.line(catalogCounts(published, localOnly));
1411
+ for (const [name, note] of notes) this.#reporter.line(` ${name}: ${note}`);
1412
+ }
1413
+ }
1414
+ const terminal = createTerminal();
1415
+ if (!await this.#apply(terminal, applyConfirmMessage(1), values, json)) {
1416
+ if (json) this.#write(catalogResultOf(entries, true, shrink));
1417
+ else this.#reporter.line(catalogVerdict(false));
1418
+ process.exitCode = 1;
1419
+ return;
1420
+ }
1421
+ if (Buffer.byteLength(updated, "utf8") > MAX_ARTIFACT_BYTES) this.#error(new ScaffoldError("WRITE", `Catalog exceeds the artifact limit at ${agentPath}`, {
1478
1422
  path: agentPath,
1479
- error
1423
+ limit: MAX_ARTIFACT_BYTES
1480
1424
  }), json);
1481
- }
1482
- if (json) writeJson(catalogJson(entries, true, shrink));
1483
- else reporter.status("success", catalogApplySuccess(agentPath));
1484
- process.exitCode = 0;
1485
- }
1486
- /**
1487
- * The whole command dispatch — a single top-level driver (no nested function
1488
- * declarations, AGENTS §4). Every verb sets `process.exitCode` (never
1489
- * `process.exit`, H4) and returns, or `halt()`s through a `finally` that
1490
- * tears its entities down first; the caller at the bottom of this file
1491
- * catches exactly one sentinel (`CliExit`) and stops.
1492
- */
1493
- async function main() {
1494
- let parsed;
1495
- try {
1496
- parsed = parseArguments();
1497
- } catch (error) {
1498
- process.stderr.write(`${error instanceof Error ? error.message : INVALID_ARGUMENTS_MESSAGE}\n`);
1499
- process.exitCode = 2;
1500
- return;
1501
- }
1502
- const { values, positionals } = parsed;
1503
- const [command, argument] = positionals;
1504
- const json = values.json === true;
1505
- sessionJson = json;
1506
- if (command === void 0) {
1507
- process.stdout.write(`${values.help ? fullHelp() : shortUsage()}\n`);
1508
- process.exitCode = 0;
1509
- return;
1510
- }
1511
- if (!isVerb(command)) usageFail(didYouMean(command), json);
1512
- if (values.help) {
1513
- process.stdout.write(`${verbHelp(command)}\n`);
1425
+ const transaction = WriteTransaction.create(target, [CATALOG_AGENT_PATH], [{
1426
+ path: CATALOG_AGENT_PATH,
1427
+ shape: "file",
1428
+ digest: baseline
1429
+ }]);
1430
+ const staged = attempt(() => {
1431
+ validateWriteDirectories(transaction);
1432
+ mkdirSync(dirname(resolvePhysicalPath(transaction.stage, CATALOG_AGENT_PATH, "WRITE", "staging")), { recursive: true });
1433
+ validateWriteDirectories(transaction);
1434
+ const contained = resolvePhysicalPath(transaction.stage, CATALOG_AGENT_PATH, "WRITE", "staging");
1435
+ writeFileSync(contained, updated, {
1436
+ encoding: "utf8",
1437
+ flag: "wx"
1438
+ });
1439
+ if (digestFile(contained) !== digestText(updated)) throw new ScaffoldError("WRITE", `Staged catalog changed at ${agentPath}`, { path: agentPath });
1440
+ validateWriteDirectories(transaction);
1441
+ });
1442
+ if (!staged.success) {
1443
+ const cleanup = attempt(() => discardWriteTransaction(transaction));
1444
+ this.#error(new ScaffoldError("WRITE", `Failed to stage ${agentPath}`, {
1445
+ path: agentPath,
1446
+ error: staged.error,
1447
+ cleanup: cleanup.success ? void 0 : cleanup.error
1448
+ }), json);
1449
+ }
1450
+ const committed = attempt(() => commitWriteTransaction(transaction, [CATALOG_AGENT_PATH]));
1451
+ if (!committed.success) this.#error(committed.error, json);
1452
+ if (json) this.#write(catalogResultOf(entries, true, shrink));
1453
+ else this.#reporter.status("success", catalogApplySuccess(agentPath));
1514
1454
  process.exitCode = 0;
1515
- return;
1516
1455
  }
1517
- if (command === "new") return runNew(values, argument, json);
1518
- if (command === "pull") return runPull(values, json);
1519
- if (command === "audit") return runAudit(values, json);
1520
- if (command === "repair") return runRepair(values, json);
1521
- if (command === "fleet") return runFleet(values, json);
1522
- return runCatalog(values, json);
1523
- }
1524
- trustSystemCertificates();
1525
- try {
1526
- await main();
1527
- } catch (error) {
1528
- if (error instanceof CliExit) process.exitCode = error.code;
1529
- else if (sessionJson) {
1530
- writeJson(errorEnvelope(isScaffoldError(error) ? error.code : "ERROR", isScaffoldError(error) ? error.message : describe(error)));
1531
- process.exitCode = 1;
1532
- } else {
1533
- reporter.status("error", describe(error));
1534
- process.exitCode = 1;
1456
+ /**
1457
+ * The whole command dispatch a single top-level driver (no nested function
1458
+ * declarations, AGENTS §4). Every verb sets `process.exitCode` (never
1459
+ * `process.exit`) and returns, or `halt()`s through a `finally` that
1460
+ * tears its entities down first; the caller at the bottom of this file
1461
+ * catches exactly one sentinel (`CliExit`) and stops.
1462
+ */
1463
+ async #dispatch(argv) {
1464
+ let parsed;
1465
+ try {
1466
+ parsed = parseArguments(argv);
1467
+ } catch (error) {
1468
+ process.stderr.write(`${error instanceof Error ? error.message : INVALID_ARGUMENTS_MESSAGE}\n`);
1469
+ process.exitCode = 2;
1470
+ return;
1471
+ }
1472
+ const { values, positionals } = parsed;
1473
+ const [command, argument] = positionals;
1474
+ const json = values.json === true;
1475
+ this.#json = json;
1476
+ if (command === void 0) {
1477
+ process.stdout.write(`${values.help ? fullHelp() : shortUsage()}\n`);
1478
+ process.exitCode = 0;
1479
+ return;
1480
+ }
1481
+ if (!isVerb(command)) this.#usage(didYouMean(command), json);
1482
+ if (command !== "catalog" && values.from !== void 0 && values.from.length > 1) this.#usage(`--from may be provided only once for '${command}'`, json);
1483
+ if (values.help) {
1484
+ process.stdout.write(`${verbHelp(command)}\n`);
1485
+ process.exitCode = 0;
1486
+ return;
1487
+ }
1488
+ if (command === "new") return this.#new(values, argument, json);
1489
+ if (command === "pull") return this.#pull(values, json);
1490
+ if (command === "audit") return this.#audit(values, json);
1491
+ if (command === "repair") return this.#repair(values, json);
1492
+ if (command === "fleet") return this.#fleet(values, json);
1493
+ return this.#catalog(values, json);
1535
1494
  }
1536
- }
1495
+ };
1496
+ //#endregion
1497
+ //#region src/bin/scaffold.ts
1498
+ await new CLI().run(process.argv.slice(2));
1537
1499
  //#endregion
1538
1500
 
1539
1501
  //# sourceMappingURL=scaffold.js.map