@intentius/chant 0.89.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 (194) 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/handlers/serve.d.ts.map +1 -1
  7. package/dist/cli/main.d.ts.map +1 -1
  8. package/dist/cli/mcp/server.d.ts +10 -5
  9. package/dist/cli/mcp/server.d.ts.map +1 -1
  10. package/dist/cli/mcp/types.d.ts +13 -5
  11. package/dist/cli/mcp/types.d.ts.map +1 -1
  12. package/dist/cli/mcp/workspace-tools.d.ts +55 -0
  13. package/dist/cli/mcp/workspace-tools.d.ts.map +1 -0
  14. package/dist/cli/registry.d.ts +31 -0
  15. package/dist/cli/registry.d.ts.map +1 -1
  16. package/dist/components/verbs/vuln-scan.d.ts +72 -0
  17. package/dist/components/verbs/vuln-scan.d.ts.map +1 -1
  18. package/dist/lifecycle/git.d.ts +40 -5
  19. package/dist/lifecycle/git.d.ts.map +1 -1
  20. package/dist/lifecycle/lease.d.ts +72 -16
  21. package/dist/lifecycle/lease.d.ts.map +1 -1
  22. package/dist/lifecycle/member-ledger.d.ts +3 -2
  23. package/dist/lifecycle/member-ledger.d.ts.map +1 -1
  24. package/dist/lifecycle/plan-ledger.d.ts +114 -0
  25. package/dist/lifecycle/plan-ledger.d.ts.map +1 -0
  26. package/dist/lifecycle/work-lease.d.ts +140 -0
  27. package/dist/lifecycle/work-lease.d.ts.map +1 -0
  28. package/dist/op/activities/activity-contracts.d.ts +2 -2
  29. package/dist/op/builders.d.ts.map +1 -1
  30. package/dist/op/discover.d.ts +25 -0
  31. package/dist/op/discover.d.ts.map +1 -1
  32. package/dist/op/index.d.ts +9 -3
  33. package/dist/op/index.d.ts.map +1 -1
  34. package/dist/op/lifecycle-receipt-store.d.ts +34 -0
  35. package/dist/op/lifecycle-receipt-store.d.ts.map +1 -0
  36. package/dist/op/local-executor.d.ts +19 -0
  37. package/dist/op/local-executor.d.ts.map +1 -1
  38. package/dist/op/local-output.d.ts.map +1 -1
  39. package/dist/op/op-ir.d.ts +10 -1
  40. package/dist/op/op-ir.d.ts.map +1 -1
  41. package/dist/op/op-verb-class.d.ts.map +1 -1
  42. package/dist/op/operator.d.ts +29 -0
  43. package/dist/op/operator.d.ts.map +1 -1
  44. package/dist/op/runtime.d.ts +9 -0
  45. package/dist/op/runtime.d.ts.map +1 -1
  46. package/dist/op/runtimes/local.d.ts.map +1 -1
  47. package/dist/op/step-output-ref.d.ts +2 -2
  48. package/dist/op/step-output-ref.d.ts.map +1 -1
  49. package/dist/op/steward.d.ts +140 -0
  50. package/dist/op/steward.d.ts.map +1 -0
  51. package/dist/op/types.d.ts +51 -0
  52. package/dist/op/types.d.ts.map +1 -1
  53. package/dist/op/work-lease-decl.d.ts +18 -0
  54. package/dist/op/work-lease-decl.d.ts.map +1 -0
  55. package/dist/op/work-lease-run.d.ts +173 -0
  56. package/dist/op/work-lease-run.d.ts.map +1 -0
  57. package/dist/workspace/box-isolation.d.ts +99 -0
  58. package/dist/workspace/box-isolation.d.ts.map +1 -0
  59. package/dist/workspace/checks/box-isolation.d.ts +18 -0
  60. package/dist/workspace/checks/box-isolation.d.ts.map +1 -0
  61. package/dist/workspace/checks/boxes.d.ts +71 -0
  62. package/dist/workspace/checks/boxes.d.ts.map +1 -0
  63. package/dist/workspace/checks/records.d.ts +1 -0
  64. package/dist/workspace/checks/records.d.ts.map +1 -1
  65. package/dist/workspace/checks.d.ts +10 -1
  66. package/dist/workspace/checks.d.ts.map +1 -1
  67. package/dist/workspace/conformance/index.d.ts +42 -2
  68. package/dist/workspace/conformance/index.d.ts.map +1 -1
  69. package/dist/workspace/conformance/vitest.d.ts.map +1 -1
  70. package/dist/workspace/decide.d.ts +184 -0
  71. package/dist/workspace/decide.d.ts.map +1 -0
  72. package/dist/workspace/decision-points.schema.json +137 -0
  73. package/dist/workspace/declaration.d.ts +51 -0
  74. package/dist/workspace/declaration.d.ts.map +1 -1
  75. package/dist/workspace/declaration.schema.json +172 -0
  76. package/dist/workspace/declared-kinds.d.ts +12 -0
  77. package/dist/workspace/declared-kinds.d.ts.map +1 -1
  78. package/dist/workspace/points-cli.d.ts +113 -0
  79. package/dist/workspace/points-cli.d.ts.map +1 -0
  80. package/dist/workspace/points.d.ts +320 -0
  81. package/dist/workspace/points.d.ts.map +1 -0
  82. package/dist/workspace/reason-codes.d.ts +29 -0
  83. package/dist/workspace/reason-codes.d.ts.map +1 -1
  84. package/dist/workspace/record-assets.d.ts.map +1 -1
  85. package/dist/workspace/records-cli.d.ts +12 -0
  86. package/dist/workspace/records-cli.d.ts.map +1 -1
  87. package/dist/workspace/records-write.d.ts +35 -3
  88. package/dist/workspace/records-write.d.ts.map +1 -1
  89. package/dist/workspace/records.d.ts +12 -3
  90. package/dist/workspace/records.d.ts.map +1 -1
  91. package/dist/workspace/source-block.d.ts +85 -0
  92. package/dist/workspace/source-block.d.ts.map +1 -0
  93. package/dist/workspace/status-stewards.d.ts +121 -0
  94. package/dist/workspace/status-stewards.d.ts.map +1 -0
  95. package/dist/workspace/status.d.ts +52 -1
  96. package/dist/workspace/status.d.ts.map +1 -1
  97. package/dist/workspace/work-cli.d.ts +78 -0
  98. package/dist/workspace/work-cli.d.ts.map +1 -0
  99. package/package.json +1 -1
  100. package/src/cli/handlers/components.test.ts +93 -0
  101. package/src/cli/handlers/components.ts +44 -3
  102. package/src/cli/handlers/operator.ts +107 -2
  103. package/src/cli/handlers/run.test.ts +19 -0
  104. package/src/cli/handlers/run.ts +53 -1
  105. package/src/cli/handlers/serve.ts +2 -1
  106. package/src/cli/main.test.ts +40 -0
  107. package/src/cli/main.ts +78 -2
  108. package/src/cli/mcp/docs-parity.test.ts +20 -2
  109. package/src/cli/mcp/server.ts +23 -6
  110. package/src/cli/mcp/types.ts +15 -2
  111. package/src/cli/mcp/workspace-tools.test.ts +211 -0
  112. package/src/cli/mcp/workspace-tools.ts +449 -0
  113. package/src/cli/registry.ts +31 -0
  114. package/src/components/verbs/vuln-scan.test.ts +124 -1
  115. package/src/components/verbs/vuln-scan.ts +142 -1
  116. package/src/lifecycle/git.ts +65 -15
  117. package/src/lifecycle/lease.test.ts +22 -0
  118. package/src/lifecycle/lease.ts +133 -29
  119. package/src/lifecycle/member-ledger.ts +3 -2
  120. package/src/lifecycle/plan-ledger.test.ts +148 -0
  121. package/src/lifecycle/plan-ledger.ts +158 -0
  122. package/src/lifecycle/work-lease.test.ts +236 -0
  123. package/src/lifecycle/work-lease.ts +426 -0
  124. package/src/op/builders.ts +5 -0
  125. package/src/op/discover.ts +71 -0
  126. package/src/op/index.ts +16 -3
  127. package/src/op/lifecycle-receipt-store.test.ts +60 -0
  128. package/src/op/lifecycle-receipt-store.ts +61 -0
  129. package/src/op/local-executor.ts +216 -18
  130. package/src/op/local-output.ts +13 -0
  131. package/src/op/op-ir.ts +14 -0
  132. package/src/op/op-verb-class.ts +6 -0
  133. package/src/op/operator.ts +75 -4
  134. package/src/op/runtime.ts +6 -0
  135. package/src/op/runtimes/local.ts +3 -0
  136. package/src/op/step-output-ref.ts +6 -2
  137. package/src/op/steward.test.ts +212 -0
  138. package/src/op/steward.ts +253 -0
  139. package/src/op/types.ts +53 -0
  140. package/src/op/work-lease-decl.ts +80 -0
  141. package/src/op/work-lease-run.test.ts +326 -0
  142. package/src/op/work-lease-run.ts +395 -0
  143. package/src/workspace/box-isolation.test.ts +261 -0
  144. package/src/workspace/box-isolation.ts +205 -0
  145. package/src/workspace/check-contract.test.ts +3 -1
  146. package/src/workspace/check.schema.json +15 -7
  147. package/src/workspace/checks/box-isolation.ts +68 -0
  148. package/src/workspace/checks/boxes.test.ts +197 -0
  149. package/src/workspace/checks/boxes.ts +307 -0
  150. package/src/workspace/checks/records.ts +25 -0
  151. package/src/workspace/checks.test.ts +7 -0
  152. package/src/workspace/checks.ts +19 -2
  153. package/src/workspace/conformance/__fixture__/decisions/decision.kind.mjs +4 -0
  154. package/src/workspace/conformance/__fixture__/decisions/decision.schema.json +100 -6
  155. package/src/workspace/conformance/index.mjs +3 -0
  156. package/src/workspace/conformance/index.ts +185 -7
  157. package/src/workspace/conformance/vitest.ts +17 -8
  158. package/src/workspace/decide.test.ts +224 -0
  159. package/src/workspace/decide.ts +576 -0
  160. package/src/workspace/decision-points.schema.json +137 -0
  161. package/src/workspace/declaration.schema.json +172 -0
  162. package/src/workspace/declaration.ts +137 -0
  163. package/src/workspace/declared-kinds.ts +25 -2
  164. package/src/workspace/intent.schema.json +4 -1
  165. package/src/workspace/point-answer.schema.json +95 -0
  166. package/src/workspace/points-cli.ts +273 -0
  167. package/src/workspace/points-write.schema.json +489 -0
  168. package/src/workspace/points.schema.json +710 -0
  169. package/src/workspace/points.test.ts +264 -0
  170. package/src/workspace/points.ts +564 -0
  171. package/src/workspace/read-contract.test.ts +18 -1
  172. package/src/workspace/reason-codes.test.ts +14 -1
  173. package/src/workspace/reason-codes.ts +36 -0
  174. package/src/workspace/record-assets.test.ts +3 -2
  175. package/src/workspace/record-assets.ts +4 -1
  176. package/src/workspace/records-amend.schema.json +2 -1
  177. package/src/workspace/records-cli.ts +15 -2
  178. package/src/workspace/records-close.schema.json +2 -1
  179. package/src/workspace/records-contract.test.ts +3 -2
  180. package/src/workspace/records-new.schema.json +4 -1
  181. package/src/workspace/records-review.schema.json +2 -1
  182. package/src/workspace/records-write.ts +85 -7
  183. package/src/workspace/records.schema.json +18 -0
  184. package/src/workspace/records.ts +61 -3
  185. package/src/workspace/source-block.test.ts +167 -0
  186. package/src/workspace/source-block.ts +129 -0
  187. package/src/workspace/status-contract.test.ts +178 -0
  188. package/src/workspace/status-stewards.ts +225 -0
  189. package/src/workspace/status.schema.json +230 -4
  190. package/src/workspace/status.ts +106 -5
  191. package/src/workspace/work-cli.test.ts +180 -0
  192. package/src/workspace/work-cli.ts +246 -0
  193. package/src/workspace/work-lease.schema.json +233 -0
  194. 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.",
