@intentius/chant 0.124.24 → 0.124.26

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 (195) hide show
  1. package/dist/cli/commands/build.d.ts.map +1 -1
  2. package/dist/cli/commands/build.js +4 -2
  3. package/dist/cli/commands/lint.d.ts.map +1 -1
  4. package/dist/cli/commands/lint.js +4 -2
  5. package/dist/cli/handlers/approve.d.ts.map +1 -1
  6. package/dist/cli/handlers/approve.js +39 -4
  7. package/dist/cli/main.d.ts.map +1 -1
  8. package/dist/cli/main.js +21 -7
  9. package/dist/cli/mcp/workspace-tools.d.ts +3 -3
  10. package/dist/cli/mcp/workspace-tools.d.ts.map +1 -1
  11. package/dist/cli/mcp/workspace-tools.js +27 -4
  12. package/dist/effect-receipt.d.ts +13 -0
  13. package/dist/effect-receipt.d.ts.map +1 -1
  14. package/dist/effect-receipt.js +19 -0
  15. package/dist/gate-decision.d.ts +1 -1
  16. package/dist/gate-decision.d.ts.map +1 -1
  17. package/dist/gate-decision.js +1 -1
  18. package/dist/lexicon-module.d.ts +7 -1
  19. package/dist/lexicon-module.d.ts.map +1 -1
  20. package/dist/lexicon-module.js +7 -2
  21. package/dist/lifecycle/gate-audit.d.ts +7 -1
  22. package/dist/lifecycle/gate-audit.d.ts.map +1 -1
  23. package/dist/lifecycle/gate-audit.js +40 -3
  24. package/dist/lifecycle/gate-checkpoint.d.ts +78 -0
  25. package/dist/lifecycle/gate-checkpoint.d.ts.map +1 -0
  26. package/dist/lifecycle/gate-checkpoint.js +103 -0
  27. package/dist/lifecycle/gate-ledger.d.ts +41 -0
  28. package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
  29. package/dist/lifecycle/gate-ledger.js +18 -0
  30. package/dist/lifecycle/receipt-plan.d.ts.map +1 -1
  31. package/dist/lifecycle/receipt-plan.js +4 -2
  32. package/dist/lifecycle/run-ledger.d.ts +2 -0
  33. package/dist/lifecycle/run-ledger.d.ts.map +1 -1
  34. package/dist/lifecycle/run-ledger.js +2 -0
  35. package/dist/lint/rules/op/index.d.ts +6 -2
  36. package/dist/lint/rules/op/index.d.ts.map +1 -1
  37. package/dist/lint/rules/op/index.js +7 -3
  38. package/dist/lint/rules/op/ops016-gate-resume.d.ts +14 -0
  39. package/dist/lint/rules/op/ops016-gate-resume.d.ts.map +1 -0
  40. package/dist/lint/rules/op/ops016-gate-resume.js +31 -0
  41. package/dist/lint/rules/op/ops017-gate-alias.d.ts +30 -0
  42. package/dist/lint/rules/op/ops017-gate-alias.d.ts.map +1 -0
  43. package/dist/lint/rules/op/ops017-gate-alias.js +67 -0
  44. package/dist/op/activity-contract.d.ts +14 -0
  45. package/dist/op/activity-contract.d.ts.map +1 -1
  46. package/dist/op/activity-contract.js +16 -0
  47. package/dist/op/builders.d.ts +7 -0
  48. package/dist/op/builders.d.ts.map +1 -1
  49. package/dist/op/builders.js +13 -0
  50. package/dist/op/gate-alias.d.ts +57 -0
  51. package/dist/op/gate-alias.d.ts.map +1 -0
  52. package/dist/op/gate-alias.js +127 -0
  53. package/dist/op/gate-checkpoint.d.ts +123 -0
  54. package/dist/op/gate-checkpoint.d.ts.map +1 -0
  55. package/dist/op/gate-checkpoint.js +280 -0
  56. package/dist/op/gate.d.ts +54 -2
  57. package/dist/op/gate.d.ts.map +1 -1
  58. package/dist/op/gate.js +142 -1
  59. package/dist/op/index.d.ts +4 -1
  60. package/dist/op/index.d.ts.map +1 -1
  61. package/dist/op/index.js +2 -1
  62. package/dist/op/local-executor.d.ts +57 -2
  63. package/dist/op/local-executor.d.ts.map +1 -1
  64. package/dist/op/local-executor.js +200 -11
  65. package/dist/op/local-output.d.ts.map +1 -1
  66. package/dist/op/local-output.js +20 -2
  67. package/dist/op/op-ir.d.ts +4 -0
  68. package/dist/op/op-ir.d.ts.map +1 -1
  69. package/dist/op/op-ir.js +4 -0
  70. package/dist/op/receipt-store.d.ts +5 -0
  71. package/dist/op/receipt-store.d.ts.map +1 -1
  72. package/dist/op/receipt-store.js +4 -3
  73. package/dist/op/runtime.d.ts +15 -0
  74. package/dist/op/runtime.d.ts.map +1 -1
  75. package/dist/op/runtimes/local.d.ts.map +1 -1
  76. package/dist/op/runtimes/local.js +5 -0
  77. package/dist/op/step-output-ref.d.ts +26 -18
  78. package/dist/op/step-output-ref.d.ts.map +1 -1
  79. package/dist/op/step-output-ref.js +86 -63
  80. package/dist/op/types.d.ts +31 -0
  81. package/dist/op/types.d.ts.map +1 -1
  82. package/dist/wave-runtime.mjs +223 -221
  83. package/dist/workspace/agent-cli.d.ts.map +1 -1
  84. package/dist/workspace/agent-cli.js +3 -0
  85. package/dist/workspace/conformance/__writer_fixture__/answers/answer.schema.json +16 -1
  86. package/dist/workspace/conformance/writer.js +1 -1
  87. package/dist/workspace/decide.d.ts +51 -9
  88. package/dist/workspace/decide.d.ts.map +1 -1
  89. package/dist/workspace/decide.js +125 -14
  90. package/dist/workspace/lineage-upgrade.d.ts.map +1 -1
  91. package/dist/workspace/lineage-upgrade.js +20 -11
  92. package/dist/workspace/point-answer.schema.json +16 -1
  93. package/dist/workspace/points-cli.d.ts +5 -3
  94. package/dist/workspace/points-cli.d.ts.map +1 -1
  95. package/dist/workspace/points-cli.js +16 -7
  96. package/dist/workspace/points-write.schema.json +39 -4
  97. package/dist/workspace/points.schema.json +42 -3
  98. package/dist/workspace/reason-codes.d.ts +2 -1
  99. package/dist/workspace/reason-codes.d.ts.map +1 -1
  100. package/dist/workspace/reason-codes.js +2 -1
  101. package/dist/workspace/records-cli.d.ts +12 -2
  102. package/dist/workspace/records-cli.d.ts.map +1 -1
  103. package/dist/workspace/records-cli.js +27 -7
  104. package/dist/workspace/records-write.d.ts.map +1 -1
  105. package/dist/workspace/records-write.js +2 -3
  106. package/dist/workspace/records.d.ts +10 -4
  107. package/dist/workspace/records.d.ts.map +1 -1
  108. package/dist/workspace/records.js +16 -3
  109. package/dist/workspace/records.schema.json +2 -1
  110. package/dist/workspace/trust/gate-seal.d.ts +32 -1
  111. package/dist/workspace/trust/gate-seal.d.ts.map +1 -1
  112. package/dist/workspace/trust/gate-seal.js +41 -2
  113. package/dist/workspace/trust/seal.d.ts +1 -1
  114. package/dist/workspace/trust/seal.d.ts.map +1 -1
  115. package/dist/workspace/trust/seal.js +1 -1
  116. package/dist/workspace/trust/verify-cli.d.ts.map +1 -1
  117. package/dist/workspace/trust/verify-cli.js +2 -1
  118. package/dist/workspace/trust/verify.d.ts +22 -0
  119. package/dist/workspace/trust/verify.d.ts.map +1 -1
  120. package/dist/workspace/trust/verify.js +94 -14
  121. package/dist/workspace/write-scope.d.ts +3 -1
  122. package/dist/workspace/write-scope.d.ts.map +1 -1
  123. package/dist/workspace/write-scope.js +11 -4
  124. package/package.json +1 -1
  125. package/src/cli/commands/build.ts +9 -2
  126. package/src/cli/commands/lint.ts +4 -2
  127. package/src/cli/compiled-cli.e2e.test.ts +45 -1
  128. package/src/cli/handlers/approve.ts +46 -4
  129. package/src/cli/handlers/operator.test.ts +91 -0
  130. package/src/cli/main.test.ts +10 -0
  131. package/src/cli/main.ts +21 -7
  132. package/src/cli/mcp/workspace-tools.e2e.test.ts +1 -1
  133. package/src/cli/mcp/workspace-tools.ts +27 -4
  134. package/src/effect-receipt.ts +22 -0
  135. package/src/gate-decision.ts +2 -0
  136. package/src/lexicon-module.ts +9 -2
  137. package/src/lifecycle/gate-audit.ts +41 -4
  138. package/src/lifecycle/gate-checkpoint.test.ts +128 -0
  139. package/src/lifecycle/gate-checkpoint.ts +133 -0
  140. package/src/lifecycle/gate-ledger.ts +61 -0
  141. package/src/lifecycle/receipt-plan.test.ts +9 -0
  142. package/src/lifecycle/receipt-plan.ts +4 -1
  143. package/src/lifecycle/run-ledger.ts +4 -0
  144. package/src/lint/rules/op/index.ts +9 -3
  145. package/src/lint/rules/op/ops016-gate-resume.test.ts +53 -0
  146. package/src/lint/rules/op/ops016-gate-resume.ts +48 -0
  147. package/src/lint/rules/op/ops017-gate-alias.test.ts +85 -0
  148. package/src/lint/rules/op/ops017-gate-alias.ts +89 -0
  149. package/src/op/activity-contract.ts +22 -1
  150. package/src/op/builders.ts +21 -1
  151. package/src/op/effect-step.test.ts +95 -1
  152. package/src/op/gate-alias.e2e.test.ts +78 -0
  153. package/src/op/gate-alias.test.ts +350 -0
  154. package/src/op/gate-alias.ts +137 -0
  155. package/src/op/gate-checkpoint.test.ts +284 -0
  156. package/src/op/gate-checkpoint.ts +322 -0
  157. package/src/op/gate.ts +198 -4
  158. package/src/op/index.ts +4 -1
  159. package/src/op/local-executor.test.ts +65 -0
  160. package/src/op/local-executor.ts +248 -12
  161. package/src/op/local-output.ts +18 -2
  162. package/src/op/op-ir.test.ts +25 -0
  163. package/src/op/op-ir.ts +8 -0
  164. package/src/op/receipt-store.ts +13 -3
  165. package/src/op/runtime.ts +8 -0
  166. package/src/op/runtimes/local.ts +5 -0
  167. package/src/op/step-output-ref.test.ts +68 -24
  168. package/src/op/step-output-ref.ts +85 -65
  169. package/src/op/types.ts +32 -0
  170. package/src/workspace/agent-cli.ts +2 -0
  171. package/src/workspace/conformance/__writer_fixture__/answers/answer.schema.json +16 -1
  172. package/src/workspace/conformance/writer.ts +1 -1
  173. package/src/workspace/decide.test.ts +87 -3
  174. package/src/workspace/decide.ts +142 -17
  175. package/src/workspace/lineage-upgrade.test.ts +21 -0
  176. package/src/workspace/lineage-upgrade.ts +19 -11
  177. package/src/workspace/point-answer.schema.json +16 -1
  178. package/src/workspace/points-cli.ts +16 -8
  179. package/src/workspace/points-write.schema.json +39 -4
  180. package/src/workspace/points.schema.json +42 -3
  181. package/src/workspace/reason-codes.ts +2 -1
  182. package/src/workspace/record-assets.test.ts +14 -0
  183. package/src/workspace/records-cli.ts +28 -7
  184. package/src/workspace/records-write-commands.e2e.test.ts +32 -0
  185. package/src/workspace/records-write.ts +2 -3
  186. package/src/workspace/records.schema.json +2 -1
  187. package/src/workspace/records.ts +17 -4
  188. package/src/workspace/trust/gate-seal.test.ts +29 -2
  189. package/src/workspace/trust/gate-seal.ts +46 -2
  190. package/src/workspace/trust/seal.ts +1 -1
  191. package/src/workspace/trust/trust.test.ts +105 -0
  192. package/src/workspace/trust/verify-cli.ts +2 -1
  193. package/src/workspace/trust/verify.ts +106 -13
  194. package/src/workspace/write-scope.test.ts +18 -0
  195. package/src/workspace/write-scope.ts +16 -5
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://json-schema.org/draft/2020-12/schema",
3
3
  "$id": "https://intentius.io/chant/schemas/workspace/points/v1/points.schema.json",
