@ybotok/loam 0.1.0-beta.1 → 0.1.0-beta.3

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 (120) hide show
  1. package/CHANGELOG.md +96 -0
  2. package/README.md +6 -1
  3. package/SCHEMA.md +22 -4
  4. package/dist/cli.js +4 -0
  5. package/dist/cli.js.map +1 -1
  6. package/dist/commands/adopt.js +17 -3
  7. package/dist/commands/adopt.js.map +1 -1
  8. package/dist/commands/archive.js +24 -8
  9. package/dist/commands/archive.js.map +1 -1
  10. package/dist/commands/delta.js +74 -47
  11. package/dist/commands/delta.js.map +1 -1
  12. package/dist/commands/dependencies.js +7 -2
  13. package/dist/commands/dependencies.js.map +1 -1
  14. package/dist/commands/docs-repo-gate.js +104 -0
  15. package/dist/commands/docs-repo-gate.js.map +1 -0
  16. package/dist/commands/explore.js +195 -0
  17. package/dist/commands/explore.js.map +1 -0
  18. package/dist/commands/format.js +17 -0
  19. package/dist/commands/format.js.map +1 -0
  20. package/dist/commands/gherkin.js +87 -48
  21. package/dist/commands/gherkin.js.map +1 -1
  22. package/dist/commands/init.js +3 -3
  23. package/dist/commands/init.js.map +1 -1
  24. package/dist/commands/instructions.js +80 -0
  25. package/dist/commands/instructions.js.map +1 -0
  26. package/dist/commands/list.js +10 -76
  27. package/dist/commands/list.js.map +1 -1
  28. package/dist/commands/migrate-openspec.js +45 -28
  29. package/dist/commands/migrate-openspec.js.map +1 -1
  30. package/dist/commands/new.js +35 -34
  31. package/dist/commands/new.js.map +1 -1
  32. package/dist/commands/rebase.js +2 -4
  33. package/dist/commands/rebase.js.map +1 -1
  34. package/dist/commands/show.js +26 -31
  35. package/dist/commands/show.js.map +1 -1
  36. package/dist/commands/status.js +17 -25
  37. package/dist/commands/status.js.map +1 -1
  38. package/dist/commands/unarchive.js +12 -5
  39. package/dist/commands/unarchive.js.map +1 -1
  40. package/dist/commands/validate.js +306 -142
  41. package/dist/commands/validate.js.map +1 -1
  42. package/dist/commands/verify.js +33 -17
  43. package/dist/commands/verify.js.map +1 -1
  44. package/dist/commands/vouch.js +156 -60
  45. package/dist/commands/vouch.js.map +1 -1
  46. package/dist/core/agent.js +392 -42
  47. package/dist/core/agent.js.map +1 -1
  48. package/dist/core/agents-stamp.js +47 -10
  49. package/dist/core/agents-stamp.js.map +1 -1
  50. package/dist/core/arch.js +28 -2
  51. package/dist/core/arch.js.map +1 -1
  52. package/dist/core/asyncapi.js +253 -0
  53. package/dist/core/asyncapi.js.map +1 -0
  54. package/dist/core/brief.js +28 -6
  55. package/dist/core/brief.js.map +1 -1
  56. package/dist/core/coherence.js +37 -13
  57. package/dist/core/coherence.js.map +1 -1
  58. package/dist/core/concurrency.js +51 -0
  59. package/dist/core/concurrency.js.map +1 -0
  60. package/dist/core/config.js +10 -2
  61. package/dist/core/config.js.map +1 -1
  62. package/dist/core/delta.js +21 -2
  63. package/dist/core/delta.js.map +1 -1
  64. package/dist/core/dependencies.js +46 -42
  65. package/dist/core/dependencies.js.map +1 -1
  66. package/dist/core/docs.js +65 -3
  67. package/dist/core/docs.js.map +1 -1
  68. package/dist/core/doctor.js +79 -27
  69. package/dist/core/doctor.js.map +1 -1
  70. package/dist/core/document-bytes.js +59 -0
  71. package/dist/core/document-bytes.js.map +1 -0
  72. package/dist/core/explore.js +225 -0
  73. package/dist/core/explore.js.map +1 -0
  74. package/dist/core/fleet-context.js +29 -63
  75. package/dist/core/fleet-context.js.map +1 -1
  76. package/dist/core/frontmatter.js +1 -5
  77. package/dist/core/frontmatter.js.map +1 -1
  78. package/dist/core/gherkin.js +3 -61
  79. package/dist/core/gherkin.js.map +1 -1
  80. package/dist/core/health.js +1 -5
  81. package/dist/core/health.js.map +1 -1
  82. package/dist/core/ids.js +34 -5
  83. package/dist/core/ids.js.map +1 -1
  84. package/dist/core/json.js +8 -1
  85. package/dist/core/json.js.map +1 -1
  86. package/dist/core/likec4.js +19 -4
  87. package/dist/core/likec4.js.map +1 -1
  88. package/dist/core/maturity.js +66 -0
  89. package/dist/core/maturity.js.map +1 -0
  90. package/dist/core/openapi-merge.js +39 -6
  91. package/dist/core/openapi-merge.js.map +1 -1
  92. package/dist/core/openapi.js +17 -5
  93. package/dist/core/openapi.js.map +1 -1
  94. package/dist/core/openspec-inventory.js +64 -27
  95. package/dist/core/openspec-inventory.js.map +1 -1
  96. package/dist/core/path-safety.js +59 -0
  97. package/dist/core/path-safety.js.map +1 -1
  98. package/dist/core/provenance.js +102 -10
  99. package/dist/core/provenance.js.map +1 -1
  100. package/dist/core/records.js +33 -0
  101. package/dist/core/records.js.map +1 -0
  102. package/dist/core/repo.js +77 -1
  103. package/dist/core/repo.js.map +1 -1
  104. package/dist/core/report.js +9 -0
  105. package/dist/core/report.js.map +1 -1
  106. package/dist/core/results.js +1 -3
  107. package/dist/core/results.js.map +1 -1
  108. package/dist/core/spec.js +28 -4
  109. package/dist/core/spec.js.map +1 -1
  110. package/dist/core/staging.js +124 -17
  111. package/dist/core/staging.js.map +1 -1
  112. package/dist/core/status.js +152 -35
  113. package/dist/core/status.js.map +1 -1
  114. package/dist/core/steps.js +72 -0
  115. package/dist/core/steps.js.map +1 -0
  116. package/dist/core/verify.js +18 -4
  117. package/dist/core/verify.js.map +1 -1
  118. package/dist/core/version.js +17 -1
  119. package/dist/core/version.js.map +1 -1
  120. package/package.json +1 -1
