@ybotok/loam 0.0.0 → 0.1.0-beta.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 (106) hide show
  1. package/CHANGELOG.md +89 -0
  2. package/COMPARISON.md +63 -0
  3. package/LICENSE +21 -0
  4. package/MIGRATING-from-OpenSpec.md +202 -0
  5. package/README.md +241 -9
  6. package/SCHEMA.md +425 -0
  7. package/SECURITY.md +9 -0
  8. package/dist/cli.js +138 -0
  9. package/dist/cli.js.map +1 -0
  10. package/dist/commands/adopt.js +226 -0
  11. package/dist/commands/adopt.js.map +1 -0
  12. package/dist/commands/archive.js +1297 -0
  13. package/dist/commands/archive.js.map +1 -0
  14. package/dist/commands/delta.js +361 -0
  15. package/dist/commands/delta.js.map +1 -0
  16. package/dist/commands/dependencies.js +78 -0
  17. package/dist/commands/dependencies.js.map +1 -0
  18. package/dist/commands/doctor.js +85 -0
  19. package/dist/commands/doctor.js.map +1 -0
  20. package/dist/commands/gherkin.js +270 -0
  21. package/dist/commands/gherkin.js.map +1 -0
  22. package/dist/commands/init.js +250 -0
  23. package/dist/commands/init.js.map +1 -0
  24. package/dist/commands/list.js +361 -0
  25. package/dist/commands/list.js.map +1 -0
  26. package/dist/commands/migrate-openspec.js +933 -0
  27. package/dist/commands/migrate-openspec.js.map +1 -0
  28. package/dist/commands/new.js +412 -0
  29. package/dist/commands/new.js.map +1 -0
  30. package/dist/commands/rebase.js +358 -0
  31. package/dist/commands/rebase.js.map +1 -0
  32. package/dist/commands/show.js +325 -0
  33. package/dist/commands/show.js.map +1 -0
  34. package/dist/commands/status.js +270 -0
  35. package/dist/commands/status.js.map +1 -0
  36. package/dist/commands/unarchive.js +310 -0
  37. package/dist/commands/unarchive.js.map +1 -0
  38. package/dist/commands/validate.js +1609 -0
  39. package/dist/commands/validate.js.map +1 -0
  40. package/dist/commands/verify.js +759 -0
  41. package/dist/commands/verify.js.map +1 -0
  42. package/dist/commands/vouch.js +325 -0
  43. package/dist/commands/vouch.js.map +1 -0
  44. package/dist/core/agent.js +1823 -0
  45. package/dist/core/agent.js.map +1 -0
  46. package/dist/core/agents-stamp.js +68 -0
  47. package/dist/core/agents-stamp.js.map +1 -0
  48. package/dist/core/arch.js +104 -0
  49. package/dist/core/arch.js.map +1 -0
  50. package/dist/core/brief.js +733 -0
  51. package/dist/core/brief.js.map +1 -0
  52. package/dist/core/coherence.js +553 -0
  53. package/dist/core/coherence.js.map +1 -0
  54. package/dist/core/config.js +172 -0
  55. package/dist/core/config.js.map +1 -0
  56. package/dist/core/delta.js +457 -0
  57. package/dist/core/delta.js.map +1 -0
  58. package/dist/core/dependencies.js +352 -0
  59. package/dist/core/dependencies.js.map +1 -0
  60. package/dist/core/docs.js +143 -0
  61. package/dist/core/docs.js.map +1 -0
  62. package/dist/core/doctor.js +544 -0
  63. package/dist/core/doctor.js.map +1 -0
  64. package/dist/core/fleet-context.js +272 -0
  65. package/dist/core/fleet-context.js.map +1 -0
  66. package/dist/core/frontmatter.js +203 -0
  67. package/dist/core/frontmatter.js.map +1 -0
  68. package/dist/core/gherkin.js +521 -0
  69. package/dist/core/gherkin.js.map +1 -0
  70. package/dist/core/health.js +71 -0
  71. package/dist/core/health.js.map +1 -0
  72. package/dist/core/ids.js +93 -0
  73. package/dist/core/ids.js.map +1 -0
  74. package/dist/core/issue.js +9 -0
  75. package/dist/core/issue.js.map +1 -0
  76. package/dist/core/json.js +67 -0
  77. package/dist/core/json.js.map +1 -0
  78. package/dist/core/likec4.js +474 -0
  79. package/dist/core/likec4.js.map +1 -0
  80. package/dist/core/openapi-merge.js +519 -0
  81. package/dist/core/openapi-merge.js.map +1 -0
  82. package/dist/core/openapi.js +243 -0
  83. package/dist/core/openapi.js.map +1 -0
  84. package/dist/core/openspec-inventory.js +1403 -0
  85. package/dist/core/openspec-inventory.js.map +1 -0
  86. package/dist/core/path-safety.js +109 -0
  87. package/dist/core/path-safety.js.map +1 -0
  88. package/dist/core/provenance.js +695 -0
  89. package/dist/core/provenance.js.map +1 -0
  90. package/dist/core/repo.js +362 -0
  91. package/dist/core/repo.js.map +1 -0
  92. package/dist/core/report.js +38 -0
  93. package/dist/core/report.js.map +1 -0
  94. package/dist/core/results.js +201 -0
  95. package/dist/core/results.js.map +1 -0
  96. package/dist/core/spec.js +481 -0
  97. package/dist/core/spec.js.map +1 -0
  98. package/dist/core/staging.js +783 -0
  99. package/dist/core/staging.js.map +1 -0
  100. package/dist/core/status.js +1015 -0
  101. package/dist/core/status.js.map +1 -0
  102. package/dist/core/verify.js +793 -0
  103. package/dist/core/verify.js.map +1 -0
  104. package/dist/core/version.js +8 -0
  105. package/dist/core/version.js.map +1 -0
  106. package/package.json +63 -4