4
4
  "title": "chant workspace points output",
5
- "description": "What `chant workspace points --json` prints (ws-058, #2739): the decision points each declared answer kind's points file declares, and the questions asked of them, each an answer record. A question is open while it is escalated to people, or proposed by a model and not yet confirmed; it carries any model's answer with its confidence and threshold, so a reader can prompt a person. With --open only open questions are listed. chant never calls a model to print this. Readers ignore fields they do not know; a field is only ever added within a version. The reason and error codes are closed lists, each in the one closed list of `reason-codes.ts`. Contract version 1 is written by chant 0.81.0 and newer, and this document was added to it by #2739; a chant without it refuses the command.",
5
+ "description": "What `chant workspace points --json` prints (ws-058, #2739): the decision points each declared answer kind's points file declares, and the questions asked of them, each an answer record. A question is open while it is escalated to people, or proposed by a model and not yet confirmed, and not withdrawn (#2888); it carries any model's answer with its confidence and threshold, so a reader can prompt a person. With --open only open questions are listed. chant never calls a model to print this. Readers ignore fields they do not know; a field is only ever added within a version. The reason and error codes are closed lists, each in the one closed list of `reason-codes.ts`. Contract version 1 is written by chant 0.81.0 and newer, and this document was added to it by #2739; a chant without it refuses the command.",
6
6
  "oneOf": [
7
7
  {
8
8
  "$ref": "#/$defs/result"
@@ -95,7 +95,8 @@
95
95
  "open",
96
96
  "escalated",
97
97
  "proposed",
98
- "answered"
98
+ "answered",
99
+ "withdrawn"
99
100
  ],
100
101
  "properties": {
101
102
  "points": {
@@ -121,6 +122,11 @@
121
122
  "answered": {
122
123
  "type": "integer",
123
124
  "minimum": 0
125
+ },
126
+ "withdrawn": {
127
+ "type": "integer",
128
+ "minimum": 0,
129
+ "description": "Questions withdrawn because they no longer apply (#2888), whatever their state; none of them is open."
124
130
  }
125
131
  }
126
132
  }
@@ -765,6 +771,38 @@
765
771
  }
