@intentius/chant 0.124.23 → 0.124.25

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 (210) hide show
  1. package/dist/ci-jobs.d.ts +41 -1
  2. package/dist/ci-jobs.d.ts.map +1 -1
  3. package/dist/ci-jobs.js +48 -1
  4. package/dist/cli/commands/build.d.ts.map +1 -1
  5. package/dist/cli/commands/build.js +14 -0
  6. package/dist/cli/handlers/approve.d.ts.map +1 -1
  7. package/dist/cli/handlers/approve.js +9 -0
  8. package/dist/cli/handlers/emulator.d.ts +13 -2
  9. package/dist/cli/handlers/emulator.d.ts.map +1 -1
  10. package/dist/cli/handlers/emulator.js +63 -3
  11. package/dist/cli/handlers/promote.d.ts.map +1 -1
  12. package/dist/cli/handlers/promote.js +7 -1
  13. package/dist/cli/handlers/run-wave.d.ts.map +1 -1
  14. package/dist/cli/handlers/run-wave.js +5 -4
  15. package/dist/cli/handlers/run.d.ts +4 -3
  16. package/dist/cli/handlers/run.d.ts.map +1 -1
  17. package/dist/cli/handlers/run.js +73 -9
  18. package/dist/cli/main.d.ts.map +1 -1
  19. package/dist/cli/main.js +38 -11
  20. package/dist/cli/mcp/workspace-tools.d.ts +3 -3
  21. package/dist/cli/mcp/workspace-tools.d.ts.map +1 -1
  22. package/dist/cli/mcp/workspace-tools.js +27 -4
  23. package/dist/cli/parse-args.d.ts.map +1 -1
  24. package/dist/cli/parse-args.js +31 -0
  25. package/dist/cli/registry.d.ts +12 -0
  26. package/dist/cli/registry.d.ts.map +1 -1
  27. package/dist/components/cli-support.d.ts +45 -6
  28. package/dist/components/cli-support.d.ts.map +1 -1
  29. package/dist/components/cli-support.js +97 -22
  30. package/dist/components/driver.d.ts.map +1 -1
  31. package/dist/components/driver.js +25 -10
  32. package/dist/effect-receipt.d.ts +13 -0
  33. package/dist/effect-receipt.d.ts.map +1 -1
  34. package/dist/effect-receipt.js +19 -0
  35. package/dist/graph-detail.d.ts.map +1 -1
  36. package/dist/graph-detail.js +24 -2
  37. package/dist/lexicon-module.d.ts +7 -1
  38. package/dist/lexicon-module.d.ts.map +1 -1
  39. package/dist/lexicon-module.js +7 -2
  40. package/dist/lifecycle/git.d.ts +11 -0
  41. package/dist/lifecycle/git.d.ts.map +1 -1
  42. package/dist/lifecycle/git.js +62 -6
  43. package/dist/lifecycle/no-record.d.ts +59 -0
  44. package/dist/lifecycle/no-record.d.ts.map +1 -0
  45. package/dist/lifecycle/no-record.js +88 -0
  46. package/dist/lifecycle/receipt-plan.d.ts.map +1 -1
  47. package/dist/lifecycle/receipt-plan.js +4 -2
  48. package/dist/op/activities/shell.d.ts.map +1 -1
  49. package/dist/op/activities/shell.js +30 -9
  50. package/dist/op/emulator-lifecycle.d.ts +23 -0
  51. package/dist/op/emulator-lifecycle.d.ts.map +1 -1
  52. package/dist/op/emulator-lifecycle.js +39 -2
  53. package/dist/op/gate.d.ts.map +1 -1
  54. package/dist/op/gate.js +3 -0
  55. package/dist/op/index.d.ts +1 -1
  56. package/dist/op/index.d.ts.map +1 -1
  57. package/dist/op/index.js +1 -1
  58. package/dist/op/local-executor.d.ts +10 -0
  59. package/dist/op/local-executor.d.ts.map +1 -1
  60. package/dist/op/local-executor.js +71 -7
  61. package/dist/op/op-waves-notify.d.ts +2 -0
  62. package/dist/op/op-waves-notify.d.ts.map +1 -1
  63. package/dist/op/op-waves-notify.js +9 -3
  64. package/dist/op/op-waves-run.d.ts +8 -2
  65. package/dist/op/op-waves-run.d.ts.map +1 -1
  66. package/dist/op/op-waves-run.js +36 -11
  67. package/dist/op/op-waves.d.ts +85 -1
  68. package/dist/op/op-waves.d.ts.map +1 -1
  69. package/dist/op/op-waves.js +153 -1
  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/runtimes/local.d.ts.map +1 -1
  74. package/dist/op/runtimes/local.js +6 -0
  75. package/dist/op/step-console.d.ts +65 -0
  76. package/dist/op/step-console.d.ts.map +1 -0
  77. package/dist/op/step-console.js +140 -0
  78. package/dist/op/step-output-ref.d.ts +26 -18
  79. package/dist/op/step-output-ref.d.ts.map +1 -1
  80. package/dist/op/step-output-ref.js +86 -63
  81. package/dist/point-decision.d.ts.map +1 -1
  82. package/dist/point-decision.js +3 -0
  83. package/dist/wave-runtime.mjs +239 -239
  84. package/dist/workspace/agent-cli.d.ts.map +1 -1
  85. package/dist/workspace/agent-cli.js +3 -0
  86. package/dist/workspace/compose-graph.d.ts +22 -3
  87. package/dist/workspace/compose-graph.d.ts.map +1 -1
  88. package/dist/workspace/compose-graph.js +18 -5
  89. package/dist/workspace/conformance/__writer_fixture__/answers/answer.schema.json +16 -1
  90. package/dist/workspace/conformance/writer.js +1 -1
  91. package/dist/workspace/decide.d.ts +51 -9
  92. package/dist/workspace/decide.d.ts.map +1 -1
  93. package/dist/workspace/decide.js +125 -14
  94. package/dist/workspace/graph.schema.json +68 -1
  95. package/dist/workspace/lineage-upgrade.d.ts.map +1 -1
  96. package/dist/workspace/lineage-upgrade.js +20 -11
  97. package/dist/workspace/nested-graph.d.ts +1 -1
  98. package/dist/workspace/nested-graph.d.ts.map +1 -1
  99. package/dist/workspace/nested-graph.js +5 -1
  100. package/dist/workspace/point-answer.schema.json +16 -1
  101. package/dist/workspace/points-cli.d.ts +5 -3
  102. package/dist/workspace/points-cli.d.ts.map +1 -1
  103. package/dist/workspace/points-cli.js +16 -7
  104. package/dist/workspace/points-write.schema.json +39 -4
  105. package/dist/workspace/points.schema.json +42 -3
  106. package/dist/workspace/reason-codes.d.ts +2 -1
  107. package/dist/workspace/reason-codes.d.ts.map +1 -1
  108. package/dist/workspace/reason-codes.js +2 -1
  109. package/dist/workspace/records-cli.d.ts +12 -2
  110. package/dist/workspace/records-cli.d.ts.map +1 -1
  111. package/dist/workspace/records-cli.js +27 -7
  112. package/dist/workspace/records-write.d.ts.map +1 -1
  113. package/dist/workspace/records-write.js +2 -3
  114. package/dist/workspace/records.d.ts +10 -4
  115. package/dist/workspace/records.d.ts.map +1 -1
  116. package/dist/workspace/records.js +16 -3
  117. package/dist/workspace/records.schema.json +2 -1
  118. package/dist/workspace/runs.d.ts.map +1 -1
  119. package/dist/workspace/runs.js +4 -2
  120. package/dist/workspace/trust/verify-cli.d.ts.map +1 -1
  121. package/dist/workspace/trust/verify-cli.js +2 -1
  122. package/dist/workspace/trust/verify.d.ts +22 -0
  123. package/dist/workspace/trust/verify.d.ts.map +1 -1
  124. package/dist/workspace/trust/verify.js +94 -14
  125. package/dist/workspace/write-scope.d.ts +3 -1
  126. package/dist/workspace/write-scope.d.ts.map +1 -1
  127. package/dist/workspace/write-scope.js +11 -4
  128. package/package.json +1 -1
  129. package/src/ci-jobs.ts +58 -1
  130. package/src/cli/commands/build.ts +18 -0
  131. package/src/cli/compiled-cli.e2e.test.ts +45 -1
  132. package/src/cli/handlers/approve.ts +9 -0
  133. package/src/cli/handlers/emulator.test.ts +61 -1
  134. package/src/cli/handlers/emulator.ts +65 -4
  135. package/src/cli/handlers/promote.test.ts +7 -0
  136. package/src/cli/handlers/promote.ts +7 -1
  137. package/src/cli/handlers/run-no-record.e2e.test.ts +87 -0
  138. package/src/cli/handlers/run-wave.ts +5 -4
  139. package/src/cli/handlers/run.test.ts +126 -0
  140. package/src/cli/handlers/run.ts +69 -9
  141. package/src/cli/main.test.ts +34 -0
  142. package/src/cli/main.ts +38 -11
  143. package/src/cli/mcp/workspace-tools.e2e.test.ts +1 -1
  144. package/src/cli/mcp/workspace-tools.ts +27 -4
  145. package/src/cli/parse-args.ts +21 -0
  146. package/src/cli/registry.ts +12 -0
  147. package/src/components/cli-support.test.ts +75 -0
  148. package/src/components/cli-support.ts +130 -25
  149. package/src/components/driver.test.ts +28 -0
  150. package/src/components/driver.ts +23 -10
  151. package/src/components/generate-disabled.test.ts +56 -0
  152. package/src/effect-receipt.ts +22 -0
  153. package/src/graph-ci-edges.test.ts +50 -0
  154. package/src/graph-detail.ts +21 -2
  155. package/src/lexicon-module.ts +9 -2
  156. package/src/lifecycle/git.ts +61 -6
  157. package/src/lifecycle/no-record.test.ts +175 -0
  158. package/src/lifecycle/no-record.ts +108 -0
  159. package/src/lifecycle/receipt-plan.test.ts +9 -0
  160. package/src/lifecycle/receipt-plan.ts +4 -1
  161. package/src/op/activities/shell.ts +28 -6
  162. package/src/op/effect-step.test.ts +95 -1
  163. package/src/op/emulator-lifecycle.test.ts +24 -1
  164. package/src/op/emulator-lifecycle.ts +58 -2
  165. package/src/op/gate.ts +3 -0
  166. package/src/op/index.ts +1 -1
  167. package/src/op/local-executor.test.ts +65 -0
  168. package/src/op/local-executor.ts +88 -8
  169. package/src/op/op-waves-jobs.test.ts +213 -0
  170. package/src/op/op-waves-notify.ts +11 -5
  171. package/src/op/op-waves-run.ts +42 -11
  172. package/src/op/op-waves.ts +202 -2
  173. package/src/op/receipt-store.ts +13 -3
  174. package/src/op/runtimes/local.ts +6 -0
  175. package/src/op/step-console.test.ts +150 -0
  176. package/src/op/step-console.ts +155 -0
  177. package/src/op/step-output-ref.test.ts +68 -24
  178. package/src/op/step-output-ref.ts +85 -65
  179. package/src/point-decision-bundle.test.ts +1 -0
  180. package/src/point-decision.ts +3 -0
  181. package/src/workspace/agent-cli.ts +2 -0
  182. package/src/workspace/compose-graph.test.ts +55 -0
  183. package/src/workspace/compose-graph.ts +28 -6
  184. package/src/workspace/conformance/__writer_fixture__/answers/answer.schema.json +16 -1
  185. package/src/workspace/conformance/writer.ts +1 -1
  186. package/src/workspace/decide.test.ts +87 -3
  187. package/src/workspace/decide.ts +142 -17
  188. package/src/workspace/graph-contract.test.ts +26 -1
  189. package/src/workspace/graph.schema.json +68 -1
  190. package/src/workspace/lineage-upgrade.test.ts +21 -0
  191. package/src/workspace/lineage-upgrade.ts +19 -11
  192. package/src/workspace/nested-graph.test.ts +14 -0
  193. package/src/workspace/nested-graph.ts +7 -2
  194. package/src/workspace/point-answer.schema.json +16 -1
  195. package/src/workspace/points-cli.ts +16 -8
  196. package/src/workspace/points-write.schema.json +39 -4
  197. package/src/workspace/points.schema.json +42 -3
  198. package/src/workspace/reason-codes.ts +2 -1
  199. package/src/workspace/record-assets.test.ts +14 -0
  200. package/src/workspace/records-cli.ts +28 -7
  201. package/src/workspace/records-write-commands.e2e.test.ts +32 -0
  202. package/src/workspace/records-write.ts +2 -3
  203. package/src/workspace/records.schema.json +2 -1
  204. package/src/workspace/records.ts +17 -4
  205. package/src/workspace/runs.ts +4 -2
  206. package/src/workspace/trust/trust.test.ts +105 -0
  207. package/src/workspace/trust/verify-cli.ts +2 -1
  208. package/src/workspace/trust/verify.ts +106 -13
  209. package/src/workspace/write-scope.test.ts +18 -0
  210. package/src/workspace/write-scope.ts +16 -5