@@ -105,7 +105,7 @@ import { FleetContext } from "./fleet-context.js";
105
105
  import { gatesArchive } from "./issue.js";
106
106
  import { repoPath } from "./json.js";
107
107
  import { featureProvenance } from "./provenance.js";
108
- import { compareIds, featurePaths, featureSpecPaths, featureSpecServices, listFeatures, listServices, servicePaths, } from "./repo.js";
108
+ import { compareIds, featurePaths, featureSpecPaths, featureSpecServices, listFeatures, listServices, servicePaths, SPEC_AXES, } from "./repo.js";
109
109
  import { requirementsMissingScenarios } from "./spec.js";
110
110
  import { COMMIT_INTENT, readCommitIntent } from "./staging.js";
111
111
  import { attestedNotice, featureChecklist, readVerificationState, tallyRecord, verificationPath, verificationVerdict, } from "./verify.js";
@@ -155,9 +155,21 @@ async function readInterruptedCommit(docsDir) {
155
155
  return {
156
156
  command: intent.command,
157
157
  feature: intent.feature,
158
- host: intent.host,
159
- pid: intent.pid,
160
- at: intent.at,
158
+ // Coerced here rather than trusted. `readCommitIntent` validates exactly the
159
+ // fields a REPAIR needs — the version, the command, the restore direction,
160
+ // the feature, the two move paths and the file digests — so `host`, `pid`
161
+ // and `at` arrive as whatever the journal happened to hold, `undefined`
162
+ // included. That is worse than null in both renderings: `JSON.stringify`
163
+ // drops an undefined value's key entirely, so `--json` loses the field
164
+ // instead of reporting it empty, and the two human lines print
165
+ // "(undefined, pid undefined, undefined)". These three are the label a
166
+ // person places the crash against and nothing branches on them, so the
167
+ // coercion belongs at this boundary — widening the validator would change
168
+ // which journals `archive` and `unarchive` agree to repair, which is a
169
+ // decision about somebody's half-written docs, not about a label.
170
+ host: typeof intent.host === "string" ? intent.host : null,
171
+ pid: typeof intent.pid === "number" ? intent.pid : null,
172
+ at: typeof intent.at === "string" ? intent.at : null,
161
173
  files: intent.files.map((f) => f.path),
162
174
  unreadable: false,
163
175
  // The recovering command is the interrupted one re-run: it repairs first,
@@ -222,7 +234,7 @@ export async function featureStatus(docsDir, feature, opts = {}) {
222
234
  // way round. `--service` is a lens on the table and the steps; a rollup that
223
235
  // moved with it would report a feature `ready` because the one service you
224
236
  // asked about happens to be written, while three others have nothing.
225
- const graded = featureArtifacts(docsDir, feature, services, blocking, verification, await contractsInFlight(docsDir, feature.id, context), governs);
237
+ const graded = featureArtifacts(docsDir, feature, services, blocking, verification, contractsHeldElsewhere(await contractOwners(docsDir, context), feature.id), governs);
226
238
  const artifacts = narrowed === undefined ? graded : graded.filter((a) => a.service === null || a.service === narrowed);
227
239
  // The dependency index rather than the `*-pending` issue codes: those name the
228
240
  // other feature in prose, and prose is not an API — dependencies.ts opens with
@@ -452,11 +464,20 @@ function fileState(id, service, path, exists, required, faulted) {
452
464
  * docs already carry the contract, or another feature in flight brings it — the
453
465
  * same softening coherence applies with its `*-pending` codes, where an
454
466
  * operation "defined by another feature still in flight" is an ordering fact
455
- * and not a hole. And one way to owe nothing at all: a service with no living
456
- * contract that this feature sends no operation to. A UI or a worker nobody
457
- * calls has no API to write down, which is the reading `validate` and `list`
458
- * already take — demanding one from a brand-new one of those would be loam
459
- * telling an author to invent a contract.
467
+ * and not a hole. And one way to owe nothing at all: a service that ALREADY
468
+ * EXISTS under `services/`, has no living contract, and that this feature sends
469
+ * no operation to. A UI or a worker nobody calls has no API to write down,
470
+ * which is the reading `validate` and `list` already take — demanding one from
471
+ * an adopted service of that shape would be loam telling an author to invent a
472
+ * contract.
473
+ *
474
+ * That last exemption is deliberately withheld from a service the living docs
475
+ * have never heard of. A feature that introduces a service and sends it no
476
+ * operation still owes the file, because nothing else in the repository will
477
+ * ever describe its surface, and `c4-api.op-undefined` fires on an op-tagged
478
+ * delta edge whether or not any `Operations:` line was written — so answering
479
+ * "none owed" there printed `(not written — none owed)` in the same payload as
480
+ * an error about that very file.
460
481
  */
461
482
  function owesContract(docsDir, svc, contracted, governsOperations) {
462
483
  if (existsSync(servicePaths(docsDir, svc).openapi))
@@ -466,18 +487,49 @@ function owesContract(docsDir, svc, contracted, governsOperations) {
466
487
  return !existsSync(servicePaths(docsDir, svc).dir) || governsOperations;
467
488
  }
468
489
  /**
469
- * Services some OTHER active feature already carries an `openapi.yaml` for.
490
+ * Which features in flight carry an `openapi.yaml` for which service — one
491
+ * enumeration of the whole index, service by service.
492
+ *
493
+ * The owners are recorded as feature IDS rather than directories because that
494
+ * is the comparison {@link contractsHeldElsewhere} makes: two directories that
495
+ * spell the same id are one feature as far as this question goes, and an index
496
+ * keyed by directory would let a duplicated id answer "somebody else has it"
497
+ * about its own twin.
498
+ *
470
499
  * Enumeration only — the feature list and its `specs/` subdirectories are
471
500
  * already in the request-scoped index by the time anything asks.
472
501
  */
473
- async function contractsInFlight(docsDir, self, context) {
474
- const out = new Set();
502
+ async function contractOwners(docsDir, context) {
503
+ const out = new Map();
475
504
  for (const f of await listFeatures(docsDir, {}, context)) {
476
- if (f.id === self)
477
- continue;
478
505
  for (const svc of f.services) {
479
- if (existsSync(featureSpecPaths(f.dir, svc).openapi))
506
+ if (!existsSync(featureSpecPaths(f.dir, svc).openapi))
507
+ continue;
508
+ const owners = out.get(svc);
509
+ if (owners === undefined)
510
+ out.set(svc, new Set([f.id]));
511
+ else
512
+ owners.add(f.id);
513
+ }
514
+ }
515
+ return out;
516
+ }
517
+ /**
518
+ * Services some OTHER active feature already carries an `openapi.yaml` for.
519
+ *
520
+ * Derived from the shared index rather than re-walked, because the answer for
521
+ * feature A and the answer for feature B are two readings of one enumeration:
522
+ * asking it once per feature made the fleet form pay F²·S `existsSync` calls
523
+ * over the two quantities that actually grow.
524
+ */
525
+ function contractsHeldElsewhere(owners, self) {
526
+ const out = new Set();
527
+ for (const [svc, ids] of owners) {
528
+ for (const id of ids) {
529
+ if (id !== self) {
480
530
  out.add(svc);
531
+ break;
532
+ }
481
533
  }
482
534
  }
483
535
  return out;
@@ -486,13 +538,22 @@ async function contractsInFlight(docsDir, self, context) {
486
538
  * The record's own rung. `stale` and `unreadable` are `draft` because both mean
487
539
  * the file on disk is the thing to fix; unconfirmed claims are `ready` because
488
540
  * the file is fine and the answer lives in the code.
541
+ *
542
+ * All four states are spelled, `recorded` included, so the `never` below fails
543
+ * the build rather than the reader: a fifth record state added to
544
+ * {@link VerificationState} would otherwise fall through into the last branch
545
+ * and be graded by `fullyVerified` alone — silently answering `ready` about a
546
+ * record whose new state nobody here has decided a rung for.
489
547
  */
490
548
  function verificationStatus(v) {
491
549
  if (v.state === "absent")
492
550
  return "missing";
493
551
  if (v.state === "unreadable" || v.state === "stale")
494
552
  return "draft";
495
- return fullyVerified(v) ? "done" : "ready";
553
+ if (v.state === "recorded")
554
+ return fullyVerified(v) ? "done" : "ready";
555
+ const unreachable = v.state;
556
+ throw new Error(`verificationStatus: no rung for verification state '${String(unreachable)}'`);
496
557
  }
497
558
  /**
498
559
  * Read the record and, only when there is one, ask whether it still answers
@@ -602,8 +663,12 @@ function featureNext(feature, services, artifacts, findings, blockedBy, verifica
602
663
  code: "next.author-openapi",
603
664
  // Not "the service is not in the living docs": a service adopted
604
665
  // without a contract is the commoner case, and that wording sent a
605
- // reader looking for a directory that was right there.
606
- statement: `Write ${api.path} — nothing gives ${svc} a living openapi.yaml, so this feature is the only thing that can define the operations it governs.`,
666
+ // reader looking for a directory that was right there. And not "the
667
+ // operations it governs" either — this step also fires for a service
668
+ // the feature INTRODUCES, where owesContract asks nothing about
669
+ // operations and the delta may tag none, so naming them promised the
670
+ // author a list they would go looking for and not find.
671
+ statement: `Write ${api.path} — nothing gives ${svc} a living openapi.yaml, so this feature is the only thing that can write down its API surface.`,
607
672
  command: `loam delta ${id} --service ${svc} --json`,
608
673
  artifact: "openapi",
609
674
  service: svc,
@@ -798,10 +863,11 @@ async function scanDeltas(docsDir, feature, services, context) {
798
863
  const out = [];
799
864
  for (const svc of services) {
800
865
  const p = featureSpecPaths(feature.dir, svc);
801
- for (const [file, label] of [
802
- [p.spec, `${svc}: requirements`],
803
- [p.archSpec, `${svc}: arch requirements`],
804
- ]) {
866
+ // SPEC_AXES already carries the prose name of each axis — "requirements"
867
+ // and "arch requirements" are its `label`s verbatim — so the pair is read
868
+ // from there rather than spelled again, and a service's status can never
869
+ // walk a different set of spec files than its validation does.
870
+ for (const [file, label] of SPEC_AXES.map((axis) => [p[axis.key], `${svc}: ${axis.label}`])) {
805
871
  if (!existsSync(file))
806
872
  continue;
807
873
  const reqs = await context.readRequirements(file);
@@ -839,10 +905,20 @@ export async function fleetStatus(docsDir, opts = {}) {
839
905
  const narrowed = opts.service;
840
906
  const all = await listServices(docsDir, context);
841
907
  const services = narrowed === undefined ? all : all.filter((s) => s.id === narrowed);
908
+ // The service this repository says it IS, when the fleet has never heard of
909
+ // it. Asked against the UNNARROWED list, because "is it adopted" is a question
910
+ // about the fleet and not about the view.
911
+ const unadopted = opts.bound !== undefined && !all.some((s) => s.id === opts.bound) ? opts.bound : null;
842
912
  const graph = await analyzeDependencies(docsDir, undefined, context);
843
913
  const entries = await listFeatures(docsDir, {}, context);
844
914
  const inScope = narrowed === undefined ? entries : entries.filter((f) => f.services.includes(narrowed));
845
- const features = await Promise.all(inScope.map(async (f) => fleetFeature(docsDir, f, graph, await contractsInFlight(docsDir, f.id, context), context)));
915
+ // Once for the fleet, not once per feature: which features hold a contract
916
+ // for which service is one fact about the repository, and every feature below
917
+ // reads its own row out of it. Built over the UNNARROWED list on purpose — a
918
+ // `--service` view still has to know that a feature outside it discharges the
919
+ // contract, or the narrowing would invent an obligation.
920
+ const owners = await contractOwners(docsDir, context);
921
+ const features = await Promise.all(inScope.map((f) => fleetFeature(docsDir, f, graph, contractsHeldElsewhere(owners, f.id), context, interrupted)));
846
922
  features.sort((a, b) => compareIds(a.id, b.id));
847
923
  return {
848
924
  interrupted,
@@ -855,7 +931,11 @@ export async function fleetStatus(docsDir, opts = {}) {
855
931
  features,
856
932
  order: graph.order.filter((id) => features.some((f) => f.id === id)),
857
933
  service: narrowed ?? null,
858
- next: fleetNext(services, features, graph, interrupted),
934
+ // The binding is passed only when this run was NOT narrowed. `--service X`
935
+ // is an explicit question about X, and answering it with a step about
936
+ // whichever service loam.json happens to name would be a different
937
+ // question's answer at the top of the list.
938
+ next: fleetNext(services, features, graph, interrupted, narrowed === undefined ? unadopted : null),
859
939
  };
860
940
  }
861
941
  /** Nothing is written down: no living spec at all. Every other absence is partial adoption. */
@@ -866,7 +946,7 @@ function undocumented(s) {
866
946
  function vouched(s) {
867
947
  return s.status === "verified" && s.sources.stamped;
868
948
  }
869
- async function fleetFeature(docsDir, feature, graph, contracted, context) {
949
+ async function fleetFeature(docsDir, feature, graph, contracted, context, interrupted) {
870
950
  const paths = featurePaths(feature.dir);
871
951
  const missing = [];
872
952
  if (!existsSync(paths.intent))
@@ -893,13 +973,23 @@ async function fleetFeature(docsDir, feature, graph, contracted, context) {
893
973
  // No `draft` here, ever: that verdict needs the coherence run this form
894
974
  // refuses to pay for, and inferring it from presence alone would be a second
895
975
  // opinion about validity — the one thing status must never invent.
896
- const stage = missing.length > 0
897
- ? "missing"
898
- : blockedBy.length > 0
899
- ? "blocked"
900
- : fullyVerified(verification)
901
- ? "done"
902
- : "ready";
976
+ //
977
+ // The interrupted commit leads, exactly as it does in the feature form: every
978
+ // presence test below is a question about files a killed `archive` may have
979
+ // half-written, so answering `done` from them is a claim about bytes nobody
980
+ // has established. Without this the two forms contradicted each other over
981
+ // one repository — `loam status --json` said `done` and offered "ship it"
982
+ // while `loam status <FEAT>` said `blocked` — and the module header's rule is
983
+ // that this projection is never greener than the gates.
984
+ const stage = interrupted !== null
985
+ ? "blocked"
986
+ : missing.length > 0
987
+ ? "missing"
988
+ : blockedBy.length > 0
989
+ ? "blocked"
990
+ : fullyVerified(verification)
991
+ ? "done"
992
+ : "ready";
903
993
  return {
904
994
  id: feature.id,
905
995
  dirName: feature.dirName,
@@ -945,8 +1035,28 @@ export const FLEET_NEXT_LIMIT = 10;
945
1035
  * once named the command CI runs. This form grades nothing; the least it can do
946
1036
  * is say where grading happens.
947
1037
  */
948
- function fleetNext(services, features, graph, interrupted) {
1038
+ function fleetNext(services, features, graph, interrupted, unadoptedBinding) {
949
1039
  const steps = [];
1040
+ // The repository this command is standing in names a service the fleet has no
1041
+ // directory for. First, ahead of every other service's partial adoption,
1042
+ // because it is the only step that is about HERE.
1043
+ //
1044
+ // This form used to count the fleet and ignore the binding entirely, so in a
1045
+ // service repo bound to an unadopted service — a freshly wired one, the most
1046
+ // common repo there is — an empty fleet made "every service is written down"
1047
+ // vacuously true and `next.fleet-clean` came out top. `loam doctor` had the
1048
+ // right answer (`doctor.service-unknown`) at the same moment, and the
1049
+ // documented agent loop reads `status`: an agent following it was sent to
1050
+ // start a feature instead of adopting the service under its feet.
1051
+ if (unadoptedBinding !== null) {
1052
+ steps.push({
1053
+ code: "next.adopt-bound",
1054
+ statement: `This repository's loam.json says it is '${unadoptedBinding}', and the fleet has no ` +
1055
+ `services/${unadoptedBinding}/ — nothing about the service you are standing in is written down yet.`,
1056
+ command: `loam adopt --service ${unadoptedBinding} --json`,
1057
+ service: unadoptedBinding,
1058
+ });
1059
+ }
950
1060
  for (const s of services.filter(undocumented)) {
951
1061
  steps.push({
952
1062
  code: "next.adopt",
@@ -981,7 +1091,14 @@ function fleetNext(services, features, graph, interrupted) {
981
1091
  }
982
1092
  steps.push({
983
1093
  code: "next.feature",
984
- statement: f.stage === "blocked"
1094
+ statement:
1095
+ // The list, not the stage, is what this sentence names. `blocked` has
1096
+ // two causes now — another feature in flight, and an interrupted commit
1097
+ // that stalls the whole repository — and the second one names no
1098
+ // feature, so keying off the stage alone printed "FEAT-1 is waiting
1099
+ // on ." The recover step above already says what the repository is
1100
+ // waiting on.
1101
+ f.stage === "blocked" && f.blockedBy.length > 0
985
1102
  ? `${f.id} is waiting on ${f.blockedBy.join(", ")}.`
986
1103
  : `${f.id} is at '${f.stage}'${f.missing.length > 0 ? ` — missing ${f.missing.join(", ")}` : ""}.`,
987
1104
  command: `loam status ${f.id} --json`,