@intentius/chant 0.93.0 → 0.95.0

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 (149) hide show
  1. package/dist/cli/handlers/operator.d.ts.map +1 -1
  2. package/dist/cli/handlers/run.d.ts.map +1 -1
  3. package/dist/cli/main.d.ts.map +1 -1
  4. package/dist/cli/mcp/workspace-plugins.d.ts +6 -6
  5. package/dist/cli/registry.d.ts +2 -0
  6. package/dist/cli/registry.d.ts.map +1 -1
  7. package/dist/op/builders.d.ts +14 -3
  8. package/dist/op/builders.d.ts.map +1 -1
  9. package/dist/op/index.d.ts +6 -3
  10. package/dist/op/index.d.ts.map +1 -1
  11. package/dist/op/operator.d.ts +90 -0
  12. package/dist/op/operator.d.ts.map +1 -1
  13. package/dist/op/steward-beside.d.ts +84 -0
  14. package/dist/op/steward-beside.d.ts.map +1 -0
  15. package/dist/op/steward.d.ts +87 -2
  16. package/dist/op/steward.d.ts.map +1 -1
  17. package/dist/workspace/box-intent.d.ts +85 -0
  18. package/dist/workspace/box-intent.d.ts.map +1 -0
  19. package/dist/workspace/box-services.d.ts +31 -0
  20. package/dist/workspace/box-services.d.ts.map +1 -0
  21. package/dist/workspace/chant-migrations.d.ts +5 -0
  22. package/dist/workspace/chant-migrations.d.ts.map +1 -1
  23. package/dist/workspace/checks/boxes.d.ts +14 -1
  24. package/dist/workspace/checks/boxes.d.ts.map +1 -1
  25. package/dist/workspace/checks.d.ts +4 -0
  26. package/dist/workspace/checks.d.ts.map +1 -1
  27. package/dist/workspace/compose-graph.d.ts +11 -0
  28. package/dist/workspace/compose-graph.d.ts.map +1 -1
  29. package/dist/workspace/composites.d.ts +5 -1
  30. package/dist/workspace/composites.d.ts.map +1 -1
  31. package/dist/workspace/decision-points.schema.json +3 -3
  32. package/dist/workspace/declaration.d.ts +43 -0
  33. package/dist/workspace/declaration.d.ts.map +1 -1
  34. package/dist/workspace/declaration.schema.json +62 -1
  35. package/dist/workspace/graph-cache.d.ts +168 -0
  36. package/dist/workspace/graph-cache.d.ts.map +1 -0
  37. package/dist/workspace/graph-cli.d.ts +11 -5
  38. package/dist/workspace/graph-cli.d.ts.map +1 -1
  39. package/dist/workspace/intent-joins.d.ts +5 -5
  40. package/dist/workspace/kind-readers.d.ts +39 -0
  41. package/dist/workspace/kind-readers.d.ts.map +1 -0
  42. package/dist/workspace/kinds.d.ts +29 -0
  43. package/dist/workspace/kinds.d.ts.map +1 -1
  44. package/dist/workspace/member-commands.d.ts +15 -1
  45. package/dist/workspace/member-commands.d.ts.map +1 -1
  46. package/dist/workspace/member-run.d.ts +2 -0
  47. package/dist/workspace/member-run.d.ts.map +1 -1
  48. package/dist/workspace/points-cli.d.ts +2 -0
  49. package/dist/workspace/points-cli.d.ts.map +1 -1
  50. package/dist/workspace/points.d.ts +3 -5
  51. package/dist/workspace/points.d.ts.map +1 -1
  52. package/dist/workspace/reason-codes.d.ts +5 -1
  53. package/dist/workspace/reason-codes.d.ts.map +1 -1
  54. package/dist/workspace/records-cli.d.ts +10 -1
  55. package/dist/workspace/records-cli.d.ts.map +1 -1
  56. package/dist/workspace/records-write.d.ts +4 -2
  57. package/dist/workspace/records-write.d.ts.map +1 -1
  58. package/dist/workspace/records.d.ts +8 -3
  59. package/dist/workspace/records.d.ts.map +1 -1
  60. package/dist/workspace/status-stewards.d.ts +31 -9
  61. package/dist/workspace/status-stewards.d.ts.map +1 -1
  62. package/dist/workspace/status.d.ts +20 -0
  63. package/dist/workspace/status.d.ts.map +1 -1
  64. package/dist/workspace/work-evidence.d.ts +1 -1
  65. package/dist/workspace/work-evidence.d.ts.map +1 -1
  66. package/dist/workspace/workspace-kinds.schema.json +26 -0
  67. package/package.json +1 -1
  68. package/src/cli/commands/carve-bridge.test.ts +7 -3
  69. package/src/cli/handlers/operator-steward-signal.e2e.test.ts +97 -0
  70. package/src/cli/handlers/operator.ts +53 -11
  71. package/src/cli/handlers/run.test.ts +71 -0
  72. package/src/cli/handlers/run.ts +65 -7
  73. package/src/cli/main.ts +21 -9
  74. package/src/cli/mcp/workspace-plugins.ts +6 -6
  75. package/src/cli/mcp/workspace-tools.ts +1 -1
  76. package/src/cli/registry.ts +2 -0
  77. package/src/cli/serve-mcp-workspace.test.ts +6 -6
  78. package/src/cli/static-config-read.test.ts +8 -2
  79. package/src/meta/source-is-text.test.ts +21 -3
  80. package/src/okf.test.ts +6 -1
  81. package/src/op/activities/decide.test.ts +8 -0
  82. package/src/op/builders.ts +14 -3
  83. package/src/op/index.ts +8 -2
  84. package/src/op/operator.ts +264 -16
  85. package/src/op/steward-beside.test.ts +267 -0
  86. package/src/op/steward-beside.ts +219 -0
  87. package/src/op/steward-points.test.ts +112 -1
  88. package/src/op/steward.ts +135 -3
  89. package/src/workspace/box-intent.test.ts +205 -0
  90. package/src/workspace/box-intent.ts +159 -0
  91. package/src/workspace/box-services.test.ts +129 -0
  92. package/src/workspace/box-services.ts +51 -0
  93. package/src/workspace/chant-migrations.ts +5 -0
  94. package/src/workspace/check.schema.json +7 -3
  95. package/src/workspace/checks/boxes.test.ts +3 -1
  96. package/src/workspace/checks/boxes.ts +66 -0
  97. package/src/workspace/checks.ts +12 -1
  98. package/src/workspace/compose-graph.test.ts +1 -0
  99. package/src/workspace/compose-graph.ts +11 -0
  100. package/src/workspace/composites.test.ts +1 -1
  101. package/src/workspace/composites.ts +12 -5
  102. package/src/workspace/conformance/__fixture__/decisions/decision.kind.mjs +4 -2
  103. package/src/workspace/decision-points.schema.json +3 -3
  104. package/src/workspace/declaration.schema.json +62 -1
  105. package/src/workspace/declaration.ts +112 -0
  106. package/src/workspace/declared-kinds.test.ts +26 -0
  107. package/src/workspace/graph-cache.test.ts +343 -0
  108. package/src/workspace/graph-cache.ts +409 -0
  109. package/src/workspace/graph-cli.ts +93 -26
  110. package/src/workspace/graph-contract.test.ts +129 -6
  111. package/src/workspace/graph.schema.json +21 -1
  112. package/src/workspace/intent-joins.test.ts +5 -5
  113. package/src/workspace/intent-joins.ts +5 -5
  114. package/src/workspace/kind-readers.e2e.test.ts +68 -0
  115. package/src/workspace/kind-readers.test.ts +134 -0
  116. package/src/workspace/kind-readers.ts +111 -0
  117. package/src/workspace/kinds.test.ts +49 -0
  118. package/src/workspace/kinds.ts +59 -2
  119. package/src/workspace/member-commands.test.ts +15 -0
  120. package/src/workspace/member-commands.ts +47 -7
  121. package/src/workspace/member-run.ts +10 -2
  122. package/src/workspace/points-cli.ts +3 -0
  123. package/src/workspace/points.schema.json +18 -0
  124. package/src/workspace/points.test.ts +15 -14
  125. package/src/workspace/points.ts +4 -16
  126. package/src/workspace/read-contract.test.ts +8 -0
  127. package/src/workspace/reason-codes.test.ts +7 -7
  128. package/src/workspace/reason-codes.ts +6 -1
  129. package/src/workspace/records-amend.schema.json +1 -0
  130. package/src/workspace/records-cli.ts +34 -13
  131. package/src/workspace/records-contract.test.ts +2 -1
  132. package/src/workspace/records-formats.test.ts +15 -15
  133. package/src/workspace/records-new.schema.json +1 -0
  134. package/src/workspace/records-quorum.test.ts +10 -3
  135. package/src/workspace/records-sessions-write.test.ts +2 -1
  136. package/src/workspace/records-since.test.ts +8 -7
  137. package/src/workspace/records-write.test.ts +101 -1
  138. package/src/workspace/records-write.ts +50 -6
  139. package/src/workspace/records.test.ts +1 -1
  140. package/src/workspace/records.ts +22 -5
  141. package/src/workspace/status-contract.test.ts +32 -1
  142. package/src/workspace/status-stewards.ts +88 -9
  143. package/src/workspace/status.schema.json +66 -4
  144. package/src/workspace/status.ts +32 -0
  145. package/src/workspace/trust/record-seal.test.ts +26 -2
  146. package/src/workspace/work-evidence.schema.json +1 -0
  147. package/src/workspace/{work-readiness-chud.test.ts → work-readiness.test.ts} +30 -32
  148. package/src/workspace/work.test.ts +17 -0
  149. package/src/workspace/workspace-kinds.schema.json +26 -0
