@intentius/chant 0.90.0 → 0.91.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 (164) hide show
  1. package/dist/cli/handlers/components.d.ts +8 -0
  2. package/dist/cli/handlers/components.d.ts.map +1 -1
  3. package/dist/cli/handlers/operator.d.ts +14 -0
  4. package/dist/cli/handlers/operator.d.ts.map +1 -1
  5. package/dist/cli/handlers/run.d.ts.map +1 -1
  6. package/dist/cli/main.d.ts.map +1 -1
  7. package/dist/cli/mcp/workspace-tools.d.ts +6 -4
  8. package/dist/cli/mcp/workspace-tools.d.ts.map +1 -1
  9. package/dist/cli/registry.d.ts +31 -0
  10. package/dist/cli/registry.d.ts.map +1 -1
  11. package/dist/components/verbs/vuln-scan.d.ts +72 -0
  12. package/dist/components/verbs/vuln-scan.d.ts.map +1 -1
  13. package/dist/lifecycle/git.d.ts +40 -5
  14. package/dist/lifecycle/git.d.ts.map +1 -1
  15. package/dist/lifecycle/lease.d.ts +72 -16
  16. package/dist/lifecycle/lease.d.ts.map +1 -1
  17. package/dist/lifecycle/member-ledger.d.ts +3 -2
  18. package/dist/lifecycle/member-ledger.d.ts.map +1 -1
  19. package/dist/lifecycle/plan-ledger.d.ts +114 -0
  20. package/dist/lifecycle/plan-ledger.d.ts.map +1 -0
  21. package/dist/lifecycle/work-lease.d.ts +140 -0
  22. package/dist/lifecycle/work-lease.d.ts.map +1 -0
  23. package/dist/op/activities/activity-contracts.d.ts +2 -2
  24. package/dist/op/builders.d.ts.map +1 -1
  25. package/dist/op/discover.d.ts +25 -0
  26. package/dist/op/discover.d.ts.map +1 -1
  27. package/dist/op/index.d.ts +9 -3
  28. package/dist/op/index.d.ts.map +1 -1
  29. package/dist/op/lifecycle-receipt-store.d.ts +34 -0
  30. package/dist/op/lifecycle-receipt-store.d.ts.map +1 -0
  31. package/dist/op/local-executor.d.ts +19 -0
  32. package/dist/op/local-executor.d.ts.map +1 -1
  33. package/dist/op/local-output.d.ts.map +1 -1
  34. package/dist/op/op-ir.d.ts +10 -1
  35. package/dist/op/op-ir.d.ts.map +1 -1
  36. package/dist/op/operator.d.ts +29 -0
  37. package/dist/op/operator.d.ts.map +1 -1
  38. package/dist/op/runtime.d.ts +9 -0
  39. package/dist/op/runtime.d.ts.map +1 -1
  40. package/dist/op/runtimes/local.d.ts.map +1 -1
  41. package/dist/op/step-output-ref.d.ts +2 -2
  42. package/dist/op/step-output-ref.d.ts.map +1 -1
  43. package/dist/op/steward.d.ts +140 -0
  44. package/dist/op/steward.d.ts.map +1 -0
  45. package/dist/op/types.d.ts +51 -0
  46. package/dist/op/types.d.ts.map +1 -1
  47. package/dist/op/work-lease-decl.d.ts +18 -0
  48. package/dist/op/work-lease-decl.d.ts.map +1 -0
  49. package/dist/op/work-lease-run.d.ts +173 -0
  50. package/dist/op/work-lease-run.d.ts.map +1 -0
  51. package/dist/workspace/box-isolation.d.ts +99 -0
  52. package/dist/workspace/box-isolation.d.ts.map +1 -0
  53. package/dist/workspace/checks/box-isolation.d.ts +18 -0
  54. package/dist/workspace/checks/box-isolation.d.ts.map +1 -0
  55. package/dist/workspace/checks/boxes.d.ts +71 -0
  56. package/dist/workspace/checks/boxes.d.ts.map +1 -0
  57. package/dist/workspace/checks/records.d.ts +1 -0
  58. package/dist/workspace/checks/records.d.ts.map +1 -1
  59. package/dist/workspace/checks.d.ts +10 -1
  60. package/dist/workspace/checks.d.ts.map +1 -1
  61. package/dist/workspace/decide.d.ts +184 -0
  62. package/dist/workspace/decide.d.ts.map +1 -0
  63. package/dist/workspace/decision-points.schema.json +137 -0
  64. package/dist/workspace/declaration.d.ts +51 -0
  65. package/dist/workspace/declaration.d.ts.map +1 -1
  66. package/dist/workspace/declaration.schema.json +172 -0
  67. package/dist/workspace/declared-kinds.d.ts +12 -0
  68. package/dist/workspace/declared-kinds.d.ts.map +1 -1
  69. package/dist/workspace/points-cli.d.ts +113 -0
  70. package/dist/workspace/points-cli.d.ts.map +1 -0
  71. package/dist/workspace/points.d.ts +320 -0
  72. package/dist/workspace/points.d.ts.map +1 -0
  73. package/dist/workspace/reason-codes.d.ts +26 -0
  74. package/dist/workspace/reason-codes.d.ts.map +1 -1
  75. package/dist/workspace/record-assets.d.ts.map +1 -1
  76. package/dist/workspace/records-cli.d.ts +12 -0
  77. package/dist/workspace/records-cli.d.ts.map +1 -1
  78. package/dist/workspace/records.d.ts +8 -2
  79. package/dist/workspace/records.d.ts.map +1 -1
  80. package/dist/workspace/status-stewards.d.ts +121 -0
  81. package/dist/workspace/status-stewards.d.ts.map +1 -0
  82. package/dist/workspace/status.d.ts +52 -1
  83. package/dist/workspace/status.d.ts.map +1 -1
  84. package/dist/workspace/work-cli.d.ts +78 -0
  85. package/dist/workspace/work-cli.d.ts.map +1 -0
  86. package/package.json +1 -1
  87. package/src/cli/handlers/components.test.ts +93 -0
  88. package/src/cli/handlers/components.ts +44 -3
  89. package/src/cli/handlers/operator.ts +107 -2
  90. package/src/cli/handlers/run.test.ts +19 -0
  91. package/src/cli/handlers/run.ts +53 -1
  92. package/src/cli/main.test.ts +40 -0
  93. package/src/cli/main.ts +78 -2
  94. package/src/cli/mcp/workspace-tools.test.ts +14 -1
  95. package/src/cli/mcp/workspace-tools.ts +49 -5
  96. package/src/cli/registry.ts +31 -0
  97. package/src/components/verbs/vuln-scan.test.ts +124 -1
  98. package/src/components/verbs/vuln-scan.ts +142 -1
  99. package/src/lifecycle/git.ts +65 -15
  100. package/src/lifecycle/lease.test.ts +22 -0
  101. package/src/lifecycle/lease.ts +133 -29
  102. package/src/lifecycle/member-ledger.ts +3 -2
  103. package/src/lifecycle/plan-ledger.test.ts +148 -0
  104. package/src/lifecycle/plan-ledger.ts +158 -0
  105. package/src/lifecycle/work-lease.test.ts +236 -0
  106. package/src/lifecycle/work-lease.ts +426 -0
  107. package/src/op/builders.ts +5 -0
  108. package/src/op/discover.ts +71 -0
  109. package/src/op/index.ts +16 -3
  110. package/src/op/lifecycle-receipt-store.test.ts +60 -0
  111. package/src/op/lifecycle-receipt-store.ts +61 -0
  112. package/src/op/local-executor.ts +216 -18
  113. package/src/op/local-output.ts +13 -0
  114. package/src/op/op-ir.ts +14 -0
  115. package/src/op/operator.ts +75 -4
  116. package/src/op/runtime.ts +6 -0
  117. package/src/op/runtimes/local.ts +3 -0
  118. package/src/op/step-output-ref.ts +6 -2
  119. package/src/op/steward.test.ts +212 -0
  120. package/src/op/steward.ts +253 -0
  121. package/src/op/types.ts +53 -0
  122. package/src/op/work-lease-decl.ts +80 -0
  123. package/src/op/work-lease-run.test.ts +326 -0
  124. package/src/op/work-lease-run.ts +395 -0
  125. package/src/workspace/box-isolation.test.ts +261 -0
  126. package/src/workspace/box-isolation.ts +205 -0
  127. package/src/workspace/check-contract.test.ts +3 -1
  128. package/src/workspace/check.schema.json +15 -7
  129. package/src/workspace/checks/box-isolation.ts +68 -0
  130. package/src/workspace/checks/boxes.test.ts +197 -0
  131. package/src/workspace/checks/boxes.ts +307 -0
  132. package/src/workspace/checks/records.ts +25 -0
  133. package/src/workspace/checks.test.ts +7 -0
  134. package/src/workspace/checks.ts +19 -2
  135. package/src/workspace/decide.test.ts +224 -0
  136. package/src/workspace/decide.ts +576 -0
  137. package/src/workspace/decision-points.schema.json +137 -0
  138. package/src/workspace/declaration.schema.json +172 -0
  139. package/src/workspace/declaration.ts +137 -0
  140. package/src/workspace/declared-kinds.ts +25 -2
  141. package/src/workspace/intent.schema.json +4 -1
  142. package/src/workspace/point-answer.schema.json +95 -0
  143. package/src/workspace/points-cli.ts +273 -0
  144. package/src/workspace/points-write.schema.json +489 -0
  145. package/src/workspace/points.schema.json +710 -0
  146. package/src/workspace/points.test.ts +264 -0
  147. package/src/workspace/points.ts +564 -0
  148. package/src/workspace/read-contract.test.ts +18 -1
  149. package/src/workspace/reason-codes.test.ts +14 -1
  150. package/src/workspace/reason-codes.ts +33 -0
  151. package/src/workspace/record-assets.test.ts +3 -2
  152. package/src/workspace/record-assets.ts +4 -1
  153. package/src/workspace/records-cli.ts +15 -2
  154. package/src/workspace/records-contract.test.ts +3 -2
  155. package/src/workspace/records.schema.json +17 -0
  156. package/src/workspace/records.ts +33 -3
  157. package/src/workspace/status-contract.test.ts +178 -0
  158. package/src/workspace/status-stewards.ts +225 -0
  159. package/src/workspace/status.schema.json +230 -4
  160. package/src/workspace/status.ts +106 -5
  161. package/src/workspace/work-cli.test.ts +180 -0
  162. package/src/workspace/work-cli.ts +246 -0
  163. package/src/workspace/work-lease.schema.json +233 -0
  164. package/src/workspace/work-readiness-chud.test.ts +145 -0
