@orkestrel/scaffold 0.0.1

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 (45) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +114 -0
  3. package/dist/bin/scaffold.js +1539 -0
  4. package/dist/bin/scaffold.js.map +1 -0
  5. package/dist/host/AGENTS.md +939 -0
  6. package/dist/host/CLAUDE.md +495 -0
  7. package/dist/host/LICENSE +21 -0
  8. package/dist/host/claude/agents/builder.md +48 -0
  9. package/dist/host/claude/agents/checker.md +37 -0
  10. package/dist/host/claude/agents/composer.md +64 -0
  11. package/dist/host/claude/agents/grok.md +50 -0
  12. package/dist/host/claude/agents/orkestrel.md +236 -0
  13. package/dist/host/claude/agents/planner.md +44 -0
  14. package/dist/host/claude/agents/researcher.md +38 -0
  15. package/dist/host/claude/agents/reviewer.md +47 -0
  16. package/dist/host/claude/agents/scout.md +35 -0
  17. package/dist/host/claude/agents/verifier.md +34 -0
  18. package/dist/host/claude/settings.json +26 -0
  19. package/dist/host/dotfiles/editorconfig +17 -0
  20. package/dist/host/dotfiles/gitattributes +3 -0
  21. package/dist/host/dotfiles/gitignore +40 -0
  22. package/dist/host/dotfiles/oxfmtrc.json +18 -0
  23. package/dist/host/dotfiles/oxlintignore +20 -0
  24. package/dist/host/dotfiles/oxlintrc.json +58 -0
  25. package/dist/host/dotfiles/prettierignore +5 -0
  26. package/dist/host/github/workflows/ci.yml +64 -0
  27. package/dist/host/guides/src/guide.md +312 -0
  28. package/dist/host/guides/src/scaffold.md +2152 -0
  29. package/dist/host/manifest.json +137 -0
  30. package/dist/host/scripts/cursor.sh +74 -0
  31. package/dist/host/scripts/deps.sh +38 -0
  32. package/dist/host/scripts/ollama.sh +163 -0
  33. package/dist/src/core/index.cjs +3728 -0
  34. package/dist/src/core/index.cjs.map +1 -0
  35. package/dist/src/core/index.d.cts +1941 -0
  36. package/dist/src/core/index.d.ts +1941 -0
  37. package/dist/src/core/index.js +3636 -0
  38. package/dist/src/core/index.js.map +1 -0
  39. package/dist/src/server/index.cjs +1595 -0
  40. package/dist/src/server/index.cjs.map +1 -0
  41. package/dist/src/server/index.d.cts +779 -0
  42. package/dist/src/server/index.d.ts +779 -0
  43. package/dist/src/server/index.js +1572 -0
  44. package/dist/src/server/index.js.map +1 -0
  45. package/package.json +113 -0