@@ -73,6 +77,7 @@ export const REASONS = {
73
77
  "record-supersedes-pending": "A supersedes link from a record whose state is weaker than the record it names, so the link has no effect yet.",
74
78
  "record-no-evidence": "The record's evidence list is empty: it cites nothing and pins no file. Information for a reviewer, never an error.",
75
79
  "review-undigested": "A verdict names no digest of the text it judged. It still counts, and an amendment does not stop it counting.",
80
+ "source-transcript-drift": "The record's source block pins a transcript by hash, the file it names can be read here, and its bytes hash to something else: it is not the transcript the record means.",
76
81
  // A work record that is valid but warned about (records and graph --intent, #2683).
77
82
  "work-needs-unknown": "A work record's needs list names a work id no record has, so the item stays blocked.",
78
83
  "work-implements-unknown": "A work record's implements list names a decision id no decision has.",
@@ -81,6 +86,10 @@ export const REASONS = {
81
86
  "work-done-unpinned": "A work record is done and its evidence list is empty: nothing shows the work was done.",
82
87
  "work-closed-without-date": "A work record is done or dropped and has no closing date.",
83
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.",
84
93
  // A verdict the quorum does not count (records, #2671, #2672).
85
94
  "review-decider": "The verdict is the decider's own, and the quorum counts verdicts besides the decider's.",
86
95
  "review-agent": "The reviewer holds the agent role in the trust policy at base.",
@@ -114,10 +123,20 @@ export const REASONS = {
114
123
  "review-unsupported": "The kind's schema has no reviews field, so its records take no review.",
115
124
  "review-note-required": "A dissent was given with no note: a dissent needs a reason.",
116
125
  "review-sign-failed": "--sign was given and no seal could be made: the key can't be read or used, git names no ssh signing key, or ssh-keygen is not installed.",
126
+ "record-state-not-initial": "A record written through chant serve mcp gives a state other than the kind's first: a new record opens proposed, and a person moves it on.",
127
+ "source-harvest-not-proposed": "A harvested record (source.via harvest) was written in a state other than the kind's first: a harvest proposes, and a person decides.",
117
128
  "record-sign-failed": "--sign was given and no author seal could be made: the record names no author, the key can't be read or used, git names no ssh signing key, or ssh-keygen is not installed.",
118
129
  // A review given in a session (records review --session, #2693).
119
130
  "session-unknown": "--session names no session of a session kind whose subjects are the record's kind.",
120
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.",
121
140
  // records --since that fails (#2673).
122
141
  "since-rev-unknown": "--since names no commit, or a session with no opening revision and no commit that added it.",
123
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.",
@@ -156,6 +175,23 @@ export const REASONS = {
156
175
  "environments-none-declared": "The member's chant.config.ts declares no environments, so only local and the environments in its ledger are listed.",
157
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.",
158
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.",
159
195
  // The lineage lock (check).
160
196
  "lock-invalid": "The lineage lock can't be read.",
161
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",
@@ -129,7 +129,8 @@
129
129
  "asset-stale",
130
130
  "record-supersedes-pending",
131
131
  "record-no-evidence",
132
- "review-undigested"
132
+ "review-undigested",
133
+ "source-transcript-drift"
133
134
  ]
134
135
  },
135
136
  "message": {
@@ -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;
@@ -129,7 +129,8 @@
129
129
  "asset-stale",
130
130
  "record-supersedes-pending",
131
131
  "record-no-evidence",
132
- "review-undigested"
132
+ "review-undigested",
133
+ "source-transcript-drift"
133
134
  ]
134
135
  },
135
136
  "message": {
@@ -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 () => {
@@ -117,7 +117,8 @@
117
117
  "asset-stale",
118
118
  "record-supersedes-pending",
119
119
  "record-no-evidence",
120
- "review-undigested"
120
+ "review-undigested",
121
+ "source-transcript-drift"
121
122
  ]
122
123
  },
123
124
  "message": {
@@ -161,6 +162,8 @@
161
162
  "record-id-unallocatable",
162
163
  "record-path-unmatched",
163
164
  "record-sign-failed",
165
+ "source-harvest-not-proposed",
166
+ "record-state-not-initial",
164
167
  "record-unparseable",
165
168
  "record-schema-invalid",
166
169
  "record-id-duplicate",
@@ -212,7 +212,8 @@
212
212
  "asset-stale",
213
213
  "record-supersedes-pending",
214
214
  "record-no-evidence",
215
- "review-undigested"
215
+ "review-undigested",
216
+ "source-transcript-drift"
216
217
  ]
217
218
  },