@@ -11,6 +11,7 @@ const discoverOpsMock = vi.fn();
11
11
  // default is what every other call sees.
12
12
  const discoverStewardsMock = vi.fn().mockResolvedValue({ stewards: new Map(), errors: [], conflicts: [] });
13
13
  const acquireStewardTurnMock = vi.fn();
14
+ const holdBesideLeaseMock = vi.fn();
14
15
  const releaseLeaseMock = vi.fn();
15
16
  const loadChantConfigMock = vi.fn();
16
17
  const writeFileSyncMock = vi.fn();
@@ -33,6 +34,10 @@ vi.mock("../../op/operator", async (importOriginal) => {
33
34
  const actual = await importOriginal<typeof import("../../op/operator")>();
34
35
  return { ...actual, acquireStewardTurn: (...args: unknown[]) => acquireStewardTurnMock(...args) };
35
36
  });
37
+ vi.mock("../../op/steward-beside", async (importOriginal) => {
38
+ const actual = await importOriginal<typeof import("../../op/steward-beside")>();
39
+ return { ...actual, holdBesideLease: (...args: unknown[]) => holdBesideLeaseMock(...args) };
40
+ });
36
41
  vi.mock("../../lifecycle/lease", async (importOriginal) => {
37
42
  const actual = await importOriginal<typeof import("../../lifecycle/lease")>();
38
43
  return { ...actual, releaseLease: (...args: unknown[]) => releaseLeaseMock(...args) };
@@ -438,6 +443,72 @@ describe("runOp: a steward's turn (#2750)", () => {
438
443
  });
439
444
  });
