@elmundi/ship-cli 0.8.1 → 0.11.2

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 (76) hide show
  1. package/README.md +415 -22
  2. package/bin/shipctl.mjs +165 -0
  3. package/lib/adapters/_fs.mjs +165 -0
  4. package/lib/adapters/agents/index.mjs +26 -0
  5. package/lib/adapters/ci/azure-pipelines.mjs +23 -0
  6. package/lib/adapters/ci/buildkite.mjs +24 -0
  7. package/lib/adapters/ci/circleci.mjs +23 -0
  8. package/lib/adapters/ci/gh-actions.mjs +29 -0
  9. package/lib/adapters/ci/gitlab-ci.mjs +23 -0
  10. package/lib/adapters/ci/jenkins.mjs +23 -0
  11. package/lib/adapters/ci/manual.mjs +18 -0
  12. package/lib/adapters/index.mjs +122 -0
  13. package/lib/adapters/language/dart.mjs +23 -0
  14. package/lib/adapters/language/go.mjs +23 -0
  15. package/lib/adapters/language/java.mjs +27 -0
  16. package/lib/adapters/language/js.mjs +32 -0
  17. package/lib/adapters/language/kotlin.mjs +48 -0
  18. package/lib/adapters/language/py.mjs +34 -0
  19. package/lib/adapters/language/rust.mjs +23 -0
  20. package/lib/adapters/language/swift.mjs +37 -0
  21. package/lib/adapters/language/ts.mjs +35 -0
  22. package/lib/adapters/trackers/azure-boards.mjs +49 -0
  23. package/lib/adapters/trackers/clickup.mjs +43 -0
  24. package/lib/adapters/trackers/github-issues.mjs +52 -0
  25. package/lib/adapters/trackers/jira.mjs +72 -0
  26. package/lib/adapters/trackers/linear.mjs +62 -0
  27. package/lib/adapters/trackers/none.mjs +18 -0
  28. package/lib/adapters/trackers/spreadsheet.mjs +28 -0
  29. package/lib/artifacts/fs-index.mjs +230 -0
  30. package/lib/bootstrap/render.mjs +373 -0
  31. package/lib/cache/store.mjs +422 -0
  32. package/lib/commands/bootstrap.mjs +4 -0
  33. package/lib/commands/callback.mjs +302 -0
  34. package/lib/commands/config.mjs +257 -0
  35. package/lib/commands/docs.mjs +1 -1
  36. package/lib/commands/doctor.mjs +583 -0
  37. package/lib/commands/feedback.mjs +355 -0
  38. package/lib/commands/help.mjs +96 -21
  39. package/lib/commands/init.mjs +830 -158
  40. package/lib/commands/kickoff.mjs +192 -0
  41. package/lib/commands/knowledge.mjs +368 -0
  42. package/lib/commands/lanes.mjs +502 -0
  43. package/lib/commands/manifest-catalog.mjs +102 -38
  44. package/lib/commands/migrate.mjs +204 -0
  45. package/lib/commands/new.mjs +452 -0
  46. package/lib/commands/patterns.mjs +9 -43
  47. package/lib/commands/run.mjs +617 -0
  48. package/lib/commands/sync.mjs +749 -0
  49. package/lib/commands/telemetry.mjs +390 -0
  50. package/lib/commands/verify.mjs +187 -0
  51. package/lib/config/io.mjs +232 -0
  52. package/lib/config/migrate.mjs +215 -0
  53. package/lib/config/schema.mjs +650 -0
  54. package/lib/detect.mjs +162 -19
  55. package/lib/feedback/drafts.mjs +129 -0
  56. package/lib/find-ship-root.mjs +16 -10
  57. package/lib/http.mjs +237 -11
  58. package/lib/state/idempotency.mjs +183 -0
  59. package/lib/state/lockfile.mjs +180 -0
  60. package/lib/telemetry/outbox.mjs +224 -0
  61. package/lib/templates.mjs +53 -65
  62. package/lib/verify/checks/agents-on-disk.mjs +58 -0
  63. package/lib/verify/checks/api-reachable.mjs +39 -0
  64. package/lib/verify/checks/artifacts-up-to-date.mjs +78 -0
  65. package/lib/verify/checks/bootstrap-files.mjs +67 -0
  66. package/lib/verify/checks/cache-integrity.mjs +51 -0
  67. package/lib/verify/checks/ci-secrets.mjs +86 -0
  68. package/lib/verify/checks/config-present.mjs +39 -0
  69. package/lib/verify/checks/gitignore-cache.mjs +51 -0
  70. package/lib/verify/checks/rules-markers.mjs +135 -0
  71. package/lib/verify/checks/stack-enums.mjs +33 -0
  72. package/lib/verify/checks/tracker-labels.mjs +91 -0
  73. package/lib/verify/registry.mjs +120 -0
  74. package/lib/version.mjs +34 -0
  75. package/package.json +10 -3
  76. package/bin/ship.mjs +0 -68