@@ -22,7 +22,7 @@
22
22
 
23
23
  import { spawn } from "node:child_process";
24
24
  import type { ParsedArgs } from "../cli/registry";
25
- import type { ComposedMember, ComposedNode, WorkspaceGraph } from "./compose-graph";
25
+ import { danglingKey, SCOPED_GROUPS, type ComposedMember, type ComposedNode, type WorkspaceGraph } from "./compose-graph";
26
26
 
27
27
  export type { NestedWorkspace } from "./compose-graph";
28
28
  import { readerToolchain, type Toolchain } from "./member-commands";
@@ -148,12 +148,17 @@ export function foldNested(graph: WorkspaceGraph, member: ComposedMember, inner:
148
148
  into[k] = [...(into[k] ?? []), ...v.map((id) => prefixed(outer, id))].sort();
149
149
  }
150
150
  }
151
- for (const key of ["byStack", "byContainer", "byWave"] as const) {
151
+ for (const key of SCOPED_GROUPS) {
152
152
  for (const [k, v] of Object.entries(innerGroups[key] ?? {})) {
153
153
  const into = (groups[key] ??= {});
154
154
  into[prefixed(outer, k)] = v.map((id) => prefixed(outer, id)).sort();
155
155
  }
156
156
  }
157
+ if (inner.dangling?.length) {
158
+ graph.dangling = [...(graph.dangling ?? []), ...inner.dangling.map((d) => ({ ...d, from: prefixed(outer, d.from), member: outer, nested: d.member }))].sort((a, b) =>
159
+ danglingKey(a).localeCompare(danglingKey(b)),
160
+ );
161
+ }
157
162
  for (const c of inner.collectors ?? []) graph.collectors.push({ ...c, member: prefixed(outer, c.member) });
158
163
  for (const [kind, attrs] of Object.entries(inner.derivedAttrs ?? {})) {
159
164
  const all = new Set([...(graph.derivedAttrs?.[kind] ?? []), ...attrs]);
@@ -2,7 +2,7 @@
2
2
  "$schema": "http://json-schema.org/draft-07/schema#",
3
3
  "$id": "urn:intentius:chant:point-answer:1",
4
4
  "title": "Decision point answer front matter",
5
- "description": "The front matter of one answer to a decision point (ws-058, #2739), as `chant workspace points ask` and `points answer` write it into the records directory of a record kind with an `answers` block. One record per point, declaration and inputs: its id is the point's name and the first 12 hex digits of the inputs hash, so the same question is answered once. A table's answer is `answered`; a model's answer at or above its threshold is `proposed` until a person confirms it; a question no decider before the quorum answered is `escalated`, open for people. An answer never changes in place: `points retract` escalates the question to people again and moves the answer into `retractions` (#3351, ws-084). An ad-hoc point's answer keeps the question and candidates its ask gave as `asked`, and they are part of the inputs hash (#3403). A workspace copies this file next to its answer kind, as reference-workspace/answers/ does. Values stay within what YAML and JSON share. Fields other than these must start with x-.",
5
+ "description": "The front matter of one answer to a decision point (ws-058, #2739), as `chant workspace points ask` and `points answer` write it into the records directory of a record kind with an `answers` block. One record per point, declaration and inputs: its id is the point's name and the first 12 hex digits of the inputs hash, so the same question is answered once. A table's answer is `answered`; a model's answer at or above its threshold is `proposed` until a person confirms it; a question no decider before the quorum answered is `escalated`, open for people. An answer never changes in place: `points retract` escalates the question to people again and moves the answer into `retractions` (#3351, ws-084). An open question that no longer applies is `withdrawn` (`points withdraw`, #2888), and asking it again opens it again. An ad-hoc point's answer keeps the question and candidates its ask gave as `asked`, and they are part of the inputs hash (#3403). A workspace copies this file next to its answer kind, as reference-workspace/answers/ does. Values stay within what YAML and JSON share. Fields other than these must start with x-.",
6
6
  "type": "object",
7
7
  "additionalProperties": false,
8
8
  "patternProperties": { "^x-": {} },
@@ -65,6 +65,17 @@
65
65
  "description": "Answers people took back, oldest first (`points retract`, #3351): each answer as it was, and who retracted it, when and why. A retracted question is escalated to people again.",
66
66
  "items": { "$ref": "#/definitions/retraction" }
67
67
  },
68
+ "withdrawn": {
69
+ "type": "object",
70
+ "description": "An open question that no longer applies, such as one about a work item its author changed, which a run asks about anew (`points withdraw`, #2888): who withdrew it, when and why. A withdrawn question is not open, and nobody answers it; asking it again opens it again.",
71
+ "required": ["by", "on"],
72
+ "properties": {
73
+ "by": { "type": "array", "minItems": 1, "items": { "type": "string", "minLength": 1 }, "description": "Who withdrew it." },
74
+ "on": { "$ref": "#/definitions/date" },
75
+ "note": { "type": "string", "minLength": 1, "description": "Why it was withdrawn (`points withdraw --note`)." }
76
+ },
77
+ "additionalProperties": false
78
+ },
68
79
  "asked_on": { "$ref": "#/definitions/date" },
69
80
  "answered_on": { "$ref": "#/definitions/date" },
70
81
  "source": {
@@ -85,6 +96,10 @@
85
96
  "if": { "properties": { "state": { "const": "escalated" } }, "required": ["state"] },
86
97
  "then": { "not": { "required": ["answer"] }, "properties": { "decider": { "properties": { "kind": { "const": "quorum" } } } } }
87
98
  },
99
+ {
100
+ "if": { "required": ["withdrawn"] },
101
+ "then": { "properties": { "state": { "enum": ["escalated", "proposed"] } } }
102
+ },
88
103
  {
89
104
  "if": { "properties": { "decider": { "properties": { "kind": { "const": "model" } }, "required": ["kind"] } }, "required": ["decider"] },
90
105
  "then": { "required": ["probabilities", "confidence", "threshold"], "properties": { "decider": { "required": ["backend", "model"] } } }
@@ -6,12 +6,12 @@
6
6
  * The points come from each record kind the declaration names with an
7
7
  * `answers` block, or from `--kind`, and the questions are that kind's
8
8
  * records. A question is open while it is escalated to people or proposed by a
9
- * model and not yet confirmed, and it lists any model's answer with its
9
+ * model and not yet confirmed, and not withdrawn (#2888), and it lists any model's answer with its
10
10
  * confidence, so a reader such as hud can prompt a person and an MCP client
11
11
  * can see it. With `--open`, only open questions are listed. It never writes
12
12
  * and never calls a model.
13
13
  *
14
- * `points ask`, `points answer` and `points retract` are the writes, in
14
+ * `points ask`, `points answer`, `points retract` and `points withdraw` are the writes, in
15
15
  * `decide.ts`.
16
16
  */
17
17
 
@@ -21,7 +21,7 @@ import { formatError } from "../cli/format";
21
21
  import type { CommandContext } from "../cli/registry";
22
22
  import { declaredRecordKinds, readDeclaration, readerVersion, WorkspaceReadError, type ErrorLocation, type WorkspaceErrorCode } from "./declaration";
23
23
  import { declaredKindFile } from "./declared-kinds";
24
- import { answerPoint, askPoint, questionView, retractAnswer, POINTS_WRITE_CONTRACT_VERSION, POINTS_WRITE_SCHEMA_ID, type PointsWriteDocument, type PointsWriteVerb, type QuestionView } from "./decide";
24
+ import { answerPoint, askPoint, questionView, retractAnswer, withdrawQuestion, POINTS_WRITE_CONTRACT_VERSION, POINTS_WRITE_SCHEMA_ID, type PointsWriteDocument, type PointsWriteVerb, type QuestionView } from "./decide";
25
25
  import { candidates, inputOutput, pointsFileOf, pointVersion, quorumOf, readPointsThrough, type Decider, type ModelAsk, type WireAnswer } from "./points";
26
26
  import type { ReasonCode } from "./reason-codes";
27
27
  import { gitRevisionSource, workingTreeSource } from "./record-source";
@@ -107,7 +107,8 @@ export type PointsDocument =
107
107
  sources: SourceView[];
108
108
  points: PointView[];
109
109
  questions: QuestionView[];
110
- summary: { points: number; questions: number; open: number; escalated: number; proposed: number; answered: number };
110
+ /** escalated, proposed and answered count by state, withdrawn ones included; withdrawn counts the questions withdrawn (#2888). */
111
+ summary: { points: number; questions: number; open: number; escalated: number; proposed: number; answered: number; withdrawn: number };
111
112
  }
112
113
  | { $schema: string; contract: number; chant: string; error: { code: WorkspaceErrorCode; message: string; location: ErrorLocation | null } };
113
114
 
@@ -197,7 +198,7 @@ export async function workspacePoints(query: PointsQuery): Promise<PointsDocumen
197
198
  sources,
198
199
  points,
199
200
  questions,
200
- summary: { points: points.length, questions: questions.length, open: questions.filter((q) => q.open).length, escalated: count("escalated"), proposed: count("proposed"), answered: count("answered") },
201
+ summary: { points: points.length, questions: questions.length, open: questions.filter((q) => q.open).length, escalated: count("escalated"), proposed: count("proposed"), answered: count("answered"), withdrawn: questions.filter((q) => q.withdrawn !== null).length },
201
202
  };
202
203
  }