766
772
  }
767
773
  },
774
+ "withdrawn": {
775
+ "type": [
776
+ "object",
777
+ "null"
778
+ ],
779
+ "description": "Who withdrew the question, when and why (points withdraw, #2888): an open question that no longer applies, such as one about a work item its author changed. A withdrawn question keeps its state and is not open. null when it was not withdrawn, and absent from a chant before it.",
780
+ "required": [
781
+ "by",
782
+ "on",
783
+ "note"
784
+ ],
785
+ "properties": {
786
+ "by": {
787
+ "type": "array",
788
+ "items": {
789
+ "type": "string"
790
+ }
791
+ },
792
+ "on": {
793
+ "type": [
794
+ "string",
795
+ "null"
796
+ ]
797
+ },
798
+ "note": {
799
+ "type": [
800
+ "string",
801
+ "null"
802
+ ]
803
+ }
804
+ }
805
+ },
768
806
  "asked": {
769
807
  "description": "An ad-hoc point's question as its ask gave it (points ask --candidates, #3403): the question's text, and its criteria, what each candidate means, in the point's question type's shape. People's answer is one of the candidates. null for a declared point's question, and absent from a chant before it.",
770
808
  "oneOf": [
@@ -825,7 +863,8 @@
825
863
  "answer-points-unreadable",
826
864
  "answer-point-unknown",
827
865
  "answer-point-changed",
828
- "record-unattested"
866
+ "record-unattested",
867
+ "record-ratified-below-quorum"
829
868
  ]
830
869
  },
