@ybotok/loam 0.0.0 → 0.1.0-beta.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 (126) hide show
  1. package/CHANGELOG.md +138 -0
  2. package/COMPARISON.md +63 -0
  3. package/LICENSE +21 -0
  4. package/MIGRATING-from-OpenSpec.md +202 -0
  5. package/README.md +246 -9
  6. package/SCHEMA.md +425 -0
  7. package/SECURITY.md +9 -0
  8. package/dist/cli.js +142 -0
  9. package/dist/cli.js.map +1 -0
  10. package/dist/commands/adopt.js +240 -0
  11. package/dist/commands/adopt.js.map +1 -0
  12. package/dist/commands/archive.js +1313 -0
  13. package/dist/commands/archive.js.map +1 -0
  14. package/dist/commands/delta.js +388 -0
  15. package/dist/commands/delta.js.map +1 -0
  16. package/dist/commands/dependencies.js +83 -0
  17. package/dist/commands/dependencies.js.map +1 -0
  18. package/dist/commands/docs-repo-gate.js +104 -0
  19. package/dist/commands/docs-repo-gate.js.map +1 -0
  20. package/dist/commands/doctor.js +85 -0
  21. package/dist/commands/doctor.js.map +1 -0
  22. package/dist/commands/explore.js +195 -0
  23. package/dist/commands/explore.js.map +1 -0
  24. package/dist/commands/format.js +17 -0
  25. package/dist/commands/format.js.map +1 -0
  26. package/dist/commands/gherkin.js +309 -0
  27. package/dist/commands/gherkin.js.map +1 -0
  28. package/dist/commands/init.js +250 -0
  29. package/dist/commands/init.js.map +1 -0
  30. package/dist/commands/instructions.js +70 -0
  31. package/dist/commands/instructions.js.map +1 -0
  32. package/dist/commands/list.js +291 -0
  33. package/dist/commands/list.js.map +1 -0
  34. package/dist/commands/migrate-openspec.js +950 -0
  35. package/dist/commands/migrate-openspec.js.map +1 -0
  36. package/dist/commands/new.js +413 -0
  37. package/dist/commands/new.js.map +1 -0
  38. package/dist/commands/rebase.js +356 -0
  39. package/dist/commands/rebase.js.map +1 -0
  40. package/dist/commands/show.js +320 -0
  41. package/dist/commands/show.js.map +1 -0
  42. package/dist/commands/status.js +254 -0
  43. package/dist/commands/status.js.map +1 -0
  44. package/dist/commands/unarchive.js +317 -0
  45. package/dist/commands/unarchive.js.map +1 -0
  46. package/dist/commands/validate.js +1591 -0
  47. package/dist/commands/validate.js.map +1 -0
  48. package/dist/commands/verify.js +775 -0
  49. package/dist/commands/verify.js.map +1 -0
  50. package/dist/commands/vouch.js +343 -0
  51. package/dist/commands/vouch.js.map +1 -0
  52. package/dist/core/agent.js +2065 -0
  53. package/dist/core/agent.js.map +1 -0
  54. package/dist/core/agents-stamp.js +68 -0
  55. package/dist/core/agents-stamp.js.map +1 -0
  56. package/dist/core/arch.js +130 -0
  57. package/dist/core/arch.js.map +1 -0
  58. package/dist/core/brief.js +733 -0
  59. package/dist/core/brief.js.map +1 -0
  60. package/dist/core/coherence.js +577 -0
  61. package/dist/core/coherence.js.map +1 -0
  62. package/dist/core/concurrency.js +51 -0
  63. package/dist/core/concurrency.js.map +1 -0
  64. package/dist/core/config.js +180 -0
  65. package/dist/core/config.js.map +1 -0
  66. package/dist/core/delta.js +476 -0
  67. package/dist/core/delta.js.map +1 -0
  68. package/dist/core/dependencies.js +356 -0
  69. package/dist/core/dependencies.js.map +1 -0
  70. package/dist/core/docs.js +144 -0
  71. package/dist/core/docs.js.map +1 -0
  72. package/dist/core/doctor.js +559 -0
  73. package/dist/core/doctor.js.map +1 -0
  74. package/dist/core/document-bytes.js +59 -0
  75. package/dist/core/document-bytes.js.map +1 -0
  76. package/dist/core/explore.js +225 -0
  77. package/dist/core/explore.js.map +1 -0
  78. package/dist/core/fleet-context.js +225 -0
  79. package/dist/core/fleet-context.js.map +1 -0
  80. package/dist/core/frontmatter.js +199 -0
  81. package/dist/core/frontmatter.js.map +1 -0
  82. package/dist/core/gherkin.js +463 -0
  83. package/dist/core/gherkin.js.map +1 -0
  84. package/dist/core/health.js +67 -0
  85. package/dist/core/health.js.map +1 -0
  86. package/dist/core/ids.js +122 -0
  87. package/dist/core/ids.js.map +1 -0
  88. package/dist/core/issue.js +9 -0
  89. package/dist/core/issue.js.map +1 -0
  90. package/dist/core/json.js +74 -0
  91. package/dist/core/json.js.map +1 -0
  92. package/dist/core/likec4.js +479 -0
  93. package/dist/core/likec4.js.map +1 -0
  94. package/dist/core/maturity.js +66 -0
  95. package/dist/core/maturity.js.map +1 -0
  96. package/dist/core/openapi-merge.js +552 -0
  97. package/dist/core/openapi-merge.js.map +1 -0
  98. package/dist/core/openapi.js +255 -0
  99. package/dist/core/openapi.js.map +1 -0
  100. package/dist/core/openspec-inventory.js +1440 -0
  101. package/dist/core/openspec-inventory.js.map +1 -0
  102. package/dist/core/path-safety.js +168 -0
  103. package/dist/core/path-safety.js.map +1 -0
  104. package/dist/core/provenance.js +720 -0
  105. package/dist/core/provenance.js.map +1 -0
  106. package/dist/core/records.js +33 -0
  107. package/dist/core/records.js.map +1 -0
  108. package/dist/core/repo.js +436 -0
  109. package/dist/core/repo.js.map +1 -0
  110. package/dist/core/report.js +47 -0
  111. package/dist/core/report.js.map +1 -0
  112. package/dist/core/results.js +199 -0
  113. package/dist/core/results.js.map +1 -0
  114. package/dist/core/spec.js +481 -0
  115. package/dist/core/spec.js.map +1 -0
  116. package/dist/core/staging.js +890 -0
  117. package/dist/core/staging.js.map +1 -0
  118. package/dist/core/status.js +1104 -0
  119. package/dist/core/status.js.map +1 -0
  120. package/dist/core/steps.js +72 -0
  121. package/dist/core/steps.js.map +1 -0
  122. package/dist/core/verify.js +807 -0
  123. package/dist/core/verify.js.map +1 -0
  124. package/dist/core/version.js +24 -0
  125. package/dist/core/version.js.map +1 -0
  126. package/package.json +63 -4