203
204
 
@@ -228,7 +229,7 @@ export function formatPoints(doc: Extract<PointsDocument, { points: unknown }>):
228
229
  lines.push("");
229
230
  for (const q of doc.questions) {
230
231
  const model = q.model && q.state !== "answered" ? ` model ${JSON.stringify(q.model.answer)} at ${q.model.confidence ?? "?"}${q.model.observed ? "" : `, below ${q.model.threshold ?? "?"}`}` : "";
231
- lines.push(`${q.id} ${q.state} ${q.title}${model}`);
232
+ lines.push(`${q.id} ${q.withdrawn ? "withdrawn" : q.state} ${q.title}${model}`);
232
233
  }
233
234
  const n = doc.summary.questions;
234
235
  lines.push(doc.open ? `${n} open ${n === 1 ? "question" : "questions"}` : `${n} ${n === 1 ? "question" : "questions"}, ${doc.summary.open} open`);
@@ -252,6 +253,7 @@ const USAGE = [
252
253
  "chant workspace points ask <point> --inputs <file|-|json> [--candidates <file|-|json>] [--response <file>] [--subject <id>] [--kind <kind file>] [--dry-run]",
253
254
  "chant workspace points answer <id> --answer <value> --by <name> [--by <name>...] [--note <text>] [--relayed-by <principal>] [--kind <kind file>] [--resume] [--dry-run]",
254
255
  "chant workspace points retract <id> --by <name> [--by <name>...] [--note <text>] [--kind <kind file>] [--dry-run]",
256
+ "chant workspace points withdraw <id> --by <name> [--by <name>...] [--note <text>] [--kind <kind file>] [--dry-run]",
255
257
  ].join("\n");