@@ -0,0 +1,1539 @@
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";
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";
8
+ import { createReporter, createSpinner, createStyler } from "@orkestrel/console";
9
+ import { createServerSink } from "@orkestrel/console/server";
10
+ import { isTerminalError } from "@orkestrel/terminal";
11
+ import { createTerminal } from "@orkestrel/terminal/server";
12
+ //#region src/bin/render.ts
13
+ /** The bin's closed verb vocabulary. */
14
+ var KNOWN_VERBS = Object.freeze([
15
+ "new",
16
+ "pull",
17
+ "audit",
18
+ "repair",
19
+ "fleet",
20
+ "catalog"
21
+ ]);
22
+ /** The jargon translation table — internal `Origin`/`Drift`/scope vocabulary → one user-facing register. */
23
+ var ORIGIN_LABEL = Object.freeze({
24
+ host: "template-owned",
25
+ template: "template-owned",
26
+ computed: "generated"
27
+ });
28
+ /** A `Finding`'s `Drift`, translated — `'aligned'` never reaches a rendered table (callers filter it first). */
29
+ var DRIFT_LABEL = Object.freeze({
30
+ aligned: "unchanged",
31
+ stale: "drifted",
32
+ missing: "missing",
33
+ foreign: "unexpected file"
34
+ });
35
+ /** A `Freshness` outcome, translated for `pull`'s per-entry cause notes. */
36
+ var FRESHNESS_LABEL = Object.freeze({
37
+ current: "unchanged",
38
+ behind: "behind",
39
+ missing: "missing upstream",
40
+ failed: "fetch failed"
41
+ });
42
+ /** The materializer's per-entry action words, translated (`copied` → `wrote`, `skipped` → `unchanged`). */
43
+ var ACTION_LABEL = Object.freeze({
44
+ written: "wrote",
45
+ copied: "wrote",
46
+ skipped: "unchanged",
47
+ removed: "removed"
48
+ });
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) {
67
+ const origins = new Map(plan.artifacts.map((artifact) => [artifact.path, artifact.origin]));
68
+ let ownedDrifted = 0;
69
+ let ownedMissing = 0;
70
+ let ownedForeign = 0;
71
+ let generatedDrifted = 0;
72
+ let generatedMissing = 0;
73
+ let generatedForeign = 0;
74
+ for (const finding of findings) {
75
+ const origin = origins.get(finding.path);
76
+ const isOwned = origin === "host" || origin === "template";
77
+ if (finding.drift === "aligned") continue;
78
+ else if (finding.drift === "stale") if (isOwned) ownedDrifted += 1;
79
+ else generatedDrifted += 1;
80
+ else if (finding.drift === "missing") if (isOwned) ownedMissing += 1;
81
+ else generatedMissing += 1;
82
+ else if (isOwned) ownedForeign += 1;
83
+ else generatedForeign += 1;
84
+ }
85
+ return {
86
+ owned: {
87
+ drifted: ownedDrifted,
88
+ missing: ownedMissing,
89
+ foreign: ownedForeign
90
+ },
91
+ generated: {
92
+ drifted: generatedDrifted,
93
+ missing: generatedMissing,
94
+ foreign: generatedForeign
95
+ }
96
+ };
97
+ }
98
+ /** `audit`'s verdict line — lowercase, verb-led, origin-split so template-owned health is immediately visible. */
99
+ 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)}`;
104
+ }
105
+ /** One aligned-column row per non-`aligned` `Finding` — `[status, kind, path]`, translated labels, ready for `reporter.table`. */
106
+ function findingRows(findings, plan) {
107
+ const origins = new Map(plan.artifacts.map((artifact) => [artifact.path, artifact.origin]));
108
+ return findings.filter((finding) => finding.drift !== "aligned").map((finding) => {
109
+ const origin = origins.get(finding.path);
110
+ const kind = origin === void 0 ? "unexpected file" : ORIGIN_LABEL[origin];
111
+ return [
112
+ DRIFT_LABEL[finding.drift],
113
+ kind,
114
+ finding.path
115
+ ];
116
+ });
117
+ }
118
+ /** The audit findings table — columns Status/Kind/Path, translated labels, via `reporter.table`. */
119
+ function auditTable(audit, plan) {
120
+ return {
121
+ columns: [
122
+ { label: "Status" },
123
+ { label: "Kind" },
124
+ { label: "Path" }
125
+ ],
126
+ rows: findingRows(audit.findings, plan)
127
+ };
128
+ }
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`;
135
+ }
136
+ /** `repair`'s dry-run verdict line. */
137
+ 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`;
144
+ }
145
+ /** `repair --apply`'s success line — the materializer tally, translated action words. */
146
+ function repairSuccess(result, removed) {
147
+ const written = result.written.length + result.copied.length;
148
+ return `${ACTION_LABEL.written} ${written}, ${ACTION_LABEL.skipped} ${result.skipped.length}, ${ACTION_LABEL.removed} ${removed.length}`;
149
+ }
150
+ /** `pull`'s freshness table rows — `[name, kind, freshness]`, translated. */
151
+ function pullRows(report) {
152
+ const guideRows = report.guides.map((guide) => [
153
+ guide.name,
154
+ "guide",
155
+ FRESHNESS_LABEL[guide.freshness] ?? guide.freshness
156
+ ]);
157
+ const versionRows = report.versions.map((version) => [
158
+ version.name,
159
+ "version",
160
+ FRESHNESS_LABEL[version.freshness] ?? version.freshness
161
+ ]);
162
+ return [...guideRows, ...versionRows];
163
+ }
164
+ /** `pull`'s freshness table — columns Name/Kind/Freshness. */
165
+ function pullTable(report) {
166
+ return {
167
+ columns: [
168
+ { label: "Name" },
169
+ { label: "Kind" },
170
+ { label: "Freshness" }
171
+ ],
172
+ rows: pullRows(report)
173
+ };
174
+ }
175
+ /** Per-entry cause notes for a non-`current` `pull` entry that carries a `note`. */
176
+ function pullCauseNotes(report) {
177
+ return [...report.guides, ...report.versions].filter((entry) => entry.note !== void 0).map((entry) => ` ${entry.name}: ${FRESHNESS_LABEL[entry.freshness] ?? entry.freshness} — ${entry.note}`);
178
+ }
179
+ /** `pull`'s tally line. */
180
+ function pullVerdict(report) {
181
+ return `pull: ${countPart(report.guides.length + report.versions.length, "entry")} — ${countPart(report.failed, "failed")}`;
182
+ }
183
+ /** `pull --apply`'s success line. */
184
+ function pullSuccess(count) {
185
+ return `wrote ${countPart(count, "guide")}`;
186
+ }
187
+ /** One `fleet` per-repo line — clean, drifted (dry-run), or repaired (`--apply`). */
188
+ 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)`;
196
+ return `${name}: ${outcome.message}`;
197
+ }
198
+ /** `fleet`'s blast-radius totals line. */
199
+ function fleetTotals(drifted, failed) {
200
+ return `total: ${countPart(drifted, "drifted repo")}, ${countPart(failed, "failed")}`;
201
+ }
202
+ /** The `catalog` terminal preview table — columns Package/Version (descriptions live only in the written table and `--json`). */
203
+ function catalogTable(entries) {
204
+ return {
205
+ columns: [{ label: "Package" }, { label: "Version" }],
206
+ rows: entries.map((entry) => [entry.name, entry.version])
207
+ };
208
+ }
209
+ /** The `catalog` shrink warning — `undefined` when the table did not shrink. */
210
+ function catalogShrinkWarning(oldRows, newRows) {
211
+ if (newRows >= oldRows) return void 0;
212
+ return `warning: catalog shrinks from ${countPart(oldRows, "row")} to ${newRows}`;
213
+ }
214
+ /** `catalog`'s counts line. */
215
+ function catalogCounts(published, localOnly) {
216
+ return `catalog: ${countPart(published, "published package")}, ${countPart(localOnly, "local-only")}`;
217
+ }
218
+ /** `new`'s dry-run plan preview — origin counts table description + the destination line. */
219
+ function newPlanTable(scaffolding) {
220
+ return {
221
+ columns: [{ label: "Origin" }, {
222
+ label: "Count",
223
+ align: "right"
224
+ }],
225
+ rows: [["template-owned", String(scaffolding.host + scaffolding.template)], ["generated", String(scaffolding.computed)]]
226
+ };
227
+ }
228
+ /** `new`'s dry-run destination line. */
229
+ function newPlanPreview(name) {
230
+ return `will write into ./${name}`;
231
+ }
232
+ /** `new --apply`'s success line. */
233
+ function newApplySuccess(count, name) {
234
+ return `wrote ${countPart(count, "file")} into ./${name}`;
235
+ }
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. */
239
+ function catalogApplySuccess(path) {
240
+ return `wrote ${path}`;
241
+ }
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}? `;
248
+ }
249
+ /** The prune double-confirm prompt message. */
250
+ function pruneConfirmMessage(count) {
251
+ return `Also delete ${countPart(count, "unexpected file")} under .claude/agents and scripts? `;
252
+ }
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
+ */
259
+ function repairHandoff(owned, foreign, prune) {
260
+ const parts = [];
261
+ if (owned > 0) parts.push(`${countPart(owned, "template-owned file")} ${owned === 1 ? "has" : "have"} drift`);
262
+ if (prune && foreign > 0) parts.push(`${countPart(foreign, "unexpected file")} will be deleted`);
263
+ return `${parts.join(" and ")} — run repair now? `;
264
+ }
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
+ */
278
+ 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
+ ];
307
+ }
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`. */
331
+ function shortUsage() {
332
+ return [
333
+ "scaffold <verb> [options]",
334
+ "",
335
+ ...KNOWN_VERBS.map((verb) => ` ${verb.padEnd(8)}${VERB_SUMMARY[verb]}`),
336
+ "",
337
+ "run 'scaffold <verb> --help' for a verb's full reference"
338
+ ].join("\n");
339
+ }
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. */
412
+ 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}`);
415
+ return [
416
+ "scaffold <verb> [options]",
417
+ "",
418
+ ...verbLines,
419
+ "",
420
+ SAFETY_BANNER,
421
+ "",
422
+ "exit codes:",
423
+ ...exitLines
424
+ ].join("\n");
425
+ }
426
+ /** One verb's help section — summary, dry-run note, one line per flag, and a concrete example. */
427
+ function verbHelp(verb) {
428
+ const flagLines = VERB_FLAG_HELP[verb].map(([flag, meaning]) => ` ${flag.padEnd(20)}${meaning}`);
429
+ return [
430
+ `scaffold ${verb} ${VERB_FLAGS[verb]}`,
431
+ "",
432
+ VERB_SUMMARY[verb],
433
+ VERB_DRY_RUN_NOTE[verb],
434
+ "",
435
+ ...flagLines,
436
+ "",
437
+ VERB_EXAMPLE[verb]
438
+ ].join("\n");
439
+ }
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);
450
+ }
451
+ return table[rows - 1][cols - 1];
452
+ }
453
+ /** The nearest candidate to `input` by `editDistance` — `undefined` when `set` is empty. */
454
+ function nearest(input, set) {
455
+ let best;
456
+ let bestDistance = Number.POSITIVE_INFINITY;
457
+ for (const candidate of set) {
458
+ const distance = editDistance(input, candidate);
459
+ if (distance < bestDistance) {
460
+ bestDistance = distance;
461
+ best = candidate;
462
+ }
463
+ }
464
+ return best;
465
+ }
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. */
472
+ function didYouMean(command) {
473
+ const retired = RETIRED_VERBS[command];
474
+ if (retired !== void 0) return `'${command}' has been renamed — use 'scaffold ${retired}'`;
475
+ const guess = nearest(command, [...KNOWN_VERBS]);
476
+ return guess === void 0 ? `unknown command "${command}"` : `unknown command "${command}" — did you mean "${guess}"?`;
477
+ }
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
+ } };
484
+ }
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
+ };
495
+ }
496
+ /** `pull --json`'s value — the `SyncReport` verbatim (already deterministic + JSON-safe). */
497
+ function pullJson(report) {
498
+ return report;
499
+ }
500
+ /** `audit --json`'s value — the `Audit` verbatim. */
501
+ function auditJson(audit) {
502
+ return audit;
503
+ }
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. */
561
+ function generatedNote(count) {
562
+ return `${countPart(count, "finding")} in generated files — these are regenerated, not hand-edited; repair does not touch them`;
563
+ }
564
+ /** `audit --live`'s freshness summary line. */
565
+ function auditLiveNote(current, behind, failed) {
566
+ return `live: ${countPart(current, "current")}, ${countPart(behind, "behind")}, ${countPart(failed, "failed")}`;
567
+ }
568
+ /** Translated vocabulary for whether the audit compared file contents or only file names, for template-owned files. */
569
+ 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)";
571
+ }
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. */
577
+ function catalogVerdict(clean) {
578
+ return clean ? "catalog: clean" : "catalog: drifted — pass --apply to write";
579
+ }
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) {
628
+ if (isScaffoldError(error)) return `[${error.code}] ${error.message}`;
629
+ return error instanceof Error ? error.message : "unknown error";
630
+ }
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
+ }
696
+ }
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
+ }
707
+ }
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();
712
+ return parseArgs({
713
+ args,
714
+ allowPositionals: true,
715
+ options: {
716
+ surfaces: { type: "string" },
717
+ deps: { type: "string" },
718
+ groups: { type: "string" },
719
+ target: { type: "string" },
720
+ from: {
721
+ type: "string",
722
+ multiple: true
723
+ },
724
+ apply: {
725
+ type: "boolean",
726
+ default: false
727
+ },
728
+ yes: {
729
+ type: "boolean",
730
+ default: false
731
+ },
732
+ json: {
733
+ type: "boolean",
734
+ default: false
735
+ },
736
+ prune: {
737
+ type: "boolean",
738
+ default: false
739
+ },
740
+ strict: {
741
+ type: "boolean",
742
+ default: false
743
+ },
744
+ live: {
745
+ type: "boolean",
746
+ default: false
747
+ },
748
+ offline: {
749
+ type: "boolean",
750
+ default: false
751
+ },
752
+ help: {
753
+ type: "boolean",
754
+ default: false,
755
+ short: "h"
756
+ }
757
+ }
758
+ });
759
+ }
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. */
923
+ function splitTokens(raw) {
924
+ return raw.split(",").map((token) => token.trim()).filter((token) => token.length > 0);
925
+ }
926
+ /** Normalize a Q1 token to a full `@orkestrel/<name>` — an already-prefixed token passes through unchanged. */
927
+ function normalizeOrkestrelToken(token) {
928
+ return token.startsWith("@orkestrel/") ? token : `@orkestrel/${token}`;
929
+ }
930
+ /**
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.
936
+ */
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
+ }
945
+ }
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));
951
+ }
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);
964
+ }
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);
1001
+ }
1002
+ const sync = createSync();
1003
+ let versions;
1004
+ try {
1005
+ versions = await sync.versions(depNames.map((depName) => dependency(depName, "*")));
1006
+ } finally {
1007
+ sync.destroy();
1008
+ }
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));
1021
+ }
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;
1027
+ }
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();
1040
+ }
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;
1050
+ 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);
1085
+ }
1086
+ } else if (json) writeJson(pullJson(report));
1087
+ process.exitCode = report.clean ? 0 : proceed ? 0 : 1;
1088
+ } finally {
1089
+ sync.destroy();
1090
+ }
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);
1100
+ }
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));
1112
+ }
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);
1121
+ }
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();
1134
+ 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();
1148
+ }
1149
+ }
1150
+ if (json) {
1151
+ writeJson(live === void 0 ? auditJson(audit) : {
1152
+ ...auditJson(audit),
1153
+ live
1154
+ });
1155
+ process.exitCode = drifted ? 1 : 0;
1156
+ return;
1157
+ }
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));
1192
+ }
1193
+ }
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);
1204
+ }
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);
1217
+ }
1218
+ let audit;
1219
+ try {
1220
+ audit = diffPlan(plan, readTarget(target, plan.artifacts.map((artifact) => artifact.path)));
1221
+ } catch (error) {
1222
+ failError(error, json);
1223
+ }
1224
+ if (!json) {
1225
+ reporter.line(REPAIR_SCOPE);
1226
+ reporter.section("Audit");
1227
+ reporter.table(auditTable(audit, plan));
1228
+ }
1229
+ const prunePaths = values.prune && existsSync(host) ? pruneTargets(target, host) : [];
1230
+ if (audit.clean && prunePaths.length === 0) {
1231
+ if (json) writeJson(repairJson(audit));
1232
+ else {
1233
+ reporter.line(repairVerdict(audit));
1234
+ const note = scopeNote(repairOutsideCount(compiled, target));
1235
+ if (note !== void 0) reporter.line(note);
1236
+ }
1237
+ process.exitCode = 0;
1238
+ return;
1239
+ }
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();
1267
+ }
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);
1283
+ try {
1284
+ const compiler = createCompiler();
1285
+ let scoped;
1286
+ 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")
1293
+ };
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
+ } finally {
1299
+ compiler.destroy();
1300
+ }
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)
1316
+ });
1317
+ }
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
+ }));
1375
+ }
1376
+ } finally {
1377
+ materializer.destroy();
1378
+ }
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()];
1393
+ try {
1394
+ entries = catalogPackages(roots);
1395
+ } catch (error) {
1396
+ failError(error, json);
1397
+ }
1398
+ } else {
1399
+ const sync = createSync({ on: { package: (name, note) => {
1400
+ if (note !== "") notes.set(name, note);
1401
+ } } });
1402
+ let registryEntries;
1403
+ try {
1404
+ registryEntries = await sync.catalog();
1405
+ } catch (error) {
1406
+ failError(error, json);
1407
+ } finally {
1408
+ sync.destroy();
1409
+ }
1410
+ published = registryEntries.length;
1411
+ let localEntries = [];
1412
+ if (explicitRoots !== void 0) try {
1413
+ localEntries = catalogPackages(explicitRoots);
1414
+ } catch (error) {
1415
+ failError(error, json);
1416
+ }
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
+ });
1428
+ }
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
+ process.exitCode = 0;
1454
+ return;
1455
+ }
1456
+ if (!json) {
1457
+ reporter.table(catalogTable(entries));
1458
+ const warning = catalogShrinkWarning(oldRows, entries.length);
1459
+ if (warning !== void 0) reporter.line(warning);
1460
+ 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(", ")}`);
1463
+ } else {
1464
+ reporter.line(catalogCounts(published, localOnly));
1465
+ for (const [name, note] of notes) reporter.line(` ${name}: ${note}`);
1466
+ }
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}`, {
1478
+ path: agentPath,
1479
+ error
1480
+ }), 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`);
1514
+ process.exitCode = 0;
1515
+ return;
1516
+ }
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;
1535
+ }
1536
+ }
1537
+ //#endregion
1538
+
1539
+ //# sourceMappingURL=scaffold.js.map