@ybotok/loam 0.2.0-alpha.5 → 0.2.0-alpha.7

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 (303) hide show
  1. package/CHANGELOG.md +1063 -0
  2. package/MIGRATING-from-OpenSpec.md +7 -4
  3. package/QUICKSTART.md +8 -3
  4. package/README.md +13 -7
  5. package/ROADMAP.md +285 -11
  6. package/SCHEMA.md +566 -45
  7. package/WORKFLOW.md +23 -10
  8. package/dist/commands/adopt/render.js +47 -8
  9. package/dist/commands/adopt/render.js.map +1 -1
  10. package/dist/commands/archive/plan/landscape.js +45 -1
  11. package/dist/commands/archive/plan/landscape.js.map +1 -1
  12. package/dist/commands/archive/plan/model/extending.js +85 -0
  13. package/dist/commands/archive/plan/model/extending.js.map +1 -0
  14. package/dist/commands/archive/run.js +53 -23
  15. package/dist/commands/archive/run.js.map +1 -1
  16. package/dist/commands/context/print.js +11 -1
  17. package/dist/commands/context/print.js.map +1 -1
  18. package/dist/commands/doctor.js +46 -0
  19. package/dist/commands/doctor.js.map +1 -1
  20. package/dist/commands/explore.js +12 -2
  21. package/dist/commands/explore.js.map +1 -1
  22. package/dist/commands/list/views.js +5 -1
  23. package/dist/commands/list/views.js.map +1 -1
  24. package/dist/commands/seed/commit.js +34 -1
  25. package/dist/commands/seed/commit.js.map +1 -1
  26. package/dist/commands/seed/plan.js +158 -13
  27. package/dist/commands/seed/plan.js.map +1 -1
  28. package/dist/commands/seed/seed.js +7 -2
  29. package/dist/commands/seed/seed.js.map +1 -1
  30. package/dist/commands/show/feature.js +2 -1
  31. package/dist/commands/show/feature.js.map +1 -1
  32. package/dist/commands/show/marks.js +3 -5
  33. package/dist/commands/show/marks.js.map +1 -1
  34. package/dist/commands/show/service.js +39 -18
  35. package/dist/commands/show/service.js.map +1 -1
  36. package/dist/commands/subsystem/edit.js +4 -2
  37. package/dist/commands/subsystem/edit.js.map +1 -1
  38. package/dist/commands/subsystem/move.js +55 -17
  39. package/dist/commands/subsystem/move.js.map +1 -1
  40. package/dist/commands/subsystem/sync.js +238 -87
  41. package/dist/commands/subsystem/sync.js.map +1 -1
  42. package/dist/commands/subsystem/txn/exclude.js +132 -0
  43. package/dist/commands/subsystem/txn/exclude.js.map +1 -0
  44. package/dist/commands/subsystem/txn/report.js +174 -0
  45. package/dist/commands/subsystem/txn/report.js.map +1 -0
  46. package/dist/commands/subsystem/txn/txn.js +126 -22
  47. package/dist/commands/subsystem/txn/txn.js.map +1 -1
  48. package/dist/commands/subsystem/txn/views.js +159 -19
  49. package/dist/commands/subsystem/txn/views.js.map +1 -1
  50. package/dist/commands/validate/arch-coverage.js +16 -9
  51. package/dist/commands/validate/arch-coverage.js.map +1 -1
  52. package/dist/commands/validate/checks/fleet-shape.js +30 -77
  53. package/dist/commands/validate/checks/fleet-shape.js.map +1 -1
  54. package/dist/commands/validate/checks/health-deps.js +9 -6
  55. package/dist/commands/validate/checks/health-deps.js.map +1 -1
  56. package/dist/commands/validate/checks/vocabulary.js +4 -19
  57. package/dist/commands/validate/checks/vocabulary.js.map +1 -1
  58. package/dist/commands/validate/feature.js +1 -1
  59. package/dist/commands/validate/feature.js.map +1 -1
  60. package/dist/commands/validate/fleet/census.js +37 -13
  61. package/dist/commands/validate/fleet/census.js.map +1 -1
  62. package/dist/commands/validate/fleet/landscape.js +131 -120
  63. package/dist/commands/validate/fleet/landscape.js.map +1 -1
  64. package/dist/commands/validate/fleet/load.js +66 -0
  65. package/dist/commands/validate/fleet/load.js.map +1 -1
  66. package/dist/commands/validate/fleet/map/attest.js +102 -0
  67. package/dist/commands/validate/fleet/map/attest.js.map +1 -0
  68. package/dist/commands/validate/fleet/map/bindings.js +109 -0
  69. package/dist/commands/validate/fleet/map/bindings.js.map +1 -0
  70. package/dist/commands/validate/fleet/map/consumers.js +219 -0
  71. package/dist/commands/validate/fleet/map/consumers.js.map +1 -0
  72. package/dist/commands/validate/fleet/map/isolation.js +116 -0
  73. package/dist/commands/validate/fleet/map/isolation.js.map +1 -0
  74. package/dist/commands/validate/fleet/map/usecases.js +56 -0
  75. package/dist/commands/validate/fleet/map/usecases.js.map +1 -0
  76. package/dist/commands/validate/fleet/scorecard/scorecard.js.map +1 -1
  77. package/dist/commands/validate/fleet/usecases/steps.js +49 -17
  78. package/dist/commands/validate/fleet/usecases/steps.js.map +1 -1
  79. package/dist/commands/validate/fleet/views/fleet-project.js +217 -0
  80. package/dist/commands/validate/fleet/views/fleet-project.js.map +1 -0
  81. package/dist/commands/validate/fleet/views/ids.js +45 -15
  82. package/dist/commands/validate/fleet/views/ids.js.map +1 -1
  83. package/dist/commands/validate/fleet/views/projects.js +258 -70
  84. package/dist/commands/validate/fleet/views/projects.js.map +1 -1
  85. package/dist/commands/validate/fleet/views/root-documents.js +99 -0
  86. package/dist/commands/validate/fleet/views/root-documents.js.map +1 -0
  87. package/dist/commands/validate/fleet/views/stale.js +32 -15
  88. package/dist/commands/validate/fleet/views/stale.js.map +1 -1
  89. package/dist/commands/validate/service/model/diverged.js +226 -0
  90. package/dist/commands/validate/service/model/diverged.js.map +1 -0
  91. package/dist/commands/validate/service/model/grade.js +163 -0
  92. package/dist/commands/validate/service/model/grade.js.map +1 -0
  93. package/dist/commands/validate/service/service.js +110 -34
  94. package/dist/commands/validate/service/service.js.map +1 -1
  95. package/dist/commands/validate/service/specs.js +39 -5
  96. package/dist/commands/validate/service/specs.js.map +1 -1
  97. package/dist/commands/validate/service/spine.js +41 -3
  98. package/dist/commands/validate/service/spine.js.map +1 -1
  99. package/dist/commands/validate/service/usecases/tags.js +113 -0
  100. package/dist/commands/validate/service/usecases/tags.js.map +1 -0
  101. package/dist/commands/validate/service/usecases/usecases.js +209 -0
  102. package/dist/commands/validate/service/usecases/usecases.js.map +1 -0
  103. package/dist/commands/validate/validate.js +10 -24
  104. package/dist/commands/validate/validate.js.map +1 -1
  105. package/dist/core/agent/agents-md/artifacts.js +1 -0
  106. package/dist/core/agent/agents-md/artifacts.js.map +1 -1
  107. package/dist/core/agent/agents-md/command-map.js +19 -5
  108. package/dist/core/agent/agents-md/command-map.js.map +1 -1
  109. package/dist/core/agent/agents-md/cycle.js +24 -9
  110. package/dist/core/agent/agents-md/cycle.js.map +1 -1
  111. package/dist/core/agent/agents-md/map/lenses/context.js +4 -3
  112. package/dist/core/agent/agents-md/map/lenses/context.js.map +1 -1
  113. package/dist/core/agent/agents-md/map/subsystem.js +70 -17
  114. package/dist/core/agent/agents-md/map/subsystem.js.map +1 -1
  115. package/dist/core/agent/agents-md/map/usecases.js +45 -3
  116. package/dist/core/agent/agents-md/map/usecases.js.map +1 -1
  117. package/dist/core/agent/agents-md/refusals.js +5 -3
  118. package/dist/core/agent/agents-md/refusals.js.map +1 -1
  119. package/dist/core/agent/agents-md.js +26 -7
  120. package/dist/core/agent/agents-md.js.map +1 -1
  121. package/dist/core/agent/support/report.js +39 -5
  122. package/dist/core/agent/support/report.js.map +1 -1
  123. package/dist/core/agent/workflows/adopt.js +123 -14
  124. package/dist/core/agent/workflows/adopt.js.map +1 -1
  125. package/dist/core/agent/workflows/check.js +34 -20
  126. package/dist/core/agent/workflows/check.js.map +1 -1
  127. package/dist/core/agent/workflows/closing.js +3 -1
  128. package/dist/core/agent/workflows/closing.js.map +1 -1
  129. package/dist/core/agent/workflows/reference/spine.js +12 -6
  130. package/dist/core/agent/workflows/reference/spine.js.map +1 -1
  131. package/dist/core/brief/brief.js +88 -19
  132. package/dist/core/brief/brief.js.map +1 -1
  133. package/dist/core/brief/checks.js +133 -7
  134. package/dist/core/brief/checks.js.map +1 -1
  135. package/dist/core/brief/landscape.js +212 -129
  136. package/dist/core/brief/landscape.js.map +1 -1
  137. package/dist/core/brief/map/extend.js +81 -0
  138. package/dist/core/brief/map/extend.js.map +1 -0
  139. package/dist/core/brief/map/owed.js +284 -0
  140. package/dist/core/brief/map/owed.js.map +1 -0
  141. package/dist/core/brief/targets.js +94 -29
  142. package/dist/core/brief/targets.js.map +1 -1
  143. package/dist/core/brief/unchecked.js +13 -3
  144. package/dist/core/brief/unchecked.js.map +1 -1
  145. package/dist/core/c4/likec4.js +23 -1
  146. package/dist/core/c4/likec4.js.map +1 -1
  147. package/dist/core/c4/parsed/deployment.js +5 -3
  148. package/dist/core/c4/parsed/deployment.js.map +1 -1
  149. package/dist/core/c4/parsed/view-ids.js +39 -1
  150. package/dist/core/c4/parsed/view-ids.js.map +1 -1
  151. package/dist/core/c4/prefetch/fleet.js +142 -0
  152. package/dist/core/c4/prefetch/fleet.js.map +1 -0
  153. package/dist/core/c4/project/architecture.js +77 -7
  154. package/dist/core/c4/project/architecture.js.map +1 -1
  155. package/dist/core/c4/project/batch.js +140 -0
  156. package/dist/core/c4/project/batch.js.map +1 -0
  157. package/dist/core/c4/project/documents.js +12 -6
  158. package/dist/core/c4/project/documents.js.map +1 -1
  159. package/dist/core/c4/project/load.js +85 -42
  160. package/dist/core/c4/project/load.js.map +1 -1
  161. package/dist/core/c4/project/staged.js +53 -15
  162. package/dist/core/c4/project/staged.js.map +1 -1
  163. package/dist/core/c4/resolve/attested.js +0 -0
  164. package/dist/core/c4/resolve/attested.js.map +1 -0
  165. package/dist/core/c4/resolve/service.js +8 -17
  166. package/dist/core/c4/resolve/service.js.map +1 -1
  167. package/dist/core/c4/resolve/steps.js +18 -0
  168. package/dist/core/c4/resolve/steps.js.map +1 -1
  169. package/dist/core/c4/root-project/exclude.js +352 -0
  170. package/dist/core/c4/root-project/exclude.js.map +1 -0
  171. package/dist/core/c4/service-model/documents.js +47 -0
  172. package/dist/core/c4/service-model/documents.js.map +1 -0
  173. package/dist/core/c4/service-model/fleet/extending.js +82 -0
  174. package/dist/core/c4/service-model/fleet/extending.js.map +1 -0
  175. package/dist/core/c4/service-model/load.js +165 -0
  176. package/dist/core/c4/service-model/load.js.map +1 -0
  177. package/dist/core/c4/service-model/renderer.js +128 -0
  178. package/dist/core/c4/service-model/renderer.js.map +1 -0
  179. package/dist/core/c4/service-model/shape.js +126 -0
  180. package/dist/core/c4/service-model/shape.js.map +1 -0
  181. package/dist/core/c4/service-model/slice.js +188 -0
  182. package/dist/core/c4/service-model/slice.js.map +1 -0
  183. package/dist/core/c4/source-scan.js +26 -0
  184. package/dist/core/c4/source-scan.js.map +1 -1
  185. package/dist/core/c4/splice/authored-source.js +60 -13
  186. package/dist/core/c4/splice/authored-source.js.map +1 -1
  187. package/dist/core/c4/splice/contract.js.map +1 -1
  188. package/dist/core/c4/splice/identity/declared.js +87 -0
  189. package/dist/core/c4/splice/identity/declared.js.map +1 -0
  190. package/dist/core/c4/splice/identity/edges.js +17 -0
  191. package/dist/core/c4/splice/identity/edges.js.map +1 -1
  192. package/dist/core/c4/splice/identity/existing.js +80 -0
  193. package/dist/core/c4/splice/identity/existing.js.map +1 -0
  194. package/dist/core/c4/splice/identity/interior.js +80 -0
  195. package/dist/core/c4/splice/identity/interior.js.map +1 -0
  196. package/dist/core/c4/splice/identity/owner.js +50 -0
  197. package/dist/core/c4/splice/identity/owner.js.map +1 -0
  198. package/dist/core/c4/splice/landscape-merge.js +154 -100
  199. package/dist/core/c4/splice/landscape-merge.js.map +1 -1
  200. package/dist/core/c4/splice/model/merge.js +140 -0
  201. package/dist/core/c4/splice/model/merge.js.map +1 -0
  202. package/dist/core/c4/splice/placement.js +14 -4
  203. package/dist/core/c4/splice/placement.js.map +1 -1
  204. package/dist/core/c4/workspace.js +2 -1
  205. package/dist/core/c4/workspace.js.map +1 -1
  206. package/dist/core/coherence/coherence.js +7 -0
  207. package/dist/core/coherence/coherence.js.map +1 -1
  208. package/dist/core/delta/delta.js +9 -0
  209. package/dist/core/delta/delta.js.map +1 -1
  210. package/dist/core/deployment/delta.js +16 -10
  211. package/dist/core/deployment/delta.js.map +1 -1
  212. package/dist/core/diff/base-state.js +21 -5
  213. package/dist/core/diff/base-state.js.map +1 -1
  214. package/dist/core/diff/victims.js +10 -1
  215. package/dist/core/diff/victims.js.map +1 -1
  216. package/dist/core/docs.js +29 -24
  217. package/dist/core/docs.js.map +1 -1
  218. package/dist/core/doctor/config.js +7 -3
  219. package/dist/core/doctor/config.js.map +1 -1
  220. package/dist/core/doctor/doctor.js +36 -8
  221. package/dist/core/doctor/doctor.js.map +1 -1
  222. package/dist/core/doctor/report.js.map +1 -1
  223. package/dist/core/doctor/reports/scan.js +242 -0
  224. package/dist/core/doctor/reports/scan.js.map +1 -0
  225. package/dist/core/explain/refusals.js +1 -1
  226. package/dist/core/explain/refusals.js.map +1 -1
  227. package/dist/core/explore/explore.js +44 -9
  228. package/dist/core/explore/explore.js.map +1 -1
  229. package/dist/core/fleet-context.js +47 -0
  230. package/dist/core/fleet-context.js.map +1 -1
  231. package/dist/core/gate/checks.js +5 -1
  232. package/dist/core/gate/checks.js.map +1 -1
  233. package/dist/core/gate/partners.js +65 -26
  234. package/dist/core/gate/partners.js.map +1 -1
  235. package/dist/core/kernel/ids/fqn/ancestors.js +65 -0
  236. package/dist/core/kernel/ids/fqn/ancestors.js.map +1 -0
  237. package/dist/core/pack/living.js +47 -13
  238. package/dist/core/pack/living.js.map +1 -1
  239. package/dist/core/pack/pack.js +9 -6
  240. package/dist/core/pack/pack.js.map +1 -1
  241. package/dist/core/projection/arch-slice.js +2 -2
  242. package/dist/core/projection/arch-slice.js.map +1 -1
  243. package/dist/core/repo/paths.js +45 -38
  244. package/dist/core/repo/paths.js.map +1 -1
  245. package/dist/core/repo/tree/render/body.js +45 -6
  246. package/dist/core/repo/tree/render/body.js.map +1 -1
  247. package/dist/core/repo/tree/render/projects.js +93 -35
  248. package/dist/core/repo/tree/render/projects.js.map +1 -1
  249. package/dist/core/repo/tree/render/service/header.js +125 -0
  250. package/dist/core/repo/tree/render/service/header.js.map +1 -0
  251. package/dist/core/repo/tree/render/service/peers.js +28 -0
  252. package/dist/core/repo/tree/render/service/peers.js.map +1 -0
  253. package/dist/core/repo/tree/render/service/view-id.js +67 -0
  254. package/dist/core/repo/tree/render/service/view-id.js.map +1 -0
  255. package/dist/core/repo/tree/render/service/views.js +78 -0
  256. package/dist/core/repo/tree/render/service/views.js.map +1 -0
  257. package/dist/core/repo/tree/render/stale.js +20 -4
  258. package/dist/core/repo/tree/render/stale.js.map +1 -1
  259. package/dist/core/repo/tree/render/views.js +149 -32
  260. package/dist/core/repo/tree/render/views.js.map +1 -1
  261. package/dist/core/repo/tree/walk.js +45 -2
  262. package/dist/core/repo/tree/walk.js.map +1 -1
  263. package/dist/core/scaffold/landscape.js +219 -9
  264. package/dist/core/scaffold/landscape.js.map +1 -1
  265. package/dist/core/staging/writes.js.map +1 -1
  266. package/dist/core/usecases/delta/claims.js +1 -0
  267. package/dist/core/usecases/delta/claims.js.map +1 -1
  268. package/dist/core/usecases/delta/overlay.js +4 -0
  269. package/dist/core/usecases/delta/overlay.js.map +1 -1
  270. package/dist/core/usecases/fleet.js +18 -6
  271. package/dist/core/usecases/fleet.js.map +1 -1
  272. package/dist/core/usecases/operations.js +7 -0
  273. package/dist/core/usecases/operations.js.map +1 -1
  274. package/dist/core/usecases/service/exclude.js +119 -0
  275. package/dist/core/usecases/service/exclude.js.map +1 -0
  276. package/dist/core/usecases/service/flows.js +161 -0
  277. package/dist/core/usecases/service/flows.js.map +1 -0
  278. package/dist/core/verify/checklist.js +42 -10
  279. package/dist/core/verify/checklist.js.map +1 -1
  280. package/dist/core/vocabulary/issue.js.map +1 -1
  281. package/dist/core/vocabulary/maturity.js +51 -1
  282. package/dist/core/vocabulary/maturity.js.map +1 -1
  283. package/examples/README.md +30 -8
  284. package/examples/docs/architecture/landscape.likec4 +47 -0
  285. package/examples/docs/architecture/services.likec4 +70 -0
  286. package/examples/docs/architecture/subsystems.likec4 +3 -0
  287. package/examples/docs/architecture/usecases/order-notification.likec4 +5 -2
  288. package/examples/docs/likec4.config.json +0 -1
  289. package/examples/docs/services/checkout-web/model.likec4 +39 -56
  290. package/examples/docs/services/order-service/arch.spec.md +13 -6
  291. package/examples/docs/services/order-service/model.likec4 +58 -70
  292. package/examples/docs/services/payment-service/arch.spec.md +7 -4
  293. package/examples/docs/services/payment-service/model.likec4 +38 -39
  294. package/examples/docs/services/platform/identity-service/arch.spec.md +7 -4
  295. package/examples/docs/services/platform/identity-service/model.likec4 +35 -37
  296. package/examples/docs/services/platform/notification-service/health.yaml +8 -3
  297. package/examples/docs/services/platform/notification-service/model.likec4 +43 -64
  298. package/package.json +1 -1
  299. package/examples/docs/services/checkout-web/likec4.config.json +0 -4
  300. package/examples/docs/services/order-service/likec4.config.json +0 -4
  301. package/examples/docs/services/payment-service/likec4.config.json +0 -4
  302. package/examples/docs/services/platform/identity-service/likec4.config.json +0 -4
  303. package/examples/docs/services/platform/notification-service/likec4.config.json +0 -4