256
258
 
257
259
  function usage(verb: PointsWriteVerb, message: string): PointsWriteDocument {
@@ -276,7 +278,7 @@ function readJson(verb: PointsWriteVerb, flag: string, value: string, cwd: strin
276
278
  }
277
279
  }
278
280
 
279
- /** `chant workspace points`, and its `ask`, `answer` and `retract` verbs. */
281
+ /** `chant workspace points`, and its `ask`, `answer`, `retract` and `withdraw` verbs. */
280
282
  export async function runWorkspacePoints(ctx: CommandContext): Promise<number> {
281
283
  const { args } = ctx;
282
284
  const cwd = process.cwd();
@@ -324,8 +326,14 @@ export async function runWorkspacePoints(ctx: CommandContext): Promise<number> {
324
326
  if (!args.bys || args.bys.length === 0) return print(usage("retract", "--by <name> is required, once for each person who takes the answer back"));
325
327
  return print(await retractAnswer({ cwd, id, by: args.bys, note: args.note, kind: args.kind, dryRun: args.dryRun }));
326
328
  }
329
+ if (verb === "withdraw") {
330
+ const id = args.extraPositional2;
331
+ if (!id) return print(usage("withdraw", "withdraw needs the question's id"));
332
+ if (!args.bys || args.bys.length === 0) return print(usage("withdraw", "--by <name> is required: who withdraws the question"));
333
+ return print(await withdrawQuestion({ cwd, id, by: args.bys, note: args.note, kind: args.kind, dryRun: args.dryRun }));
334
+ }
327
335
  if (verb !== undefined) {
328
- console.error(formatError({ message: `chant workspace points takes no argument but ask, answer or retract (got ${verb})`, hint: USAGE }));
336
+ console.error(formatError({ message: `chant workspace points takes no argument but ask, answer, retract or withdraw (got ${verb})`, hint: USAGE }));
329
337
  return 1;
330
338
  }
331
339
  const doc = await workspacePoints({ cwd, at: args.at, open: args.open, kind: args.kind });
@@ -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-write/v1/points-write.schema.json",
4
4
  "title": "chant workspace points ask, answer and retract output",
5
- "description": "What `chant workspace points ask`, `points answer` and `points retract` print (ws-058, #2739, #3351): the answer record asked for or written, as `points --json` lists a question. ask returns the record that is there when the same point, declaration and inputs were answered or proposed before (reused), and otherwise writes one: answered from a table, proposed from a model at or above its threshold, escalated to people below it. answer records people's answer to an open question once the point's quorum is met, with their note. retract takes an answer back once the quorum is met: the question is escalated to people again and the answer moves into its retractions (ws-084). Nothing is committed to the working branch, and nothing is written on a refusal or with --dry-run. In a steward's turn a new record goes on the lifecycle ledger instead of the working tree (#2786), as a commit on chant/lifecycle, and a record already held there is rewritten there. The error codes are a closed list, each in the one closed list of `reason-codes.ts`. Written at contract version 1 of the write documents.",
5
+ "description": "What `chant workspace points ask`, `points answer`, `points retract` and `points withdraw` print (ws-058, #2739, #3351, #2888): the answer record asked for or written, as `points --json` lists a question. ask returns the record that is there when the same point, declaration and inputs were answered or proposed before (reused), and otherwise writes one: answered from a table, proposed from a model at or above its threshold, escalated to people below it. answer records people's answer to an open question once the point's quorum is met, with their note. retract takes an answer back once the quorum is met: the question is escalated to people again and the answer moves into its retractions (ws-084). withdraw closes an open question that no longer applies, with who withdrew it, when and why; asking it again opens it again (#2888). Nothing is committed to the working branch, and nothing is written on a refusal or with --dry-run. In a steward's turn a new record goes on the lifecycle ledger instead of the working tree (#2786), as a commit on chant/lifecycle, and a record already held there is rewritten there. The error codes are a closed list, each in the one closed list of `reason-codes.ts`. Written at contract version 1 of the write documents.",
6
6
  "oneOf": [
7
7
  {
8
8
  "$ref": "#/$defs/result"
@@ -38,7 +38,8 @@
38
38
  "enum": [
39
39
  "ask",
40
40
  "answer",
41
- "retract"
41
+ "retract",
42
+ "withdraw"
42
43
  ]
43
44
  },
44
45
  "kind": {
@@ -507,6 +508,38 @@
507
508
  }
508
509
  }
509
510
  },