440
445
 
446
+ /**
447
+ * chant #2861: `chant run <op>` for an Op a steward runs beside its turns
448
+ * takes the Op's own lease, as `--holder`, and never the steward's turn.
449
+ * `holdBesideLease` is stubbed; `op/steward-beside.test.ts` runs it against a
450
+ * real repo.
451
+ */
452
+ describe("runOp: an Op beside a steward's turns (#2861)", () => {
453
+ function stewardBeside(opName: string, name: string) {
454
+ return {
455
+ stewards: new Map([[name, {
456
+ declaration: {
457
+ kind: "Chant::Steward", name, ops: [{ name: "converge", phases: [] }, { name: opName, phases: [] }],
458
+ beside: [{ op: opName, ready: null }],
459
+ form: { default: "local", environments: {} }, capabilities: [], vault: null,
460
+ },
461
+ filePath: "ops/steward.op.ts", exportName: "steward",
462
+ }]]),
463
+ errors: [], conflicts: [],
464
+ };
465
+ }
466
+
467
+ beforeEach(() => {
468
+ discoverOpsMock.mockReset();
469
+ loadChantConfigMock.mockReset().mockResolvedValue({ config: {} });
470
+ loadPluginsMock.mockReset().mockResolvedValue([]);
471
+ acquireStewardTurnMock.mockReset();
472
+ holdBesideLeaseMock.mockReset();
473
+ releaseLeaseMock.mockReset().mockResolvedValue(true);
474
+ });
475
+
476
+ test("takes the Op's lease as --holder, not the turn, runs, and releases it once the run is over", async () => {
477
+ discoverOpsMock.mockResolvedValue({
478
+ ops: new Map([localOp("dispatch", [{ kind: "activity", fn: "shellCmd", args: { cmd: "true" } }])]),
479
+ errors: [],
480
+ });
481
+ discoverStewardsMock.mockResolvedValueOnce(stewardBeside("dispatch", "box-steward"));
482
+ const release = vi.fn(async () => {});
483
+ holdBesideLeaseMock.mockResolvedValue({ acquired: true, lease: { op: "dispatch", holder: "h", token: "t1", acquiredAt: "x", expiresAt: "y" }, release });
484
+ const stderrWrite = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
485
+ const exit = await runOp({ args: makeArgs({ path: "dispatch", holder: "box-steward/dispatch@op1" }), plugins: [], serializers: [] });
486
+ stderrWrite.mockRestore();
487
+
488
+ expect(exit).toBe(0);
489
+ expect(acquireStewardTurnMock).not.toHaveBeenCalled();
490
+ expect(holdBesideLeaseMock).toHaveBeenCalledWith("dispatch", "box-steward/dispatch@op1");
491
+ expect(release).toHaveBeenCalledTimes(1);
492
+ });
493
+
494
+ test("a run of it in progress: refused, naming the holder, nothing runs", async () => {
495
+ discoverOpsMock.mockResolvedValue({
496
+ ops: new Map([localOp("dispatch", [{ kind: "activity", fn: "shellCmd", args: { cmd: "true" } }])]),
497
+ errors: [],
498
+ });
499
+ discoverStewardsMock.mockResolvedValueOnce(stewardBeside("dispatch", "box-steward"));
500
+ holdBesideLeaseMock.mockResolvedValue({ acquired: false, heldBy: "box-steward/dispatch@op1" });
501
+ const stderr = makeStderrSpy();
502
+ const exit = await runOp({ args: makeArgs({ path: "dispatch" }), plugins: [], serializers: [] });
503
+
504
+ expect(exit).toBe(1);
505
+ const out = stderr.join("\n");
506
+ expect(out).toContain(`runs beside steward "box-steward"'s turns`);
507
+ expect(out).toContain("box-steward/dispatch@op1");
508
+ expect(acquireStewardTurnMock).not.toHaveBeenCalled();
509
+ });
510
+ });
511
+
441
512
  /**
442
513
  * chant #2243 — a gated run's exit code is the one thing `--gated-exit`
443
514
  * remaps, and the gate is reported where CI can see it without opening a log.
@@ -17,7 +17,8 @@ import { recordGateApproval } from "./operator";
17
17
  import { formatError, formatWarning, formatSuccess, formatBold, formatInfo } from "../format";
18
18
  import { resolveCliBuildParams, parseParamFlags } from "../build-params-cli";
19
19
  import type { CommandContext } from "../registry";
20
- import { stewardFormFor, stewardTurnLeaseName, DEFAULT_STEWARD_ENV, type StewardDeclaration } from "../../op/steward";
20
+ import { stewardBesideFor, stewardFormFor, stewardTurnLeaseName, DEFAULT_STEWARD_ENV, type StewardDeclaration } from "../../op/steward";
21
+ import { holdBesideLease } from "../../op/steward-beside";
21
22
  import { acquireStewardTurn, STEWARD_TURN_WAIT_MS } from "../../op/operator";
22
23
  import { releaseLease, currentHolderId, type AcquireLeaseResult } from "../../lifecycle/lease";
23
24
  import { StaleLockError } from "../../lifecycle/git";
@@ -557,6 +558,12 @@ type StewardTurnGate =
557
558
  * hand. Ctrl-C already means "abort my own run" here; it must not also mean
558
559
  * "give up waiting for someone else's turn".
559
560
  *
561
+ * An Op the steward runs beside its turns (#2861) takes the Op's own lease
562
+ * instead, renewed for as long as the run lasts, and never the turn: the run
563
+ * is one at a time with the runs the operator starts of it, and holds none
564
+ * of the steward's other Ops up. Held already, it refuses at once, naming
565
+ * the holder.
566
+ *
560
567
  * `undefined` (via the caller checking `owner`) when no steward lists this
561
568
  * op — the ordinary, untouched path.
562
569
  */