831
870
  "message": {
@@ -112,6 +112,7 @@ export const REASONS = {
112
112
  "seal-unverifiable": "Nothing here can say whose seal it is: there is no signers file at base, ssh-keygen is not installed, or the record came back in a return sealed by a signer no admission lists yet.",
113
113
  // A record whose author seal is not attested under a signers file at base (records, #2688). A warning: the record is still read.
114
114
  "record-unattested": "A signers file is active at base, and the record names an author whose seal does not verify: it has none, the author has no key in the file, or the signature fails.",
115
+ "record-ratified-below-quorum": "The record is in its kind's ratified state (reviews.ratified), and its quorum is not met: it was written to that state by hand, past the check of records new and amend. It is still read, and stays valid.",
115
116
  // A records read that fails (records).
116
117
  "kind-unreadable": "The record kind file is missing or could not be imported.",
117
118
  "kind-invalid": "The record kind file exports no recordKind, or its shape is wrong.",
@@ -161,7 +162,7 @@ export const REASONS = {
161
162
  "answer-in-steward-turn": "The answer was given during a steward's turn, or by a process it started: a steward never answers a decision point, and a person answers it through hud or at a shell.",
162
163
  "answer-never-over-mcp": "The answer or retraction came over MCP or ACP, or from a process a session on one started, and the question constrains a gate that is never resolved there: a person answers it at a shell or through hud (#3817).",
163
164
  "answer-not-answered": "points retract names a question that has no answer to retract: it is escalated or proposed, and people answer it instead (#3351).",
164
- "answer-field-unsupported": "The answer kind's copy of point-answer.schema.json predates a field the write needs, a note, a relay, a retraction or an ad-hoc question's asked: copy the schema anew to turn it on (#3351, #3402, #3403).",
165
+ "answer-field-unsupported": "The answer kind's copy of point-answer.schema.json predates a field the write needs, a note, a relay, a retraction, an ad-hoc question's asked or a withdrawal: copy the schema anew to turn it on (#3351, #3402, #3403, #2888).",
165
166
  "point-candidates-invalid": "points ask names an ad-hoc point without --candidates, a declared point with them, or candidates that are not a question and criteria of the point's question type (#3403).",
166
167
  // records --since that fails (#2673).
167
168
  "since-rev-unknown": "--since names no commit, or a session with no opening revision and no commit that added it.",
@@ -181,6 +181,20 @@ describe("chant workspace records checks each pin", () => {
181
181
  expect(pinFile("ws/design/screens/home.png", root)).toEqual({ path: "design/screens/home.png", sha256: sha(PNG) });
182
182
  expect(pinFile("design", join(root, "ws"))).toEqual({ error: "design is not a file" });
183
183
  });
184
+
185
+ test("records pin takes the workspace above the current directory or the kind file, not the nested one above the file (#3038)", () => {
186
+ const root = repo({
187
+ "chant.workspace.json": declaration([{ name: "template", dir: "template", kind: "other", because: "a template" }]),
188
+ "template/chant.workspace.json": declaration([{ name: "box", dir: "box", kind: "other", because: "the box" }]),
189
+ "template/box/builder.mjs": "export {};\n",
190
+ "decisions/decision.kind.mjs": KIND,
191
+ });
192
+ const hash = sha("export {};\n");
193
+ expect(pinFile("template/box/builder.mjs", root)).toEqual({ path: "template/box/builder.mjs", sha256: hash });
194
+ expect(pinFile("box/builder.mjs", join(root, "template"))).toEqual({ path: "box/builder.mjs", sha256: hash });
195
+ expect(pinFile("box/builder.mjs", join(root, "template"), "../decisions/decision.kind.mjs")).toEqual({ path: "template/box/builder.mjs", sha256: hash });
196
+ expect(pinFile("chant.workspace.json", root, "template/box/builder.mjs")).toMatchObject({ error: expect.stringContaining("not a file path inside the workspace") });
197
+ });
184
198
  });
185
199
 
186
200
  describe("chant workspace graph --kind", () => {
@@ -78,7 +78,7 @@ export const RECORDS_CONTRACT_VERSION = 1;
78
78
  export const RECORDS_OUTPUT_SCHEMA_ID = "https://intentius.io/chant/schemas/workspace/records/v1/records.schema.json";
79
79
 
80
80
  const USAGE =
81
- "chant workspace records [--kind <kind file>] [--current] [--uncommitted | --at <rev>] [--base <rev>] [--require attested] [--json] | chant workspace records [--kind <kind file>] --since <rev|session id> [--at <rev>] [--json] | chant workspace records pin <path> | chant workspace records new|amend|review|close (#2670, #2693)";
81
+ "chant workspace records [--kind <kind file>] [--current] [--uncommitted | --at <rev>] [--base <rev>] [--require attested] [--json] | chant workspace records [--kind <kind file>] --since <rev|session id> [--at <rev>] [--json] | chant workspace records pin <path> [--kind <kind file>] | chant workspace records new|amend|review|close (#2670, #2693)";
82
82
 
83
83
  /** Exit code when the read worked and a record falls below `--require`. */
84
84
  export const EXIT_BELOW_REQUIRED = 2;
@@ -336,6 +336,16 @@ export async function quorumOptionsFor(loaded: LoadedRecordKind, tree: Workspace
336
336
  };
337
337
  }
338
338
 
339
+ /**
340
+ * What a quorum that is not met lacks, for `ratify-quorum-not-met` and
341
+ * `record-ratified-below-quorum` (#2873, #2878): the need, the agreeing
342
+ * verdicts that count, and each verdict that does not count, with why.
343
+ */
344
+ export function quorumShortfall(q: Quorum): string {
345
+ const notCounted = q.notCounted.length > 0 ? ` (not counted: ${q.notCounted.map((v) => `${v.reviewer}, ${v.reason!.code}`).join("; ")})` : "";
346
+ return `its quorum needs ${q.need} agreeing verdicts that count, and it has ${q.agreed}${notCounted}`;
347
+ }
348
+
339
349
  /**
340
350
  * Where a kind's pinned paths resolve: the workspace whose declaration sits
341
351
  * nearest above the kind file, when it is inside the repository, or else the
@@ -446,10 +456,16 @@ export async function queryRecords(query: RecordsQuery): Promise<RecordsDocument
446
456
  // Returned work (#2552): its seals verify against the signers admitted for its return too.
447
457
  const recordPolicy = p.returned ? returnedPolicy(policy, p.returned.id) : policy;
448
458
  const options = p.returned ? await quorumOptionsFor(loaded, tree, recordPolicy) : quorumOptions;
459
+ const quorum = options ? computeQuorum(loaded.kind, r, options) : null;
460
+ const ratified = loaded.kind.reviews?.ratified;
461
+ if (quorum && !quorum.met && ratified !== undefined && r.state === ratified) {
462
+ // Written to the ratified state by hand, past the check of records new and amend (#2878).
463
+ r.warnings.push({ code: "record-ratified-below-quorum", message: `${r.id ?? r.path} is ${ratified}, and ${quorumShortfall(quorum)}` });
464
+ }
449
465
  records.push({
450
466
  ...r,
451
467
  provenance: p,
452
- ...(options ? { quorum: computeQuorum(loaded.kind, r, options) } : {}),
468
+ ...(options ? { quorum } : {}),
453
469
  ...(seals ? authorSeal(loaded.kind, r, recordPolicy, seals.checkRecordSeal) : {}),
454
470
  });
455
471
  }
@@ -635,12 +651,17 @@ async function raiseWorkGaps(loaded: LoadedRecordKind, records: RecordView[], op
635
651
  /**
636
652
  * `chant workspace records pin <path>`: the `{path, sha256}` a decision's
637
653
  * evidence entry holds for a file, with the path from the workspace root
638
- * (the nearest declaration above the file, or the repository root).
654
+ * records resolve pins in. With `kind`, that is the declaration nearest above
655
+ * the kind file, or the repository root, as {@link pinRoot} has it. Without,
656
+ * it is the declaration nearest above `cwd`, and only when there is none the
657
+ * one nearest above the file, so a workspace nested inside the current one is
658
+ * never taken for it (#3038).
639
659
  */
640
- export function pinFile(file: string, cwd: string): { path: string; sha256: string } | { error: string } {
660
+ export function pinFile(file: string, cwd: string, kind?: string): { path: string; sha256: string } | { error: string } {
641
661
  const abs = realpathOr(resolve(cwd, file));
642
- const top = gitRoot(dirname(abs));
643
- const ws = findWorkspaceRoot(dirname(abs));
662
+ const from = kind === undefined ? dirname(abs) : dirname(resolve(cwd, kind));
663
+ const ws = kind === undefined ? (findWorkspaceRoot(cwd) ?? findWorkspaceRoot(from)) : findWorkspaceRoot(from);
664
+ const top = gitRoot(from);
644
665
  const base = ws ? realpathOr(ws.dir) : top ? realpathOr(top) : realpathOr(cwd);
645
666
  const path = relative(base, abs).split(sep).join("/");
646
667
  if (path.startsWith("..") || !isWorkspacePath(path)) return { error: `${file} is not a file path inside the workspace at ${base}` };
@@ -659,7 +680,7 @@ export async function runWorkspaceRecords(ctx: CommandContext): Promise<number>
659
680
  console.error(formatError({ message: "pin needs the path of a file", hint: USAGE }));
660
681
  return 1;
661
682
  }
662
- const pin = pinFile(args.extraPositional2, process.cwd());
683
+ const pin = pinFile(args.extraPositional2, process.cwd(), args.kind);
663
684
  if ("error" in pin) {
664
685
  console.error(formatError({ message: pin.error, hint: USAGE }));
665
686
  return 1;
@@ -419,6 +419,38 @@ describe("ratifying against the quorum (#2873)", () => {
419
419
  expect(code(doc)).toBe("ratify-quorum-not-met");
420
420
  });
421
421
 
422
+ test("a record edited by hand to ratified below its quorum reads with record-ratified-below-quorum, and stays valid (#2878)", async () => {
423
+ const read = async (id: string, kind = KIND) => {
424
+ const doc = await queryRecords({ kind, cwd: dir });
425
+ if ("error" in doc) throw new Error(doc.error.message);
426
+ return doc.records.find((r) => r.id === id)!;
427
+ };
428
+ const warned = (r: { warnings: { code: string }[] }) => r.warnings.some((w) => w.code === "record-ratified-below-quorum");
429
+ // A decided record below its quorum is not warned about.
430
+ await agree("alice");
431
+ await agree("lex00");
432
+ expect(warned(await read("ws-001"))).toBe(false);
433
+ writeFileSync(join(dir, "decisions", "ws-001-one.md"), text("ws-001-one.md").replace('state: "decided"', 'state: "ratified"'));
434
+ const r = await read("ws-001");
435
+ expect(r).toMatchObject({ state: "ratified", valid: true, reasons: [] });
436
+ expect(r.quorum).toMatchObject({ need: 2, agreed: 1, met: false });
437
+ expect(r.warnings.find((w) => w.code === "record-ratified-below-quorum")!.message).toBe(
438
+ "ws-001 is ratified, and its quorum needs 2 agreeing verdicts that count, and it has 1 (not counted: lex00, review-decider)",
439
+ );
440
+ // A record ratified through amend at its quorum carries no such warning.
441
+ put("ws-006-six.md", decision({ id: "ws-006", title: "Six" }));
442
+ await agree("alice", "ws-006");
443
+ await agree("bob", "ws-006");
444
+ expect(await ratify("ws-006")).toMatchObject({ changed: ["state", "closed_digest"] });
445
+ expect(warned(await read("ws-006"))).toBe(false);
446
+ // A kind without reviews.ratified never warns: ws-003 opened ratified with no verdicts.
447
+ expect(warned(await read("ws-003"))).toBe(true);
448
+ // A kind module is cached by its path, so the kind without it is a second file.
449
+ const kindFile = join(dir, "decisions", "decision.kind.mjs");
450
+ writeFileSync(join(dir, "decisions", "plain.kind.mjs"), readFileSync(kindFile, "utf-8").replace(', ratified: "ratified" }', " }"));
451
+ expect(warned(await read("ws-003", "decisions/plain.kind.mjs"))).toBe(false);
452
+ });
453
+
422
454
  test("records new refuses a record that opens ratified below its quorum", async () => {
423
455
  const fresh = decision({ title: "Straight in", state: "ratified" });
424
456
  delete fresh.id;
@@ -61,7 +61,7 @@ import {
61
61
  type RecordEntry,
62
62
  type RecordWarning,
63
63
  } from "./records";
64
- import { declaredKindFiles, pinRoot, quorumOptionsFor, realpathOr } from "./records-cli";
64
+ import { declaredKindFiles, pinRoot, quorumOptionsFor, quorumShortfall, realpathOr } from "./records-cli";
65
65
  import { policyAtBase, resolveBase } from "./trust/provenance";
66
66
  import { WorkspaceReadError } from "./declaration";
67
67
  import { findSessionKinds, headCommit, sessionKindsFor } from "./session-kinds";
@@ -516,10 +516,9 @@ async function refuseRatifyBelowQuorum(o: Opened, written: RecordEntry, id: stri
516
516
  const options = await quorumOptionsFor(o.loaded, tree, policy);
517
517
  const q = options ? computeQuorum(o.loaded.kind, written, options) : null;
518
518
  if (q === null || q.met) return;
519
- const notCounted = q.notCounted.length > 0 ? ` (not counted: ${q.notCounted.map((v) => `${v.reviewer}, ${v.reason!.code}`).join("; ")})` : "";
520
519
  throw new RecordWriteError(
521
520
  "ratify-quorum-not-met",
522
- `${id} can't be ${ratified} yet: its quorum needs ${q.need} agreeing verdicts that count, and it has ${q.agreed}${notCounted}. Add verdicts with chant workspace records review ${id} --verdict agree --by <reviewer>, then set ${o.loaded.kind.stateField} to ${ratified} again`,
521
+ `${id} can't be ${ratified} yet: ${quorumShortfall(q)}. Add verdicts with chant workspace records review ${id} --verdict agree --by <reviewer>, then set ${o.loaded.kind.stateField} to ${ratified} again`,
523
522
  );
524
523
  }
525
524
 
@@ -413,7 +413,8 @@
413
413
  "answer-points-unreadable",
414
414
  "answer-point-unknown",
415
415
  "answer-point-changed",
416
- "record-unattested"
416
+ "record-unattested",
417
+ "record-ratified-below-quorum"
417
418
  ]
418
419
  },
419
420
  "message": { "type": "string" }
@@ -147,8 +147,9 @@ export const SEAL_REASON_CODES = [
147
147
  export type SealCode = (typeof SEAL_REASON_CODES)[number];
148
148
 
149
149
  /**
150
- * A warning about a record's own seal (#2688), raised by `records` with the
151
- * policy at base, never by a write. Closed, like the reason codes.
150
+ * A warning `records` raises with the policy at base, never by a write: about
151
+ * a record's own seal (#2688), or a ratified record's quorum (#2878). Closed,
152
+ * like the reason codes.
152
153
  */
153
154
  export const SEAL_WARNING_CODES = [
154
155
  /**
@@ -158,6 +159,13 @@ export const SEAL_WARNING_CODES = [
158
159
  * fails. The record is still read, and still valid.
159
160
  */
160
161
  "record-unattested",
162
+ /**
163
+ * The record is in its kind's ratified state (`reviews.ratified`, #2873),
164
+ * and its quorum, computed as `records` computes it, is not met: it was
165
+ * written to that state by hand, past the check of `records new` and
166
+ * `amend`. The record is still read, and still valid (#2878).
167
+ */
168
+ "record-ratified-below-quorum",
161
169
  ] as const satisfies readonly ReasonCode[];
162
170
  export type SealWarningCode = (typeof SEAL_WARNING_CODES)[number];
163
171
 
@@ -201,7 +209,7 @@ export interface VerdictAttestation {
201
209
  }
202
210
 
203
211
  export interface RecordWarning {
204
- /** 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). */
212
+ /** 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, #2878). */
205
213
  code: RecordWarningCode | WorkWarningCode | SealWarningCode | AnswerWarningCode;
206
214
  message: string;
207
215
  }
@@ -618,12 +626,17 @@ export interface LoadedRecordKind {
618
626
  * Import the kind file at `path` through the path loader lexicons use (#2520).
619
627
  * The kind is registered under a name no lexicon package can have (npm names
620
628
  * hold no `:`), imported and unregistered again, so no lexicon lookup sees it.
629
+ *
630
+ * A kind file is a data-only ES module, so a `.mjs`, `.cjs` or `.js` one loads
631
+ * with plain node and only a TypeScript one registers tsx (#3276). That keeps
632
+ * `workspace ls`, `status` and `records` from paying for tsx's loader thread
633
+ * on every read.
621
634
  */
622
635
  export async function importKindModule(path: string): Promise<Record<string, unknown>> {
623
636
  const name = `record-kind:${path}`;
624
637
  registerLexiconDeclarations([{ name, module: path }], dirname(path));
625
638
  try {
626
- const mod = await importLexiconModule(name);
639
+ const mod = await importLexiconModule(name, { typeScript: !/\.[cm]?js$/.test(path) });
627
640
  if (!mod) throw new Error(`${path} was not registered`);
628
641
  return mod;
629
642
  } finally {
@@ -11,7 +11,7 @@ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
11
11
  import { tmpdir } from "node:os";
12
12
  import { join } from "node:path";
13
13
  import { afterAll, describe, expect, test } from "vitest";
14
- import { GATE_SEAL_NAMESPACE, gateSealPayload, gateSealRefusal, sshKeyFingerprint, verifySshSignatureWith, type GateSigners, type SealedGateApproval } from "./gate-seal";
14
+ import { GATE_SEAL_NAMESPACE, gateSealPayload, gateSealRefusal, gateSealVersion, sshKeyFingerprint, verifySshSignatureWith, type GateSigners, type SealedGateApproval } from "./gate-seal";
15
15
  import { checkGateSeal } from "./seal";
16
16
  import { emptyPolicy, parseAllowedSigners, type TrustPolicy } from "./policy";
17
17
  import { hasSshKeygen } from "./test-repo";
@@ -96,7 +96,7 @@ describe.skipIf(!hasSshKeygen)("an SSHSIG checked with node:crypto", () => {
96
96
  const policy = policyOf(`alice ${k.pub}\n`);
97
97
  const seal = { signer: "alice", key: "SHA256:x", signature };
98
98
  expect(both(policy, { ...a, seal })).toEqual({ ours: null, keygen: true });
99
- for (const edited of [{ planDigest: "jcs1-sha256:other" }, { timestamp: "2026-10-12T00:00:00.000Z" }, { relayedBy: "bot" }, { environment: "prod" }]) {
99
+ for (const edited of [{ planDigest: "jcs1-sha256:other" }, { timestamp: "2026-10-12T00:00:00.000Z" }, { relayedBy: "bot" }, { environment: "prod" }, { checkpointDigest: "jcs1-sha256:added" }]) {
100
100
  const res = both(policy, { ...a, ...edited, seal });
101
101
  expect(res.keygen).toBe(false);
102
102
  expect(res.ours).toMatch(/does not verify for alice/);
@@ -137,6 +137,33 @@ describe.skipIf(!hasSshKeygen)("an SSHSIG checked with node:crypto", () => {
137
137
  });
138
138
  });
139
139
 
140
+ // #3947: seal version 2 signs the checkpoint digest as one more line, so the
141
+ // outputs a resumed run carries on with are covered, and a version 1 payload
142
+ // is unchanged.
143
+ describe("seal version 2 covers the checkpoint", () => {
144
+ test("the payload adds a checkpoint line only when the approval carries one", () => {
145
+ const a = approval({ relayedBy: "bot" });
146
+ expect(gateSealVersion(a)).toBe(1);
147
+ expect(gateSealPayload(a).toString()).toBe("migrations\nmigrations-wave-1\n\njcs1-sha256:abc\nalice\n2026-10-11T00:00:00.000Z\nrelayed-by bot");
148
+ const b = approval({ relayedBy: "bot", checkpointDigest: "jcs1-sha256:cp" });
149
+ expect(gateSealVersion(b)).toBe(2);
150
+ expect(gateSealPayload(b).toString()).toBe(`${gateSealPayload(a).toString()}\ncheckpoint jcs1-sha256:cp`);
151
+ });
152
+
153
+ test.skipIf(!hasSshKeygen)("a version 2 seal verifies, and swapping or removing the checkpoint stops it", () => {
154
+ const k = key("alice-cp", ["-t", "ed25519"]);
155
+ const a = approval({ checkpointDigest: "jcs1-sha256:cp" });
156
+ const seal = { signer: "alice", key: "SHA256:x", signature: sign(k, gateSealPayload(a)), version: 2 };
157
+ const policy = policyOf(`alice ${k.pub}\n`);
158
+ expect(both(policy, { ...a, seal })).toEqual({ ours: null, keygen: true });
159
+ for (const edited of [{ checkpointDigest: "jcs1-sha256:other" }, { checkpointDigest: undefined }]) {
160
+ const res = both(policy, { ...a, ...edited, seal });
161
+ expect(res.keygen).toBe(false);
162
+ expect(res.ours).toMatch(/does not verify for alice/);
163
+ }
164
+ });
165
+ });
166
+
140
167
  describe("the refusals that need no signature", () => {
141
168
  const signers = (over: Partial<GateSigners> = {}): GateSigners => ({ base: "a".repeat(40), signersPath: ".chant/allowed_signers", active: true, signers: [], problems: [], ...over });
142
169
 
@@ -37,9 +37,42 @@ export interface SealedGateApproval {
37
37
  /** Who relayed the approval for `resolvedBy` (#3402), when someone did. */
38
38
  relayedBy?: string;
39
39
  timestamp: string;
40
+ /** The checkpoint the approval lets a run resume from (#3947), when it carries one. */
41
+ checkpointDigest?: string;
42
+ /** The line a carried approval came from (#3949): the names and time its seal was made under. */
43
+ carriedFrom?: { op: string; gate: string; timestamp: string; checkpointDigest?: string };
40
44
  seal?: unknown;
41
45
  }
42
46
 
47
+ /**
48
+ * The approval as it was sealed (#3949). A carried approval is recorded
49
+ * under a gate's new name and the time it was carried, and its seal was
50
+ * made over the line it came from, so its op, gate, time and checkpoint
51
+ * digest are read off `carriedFrom`. Any other approval is returned as it is.
52
+ */
53
+ export function sealedFieldsOf(a: SealedGateApproval): SealedGateApproval {
54
+ const from = a.carriedFrom;
55
+ if (from === undefined) return a;
56
+ const { carriedFrom: _from, checkpointDigest: _checkpoint, ...rest } = a;
57
+ return {
58
+ ...rest,
59
+ op: from.op,
60
+ gate: from.gate,
61
+ timestamp: from.timestamp,
62
+ ...(from.checkpointDigest !== undefined ? { checkpointDigest: from.checkpointDigest } : {}),
63
+ };
64
+ }
65
+
66
+ /**
67
+ * The version of a seal over `a` (#3947): 2 when the approval carries a
68
+ * checkpoint digest, which the payload signs, and 1 otherwise. `chant approve
69
+ * --sign` records 2 on the seal; a seal records no version for 1.
70
+ */
71
+ export function gateSealVersion(a: Pick<SealedGateApproval, "checkpointDigest" | "carriedFrom">): 1 | 2 {
72
+ const checkpoint = a.carriedFrom !== undefined ? a.carriedFrom.checkpointDigest : a.checkpointDigest;
73
+ return checkpoint !== undefined ? 2 : 1;
74
+ }
75
+
43
76
  /**
44
77
  * The bytes a gate approval's seal signs (#3163): the op (or component), the
45
78
  * gate, the environment, the plan digest, the approver and the time, joined
@@ -48,10 +81,21 @@ export interface SealedGateApproval {
48
81
  * `relayed-by <principal>`, so the relay can't be added, changed or removed
49
82
  * after sealing; an approval nobody relayed signs the six lines it always did,
50
83
  * so seals made before #3402 still verify.
84
+ *
85
+ * Seal version 2 (#3947) adds the checkpoint a resumed run carries its step
86
+ * outputs from: an approval with a `checkpointDigest` signs one more line,
87
+ * `checkpoint <digest>`, last. The values the steps after the gate read are
88
+ * then covered by the seal too, and removing or swapping the digest stops it
89
+ * verifying. An approval with none signs what a version 1 seal signed.
90
+ *
91
+ * A carried approval (#3949) signs what the line it came from signed: see
92
+ * {@link sealedFieldsOf}.
51
93
  */
52
- export function gateSealPayload(a: SealedGateApproval): Buffer {
94
+ export function gateSealPayload(approval: SealedGateApproval): Buffer {
95
+ const a = sealedFieldsOf(approval);
53
96
  const relay = a.relayedBy !== undefined ? `\nrelayed-by ${a.relayedBy}` : "";
54
- return Buffer.from(`${a.op}\n${a.gate}\n${a.environment ?? ""}\n${a.planDigest ?? ""}\n${a.resolvedBy}\n${a.timestamp}${relay}`, "utf-8");
97
+ const checkpoint = gateSealVersion(a) === 2 ? `\ncheckpoint ${a.checkpointDigest}` : "";
98
+ return Buffer.from(`${a.op}\n${a.gate}\n${a.environment ?? ""}\n${a.planDigest ?? ""}\n${a.resolvedBy}\n${a.timestamp}${relay}${checkpoint}`, "utf-8");
55
99
  }
56
100
 
57
101
  // ── SSHSIG ───────────────────────────────────────────────────────────────
@@ -32,7 +32,7 @@ import type { Signer } from "./trust-files";
32
32
  import { sshKeygen, verifySshSignature } from "./ssh-commit";
33
33
  import { GATE_SEAL_NAMESPACE, gateSealPayload, namespaceAllowed, sshSignatureKey, verifySshSignatureWith, type SealedGateApproval } from "./gate-seal";
34
34
 
35
- export { GATE_SEAL_NAMESPACE, gateSealPayload, type SealedGateApproval } from "./gate-seal";
35
+ export { GATE_SEAL_NAMESPACE, gateSealPayload, gateSealVersion, sealedFieldsOf, type SealedGateApproval } from "./gate-seal";
36
36
 
37
37
  /** The ssh signature namespace of a verdict seal. */
38
38
  export const REVIEW_SEAL_NAMESPACE = "chant-review";
@@ -8,6 +8,7 @@
8
8
  */
9
9
 
10
10
  import { execFileSync } from "node:child_process";
11
+ import { join } from "node:path";
11
12
  import Ajv2020 from "ajv/dist/2020";
12
13
  import schema from "../records.schema.json";
13
14
  import { afterEach, describe, expect, test } from "vitest";
@@ -357,6 +358,110 @@ describe.skipIf(!hasSshKeygen)("the ssh-commit attestor, against the policy at b
357
358
  });
358
359
  });
359
360
 
361
+ describe.skipIf(!hasSshKeygen)("writeScope and agents in the declaration are protected (#3079)", () => {
362
+ const scoped = (over: Record<string, unknown> = {}) =>
363
+ JSON.stringify(
364
+ {
365
+ name: "ws",
366
+ schema: 1,
367
+ members: [{ name: "app", dir: "app", kind: "other", because: "plain files" }],
368
+ writeScope: { agent: { records: {} } },
369
+ agents: [{ name: "app-agent", member: "app" }],
370
+ ...over,
371
+ },
372
+ null,
373
+ 2,
374
+ );
375
+
376
+ /** main: alice and bob sign, alice is admin when `admin`; the declaration at base restricts agents. */
377
+ function baseline(label: string, opts: { admin?: boolean; declaration?: string; dir?: string } = {}) {
378
+ const r = repo(label);
379
+ const alice = r.key("alice");
380
+ const bob = r.key("bob");
381
+ r.write(".chant/allowed_signers", signers(["alice@example.test", alice], ["bob@example.test", bob]));
382
+ if (opts.admin) r.write(".chant/trust.json", JSON.stringify({ schema: 1, roles: { admin: ["alice@example.test"] } }));
383
+ r.commit("policy", alice);
384
+ r.write(`${opts.dir ? `${opts.dir}/` : ""}chant.workspace.json`, opts.declaration ?? scoped());
385
+ r.commit("declaration", alice);
386
+ r.git(["checkout", "-q", "-b", "change"]);
387
+ return { r, alice, bob };
388
+ }
389
+
390
+ test("an unsigned commit that widens writeScope is a refused protected write", async () => {
391
+ const { r } = baseline("scope-unsigned");
392
+ r.write("chant.workspace.json", scoped({ writeScope: { agent: { records: { decision: ["new", "amend"] } } } }));
393
+ const c = r.commit("let agents amend");
394
+ const report = await verify(r);
395
+ expect(report.ok).toBe(false);
396
+ expect(report.protectedWrites).toEqual([expect.objectContaining({ commit: c, paths: ["chant.workspace.json"], allowed: false })]);
397
+ expect(report.failures.join("\n")).toMatch(/chant\.workspace\.json \(writeScope\) is protected/);
398
+ });
399
+
400
+ test("a change to agents is a protected write, allowed when a signer at base signs it", async () => {
401
+ const { r, alice } = baseline("agents-signed");
402
+ r.write("chant.workspace.json", scoped({ agents: [{ name: "app-agent", member: "app" }, { name: "other", member: "app" }] }));
403
+ r.commit("add a session", alice);
404
+ const report = await verify(r);
405
+ expect(report.protectedWrites).toEqual([expect.objectContaining({ allowed: true, principal: "alice@example.test", blocks: ["agents"] })]);
406
+ expect(report.ok).toBe(true);
407
+ });
408
+
409
+ test("with an admin role granted, a signer who is not an admin may not change writeScope", async () => {
410
+ const { r, bob } = baseline("scope-admin", { admin: true });
411
+ r.write("chant.workspace.json", scoped({ writeScope: {} }));
412
+ r.commit("drop the scope", bob);
413
+ const report = await verify(r);
414
+ expect(report.protectedWrites[0]).toMatchObject({ allowed: false, principal: "bob@example.test", blocks: ["writeScope"] });
415
+ expect(report.ok).toBe(false);
416
+ });
417
+
418
+ test("deleting the declaration drops the scope, and is a protected write", async () => {
419
+ const { r } = baseline("scope-deleted");
420
+ r.git(["rm", "-q", "chant.workspace.json"]);
421
+ r.commit("no more workspace");
422
+ const report = await verify(r);
423
+ expect(report.protectedWrites[0]).toMatchObject({ allowed: false, blocks: ["writeScope", "agents"] });
424
+ expect(report.ok).toBe(false);
425
+ });
426
+
427
+ test("other edits to the declaration are not protected", async () => {
428
+ const { r } = baseline("scope-other");
429
+ r.write("chant.workspace.json", scoped({ name: "renamed" }));
430
+ r.commit("rename");
431
+ const report = await verify(r);
432
+ expect(report.protectedWrites).toEqual([]);
433
+ expect(report.declaration).toEqual({ path: "chant.workspace.json", blocks: ["writeScope", "agents"], changed: [] });
434
+ expect(report.ok).toBe(true);
435
+ });
436
+
437
+ test("a scope the change adds, where the declaration at base has none, is not protected", async () => {
438
+ const { r } = baseline("scope-none", { declaration: JSON.stringify({ name: "ws", schema: 1, members: [] }) });
439
+ r.write("chant.workspace.json", scoped());
440
+ r.commit("restrict agents");
441
+ const report = await verify(r);
442
+ expect(report.protectedWrites).toEqual([]);
443
+ expect(report.declaration).toBeNull();
444
+ });
445
+
446
+ test("a change and its revert inside the range leave nothing to protect", async () => {
447
+ const { r } = baseline("scope-revert");
448
+ r.write("chant.workspace.json", scoped({ writeScope: {} }));
449
+ r.commit("drop");
450
+ r.write("chant.workspace.json", scoped());
451
+ r.commit("restore");
452
+ const report = await verify(r);
453
+ expect(report.protectedWrites).toEqual([]);
454
+ });
455
+
456
+ test("the declaration is found from cwd, as check --changes finds it", async () => {
457
+ const { r } = baseline("scope-nested", { dir: "ws" });
458
+ r.write("ws/chant.workspace.json", scoped({ writeScope: {} }));
459
+ r.commit("drop");
460
+ const report = await verifyChange({ repo: r.dir, cwd: join(r.dir, "ws"), base: "main", attestors: await activeAttestors() });
461
+ expect(report.protectedWrites).toEqual([expect.objectContaining({ paths: ["ws/chant.workspace.json"], allowed: false })]);
462
+ });
463
+ });
464
+
360
465
  describe("attestation is opt-in", () => {
361
466
  test("without a signers file at base nothing is verified, and every record is unattested", async () => {
362
467
  const r = repo("off");
@@ -26,6 +26,7 @@ export async function runWorkspaceVerify(ctx: CommandContext): Promise<number> {
26
26
  }
27
27
  const report = verifyChange({
28
28
  repo,
29
+ cwd: process.cwd(),
29
30
  base: args.base,
30
31
  head: args.head,
31
32
  require: args.require === "attested" || args.require === "attested-runs" ? args.require : undefined,
@@ -49,7 +50,7 @@ function printReport(r: ChangeReport): void {
49
50
  lines.push(` ${c.commit.slice(0, 8)} ${c.level}${who} ${c.subject}${note}`);
50
51
  }
51
52
  for (const c of r.runs.commits) lines.push(` ${c.commit.slice(0, 8)} agent run ${c.runs.join(", ") || c.run}: ${c.attested ? "attested" : "not attested"}, ${c.reason}`);
52
- for (const w of r.protectedWrites) lines.push(` protected write ${w.commit.slice(0, 8)} ${w.paths.join(", ")}: ${w.allowed ? "allowed" : "refused"}, ${w.reason}`);
53
+ for (const w of r.protectedWrites) lines.push(` protected write ${w.commit.slice(0, 8)} ${w.paths.join(", ")}${w.blocks ? ` (${w.blocks.join(", ")})` : ""}: ${w.allowed ? "allowed" : "refused"}, ${w.reason}`);
53
54
  if (r.rotation && "to" in r.rotation) lines.push(` signer set: version ${r.rotation.from} to ${r.rotation.to}, signed by ${r.rotation.signedBy.join(", ")}`);
54
55
  for (const n of r.notes) lines.push(n);
55
56
  console.log(lines.join("\n"));