@@ -0,0 +1,104 @@
1
+ /**
2
+ * The three refusals every command owes a docs repo it cannot read.
3
+ *
4
+ * One doctrine, three ways of reaching it: the gate a command passes before it
5
+ * enumerates anything, the error the enumeration throws when the repo goes away
6
+ * underneath it, and the errno that comes back when the repo is real but one
7
+ * file under it will not open. Six commands reach for one or another of them
8
+ * (`adopt`, `list`, `new`, `show`, `status`, `validate`), and the whole point of
9
+ * collecting them is that all six answer with the same sentences.
10
+ *
11
+ * They live in `commands/` rather than in `core/`: each one calls `fail`, which
12
+ * writes to stdout or stderr and sets the exit code. That is a decision about
13
+ * how this process ends, and core modules compute answers rather than end
14
+ * processes.
15
+ */
16
+ import { fail } from "../core/json.js";
17
+ import { docsRepoState } from "../core/repo.js";
18
+ /**
19
+ * The refusal every READ command owes a `docsDir` that is not a docs repo.
20
+ *
21
+ * It is not a formality. `validate --all` and `list` used to answer a docsDir
22
+ * that does not exist with a green report over zero services, which is the one
23
+ * output a fleet gate must never produce: "nothing is wrong" and "I could not
24
+ * look" are opposite facts, and in CI the first one merges. The two codes are
25
+ * kept apart because the fixes point in different directions — a wrong path in
26
+ * loam.json versus a docs repo that was never cloned or scaffolded — exactly
27
+ * the way `no-config` and `config-invalid` are kept apart.
28
+ *
29
+ * "Zero services" stays reachable, and only from a REAL docs repo whose
30
+ * `services/` is empty: that is a legitimate state (before the first
31
+ * `loam adopt`) and it must keep exiting 0.
32
+ *
33
+ * `need` says how much of a docs repo this particular run has to have. A run
34
+ * that only reads features genuinely does not need `services/` — repo.ts takes
35
+ * the same position for `listFeatures` — and refusing there would turn one
36
+ * diagnosis into two contradictory ones. A run that enumerates services needs
37
+ * the directory that IS the list of services.
38
+ *
39
+ * It used to live in validate.ts, on the reasoning that validate is the fleet
40
+ * gate this doctrine is about. Six commands import it now (`adopt`, `list`,
41
+ * `new`, `show`, `status`, `validate`), so the doctrine outgrew its host: `loam
42
+ * show` was structurally depending on the whole validate implementation for
43
+ * twenty lines of policy. The rule is the module now, and validate is one of
44
+ * its callers like the rest.
45
+ */
46
+ export function docsRepoReady(json, docsDir, need) {
47
+ const state = docsRepoState(docsDir);
48
+ if (state.kind === "ok" || (state.kind === "no-services" && need === "docs"))
49
+ return true;
50
+ if (state.kind === "missing") {
51
+ fail(json, "docs-missing", `The configured docs repo does not exist: ${state.path}. ` +
52
+ "Fix `docsDir` in loam.json, clone the docs repo there, " +
53
+ "or run `loam init --docs <dir> --create` to make a new one.");
54
+ return false;
55
+ }
56
+ fail(json, "services-missing", `${state.path} is not a docs repo — it has no services/ directory. ` +
57
+ "Point `docsDir` in loam.json at the shared docs repo, " +
58
+ "or run `loam init --docs <dir> --create` to make one.");
59
+ return false;
60
+ }
61
+ /**
62
+ * The same refusal, reached the other way: `listServices`/`listFeatures` throw
63
+ * `DocsRepoUnavailableError` when the repo goes away mid-run (or when a caller
64
+ * reaches enumeration without passing the gate above). Mapping it back onto the
65
+ * same two codes keeps one breach spelled one way, instead of surfacing as the
66
+ * `internal` catch-all in cli.ts.
67
+ */
68
+ export function reportDocsRepoError(json, err) {
69
+ fail(json, err.state.kind === "missing" ? "docs-missing" : "services-missing", err.message);
70
+ }
71
+ /**
72
+ * The third refusal a reading command owes: the docs repo IS a docs repo, but
73
+ * one file under it cannot be read. `list`, `show`, `status` and `validate` all
74
+ * walk a directory tree to answer, so all four owe the same sentence — and all
75
+ * four kept their own copy of it, which is exactly how three of them ended up
76
+ * wrong. Sharing it here, beside `reportDocsRepoError`, is the same trade: one
77
+ * breach spelled one way instead of four that drift.
78
+ *
79
+ * A filesystem failure is recognised by its errno, NOT by carrying a `path`.
80
+ * Node reports EISDIR from `read()` with no path at all — the directory opened,
81
+ * the read failed — so a guard that looked only for `path` was defeated by the
82
+ * commonest way a malformed docs repo breaks: one directory sitting where a
83
+ * file belongs escaped as a bare `internal: EISDIR`, naming neither the file
84
+ * nor the target. Anything carrying no errno at all is a real bug, and still
85
+ * escapes untouched.
86
+ *
87
+ * `consequence` is the clause after "so" — what the caller could not answer
88
+ * because of this. It is the only part that differs between the four.
89
+ */
90
+ export function reportRepositoryUnavailable(json, err, consequence, docsDir) {
91
+ const e = err;
92
+ if (e.path === undefined && typeof e.errno !== "number")
93
+ throw err;
94
+ const why = err instanceof Error ? err.message : String(err);
95
+ fail(json, "repository-unavailable", e.path !== undefined
96
+ ? `${e.path} could not be read, so ${consequence}. ${why}`
97
+ : // Naming the target's own directory here would be a guess, and a guess
98
+ // that is wrong precisely when it matters: one malformed artifact makes
99
+ // every OTHER target's answer fail too, and each would have accused its
100
+ // own files. Say what is known instead.
101
+ `A file under ${docsDir} could not be read, so ${consequence}. ${why}. ` +
102
+ `The failure named no path — that is how Node reports a directory sitting where a file belongs.`);
103
+ }
104
+ //# sourceMappingURL=docs-repo-gate.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"docs-repo-gate.js","sourceRoot":"","sources":["../../src/commands/docs-repo-gate.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AACH,OAAO,EAAE,IAAI,EAAE,MAAM,iBAAiB,CAAC;AACvC,OAAO,EAAE,aAAa,EAAiC,MAAM,iBAAiB,CAAC;AAE/E;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AACH,MAAM,UAAU,aAAa,CAAC,IAAa,EAAE,OAAe,EAAE,IAAyB;IACrF,MAAM,KAAK,GAAG,aAAa,CAAC,OAAO,CAAC,CAAC;IACrC,IAAI,KAAK,CAAC,IAAI,KAAK,IAAI,IAAI,CAAC,KAAK,CAAC,IAAI,KAAK,aAAa,IAAI,IAAI,KAAK,MAAM,CAAC;QAAE,OAAO,IAAI,CAAC;IAC1F,IAAI,KAAK,CAAC,IAAI,KAAK,SAAS,EAAE,CAAC;QAC7B,IAAI,CACF,IAAI,EACJ,cAAc,EACd,4CAA4C,KAAK,CAAC,IAAI,IAAI;YACxD,yDAAyD;YACzD,6DAA6D,CAChE,CAAC;QACF,OAAO,KAAK,CAAC;IACf,CAAC;IACD,IAAI,CACF,IAAI,EACJ,kBAAkB,EAClB,GAAG,KAAK,CAAC,IAAI,uDAAuD;QAClE,wDAAwD;QACxD,uDAAuD,CAC1D,CAAC;IACF,OAAO,KAAK,CAAC;AACf,CAAC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,mBAAmB,CAAC,IAAa,EAAE,GAA6B;IAC9E,IAAI,CAAC,IAAI,EAAE,GAAG,CAAC,KAAK,CAAC,IAAI,KAAK,SAAS,CAAC,CAAC,CAAC,cAAc,CAAC,CAAC,CAAC,kBAAkB,EAAE,GAAG,CAAC,OAAO,CAAC,CAAC;AAC9F,CAAC;AAED;;;;;;;;;;;;;;;;;;GAkBG;AACH,MAAM,UAAU,2BAA2B,CACzC,IAAa,EACb,GAAY,EACZ,WAAmB,EACnB,OAAe;IAEf,MAAM,CAAC,GAAG,GAA4B,CAAC;IACvC,IAAI,CAAC,CAAC,IAAI,KAAK,SAAS,IAAI,OAAO,CAAC,CAAC,KAAK,KAAK,QAAQ;QAAE,MAAM,GAAG,CAAC;IACnE,MAAM,GAAG,GAAG,GAAG,YAAY,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC;IAC7D,IAAI,CACF,IAAI,EACJ,wBAAwB,EACxB,CAAC,CAAC,IAAI,KAAK,SAAS;QAClB,CAAC,CAAC,GAAG,CAAC,CAAC,IAAI,0BAA0B,WAAW,KAAK,GAAG,EAAE;QAC1D,CAAC,CAAC,uEAAuE;YACvE,wEAAwE;YACxE,wEAAwE;YACxE,wCAAwC;YACxC,gBAAgB,OAAO,0BAA0B,WAAW,KAAK,GAAG,IAAI;gBACxE,gGAAgG,CACrG,CAAC;AACJ,CAAC","sourcesContent":["/**\n * The three refusals every command owes a docs repo it cannot read.\n *\n * One doctrine, three ways of reaching it: the gate a command passes before it\n * enumerates anything, the error the enumeration throws when the repo goes away\n * underneath it, and the errno that comes back when the repo is real but one\n * file under it will not open. Six commands reach for one or another of them\n * (`adopt`, `list`, `new`, `show`, `status`, `validate`), and the whole point of\n * collecting them is that all six answer with the same sentences.\n *\n * They live in `commands/` rather than in `core/`: each one calls `fail`, which\n * writes to stdout or stderr and sets the exit code. That is a decision about\n * how this process ends, and core modules compute answers rather than end\n * processes.\n */\nimport { fail } from \"../core/json.js\";\nimport { docsRepoState, type DocsRepoUnavailableError } from \"../core/repo.js\";\n\n/**\n * The refusal every READ command owes a `docsDir` that is not a docs repo.\n *\n * It is not a formality. `validate --all` and `list` used to answer a docsDir\n * that does not exist with a green report over zero services, which is the one\n * output a fleet gate must never produce: \"nothing is wrong\" and \"I could not\n * look\" are opposite facts, and in CI the first one merges. The two codes are\n * kept apart because the fixes point in different directions — a wrong path in\n * loam.json versus a docs repo that was never cloned or scaffolded — exactly\n * the way `no-config` and `config-invalid` are kept apart.\n *\n * \"Zero services\" stays reachable, and only from a REAL docs repo whose\n * `services/` is empty: that is a legitimate state (before the first\n * `loam adopt`) and it must keep exiting 0.\n *\n * `need` says how much of a docs repo this particular run has to have. A run\n * that only reads features genuinely does not need `services/` — repo.ts takes\n * the same position for `listFeatures` — and refusing there would turn one\n * diagnosis into two contradictory ones. A run that enumerates services needs\n * the directory that IS the list of services.\n *\n * It used to live in validate.ts, on the reasoning that validate is the fleet\n * gate this doctrine is about. Six commands import it now (`adopt`, `list`,\n * `new`, `show`, `status`, `validate`), so the doctrine outgrew its host: `loam\n * show` was structurally depending on the whole validate implementation for\n * twenty lines of policy. The rule is the module now, and validate is one of\n * its callers like the rest.\n */\nexport function docsRepoReady(json: boolean, docsDir: string, need: \"docs\" | \"services\"): boolean {\n const state = docsRepoState(docsDir);\n if (state.kind === \"ok\" || (state.kind === \"no-services\" && need === \"docs\")) return true;\n if (state.kind === \"missing\") {\n fail(\n json,\n \"docs-missing\",\n `The configured docs repo does not exist: ${state.path}. ` +\n \"Fix `docsDir` in loam.json, clone the docs repo there, \" +\n \"or run `loam init --docs <dir> --create` to make a new one.\",\n );\n return false;\n }\n fail(\n json,\n \"services-missing\",\n `${state.path} is not a docs repo — it has no services/ directory. ` +\n \"Point `docsDir` in loam.json at the shared docs repo, \" +\n \"or run `loam init --docs <dir> --create` to make one.\",\n );\n return false;\n}\n\n/**\n * The same refusal, reached the other way: `listServices`/`listFeatures` throw\n * `DocsRepoUnavailableError` when the repo goes away mid-run (or when a caller\n * reaches enumeration without passing the gate above). Mapping it back onto the\n * same two codes keeps one breach spelled one way, instead of surfacing as the\n * `internal` catch-all in cli.ts.\n */\nexport function reportDocsRepoError(json: boolean, err: DocsRepoUnavailableError): void {\n fail(json, err.state.kind === \"missing\" ? \"docs-missing\" : \"services-missing\", err.message);\n}\n\n/**\n * The third refusal a reading command owes: the docs repo IS a docs repo, but\n * one file under it cannot be read. `list`, `show`, `status` and `validate` all\n * walk a directory tree to answer, so all four owe the same sentence — and all\n * four kept their own copy of it, which is exactly how three of them ended up\n * wrong. Sharing it here, beside `reportDocsRepoError`, is the same trade: one\n * breach spelled one way instead of four that drift.\n *\n * A filesystem failure is recognised by its errno, NOT by carrying a `path`.\n * Node reports EISDIR from `read()` with no path at all — the directory opened,\n * the read failed — so a guard that looked only for `path` was defeated by the\n * commonest way a malformed docs repo breaks: one directory sitting where a\n * file belongs escaped as a bare `internal: EISDIR`, naming neither the file\n * nor the target. Anything carrying no errno at all is a real bug, and still\n * escapes untouched.\n *\n * `consequence` is the clause after \"so\" — what the caller could not answer\n * because of this. It is the only part that differs between the four.\n */\nexport function reportRepositoryUnavailable(\n json: boolean,\n err: unknown,\n consequence: string,\n docsDir: string,\n): void {\n const e = err as NodeJS.ErrnoException;\n if (e.path === undefined && typeof e.errno !== \"number\") throw err;\n const why = err instanceof Error ? err.message : String(err);\n fail(\n json,\n \"repository-unavailable\",\n e.path !== undefined\n ? `${e.path} could not be read, so ${consequence}. ${why}`\n : // Naming the target's own directory here would be a guess, and a guess\n // that is wrong precisely when it matters: one malformed artifact makes\n // every OTHER target's answer fail too, and each would have accused its\n // own files. Say what is known instead.\n `A file under ${docsDir} could not be read, so ${consequence}. ${why}. ` +\n `The failure named no path — that is how Node reports a directory sitting where a file belongs.`,\n );\n}\n"]}
@@ -0,0 +1,85 @@
1
+ import { diagnose } from "../core/doctor.js";
2
+ import { emitJson } from "../core/json.js";
3
+ export function registerDoctor(program) {
4
+ program
5
+ .command("doctor")
6
+ .description("Diagnose local loam configuration and docs-repo accessibility without writing")
7
+ .option("--json", "emit the machine contract instead of the human view")
8
+ .action(async (opts) => {
9
+ const report = await diagnose();
10
+ if (opts.json === true)
11
+ emitJson({ command: "doctor", ...report });
12
+ else
13
+ printDoctor(report);
14
+ if (!report.healthy)
15
+ process.exitCode = 1;
16
+ });
17
+ }
18
+ function yesNo(value) {
19
+ return value ? "yes" : "no";
20
+ }
21
+ /**
22
+ * The stamp as one readable token. "absent" and "unstamped" are kept apart
23
+ * because they are different repos: the first has no AGENTS.md at all, the
24
+ * second has one nobody has claimed a version for.
25
+ */
26
+ function stampLabel(stamp) {
27
+ if (!stamp.present)
28
+ return "absent";
29
+ if (stamp.version === null)
30
+ return "unstamped";
31
+ return `v${stamp.version}${stamp.stale ? " (stale)" : ""}`;
32
+ }
33
+ /**
34
+ * What a killed writer left in the docs repo, as one line. "clean" is the
35
+ * answer on every healthy repo, and "(unresolved)" keeps it apart from clean:
36
+ * a docsDir that never resolved was never looked at.
37
+ */
38
+ function writePathLabel(residue) {
39
+ if (residue === null)
40
+ return "(unresolved)";
41
+ const parts = [
42
+ ...(residue.lock === null ? [] : [`lock held${residue.lock.stale ? " (stale)" : ""}`]),
43
+ ...(residue.intentUnreadable
44
+ ? ["interrupted commit (unreadable)"]
45
+ : residue.intent === null
46
+ ? []
47
+ : [`interrupted ${residue.intent.command} of ${residue.intent.feature}`]),
48
+ ...(residue.temps.length === 0 ? [] : [`${residue.temps.length} orphaned temp file(s)`]),
49
+ ];
50
+ return parts.length === 0 ? "clean" : parts.join(" · ");
51
+ }
52
+ function printDoctor(report) {
53
+ console.log(`loam doctor — ${report.healthy ? "healthy" : "blocked"}`);
54
+ console.log(` runtime ${report.runtime.package}@${report.runtime.version}`);
55
+ console.log(` node ${report.runtime.node} ${report.runtime.platform}/${report.runtime.arch}`);
56
+ console.log(` config ${report.config.status} ${report.config.path}`);
57
+ if (report.config.error !== null)
58
+ console.log(` ${report.config.error}`);
59
+ console.log(` docsDir ${report.docs.path ?? "(unresolved)"}`);
60
+ console.log(` access exists ${yesNo(report.docs.exists)} · read ${yesNo(report.docs.readable)} · write ${yesNo(report.docs.writable)}`);
61
+ console.log(` fleet services/ ${yesNo(report.docs.servicesDir)} · landscape ${yesNo(report.docs.landscape)} · ${report.counts.services} services · ${report.counts.activeFeatures} active features`);
62
+ console.log(` binding ${report.currentService.configured ?? "(none)"} · ${report.currentService.status}`);
63
+ // The agent surface is reported whether or not it has drifted — like every
64
+ // line above it, this is state, not a complaint. Only the findings block is
65
+ // silent when there is nothing to say.
66
+ const agents = report.agents;
67
+ console.log(` agents ${agents.tools.length === 0 ? "(none)" : agents.tools.join(", ")}`
68
+ + ` (${agents.toolsSource}) · ${agents.plannedFiles} files · ${agents.missingFiles.length} missing`
69
+ + ` · ${agents.staleFiles.length} stale · AGENTS.md ${stampLabel(agents.stamp)}`);
70
+ // The write path is state too, and its clean answer is the one worth printing
71
+ // most often: a reader who has just been told the docs are half-written needs
72
+ // to see it go away.
73
+ console.log(` write path ${writePathLabel(report.writePath)}`);
74
+ if (report.findings.length > 0) {
75
+ console.log("\n findings");
76
+ for (const finding of report.findings) {
77
+ console.log(` ${finding.severity === "blocker" ? "✗" : "⚠"} ${finding.code}: ${finding.message}`);
78
+ // The fix is printed on its own indented line rather than appended to the
79
+ // message: what is wrong and what to type are two different sentences,
80
+ // and someone scanning a blocked repo reads down the `fix:` column.
81
+ console.log(` fix: ${finding.fix}`);
82
+ }
83
+ }
84
+ }
85
+ //# sourceMappingURL=doctor.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"doctor.js","sourceRoot":"","sources":["../../src/commands/doctor.ts"],"names":[],"mappings":"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","sourcesContent":["import type { Command } from \"commander\";\nimport { diagnose, type DoctorReport } from \"../core/doctor.js\";\nimport { emitJson } from \"../core/json.js\";\n\ninterface DoctorOptions {\n json?: boolean;\n}\n\nexport function registerDoctor(program: Command): void {\n program\n .command(\"doctor\")\n .description(\"Diagnose local loam configuration and docs-repo accessibility without writing\")\n .option(\"--json\", \"emit the machine contract instead of the human view\")\n .action(async (opts: DoctorOptions) => {\n const report = await diagnose();\n if (opts.json === true) emitJson({ command: \"doctor\", ...report });\n else printDoctor(report);\n if (!report.healthy) process.exitCode = 1;\n });\n}\n\nfunction yesNo(value: boolean): string {\n return value ? \"yes\" : \"no\";\n}\n\n/**\n * The stamp as one readable token. \"absent\" and \"unstamped\" are kept apart\n * because they are different repos: the first has no AGENTS.md at all, the\n * second has one nobody has claimed a version for.\n */\nfunction stampLabel(stamp: DoctorReport[\"agents\"][\"stamp\"]): string {\n if (!stamp.present) return \"absent\";\n if (stamp.version === null) return \"unstamped\";\n return `v${stamp.version}${stamp.stale ? \" (stale)\" : \"\"}`;\n}\n\n/**\n * What a killed writer left in the docs repo, as one line. \"clean\" is the\n * answer on every healthy repo, and \"(unresolved)\" keeps it apart from clean:\n * a docsDir that never resolved was never looked at.\n */\nfunction writePathLabel(residue: DoctorReport[\"writePath\"]): string {\n if (residue === null) return \"(unresolved)\";\n const parts = [\n ...(residue.lock === null ? [] : [`lock held${residue.lock.stale ? \" (stale)\" : \"\"}`]),\n ...(residue.intentUnreadable\n ? [\"interrupted commit (unreadable)\"]\n : residue.intent === null\n ? []\n : [`interrupted ${residue.intent.command} of ${residue.intent.feature}`]),\n ...(residue.temps.length === 0 ? [] : [`${residue.temps.length} orphaned temp file(s)`]),\n ];\n return parts.length === 0 ? \"clean\" : parts.join(\" · \");\n}\n\nfunction printDoctor(report: DoctorReport): void {\n console.log(`loam doctor — ${report.healthy ? \"healthy\" : \"blocked\"}`);\n console.log(` runtime ${report.runtime.package}@${report.runtime.version}`);\n console.log(` node ${report.runtime.node} ${report.runtime.platform}/${report.runtime.arch}`);\n console.log(` config ${report.config.status} ${report.config.path}`);\n if (report.config.error !== null) console.log(` ${report.config.error}`);\n console.log(` docsDir ${report.docs.path ?? \"(unresolved)\"}`);\n console.log(\n ` access exists ${yesNo(report.docs.exists)} · read ${yesNo(report.docs.readable)} · write ${yesNo(report.docs.writable)}`,\n );\n console.log(\n ` fleet services/ ${yesNo(report.docs.servicesDir)} · landscape ${yesNo(report.docs.landscape)} · ${report.counts.services} services · ${report.counts.activeFeatures} active features`,\n );\n console.log(\n ` binding ${report.currentService.configured ?? \"(none)\"} · ${report.currentService.status}`,\n );\n // The agent surface is reported whether or not it has drifted — like every\n // line above it, this is state, not a complaint. Only the findings block is\n // silent when there is nothing to say.\n const agents = report.agents;\n console.log(\n ` agents ${agents.tools.length === 0 ? \"(none)\" : agents.tools.join(\", \")}`\n + ` (${agents.toolsSource}) · ${agents.plannedFiles} files · ${agents.missingFiles.length} missing`\n + ` · ${agents.staleFiles.length} stale · AGENTS.md ${stampLabel(agents.stamp)}`,\n );\n // The write path is state too, and its clean answer is the one worth printing\n // most often: a reader who has just been told the docs are half-written needs\n // to see it go away.\n console.log(` write path ${writePathLabel(report.writePath)}`);\n if (report.findings.length > 0) {\n console.log(\"\\n findings\");\n for (const finding of report.findings) {\n console.log(` ${finding.severity === \"blocker\" ? \"✗\" : \"⚠\"} ${finding.code}: ${finding.message}`);\n // The fix is printed on its own indented line rather than appended to the\n // message: what is wrong and what to type are two different sentences,\n // and someone scanning a blocked repo reads down the `fix:` column.\n console.log(` fix: ${finding.fix}`);\n }\n }\n}\n"]}
@@ -0,0 +1,195 @@
1
+ import { loadConfig } from "../core/config.js";
2
+ import { explore } from "../core/explore.js";
3
+ import { FleetContext } from "../core/fleet-context.js";
4
+ import { emitJson, fail, reportNoConfig } from "../core/json.js";
5
+ import { assertServiceId, featureIdProblem, InvalidIdError } from "../core/ids.js";
6
+ import { DocsRepoUnavailableError } from "../core/repo.js";
7
+ import { docsRepoReady, reportDocsRepoError, reportRepositoryUnavailable } from "./docs-repo-gate.js";
8
+ import { plural } from "./format.js";
9
+ /**
10
+ * The feature id in the suggested command line when `--as` did not say.
11
+ *
12
+ * It has to satisfy the feature-id grammar, because `scaffold` is documented as
13
+ * the literal `loam new` line and AGENTS.md's rule is that an instruction loam
14
+ * prints and loam refuses is a defect. `FEAT-NNN` — the obvious placeholder —
15
+ * is exactly that: `loam new FEAT-NNN` comes back `invalid-option`, so the last
16
+ * line of every default run was a command that could not be run.
17
+ *
18
+ * `FEAT-000` parses and is still unmistakably a placeholder, which buys the
19
+ * property the refusal was standing in for: an agent that pastes it unedited
20
+ * gets a feature called FEAT-000, visible in `loam list` within seconds and
21
+ * deletable with `git rm -r`. A plausible id (FEAT-1) would not be.
22
+ */
23
+ const PLACEHOLDER = "FEAT-000";
24
+ export function registerExplore(program) {
25
+ program
26
+ .command("explore")
27
+ .argument("[service...]", "service ids the change starts from")
28
+ .description("Read the fleet around a change nobody has written down yet — writes nothing")
29
+ .option("--json", "emit the machine contract instead of the human view")
30
+ .option("--op <operationId>", "seed from an operation instead of a service; repeatable", (value, prev = []) => [...prev, value])
31
+ .option("--as <FEAT>", `feature id for the suggested command line (default: ${PLACEHOLDER})`)
32
+ .action(async (serviceArgs, opts) => {
33
+ const json = opts.json === true;
34
+ const services = serviceArgs ?? [];
35
+ const operations = opts.op ?? [];
36
+ // `--as` is interpolated into the `loam new` line this command prints
37
+ // under `next:` and returns as `scaffold`, and `loam-feature` teaches an
38
+ // agent to pass `$1` straight into it. Unchecked, `explore` hands back a
39
+ // command `loam new` then refuses — loam instructing an agent to run
40
+ // something loam rejects, which is the one thing the printed-command
41
+ // guard exists to prevent and the one shape it cannot see, because it
42
+ // reads literal source strings and this line is assembled from argv.
43
+ // Same grammar as `new`'s, from core/ids.ts, so the two cannot disagree.
44
+ if (opts.as !== undefined) {
45
+ const problem = featureIdProblem(opts.as, "feature id for --as");
46
+ if (problem !== null) {
47
+ fail(json, "invalid-option", problem);
48
+ return;
49
+ }
50
+ }
51
+ if (services.length === 0 && operations.length === 0) {
52
+ fail(json, "invalid-option", "Nothing to explore from. Name at least one service, or pass --op <operationId>. " +
53
+ "`loam list services` is the fleet; `loam show <service>` is one of them.");
54
+ return;
55
+ }
56
+ // Rule 6 in docs/DESIGN.md: a raw argv string that reaches a path join is
57
+ // guarded at the command boundary. Every id here reaches `servicePaths`.
58
+ // Before `loadConfig`, exactly as `loam new` guards its `--touches` list:
59
+ // the grammar is a fact about the argument, not about the repository, so
60
+ // an unusable id should answer the same whether or not you are standing
61
+ // in a wired repo.
62
+ //
63
+ // A seed naming a service that does not EXIST is fine and reported as
64
+ // `unknown` — the feature may be introducing it — but one that could not
65
+ // be a service directory at all is a typo, not an intention.
66
+ try {
67
+ for (const id of services)
68
+ assertServiceId(id, "service");
69
+ }
70
+ catch (err) {
71
+ if (!(err instanceof InvalidIdError))
72
+ throw err;
73
+ fail(json, "invalid-option", err.message);
74
+ return;
75
+ }
76
+ const config = await loadConfig();
77
+ if (!config) {
78
+ reportNoConfig(json);
79
+ return;
80
+ }
81
+ const { docsDir } = config;
82
+ // `services`, not `docs`: every answer here is a statement about the
83
+ // fleet, and an empty ring read out of a directory that is not a docs
84
+ // repo says "nothing calls this service" about a repo nobody looked in.
85
+ if (!docsRepoReady(json, docsDir, "services"))
86
+ return;
87
+ try {
88
+ const result = await explore({
89
+ docsDir,
90
+ services,
91
+ operations,
92
+ featureId: opts.as ?? PLACEHOLDER,
93
+ context: new FleetContext(),
94
+ });
95
+ if (json) {
96
+ emitJson({ command: "explore", docsDir, ...result });
97
+ return;
98
+ }
99
+ print(result);
100
+ }
101
+ catch (err) {
102
+ if (err instanceof DocsRepoUnavailableError) {
103
+ reportDocsRepoError(json, err);
104
+ return;
105
+ }
106
+ // The scan is all-or-nothing — it reads every seed's contract to build
107
+ // one table — so the honest answer to an unreadable file is a refusal
108
+ // naming the path, not a ring with a silent hole in it.
109
+ reportRepositoryUnavailable(json, err, "the exploration would be missing a service", docsDir);
110
+ }
111
+ });
112
+ }
113
+ /* ------------------------------------------------------------------ */
114
+ function print(r) {
115
+ if (!r.landscape.present || !r.landscape.parses) {
116
+ // Said first and without hedging: with no readable map there IS no ring,
117
+ // and a reader who takes the empty neighbour list at face value concludes
118
+ // the change is contained when nothing checked that.
119
+ const why = r.landscape.present ? "does not parse" : "does not exist";
120
+ console.log(`! architecture/landscape.likec4 ${why} — no neighbour below is derived, and none is missing either\n`);
121
+ }
122
+ for (const s of r.services) {
123
+ console.log(`${s.id} ${label(s)}`);
124
+ if (!s.known) {
125
+ // Two different facts wear the same absence. A SEED nobody typed by
126
+ // accident is a service the feature introduces; one typed by accident is
127
+ // a typo, and the near-miss list is what separates them. A neighbour is
128
+ // neither — the fleet map drew an edge to something with no
129
+ // `services/<id>/`, which is usually an external system and occasionally
130
+ // a service nobody has adopted. Saying "typo" about that one sends a
131
+ // reader looking for a mistake they did not make.
132
+ const near = r.unknown.find((u) => u.id === s.id)?.nearest ?? [];
133
+ const why = s.reason === "seed"
134
+ ? "the feature introduces it, or the id is a typo"
135
+ : "an external system, or a service nobody has adopted";
136
+ console.log(` no services/${s.id}/ — ${why}` +
137
+ (near.length > 0 ? `; closest existing: ${near.join(", ")}` : ""));
138
+ }
139
+ else {
140
+ if (s.missing.length > 0)
141
+ console.log(` missing: ${s.missing.join(", ")}`);
142
+ if (!s.modelled)
143
+ console.log(` not in the fleet map — no edge into it can be checked`);
144
+ // Said before `exposes`, and said at all: an unreadable contract exposes
145
+ // an empty list, and printing nothing there reads as "this service offers
146
+ // no endpoints" — the answer that turns a MODIFIED requirement into an
147
+ // ADDED one.
148
+ if (s.openapi.unreadable) {
149
+ console.log(` ! openapi.yaml does not parse — nothing here lists what ${s.id} exposes`);
150
+ if (s.openapi.error !== undefined)
151
+ console.log(` ${s.openapi.error}`);
152
+ }
153
+ else if (s.operations.length > 0) {
154
+ console.log(` exposes: ${s.operations.join(", ")}`);
155
+ }
156
+ }
157
+ for (const e of s.inbound)
158
+ console.log(` ← ${e.service}${edgeNote(e.op, e.title)}`);
159
+ for (const e of s.outbound)
160
+ console.log(` → ${e.service}${edgeNote(e.op, e.title)}`);
161
+ console.log("");
162
+ }
163
+ if (r.unresolvedOperations.length > 0) {
164
+ console.log(`! no living contract defines ${r.unresolvedOperations.join(", ")} — ` +
165
+ `the feature is adding ${r.unresolvedOperations.length === 1 ? "it" : "them"}, or the id is wrong\n`);
166
+ }
167
+ if (r.neighbours.length > 0) {
168
+ // The whole point of the command, so it is stated as a question rather than
169
+ // a list: these are candidates for --touches, and loam does not know which
170
+ // of them this change actually touches.
171
+ console.log(`${plural(r.neighbours.length, "neighbour")} one hop out: ${r.neighbours.join(", ")}`);
172
+ console.log(" weigh each one — loam knows they are connected, not whether you change them\n");
173
+ }
174
+ if (r.overlaps.length > 0) {
175
+ console.log("already in flight over this ground");
176
+ for (const o of r.overlaps)
177
+ console.log(` ${o.feature} ${o.services.join(", ")}`);
178
+ console.log(" `loam dependencies` says which must archive first, and what no ordering fixes\n");
179
+ }
180
+ console.log(`next: ${r.scaffold}`);
181
+ }
182
+ function label(s) {
183
+ const where = s.reason === "seed" ? "seed" : s.reason === "calls-seed" ? "calls a seed" : "called by a seed";
184
+ // `known`, matching the branch in `print` — not `maturity === null`. The two
185
+ // are set together and cannot disagree today, but discriminating on different
186
+ // fields for the same question is how they start to: a reader fixing one
187
+ // branch has no reason to look at the other.
188
+ return s.known ? `${where} · ${s.maturity}` : `${where} · not in the docs repo`;
189
+ }
190
+ function edgeNote(op, title) {
191
+ if (op !== null)
192
+ return ` ${op}`;
193
+ return title !== null ? ` "${title}"` : "";
194
+ }
195
+ //# sourceMappingURL=explore.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"explore.js","sourceRoot":"","sources":["../../src/commands/explore.ts"],"names":[],"mappings":"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","sourcesContent":["import type { Command } from \"commander\";\nimport { loadConfig } from \"../core/config.js\";\nimport { explore, type Exploration, type ExploreService } from \"../core/explore.js\";\nimport { FleetContext } from \"../core/fleet-context.js\";\nimport { emitJson, fail, reportNoConfig } from \"../core/json.js\";\nimport { assertServiceId, featureIdProblem, InvalidIdError } from \"../core/ids.js\";\nimport { DocsRepoUnavailableError } from \"../core/repo.js\";\nimport { docsRepoReady, reportDocsRepoError, reportRepositoryUnavailable } from \"./docs-repo-gate.js\";\nimport { plural } from \"./format.js\";\n\n/**\n * The feature id in the suggested command line when `--as` did not say.\n *\n * It has to satisfy the feature-id grammar, because `scaffold` is documented as\n * the literal `loam new` line and AGENTS.md's rule is that an instruction loam\n * prints and loam refuses is a defect. `FEAT-NNN` — the obvious placeholder —\n * is exactly that: `loam new FEAT-NNN` comes back `invalid-option`, so the last\n * line of every default run was a command that could not be run.\n *\n * `FEAT-000` parses and is still unmistakably a placeholder, which buys the\n * property the refusal was standing in for: an agent that pastes it unedited\n * gets a feature called FEAT-000, visible in `loam list` within seconds and\n * deletable with `git rm -r`. A plausible id (FEAT-1) would not be.\n */\nconst PLACEHOLDER = \"FEAT-000\";\n\ninterface ExploreOptions {\n json?: boolean;\n op?: string[];\n as?: string;\n}\n\nexport function registerExplore(program: Command): void {\n program\n .command(\"explore\")\n .argument(\"[service...]\", \"service ids the change starts from\")\n .description(\"Read the fleet around a change nobody has written down yet — writes nothing\")\n .option(\"--json\", \"emit the machine contract instead of the human view\")\n .option(\n \"--op <operationId>\",\n \"seed from an operation instead of a service; repeatable\",\n (value: string, prev: string[] = []) => [...prev, value],\n )\n .option(\"--as <FEAT>\", `feature id for the suggested command line (default: ${PLACEHOLDER})`)\n .action(async (serviceArgs: string[], opts: ExploreOptions) => {\n const json = opts.json === true;\n const services = serviceArgs ?? [];\n const operations = opts.op ?? [];\n\n // `--as` is interpolated into the `loam new` line this command prints\n // under `next:` and returns as `scaffold`, and `loam-feature` teaches an\n // agent to pass `$1` straight into it. Unchecked, `explore` hands back a\n // command `loam new` then refuses — loam instructing an agent to run\n // something loam rejects, which is the one thing the printed-command\n // guard exists to prevent and the one shape it cannot see, because it\n // reads literal source strings and this line is assembled from argv.\n // Same grammar as `new`'s, from core/ids.ts, so the two cannot disagree.\n if (opts.as !== undefined) {\n const problem = featureIdProblem(opts.as, \"feature id for --as\");\n if (problem !== null) {\n fail(json, \"invalid-option\", problem);\n return;\n }\n }\n if (services.length === 0 && operations.length === 0) {\n fail(\n json,\n \"invalid-option\",\n \"Nothing to explore from. Name at least one service, or pass --op <operationId>. \" +\n \"`loam list services` is the fleet; `loam show <service>` is one of them.\",\n );\n return;\n }\n\n // Rule 6 in docs/DESIGN.md: a raw argv string that reaches a path join is\n // guarded at the command boundary. Every id here reaches `servicePaths`.\n // Before `loadConfig`, exactly as `loam new` guards its `--touches` list:\n // the grammar is a fact about the argument, not about the repository, so\n // an unusable id should answer the same whether or not you are standing\n // in a wired repo.\n //\n // A seed naming a service that does not EXIST is fine and reported as\n // `unknown` — the feature may be introducing it — but one that could not\n // be a service directory at all is a typo, not an intention.\n try {\n for (const id of services) assertServiceId(id, \"service\");\n } catch (err) {\n if (!(err instanceof InvalidIdError)) throw err;\n fail(json, \"invalid-option\", err.message);\n return;\n }\n\n const config = await loadConfig();\n if (!config) {\n reportNoConfig(json);\n return;\n }\n const { docsDir } = config;\n // `services`, not `docs`: every answer here is a statement about the\n // fleet, and an empty ring read out of a directory that is not a docs\n // repo says \"nothing calls this service\" about a repo nobody looked in.\n if (!docsRepoReady(json, docsDir, \"services\")) return;\n\n try {\n const result = await explore({\n docsDir,\n services,\n operations,\n featureId: opts.as ?? PLACEHOLDER,\n context: new FleetContext(),\n });\n if (json) {\n emitJson({ command: \"explore\", docsDir, ...result });\n return;\n }\n print(result);\n } catch (err) {\n if (err instanceof DocsRepoUnavailableError) {\n reportDocsRepoError(json, err);\n return;\n }\n // The scan is all-or-nothing — it reads every seed's contract to build\n // one table — so the honest answer to an unreadable file is a refusal\n // naming the path, not a ring with a silent hole in it.\n reportRepositoryUnavailable(json, err, \"the exploration would be missing a service\", docsDir);\n }\n });\n}\n\n/* ------------------------------------------------------------------ */\n\nfunction print(r: Exploration): void {\n if (!r.landscape.present || !r.landscape.parses) {\n // Said first and without hedging: with no readable map there IS no ring,\n // and a reader who takes the empty neighbour list at face value concludes\n // the change is contained when nothing checked that.\n const why = r.landscape.present ? \"does not parse\" : \"does not exist\";\n console.log(\n `! architecture/landscape.likec4 ${why} — no neighbour below is derived, and none is missing either\\n`,\n );\n }\n\n for (const s of r.services) {\n console.log(`${s.id} ${label(s)}`);\n if (!s.known) {\n // Two different facts wear the same absence. A SEED nobody typed by\n // accident is a service the feature introduces; one typed by accident is\n // a typo, and the near-miss list is what separates them. A neighbour is\n // neither — the fleet map drew an edge to something with no\n // `services/<id>/`, which is usually an external system and occasionally\n // a service nobody has adopted. Saying \"typo\" about that one sends a\n // reader looking for a mistake they did not make.\n const near = r.unknown.find((u) => u.id === s.id)?.nearest ?? [];\n const why =\n s.reason === \"seed\"\n ? \"the feature introduces it, or the id is a typo\"\n : \"an external system, or a service nobody has adopted\";\n console.log(\n ` no services/${s.id}/ — ${why}` +\n (near.length > 0 ? `; closest existing: ${near.join(\", \")}` : \"\"),\n );\n } else {\n if (s.missing.length > 0) console.log(` missing: ${s.missing.join(\", \")}`);\n if (!s.modelled) console.log(` not in the fleet map — no edge into it can be checked`);\n // Said before `exposes`, and said at all: an unreadable contract exposes\n // an empty list, and printing nothing there reads as \"this service offers\n // no endpoints\" — the answer that turns a MODIFIED requirement into an\n // ADDED one.\n if (s.openapi.unreadable) {\n console.log(` ! openapi.yaml does not parse — nothing here lists what ${s.id} exposes`);\n if (s.openapi.error !== undefined) console.log(` ${s.openapi.error}`);\n } else if (s.operations.length > 0) {\n console.log(` exposes: ${s.operations.join(\", \")}`);\n }\n }\n for (const e of s.inbound) console.log(` ← ${e.service}${edgeNote(e.op, e.title)}`);\n for (const e of s.outbound) console.log(` → ${e.service}${edgeNote(e.op, e.title)}`);\n console.log(\"\");\n }\n\n if (r.unresolvedOperations.length > 0) {\n console.log(\n `! no living contract defines ${r.unresolvedOperations.join(\", \")} — ` +\n `the feature is adding ${r.unresolvedOperations.length === 1 ? \"it\" : \"them\"}, or the id is wrong\\n`,\n );\n }\n\n if (r.neighbours.length > 0) {\n // The whole point of the command, so it is stated as a question rather than\n // a list: these are candidates for --touches, and loam does not know which\n // of them this change actually touches.\n console.log(\n `${plural(r.neighbours.length, \"neighbour\")} one hop out: ${r.neighbours.join(\", \")}`,\n );\n console.log(\" weigh each one — loam knows they are connected, not whether you change them\\n\");\n }\n\n if (r.overlaps.length > 0) {\n console.log(\"already in flight over this ground\");\n for (const o of r.overlaps) console.log(` ${o.feature} ${o.services.join(\", \")}`);\n console.log(\" `loam dependencies` says which must archive first, and what no ordering fixes\\n\");\n }\n\n console.log(`next: ${r.scaffold}`);\n}\n\nfunction label(s: ExploreService): string {\n const where = s.reason === \"seed\" ? \"seed\" : s.reason === \"calls-seed\" ? \"calls a seed\" : \"called by a seed\";\n // `known`, matching the branch in `print` — not `maturity === null`. The two\n // are set together and cannot disagree today, but discriminating on different\n // fields for the same question is how they start to: a reader fixing one\n // branch has no reason to look at the other.\n return s.known ? `${where} · ${s.maturity}` : `${where} · not in the docs repo`;\n}\n\nfunction edgeNote(op: string | null, title: string | null): string {\n if (op !== null) return ` ${op}`;\n return title !== null ? ` \"${title}\"` : \"\";\n}\n"]}
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Small shaping helpers for the human renderers.
3
+ *
4
+ * They live in `commands/` and not in `core/`: nothing here computes an answer,
5
+ * it only decides how an answer reads to a person. The `--json` payloads never
6
+ * touch this file.
7
+ */
8
+ /**
9
+ * "1 service", "2 services". The regular English plural, spelled once because
10
+ * five renderers had drifted into five copies of the same ternary and a sixth
11
+ * would have been written the next time somebody counted something. A noun with
12
+ * an irregular plural does not belong here — say both forms at the call site.
13
+ */
14
+ export function plural(n, noun) {
15
+ return `${n} ${noun}${n === 1 ? "" : "s"}`;
16
+ }
17
+ //# sourceMappingURL=format.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"format.js","sourceRoot":"","sources":["../../src/commands/format.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH;;;;;GAKG;AACH,MAAM,UAAU,MAAM,CAAC,CAAS,EAAE,IAAY;IAC5C,OAAO,GAAG,CAAC,IAAI,IAAI,GAAG,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,GAAG,EAAE,CAAC;AAC7C,CAAC","sourcesContent":["/**\n * Small shaping helpers for the human renderers.\n *\n * They live in `commands/` and not in `core/`: nothing here computes an answer,\n * it only decides how an answer reads to a person. The `--json` payloads never\n * touch this file.\n */\n\n/**\n * \"1 service\", \"2 services\". The regular English plural, spelled once because\n * five renderers had drifted into five copies of the same ternary and a sixth\n * would have been written the next time somebody counted something. A noun with\n * an irregular plural does not belong here — say both forms at the call site.\n */\nexport function plural(n: number, noun: string): string {\n return `${n} ${noun}${n === 1 ? \"\" : \"s\"}`;\n}\n"]}