@@ -57,6 +57,10 @@ export const REASONS = {
57
57
  "gates-no-ledger": "The checkout has no chant/lifecycle branch, so there is no gate ledger to read.",
58
58
  "gates-no-gate-ledger": "The branch has no gate ledger for the member: no run of it has reached a gate.",
59
59
  "gates-ledger-unreadable": "Reading the member's gate ledger failed, so no gate is listed.",
60
+ // A member's stewards status can't fully list (status, #2731).
61
+ "stewards-unreadable": "An *.op.ts file could not be imported, so a steward it declares may be missing.",
62
+ "stewards-conflict": "A steward was dropped: its name, or an Op it lists, belongs to another steward.",
63
+ "steward-runs-unreadable": "Reading an Op's run ledger failed, so its last run is null.",
60
64
  // A record that isn't valid (records).
61
65
  "record-unparseable": "No front matter, a YAML error or a value outside the JSON subset of YAML, or for a JSON kind a file that is not one object or repeats a member name.",
62
66
  "record-schema-invalid": "The record's front matter, or its JSON object, does not match the kind's schema.",
@@ -82,6 +86,10 @@ export const REASONS = {
82
86
  "work-done-unpinned": "A work record is done and its evidence list is empty: nothing shows the work was done.",
83
87
  "work-closed-without-date": "A work record is done or dropped and has no closing date.",
84
88
  "work-done-gap-open": "A work record is done, and the finding it came from still fires on its region. graph --intent raises it, and records does by walking that region.",
89
+ // An answer to a decision point that is valid but warned about (records, points, ws-058, #2739).
90
+ "answer-points-unreadable": "The points file the answer kind names can't be read, or is not valid.",
91
+ "answer-point-unknown": "The answer's point is not declared in the points file the answer kind names.",
92
+ "answer-point-changed": "The point's declaration changed since the question was asked, so the answer is to an older version of the question.",
85
93
  // A verdict the quorum does not count (records, #2671, #2672).
86
94
  "review-decider": "The verdict is the decider's own, and the quorum counts verdicts besides the decider's.",
87
95
  "review-agent": "The reviewer holds the agent role in the trust policy at base.",
@@ -121,6 +129,14 @@ export const REASONS = {
121
129
  // A review given in a session (records review --session, #2693).
122
130
  "session-unknown": "--session names no session of a session kind whose subjects are the record's kind.",
123
131
  "session-not-open": "--session names a session in a closed state, which takes no more verdicts.",
132
+ // Decision points (points, points ask, points answer, ws-058, #2739).
133
+ "points-undeclared": "No record kind with an answers block is declared, or given with --kind, so there is no points file to ask.",
134
+ "points-invalid": "The points file an answer kind names can't be read, or does not match decision-points.schema.json and the rules checked in code.",
135
+ "point-unknown": "No points file declares the point asked, or the one an answer names.",
136
+ "point-inputs-invalid": "The inputs given to an ask are not a JSON object of the point's declared inputs.",
137
+ "point-decider-failed": "A model decider that fails closed (unreachable: fail) could not answer, so the ask wrote nothing.",
138
+ "answer-not-candidate": "The people's answer is not one of the question's candidates.",
139
+ "quorum-not-met": "Too few of the people who answered count toward the point's quorum: distinct, not holding the agent role, and holding one of its roles when it names any.",
124
140
  // records --since that fails (#2673).
125
141
  "since-rev-unknown": "--since names no commit, or a session with no opening revision and no commit that added it.",
126
142
  "since-session-unknown": "--since has the shape of a session id and names no commit, and no session the kind or the declaration reads has that id.",
@@ -159,6 +175,23 @@ export const REASONS = {
159
175
  "environments-none-declared": "The member's chant.config.ts declares no environments, so only local and the environments in its ledger are listed.",
160
176
  "environments-ledger-undeclared": "The member's ledger has releases in an environment its config's environments don't cover, so chant run --env would refuse it and it is not listed.",
161
177
  "environments-ledger-unreadable": "The chant/lifecycle branch exists and the member's ledger environments could not be listed.",
178
+ // A box that holds a credential (check, #2726). Each is also a WSP finding.
179
+ "box-credential-declared": "A file in a box member's directory carries a literal secret: a credential's shape, or a literal where a credential goes. A variable or secret-manager reference is not one.",
180
+ "box-capability-unbrokered": "A capability in a member's box block names no broker, so the box would hold its credential.",
181
+ // The work lease (chant workspace work claim|renew|release, #2732): why the command could not run.
182
+ "work-kind-missing": "No work kind to find the item in: --kind names a kind with no work block, or the declaration names no work kind.",
183
+ "work-kind-ambiguous": "More than one declared work kind has a record with the id, so --kind must name one.",
184
+ "work-item-unknown": "No work record has the id, so there is nothing to lease.",
185
+ "work-item-closed": "A claim on a work item in a closed state, such as done or dropped: there is no work left to claim.",
186
+ // The work lease: why a claim, renew or release was refused (exit 2).
187
+ "lease-held": "Someone holds a live lease on the work item: another worker, or, for a claim, the same one.",
188
+ "lease-not-held": "Nobody holds a live lease on the work item: it expired, was released or was never claimed.",
189
+ "lease-token-mismatch": "The live lease on the work item carries another fencing token than the one given.",
190
+ "lease-race": "Another writer changed the lease between this command's read and its write.",
191
+ "lease-push-rejected": "The remote refused the lease push: another clone claimed the item first, or the remote could not be reached.",
192
+ // A box whose isolation fails (check, #2727). Each is also a WSP finding.
193
+ "box-isolation-collision": "Two boxes on one host resolve to the same port, state path or cookie name, or two ports in one box share an offset.",
194
+ "box-isolation-literal": "A host's stateRoot or a box's state entry is a literal machine path instead of one derived from an environment reference and the box's name.",
162
195
  // The lineage lock (check).
163
196
  "lock-invalid": "The lineage lock can't be read.",
164
197
  "manual-step-open": "A scope in the lineage lock has an open manual step.",
@@ -18,6 +18,7 @@ import { constraintCovers, isWorkspacePath, memberHolding, WORKSPACE_PATH_PATTER
18
18
  import { pinFile, queryRecords, type RecordsDocument } from "./records-cli";
19
19
  import { parseFrontMatter, RECORD_WARNING_CODES, SEAL_WARNING_CODES } from "./records";
20
20
  import { WORK_WARNING_CODES } from "./work";
21
+ import { ANSWER_WARNING_CODES } from "./points";
21
22
  import { workspaceGraph } from "./graph-cli";
22
23
  import graphSchema from "./graph.schema.json";
23
24
  import recordsSchema from "./records.schema.json";
@@ -170,8 +171,8 @@ describe("chant workspace records checks each pin", () => {
170
171
 
171
172
  test("the schema lists exactly the warning codes", () => {
172
173
  // A work kind's records carry the work warnings too (#2683), work-done-gap-open included since records walks a done item's region (#2686),
173
- // and records adds record-unattested for an author seal under a signers file at base (#2688).
174
- expect(recordsSchema.$defs.warning.properties.code.enum).toEqual([...RECORD_WARNING_CODES, ...WORK_WARNING_CODES, ...SEAL_WARNING_CODES]);
174
+ // an answer kind's the answer warnings (ws-058), and records adds record-unattested for an author seal under a signers file at base (#2688).
175
+ expect(recordsSchema.$defs.warning.properties.code.enum).toEqual([...RECORD_WARNING_CODES, ...WORK_WARNING_CODES, ...ANSWER_WARNING_CODES, ...SEAL_WARNING_CODES]);
175
176
  });
176
177
 
177
178
  test("records pin <path> prints the entry's path from the workspace root and the file's hash", () => {
@@ -81,7 +81,10 @@ export function checkPins(pins: { path: string; sha256: string }[], tree: Worksp
81
81
  const state: PinState = actual === null ? "missing" : actual === pin.sha256 ? "pinned" : "drifted";
82
82
  assets.push({ ...pin, actual, state });
83
83
  if (state === "missing") {
84
- warnings.push({ code: "asset-missing", message: `evidence pins ${pin.path}, which does not exist${tree.label}` });
84
+ // Worded the same for the working tree and a revision (#2745): a missing
85
+ // file is missing either way, and `tree.label` would otherwise make the
86
+ // same finding read differently depending on which one was read.
87
+ warnings.push({ code: "asset-missing", message: `evidence pins ${pin.path}, which does not exist` });
85
88
  } else if (state === "drifted") {
86
89
  warnings.push({
87
90
  code: "asset-drift",
@@ -92,6 +92,13 @@ export type RecordView = RecordEntry & {
92
92
  /** true when the record's seal verifies for its author against the signers at base; false when it fails, or is missing under an active policy; null when nothing here can say (#2688). */
93
93
  attested?: boolean | null;
94
94
  attestation?: VerdictAttestation;
95
+ /**
96
+ * For a work kind read in the working tree (#2732): the item's active work
97
+ * lease, or null when nobody holds one. Read from the local lease refs of
98
+ * the ledger owning the kind, without fetching. Absent under `--at`, since a
99
+ * lease is live state and not part of a revision.
100
+ */
101
+ lease?: { holder: string; token: string; acquiredAt: string; expiresAt: string } | null;
95
102
  };
96
103
 
97
104
  /** The role in the trust policy whose holders' verdicts the quorum does not count (#2671). */
@@ -304,6 +311,11 @@ export async function queryRecords(query: RecordsQuery): Promise<RecordsDocument
304
311
  ...(seals ? authorSeal(loaded.kind, r, policy, seals.checkRecordSeal) : {}),
305
312
  }));
306
313
  if (loaded.kind.work && query.workGaps !== false && top) await raiseWorkGaps(loaded, records, { root, workspaceRoot, at: query.at });
314
+ if (loaded.kind.work && at === null && top) {
315
+ const { activeWorkLeases } = await import("../lifecycle/work-lease");
316
+ const leases = await activeWorkLeases(loaded.file);
317
+ for (const r of records) if (r.id !== null) r.lease = leases.get(r.id) ?? null;
318
+ }
307
319
  return {
308
320
  $schema: RECORDS_OUTPUT_SCHEMA_ID,
309
321
  contract: RECORDS_CONTRACT_VERSION,
@@ -596,7 +608,8 @@ function formatRecords(records: RecordView[], summary: { total: number; valid: n
596
608
  if (r.ready !== undefined) {
597
609
  const blocked = (r.blockedBy ?? []).map((b) => `${b.id} (${b.state ?? "unknown"})`).join(", ");
598
610
  const implemented = (r.implements ?? []).map((d) => `${d.id} (${d.state ?? "unknown"})`).join(", ");
599
- const status = [r.ready ? "ready" : blocked ? `blocked by ${blocked}` : "", implemented ? `implements ${implemented}` : ""].filter(Boolean).join("; ");
611
+ const held = r.lease ? `held by ${r.lease.holder} until ${r.lease.expiresAt}` : "";
612
+ const status = [r.ready ? "ready" : blocked ? `blocked by ${blocked}` : "", implemented ? `implements ${implemented}` : "", held].filter(Boolean).join("; ");
600
613
  if (status) lines.push(`${" ".repeat(idWidth + 2)}${status}`);
601
614
  }
602
615
  for (const reason of r.reasons) lines.push(`${" ".repeat(idWidth + 2)}${reason.code}: ${reason.message} (${r.path})`);
@@ -620,7 +633,7 @@ export async function runWorkspaceUnknown(ctx: CommandContext): Promise<number>
620
633
  console.error(
621
634
  formatError({
622
635
  message: sub ? `Unknown workspace subcommand: ${sub}` : "chant workspace needs a subcommand",
623
- hint: `Workspace subcommands: audit, build, check, graph, init, lineage, lint, ls, records, status, upgrade, verify. Run "chant --help" for their options.`,
636
+ hint: `Workspace subcommands: audit, build, check, graph, init, lineage, lint, ls, records, status, upgrade, verify, work. Run "chant --help" for their options.`,
624
637
  }),
625
638
  );
626
639
  return 1;
@@ -13,6 +13,7 @@ import { afterAll, describe, expect, test } from "vitest";
13
13
  import { queryRecords, RECORDS_CONTRACT_VERSION, RECORDS_OUTPUT_SCHEMA_ID, type RecordsDocument } from "./records-cli";
14
14
  import { READ_ERROR_CODES, RECORD_REASON_CODES, RECORD_WARNING_CODES, recordTextDigest, REVIEW_REASON_CODES, SEAL_WARNING_CODES } from "./records";
15
15
  import { WORK_WARNING_CODES } from "./work";
16
+ import { ANSWER_WARNING_CODES } from "./points";
16
17
  import schema from "./records.schema.json";
17
18
  import { PROVENANCE_LEVELS } from "./trust/attestor";
18
19
 
@@ -149,8 +150,8 @@ describe("records output schema", () => {
149
150
 
150
151
  test("lists exactly the warning codes the code can return", () => {
151
152
  // A work kind's records carry the work warnings too (#2683), work-done-gap-open included since records walks a done item's region (#2686),
152
- // and records adds record-unattested for an author seal under a signers file at base (#2688).
153
- expect(schema.$defs.warning.properties.code.enum).toEqual([...RECORD_WARNING_CODES, ...WORK_WARNING_CODES, ...SEAL_WARNING_CODES]);
153
+ // an answer kind's the answer warnings (ws-058), and records adds record-unattested for an author seal under a signers file at base (#2688).
154
+ expect(schema.$defs.warning.properties.code.enum).toEqual([...RECORD_WARNING_CODES, ...WORK_WARNING_CODES, ...ANSWER_WARNING_CODES, ...SEAL_WARNING_CODES]);
154
155
  });
155
156
 
156
157
  test("every failure validates with its code", async () => {
@@ -177,6 +177,10 @@
177
177
  "description": "Added in contract 1 by #2683, on every parsed record of a work kind. Each decision the record implements, with its state now; null for an id no decision has.",
178
178
  "type": "array",
179
179
  "items": { "$ref": "#/$defs/workLink" }
180
+ },
181
+ "lease": {
182
+ "description": "Added in contract 1 by #2732, on every record of a work kind with an id, read in the working tree (absent under --at). The item's active work lease (chant workspace work claim), or null when nobody holds one live. Read from the local lease refs, refs/chant/lease/[_members/<member>/]work/<id> and the remote's as last fetched, of the ledger owning the work kind file; records never fetches.",
183
+ "oneOf": [{ "type": "null" }, { "$ref": "#/$defs/workLease" }]
180
184
  }
181
185
  },
182
186
  "allOf": [
@@ -187,6 +191,16 @@
187
191
  }
188
192
  ]
189
193
  },
194
+ "workLease": {
195
+ "type": "object",
196
+ "required": ["holder", "token", "acquiredAt", "expiresAt"],
197
+ "properties": {
198
+ "holder": { "type": "string", "description": "Who holds the lease, as --holder gave it." },
199
+ "token": { "type": "string", "description": "The fencing token: new on every claim, kept by every renew." },
200
+ "acquiredAt": { "type": "string", "description": "When the lease was claimed, ISO 8601." },
201
+ "expiresAt": { "type": "string", "description": "When the lease expires unless renewed, ISO 8601." }
202
+ }
203
+ },
190
204
  "asset": {
191
205
  "type": "object",
192
206
  "required": ["path", "sha256", "actual", "state"],
@@ -217,6 +231,9 @@
217
231
  "work-done-unpinned",
218
232
  "work-closed-without-date",
219
233
  "work-done-gap-open",
234
+ "answer-points-unreadable",
235
+ "answer-point-unknown",
236
+ "answer-point-changed",
220
237
  "record-unattested"
221
238
  ]
222
239
  },
@@ -32,6 +32,7 @@ import type { RecordSource } from "./record-source";
32
32
  import { sourceBlock, sourceBlockProblems, transcriptDrift } from "./source-block";
33
33
  import type { WorkspaceTree } from "./tree";
34
34
  import type { DecisionWork, WorkLink, WorkWarningCode } from "./work";
35
+ import type { AnswerWarningCode } from "./points";
35
36
 
36
37
  // ── Reason codes ─────────────────────────────────────────────────────────────
37
38
 
@@ -175,8 +176,8 @@ export interface VerdictAttestation {
175
176
  }
176
177
 
177
178
  export interface RecordWarning {
178
- /** A work kind's records also carry the codes of `WORK_WARNING_CODES` (#2683), and `records` adds `SEAL_WARNING_CODES` (#2688). */
179
- code: RecordWarningCode | WorkWarningCode | SealWarningCode;
179
+ /** A work kind's records also carry the codes of `WORK_WARNING_CODES` (#2683), an answer kind's those of `ANSWER_WARNING_CODES` (#2739), and `records` adds `SEAL_WARNING_CODES` (#2688). */
180
+ code: RecordWarningCode | WorkWarningCode | SealWarningCode | AnswerWarningCode;
180
181
  message: string;
181
182
  }
182
183
 
@@ -360,6 +361,17 @@ export const recordKindSchema = z
360
361
  })
361
362
  .strict()
362
363
  .optional(),
364
+ /**
365
+ * An answer kind (ws-058, #2739): the decision points file its records
366
+ * answer, relative to this kind file. The records are written by `chant
367
+ * workspace points ask` and `points answer` in the shape of
368
+ * `point-answer.schema.json`, with an `id`, the states `escalated`,
369
+ * `proposed` and `answered`, and `answered` closed. With it, `records`
370
+ * warns about an answer whose point is gone or changed, `check` validates
371
+ * the points file (WSP116), and `points` lists the open questions.
372
+ * Optional.
373
+ */
374
+ answers: z.object({ points: z.string().min(1) }).strict().optional(),
363
375
  })
364
376
  .strict()
365
377
  .refine((k) => (k.idField === undefined) !== (k.idFrom === undefined), {
@@ -393,7 +405,23 @@ export const recordKindSchema = z
393
405
  .refine((k) => !k.work || (k.states !== undefined && k.states.includes(k.work.open) && k.states.includes(k.work.done)), {
394
406
  message: "a work kind must have states, and its work open and done states must be listed in them",
395
407
  path: ["work"],
396
- });
408
+ })
409
+ .refine(
410
+ (k) =>
411
+ !k.answers ||
412
+ (k.format === "markdown-front-matter" &&
413
+ k.idField === "id" &&
414
+ k.stateField === "state" &&
415
+ ANSWER_STATES.every((s) => k.states?.includes(s)) &&
416
+ (k.closedStates ?? []).includes("answered")),
417
+ {
418
+ message: 'an answer kind is markdown-front-matter with idField "id", stateField "state", the states escalated, proposed and answered, and answered closed',
419
+ path: ["answers"],
420
+ },
421
+ );
422
+
423
+ /** The states of an answer kind's records (ws-058): open for people, proposed by a model, answered by a table, people, or a confirmed proposal. */
424
+ export const ANSWER_STATES = ["escalated", "proposed", "answered"] as const;
397
425
 
398
426
  export type RecordKind = z.infer<typeof recordKindSchema>;
399
427
 
@@ -1250,6 +1278,8 @@ export async function readRecords(loaded: LoadedRecordKind, options: ReadRecords
1250
1278
  joinSessions(kind, entries, texts, options.subjects ?? null);
1251
1279
  // A work kind's links, ready and blocked (#2683), from every record before --current.
1252
1280
  const work = kind.work ? await (await import("./work")).applyWork(loaded, entries, options) : undefined;
1281
+ // An answer kind's points (ws-058): an answer whose point is gone or changed is warned about.
1282
+ if (kind.answers) (await import("./points")).applyAnswers(loaded, entries, options);
1253
1283
 
1254
1284
  for (const e of entries) e.valid = e.reasons.length === 0;
1255
1285
  const records = options.current ? entries.filter((e) => e.supersededBy === null) : entries;
@@ -22,10 +22,15 @@ import {
22
22
  STATUS_GATE_REASON_CODES,
23
23
  STATUS_OUTPUT_SCHEMA_ID,
24
24
  STATUS_REASON_CODES,
25
+ STATUS_STEWARD_REASON_CODES,
25
26
  workspaceStatus,
26
27
  type StatusDocument,
27
28
  } from "./status";
28
29
  import schema from "./status.schema.json";
30
+ import { appendRunRecord } from "../lifecycle/run-ledger";
31
+ import { acquireStewardLease } from "../op/operator";
32
+ import { stewardWorkHolder } from "../op/work-lease-run";
33
+ import { claimWorkLease } from "../lifecycle/work-lease";
29
34
 
30
35
  const REPO = join(import.meta.dirname, "..", "..", "..", "..");
31
36
 
@@ -148,6 +153,7 @@ describe("status output schema", () => {
148
153
  expect(schema.$defs.environment.properties.reason.oneOf[1].properties!.code.enum).toEqual([...STATUS_REASON_CODES]);
149
154
  expect(schema.$defs.failure.properties.error.properties.code.enum).toEqual([...STATUS_ERROR_CODES]);
150
155
  expect(schema.$defs.gateLedger.properties.reason.oneOf[1].properties!.code.enum).toEqual([...STATUS_GATE_REASON_CODES]);
156
+ expect(schema.$defs.member.properties.stewardReasons.items.properties.code.enum).toEqual([...STATUS_STEWARD_REASON_CODES]);
151
157
  });
152
158
  });
153
159
 
@@ -163,6 +169,8 @@ describe("chant workspace status on built workspaces", () => {
163
169
  expect(web.environments[0].ledger).toEqual({ layout: "members", path: "_members/web/staging/releases.jsonl", shared: false });
164
170
  // The later of the two records wins.
165
171
  expect(web.environments[0].releases.map((r) => [r.component, r.digest, r.gitSha])).toEqual([["web", D("a"), "a".repeat(40)]]);
172
+ // No plan was ever written for these digests (ws-055, #2733): the field is present, and null.
173
+ expect(web.environments[0].releases.map((r) => r.plan)).toEqual([null]);
166
174
  // api has no _members/api/ yet, so it falls back to the flat ledger, which the root member reads too.
167
175
  expect(api.environments[0].ledger).toEqual({ layout: "flat", path: "staging/releases.jsonl", shared: true });
168
176
  expect(site.environments[0].ledger).toEqual({ layout: "flat", path: "staging/releases.jsonl", shared: true });
@@ -199,6 +207,33 @@ describe("chant workspace status on built workspaces", () => {
199
207
  expect(text).toContain("3 members, 3 with a release in staging, 2 differ from prod, 0 unreadable");
200
208
  });
201
209
 
210
+ test("resolves a release's plan from _plans/<digest>.json, member-scoped per #2524 D7 (ws-055, #2733)", async () => {
211
+ const root = repo({
212
+ "chant.workspace.json": declaration([
213
+ { name: "site", dir: ".", kind: "other", because: "the root project" },
214
+ { name: "web", dir: "apps/web", kind: "chant" },
215
+ ]),
216
+ "apps/web/chant.config.ts": "",
217
+ });
218
+ const webPlan = { digest: D("a"), release: "r-web", units: [{ id: "w-1" }] };
219
+ const apiPlan = { digest: D("b"), release: "r-api", units: [] };
220
+ lifecycle(root, {
221
+ "_members/web/staging/releases.jsonl": jsonl(release("web", "staging", D("a"), "a".repeat(40))),
222
+ [`_members/web/_plans/sha256_${"a".repeat(64)}.json`]: JSON.stringify(webPlan),
223
+ "staging/releases.jsonl": jsonl(release("api", "staging", D("b"), "b".repeat(40)), release("worker", "staging", D("c"), "c".repeat(40))),
224
+ [`_plans/sha256_${"b".repeat(64)}.json`]: JSON.stringify(apiPlan),
225
+ });
226
+ const doc = result(await workspaceStatus({ cwd: root, env: "staging" }));
227
+ expectValid(doc);
228
+ const web = doc.members.find((m) => m.name === "web")!;
229
+ const site = doc.members.find((m) => m.name === "site")!;
230
+ expect(web.environments[0].releases[0].plan).toEqual(webPlan);
231
+ const byComponent = new Map(site.environments[0].releases.map((r) => [r.component, r]));
232
+ expect(byComponent.get("api")?.plan).toEqual(apiPlan);
233
+ // worker has no plan stored under its digest: reads null, not an error.
234
+ expect(byComponent.get("worker")?.plan).toBeNull();
235
+ });
236
+
202
237
  test("compares on the input digest when a release has one", async () => {
203
238
  const root = repo({ "chant.workspace.json": declaration([{ name: "chart", dir: "chart", kind: "other", because: "helm" }]) });
204
239
  lifecycle(root, {
@@ -253,6 +288,33 @@ describe("chant workspace status on built workspaces", () => {
253
288
  expect(formatStatus(doc)).toContain("web - no release");
254
289
  });
255
290
 
291
+ test("each member lists its box block: the capability, its broker and its scope (#2726)", async () => {
292
+ const root = repo({
293
+ "chant.workspace.json": declaration([
294
+ { name: "web", dir: "web", kind: "chant" },
295
+ {
296
+ name: "box",
297
+ dir: "box",
298
+ kind: "other",
299
+ because: "the box's declarations",
300
+ box: { capabilities: [{ name: "fountain", broker: "lobby", scope: ["agent", "vault", "conversations", "sandboxes"] }, { name: "inference" }] },
301
+ },
302
+ ]),
303
+ });
304
+ const doc = result(await workspaceStatus({ cwd: root, env: "prod" }));
305
+ expectValid(doc);
306
+ expect(doc.members.map((m) => m.box)).toEqual([
307
+ null,
308
+ {
309
+ capabilities: [
310
+ { name: "fountain", broker: "lobby", scope: ["agent", "vault", "conversations", "sandboxes"] },
311
+ { name: "inference", broker: null, scope: [] },
312
+ ],
313
+ isolation: null,
314
+ },
315
+ ]);
316
+ });
317
+
256
318
  test("every failure validates with its code", async () => {
257
319
  const empty = repo({});
258
320
  const outside = realpathSync(mkdtempSync(join(tmpdir(), "chant-status-nogit-")));
@@ -459,6 +521,122 @@ describe("gate state in chant workspace status --json (#2674)", () => {
459
521
  });
460
522
  });
461
523
 
524
+ describe("stewards in chant workspace status --json (#2731)", () => {
525
+ const op = (name: string, cron?: string) => ({
526
+ name,
527
+ overview: name,
528
+ phases: [{ name: "Run", steps: [{ kind: "activity", fn: "noop", args: {} }] }],
529
+ ...(cron ? { schedule: { cron, overlap: "skip" } } : {}),
530
+ });
531
+
532
+ /** A box member declaring one steward, local by default and fountain on fountain-k3d. */
533
+ function stewarded(): string {
534
+ const steward = {
535
+ kind: "Chant::Steward",
536
+ name: "box-steward",
537
+ ops: [
538
+ op("box-converge", "* * * * *"),
539
+ op("box-release"),
540
+ { ...op("box-dispatch", "*/5 * * * *"), workLease: { item: ["W-1", "W-2"] }, changesCheckout: true },
541
+ ],
542
+ form: { default: "local", environments: { "fountain-k3d": "fountain" } },
543
+ capabilities: ["fountain", "inference"],
544
+ vault: null,
545
+ };
546
+ const root = repo({
547
+ "chant.workspace.json": declaration([
548
+ { name: "box", dir: "box", kind: "chant", box: { capabilities: [{ name: "fountain", broker: "lobby", scope: ["agent", "vault"] }] } },
549
+ { name: "notes", dir: "notes", kind: "other", because: "prose" },
550
+ ]),
551
+ "box/chant.config.ts": "export default {};\n",
552
+ "box/ops/steward.op.ts": `export const steward = ${JSON.stringify(steward)};\n`,
553
+ "notes/README.md": "notes\n",
554
+ });
555
+ lifecycle(root, { "README": "ledger\n" });
556
+ return root;
557
+ }
558
+
559
+ test("names the steward, its form in the environment asked for, its Ops, schedules, last runs and lease", async () => {
560
+ const root = stewarded();
561
+ const box = join(root, "box");
562
+ await appendRunRecord(
563
+ { op: "box-converge", env: "local", started: "2026-09-25T10:00:00.000Z", ended: "2026-09-25T10:00:05.000Z", status: "ok", labels: {}, outcomes: {}, phases: [] },
564
+ { cwd: box },
565
+ );
566
+ expect((await acquireStewardLease("box-steward", "box-host:1:abc", { cwd: box, now: () => new Date("2026-09-25T10:00:00.000Z") })).acquired).toBe(true);
567
+ // #2748: the dispatch turn holds W-1; someone else holds W-2.
568
+ const turn = await claimWorkLease("W-1", stewardWorkHolder("box-steward", "box-dispatch", "box-host:1:abc"), { cwd: box, ttlMs: 600_000, now: () => new Date("2026-09-25T10:00:30.000Z") });
569
+ expect(turn.ok).toBe(true);
570
+ expect((await claimWorkLease("W-2", "someone-else", { cwd: box, ttlMs: 600_000, now: () => new Date("2026-09-25T10:00:30.000Z") })).ok).toBe(true);
571
+
572
+ const doc = result(await workspaceStatus({ cwd: root, env: "minimal", now: "2026-09-25T10:01:00.000Z" }));
573
+ expectValid(doc);
574
+ const [boxMember, notes] = doc.members;
575
+ expect(notes.stewards).toEqual([]);
576
+ expect(boxMember.stewardReasons).toEqual([]);
577
+ expect(boxMember.stewards).toHaveLength(1);
578
+ const s = boxMember.stewards[0];
579
+ expect(s).toMatchObject({
580
+ name: "box-steward",
581
+ file: "ops/steward.op.ts",
582
+ form: "local",
583
+ forms: { default: "local", environments: { "fountain-k3d": "fountain" } },
584
+ // #2726: the steward holds no credential; its capabilities join the box block.
585
+ vault: null,
586
+ capabilities: [
587
+ { name: "fountain", broker: "lobby", declared: true },
588
+ { name: "inference", broker: null, declared: false },
589
+ ],
590
+ lease: { holder: "box-host:1:abc", acquiredAt: "2026-09-25T10:00:00.000Z", live: true },
591
+ });
592
+ expect(s.ops).toEqual([
593
+ {
594
+ name: "box-converge",
595
+ schedule: { cron: "* * * * *", overlap: "skip" },
596
+ env: "local",
597
+ lastRun: { id: expect.any(String), status: "ok", started: "2026-09-25T10:00:00.000Z", ended: "2026-09-25T10:00:05.000Z", gate: null },
598
+ changesCheckout: false,
599
+ workLease: null,
600
+ },
601
+ { name: "box-release", schedule: null, env: "local", lastRun: null, changesCheckout: false, workLease: null },
602
+ {
603
+ name: "box-dispatch",
604
+ schedule: { cron: "*/5 * * * *", overlap: "skip" },
605
+ env: "local",
606
+ lastRun: null,
607
+ changesCheckout: true,
608
+ workLease: {
609
+ kind: null,
610
+ held: [
611
+ {
612
+ item: "W-1",
613
+ holder: "box-steward/box-dispatch@box-host:1:abc",
614
+ token: turn.ok ? turn.lease.token : "",
615
+ acquiredAt: "2026-09-25T10:00:30.000Z",
616
+ expiresAt: "2026-09-25T10:10:30.000Z",
617
+ state: "active",
618
+ },
619
+ ],
620
+ },
621
+ },
622
+ ]);
623
+
624
+ const k3d = result(await workspaceStatus({ cwd: root, env: "fountain-k3d", now: "2026-09-25T11:00:00.000Z" }));
625
+ expectValid(k3d);
626
+ expect(k3d.members[0].stewards[0].form).toBe("fountain");
627
+ expect(k3d.members[0].stewards[0].lease!.live).toBe(false);
628
+ });
629
+
630
+ test("an op file that can't be imported is a reason, and the read still succeeds", async () => {
631
+ const root = stewarded();
632
+ writeFileSync(join(root, "box", "ops", "broken.op.ts"), "export const x = ;\n");
633
+ const doc = result(await workspaceStatus({ cwd: root, env: "minimal" }));
634
+ expectValid(doc);
635
+ expect(doc.members[0].stewards.map((s) => s.name)).toEqual(["box-steward"]);
636
+ expect(doc.members[0].stewardReasons.map((r) => r.code)).toEqual(["stewards-unreadable"]);
637
+ });
638
+ });
639
+
462
640
  describe("chant workspace status on the chant repo (#2557)", () => {
463
641
  test("validates and lists every declared member", async () => {
464
642
  const doc = result(await workspaceStatus({ cwd: join(REPO, "packages", "core"), env: "prod", compareTo: "staging" }));