218
219
  "message": {
@@ -67,6 +67,8 @@ export const NEW_ERROR_CODES = [
67
67
  "record-id-unallocatable",
68
68
  "record-path-unmatched",
69
69
  "record-sign-failed",
70
+ "source-harvest-not-proposed",
71
+ "record-state-not-initial",
70
72
  ...RECORD_REASON_CODES,
71
73
  ] as const satisfies readonly ReasonCode[];
72
74
 
@@ -354,7 +356,7 @@ export async function readAll(o: Opened, source: RecordSource): Promise<RecordEn
354
356
  const subjectKind = await loadRecordKind(resolve(dirname(o.loaded.file), o.loaded.kind.session.subjects.kind), o.root);
355
357
  subjects = { records: (await readRecords(subjectKind, { root: o.root, source })).records, reviews: subjectKind.kind.reviews?.field ?? "reviews" };
356
358
  }
357
- return (await readRecords(o.loaded, { root: o.root, source, assets, ...(subjects ? { subjects } : {}) })).records;
359
+ return (await readRecords(o.loaded, { root: o.root, source, assets, workspaceRoot: o.workspaceRoot, ...(subjects ? { subjects } : {}) })).records;
358
360
  }
359
361
 
360
362
  /** `base` with the file at `path` holding `text`, added to its directory when new. */
