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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/README.md +6 -1
  3. package/dist/cli.js +4 -0
  4. package/dist/cli.js.map +1 -1
  5. package/dist/commands/adopt.js +17 -3
  6. package/dist/commands/adopt.js.map +1 -1
  7. package/dist/commands/archive.js +24 -8
  8. package/dist/commands/archive.js.map +1 -1
  9. package/dist/commands/delta.js +74 -47
  10. package/dist/commands/delta.js.map +1 -1
  11. package/dist/commands/dependencies.js +7 -2
  12. package/dist/commands/dependencies.js.map +1 -1
  13. package/dist/commands/docs-repo-gate.js +104 -0
  14. package/dist/commands/docs-repo-gate.js.map +1 -0
  15. package/dist/commands/explore.js +195 -0
  16. package/dist/commands/explore.js.map +1 -0
  17. package/dist/commands/format.js +17 -0
  18. package/dist/commands/format.js.map +1 -0
  19. package/dist/commands/gherkin.js +87 -48
  20. package/dist/commands/gherkin.js.map +1 -1
  21. package/dist/commands/init.js +3 -3
  22. package/dist/commands/init.js.map +1 -1
  23. package/dist/commands/instructions.js +70 -0
  24. package/dist/commands/instructions.js.map +1 -0
  25. package/dist/commands/list.js +6 -76
  26. package/dist/commands/list.js.map +1 -1
  27. package/dist/commands/migrate-openspec.js +45 -28
  28. package/dist/commands/migrate-openspec.js.map +1 -1
  29. package/dist/commands/new.js +35 -34
  30. package/dist/commands/new.js.map +1 -1
  31. package/dist/commands/rebase.js +2 -4
  32. package/dist/commands/rebase.js.map +1 -1
  33. package/dist/commands/show.js +26 -31
  34. package/dist/commands/show.js.map +1 -1
  35. package/dist/commands/status.js +8 -24
  36. package/dist/commands/status.js.map +1 -1
  37. package/dist/commands/unarchive.js +12 -5
  38. package/dist/commands/unarchive.js.map +1 -1
  39. package/dist/commands/validate.js +115 -133
  40. package/dist/commands/validate.js.map +1 -1
  41. package/dist/commands/verify.js +33 -17
  42. package/dist/commands/verify.js.map +1 -1
  43. package/dist/commands/vouch.js +77 -59
  44. package/dist/commands/vouch.js.map +1 -1
  45. package/dist/core/agent.js +273 -31
  46. package/dist/core/agent.js.map +1 -1
  47. package/dist/core/arch.js +28 -2
  48. package/dist/core/arch.js.map +1 -1
  49. package/dist/core/brief.js +3 -3
  50. package/dist/core/brief.js.map +1 -1
  51. package/dist/core/coherence.js +37 -13
  52. package/dist/core/coherence.js.map +1 -1
  53. package/dist/core/concurrency.js +51 -0
  54. package/dist/core/concurrency.js.map +1 -0
  55. package/dist/core/config.js +10 -2
  56. package/dist/core/config.js.map +1 -1
  57. package/dist/core/delta.js +21 -2
  58. package/dist/core/delta.js.map +1 -1
  59. package/dist/core/dependencies.js +46 -42
  60. package/dist/core/dependencies.js.map +1 -1
  61. package/dist/core/docs.js +2 -1
  62. package/dist/core/docs.js.map +1 -1
  63. package/dist/core/doctor.js +41 -26
  64. package/dist/core/doctor.js.map +1 -1
  65. package/dist/core/document-bytes.js +59 -0
  66. package/dist/core/document-bytes.js.map +1 -0
  67. package/dist/core/explore.js +225 -0
  68. package/dist/core/explore.js.map +1 -0
  69. package/dist/core/fleet-context.js +16 -63
  70. package/dist/core/fleet-context.js.map +1 -1
  71. package/dist/core/frontmatter.js +1 -5
  72. package/dist/core/frontmatter.js.map +1 -1
  73. package/dist/core/gherkin.js +3 -61
  74. package/dist/core/gherkin.js.map +1 -1
  75. package/dist/core/health.js +1 -5
  76. package/dist/core/health.js.map +1 -1
  77. package/dist/core/ids.js +34 -5
  78. package/dist/core/ids.js.map +1 -1
  79. package/dist/core/json.js +8 -1
  80. package/dist/core/json.js.map +1 -1
  81. package/dist/core/likec4.js +6 -1
  82. package/dist/core/likec4.js.map +1 -1
  83. package/dist/core/maturity.js +66 -0
  84. package/dist/core/maturity.js.map +1 -0
  85. package/dist/core/openapi-merge.js +39 -6
  86. package/dist/core/openapi-merge.js.map +1 -1
  87. package/dist/core/openapi.js +17 -5
  88. package/dist/core/openapi.js.map +1 -1
  89. package/dist/core/openspec-inventory.js +64 -27
  90. package/dist/core/openspec-inventory.js.map +1 -1
  91. package/dist/core/path-safety.js +59 -0
  92. package/dist/core/path-safety.js.map +1 -1
  93. package/dist/core/provenance.js +35 -10
  94. package/dist/core/provenance.js.map +1 -1
  95. package/dist/core/records.js +33 -0
  96. package/dist/core/records.js.map +1 -0
  97. package/dist/core/repo.js +75 -1
  98. package/dist/core/repo.js.map +1 -1
  99. package/dist/core/report.js +9 -0
  100. package/dist/core/report.js.map +1 -1
  101. package/dist/core/results.js +1 -3
  102. package/dist/core/results.js.map +1 -1
  103. package/dist/core/spec.js +3 -3
  104. package/dist/core/spec.js.map +1 -1
  105. package/dist/core/staging.js +124 -17
  106. package/dist/core/staging.js.map +1 -1
  107. package/dist/core/status.js +122 -33
  108. package/dist/core/status.js.map +1 -1
  109. package/dist/core/steps.js +72 -0
  110. package/dist/core/steps.js.map +1 -0
  111. package/dist/core/verify.js +18 -4
  112. package/dist/core/verify.js.map +1 -1
  113. package/dist/core/version.js +17 -1
  114. package/dist/core/version.js.map +1 -1
  115. 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);