@@ -0,0 +1,132 @@
1
+ /**
2
+ * The ROOT `likec4.config.json`'s `exclude` list, as `loam subsystem sync`
3
+ * derives it from the shapes on disk.
4
+ *
5
+ * It sits beside `./views.ts` for that module's reason and by the same seam:
6
+ * both answer "what should this renderer-only file say, given the tree?", both
7
+ * are pure derivations one command writes, and keeping them together is what
8
+ * stops the writer and the grader spelling one rule twice. The difference is
9
+ * ownership, and it is stated in `core/c4/service-model/renderer.ts`: every byte
10
+ * of the generated views file is loam's, while this file is the team's and loam
11
+ * maintains exactly the `services/` entries in it. What an entry COVERS is
12
+ * `core/c4/root-project/exclude.ts`'s question, asked of the directory the entry
13
+ * names rather than of its spelling.
14
+ *
15
+ * It is a module rather than three functions in `../sync.ts` because that file
16
+ * reached its line limit carrying them, and this is the phase boundary inside
17
+ * it: `sync.ts` is the lock, the transaction and the report; this is one of the
18
+ * three things it decides to write.
19
+ */
20
+ import { readFile } from "node:fs/promises";
21
+ import { excludingEntry, parseRootProject, readRootExclude } from "../../../core/c4/root-project/exclude.js";
22
+ import { standaloneExclude } from "../../../core/c4/service-model/renderer.js";
23
+ import { serviceTreePath } from "../../../core/kernel/ids/dirs.js";
24
+ import { rootProjectPath } from "../../../core/repo/paths.js";
25
+ import { planWrite } from "../../../core/staging/writes.js";
26
+ /** The answer for a run that has no root file to read, and the shape every other arm fills. */
27
+ export const NO_EXCLUDE = {
28
+ updated: false,
29
+ entries: [],
30
+ added: [],
31
+ removed: [],
32
+ write: null,
33
+ unreadable: false,
34
+ };
35
+ /**
36
+ * The root project's `exclude` list, recomputed from the shapes on disk.
37
+ *
38
+ * WHAT MAKES THIS SAFE TO RUN ON EVERY SYNC is the split `standaloneExclude`
39
+ * enforces: the `services/` half of the list is loam's and every other entry is
40
+ * the team's, kept in order. The scaffold's node_modules glob, `features/**`, a
41
+ * team's own `drafts/**` all survive; only the entries that decide whether a
42
+ * model is renderable are derived. A standalone model MUST be excluded (inside
43
+ * the root project every kind it declares is a duplicate blamed on the map as
44
+ * well) and an extending model must NOT be (the root project is the only place
45
+ * it parses), so a service that migrates from one shape to the other has its
46
+ * entry added or dropped on the next sync instead of quietly blanking a project.
47
+ *
48
+ * THE GATE keeps an old repository untouched. A fleet whose models all stand
49
+ * alone and whose root already says `services/**` is already correct, and
50
+ * rewriting it into one entry per service would be a diff with no fact behind
51
+ * it — so the rewrite happens only when at least one model extends the map (the
52
+ * blanket exclusion is then wrong) or some standalone model is not covered at
53
+ * all (the renderer is merging it into the map right now). Comparing the parsed
54
+ * ARRAYS rather than the bytes is the second half of the same rule: a file whose
55
+ * `exclude` already says what this run would write is left exactly as the team
56
+ * formatted it.
57
+ *
58
+ * A root config loam cannot read an `exclude` list out of is REPORTED and never
59
+ * rewritten. Guessing at the JSON a team hand-wrote is how a writer destroys a
60
+ * file it did not understand.
61
+ */
62
+ export async function planExclude(docsDir, services, models) {
63
+ const current = await readRootExclude(docsDir);
64
+ if (current === null)
65
+ return { ...NO_EXCLUDE, unreadable: true };
66
+ const standalone = models.filter((m) => m.standalone).map((m) => serviceTreePath(m.service));
67
+ // Both halves of the shape scan travel, not just the standalone one: an entry
68
+ // is loam's to take back either because loam wrote it (a tree the enumeration
69
+ // returns) or because it HIDES a model that only parses in the root project,
70
+ // and only the extending list can answer the second question. Without it a
71
+ // subsystem-wide `services/platform/**` survived every sync while
72
+ // `service.model-excluded` named that same sync as the repair.
73
+ const extending = models.filter((m) => !m.standalone).map((m) => serviceTreePath(m.service));
74
+ const wanted = standaloneExclude(current, {
75
+ standalone,
76
+ extending,
77
+ enumerated: services.map((svc) => serviceTreePath(svc)),
78
+ });
79
+ const uncovered = standalone.some((tree) => excludingEntry(current, tree) === null);
80
+ if (extending.length === 0 && !uncovered)
81
+ return { ...NO_EXCLUDE, entries: [...current] };
82
+ if (current.length === wanted.length && current.every((entry, i) => entry === wanted[i])) {
83
+ return { ...NO_EXCLUDE, entries: wanted };
84
+ }
85
+ const bytes = await rewrittenRootProject(docsDir, wanted);
86
+ if (bytes === null)
87
+ return { ...NO_EXCLUDE, entries: [...current], unreadable: true };
88
+ return {
89
+ updated: true,
90
+ entries: wanted,
91
+ added: wanted.filter((entry) => !current.includes(entry)),
92
+ removed: current.filter((entry) => !wanted.includes(entry)),
93
+ // `planWrite` decides create-vs-overwrite from the filesystem, so the root
94
+ // file — which by definition already exists here — is staged as an
95
+ // OVERWRITE, and `swapStaged` compares it against its pre-image immediately
96
+ // before the rename: a team member editing the same file during the sync
97
+ // stops the commit instead of having their bytes buried.
98
+ write: planWrite(rootProjectPath(docsDir), bytes),
99
+ unreadable: false,
100
+ };
101
+ }
102
+ /**
103
+ * The root project file with its `exclude` replaced and NOTHING else touched —
104
+ * every other key kept, in the order the team wrote them, re-serialised as
105
+ * 2-space JSON with a trailing newline (`core/docs.ts`'s own form, so a
106
+ * scaffolded file round-trips byte for byte).
107
+ *
108
+ * Null when the file stopped being a JSON object between `readRootExclude` and
109
+ * here, which is a concurrent edit rather than a state loam may write over.
110
+ */
111
+ async function rewrittenRootProject(docsDir, exclude) {
112
+ let text;
113
+ try {
114
+ text = await readFile(rootProjectPath(docsDir), "utf8");
115
+ }
116
+ catch {
117
+ // Unreadable is not "excludes nothing": the caller reports it and writes
118
+ // nothing at all.
119
+ return null;
120
+ }
121
+ // The SAME parse the grader read the list with — `parseRootProject`, which
122
+ // tolerates the byte-order mark PowerShell's `Out-File` writes. Two parses
123
+ // would mean a run that read an `exclude` here and then declared the file
124
+ // unreadable at the write, which is exactly what a BOM produced before
125
+ // (re-verification 2026-09-04, area C item 3). Re-read rather than carried:
126
+ // the point of this read is that it happens immediately before the swap.
127
+ const parsed = parseRootProject(text);
128
+ if (parsed === null)
129
+ return null;
130
+ return `${JSON.stringify({ ...parsed, exclude: [...exclude] }, null, 2)}\n`;
131
+ }
132
+ //# sourceMappingURL=exclude.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"exclude.js","sourceRoot":"","sources":["../../../../src/commands/subsystem/txn/exclude.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * The ROOT `likec4.config.json`'s `exclude` list, as `loam subsystem sync`\n * derives it from the shapes on disk.\n *\n * It sits beside `./views.ts` for that module's reason and by the same seam:\n * both answer \"what should this renderer-only file say, given the tree?\", both\n * are pure derivations one command writes, and keeping them together is what\n * stops the writer and the grader spelling one rule twice. The difference is\n * ownership, and it is stated in `core/c4/service-model/renderer.ts`: every byte\n * of the generated views file is loam's, while this file is the team's and loam\n * maintains exactly the `services/` entries in it. What an entry COVERS is\n * `core/c4/root-project/exclude.ts`'s question, asked of the directory the entry\n * names rather than of its spelling.\n *\n * It is a module rather than three functions in `../sync.ts` because that file\n * reached its line limit carrying them, and this is the phase boundary inside\n * it: `sync.ts` is the lock, the transaction and the report; this is one of the\n * three things it decides to write.\n */\nimport { readFile } from \"node:fs/promises\";\nimport { excludingEntry, parseRootProject, readRootExclude } from \"../../../core/c4/root-project/exclude.js\";\nimport { standaloneExclude } from \"../../../core/c4/service-model/renderer.js\";\nimport { serviceTreePath, type DocsDir } from \"../../../core/kernel/ids/dirs.js\";\nimport { rootProjectPath } from \"../../../core/repo/paths.js\";\nimport type { SurveyedModel } from \"../../../core/repo/tree/render/projects.js\";\nimport type { WalkedService } from \"../../../core/repo/tree/walk.js\";\nimport { planWrite, type PlannedWrite } from \"../../../core/staging/writes.js\";\n\n/** What one sync did to the ROOT project's `exclude` list. */\nexport interface ExcludePlan {\n /** Whether this run rewrites the list. */\n updated: boolean;\n /** The list as it stands after this run — the current one when nothing changed. */\n entries: string[];\n added: string[];\n removed: string[];\n /** The rewrite, staged with everything else. Null when nothing is owed. */\n write: PlannedWrite | null;\n /** The root file exists and loam could not read an `exclude` list out of it — reported, never rewritten. */\n unreadable: boolean;\n}\n\n/** The answer for a run that has no root file to read, and the shape every other arm fills. */\nexport const NO_EXCLUDE: ExcludePlan = {\n updated: false,\n entries: [],\n added: [],\n removed: [],\n write: null,\n unreadable: false,\n};\n\n/**\n * The root project's `exclude` list, recomputed from the shapes on disk.\n *\n * WHAT MAKES THIS SAFE TO RUN ON EVERY SYNC is the split `standaloneExclude`\n * enforces: the `services/` half of the list is loam's and every other entry is\n * the team's, kept in order. The scaffold's node_modules glob, `features/**`, a\n * team's own `drafts/**` all survive; only the entries that decide whether a\n * model is renderable are derived. A standalone model MUST be excluded (inside\n * the root project every kind it declares is a duplicate blamed on the map as\n * well) and an extending model must NOT be (the root project is the only place\n * it parses), so a service that migrates from one shape to the other has its\n * entry added or dropped on the next sync instead of quietly blanking a project.\n *\n * THE GATE keeps an old repository untouched. A fleet whose models all stand\n * alone and whose root already says `services/**` is already correct, and\n * rewriting it into one entry per service would be a diff with no fact behind\n * it — so the rewrite happens only when at least one model extends the map (the\n * blanket exclusion is then wrong) or some standalone model is not covered at\n * all (the renderer is merging it into the map right now). Comparing the parsed\n * ARRAYS rather than the bytes is the second half of the same rule: a file whose\n * `exclude` already says what this run would write is left exactly as the team\n * formatted it.\n *\n * A root config loam cannot read an `exclude` list out of is REPORTED and never\n * rewritten. Guessing at the JSON a team hand-wrote is how a writer destroys a\n * file it did not understand.\n */\nexport async function planExclude(\n docsDir: DocsDir,\n services: readonly WalkedService[],\n models: readonly SurveyedModel<WalkedService>[],\n): Promise<ExcludePlan> {\n const current = await readRootExclude(docsDir);\n if (current === null) return { ...NO_EXCLUDE, unreadable: true };\n const standalone = models.filter((m) => m.standalone).map((m) => serviceTreePath(m.service));\n // Both halves of the shape scan travel, not just the standalone one: an entry\n // is loam's to take back either because loam wrote it (a tree the enumeration\n // returns) or because it HIDES a model that only parses in the root project,\n // and only the extending list can answer the second question. Without it a\n // subsystem-wide `services/platform/**` survived every sync while\n // `service.model-excluded` named that same sync as the repair.\n const extending = models.filter((m) => !m.standalone).map((m) => serviceTreePath(m.service));\n const wanted = standaloneExclude(current, {\n standalone,\n extending,\n enumerated: services.map((svc) => serviceTreePath(svc)),\n });\n const uncovered = standalone.some((tree) => excludingEntry(current, tree) === null);\n if (extending.length === 0 && !uncovered) return { ...NO_EXCLUDE, entries: [...current] };\n if (current.length === wanted.length && current.every((entry, i) => entry === wanted[i])) {\n return { ...NO_EXCLUDE, entries: wanted };\n }\n const bytes = await rewrittenRootProject(docsDir, wanted);\n if (bytes === null) return { ...NO_EXCLUDE, entries: [...current], unreadable: true };\n return {\n updated: true,\n entries: wanted,\n added: wanted.filter((entry) => !current.includes(entry)),\n removed: current.filter((entry) => !wanted.includes(entry)),\n // `planWrite` decides create-vs-overwrite from the filesystem, so the root\n // file — which by definition already exists here — is staged as an\n // OVERWRITE, and `swapStaged` compares it against its pre-image immediately\n // before the rename: a team member editing the same file during the sync\n // stops the commit instead of having their bytes buried.\n write: planWrite(rootProjectPath(docsDir), bytes),\n unreadable: false,\n };\n}\n\n/**\n * The root project file with its `exclude` replaced and NOTHING else touched —\n * every other key kept, in the order the team wrote them, re-serialised as\n * 2-space JSON with a trailing newline (`core/docs.ts`'s own form, so a\n * scaffolded file round-trips byte for byte).\n *\n * Null when the file stopped being a JSON object between `readRootExclude` and\n * here, which is a concurrent edit rather than a state loam may write over.\n */\nasync function rewrittenRootProject(docsDir: DocsDir, exclude: readonly string[]): Promise<string | null> {\n let text: string;\n try {\n text = await readFile(rootProjectPath(docsDir), \"utf8\");\n } catch {\n // Unreadable is not \"excludes nothing\": the caller reports it and writes\n // nothing at all.\n return null;\n }\n // The SAME parse the grader read the list with — `parseRootProject`, which\n // tolerates the byte-order mark PowerShell's `Out-File` writes. Two parses\n // would mean a run that read an `exclude` here and then declared the file\n // unreadable at the write, which is exactly what a BOM produced before\n // (re-verification 2026-09-04, area C item 3). Re-read rather than carried:\n // the point of this read is that it happens immediately before the swap.\n const parsed = parseRootProject(text);\n if (parsed === null) return null;\n return `${JSON.stringify({ ...parsed, exclude: [...exclude] }, null, 2)}\\n`;\n}\n"]}
@@ -0,0 +1,174 @@
1
+ /**
2
+ * What one `loam subsystem sync` SAYS it did — the `--json` envelope and the
3
+ * human lines, in one place.
4
+ *
5
+ * It sits beside `./views.ts` and `./exclude.ts` because it is the third answer
6
+ * about the same three files, and it moved out of `../sync.ts` for that file's
7
+ * standing reason: `sync.ts` is the lock, the transaction and the plan, and it
8
+ * reached the 400-line limit carrying the output too. Composing a report is not
9
+ * a phase of the commit, so nothing here reads the disk or decides anything — a
10
+ * caller hands over what happened and this spells it.
11
+ */
12
+ import { emitJson } from "../../../core/envelope/json.js";
13
+ import { authoredRefusal } from "../../../core/repo/tree/render/service/header.js";
14
+ import { LANDSCAPE_UNREADABLE_NOTE } from "./views.js";
15
+ /** The second generated file's name, spelled once here so the key and the sentence cannot drift. */
16
+ const SERVICE_VIEWS = "architecture/services.likec4";
17
+ export function reportSync(json, out) {
18
+ // One reason today, and it is exactly the `action` it explains — spelled as a
19
+ // record so a second cause is an added value rather than a re-read of the
20
+ // sentence a person sees.
21
+ const blocked = out.action === "blocked" ? { reason: "landscape-invalid" } : null;
22
+ if (json) {
23
+ emitJson({
24
+ command: "subsystem",
25
+ path: "architecture/subsystems.likec4",
26
+ action: out.action,
27
+ subsystems: out.subsystems,
28
+ // ADDITIVE, and emitted on every run including the untouched one: a
29
+ // consumer must be able to read what happened to the second generated file
30
+ // without branching on whether this run happened to change it. `path` and
31
+ // `action` keep their frozen meaning — the subsystem file — so nothing
32
+ // reading them sees a new one.
33
+ serviceViews: { path: SERVICE_VIEWS, action: out.serviceViews },
34
+ ...(blocked === null ? {} : { blocked }),
35
+ ...(out.recovered === null ? {} : { recovered: out.recovered }),
36
+ projects: {
37
+ root: out.projects.root,
38
+ created: out.projects.created,
39
+ removed: out.projects.removed,
40
+ current: out.projects.current,
41
+ // Additive, and emitted in every mode including the untouched one: a
42
+ // consumer must be able to read what the root project now excludes
43
+ // without branching on whether this run happened to change it.
44
+ //
45
+ // `unreadable` travels with it because `entries: []` alone collapses the
46
+ // two answers `readRootExclude` insists must stay apart — "the file
47
+ // excludes nothing" and "loam could not read the file" are opposite
48
+ // facts about what the renderer will load, and the text view had the
49
+ // distinction while `--json` did not (verification 2026-09-04, review C).
50
+ exclude: {
51
+ updated: out.projects.exclude.updated,
52
+ entries: out.projects.exclude.entries,
53
+ added: out.projects.exclude.added,
54
+ removed: out.projects.exclude.removed,
55
+ unreadable: out.projects.exclude.unreadable,
56
+ },
57
+ },
58
+ });
59
+ return;
60
+ }
61
+ if (out.recovered !== null && out.recovered.outcome !== "consistent") {
62
+ console.log(`note: recovered an interrupted \`loam ${out.recovered.command}\` commit first (${out.recovered.outcome}).`);
63
+ }
64
+ console.log(views(out));
65
+ serviceViews(out.serviceViews);
66
+ // Owed on BOTH arms: the run that declined to rewrite an existing file, and
67
+ // the run that wrote the tolerant shape because there was none to protect.
68
+ if (out.mapUnreadable)
69
+ console.log(LANDSCAPE_UNREADABLE_NOTE);
70
+ projects(out.projects);
71
+ exclude(out.projects);
72
+ }
73
+ /** The one line about the generated views file. */
74
+ function views(out) {
75
+ // "nothing to write" is a claim about the WHOLE run, and it is false the moment
76
+ // any of this verb's FOUR writers landed something beside a current subsystem
77
+ // views file: the per-service views file, a project file created, one removed,
78
+ // or the root `exclude` rewritten. The exclude arm was missed when it was
79
+ // added, so the tail contradicted the very next line of output (verification
80
+ // 2026-09-04, review C) — and the per-service file is the same trap one file
81
+ // over, which is why it is in the same expression rather than a second one.
82
+ const quiet = out.serviceViews === "current" &&
83
+ out.projects.created.length === 0 &&
84
+ out.projects.removed.length === 0 &&
85
+ !out.projects.exclude.updated;
86
+ const sentence = {
87
+ current: `architecture/subsystems.likec4 is current (${out.subsystems} subsystem(s))${quiet ? " — nothing to write." : "."}`,
88
+ created: `wrote architecture/subsystems.likec4 — ${out.subsystems} subsystem view(s).`,
89
+ updated: `updated architecture/subsystems.likec4 — ${out.subsystems} subsystem view(s).`,
90
+ removed: `removed architecture/subsystems.likec4 — the tree has no subsystems, so the generated file must be absent.`,
91
+ blocked: "architecture/subsystems.likec4 left as it is — architecture/ does not parse as one project, so the " +
92
+ "render's element join is the landscape file's alone (a use-case document that binds an element of its " +
93
+ "own is not in it), and rewriting the file would take its `include` lines with it.",
94
+ refused: authoredRefusal("architecture/subsystems.likec4"),
95
+ };
96
+ return sentence[out.action];
97
+ }
98
+ /**
99
+ * The one line about `architecture/services.likec4`, printed on every answer but
100
+ * `current` — so on the two that left the file alone (`blocked`, `refused`) as
101
+ * well as the three that changed it. "Nothing happened to it, and here is why"
102
+ * is exactly the sentence a person needs; only "nothing happened to it because
103
+ * nothing needed to" is silence worth keeping.
104
+ *
105
+ * SILENT WHEN CURRENT, and that is the difference from the subsystem file's line
106
+ * above: that one is the sentence this command has always ended on and a person
107
+ * reads it as "the run happened", while a second unconditional line about a file
108
+ * nothing touched is noise on every run in a fleet that is already in order. The
109
+ * `--json` key is unconditional for the opposite reason — a consumer branches on
110
+ * a value, not on a line's presence.
111
+ *
112
+ * No view COUNT here, deliberately. The subsystem line counts subsystems because
113
+ * the tree answers that without reading anything; the per-service file draws
114
+ * only the services the MAP resolves, and the honest count is the render's, not
115
+ * the tree's. loam never reads a generated file's content (docs/DESIGN.md rule
116
+ * 26), so counting the views in it is the one place the count could come from
117
+ * and the one place it may not.
118
+ */
119
+ function serviceViews(action) {
120
+ if (action === "current")
121
+ return;
122
+ const sentence = {
123
+ created: `wrote ${SERVICE_VIEWS} — one overview view per service the fleet map draws.`,
124
+ updated: `updated ${SERVICE_VIEWS} — one overview view per service the fleet map draws.`,
125
+ removed: `removed ${SERVICE_VIEWS} — the fleet map draws none of the services under services/, so the generated file must be absent.`,
126
+ blocked: `${SERVICE_VIEWS} left as it is — architecture/ does not parse as one project, so no service resolves to a ` +
127
+ "map element and the render would have emptied the file rather than improved it.",
128
+ refused: authoredRefusal(SERVICE_VIEWS),
129
+ };
130
+ console.log(sentence[action]);
131
+ }
132
+ /** The per-service project files this run created and removed. */
133
+ function projects(out) {
134
+ if (out.created.length > 0) {
135
+ console.log(`wrote ${out.created.length} services/<…>/likec4.config.json — one LikeC4 project per service model whose ` +
136
+ "model stands alone (an existing file is yours and is never rewritten; commit the new ones before a " +
137
+ "`loam subsystem move`, which refuses over untracked paths). With more than one project, " +
138
+ "likec4 validate at the docs root needs --project <name>; build and export take every project.");
139
+ }
140
+ // Named one per line: this is a DELETE of a file a team may have written, and
141
+ // a count would leave a reader nothing to check against their own diff.
142
+ for (const path of out.removed)
143
+ console.log(`removed ${path}`);
144
+ if (out.removed.length > 0) {
145
+ console.log("— that model extends the fleet map, and a project file beside it registers a second renderer project " +
146
+ "rooted at the service directory: `likec4 validate .` then refuses without `--project`, and wherever " +
147
+ "that project claims the model it loads it alone, where the map's kinds are not, and the fleet project " +
148
+ "loses the service's interior. This is what `service.likec4-config-stray` names.");
149
+ }
150
+ if (!out.root && out.modelled > 0) {
151
+ console.log("note: no likec4.config.json at the docs root, so no service project files were written — " +
152
+ "`loam doctor` prints the root file.");
153
+ }
154
+ }
155
+ /** The root project's `exclude`, and the one state loam refuses to touch. */
156
+ function exclude(out) {
157
+ const plan = out.exclude;
158
+ if (plan.updated) {
159
+ // Named entries rather than a count: this line is the whole record of a
160
+ // rewrite to a file the team owns, and "updated the exclude list" gives a
161
+ // reader nothing to check against their own diff.
162
+ const parts = [
163
+ ...(plan.added.length > 0 ? [`+${plan.added.join(" +")}`] : []),
164
+ ...(plan.removed.length > 0 ? [`-${plan.removed.join(" -")}`] : []),
165
+ ];
166
+ console.log(`rewrote likec4.config.json's \`exclude\` (${parts.join(" ")}) — the root project excludes exactly the ` +
167
+ "models that stand alone; a model that extends the fleet map renders from the docs root and must not be excluded.");
168
+ }
169
+ if (plan.unreadable) {
170
+ console.log("note: likec4.config.json at the docs root is not a JSON object with a string `exclude` list, so it was " +
171
+ "left exactly as it is — loam does not rewrite a project file it cannot read.");
172
+ }
173
+ }
174
+ //# sourceMappingURL=report.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"report.js","sourceRoot":"","sources":["../../../../src/commands/subsystem/txn/report.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * What one `loam subsystem sync` SAYS it did — the `--json` envelope and the\n * human lines, in one place.\n *\n * It sits beside `./views.ts` and `./exclude.ts` because it is the third answer\n * about the same three files, and it moved out of `../sync.ts` for that file's\n * standing reason: `sync.ts` is the lock, the transaction and the plan, and it\n * reached the 400-line limit carrying the output too. Composing a report is not\n * a phase of the commit, so nothing here reads the disk or decides anything — a\n * caller hands over what happened and this spells it.\n */\nimport { emitJson } from \"../../../core/envelope/json.js\";\nimport { authoredRefusal } from \"../../../core/repo/tree/render/service/header.js\";\nimport type { CommitRecovery } from \"../../../core/staging/interrupted.js\";\nimport type { ExcludePlan } from \"./exclude.js\";\nimport { LANDSCAPE_UNREADABLE_NOTE } from \"./views.js\";\n\n/**\n * What one sync did to the views file — and ONLY that file; the projects report\n * separately.\n *\n * `blocked` is the fifth answer: the map cannot be read, a generated file\n * already exists, and this run left it exactly as it is. The tolerant render\n * used to run anyway and rewrote a good file down to title and description\n * (verification 2026-09-04, W5). The project half is unaffected and still runs —\n * neither the per-service files nor the root `exclude` depend on the map.\n *\n * `refused` is the sixth, and it is about OWNERSHIP rather than about the map:\n * the file standing at a path loam generates does not open with loam's mark, so\n * it is somebody's authored document and this command will not take it. It is\n * the only action that also sets a non-zero exit code (`../sync.ts` says why),\n * because unlike `blocked` it is not a state any later run clears on its own.\n */\nexport type SyncAction = \"current\" | \"created\" | \"updated\" | \"removed\" | \"blocked\" | \"refused\";\n\n/** The second generated file's name, spelled once here so the key and the sentence cannot drift. */\nconst SERVICE_VIEWS = \"architecture/services.likec4\";\n\n/** Why a run answered `blocked`. One reason today; a key rather than a bare string so a second one is additive. */\ninterface SyncBlocked {\n reason: \"landscape-invalid\";\n}\n\n/** The additive `projects` payload key, and what the text lines are composed from. */\nexport interface ProjectsReport {\n root: boolean;\n /** POSIX paths relative to the DOCS ROOT, written this run, sorted by path. */\n created: string[];\n /**\n * POSIX paths relative to the DOCS ROOT, DELETED this run, sorted by path — a\n * `likec4.config.json` beside a model that extends the map. `[]` rather than\n * absent on a run that removed none, so a consumer reads the key.\n *\n * SORTED BY PATH rather than by service id, which is the order the survey\n * hands them over in: `../sync.ts` sorts both arrays where it spells them, so\n * the `--json` list and the `removed <path>` lines below can never disagree.\n */\n removed: string[];\n current: number;\n modelled: number;\n exclude: ExcludePlan;\n}\n\n/** Everything one run has to say, as the caller knows it. */\nexport interface SyncReport {\n /** `architecture/subsystems.likec4` — and nothing else; the `--json` `action` key is exactly this. */\n action: SyncAction;\n /**\n * `architecture/services.likec4` — the per-service overviews.\n *\n * A field of its own rather than a folded `action`, for the reason the shared\n * finding code carries the path: the two files drift for different reasons and\n * a reader told only that \"the generated views changed\" would have to diff the\n * repo to find out which one. `blocked` reaches it exactly as it reaches the\n * other, and for a sharper reason — with no readable map no service resolves,\n * so the tolerant render is the empty file rather than a poorer one.\n */\n serviceViews: SyncAction;\n subsystems: number;\n recovered: CommitRecovery | null;\n projects: ProjectsReport;\n /** The map could not be read at all — the note is owed whether or not a file was protected. */\n mapUnreadable: boolean;\n}\n\nexport function reportSync(json: boolean, out: SyncReport): void {\n // One reason today, and it is exactly the `action` it explains — spelled as a\n // record so a second cause is an added value rather than a re-read of the\n // sentence a person sees.\n const blocked: SyncBlocked | null = out.action === \"blocked\" ? { reason: \"landscape-invalid\" } : null;\n if (json) {\n emitJson({\n command: \"subsystem\",\n path: \"architecture/subsystems.likec4\",\n action: out.action,\n subsystems: out.subsystems,\n // ADDITIVE, and emitted on every run including the untouched one: a\n // consumer must be able to read what happened to the second generated file\n // without branching on whether this run happened to change it. `path` and\n // `action` keep their frozen meaning — the subsystem file — so nothing\n // reading them sees a new one.\n serviceViews: { path: SERVICE_VIEWS, action: out.serviceViews },\n ...(blocked === null ? {} : { blocked }),\n ...(out.recovered === null ? {} : { recovered: out.recovered }),\n projects: {\n root: out.projects.root,\n created: out.projects.created,\n removed: out.projects.removed,\n current: out.projects.current,\n // Additive, and emitted in every mode including the untouched one: a\n // consumer must be able to read what the root project now excludes\n // without branching on whether this run happened to change it.\n //\n // `unreadable` travels with it because `entries: []` alone collapses the\n // two answers `readRootExclude` insists must stay apart — \"the file\n // excludes nothing\" and \"loam could not read the file\" are opposite\n // facts about what the renderer will load, and the text view had the\n // distinction while `--json` did not (verification 2026-09-04, review C).\n exclude: {\n updated: out.projects.exclude.updated,\n entries: out.projects.exclude.entries,\n added: out.projects.exclude.added,\n removed: out.projects.exclude.removed,\n unreadable: out.projects.exclude.unreadable,\n },\n },\n });\n return;\n }\n if (out.recovered !== null && out.recovered.outcome !== \"consistent\") {\n console.log(\n `note: recovered an interrupted \\`loam ${out.recovered.command}\\` commit first (${out.recovered.outcome}).`,\n );\n }\n console.log(views(out));\n serviceViews(out.serviceViews);\n // Owed on BOTH arms: the run that declined to rewrite an existing file, and\n // the run that wrote the tolerant shape because there was none to protect.\n if (out.mapUnreadable) console.log(LANDSCAPE_UNREADABLE_NOTE);\n projects(out.projects);\n exclude(out.projects);\n}\n\n/** The one line about the generated views file. */\nfunction views(out: SyncReport): string {\n // \"nothing to write\" is a claim about the WHOLE run, and it is false the moment\n // any of this verb's FOUR writers landed something beside a current subsystem\n // views file: the per-service views file, a project file created, one removed,\n // or the root `exclude` rewritten. The exclude arm was missed when it was\n // added, so the tail contradicted the very next line of output (verification\n // 2026-09-04, review C) — and the per-service file is the same trap one file\n // over, which is why it is in the same expression rather than a second one.\n const quiet =\n out.serviceViews === \"current\" &&\n out.projects.created.length === 0 &&\n out.projects.removed.length === 0 &&\n !out.projects.exclude.updated;\n const sentence: Record<SyncAction, string> = {\n current: `architecture/subsystems.likec4 is current (${out.subsystems} subsystem(s))${quiet ? \" — nothing to write.\" : \".\"}`,\n created: `wrote architecture/subsystems.likec4 — ${out.subsystems} subsystem view(s).`,\n updated: `updated architecture/subsystems.likec4 — ${out.subsystems} subsystem view(s).`,\n removed: `removed architecture/subsystems.likec4 — the tree has no subsystems, so the generated file must be absent.`,\n blocked:\n \"architecture/subsystems.likec4 left as it is — architecture/ does not parse as one project, so the \" +\n \"render's element join is the landscape file's alone (a use-case document that binds an element of its \" +\n \"own is not in it), and rewriting the file would take its `include` lines with it.\",\n refused: authoredRefusal(\"architecture/subsystems.likec4\"),\n };\n return sentence[out.action];\n}\n\n/**\n * The one line about `architecture/services.likec4`, printed on every answer but\n * `current` — so on the two that left the file alone (`blocked`, `refused`) as\n * well as the three that changed it. \"Nothing happened to it, and here is why\"\n * is exactly the sentence a person needs; only \"nothing happened to it because\n * nothing needed to\" is silence worth keeping.\n *\n * SILENT WHEN CURRENT, and that is the difference from the subsystem file's line\n * above: that one is the sentence this command has always ended on and a person\n * reads it as \"the run happened\", while a second unconditional line about a file\n * nothing touched is noise on every run in a fleet that is already in order. The\n * `--json` key is unconditional for the opposite reason — a consumer branches on\n * a value, not on a line's presence.\n *\n * No view COUNT here, deliberately. The subsystem line counts subsystems because\n * the tree answers that without reading anything; the per-service file draws\n * only the services the MAP resolves, and the honest count is the render's, not\n * the tree's. loam never reads a generated file's content (docs/DESIGN.md rule\n * 26), so counting the views in it is the one place the count could come from\n * and the one place it may not.\n */\nfunction serviceViews(action: SyncAction): void {\n if (action === \"current\") return;\n const sentence: Record<Exclude<SyncAction, \"current\">, string> = {\n created: `wrote ${SERVICE_VIEWS} — one overview view per service the fleet map draws.`,\n updated: `updated ${SERVICE_VIEWS} — one overview view per service the fleet map draws.`,\n removed: `removed ${SERVICE_VIEWS} — the fleet map draws none of the services under services/, so the generated file must be absent.`,\n blocked:\n `${SERVICE_VIEWS} left as it is — architecture/ does not parse as one project, so no service resolves to a ` +\n \"map element and the render would have emptied the file rather than improved it.\",\n refused: authoredRefusal(SERVICE_VIEWS),\n };\n console.log(sentence[action]);\n}\n\n/** The per-service project files this run created and removed. */\nfunction projects(out: ProjectsReport): void {\n if (out.created.length > 0) {\n console.log(\n `wrote ${out.created.length} services/<…>/likec4.config.json — one LikeC4 project per service model whose ` +\n \"model stands alone (an existing file is yours and is never rewritten; commit the new ones before a \" +\n \"`loam subsystem move`, which refuses over untracked paths). With more than one project, \" +\n \"likec4 validate at the docs root needs --project <name>; build and export take every project.\",\n );\n }\n // Named one per line: this is a DELETE of a file a team may have written, and\n // a count would leave a reader nothing to check against their own diff.\n for (const path of out.removed) console.log(`removed ${path}`);\n if (out.removed.length > 0) {\n console.log(\n \"— that model extends the fleet map, and a project file beside it registers a second renderer project \" +\n \"rooted at the service directory: `likec4 validate .` then refuses without `--project`, and wherever \" +\n \"that project claims the model it loads it alone, where the map's kinds are not, and the fleet project \" +\n \"loses the service's interior. This is what `service.likec4-config-stray` names.\",\n );\n }\n if (!out.root && out.modelled > 0) {\n console.log(\n \"note: no likec4.config.json at the docs root, so no service project files were written — \" +\n \"`loam doctor` prints the root file.\",\n );\n }\n}\n\n/** The root project's `exclude`, and the one state loam refuses to touch. */\nfunction exclude(out: ProjectsReport): void {\n const plan = out.exclude;\n if (plan.updated) {\n // Named entries rather than a count: this line is the whole record of a\n // rewrite to a file the team owns, and \"updated the exclude list\" gives a\n // reader nothing to check against their own diff.\n const parts = [\n ...(plan.added.length > 0 ? [`+${plan.added.join(\" +\")}`] : []),\n ...(plan.removed.length > 0 ? [`-${plan.removed.join(\" -\")}`] : []),\n ];\n console.log(\n `rewrote likec4.config.json's \\`exclude\\` (${parts.join(\" \")}) — the root project excludes exactly the ` +\n \"models that stand alone; a model that extends the fleet map renders from the docs root and must not be excluded.\",\n );\n }\n if (plan.unreadable) {\n console.log(\n \"note: likec4.config.json at the docs root is not a JSON object with a string `exclude` list, so it was \" +\n \"left exactly as it is — loam does not rewrite a project file it cannot read.\",\n );\n }\n}\n"]}
@@ -1,12 +1,15 @@
1
1
  /**
2
2
  * The shared commit window every subsystem WRITE verb goes through — `new`,
3
3
  * `rm`, `move` and `rename` all land here: lock, recover a predecessor's
4
- * journal, plan against the walked tree, regenerate the views file from the
5
- * PLANNED post-state, commit through the journaled transaction (directory
6
- * renames included), report. One window, because the views file must land in
7
- * the same commit as whatever changed the tree — a marker without its view,
8
- * or renames without theirs, is exactly the `subsystem.views-stale` state
9
- * the generated file's contract refuses to leave between two commits.
4
+ * journal, plan against the walked tree, regenerate BOTH generated views files
5
+ * from the PLANNED post-state, commit through the journaled transaction
6
+ * (directory renames included), report. One window, because those files must
7
+ * land in the same commit as whatever changed the tree — a marker without its
8
+ * view, or renames without theirs, is exactly the `subsystem.views-stale` state
9
+ * the generated files' contract refuses to leave between two commits. The
10
+ * per-service file is in that rule and not beside it: its titles are
11
+ * `<subsystem path> / <service id>`, so the placement a verb changes is written
12
+ * into it too.
10
13
  *
11
14
  * This window does NOT write the per-service project files
12
15
  * (`services/<…>/<id>/likec4.config.json`); only `../sync.ts` does, and the
@@ -26,11 +29,19 @@
26
29
  * model with no file beside it, and that is the state `validate --all` names
27
30
  * as `service.likec4-config-missing`, with `loam subsystem sync` as the
28
31
  * repair — one writer, so the create-only rule has one place to hold.
32
+ *
33
+ * NEITHER generated file is written over a document loam did not write, and
34
+ * that guard is checked HERE as well as in `../sync.ts` — see the comment at
35
+ * the check. Both are filenames loam RESERVES inside a directory the team
36
+ * owns, both are rewritten whole, and until the check below existed all four
37
+ * verbs took an authored file at either path without a word. Unlike sync,
38
+ * which reports the one file `refused` and completes the rest, a verb refuses
39
+ * WHOLE: the file has to land in this commit, so half of this commit is not a
40
+ * lesser version of it.
29
41
  */