@@ -585,6 +592,8 @@ async function stewardTurnGate(opName: string, ctx: CommandContext): Promise<Ste
585
592
  };
586
593
  }
587
594
 
595
+ if (stewardBesideFor(owner, opName)) return besideLeaseGate(opName, owner.name, ctx);
596
+
588
597
  const holder = currentHolderId();
589
598
  let turn: AcquireLeaseResult;
590
599
  try {
@@ -619,6 +628,36 @@ async function stewardTurnGate(opName: string, ctx: CommandContext): Promise<Ste
619
628
  return { ok: true, release: async () => { await releaseLease(stewardTurnLeaseName(stewardName), holder, lease.token).catch(() => false); } };
620
629
  }
621
630
 
631
+ /**
632
+ * The lease a `chant run` of an Op beside a steward's turns holds (#2861):
633
+ * the Op's own, as `--holder` (the operator passes `<steward>/<op>@<its
634
+ * holder>`) or this process.
635
+ */
636
+ async function besideLeaseGate(opName: string, steward: string, ctx: CommandContext): Promise<StewardTurnGate> {
637
+ const holder = ctx.args.holder ?? currentHolderId();
638
+ let held;
639
+ try {
640
+ held = await holdBesideLease(opName, holder);
641
+ } catch (err) {
642
+ return {
643
+ ok: false,
644
+ message: `Op "${opName}" runs beside steward "${steward}"'s turns, and its lease could not be read`,
645
+ hint:
646
+ `${err instanceof StaleLockError ? err.message : err instanceof Error ? err.message : String(err)} ` +
647
+ `This can happen when a process was killed mid-write; check refs/chant/lease/${opName} for a stale lock.`,
648
+ };
649
+ }
650
+ if (!held.acquired) {
651
+ return {
652
+ ok: false,
653
+ message: `Op "${opName}" runs beside steward "${steward}"'s turns, one run at a time, and a run of it is in progress` +
654
+ (held.heldBy ? ` (held by ${held.heldBy})` : ""),
655
+ hint: `Wait for that run to end and re-run \`chant run ${opName}\`; \`chant workspace status --json\` lists it under the steward. Its lease expires on its own if the process holding it died.`,
656
+ };
657
+ }
658
+ return { ok: true, release: held.release };
659
+ }
660
+
622
661
  export async function runOp(ctx: CommandContext): Promise<number> {
623
662
  if (ctx.args.generate) {
624
663
  if (ctx.args.components) {
@@ -975,20 +1014,35 @@ export async function runOpOnRuntime(ctx: CommandContext): Promise<number> {
975
1014
  // runs under; it is taken where the run executes, which for a hosted
976
1015
  // runtime is not this process.
977
1016
  const work = await resolveRunWork(ctx, config, runtime.name);
978
- if (work === null) return 1;
1017
+ if (work === null) {
1018
+ await stewardGate?.release?.();
1019
+ return 1;
1020
+ }
979
1021
 
980
1022
  // `--progress-json` streams one NDJSON StepRecord per settled step, fed by
981
1023
  // whatever the runtime reports through `progress`.
982
1024
  const progress = ctx.args.progressJson ? ndjsonProgressSink<StepRecord>() : undefined;
983
1025
 
984
- // Ctrl-C aborts in-flight activities (kills their child processes) instead of
985
- // orphaning them. The handler is removed in `finally` so it never leaks.
1026
+ // Ctrl-C, or a SIGTERM (the signal a steward's operator sends this process
1027
+ // when it runs beside the steward's turns and the operator itself stops,
1028
+ // and what a plain `kill <pid>` sends with no signal named), aborts
1029
+ // in-flight activities (kills their child processes) instead of orphaning
1030
+ // them. `process.on`, not `.once` (chant#2872's shape, here too): a second
1031
+ // signal arriving before the `finally` below has released this run's lease
1032
+ // (`stewardGate`, for a beside run) must not fall through to Node's default
1033
+ // disposition and skip that release. `stopping` makes a repeat a no-op; the
1034
+ // listeners are removed only after the release, at the very end of
1035
+ // `finally`, not before it.
986
1036
  const controller = new AbortController();
1037
+ let stopping = false;
987
1038
  const onSigint = () => {
1039
+ if (stopping) return;
1040
+ stopping = true;
988
1041
  console.error(formatWarning({ message: "interrupted — stopping Op" }));
989
1042
  controller.abort();
990
1043
  };
991
- process.once("SIGINT", onSigint);
1044
+ process.on("SIGINT", onSigint);
1045
+ process.on("SIGTERM", onSigint);
992
1046
 
993
1047
  try {
994
1048
  const handle = await runtime.start(config, {
@@ -1065,10 +1119,14 @@ export async function runOpOnRuntime(ctx: CommandContext): Promise<number> {
1065
1119
  console.error(formatError({ message: err instanceof Error ? err.message : String(err) }));
1066
1120
  return 1;
1067
1121
  } finally {
1068
- process.removeListener("SIGINT", onSigint);
1069
1122
  // The turn is over, win or lose — free it so the steward's next round,
1070
- // or the next hand run, doesn't wait out this run's own TTL (#2750).
1123
+ // or the next hand run, doesn't wait out this run's own TTL (#2750). The
1124
+ // listeners stay registered until after this release (chant#2872): it is
1125
+ // itself async, and removing them first would let a repeat signal in
1126
+ // that window fall through to Node's default disposition and skip it.
1071
1127
  await stewardGate?.release?.();
1128
+ process.removeListener("SIGINT", onSigint);
1129
+ process.removeListener("SIGTERM", onSigint);
1072
1130
  }
1073
1131
  }
1074
1132
 
package/src/cli/main.ts CHANGED
@@ -90,6 +90,7 @@ const BOOLEAN_FLAGS = new Set([
90
90
  "--no-release-record",
91
91
  "--fold",
92
92
  "--no-fold",
93
+ "--no-cache",
93
94
  "--sandbox",
94
95
  "--yes",
95
96
  "--confirm-prod",
@@ -481,6 +482,9 @@ export function parseArgs(args: string[]): ParsedArgs {
481
482
  result.noReleaseRecord = true;
482
483
  } else if (arg === "--fold") {
483
484
  result.fold = true;
485
+ } else if (arg === "--no-cache") {
486
+ // `chant workspace graph --no-cache` (#2876): read every member.
487
+ result.noCache = true;
484
488
  } else if (arg === "--no-fold") {
485
489
  // chant #1134 — fold is the default build path; this is the explicit
486
490
  // opt-out, and like --fold it beats chant.config.ts's build.fold.
@@ -748,7 +752,9 @@ Ops:
748
752
  CronJob invokers use this instead of the daemon).
749
753
  --steward [<name>] runs a declared steward's local
750
754
  form instead (#2731): its scheduled Ops on their
751
- crons, under the steward's own lease
755
+ crons, under the steward's own lease, and its
756
+ beside Ops as chant run processes of their own
757
+ (#2861)
752
758
  operator status Last tick, outcomes, and pending gates per
753
759
  ConvergeOp, read from the chant/lifecycle orphan
754
760
  branch alone — no daemon needs to be running
@@ -841,10 +847,12 @@ Workspace (level 1, #2524):
841
847
  Set top-level fields of one record. A closed record
842
848
  never changes, and an approved one changes only its
843
849
  state (upward), pins and reviews; anything else is
844
- refused with amend-supersede-instead. --sign seals
845
- the author again; without it an amendment removes
846
- the author seal and says so. Prints
847
- {path, id, changed}
850
+ refused with amend-supersede-instead. Moving a
851
+ record to its kind's ratified state (ratified for
852
+ decisions) is refused with ratify-quorum-not-met
853
+ until its quorum is met. --sign seals the author
854
+ again; without it an amendment removes the author
855
+ seal and says so. Prints {path, id, changed}
848
856
  workspace records review <id> [--kind <kind file>] --verdict agree|dissent|abstain --by <principal> [--note <text>] [--session <id>] [--sign [<key file>]] [--dry-run]
849
857
  Append a review to one record, dated and bound to
850
858
  the digest of the record text. A dissent needs
@@ -931,7 +939,10 @@ Workspace (level 1, #2524):
931
939
  with <member>/<id> ids and groups.byMember: the
932
940
  read-contract document. --at <rev> runs each member's
933
941
  source as it was at that commit; --kind adds the
934
- records' asset and constrains links
942
+ records' asset and constrains links. A member whose
943
+ source and toolchain are unchanged is served from
944
+ the cache in $CHANT_CACHE_DIR or ~/.cache/chant;
945
+ --no-cache reads every member
935
946
  workspace graph --composites [--at <rev>] [--member <name>] [-o <file>]
936
947
  Each composite instance the members declare, with the
937
948
  components whose contract can deploy it; an instance
@@ -1284,9 +1295,10 @@ async function loadPluginsOrExit(path: string): Promise<import("../lexicon").Lex
1284
1295
  * #2700 — whether `path` is the root of a declared workspace and holds no
1285
1296
  * lexicon of its own: no `lexicons` in a root config, and no lexicon import in
1286
1297
  * the root's source, which leaves the members' directories out (#2527). A
1287
- * generated chud repo is this shape: its lexicons are all in `delivery/`.
1288
- * `chant serve mcp` starts there instead of refusing with "No lexicon
1289
- * detected", and serves core with the chant members' lexicons.
1298
+ * generated app repo can be this shape: its lexicons are all under a
1299
+ * delivery directory, not the root. `chant serve mcp` starts there instead of
1300
+ * refusing with "No lexicon detected", and serves core with the chant
1301
+ * members' lexicons.
1290
1302
  *
1291
1303
  * A directory with no workspace declaration costs only `findWorkspaceRoot`'s
1292
1304
  * existence checks, so a level-0 project reaches `loadPluginsOrExit` as before.
@@ -2,12 +2,12 @@
2
2
  * #2700 — the lexicons `chant serve mcp` loads at the root of a declared
3
3
  * workspace that has no lexicon of its own.
4
4
  *
5
- * Such a root (a generated chud repo, say, whose lexicons are all in
6
- * `delivery/`) used to refuse with "No lexicon detected", so an agent started
7
- * there got none of chant's tools. The server now starts with core's tools and
8
- * resources, and with the lexicons of the workspace's members of kind chant,
9
- * each read from that member's own config the way `chant build` in the
10
- * member's directory reads it.
5
+ * Such a root (a generated app repo, say, whose lexicons are all under a
6
+ * delivery directory) used to refuse with "No lexicon detected", so an agent
7
+ * started there got none of chant's tools. The server now starts with core's
8
+ * tools and resources, and with the lexicons of the workspace's members of
9
+ * kind chant, each read from that member's own config the way `chant build`
10
+ * in the member's directory reads it.
11
11
  *
12
12
  * Loading is best effort, one member and one lexicon at a time: a member whose
13
13
  * config does not load, or a lexicon this chant cannot import, is left out and
@@ -195,7 +195,7 @@ export const workspaceWriteTools: ToolDefinition[] = [
195
195
  {
196
196
  name: "records-amend",
197
197
  description:
198
- "Set top-level fields of a record: chant workspace records amend. A closed record never changes, and an approved one changes only in its state, evidence and reviews: anything else, its reasoning included, is a new record that supersedes it. A source block given in the fields records that the change came through MCP. " +
198
+ "Set top-level fields of a record: chant workspace records amend. A closed record never changes, and an approved one changes only in its state, evidence and reviews: anything else, its reasoning included, is a new record that supersedes it. A record moves to its kind's ratified state only once its review quorum is met (ratify-quorum-not-met). A source block given in the fields records that the change came through MCP. " +
199
199
  PROTOCOL,
200
200
  inputSchema: {
201
201
  type: "object",
@@ -299,6 +299,8 @@ export interface ParsedArgs {
299
299
  subject?: string;
300
300
  /** `chant workspace points answer <id> --answer <value>` (#2739): the people's answer. */
301
301
  answer?: string;
302
+ /** `chant workspace graph --no-cache` (#2876): read every member, bypassing the per-member cache. */
303
+ noCache?: boolean;
302
304
  /** `chant workspace graph --composites` (#2662): print each composite instance with the components that can deploy it. */
303
305
  composites?: boolean;
304
306
  /** `chant workspace graph --intent <path[:start-end]>` (#2651): the region the intent graph is over. */
@@ -73,8 +73,8 @@ describe("chant serve mcp at a workspace root with no lexicon of its own (#2700)
73
73
  expect(init.instructions).toContain("Member delivery (delivery/) declares docker.");
74
74
  }, TIMEOUT);
75
75
 
76
- test("a chud-shaped root, lexicons only in a member, serves core and the lexicons that load", () => {
77
- const root = join(scratch, "chud-shaped");
76
+ test("a lexiconless root, lexicons only in a member, serves core and the lexicons that load", () => {
77
+ const root = join(scratch, "lexiconless-shaped");
78
78
  mkdirSync(join(root, ".git"), { recursive: true });
79
79
  mkdirSync(join(root, "delivery"), { recursive: true });
80
80
  mkdirSync(join(root, "app"), { recursive: true });
@@ -87,9 +87,9 @@ describe("chant serve mcp at a workspace root with no lexicon of its own (#2700)
87
87
  ],
88
88
  pins: [],
89
89
  }));
90
- // `chud` is not installed here, as it would not be for a chant without
91
- // @intentius/chud: it is named as not loaded and the rest is served.
92
- writeFileSync(join(root, "delivery", "chant.config.ts"), 'export default { lexicons: ["docker", "chud"] };\n');
90
+ // `acme` names a lexicon package that isn't installed here: it is named
91
+ // as not loaded and the rest is served.
92
+ writeFileSync(join(root, "delivery", "chant.config.ts"), 'export default { lexicons: ["docker", "acme"] };\n');
93
93
  writeFileSync(join(root, "app", "server.js"), "// not chant\n");
94
94
 
95
95
  const { status, stderr, init, tools } = initializeAndList(root);
@@ -97,7 +97,7 @@ describe("chant serve mcp at a workspace root with no lexicon of its own (#2700)
97
97
  for (const name of CORE_TOOLS) expect(tools).toContain(name);
98
98
  expect(tools).toContain("docker:diff");
99
99
  expect(init.instructions).toContain('workspace "t"');
100
- expect(init.instructions).toContain("Lexicon chud did not load");
100
+ expect(init.instructions).toContain("Lexicon acme did not load");
101
101
  expect(init.instructions).toContain("Lexicon tools and resources served: docker.");
102
102
  }, TIMEOUT);
103
103
 
@@ -72,7 +72,9 @@ afterEach(() => {
72
72
  function chant(args: string[], cwd: string): string {
73
73
  const result = spawnSync(process.execPath, ["--import", tsxLoader, mainTs, ...args], {
74
74
  cwd,
75
- env: { PATH: process.env.PATH ?? "", HOME: home },
75
+ // TMPDIR passes through so tsx caches under the run's temp directory
76
+ // (chant#2864), not the machine-wide default this scrubbed env would give.
77
+ env: { PATH: process.env.PATH ?? "", HOME: home, TMPDIR: tmpdir() },
76
78
  encoding: "utf-8",
77
79
  input: "n\n",
78
80
  timeout: 60_000,
@@ -105,7 +107,11 @@ describe("commands that read path lexicons statically run no project code", () =
105
107
 
106
108
  test("a config it cannot read is reported, and still not run", () => {
107
109
  writeConfig(`process.env.CI ? ["github"] : [{ name: "github", module: "./gh/index.ts" }]`);
108
- const output = chant(["dev", "onboard", "github"], project);
110
+ // `import --agents` rather than `dev onboard`: with the config unread,
111
+ // github is treated as a package, and onboard then patches the chant
112
+ // repo it runs from (its CI workflow, chant#2863). Both commands print
113
+ // the notice from the same recordProjectLexicons read.
114
+ const output = chant(["import", "--agents", "--lexicon", "github", "--output", join(root, "out")], project);
109
115
  expect(output).toContain("could not read the lexicons in");
110
116
  expect(output).toContain("treated as a package");
111
117
  expect(existsSync(marker)).toBe(false);
@@ -27,11 +27,28 @@ import { fileURLToPath } from "node:url";
27
27
  */
28
28
  const SRC = fileURLToPath(new URL("../", import.meta.url));
29
29
 
30
+ /**
31
+ * Run `read`, or return undefined when the path is gone. Other test files
32
+ * create and delete fixtures under src while this one walks it
33
+ * (lint/config.test.ts's `__test_config__`, chant#2870), so an entry listed a
34
+ * moment ago may no longer exist.
35
+ */
36
+ function unlessGone<T>(read: () => T): T | undefined {
37
+ try {
38
+ return read();
39
+ } catch (err) {
40
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") return undefined;
41
+ throw err;
42
+ }
43
+ }
44
+
30
45
  /** Every TypeScript file under core's src, tests and fixtures included. */
31
46
  function sourceFiles(dir: string): string[] {
32
- return readdirSync(dir).flatMap((entry) => {
47
+ return (unlessGone(() => readdirSync(dir)) ?? []).flatMap((entry) => {
33
48
  const path = join(dir, entry);
34
- if (statSync(path).isDirectory()) return entry === "node_modules" ? [] : sourceFiles(path);
49
+ const stat = unlessGone(() => statSync(path));
50
+ if (stat === undefined) return [];
51
+ if (stat.isDirectory()) return entry === "node_modules" ? [] : sourceFiles(path);
35
52
  return /\.(ts|mts|cts)$/.test(entry) ? [path] : [];
36
53
  });
37
54
  }
@@ -40,7 +57,8 @@ describe("source files are text, not binary (chant#2280)", () => {
40
57
  test("no shipped source file contains a NUL byte", () => {
41
58
  const offenders: string[] = [];
42
59
  for (const file of sourceFiles(SRC)) {
43
- const buf = readFileSync(file);
60
+ const buf = unlessGone(() => readFileSync(file));
61
+ if (buf === undefined) continue;
44
62
  const at = buf.indexOf(0);
45
63
  if (at !== -1) offenders.push(`${relative(SRC, file)} (first at byte ${at})`);
46
64
  }
package/src/okf.test.ts CHANGED
@@ -155,7 +155,12 @@ const exampleCases: Array<[string, string]> = [
155
155
 
156
156
  describe("buildOkfBundle over shipped examples", () => {
157
157
  for (const [name, rel] of exampleCases) {
158
- test(`${name} emits a conformant, snapshot-stable bundle`, async () => {
158
+ // The test's own `expect` names the snapshot after this test. The global
159
+ // one names it after whichever test is current, so when a slow discover
160
+ // outlives its timeout and the next case starts, a late call records
161
+ // under the next case's name and the checkout gains a "... 2" snapshot
162
+ // (chant#2863).
163
+ test(`${name} emits a conformant, snapshot-stable bundle`, async ({ expect }) => {
159
164
  const projectPath = resolve(repoRoot, rel);
160
165
  const result = await discover(projectPath);
161
166
  expect(result.errors).toEqual([]);
@@ -65,6 +65,14 @@ afterAll(async () => {
65
65
  });
66
66
 
67
67
  describe("decide against the stub (#2740)", () => {
68
+ test("points --json carries each point's criteria, as the points file declares it (#2853)", async () => {
69
+ const doc = await workspacePoints({ cwd: root });
70
+ if ("error" in doc) throw new Error(doc.error.message);
71
+ expect(doc.points.find((p) => p.name === "triage")?.criteria).toEqual({ true: "A person looks at it today.", false: "It can wait." });
72
+ expect(doc.points.find((p) => p.name === "route")?.criteria).toEqual({ platform: "The platform team.", app: "The app team.", docs: "The docs team." });
73
+ expect(doc.points.find((p) => p.name === "effort")?.criteria).toEqual(["low", "medium", "high"]);
74
+ });
75
+
68
76
  test("a noul: the model's answer at the threshold is recorded as proposed, and the run waits on it", async () => {
69
77
  script = { triage: { type: "noul", noul: 0.91 } };
70
78
  const w = await waitsOn(ask({ point: "triage", inputs: { "record.size": 5, "record.risky": true }, subject: "T-1" }));
@@ -916,10 +916,21 @@ export const spriteApplyNetworkPolicy = (args: {
916
916
  return activity("spriteApplyNetworkPolicy", rest, profile ?? "fastIdempotent");
917
917
  };
918
918
 
919
- /** Reconcile a sprite's background services (create-or-update, optionally start). Defaults to the `fastIdempotent` profile (override via `profile`). */
919
+ /**
920
+ * Reconcile a sprite's background services (create-or-update, optionally
921
+ * start). With an `id`, through the Sprites API and its `services`; without
922
+ * one, inside the sprite through sprite-env, applying the box block's
923
+ * services (`box: true`, #2880): `only` names some of them, `start` starts
924
+ * the applied ones that are not running, `restart` restarts the converged
925
+ * ones. Defaults to the `fastIdempotent` profile (override via `profile`).
926
+ */
920
927
  export const spriteApplyServices = (args: {
921
- id: string;
922
- services: Array<{
928
+ id?: string;
929
+ box?: boolean;
930
+ only?: string[];
931
+ restart?: boolean;
932
+ spriteEnv?: string;
933
+ services?: Array<{
923
934
  name: string;
924
935
  cmd: string;
925
936
  args?: string[];
package/src/op/index.ts CHANGED
@@ -109,15 +109,21 @@ export type {
109
109
  export {
110
110
  discoverConvergeOps, runOperatorRound, runOperatorForever, formatRoundLine,
111
111
  formatSignalLine, DEFAULT_OPERATOR_INTERVAL_MS, acquireStewardLease,
112
- acquireStewardTurn, STEWARD_TURN_WAIT_MS,
112
+ acquireStewardTurn, STEWARD_TURN_WAIT_MS, createBesideState, waitForBesideRuns, stopBesideRuns,
113
113
  } from "./operator";
114
+ export type { BesideState } from "./operator";
115
+ export {
116
+ spawnBesideRun, inProcessBesideLauncher, holdBesideLease, askReady, DEFAULT_READY_TIMEOUT_MS,
117
+ } from "./steward-beside";
118
+ export type { BesideStart, BesideExit, BesideHandle, BesideLauncher, BesideWhy, HeldBesideLease, ReadyAnswer } from "./steward-beside";
114
119
  export {
115
120
  declareSteward, isStewardDeclaration, stewardFormFor, stewardOpConfig, normaliseStewardForm, stewardLeaseName,
116
- stewardTurnLeaseName,
121
+ stewardTurnLeaseName, stewardBesideOf, stewardBesideFor, stewardTurnOps, readinessKeys,
117
122
  STEWARD_KIND, STEWARD_FORMS, STEWARD_NAME_PATTERN, DEFAULT_STEWARD_ENV,
118
123
  } from "./steward";
119
124
  export type {
120
125
  StewardDeclaration, StewardDeclarationConfig, StewardForm, StewardFormSpec, StewardOpInput,
126
+ StewardBeside, StewardBesideInput,
121
127
  } from "./steward";
122
128
  export { discoverStewards } from "./discover";
123
129
  export {