@@ -441,6 +443,24 @@ function refuseRevisionFields(kind: LoadedRecordKind["kind"], fields: Record<str
441
443
  }
442
444
  }
443
445
 
446
+ /**
447
+ * Refuse a harvested record in any state but the kind's first (#2708): a
448
+ * record whose source block says `via: "harvest"` came out of a transcript
449
+ * after the fact, so it is a proposal, and a person decides it.
450
+ */
451
+ function refuseHarvestedDecision(kind: LoadedRecordKind["kind"], fields: Record<string, unknown>): void {
452
+ if (!kind.source || !kind.states || kind.stateField === undefined) return;
453
+ const block = fields[kind.source.field];
454
+ if (block === null || typeof block !== "object" || Array.isArray(block) || (block as Record<string, unknown>).via !== "harvest") return;
455
+ const opens = kind.states[0];
456
+ const state = fields[kind.stateField];
457
+ if (state === opens) return;
458
+ throw new RecordWriteError(
459
+ "source-harvest-not-proposed",
460
+ `${kind.source.field}.via is harvest, and a harvested ${kind.name} opens ${opens}, not ${JSON.stringify(state ?? null)}: write it with ${kind.stateField} "${opens}", and let the person who decides it move it on with chant workspace records amend or review`,
461
+ );
462
+ }
463
+
444
464
  /**
445
465
  * `text`, the record `data` holds, with its author sealed (#2688): an ssh
446
466
  * signature by the key `sign` names over the record id, the digest of
@@ -594,14 +614,71 @@ export interface NewRecordOptions {
594
614
  sign?: string | true;
595
615
  }
596
616
 
617
+ /**
618
+ * A write made through a channel other than the command line (#2707), such
619
+ * as `chant serve mcp`. The CLI never sets one.
620
+ */
621
+ export interface WriteChannel {
622
+ /**
623
+ * Laid over the kind's source block (#2708) when the kind declares one:
624
+ * `via` and `client`. A new record gets the block, made when the fields
625
+ * give none; an amendment only when its fields set the block.
626
+ */
627
+ source: Record<string, unknown>;
628
+ /** A new record opens in the kind's first state: filled in when the fields give no state, and refused with record-state-not-initial when they give another. */
629
+ opensInitial?: boolean;
630
+ }
631
+
632
+ /** What a channel's write adds to the command line's: who the author is, and how it came. */
633
+ export interface ChannelOptions {
634
+ /** The record's author, written to the kind's `reviews.decider` field: the person or agent that decided. */
635
+ by?: string;
636
+ through?: WriteChannel;
637
+ }
638
+
639
+ /** `fields` with the channel's author and source laid over them (#2707). `isNew` for `records new`. */
640
+ function applyChannel(o: Opened, fields: Record<string, unknown>, opts: ChannelOptions, isNew: boolean, flag: string): Record<string, unknown> {
641
+ const { kind } = o.loaded;
642
+ const out = { ...fields };
643
+ if (opts.by !== undefined) {
644
+ if (!kind.reviews) throw new RecordWriteError("write-usage-invalid", `by names a record's author, the kind's reviews.decider field, and the ${kind.name} kind declares no reviews`);
645
+ if (opts.by.trim() === "") throw new RecordWriteError("write-usage-invalid", "by needs the name of the person or agent that decided");
646
+ const f = kind.reviews.decider;
647
+ if (out[f] !== undefined && out[f] !== null && out[f] !== opts.by) {
648
+ throw new RecordWriteError("write-input-invalid", `the fields given with ${flag} set ${f} to ${JSON.stringify(out[f])}, and by names ${JSON.stringify(opts.by)}: give one author`);
649
+ }
650
+ out[f] = opts.by;
651
+ }
652
+ const through = opts.through;
653
+ if (!through) return out;
654
+ if (isNew && through.opensInitial && kind.states && kind.stateField !== undefined) {
655
+ const first = kind.states[0];
656
+ const state = out[kind.stateField];
657
+ if (state === undefined) out[kind.stateField] = first;
658
+ else if (state !== first) {
659
+ throw new RecordWriteError(
660
+ "record-state-not-initial",
661
+ `a new ${kind.name} opens ${first}, and the fields give ${kind.stateField} ${JSON.stringify(state)}: write it ${first}, and let the person who decides it move it on with a review or an amendment`,
662
+ );
663
+ }
664
+ }
665
+ if (kind.source && (isNew || kind.source.field in out)) {
666
+ const f = kind.source.field;
667
+ const block = out[f];
668
+ if (block === undefined || block === null) out[f] = { ...through.source };
669
+ else if (typeof block === "object" && !Array.isArray(block)) out[f] = { ...(block as Record<string, unknown>), ...through.source };
670
+ }
671
+ return out;
672
+ }
673
+
597
674
  /** `records new`: write one new record from validated fields, sealed by its author with `sign`. */