@@ -842,7 +908,13 @@ export async function fleetStatus(docsDir, opts = {}) {
842
908
  const graph = await analyzeDependencies(docsDir, undefined, context);
843
909
  const entries = await listFeatures(docsDir, {}, context);
844
910
  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)));
911
+ // Once for the fleet, not once per feature: which features hold a contract
912
+ // for which service is one fact about the repository, and every feature below
913
+ // reads its own row out of it. Built over the UNNARROWED list on purpose — a
914
+ // `--service` view still has to know that a feature outside it discharges the
915
+ // contract, or the narrowing would invent an obligation.
916
+ const owners = await contractOwners(docsDir, context);
917
+ const features = await Promise.all(inScope.map((f) => fleetFeature(docsDir, f, graph, contractsHeldElsewhere(owners, f.id), context, interrupted)));
846
918
  features.sort((a, b) => compareIds(a.id, b.id));
847
919
  return {
848
920
  interrupted,
@@ -866,7 +938,7 @@ function undocumented(s) {
866
938
  function vouched(s) {
867
939
  return s.status === "verified" && s.sources.stamped;
868
940
  }
869
- async function fleetFeature(docsDir, feature, graph, contracted, context) {
941
+ async function fleetFeature(docsDir, feature, graph, contracted, context, interrupted) {
870
942
  const paths = featurePaths(feature.dir);
871
943
  const missing = [];
872
944
  if (!existsSync(paths.intent))
@@ -893,13 +965,23 @@ async function fleetFeature(docsDir, feature, graph, contracted, context) {
893
965
  // No `draft` here, ever: that verdict needs the coherence run this form
894
966
  // refuses to pay for, and inferring it from presence alone would be a second
895
967
  // 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";
968
+ //
969
+ // The interrupted commit leads, exactly as it does in the feature form: every
970
+ // presence test below is a question about files a killed `archive` may have
971
+ // half-written, so answering `done` from them is a claim about bytes nobody
972
+ // has established. Without this the two forms contradicted each other over
973
+ // one repository — `loam status --json` said `done` and offered "ship it"
974
+ // while `loam status <FEAT>` said `blocked` — and the module header's rule is
975
+ // that this projection is never greener than the gates.
976
+ const stage = interrupted !== null
977
+ ? "blocked"
978
+ : missing.length > 0
979
+ ? "missing"
980
+ : blockedBy.length > 0
981
+ ? "blocked"
982
+ : fullyVerified(verification)
983
+ ? "done"
984
+ : "ready";
903
985
  return {
904
986
  id: feature.id,
905
987
  dirName: feature.dirName,
@@ -981,7 +1063,14 @@ function fleetNext(services, features, graph, interrupted) {
981
1063
  }
982
1064
  steps.push({
983
1065
  code: "next.feature",
984
- statement: f.stage === "blocked"
1066
+ statement:
1067
+ // The list, not the stage, is what this sentence names. `blocked` has
1068
+ // two causes now — another feature in flight, and an interrupted commit
1069
+ // that stalls the whole repository — and the second one names no
1070
+ // feature, so keying off the stage alone printed "FEAT-1 is waiting
1071
+ // on ." The recover step above already says what the repository is
1072
+ // waiting on.
1073
+ f.stage === "blocked" && f.blockedBy.length > 0
985
1074
  ? `${f.id} is waiting on ${f.blockedBy.join(", ")}.`
986
1075
  : `${f.id} is at '${f.stage}'${f.missing.length > 0 ? ` — missing ${f.missing.join(", ")}` : ""}.`,
987
1076
  command: `loam status ${f.id} --json`,