30
42
  import { existsSync } from "node:fs";
31
43
  import { readFile } from "node:fs/promises";
32
- import { fail } from "../../../core/envelope/json.js";
33
- import { subsystemViewsPath } from "../../../core/repo/paths.js";
44
+ import { fail, repoPath } from "../../../core/envelope/json.js";
34
45
  import { listFleetTree } from "../../../core/repo/repo.js";
35
46
  import { stageWrites } from "../../../core/staging/commit.js";
36
47
  import { InterruptedCommitError } from "../../../core/staging/interrupted.js";
@@ -38,11 +49,21 @@ import { acquireDocsLock, DocsBusyError } from "../../../core/staging/lock.js";
38
49
  import { recoverInterruptedCommit } from "../../../core/staging/recovery/recover.js";
39
50
  import { commitStaged } from "../../../core/staging/txn/transaction.js";
40
51
  import {} from "../../../core/staging/writes.js";
52
+ import { authoredGeneratedPaths, authoredRefusal } from "../../../core/repo/tree/render/service/header.js";
41
53
  import { viewsAgree } from "../../../core/repo/tree/render/views.js";
42
- import { expectedViews } from "./views.js";
43
- /** The text tail a verb's success line carries about the generated file. */
54
+ import { expectedViews, LANDSCAPE_UNREADABLE_NOTE } from "./views.js";
55
+ /** The text tail a verb's success line carries about the generated files — one clause per file that moved. */
44
56
  export function reportViews(views) {
45
- return views === "current" ? "" : ` — architecture/subsystems.likec4 ${views}`;
57
+ return ([
58
+ ["architecture/subsystems.likec4", views.subsystems],
59
+ ["architecture/services.likec4", views.services],
60
+ ]
61
+ // `current` says nothing: the tail hangs off a success line, and a verb
62
+ // that renamed one directory must not print two clauses about files that
63
+ // did not move.
64
+ .filter(([, action]) => action !== "current")
65
+ .map(([file, action]) => ` — ${file} ${action}`)
66
+ .join(""));
46
67
  }