598
- export async function newRecord(opts: NewRecordOptions): Promise<NewDocument> {
675
+ export async function newRecord(opts: NewRecordOptions & ChannelOptions): Promise<NewDocument> {
599
676
  try {
600
677
  if (opts.prefix !== undefined && !/^[A-Za-z][A-Za-z0-9]*$/.test(opts.prefix)) {
601
678
  throw new RecordWriteError("write-usage-invalid", `--prefix takes letters and digits, starting with a letter, not ${JSON.stringify(opts.prefix)}`);
602
679
  }
603
- const fields = parseFields(opts.fields, "--from");
604
680
  const o = await open(opts.kind, opts.cwd);
681
+ const fields = applyChannel(o, parseFields(opts.fields, "--from"), opts, true, "--from");
605
682
  const { kind, schema } = o.loaded;
606
683
  refuseSealField(o, fields, "--from");
607
684
  refuseRevisionFields(kind, fields, {}, "--from");
@@ -617,6 +694,7 @@ export async function newRecord(opts: NewRecordOptions): Promise<NewDocument> {
617
694
  if (taken) throw new RecordWriteError("record-id-taken", `id ${given} is already used by ${taken.path}; ids are never reused, so leave ${idField} out to have the next one allocated`);
618
695
  id = given;
619
696
  }
697
+ refuseHarvestedDecision(kind, fields);
620
698
  // A session records the commit it opened at (#2693): HEAD now, or null before the first commit.
621
699
  const opened = kind.session?.openedRev ? { [kind.session.openedRev]: headCommit(o.root) } : {};
622
700
  const full = { ...fields, ...opened, [idField]: id };
@@ -676,10 +754,10 @@ export interface AmendRecordOptions {
676
754
  * changes only the reviews leaves the digest, and the seal, as they were.
677
755
  * `--sign` with nothing to change seals the record as it is.
678
756
  */
679
- export async function amendRecord(opts: AmendRecordOptions): Promise<AmendDocument> {
757
+ export async function amendRecord(opts: AmendRecordOptions & ChannelOptions): Promise<AmendDocument> {
680
758
  try {
681
- const given = parseFields(opts.fields, "--set");
682
759
  const o = await open(opts.kind, opts.cwd);
760
+ const given = applyChannel(o, parseFields(opts.fields, "--set"), opts, false, "--set");
683
761
  const { kind } = o.loaded;
684
762
  refuseSealField(o, given, "--set");
685
763
  const before = await readAll(o, o.source);
@@ -922,7 +1000,7 @@ function usage(schema: string, message: string): WriteFailure<"write-usage-inval
922
1000
  * message the verb has always given; several are refused, since a write
923
1001
  * never guesses which kind it means.
924
1002
  */
925
- function declaredWriteKind(schema: string, cwd: string, missing: string): string | WriteFailure<"write-usage-invalid"> {
1003
+ export function declaredWriteKind(schema: string, cwd: string, missing: string): string | WriteFailure<"write-usage-invalid"> {
926
1004
  let kinds: ReturnType<typeof declaredKindFiles>;
927
1005
  try {
928
1006
  kinds = declaredKindFiles(cwd);
@@ -978,7 +1056,7 @@ function readInput(schema: string, flag: string, value: string | undefined, cwd:
978
1056
  * The session kind `records close` goes through when none is named (#2693):
979
1057
  * the one session kind the declaration names.
980
1058
  */
981
- async function declaredSessionKind(schema: string, cwd: string): Promise<string | WriteFailure<"write-usage-invalid">> {
1059
+ export async function declaredSessionKind(schema: string, cwd: string): Promise<string | WriteFailure<"write-usage-invalid">> {
982
1060
  const kinds = (await findSessionKinds(cwd)).map((k) => k.file);
983
1061
  if (kinds.length === 1) return kinds[0];
984
1062
  if (kinds.length === 0) return usage(schema, "--kind <session kind file> is required: the declaration names no session kind");
@@ -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"],
@@ -209,6 +223,7 @@
209
223
  "record-supersedes-pending",
210
224
  "record-no-evidence",
211
225
  "review-undigested",
226
+ "source-transcript-drift",
212
227
  "work-needs-unknown",
213
228
  "work-implements-unknown",
214
229
  "work-needs-cycle",
@@ -216,6 +231,9 @@
216
231
  "work-done-unpinned",
217
232
  "work-closed-without-date",
218
233
  "work-done-gap-open",
234
+ "answer-points-unreadable",
235
+ "answer-point-unknown",
236
+ "answer-point-changed",
219
237
  "record-unattested"
220
238
  ]
221
239
  },
@@ -29,8 +29,10 @@ import type { ReasonCode } from "./reason-codes";
29
29
  import { checkPins, pinEntries, type AssetPin } from "./record-assets";
30
30
  import { joinSessions, type SessionCitation } from "./record-sessions";
31
31
  import type { RecordSource } from "./record-source";
32
+ import { sourceBlock, sourceBlockProblems, transcriptDrift } from "./source-block";
32
33
  import type { WorkspaceTree } from "./tree";
33
34
  import type { DecisionWork, WorkLink, WorkWarningCode } from "./work";
35
+ import type { AnswerWarningCode } from "./points";
34
36
 
35
37
  // ── Reason codes ─────────────────────────────────────────────────────────────
36
38
 
@@ -84,6 +86,12 @@ export const RECORD_WARNING_CODES = [
84
86
  * amendment does not stop it counting (#2672).
85
87
  */
86
88
  "review-undigested",
89
+ /**
90
+ * The kind's source block pins a transcript by hash, the file it names can
91
+ * be read here, and its bytes hash to something else: it is not the
92
+ * transcript the record means (#2708).
93
+ */
94
+ "source-transcript-drift",
87
95
  ] as const satisfies readonly ReasonCode[];
88
96
  export type RecordWarningCode = (typeof RECORD_WARNING_CODES)[number];
89
97
 
@@ -168,8 +176,8 @@ export interface VerdictAttestation {
168
176
  }
169
177
 
170
178
  export interface RecordWarning {
171
- /** A work kind's records also carry the codes of `WORK_WARNING_CODES` (#2683), and `records` adds `SEAL_WARNING_CODES` (#2688). */
172
- 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;
173
181
  message: string;
174
182
  }
175
183
 
@@ -299,6 +307,15 @@ export const recordKindSchema = z
299
307
  * `withdrawn_on`. Optional.
300
308
  */
301
309
  reviews: z.object({ field: z.string().min(1), decider: z.string().min(1) }).strict().optional(),
310
+ /**
311
+ * The front-matter object that says where a record came from, opted in
312
+ * to the stored source block (#2708): `via`, `client`, `harness`,
313
+ * `model`, `session`, `turns` and `transcript`, beside the kind's own
314
+ * fields in the same object. With it, those fields are checked for their
315
+ * shapes, a write with `via: "harvest"` must open in the kind's first
316
+ * state, and `records` warns `source-transcript-drift`. Optional.
317
+ */
318
+ source: z.object({ field: z.string().min(1) }).strict().optional(),
302
319
  /**
303
320
  * A review-session kind (#2673, #2650 C10): the front-matter list of the
304
321
  * verdicts a session produced, the field that seals a closed session, and
@@ -344,6 +361,17 @@ export const recordKindSchema = z
344
361
  })
345
362
  .strict()
346
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(),
347
375
  })
348
376
  .strict()
349
377
  .refine((k) => (k.idField === undefined) !== (k.idFrom === undefined), {
@@ -377,7 +405,23 @@ export const recordKindSchema = z
377
405
  .refine((k) => !k.work || (k.states !== undefined && k.states.includes(k.work.open) && k.states.includes(k.work.done)), {
378
406
  message: "a work kind must have states, and its work open and done states must be listed in them",
379
407
  path: ["work"],
380
- });
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;
381
425
 
382
426
  export type RecordKind = z.infer<typeof recordKindSchema>;
383
427
 
@@ -1076,6 +1120,8 @@ export async function readRecords(loaded: LoadedRecordKind, options: ReadRecords
1076
1120
  const match = new RegExp(kind.location.match);
1077
1121
  const validate = await compileSchema(loaded.schema, loaded.refs);
1078
1122
  const workspaceRoot = options.workspaceRoot ?? ".";
1123
+ // Where a relative transcript path resolves (#2708): the workspace root in the working tree.
1124
+ const workspaceDir = workspaceRoot === "." ? options.root : resolve(options.root, ...workspaceRoot.split("/"));
1079
1125
 
1080
1126
  const entries: RecordEntry[] = [];
1081
1127
  const texts = new Map<string, string>();
@@ -1127,6 +1173,16 @@ export async function readRecords(loaded: LoadedRecordKind, options: ReadRecords
1127
1173
  if (!result.ok) {
1128
1174
  entry.reasons.push({ code: "record-schema-invalid", message: result.errors.join("; ") });
1129
1175
  }
1176
+ if (kind.source) {
1177
+ // The stored source block (#2708): its proposal fields' shapes, and the transcript it pins.
1178
+ const block = sourceBlock(fm.value, kind.source.field);
1179
+ if (block) {
1180
+ const problems = sourceBlockProblems(block, kind.source.field);
1181
+ if (problems.length > 0) entry.reasons.push({ code: "record-schema-invalid", message: problems.join("; ") });
1182
+ const drift = transcriptDrift(block, kind.source.field, workspaceDir);
1183
+ if (drift) entry.warnings.push({ code: "source-transcript-drift", message: drift });
1184
+ }
1185
+ }
1130
1186
  if (kind.pins) {
1131
1187
  const cited = fm.value[kind.pins.field];
1132
1188
  if (Array.isArray(cited) && cited.length === 0) {
@@ -1222,6 +1278,8 @@ export async function readRecords(loaded: LoadedRecordKind, options: ReadRecords
1222
1278
  joinSessions(kind, entries, texts, options.subjects ?? null);
1223
1279
  // A work kind's links, ready and blocked (#2683), from every record before --current.
1224
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);
1225
1283
 
1226
1284
  for (const e of entries) e.valid = e.reasons.length === 0;
1227
1285
  const records = options.current ? entries.filter((e) => e.supersededBy === null) : entries;