@@ -0,0 +1,270 @@
1
+ import { existsSync } from "node:fs";
2
+ import { mkdir, readFile, unlink, writeFile } from "node:fs/promises";
3
+ import { join, relative } from "node:path";
4
+ import { loadConfig } from "../core/config.js";
5
+ import { decodeDocument, NotUtf8DocumentError } from "../core/fleet-context.js";
6
+ import { emitJson, emitJsonError, fail, reportNoConfig } from "../core/json.js";
7
+ import { featureSpecPaths, listFeatures, missingFeatureMessage, resolveFeature, servicePaths, SPEC_AXES, } from "../core/repo.js";
8
+ import { parseRequirements } from "../core/spec.js";
9
+ import { axisLabel, featureFilesUnder, gherkinRoot, parseStampedFeature, planEmission, } from "../core/gherkin.js";
10
+ import { LOAM_VERSION } from "../core/version.js";
11
+ import { resolveInside, UnsafePathError } from "../core/path-safety.js";
12
+ export function registerGherkin(program) {
13
+ program
14
+ .command("gherkin")
15
+ .argument("[featureId]", "feature id — emit its ADDED/MODIFIED requirements; omit for the full living suite")
16
+ .description("Emit Gherkin .feature files from spec scenarios into this service repo's <gherkinDir>/loam/")
17
+ .option("--service <id>", "service to emit for (defaults to the configured service)")
18
+ .option("--dry-run", "print the plan (writes, replacements, deletions) and write nothing")
19
+ .option("--json", "emit the machine contract instead of the human view")
20
+ .action(async (featureArg, opts) => {
21
+ const json = opts.json === true;
22
+ const dryRun = opts.dryRun === true;
23
+ const config = await loadConfig();
24
+ if (!config) {
25
+ reportNoConfig(json);
26
+ return;
27
+ }
28
+ const service = opts.service ?? config.service;
29
+ if (service === undefined) {
30
+ return fail(json, "invalid-option", "No service. Pass --service <id> or set it in loam.json.");
31
+ }
32
+ // The emission writes into the repo loam is standing in, so it can only
33
+ // run where that repo is this service's — vouch's refusal, verbatim logic.
34
+ if (config.service !== service) {
35
+ const here = config.service === undefined ? "this is not a service repo" : `this repo is '${config.service}'`;
36
+ return fail(json, "invalid-option", `Cannot emit gherkin for '${service}' from here: ${here}. Generated .feature files land in the service's own repo — run it there.`);
37
+ }
38
+ // The repo root, not the cwd: `config.root` is the directory loam.json
39
+ // was actually found in, and config discovery walks upward. Taken from
40
+ // `process.cwd()` this emitted the whole suite into whatever directory
41
+ // the caller happened to stand in — `src/api/features/loam/…` inside the
42
+ // source tree, which the next run from the root neither finds, replaces
43
+ // nor deletes, and which `loam validate` grades as a suite that was never
44
+ // generated.
45
+ const repoDir = config.root ?? process.cwd();
46
+ let root;
47
+ try {
48
+ root = gherkinRoot(repoDir, config.gherkinDir);
49
+ }
50
+ catch (err) {
51
+ if (!(err instanceof UnsafePathError))
52
+ throw err;
53
+ return fail(json, "invalid-option", `Cannot emit Gherkin: ${err.message}. The output directory must stay inside the service repo.`);
54
+ }
55
+ const rel = (abs) => relative(repoDir, abs).split(/[\\/]/).join("/");
56
+ // The requirement set, by scope. Feature mode takes ADDED and MODIFIED —
57
+ // a BASE requirement is the living state quoted, a REMOVED one is being
58
+ // retired, and neither is behaviour anybody is about to test. Living mode
59
+ // takes everything the living files hold.
60
+ //
61
+ // Read through `decodeDocument`, not `readFile(…, "utf8")`, and refused
62
+ // rather than degraded: a spec.md saved as UTF-16 decodes to a document
63
+ // with no requirements in it, which here is not merely a wrong answer but
64
+ // a destructive one — an empty plan makes every existing .feature under
65
+ // loam/ an orphan, and a living run DELETES its orphans. The whole
66
+ // generated suite would go because a docs file was written by PowerShell.
67
+ let mode = "living";
68
+ let featureId;
69
+ const byAxis = [];
70
+ const readSpec = async (path) => parseRequirements(decodeDocument(await readFile(path), path));
71
+ try {
72
+ if (featureArg !== undefined) {
73
+ const feature = await resolveFeature(config.docsDir, featureArg, "exclude");
74
+ if (!feature) {
75
+ return fail(json, "unknown-target", await missingFeatureMessage(config.docsDir, featureArg));
76
+ }
77
+ mode = "feature";
78
+ featureId = feature.id;
79
+ const paths = featureSpecPaths(feature.dir, service);
80
+ for (const axis of SPEC_AXES) {
81
+ const path = paths[axis.key];
82
+ const reqs = existsSync(path)
83
+ ? (await readSpec(path)).filter((r) => r.kind === "ADDED" || r.kind === "MODIFIED")
84
+ : [];
85
+ byAxis.push({ axis, reqs });
86
+ }
87
+ }
88
+ else {
89
+ const paths = servicePaths(config.docsDir, service);
90
+ if (!existsSync(paths.spec)) {
91
+ return fail(json, "unknown-target", `No living spec at ${paths.spec}. Run \`loam adopt\` for '${service}' first.`);
92
+ }
93
+ for (const axis of SPEC_AXES) {
94
+ const path = paths[axis.key];
95
+ const reqs = existsSync(path) ? (await readSpec(path)).filter((r) => r.kind !== "REMOVED") : [];
96
+ byAxis.push({ axis, reqs });
97
+ }
98
+ }
99
+ }
100
+ catch (err) {
101
+ if (!(err instanceof NotUtf8DocumentError))
102
+ throw err;
103
+ return fail(json, "repository-unavailable", `Cannot emit gherkin: ${err.message}`);
104
+ }
105
+ // `service` is not decoration: it salts every `@loam-digest-…` stamp, and
106
+ // it is the same string `loam verify` files the matching claims under.
107
+ const plan = planEmission(byAxis, { service, featureTag: featureId, version: LOAM_VERSION });
108
+ // Orphans of THIS scope, inside loam/ and nowhere else. Feature mode owns
109
+ // only its own emissions: stamped files carrying this feature's tag whose
110
+ // file is no longer in the plan (a requirement renamed or dropped).
111
+ // Living mode owns the whole suite — any .feature not in the plan goes —
112
+ // EXCEPT files tagged with a feature still in flight: those answer to
113
+ // their feature's delta until it archives, and `loam gherkin <FEAT>` is
114
+ // their regeneration.
115
+ const planned = new Set(plan.map((f) => f.fileName));
116
+ // Needed by BOTH modes now: living mode exempts in-flight files from
117
+ // deletion and replacement, and feature mode has to recognise another
118
+ // feature's in-flight file to refuse overwriting it.
119
+ const activeIds = new Set((await listFeatures(config.docsDir)).map((f) => f.id));
120
+ const orphans = [];
121
+ if (existsSync(root)) {
122
+ for (const abs of await featureFilesUnder(root)) {
123
+ if (planned.has(relative(root, abs).split(/[\\/]/).join("/")))
124
+ continue;
125
+ const stamped = parseStampedFeature(await readFile(abs, "utf8"));
126
+ if (mode === "feature") {
127
+ if (stamped !== null && stamped.tags.includes(featureId))
128
+ orphans.push(abs);
129
+ }
130
+ else {
131
+ if (stamped !== null && stamped.tags.some((t) => activeIds.has(t)))
132
+ continue;
133
+ orphans.push(abs);
134
+ }
135
+ }
136
+ }
137
+ const actions = [];
138
+ for (const f of plan) {
139
+ let path;
140
+ try {
141
+ // Check the final file as well as the owned root: a pre-planted
142
+ // `<slug>.feature` symlink must not turn writeFile into an overwrite
143
+ // outside the repository.
144
+ path = resolveInside(repoDir, relative(repoDir, join(root, f.fileName)), `gherkin file '${f.fileName}'`);
145
+ }
146
+ catch (err) {
147
+ if (!(err instanceof UnsafePathError))
148
+ throw err;
149
+ return fail(json, "invalid-option", `Cannot emit Gherkin: ${err.message}.`);
150
+ }
151
+ if (!existsSync(path)) {
152
+ actions.push({ ...f, path, action: "written" });
153
+ continue;
154
+ }
155
+ const existing = parseStampedFeature(await readFile(path, "utf8"));
156
+ if (mode === "living") {
157
+ if (existing !== null && existing.tags.some((t) => activeIds.has(t))) {
158
+ actions.push({ ...f, path, action: "kept", kept: existing });
159
+ continue;
160
+ }
161
+ }
162
+ else {
163
+ const owners = existing === null
164
+ ? []
165
+ : existing.tags.filter((t) => t !== featureId && activeIds.has(t));
166
+ if (owners.length > 0) {
167
+ actions.push({ ...f, path, action: "conflict", kept: existing, owners });
168
+ continue;
169
+ }
170
+ }
171
+ actions.push({ ...f, path, action: "replaced" });
172
+ }
173
+ // All or nothing: one conflicting file refuses the whole emission, so a
174
+ // half-written suite can never be the state an agent has to reason about.
175
+ const conflicts = actions.filter((a) => a.action === "conflict");
176
+ if (conflicts.length > 0) {
177
+ const detail = conflicts
178
+ .map((c) => `${rel(c.path)} is @${c.owners.join(" @")} (requirement '${c.requirement.name}')`)
179
+ .join("; ");
180
+ const message = `Cannot emit gherkin for ${featureId}: ${conflicts.length} file(s) belong to another feature still in flight — ${detail}. ` +
181
+ `A .feature file carries one feature's delta; overwriting would revert that feature's wording and destroy the digest stamps \`loam verify --results\` matches on. ` +
182
+ `Archive (or abandon) the owning feature first, or rename the requirement in ${featureId}'s delta so the two stop sharing a file name.`;
183
+ if (json) {
184
+ emitJsonError("gherkin-conflict", message, {
185
+ conflicts: conflicts.map((c) => ({
186
+ path: rel(c.path),
187
+ action: "conflict",
188
+ requirement: c.requirement.name,
189
+ inFlight: c.owners,
190
+ })),
191
+ });
192
+ return;
193
+ }
194
+ return fail(json, "gherkin-conflict", message);
195
+ }
196
+ const writes = actions.filter((a) => a.action !== "kept");
197
+ if (!dryRun) {
198
+ // The root is created only when something lands in it: an emission with
199
+ // nothing to emit must not flip the repo into "opted in" — an empty
200
+ // loam/ tells `loam validate` the whole living suite is missing.
201
+ if (writes.length > 0)
202
+ await mkdir(root, { recursive: true });
203
+ for (const a of writes)
204
+ await writeFile(a.path, a.content, "utf8");
205
+ for (const o of orphans)
206
+ await unlink(o);
207
+ }
208
+ if (json) {
209
+ emitJson({
210
+ mode,
211
+ ...(featureId === undefined ? {} : { feature: featureId }),
212
+ service,
213
+ root: rel(root),
214
+ written: !dryRun,
215
+ files: actions.map((a) => ({
216
+ path: rel(a.path),
217
+ action: a.action,
218
+ axis: axisLabel(a.axis),
219
+ requirement: a.requirement.name,
220
+ // A kept file's numbers describe what STAYS on disk (the in-flight
221
+ // emission), not the emission that was withheld.
222
+ scenarios: a.action === "kept" ? a.kept.scenarios.length : a.digests.length,
223
+ digests: a.action === "kept" ? a.kept.scenarios.map((s) => s.digest) : a.digests,
224
+ ...(a.action === "kept"
225
+ ? { inFlight: a.kept.tags.filter((t) => activeIds.has(t)) }
226
+ : { stepless: a.stepless }),
227
+ })),
228
+ deleted: orphans.map(rel),
229
+ });
230
+ return;
231
+ }
232
+ const head = mode === "feature" ? `${featureId} · ${service}` : `${service} (living suite)`;
233
+ console.log(`gherkin ${head} → ${rel(root)}/${dryRun ? " (dry run)" : ""}\n`);
234
+ if (actions.length === 0) {
235
+ console.log(mode === "feature"
236
+ ? ` ${featureId} has no ADDED or MODIFIED requirements for ${service} — nothing to emit.`
237
+ : ` the living specs hold no requirements for ${service} — nothing to emit.`);
238
+ }
239
+ // `conflict` never reaches here — the run refused above — but the map is
240
+ // total so a future action cannot silently print `undefined`.
241
+ const VERB = {
242
+ written: "write ",
243
+ replaced: "replace",
244
+ kept: "keep ",
245
+ conflict: "CONFLICT",
246
+ };
247
+ for (const a of actions) {
248
+ if (a.action === "kept") {
249
+ const owners = a.kept.tags.filter((t) => activeIds.has(t));
250
+ console.log(` keep ${a.fileName} — ${a.requirement.name} (in flight: @${owners.join(" @")} — \`loam gherkin ${owners[0]}\` regenerates it)`);
251
+ continue;
252
+ }
253
+ const n = a.digests.length;
254
+ const arch = a.axis.key === "archSpec" ? ", arch" : "";
255
+ console.log(` ${VERB[a.action]} ${a.fileName} — ${a.requirement.name} (${n} scenario${n === 1 ? "" : "s"}${arch})`);
256
+ for (const name of a.stepless) {
257
+ console.log(` ⚠ scenario '${name}' has NO recognizable steps — cucumber runs it vacuously green and \`verify --results\` can never confirm it; reword its body as \`- **Given/When/Then**\` bullets`);
258
+ }
259
+ }
260
+ for (const o of orphans)
261
+ console.log(` delete ${relative(root, o).split(/[\\/]/).join("/")} — no longer in this scope`);
262
+ const wrote = `${writes.length} file(s)`;
263
+ const keptNote = actions.length > writes.length ? `, ${actions.length - writes.length} kept in flight` : "";
264
+ const dropped = orphans.length > 0 ? `, ${orphans.length} deletion(s)` : "";
265
+ console.log(dryRun
266
+ ? `\n ${wrote}${keptNote}${dropped} — dry run, nothing was written.`
267
+ : `\n ${wrote} written${keptNote}${dropped}. Write step definitions OUTSIDE ${rel(root)}/ — regeneration rewrites it.`);
268
+ });
269
+ }
270
+ //# sourceMappingURL=gherkin.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"gherkin.js","sourceRoot":"","sources":["../../src/commands/gherkin.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * `loam gherkin` — the third writer in the CLI (after archive and vouch), and\n * the only one that writes into the SERVICE repo, never the docs repo: the\n * docs state what must be true, the tests live with the code that makes it so.\n *\n * Two scopes, one emitter. `loam gherkin <FEAT>` emits the feature's ADDED and\n * MODIFIED requirements for this service — both spec axes — as the acceptance\n * skeleton to implement against, each file tagged with the feature id.\n * `loam gherkin --service <id>` (no feature) emits the full living suite from\n * spec.md + arch.spec.md: the regression skeleton a legacy service gets at\n * adoption. Either way the output is `<gherkinDir>/loam/` inside this repo,\n * a directory loam owns outright: the scope's files are rewritten, the scope's\n * orphans are deleted (and reported), and nothing outside `loam/` is touched —\n * except that a living run neither deletes NOR overwrites a file tagged with a\n * feature still in flight: it answers to that feature's delta until it\n * archives, and is reported as kept. A FEATURE run cannot keep — the file it\n * wants holds one feature's delta — so when the file it would write belongs to\n * a different feature still in flight, the whole run refuses (`gherkin-conflict`)\n * and names the owner rather than reverting it.\n *\n * It refuses to run anywhere but the service's own repo — vouch's discipline,\n * for vouch's reason: the output lands in the repo loam is standing in, and\n * from anywhere else that repo is somebody else's.\n */\nimport type { Command } from \"commander\";\nimport { existsSync } from \"node:fs\";\nimport { mkdir, readFile, unlink, writeFile } from \"node:fs/promises\";\nimport { join, relative } from \"node:path\";\nimport { loadConfig } from \"../core/config.js\";\nimport { decodeDocument, NotUtf8DocumentError } from \"../core/fleet-context.js\";\nimport { emitJson, emitJsonError, fail, reportNoConfig } from \"../core/json.js\";\nimport {\n featureSpecPaths,\n listFeatures,\n missingFeatureMessage,\n resolveFeature,\n servicePaths,\n SPEC_AXES,\n type SpecAxis,\n} from \"../core/repo.js\";\nimport { parseRequirements, type Requirement } from \"../core/spec.js\";\nimport {\n axisLabel,\n featureFilesUnder,\n gherkinRoot,\n parseStampedFeature,\n planEmission,\n type PlannedFeature,\n type StampedFeature,\n} from \"../core/gherkin.js\";\nimport { LOAM_VERSION } from \"../core/version.js\";\nimport { resolveInside, UnsafePathError } from \"../core/path-safety.js\";\n\ninterface GherkinOptions {\n service?: string;\n dryRun?: boolean;\n json?: boolean;\n}\n\nexport function registerGherkin(program: Command): void {\n program\n .command(\"gherkin\")\n .argument(\"[featureId]\", \"feature id — emit its ADDED/MODIFIED requirements; omit for the full living suite\")\n .description(\n \"Emit Gherkin .feature files from spec scenarios into this service repo's <gherkinDir>/loam/\",\n )\n .option(\"--service <id>\", \"service to emit for (defaults to the configured service)\")\n .option(\"--dry-run\", \"print the plan (writes, replacements, deletions) and write nothing\")\n .option(\"--json\", \"emit the machine contract instead of the human view\")\n .action(async (featureArg: string | undefined, opts: GherkinOptions) => {\n const json = opts.json === true;\n const dryRun = opts.dryRun === true;\n\n const config = await loadConfig();\n if (!config) {\n reportNoConfig(json);\n return;\n }\n const service = opts.service ?? config.service;\n if (service === undefined) {\n return fail(json, \"invalid-option\", \"No service. Pass --service <id> or set it in loam.json.\");\n }\n // The emission writes into the repo loam is standing in, so it can only\n // run where that repo is this service's — vouch's refusal, verbatim logic.\n if (config.service !== service) {\n const here =\n config.service === undefined ? \"this is not a service repo\" : `this repo is '${config.service}'`;\n return fail(\n json,\n \"invalid-option\",\n `Cannot emit gherkin for '${service}' from here: ${here}. Generated .feature files land in the service's own repo — run it there.`,\n );\n }\n\n // The repo root, not the cwd: `config.root` is the directory loam.json\n // was actually found in, and config discovery walks upward. Taken from\n // `process.cwd()` this emitted the whole suite into whatever directory\n // the caller happened to stand in — `src/api/features/loam/…` inside the\n // source tree, which the next run from the root neither finds, replaces\n // nor deletes, and which `loam validate` grades as a suite that was never\n // generated.\n const repoDir = config.root ?? process.cwd();\n let root: string;\n try {\n root = gherkinRoot(repoDir, config.gherkinDir);\n } catch (err) {\n if (!(err instanceof UnsafePathError)) throw err;\n return fail(\n json,\n \"invalid-option\",\n `Cannot emit Gherkin: ${err.message}. The output directory must stay inside the service repo.`,\n );\n }\n const rel = (abs: string): string => relative(repoDir, abs).split(/[\\\\/]/).join(\"/\");\n\n // The requirement set, by scope. Feature mode takes ADDED and MODIFIED —\n // a BASE requirement is the living state quoted, a REMOVED one is being\n // retired, and neither is behaviour anybody is about to test. Living mode\n // takes everything the living files hold.\n //\n // Read through `decodeDocument`, not `readFile(…, \"utf8\")`, and refused\n // rather than degraded: a spec.md saved as UTF-16 decodes to a document\n // with no requirements in it, which here is not merely a wrong answer but\n // a destructive one — an empty plan makes every existing .feature under\n // loam/ an orphan, and a living run DELETES its orphans. The whole\n // generated suite would go because a docs file was written by PowerShell.\n let mode: \"feature\" | \"living\" = \"living\";\n let featureId: string | undefined;\n const byAxis: Array<{ axis: SpecAxis; reqs: Requirement[] }> = [];\n const readSpec = async (path: string): Promise<Requirement[]> =>\n parseRequirements(decodeDocument(await readFile(path), path));\n try {\n if (featureArg !== undefined) {\n const feature = await resolveFeature(config.docsDir, featureArg, \"exclude\");\n if (!feature) {\n return fail(json, \"unknown-target\", await missingFeatureMessage(config.docsDir, featureArg));\n }\n mode = \"feature\";\n featureId = feature.id;\n const paths = featureSpecPaths(feature.dir, service);\n for (const axis of SPEC_AXES) {\n const path = paths[axis.key];\n const reqs = existsSync(path)\n ? (await readSpec(path)).filter((r) => r.kind === \"ADDED\" || r.kind === \"MODIFIED\")\n : [];\n byAxis.push({ axis, reqs });\n }\n } else {\n const paths = servicePaths(config.docsDir, service);\n if (!existsSync(paths.spec)) {\n return fail(\n json,\n \"unknown-target\",\n `No living spec at ${paths.spec}. Run \\`loam adopt\\` for '${service}' first.`,\n );\n }\n for (const axis of SPEC_AXES) {\n const path = paths[axis.key];\n const reqs = existsSync(path) ? (await readSpec(path)).filter((r) => r.kind !== \"REMOVED\") : [];\n byAxis.push({ axis, reqs });\n }\n }\n } catch (err) {\n if (!(err instanceof NotUtf8DocumentError)) throw err;\n return fail(json, \"repository-unavailable\", `Cannot emit gherkin: ${err.message}`);\n }\n\n // `service` is not decoration: it salts every `@loam-digest-…` stamp, and\n // it is the same string `loam verify` files the matching claims under.\n const plan = planEmission(byAxis, { service, featureTag: featureId, version: LOAM_VERSION });\n\n // Orphans of THIS scope, inside loam/ and nowhere else. Feature mode owns\n // only its own emissions: stamped files carrying this feature's tag whose\n // file is no longer in the plan (a requirement renamed or dropped).\n // Living mode owns the whole suite — any .feature not in the plan goes —\n // EXCEPT files tagged with a feature still in flight: those answer to\n // their feature's delta until it archives, and `loam gherkin <FEAT>` is\n // their regeneration.\n const planned = new Set(plan.map((f) => f.fileName));\n // Needed by BOTH modes now: living mode exempts in-flight files from\n // deletion and replacement, and feature mode has to recognise another\n // feature's in-flight file to refuse overwriting it.\n const activeIds = new Set((await listFeatures(config.docsDir)).map((f) => f.id));\n const orphans: string[] = [];\n if (existsSync(root)) {\n for (const abs of await featureFilesUnder(root)) {\n if (planned.has(relative(root, abs).split(/[\\\\/]/).join(\"/\"))) continue;\n const stamped = parseStampedFeature(await readFile(abs, \"utf8\"));\n if (mode === \"feature\") {\n if (stamped !== null && stamped.tags.includes(featureId!)) orphans.push(abs);\n } else {\n if (stamped !== null && stamped.tags.some((t) => activeIds.has(t))) continue;\n orphans.push(abs);\n }\n }\n }\n\n // The in-flight exemption guards the OVERWRITE path too, not only the\n // orphan scan above: files are named by requirement slug in both modes,\n // so a MODIFIED requirement's living emission always collides with the\n // active feature's file — and replacing it reverted the delta's wording\n // mid-flight, feature tag and new digest stamps destroyed, invisibly\n // (the reverted file grades current against the living spec). A planned\n // path whose existing content is stamped and tagged with a feature still\n // in flight is KEPT and reported as such; it answers to its feature's\n // delta until the feature archives, and then living regeneration\n // replaces it normally.\n //\n // FEATURE mode has the same collision and cannot solve it by keeping:\n // two features in flight against one requirement slug want the same\n // file, and whichever runs second used to `replace` — silently reverting\n // the other feature's wording, feature tag and digest stamps, so its\n // `verify --results` could never confirm a scenario again. Nothing loam\n // can write is right here (the file holds ONE feature's delta), so the\n // run refuses and names the owner: the two features have to be sequenced,\n // or the requirement renamed.\n type Action = \"written\" | \"replaced\" | \"kept\" | \"conflict\";\n const actions: Array<\n PlannedFeature & { path: string; action: Action; kept?: StampedFeature; owners?: string[] }\n > = [];\n for (const f of plan) {\n let path: string;\n try {\n // Check the final file as well as the owned root: a pre-planted\n // `<slug>.feature` symlink must not turn writeFile into an overwrite\n // outside the repository.\n path = resolveInside(\n repoDir,\n relative(repoDir, join(root, f.fileName)),\n `gherkin file '${f.fileName}'`,\n );\n } catch (err) {\n if (!(err instanceof UnsafePathError)) throw err;\n return fail(json, \"invalid-option\", `Cannot emit Gherkin: ${err.message}.`);\n }\n if (!existsSync(path)) {\n actions.push({ ...f, path, action: \"written\" });\n continue;\n }\n const existing = parseStampedFeature(await readFile(path, \"utf8\"));\n if (mode === \"living\") {\n if (existing !== null && existing.tags.some((t) => activeIds.has(t))) {\n actions.push({ ...f, path, action: \"kept\", kept: existing });\n continue;\n }\n } else {\n const owners =\n existing === null\n ? []\n : existing.tags.filter((t) => t !== featureId && activeIds.has(t));\n if (owners.length > 0) {\n actions.push({ ...f, path, action: \"conflict\", kept: existing!, owners });\n continue;\n }\n }\n actions.push({ ...f, path, action: \"replaced\" });\n }\n\n // All or nothing: one conflicting file refuses the whole emission, so a\n // half-written suite can never be the state an agent has to reason about.\n const conflicts = actions.filter((a) => a.action === \"conflict\");\n if (conflicts.length > 0) {\n const detail = conflicts\n .map((c) => `${rel(c.path)} is @${c.owners!.join(\" @\")} (requirement '${c.requirement.name}')`)\n .join(\"; \");\n const message =\n `Cannot emit gherkin for ${featureId}: ${conflicts.length} file(s) belong to another feature still in flight — ${detail}. ` +\n `A .feature file carries one feature's delta; overwriting would revert that feature's wording and destroy the digest stamps \\`loam verify --results\\` matches on. ` +\n `Archive (or abandon) the owning feature first, or rename the requirement in ${featureId}'s delta so the two stop sharing a file name.`;\n if (json) {\n emitJsonError(\"gherkin-conflict\", message, {\n conflicts: conflicts.map((c) => ({\n path: rel(c.path),\n action: \"conflict\",\n requirement: c.requirement.name,\n inFlight: c.owners,\n })),\n });\n return;\n }\n return fail(json, \"gherkin-conflict\", message);\n }\n\n const writes = actions.filter((a) => a.action !== \"kept\");\n\n if (!dryRun) {\n // The root is created only when something lands in it: an emission with\n // nothing to emit must not flip the repo into \"opted in\" — an empty\n // loam/ tells `loam validate` the whole living suite is missing.\n if (writes.length > 0) await mkdir(root, { recursive: true });\n for (const a of writes) await writeFile(a.path, a.content, \"utf8\");\n for (const o of orphans) await unlink(o);\n }\n\n if (json) {\n emitJson({\n mode,\n ...(featureId === undefined ? {} : { feature: featureId }),\n service,\n root: rel(root),\n written: !dryRun,\n files: actions.map((a) => ({\n path: rel(a.path),\n action: a.action,\n axis: axisLabel(a.axis),\n requirement: a.requirement.name,\n // A kept file's numbers describe what STAYS on disk (the in-flight\n // emission), not the emission that was withheld.\n scenarios: a.action === \"kept\" ? a.kept!.scenarios.length : a.digests.length,\n digests: a.action === \"kept\" ? a.kept!.scenarios.map((s) => s.digest) : a.digests,\n ...(a.action === \"kept\"\n ? { inFlight: a.kept!.tags.filter((t) => activeIds.has(t)) }\n : { stepless: a.stepless }),\n })),\n deleted: orphans.map(rel),\n });\n return;\n }\n\n const head = mode === \"feature\" ? `${featureId} · ${service}` : `${service} (living suite)`;\n console.log(`gherkin ${head} → ${rel(root)}/${dryRun ? \" (dry run)\" : \"\"}\\n`);\n if (actions.length === 0) {\n console.log(\n mode === \"feature\"\n ? ` ${featureId} has no ADDED or MODIFIED requirements for ${service} — nothing to emit.`\n : ` the living specs hold no requirements for ${service} — nothing to emit.`,\n );\n }\n // `conflict` never reaches here — the run refused above — but the map is\n // total so a future action cannot silently print `undefined`.\n const VERB: Record<Action, string> = {\n written: \"write \",\n replaced: \"replace\",\n kept: \"keep \",\n conflict: \"CONFLICT\",\n };\n for (const a of actions) {\n if (a.action === \"kept\") {\n const owners = a.kept!.tags.filter((t) => activeIds.has(t));\n console.log(\n ` keep ${a.fileName} — ${a.requirement.name} (in flight: @${owners.join(\" @\")} — \\`loam gherkin ${owners[0]}\\` regenerates it)`,\n );\n continue;\n }\n const n = a.digests.length;\n const arch = a.axis.key === \"archSpec\" ? \", arch\" : \"\";\n console.log(\n ` ${VERB[a.action]} ${a.fileName} — ${a.requirement.name} (${n} scenario${n === 1 ? \"\" : \"s\"}${arch})`,\n );\n for (const name of a.stepless) {\n console.log(\n ` ⚠ scenario '${name}' has NO recognizable steps — cucumber runs it vacuously green and \\`verify --results\\` can never confirm it; reword its body as \\`- **Given/When/Then**\\` bullets`,\n );\n }\n }\n for (const o of orphans) console.log(` delete ${relative(root, o).split(/[\\\\/]/).join(\"/\")} — no longer in this scope`);\n const wrote = `${writes.length} file(s)`;\n const keptNote = actions.length > writes.length ? `, ${actions.length - writes.length} kept in flight` : \"\";\n const dropped = orphans.length > 0 ? `, ${orphans.length} deletion(s)` : \"\";\n console.log(\n dryRun\n ? `\\n ${wrote}${keptNote}${dropped} — dry run, nothing was written.`\n : `\\n ${wrote} written${keptNote}${dropped}. Write step definitions OUTSIDE ${rel(root)}/ — regeneration rewrites it.`,\n );\n });\n}\n"]}
@@ -0,0 +1,250 @@
1
+ import { existsSync, statSync } from "node:fs";
2
+ import { join, resolve } from "node:path";
3
+ import { CONFIG_FILENAME, findConfigPath, loadConfig, localConfigPath, saveConfig, } from "../core/config.js";
4
+ import { emitJson, fail } from "../core/json.js";
5
+ import { plannedDocsFiles, scaffoldDocs } from "../core/docs.js";
6
+ import { docsRepoState, listServices } from "../core/repo.js";
7
+ import { InvalidIdError, assertServiceId } from "../core/ids.js";
8
+ import { AGENT_TOOLS, detectAgentTools, plannedCommandFiles, scaffoldAgentCommands, } from "../core/agent.js";
9
+ /**
10
+ * Resolve --tools to registry ids, or null after reporting the refusal. The
11
+ * default (the cwd scan) lives at the call site, not in commander, so an
12
+ * explicit `--tools` is distinguishable from none — which is what lets both the
13
+ * autodetection fallback and the `--no-*` contradiction be decided instead of
14
+ * silently arbitrated.
15
+ */
16
+ function resolveTools(raw, json) {
17
+ const supported = Object.keys(AGENT_TOOLS);
18
+ if (raw === "all")
19
+ return supported;
20
+ const ids = [...new Set(raw.split(",").map((t) => t.trim()).filter((t) => t !== ""))];
21
+ const unknown = ids.filter((t) => !(t in AGENT_TOOLS));
22
+ if (ids.length === 0 || unknown.length > 0) {
23
+ fail(json, "invalid-option", (ids.length === 0
24
+ ? "--tools names no tool."
25
+ : `--tools does not recognize: ${unknown.join(", ")}.`) +
26
+ ` Supported: ${supported.join(", ")} — or "all".`);
27
+ return null;
28
+ }
29
+ return ids;
30
+ }
31
+ /**
32
+ * `--docs` exactly as the caller wrote it, with separators normalised.
33
+ *
34
+ * Stored verbatim on purpose. `init` used to resolve it and write the absolute
35
+ * result, which meant a committed loam.json named a directory that existed on
36
+ * one laptop: every teammate who cloned the service repo got a docsDir under
37
+ * someone else's home directory, and `loam list` reported an empty fleet
38
+ * instead of saying so. `loadConfig` resolves relative paths against the config
39
+ * file's own directory, so `../docs` keeps meaning "next to this repo" wherever
40
+ * the pair is checked out. An absolute `--docs` is still stored absolute — that
41
+ * is the caller's explicit choice, and `loam doctor` warns about it.
42
+ *
43
+ * Backslashes become forward slashes so a config written on Windows resolves on
44
+ * POSIX; a trailing separator is dropped so the stored spelling is stable.
45
+ */
46
+ function storedDocsDir(raw) {
47
+ const slashed = raw.split("\\").join("/");
48
+ if (slashed.length <= 1)
49
+ return slashed;
50
+ const trimmed = slashed.replace(/\/+$/, "");
51
+ return trimmed === "" ? "/" : trimmed;
52
+ }
53
+ /**
54
+ * Does this directory already hold a docs repo? `services/` plus `AGENTS.md` —
55
+ * the two things every docs repo has and no service repo does. The pair is the
56
+ * whole point: a single marker would make `init --docs ../srv` (a typo for
57
+ * `../docs`) look like a join and adopt the service repo as the fleet.
58
+ */
59
+ function isDocsRepo(dir) {
60
+ return docsRepoState(dir).kind === "ok" && existsSync(join(dir, "AGENTS.md"));
61
+ }
62
+ export function registerInit(program) {
63
+ program
64
+ .command("init")
65
+ .description("Initialize loam: create/point at the shared docs repo and write local config")
66
+ .option("--docs <dir>", "path to the shared docs repo (the source of truth)", ".loam-docs")
67
+ .option("--service <id>", "canonical id of the service in this repo")
68
+ .option("--create", "create a new docs repo at --docs instead of joining an existing one")
69
+ .option("--force", `write ${CONFIG_FILENAME} here even though one governs a parent directory`)
70
+ .option("--tools <ids>", "agent tools to generate for, overriding the scan of this directory: " +
71
+ `comma-separated (${Object.keys(AGENT_TOOLS).join(", ")}) or "all"`)
72
+ .option("--no-commands", "skip the slash commands for this repo entirely")
73
+ .option("--no-skills", "skip the agent skills for this repo entirely")
74
+ .option("--json", "emit the machine contract instead of the human view")
75
+ .action(async (opts, command) => {
76
+ const json = opts.json === true;
77
+ const cwd = process.cwd();
78
+ // The id becomes services/<id>/ in the shared repo, so it is validated
79
+ // before anything is written — by the same rule adopt, vouch and gherkin
80
+ // use, never by a guard invented here.
81
+ if (opts.service !== undefined) {
82
+ try {
83
+ assertServiceId(opts.service);
84
+ }
85
+ catch (err) {
86
+ if (!(err instanceof InvalidIdError))
87
+ throw err;
88
+ fail(json, "invalid-option", err.message);
89
+ return;
90
+ }
91
+ }
92
+ // A loam.json in an ancestor already governs this directory: every command
93
+ // run from here resolves to it. Writing a second one below it would shadow
94
+ // the first for this subtree only — the kind of split-brain nobody
95
+ // discovers until two commands disagree about which docs repo is the fleet.
96
+ const governing = findConfigPath(cwd);
97
+ if (governing !== null && governing !== localConfigPath(cwd) && opts.force !== true) {
98
+ fail(json, "already-exists", `${governing} already governs this directory. ` +
99
+ `Run loam from ${resolve(governing, "..")}, or pass --force to give this ` +
100
+ `subdirectory its own ${CONFIG_FILENAME}.`);
101
+ return;
102
+ }
103
+ // Only THIS directory's config is spread forward. Under --force there is
104
+ // a config in an ancestor too, and inheriting its `service` would bind
105
+ // this repo to a service somebody else's repo declared.
106
+ const existing = existsSync(localConfigPath(cwd)) ? await loadConfig(cwd) : null;
107
+ // A re-run must not move a committed pointer. `--docs` has a default
108
+ // (`.loam-docs`), so an init invoked for any OTHER reason — `--service`,
109
+ // a new `--tools`, the fix `doctor.agent-files-missing` prints — used to
110
+ // arrive here carrying a docsDir nobody typed: the default then named no
111
+ // docs repo, the refusal steered the caller to `--create`, and `--create`
112
+ // rewrote `docsDir` in the committed loam.json to a freshly scaffolded
113
+ // empty one. `validate --all` went green over an empty fleet, which is
114
+ // the exact outcome `--create` exists to prevent. So the flag wins only
115
+ // when it was actually passed; otherwise the file this repo already
116
+ // committed does.
117
+ const committedDocs = existing?.docsDirAsWritten;
118
+ const docsTyped = command.getOptionValueSource("docs") !== "default";
119
+ const docsSource = docsTyped ? "flag" : committedDocs === undefined ? "default" : "config";
120
+ const docsOption = !docsTyped && committedDocs !== undefined ? committedDocs : opts.docs;
121
+ const docsDir = resolve(cwd, docsOption);
122
+ // The one predictable failure: --docs naming a file. Refused here so the
123
+ // caller gets a clean envelope/message instead of mkdir's ENOTDIR throw.
124
+ if (existsSync(docsDir) && !statSync(docsDir).isDirectory()) {
125
+ fail(json, "invalid-option", `--docs points at a file, not a directory: ${docsDir}`);
126
+ return;
127
+ }
128
+ // Joining an existing docs repo and creating a new one are different
129
+ // intentions, and `init` used to perform the second one whenever the first
130
+ // was misspelled: `--docs ../dcos` scaffolded a second, empty docs repo
131
+ // beside the real one, and the fleet quietly split in two. Creation is now
132
+ // something the caller asks for.
133
+ const joining = isDocsRepo(docsDir);
134
+ if (!joining && opts.create !== true) {
135
+ fail(json, "invalid-option", docsRepoState(docsDir).kind === "missing"
136
+ ? `--docs points at a directory that does not exist: ${docsDir}. ` +
137
+ "If that is a typo, fix it; to create a new docs repo there, pass --create."
138
+ : `--docs points at ${docsDir}, which is not a docs repo ` +
139
+ "(a docs repo has services/ and AGENTS.md). " +
140
+ "Point --docs at the shared docs repo, or pass --create to make a new one here.");
141
+ return;
142
+ }
143
+ // --tools selects the files to write; --no-commands and --no-skills each
144
+ // say write none of that kind. Together they contradict — refused, not
145
+ // arbitrated, in either combination.
146
+ const suppressed = [
147
+ ...(opts.commands ? [] : ["--no-commands"]),
148
+ ...(opts.skills ? [] : ["--no-skills"]),
149
+ ];
150
+ if (opts.tools !== undefined && suppressed.length > 0) {
151
+ fail(json, "invalid-option", `--tools contradicts ${suppressed.join(" and ")}: ` +
152
+ "one selects the files to generate, the other suppresses them.");
153
+ return;
154
+ }
155
+ const delivery = [
156
+ ...(opts.commands ? ["commands"] : []),
157
+ ...(opts.skills ? ["skills"] : []),
158
+ ];
159
+ // Without --tools the repo decides: whichever tools have left their own
160
+ // dot-directories here. Falling back to claude keeps a bare repo behaving
161
+ // exactly as it did before the scan existed. `detected` is reported either
162
+ // way — what the scan saw is a fact about the repo, not about the flags.
163
+ const detected = detectAgentTools(cwd);
164
+ const scanned = detected.length > 0 ? detected : ["claude"];
165
+ const tools = opts.tools === undefined ? scanned : resolveTools(opts.tools, json);
166
+ if (tools === null)
167
+ return;
168
+ // `skipped` is the other half of the never-overwrite contract: the
169
+ // scaffolds return only what they created, so what they left alone is
170
+ // probed here, before they run — in the order they would create it, so
171
+ // created + skipped is the same list on every repo. Both halves come from
172
+ // the scaffolds' own planned-file lists, so the probe cannot drift from
173
+ // what is actually written.
174
+ const agentFiles = delivery.length > 0 ? plannedCommandFiles(cwd, tools, delivery) : [];
175
+ const candidates = [...plannedDocsFiles(docsDir), ...agentFiles.map((f) => f.path)];
176
+ const skipped = candidates.filter((p) => existsSync(p));
177
+ // Joining touches the docs repo not at all: it is somebody's committed
178
+ // repository, and "point at it" must never mean "write to it".
179
+ const created = joining && opts.create !== true ? [] : (await scaffoldDocs(docsDir)).created;
180
+ const generatedFor = delivery.length > 0 ? tools : [];
181
+ if (delivery.length > 0) {
182
+ created.push(...(await scaffoldAgentCommands(cwd, tools, delivery)));
183
+ }
184
+ const stored = storedDocsDir(docsOption);
185
+ // Union, not replacement: this run's files join the ones earlier runs left
186
+ // on disk, and `agentTools` is a record of what the repo HOLDS. A run that
187
+ // generated nothing adds nothing and erases nothing.
188
+ const recordedTools = [...new Set([...(existing?.agentTools ?? []), ...generatedFor])];
189
+ const config = {
190
+ ...existing,
191
+ docsDir: stored,
192
+ ...(opts.service ? { service: opts.service } : {}),
193
+ ...(recordedTools.length > 0 ? { agentTools: recordedTools } : {}),
194
+ };
195
+ const configFile = await saveConfig(config, cwd);
196
+ const serviceCount = (await listServices(docsDir)).length;
197
+ if (json) {
198
+ // `docsDir` stays the resolved absolute path — it is what a consumer
199
+ // needs to find the repo. `docsDirStored` is what was written to
200
+ // loam.json, which is the fact this command is now careful about.
201
+ // `tools` names what files were generated FOR — empty when every
202
+ // delivery was suppressed and none were. `detected` is the separate
203
+ // question of what the scan SAW, which `--tools` overrides without
204
+ // changing: an agent comparing the two learns whether its selection
205
+ // matches the repo.
206
+ emitJson({
207
+ docsDir,
208
+ docsDirStored: stored,
209
+ // Where the pointer came from: `flag` (a --docs somebody typed),
210
+ // `config` (the one this repo already committed — a re-run cannot
211
+ // move it), or `default` (nothing said, nothing committed).
212
+ docsDirSource: docsSource,
213
+ docsRepo: joining ? "existing" : "created",
214
+ services: serviceCount,
215
+ created,
216
+ skipped,
217
+ tools: generatedFor,
218
+ detected,
219
+ });
220
+ return;
221
+ }
222
+ console.log("loam initialized.");
223
+ if (joining) {
224
+ console.log(` docs repo: ${docsDir}`);
225
+ console.log(` pointing at existing docs repo (${serviceCount} services)`);
226
+ }
227
+ else {
228
+ console.log(` docs repo: ${docsDir} (created)`);
229
+ }
230
+ console.log(` docsDir: ${stored} (as stored in ${CONFIG_FILENAME}`
231
+ + (docsSource === "config" ? " — kept; no --docs was passed" : "") + ")");
232
+ if (config.service)
233
+ console.log(` service: ${config.service}`);
234
+ console.log(` config: ${configFile}`);
235
+ console.log(` commit ${CONFIG_FILENAME} — docsDir is resolved relative to it`);
236
+ if (opts.tools === undefined && detected.length > 0) {
237
+ console.log(` detected: ${detected.join(", ")}`);
238
+ }
239
+ if (opts.commands)
240
+ console.log(` commands: ${tools.join(", ")}`);
241
+ if (opts.skills)
242
+ console.log(` skills: ${tools.join(", ")}`);
243
+ if (created.length > 0) {
244
+ console.log(" scaffolded:");
245
+ for (const c of created)
246
+ console.log(` + ${c}`);
247
+ }
248
+ });
249
+ }
250
+ //# sourceMappingURL=init.js.map