47
68
  /**
48
69
  * Lock → recover → plan → stage (views included) → journaled commit → report.
@@ -74,19 +95,69 @@ export async function commitWindow(docsDir, json, plan) {
74
95
  const txn = await plan(await listFleetTree(docsDir));
75
96
  if (txn === null)
76
97
  return;
77
- const path = subsystemViewsPath(docsDir);
78
98
  const expected = await expectedViews(docsDir, txn.tree);
79
- const bytes = expected === null ? null : Buffer.from(expected, "utf8");
80
- // Content, not bytes — `viewsAgree` carries the reason. Without it every
81
- // subsystem move on a Windows clone folded a rewrite of this file into its
82
- // transaction that changed not one fact, and journalled it.
83
- const current = existsSync(path) ? await readFile(path, "utf8") : null;
84
- let views = "current";
85
- const writes = [...txn.writes];
86
- if (!viewsAgree(current, expected)) {
87
- views = current === null ? "created" : bytes === null ? "removed" : "updated";
88
- writes.push({ path, content: bytes });
99
+ // OWNERSHIP BEFORE ANYTHING IS STAGED, and the WHOLE verb is refused.
100
+ //
101
+ // The guard first shipped in `../sync.ts` alone, and this window is the
102
+ // other writer of exactly the same two reserved paths — for `subsystem
103
+ // new`, `rm`, `move` and `rename` at once. Reproduced before this call
104
+ // existed: with a hand-authored `architecture/services.likec4` in the tree,
105
+ // `loam subsystem new platform` printed "architecture/services.likec4
106
+ // updated" and destroyed it, first line and all. One predicate, one
107
+ // sentence, shared from beneath every writer
108
+ // (`core/repo/tree/render/service/header.ts`).
109
+ //
110
+ // WHY THE WHOLE OPERATION AND NOT JUST THE FILE. `sync` can decline one file
111
+ // and still do the rest, because nothing else it writes depends on that file
112
+ // and every part of it is idempotent. This window cannot: its entire reason
113
+ // for existing is that the generated files land in the SAME commit as the
114
+ // change to the tree, so completing the structural half and reporting the
115
+ // file as refused would MANUFACTURE the `subsystem.views-stale` state the
116
+ // banner above says this window refuses to leave between two commits — and
117
+ // it would do so irreversibly, since `move` and `rename` also stage their
118
+ // renames in git. A half-done move nobody asked for is worse than a refusal
119
+ // a person can act on, and a refusal is re-runnable the moment the authored
120
+ // file is out of the reserved path.
121
+ //
122
+ // It runs AFTER `plan`, which writes nothing and has already answered the
123
+ // verb-specific refusals (`unknown-target`, `subsystem-not-empty`,
124
+ // `move-uncommitted`): a caller whose name is wrong AND whose tree holds an
125
+ // authored views file is better told about the name.
126
+ const authored = await authoredGeneratedPaths([expected.subsystems.path, expected.services.path]);
127
+ if (authored.length > 0) {
128
+ fail(json,
129
+ // The code is `already-exists` and not a new one because it is exactly
130
+ // what that code has always meant here — "refusing to create over
131
+ // something that already exists, because overwriting would destroy work
132
+ // nobody asked to lose" — and it is already this command's documented
133
+ // refusal for the flat namespace. A second code for the same sentence
134
+ // would be a second branch an agent has to learn for no new decision.
135
+ "already-exists", authored.map((path) => authoredRefusal(repoPath(docsDir, path))).join(" ") +
136
+ " NOTHING was written and nothing was renamed: this command lands the generated views file in the same " +
137
+ "commit as the change to the tree, so it refuses whole rather than leaving the tree changed — and, for " +
138
+ "`move` and `rename`, renames staged in git — with a views file it may not write.");
139
+ return;
89
140
  }
141
+ const writes = [...txn.writes];
142
+ // BOTH generated files, in the ONE commit that changes the tree. The
143
+ // per-service file is not a bystander here: its view titles are
144
+ // `<subsystem path> / <service id>`, so every `move` and `rename` restales
145
+ // it exactly as it restales the subsystem views, and leaving it for a later
146
+ // `sync` is the failing-between-two-commits state this window exists to
147
+ // refuse.
148
+ const views = {
149
+ subsystems: await stageGenerated(expected.subsystems, writes, false),
150
+ // PROTECTED when the map did not parse as a project, and only this file
151
+ // is: `renderSubsystemViews` answers null off the TREE, which is readable
152
+ // either way, while `renderServiceOverviews` answers null whenever no
153
+ // service resolves to an element — which is what an unreadable map looks
154
+ // like from here. Under `known: false` a null render is therefore not
155
+ // evidence of absence, and writing it would delete the whole file over a
156
+ // sibling document that does not parse. A verb still rewrites a file it
157
+ // CAN render (the tolerant arm this window has always taken), and the
158
+ // person is told by the note at the foot of this function.
159
+ services: await stageGenerated(expected.services, writes, !expected.known),
160
+ };
90
161
  const staged = await stageWrites(writes);
91
162
  const committed = await commitStaged({
92
163
  root: docsDir,
@@ -105,9 +176,42 @@ export async function commitWindow(docsDir, json, plan) {
105
176
  return;
106
177
  }
107
178
  await txn.report(views, recovered);
179
+ // A verb CANNOT leave this file behind the tree — its bytes depend on the
180
+ // placement the verb just changed, so the tolerant render lands even when the
181
+ // map is unreadable and the file loses its includes. `sync` may decline that
182
+ // rewrite (`action: "blocked"`); a verb may not, so the person is told
183
+ // instead, and the same note names the run that puts the includes back.
184
+ if (!json && !expected.known)
185
+ console.log(LANDSCAPE_UNREADABLE_NOTE);
108
186
  }
109
187
  finally {
110
188
  await releaseLock();
111
189
  }
112
190
  }
191
+ /**
192
+ * Compare ONE generated file against what it must say, append the write when
193
+ * they differ, and name what happened.
194
+ *
195
+ * The compare is over CONTENT, not bytes — `viewsAgree` carries the reason,
196
+ * beside the generator that mints them: an ordinary Windows clone hands a
197
+ * generated file back with CRLF and not one fact in it changed, and without
198
+ * this every subsystem move on such a clone folded a rewrite that changed
199
+ * nothing into its transaction and journalled it. What gets WRITTEN is still
200
+ * the LF render as a Buffer, so the decode here is a comparison only.
201
+ *
202
+ * `protect` is the caller's statement that a NULL render is not evidence: the
203
+ * file is left exactly as it is rather than deleted. It is never a licence to
204
+ * skip a real rewrite — a file loam can render is rendered, which is the
205
+ * difference between this window and `../sync.ts`'s `blocked`.
206
+ */
207
+ async function stageGenerated(file, writes, protect) {
208
+ const current = existsSync(file.path) ? await readFile(file.path, "utf8") : null;
209
+ if (viewsAgree(current, file.content))
210
+ return "current";
211
+ if (protect && file.content === null)
212
+ return "current";
213
+ const bytes = file.content === null ? null : Buffer.from(file.content, "utf8");
214
+ writes.push({ path: file.path, content: bytes });
215
+ return current === null ? "created" : bytes === null ? "removed" : "updated";
216
+ }
113
217
  //# sourceMappingURL=txn.js.map