511
+ "withdrawn": {
512
+ "type": [
513
+ "object",
514
+ "null"
515
+ ],
516
+ "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.",
517
+ "required": [
518
+ "by",
519
+ "on",
520
+ "note"
521
+ ],
522
+ "properties": {
523
+ "by": {
524
+ "type": "array",
525
+ "items": {
526
+ "type": "string"
527
+ }
528
+ },
529
+ "on": {
530
+ "type": [
531
+ "string",
532
+ "null"
533
+ ]
534
+ },
535
+ "note": {
536
+ "type": [
537
+ "string",
538
+ "null"
539
+ ]
540
+ }
541
+ }
542
+ },
510
543
  "asked": {
511
544
  "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.",
512
545
  "oneOf": [
@@ -567,7 +600,8 @@
567
600
  "answer-points-unreadable",
568
601
  "answer-point-unknown",
569
602
  "answer-point-changed",
570
- "record-unattested"
603
+ "record-unattested",
604
+ "record-ratified-below-quorum"
571
605
  ]
572
606
  },
573
607
  "message": {
@@ -598,7 +632,8 @@
598
632
  "enum": [
599
633
  "ask",
600
634
  "answer",
601
- "retract"
635
+ "retract",
636
+ "withdraw"
602
637
  ]
603
638
  },