@@ -0,0 +1,583 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { detectAll } from "../adapters/index.mjs";
4
+ import { isFile, isDir, pkgDeps, readJson } from "../adapters/_fs.mjs";
5
+ import { findShipRoot, readConfig } from "../config/io.mjs";
6
+ import { resolveAgentSignal } from "../detect.mjs";
7
+
8
+ const INVENTORY_REL = path.join(".ship", "inventory.json");
9
+ const CONFIG_REL = path.join(".ship", "config.yml");
10
+ const CACHE_REL = path.join(".ship", "cache");
11
+
12
+ /**
13
+ * @param {{ json: boolean, yes: boolean, force: boolean, dryRun: boolean }} ctx
14
+ * @param {string[]} args
15
+ */
16
+ export async function doctorCommand(ctx, args) {
17
+ if (args[0] === "help" || args[0] === "-h" || args[0] === "--help") {
18
+ printDoctorHelp();
19
+ return;
20
+ }
21
+
22
+ let cwd = process.cwd();
23
+ let writeInventory = false;
24
+ let jsonOut = !!ctx.json;
25
+ /* eslint-disable-next-line no-unused-vars */
26
+ let noNetwork = false;
27
+
28
+ for (let i = 0; i < args.length; i++) {
29
+ const a = args[i];
30
+ if (a === "--cwd" && args[i + 1]) {
31
+ cwd = path.resolve(String(args[++i]));
32
+ continue;
33
+ }
34
+ if (a.startsWith("--cwd=")) {
35
+ cwd = path.resolve(a.slice("--cwd=".length));
36
+ continue;
37
+ }
38
+ if (a === "--write-inventory") {
39
+ writeInventory = true;
40
+ continue;
41
+ }
42
+ if (a === "--json") {
43
+ jsonOut = true;
44
+ continue;
45
+ }
46
+ if (a === "--no-network") {
47
+ noNetwork = true;
48
+ continue;
49
+ }
50
+ if (a === "--help" || a === "-h") {
51
+ printDoctorHelp();
52
+ return;
53
+ }
54
+ throw new Error(`doctor: unknown argument: ${a}`);
55
+ }
56
+
57
+ const findings = await detectAll(cwd);
58
+ const inferred = inferStack(cwd, findings);
59
+ const presetInfo = inferPreset(cwd);
60
+ inferred.preset = presetInfo.preset;
61
+
62
+ const existing = shipArtifactsSnapshot(cwd);
63
+
64
+ const configInfo = loadShipConfig(cwd);
65
+ const reconciled = reconcileStack(findings, inferred, configInfo);
66
+
67
+ const report = {
68
+ version: 1,
69
+ detected_at: new Date().toISOString(),
70
+ cwd: path.resolve(cwd),
71
+ findings,
72
+ inferred,
73
+ preset_evidence: presetInfo.evidence,
74
+ existing,
75
+ config: configInfo.stack,
76
+ disk: { ...inferred, preset_evidence: presetInfo.evidence },
77
+ reconciled,
78
+ recommendations: buildRecommendations({
79
+ inferred,
80
+ existing,
81
+ config: configInfo.stack,
82
+ reconciled,
83
+ }),
84
+ };
85
+
86
+ if (jsonOut) {
87
+ process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
88
+ } else {
89
+ printHumanReport(report);
90
+ }
91
+
92
+ if (writeInventory) {
93
+ const invPath = await writeInventoryFile(cwd, report);
94
+ if (!jsonOut) {
95
+ process.stdout.write(`\nWrote ${path.relative(cwd, invPath) || invPath}.\n`);
96
+ }
97
+ }
98
+ }
99
+
100
+ function printDoctorHelp() {
101
+ console.log(`shipctl doctor — inspect a repository and propose a Ship stack.
102
+
103
+ USAGE
104
+ shipctl doctor [--json] [--cwd DIR] [--write-inventory] [--no-network]
105
+
106
+ DESCRIPTION
107
+ Runs every registered tracker/CI/language/agent adapter's detect() hook
108
+ against the target repo, prints a human-readable report (or JSON with
109
+ --json), and optionally writes .ship/inventory.json for consumption by
110
+ 'shipctl init --bootstrap'.
111
+
112
+ FLAGS
113
+ --cwd DIR Inspect DIR instead of the current working directory.
114
+ --write-inventory Persist findings to .ship/inventory.json.
115
+ --json Machine-readable JSON output.
116
+ --no-network Reserved; doctor never makes network calls in v1.
117
+ `);
118
+ }
119
+
120
+ /**
121
+ * Produce the inferred stack fields. Picks the highest-confidence non-zero
122
+ * adapter per category, falling back to `none`/`manual` for tracker/ci when
123
+ * nothing confident was detected, and to `multi` for language.
124
+ */
125
+ function inferStack(_cwd, findings) {
126
+ const pickTop = (arr, { min = 0, fallback = null } = {}) => {
127
+ const confident = arr.filter((e) => e.present && e.confidence > min);
128
+ if (confident.length) return confident[0].id;
129
+ return fallback;
130
+ };
131
+
132
+ const trackerExclFallback = findings.trackers.filter((t) => t.id !== "none");
133
+ const ciExclFallback = findings.ci.filter((c) => c.id !== "manual");
134
+
135
+ const tracker = pickTop(trackerExclFallback, { min: 0.1, fallback: "none" });
136
+ const ci = pickTop(ciExclFallback, { min: 0.1, fallback: "manual" });
137
+ const language = pickTop(findings.language, { min: 0.1, fallback: "multi" }) || "multi";
138
+
139
+ const agents = findings.agents
140
+ .filter((a) => a.present && a.confidence >= 0.5)
141
+ .map((a) => a.id);
142
+
143
+ return {
144
+ tracker,
145
+ ci,
146
+ language,
147
+ agents,
148
+ preset: "adoption-minimum",
149
+ };
150
+ }
151
+
152
+ /**
153
+ * Inspect the repo for preset heuristics. Returns the first match per the
154
+ * priority in the RFC-matching task spec; `adoption-minimum` if none match.
155
+ */
156
+ function inferPreset(cwd) {
157
+ const evidence = [];
158
+
159
+ const pkg = readJson(cwd, "package.json");
160
+ const deps = pkgDeps(pkg);
161
+ const hasDep = (name) => Object.prototype.hasOwnProperty.call(deps, name);
162
+
163
+ // Mobile app
164
+ if (isFile(cwd, "pubspec.yaml")) {
165
+ evidence.push("pubspec.yaml (Flutter / Dart)");
166
+ return { preset: "mobile-app", evidence };
167
+ }
168
+ if (isDir(cwd, "ios") && isDir(cwd, "android")) {
169
+ evidence.push("ios/ and android/ directories");
170
+ return { preset: "mobile-app", evidence };
171
+ }
172
+ if (hasDep("react-native")) {
173
+ evidence.push("react-native in deps");
174
+ return { preset: "mobile-app", evidence };
175
+ }
176
+ if (hasDep("expo")) {
177
+ evidence.push("expo in deps");
178
+ return { preset: "mobile-app", evidence };
179
+ }
180
+
181
+ // Monorepo
182
+ if (isDir(cwd, "packages")) {
183
+ evidence.push("packages/ directory");
184
+ return { preset: "monorepo", evidence };
185
+ }
186
+ if (isFile(cwd, "pnpm-workspace.yaml")) {
187
+ evidence.push("pnpm-workspace.yaml");
188
+ return { preset: "monorepo", evidence };
189
+ }
190
+ if (isFile(cwd, "lerna.json")) {
191
+ evidence.push("lerna.json");
192
+ return { preset: "monorepo", evidence };
193
+ }
194
+ if (isFile(cwd, "turbo.json")) {
195
+ evidence.push("turbo.json");
196
+ return { preset: "monorepo", evidence };
197
+ }
198
+
199
+ // Web app
200
+ for (const f of ["next.config.js", "next.config.mjs", "next.config.ts", "next.config.cjs"]) {
201
+ if (isFile(cwd, f)) {
202
+ evidence.push(f);
203
+ return { preset: "web-app", evidence };
204
+ }
205
+ }
206
+ for (const f of ["vite.config.js", "vite.config.mjs", "vite.config.ts", "vite.config.cjs"]) {
207
+ if (isFile(cwd, f)) {
208
+ evidence.push(f);
209
+ return { preset: "web-app", evidence };
210
+ }
211
+ }
212
+ for (const f of ["svelte.config.js", "svelte.config.mjs", "svelte.config.ts"]) {
213
+ if (isFile(cwd, f)) {
214
+ evidence.push(f);
215
+ return { preset: "web-app", evidence };
216
+ }
217
+ }
218
+
219
+ // API backend
220
+ if (isFile(cwd, "Dockerfile")) {
221
+ const hasBackendEntry =
222
+ isFile(cwd, "main.py") ||
223
+ isFile(cwd, "server.ts") ||
224
+ isFile(cwd, "server.js") ||
225
+ isFile(cwd, "app.py") ||
226
+ isFile(cwd, "app.ts");
227
+ const hasUiHint =
228
+ isFile(cwd, "index.html") ||
229
+ isDir(cwd, "public") ||
230
+ isDir(cwd, "src/pages") ||
231
+ isDir(cwd, "app");
232
+ if (hasBackendEntry && !hasUiHint) {
233
+ evidence.push("Dockerfile + backend entry (main.py|server.ts) and no UI folder");
234
+ return { preset: "api-backend", evidence };
235
+ }
236
+ }
237
+
238
+ // CLI
239
+ if (isDir(cwd, "bin") && pkg && typeof pkg.bin === "object") {
240
+ evidence.push("bin/ + package.json:bin");
241
+ return { preset: "cli", evidence };
242
+ }
243
+ if (isFile(cwd, "go.mod") && isDir(cwd, "cmd")) {
244
+ evidence.push("go.mod + cmd/");
245
+ return { preset: "cli", evidence };
246
+ }
247
+ if (isFile(cwd, "Cargo.toml") && isDir(cwd, "src/bin")) {
248
+ evidence.push("Cargo.toml + src/bin/");
249
+ return { preset: "cli", evidence };
250
+ }
251
+
252
+ return { preset: "adoption-minimum", evidence: ["no strong preset signals"] };
253
+ }
254
+
255
+ function shipArtifactsSnapshot(cwd) {
256
+ const cursorRulesHit = fs.existsSync(path.join(cwd, ".cursor", "rules"))
257
+ ? detectCursorShipRules(cwd)
258
+ : null;
259
+ return {
260
+ config_yml: isFile(cwd, CONFIG_REL) ? "present" : "missing",
261
+ cache_dir: isDir(cwd, CACHE_REL) ? "present" : "missing",
262
+ inventory_json: isFile(cwd, INVENTORY_REL) ? "present" : "missing",
263
+ cursor_ship_rules: cursorRulesHit || "missing",
264
+ };
265
+ }
266
+
267
+ function detectCursorShipRules(cwd) {
268
+ const dir = path.join(cwd, ".cursor", "rules");
269
+ let entries;
270
+ try {
271
+ entries = fs.readdirSync(dir);
272
+ } catch {
273
+ return "missing";
274
+ }
275
+ const hit = entries.find((n) => n.startsWith("ship-"));
276
+ return hit ? `present (${hit})` : "missing";
277
+ }
278
+
279
+ function recommendations(inferred, existing) {
280
+ const steps = [];
281
+ if (existing.config_yml !== "present") {
282
+ steps.push("shipctl config init");
283
+ }
284
+ const agentsPart = inferred.agents.length ? ` --agents ${inferred.agents.join(",")}` : "";
285
+ steps.push(
286
+ `shipctl init --bootstrap --tracker ${inferred.tracker} --ci ${inferred.ci}${agentsPart} --preset ${inferred.preset}`,
287
+ );
288
+ steps.push("shipctl sync");
289
+ steps.push("shipctl verify");
290
+ return steps;
291
+ }
292
+
293
+ /**
294
+ * Load .ship/config.yml starting from `cwd` (walking upward). Returns the
295
+ * declared stack subtree (tracker/ci/language/preset/agents plus
296
+ * api.channel) or null if no config is present. Parse errors are swallowed
297
+ * into `null` so doctor never crashes on malformed YAML — the disk-only
298
+ * recommendation path still runs.
299
+ *
300
+ * @param {string} cwd
301
+ * @returns {{filePath:string|null, stack: null | {tracker:string|null, ci:string|null, language:string|null, preset:string|null, agents:string[], channel:string|null}}}
302
+ */
303
+ function loadShipConfig(cwd) {
304
+ try {
305
+ const root = findShipRoot(cwd);
306
+ if (!root) return { filePath: null, stack: null };
307
+ const { config, filePath } = readConfig(root);
308
+ const s = (config && config.stack) || {};
309
+ const api = (config && config.api) || {};
310
+ return {
311
+ filePath,
312
+ stack: {
313
+ tracker: typeof s.tracker === "string" ? s.tracker : null,
314
+ ci: typeof s.ci === "string" ? s.ci : null,
315
+ language: typeof s.language === "string" ? s.language : null,
316
+ preset: typeof s.preset === "string" ? s.preset : null,
317
+ agents: Array.isArray(s.agents) ? [...s.agents] : [],
318
+ channel: typeof api.channel === "string" ? api.channel : null,
319
+ },
320
+ };
321
+ } catch {
322
+ return { filePath: null, stack: null };
323
+ }
324
+ }
325
+
326
+ /**
327
+ * Merge disk-inferred signals with the declared `.ship/config.yml` stack.
328
+ * For tracker/ci/language/preset, config wins when present. For agents,
329
+ * we union config-declared ids with disk-detected ids (after mapping raw
330
+ * signals like `agents-md` → `codex` via `resolveAgentSignal`).
331
+ *
332
+ * Returns both the merged view and per-agent provenance so the JSON /
333
+ * human reporter can explain why AGENTS.md was counted as codex.
334
+ *
335
+ * @param {{trackers:Array, ci:Array, language:Array, agents:Array}} findings
336
+ * @param {{tracker:string, ci:string, language:string, agents:string[], preset:string}} inferred
337
+ * @param {ReturnType<typeof loadShipConfig>} configInfo
338
+ */
339
+ function reconcileStack(findings, inferred, configInfo) {
340
+ const config = configInfo.stack;
341
+ const configAgents = config ? config.agents : [];
342
+
343
+ const diskAgentSignals = (findings.agents || [])
344
+ .filter((a) => a.present && a.confidence >= 0.5)
345
+ .map((a) => ({
346
+ signal: a.id,
347
+ resolved: resolveAgentSignal(a.id, configAgents),
348
+ confidence: a.confidence,
349
+ evidence: (a.evidence && a.evidence[0] && a.evidence[0].where) || null,
350
+ label: (a.evidence && a.evidence[0] && a.evidence[0].match) || null,
351
+ }));
352
+
353
+ const agentSet = new Set(configAgents);
354
+ for (const s of diskAgentSignals) agentSet.add(s.resolved);
355
+
356
+ return {
357
+ tracker: config?.tracker || inferred.tracker,
358
+ ci: config?.ci || inferred.ci,
359
+ language: config?.language || inferred.language,
360
+ preset: config?.preset || inferred.preset,
361
+ agents: [...agentSet],
362
+ config_agents: [...configAgents],
363
+ disk_agents: diskAgentSignals.map((s) => s.resolved),
364
+ agent_signals: diskAgentSignals,
365
+ source: {
366
+ tracker: config?.tracker ? "config" : "disk",
367
+ ci: config?.ci ? "config" : "disk",
368
+ language: config?.language ? "config" : "disk",
369
+ preset: config?.preset ? "config" : "disk",
370
+ },
371
+ };
372
+ }
373
+
374
+ /**
375
+ * Decide what to tell the operator. When `.ship/config.yml` is present,
376
+ * never propose a stack that contradicts it — instead propose additive
377
+ * init commands that bring disk into agreement with config.
378
+ *
379
+ * @param {{inferred:object, existing:object, config:object|null, reconciled:object}} ctx
380
+ */
381
+ function buildRecommendations(ctx) {
382
+ const { inferred, existing, config, reconciled } = ctx;
383
+ if (!config) return recommendations(inferred, existing);
384
+
385
+ const configAgents = new Set(config.agents || []);
386
+ const diskResolved = new Set(reconciled.disk_agents || []);
387
+ const extras = [...diskResolved].filter((id) => !configAgents.has(id));
388
+ const missingOnDisk = configAgents.size > 0 && diskResolved.size === 0;
389
+
390
+ const diskPresent =
391
+ existing.config_yml === "present" &&
392
+ (existing.cache_dir === "present" || diskResolved.size > 0);
393
+
394
+ if (missingOnDisk) {
395
+ const list = [...configAgents].join(",");
396
+ return [`shipctl init --bootstrap --copy-rules --agents ${list}`, "shipctl verify"];
397
+ }
398
+
399
+ if (extras.length) {
400
+ const union = [...new Set([...configAgents, ...extras])].join(",");
401
+ return [
402
+ `shipctl init --agents ${union} --copy-rules`,
403
+ "shipctl verify",
404
+ ];
405
+ }
406
+
407
+ if (!diskPresent) {
408
+ return ["shipctl init --bootstrap --copy-rules", "shipctl verify"];
409
+ }
410
+
411
+ return ["Config and disk agree. Run `shipctl verify`."];
412
+ }
413
+
414
+ function printHumanReport(report) {
415
+ const {
416
+ cwd,
417
+ findings,
418
+ inferred,
419
+ preset_evidence,
420
+ existing,
421
+ recommendations: recs,
422
+ config,
423
+ reconciled,
424
+ } = report;
425
+ const out = [];
426
+ out.push(`Ship doctor — inspecting ${cwd}`);
427
+ out.push("");
428
+
429
+ const topN = (arr, n, filterPresent = true) => {
430
+ const pool = filterPresent ? arr.filter((e) => e.present && e.confidence > 0) : arr;
431
+ return pool.slice(0, n);
432
+ };
433
+
434
+ const evToString = (ev) =>
435
+ ev
436
+ .map((e) => {
437
+ const where = e.where && e.where !== "-" ? e.where : "";
438
+ const match = e.match || "";
439
+ return where ? `${where}${match ? ` (${match})` : ""}` : match;
440
+ })
441
+ .filter(Boolean)
442
+ .join(", ");
443
+
444
+ if (config) {
445
+ // Reconciliation view — config is authoritative, disk is annotated.
446
+ const diskPick = (arr) => {
447
+ const pool = arr.filter((e) => e.present && e.confidence > 0);
448
+ return pool[0] || null;
449
+ };
450
+ const fmtDisk = (entry) =>
451
+ entry ? `${entry.id} (${entry.confidence.toFixed(2)})` : "no signal";
452
+ const reconLine = (label, configVal, diskEntry) => {
453
+ const left = configVal ? `${configVal} (config)` : "(unset)";
454
+ const right = `disk: ${fmtDisk(diskEntry)}`;
455
+ out.push(`${label.padEnd(12)} ${left} · ${right}`);
456
+ };
457
+
458
+ reconLine("Tracker:", config.tracker, diskPick(findings.trackers));
459
+ reconLine("CI:", config.ci, diskPick(findings.ci));
460
+ reconLine("Language:", config.language, diskPick(findings.language));
461
+
462
+ const declared = config.agents || [];
463
+ out.push(`${"Agents:".padEnd(12)} declared: ${declared.length ? declared.join(", ") : "(none)"}`);
464
+ const signals = reconciled?.agent_signals || [];
465
+ if (signals.length) {
466
+ const parts = signals.map((s) => {
467
+ const where = s.evidence && s.evidence !== "-" ? s.evidence : s.signal;
468
+ return s.signal !== s.resolved
469
+ ? `${where} (→ ${s.resolved} via config)`
470
+ : s.resolved;
471
+ });
472
+ out.push(`${"".padEnd(12)} disk: ${parts.join(", ")}`);
473
+ } else {
474
+ out.push(`${"".padEnd(12)} disk: (none)`);
475
+ }
476
+
477
+ const presetEvidence =
478
+ preset_evidence && preset_evidence.length ? preset_evidence.join(", ") : "no strong signals";
479
+ const diskPresetNote = `disk inferred: ${inferred.preset} — evidence: ${presetEvidence}`;
480
+ out.push(
481
+ `${"Preset:".padEnd(12)} ${config.preset ? `${config.preset} (config)` : "(unset)"} [${diskPresetNote}]`,
482
+ );
483
+ out.push("");
484
+ } else {
485
+ const categoryLine = (label, entries, fallback) => {
486
+ const top = topN(entries, 5);
487
+ if (!top.length) {
488
+ out.push(`${label.padEnd(12)} ${fallback}`);
489
+ return;
490
+ }
491
+ const head = top[0];
492
+ const evStr = evToString(head.evidence);
493
+ const headLine = `${label.padEnd(12)} ${head.id} (${head.confidence.toFixed(2)})${
494
+ evStr ? ` · evidence: ${evStr}` : ""
495
+ }`;
496
+ out.push(headLine);
497
+ for (const row of top.slice(1)) {
498
+ out.push(`${"".padEnd(12)} ${row.id} (${row.confidence.toFixed(2)})`);
499
+ }
500
+ };
501
+
502
+ categoryLine("Tracker:", findings.trackers, "none detected");
503
+ categoryLine("CI:", findings.ci, "none detected");
504
+ categoryLine("Language:", findings.language, "none detected");
505
+
506
+ const agents = findings.agents.filter((a) => a.present && a.confidence > 0);
507
+ const agentStr =
508
+ agents
509
+ .slice(0, 8)
510
+ .map((a) => `${a.id} (${a.confidence.toFixed(2)})`)
511
+ .join(", ") || "none";
512
+ out.push(`${"Agents:".padEnd(12)} ${agentStr}`);
513
+ out.push("");
514
+
515
+ const presetEvidence =
516
+ preset_evidence && preset_evidence.length ? preset_evidence.join(", ") : "no strong signals";
517
+ out.push(`Inferred preset: ${inferred.preset} (evidence: ${presetEvidence})`);
518
+ out.push("");
519
+ }
520
+
521
+ out.push("Existing Ship artifacts:");
522
+ out.push(` .ship/config.yml ${existing.config_yml}`);
523
+ out.push(` .ship/cache/ ${existing.cache_dir}`);
524
+ out.push(` .ship/inventory.json ${existing.inventory_json}`);
525
+ out.push(` .cursor/rules/ship-* ${existing.cursor_ship_rules}`);
526
+ out.push("");
527
+
528
+ out.push("Recommendations:");
529
+ recs.forEach((r, i) => out.push(` ${i + 1}. ${r}`));
530
+ out.push("");
531
+
532
+ const nextCmd =
533
+ recs.find((r) => r.startsWith("shipctl init")) || recs[0] || "";
534
+ out.push(`Next: ${nextCmd}`);
535
+
536
+ process.stdout.write(`${out.join("\n")}\n`);
537
+ }
538
+
539
+ async function writeInventoryFile(cwd, report) {
540
+ const body = {
541
+ version: 1,
542
+ detected_at: report.detected_at,
543
+ cwd: report.cwd,
544
+ findings: report.findings,
545
+ inferred: report.inferred,
546
+ };
547
+
548
+ // Prefer the real config-io module when available (race with parallel agent);
549
+ // fall back to node:fs so doctor can always ship its inventory.
550
+ let io = null;
551
+ try {
552
+ io = await import("../config/io.mjs");
553
+ } catch {
554
+ io = null;
555
+ }
556
+
557
+ const absDir = path.join(cwd, ".ship");
558
+ const absPath = path.join(absDir, "inventory.json");
559
+
560
+ if (io && typeof io.findShipRoot === "function") {
561
+ // Honour the existing .ship/ location if config was already initialised
562
+ // nearby; otherwise fall through to the cwd-local path.
563
+ try {
564
+ const root = io.findShipRoot(cwd);
565
+ if (root) {
566
+ const p = path.join(root, ".ship", "inventory.json");
567
+ fs.mkdirSync(path.dirname(p), { recursive: true });
568
+ const tmp = `${p}.tmp`;
569
+ fs.writeFileSync(tmp, `${JSON.stringify(body, null, 2)}\n`, "utf8");
570
+ fs.renameSync(tmp, p);
571
+ return p;
572
+ }
573
+ } catch {
574
+ // Ignore and fall through to the direct fs write below.
575
+ }
576
+ }
577
+
578
+ fs.mkdirSync(absDir, { recursive: true });
579
+ const tmp = `${absPath}.tmp`;
580
+ fs.writeFileSync(tmp, `${JSON.stringify(body, null, 2)}\n`, "utf8");
581
+ fs.renameSync(tmp, absPath);
582
+ return absPath;
583
+ }