604
639
  "error": {
@@ -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 {
@@ -55,7 +55,7 @@ import { createHash, randomBytes } from "node:crypto";
55
55
  import { readFileSync, statSync } from "node:fs";
56
56
  import { resolve } from "node:path";
57
57
  import { z } from "zod";
58
- import { describeLifecyclePush, fetchLifecycleStatus, type LifecyclePushStatus, pushLifecycleStatus, readBlobBySha, RefCASConflictError, writeLedgerFiles } from "../lifecycle/git";
58
+ import { describeLifecyclePush, fetchLifecycleStatus, ledgerRevision, type LifecyclePushStatus, pushLifecycleStatus, readBlobBySha, RefCASConflictError, writeLedgerFiles } from "../lifecycle/git";
59
59
  import { resolveMemberLedger } from "../lifecycle/member-ledger";
60
60
  import { sortedJsonReplacer } from "../utils";
61
61
  import { followSquashes, type SquashFollow } from "./squash";
@@ -509,7 +509,9 @@ async function appendRun(top: string, rootOnDisk: string, id: string, make: (exi
509
509
  let written: RunLine[] = [];
510
510
  let lastErr: unknown;
511
511
  for (let attempt = 1; attempt <= APPEND_RETRY_ATTEMPTS && commit === undefined; attempt++) {
512
- const prior = git(top, ["rev-parse", "--verify", "--quiet", `refs/heads/${LEDGER_BRANCH}:${path}`])?.trim() || null;
512
+ // #3955: in a run that records nothing, the run's own unrecorded tip.
513
+ const rev = await ledgerRevision(top);
514
+ const prior = git(top, ["rev-parse", "--verify", "--quiet", `${rev === LEDGER_BRANCH ? `refs/heads/${LEDGER_BRANCH}` : rev}:${path}`])?.trim() || null;
513
515
  const text = prior ? ((await readBlobBySha(prior, { cwd: top })) ?? "") : "";
514
516
  const existing = parseRunFile(text, id).lines;
515
517
  const lines = make(existing);