@intentius/chant 0.124.5 → 0.124.7

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 (53) hide show
  1. package/dist/cli/handlers/gate-resume.d.ts +32 -1
  2. package/dist/cli/handlers/gate-resume.d.ts.map +1 -1
  3. package/dist/cli/main.d.ts.map +1 -1
  4. package/dist/lifecycle/gate-ledger.d.ts +7 -0
  5. package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
  6. package/dist/lifecycle/rollback.d.ts.map +1 -1
  7. package/dist/op/activities/forge-pull-request.d.ts +49 -0
  8. package/dist/op/activities/forge-pull-request.d.ts.map +1 -0
  9. package/dist/op/activities/lexicon-upgrade.d.ts +7 -0
  10. package/dist/op/activities/lexicon-upgrade.d.ts.map +1 -1
  11. package/dist/op/activities/propose-upgrade.d.ts +6 -0
  12. package/dist/op/activities/propose-upgrade.d.ts.map +1 -1
  13. package/dist/op/activities/reconcile.d.ts.map +1 -1
  14. package/dist/op/gate-point-run.d.ts +1 -1
  15. package/dist/op/gate-point-run.d.ts.map +1 -1
  16. package/dist/op/gate-point.d.ts +6 -0
  17. package/dist/op/gate-point.d.ts.map +1 -1
  18. package/dist/op/gate-resume.d.ts +2 -31
  19. package/dist/op/gate-resume.d.ts.map +1 -1
  20. package/dist/op/gate-run-locator.d.ts +40 -0
  21. package/dist/op/gate-run-locator.d.ts.map +1 -0
  22. package/dist/point-decision.d.ts +63 -1
  23. package/dist/point-decision.d.ts.map +1 -1
  24. package/dist/pr-forge.d.ts +72 -7
  25. package/dist/pr-forge.d.ts.map +1 -1
  26. package/dist/workspace/points-cli.d.ts.map +1 -1
  27. package/dist/workspace/records-cli.d.ts +15 -0
  28. package/dist/workspace/records-cli.d.ts.map +1 -1
  29. package/package.json +1 -1
  30. package/src/cli/handlers/gate-resume.ts +84 -11
  31. package/src/cli/main.ts +4 -2
  32. package/src/lifecycle/gate-ledger.ts +7 -0
  33. package/src/lifecycle/rollback.ts +10 -2
  34. package/src/op/activities/forge-pull-request.test.ts +91 -0
  35. package/src/op/activities/forge-pull-request.ts +98 -0
  36. package/src/op/activities/lexicon-upgrade.test.ts +58 -0
  37. package/src/op/activities/lexicon-upgrade.ts +34 -8
  38. package/src/op/activities/propose-upgrade.ts +13 -0
  39. package/src/op/activities/reconcile.ts +10 -2
  40. package/src/op/gate-point-ci.e2e.test.ts +39 -2
  41. package/src/op/gate-point-resume.e2e.test.ts +203 -0
  42. package/src/op/gate-point-run.ts +3 -0
  43. package/src/op/gate-point.ts +11 -0
  44. package/src/op/gate-resume.ts +5 -95
  45. package/src/op/gate-run-locator.ts +105 -0
  46. package/src/point-decision-bundle.test.ts +1 -0
  47. package/src/point-decision.ts +97 -1
  48. package/src/pr-forge-bundle.test.ts +1 -1
  49. package/src/pr-forge.test.ts +154 -0
  50. package/src/pr-forge.ts +179 -12
  51. package/src/workspace/points-cli.ts +7 -2
  52. package/src/workspace/records-cli.ts +38 -7
  53. package/src/workspace/records.schema.json +4 -0
package/src/pr-forge.ts CHANGED
@@ -2,7 +2,8 @@
2
2
  * What chant and the products built on it ask of a forge: keep one comment
3
3
  * (a merge request note on GitLab) on a pull request up to date, set a commit
4
4
  * status, keep one tracking issue in the repository, list who approved a pull
5
- * request, and find the pull request that merged a commit.
5
+ * request, find the pull request that merged a commit, and open or edit a
6
+ * pull request (a merge request on GitLab) for a branch.
6
7
  *
7
8
  * The pull-request loop (#3183) uses the note, the status and the review
8
9
  * calls. `reconcilePr`'s `comment` and `issue` finding modes post through the
@@ -21,6 +22,10 @@
21
22
  * `gitlab:<login>`, or `<forge>@<host>:<login>` off the forge's default host.
22
23
  * The apply compares that string with the approval's `resolvedBy`.
23
24
  *
25
+ * Opening a pull request goes over REST too (#3798): `gh pr` speaks GitHub's
26
+ * GraphQL, which Forgejo does not serve, so chant's own pull-request paths
27
+ * open theirs through {@link PrForge.openPullRequest} on Forgejo and GitLab.
28
+ *
24
29
  * Like `./pr-loop.ts`, this bundles without the TypeScript toolchain (#3421):
25
30
  * it imports nothing but `node:fs`, to read a CI job's event payload.
26
31
  * `./pr-forge-bundle.test.ts` holds it to that.
@@ -41,7 +46,13 @@ export type ForgeFetch = (
41
46
  export interface ForgeStatus {
42
47
  /** The status's name, such as `chant/plan` or `terragucci/plan`. */
43
48
  context: string;
44
- state: "pending" | "success" | "failure";
49
+ /**
50
+ * GitLab's own states, plus GitHub's `failure` and `error`. Each forge gets
51
+ * the nearest state it has ({@link statusStateFor}): `running` is `pending`
52
+ * on GitHub and Forgejo, `canceled` is `error` there, and `failure` and
53
+ * `error` are `failed` on GitLab.
54
+ */
55
+ state: ForgeStatusState;
45
56
  /** Cut to what the forge keeps: 140 characters on GitHub and Forgejo, 255 on GitLab. */
46
57
  description: string;
47
58
  /** The pipeline run, linked from the status. */
@@ -50,6 +61,30 @@ export interface ForgeStatus {
50
61
  pipelineId?: number;
51
62
  }
52
63
 
64
+ /** A commit status's state, as a caller names it. */
65
+ export type ForgeStatusState = "pending" | "running" | "success" | "failed" | "failure" | "canceled" | "error";
66
+
67
+ /** Every {@link ForgeStatusState}, for a caller that reads one from its own input. */
68
+ export const FORGE_STATUS_STATES: readonly ForgeStatusState[] = ["pending", "running", "success", "failed", "failure", "canceled", "error"];
69
+
70
+ /**
71
+ * What each forge is sent for a {@link ForgeStatusState} (#3834). GitHub
72
+ * keeps `pending`, `success`, `failure` and `error`; Forgejo the same and
73
+ * `warning`; GitLab `pending`, `running`, `success`, `failed` and `canceled`.
74
+ * A state the forge lacks becomes its nearest one.
75
+ */
76
+ const STATUS_STATE: Record<"github" | "gitlab", Record<ForgeStatusState, string>> = {
77
+ github: { pending: "pending", running: "pending", success: "success", failed: "failure", failure: "failure", canceled: "error", error: "error" },
78
+ gitlab: { pending: "pending", running: "running", success: "success", failed: "failed", failure: "failed", canceled: "canceled", error: "failed" },
79
+ };
80
+
81
+ /** The state `kind` is sent for `state`. Forgejo takes GitHub's. */
82
+ export function statusStateFor(kind: ForgeKind, state: ForgeStatusState): string {
83
+ const mapped = STATUS_STATE[kind === "gitlab" ? "gitlab" : "github"][state];
84
+ if (mapped === undefined) throw new Error(`a commit status state is one of ${FORGE_STATUS_STATES.join(", ")}, not ${JSON.stringify(state)}`);
85
+ return mapped;
86
+ }
87
+
53
88
  /**
54
89
  * How a comment or an issue is told to be the caller's own. By default the
55
90
  * body starts with the marker, chant's recipe since #2291, and anyone may
@@ -59,12 +94,15 @@ export interface MarkerMatch {
59
94
  /** `prefix` (the default): the body starts with the marker. `anywhere`: the body carries it. */
60
95
  match?: "prefix" | "anywhere";
61
96
  /**
62
- * Only a comment or issue written by this login counts: `user.login` on
63
- * GitHub and Forgejo, `author.username` on GitLab, compared without case.
64
- * A merge request's author can write a note carrying any marker, so a
65
- * product that reads its own notes back names its bot here.
97
+ * Only a comment or issue written by this login, or by any of these
98
+ * logins, counts: `user.login` on GitHub and Forgejo, `author.username` on
99
+ * GitLab, compared without case. A merge request's author can write a note
100
+ * carrying any marker, so a product that reads its own notes back names
101
+ * its bot here. A list is for a bot whose login the job cannot know in
102
+ * advance, such as `gitea-actions` or `forgejo-actions` on Forgejo (#3834).
103
+ * An empty list matches nobody.
66
104
  */
67
- author?: string;
105
+ author?: string | readonly string[];
68
106
  }
69
107
 
70
108
  /** What {@link PrForge.upsertComment} did. */
@@ -146,6 +184,34 @@ export interface IssueResult {
146
184
  created?: string;
147
185
  }
148
186
 
187
+ /** A pull request (merge request) for {@link PrForge.openPullRequest} to open. */
188
+ export interface PullRequestSpec {
189
+ /** The branch with the change, in the same repository. */
190
+ head: string;
191
+ /** The branch it merges into. Default: the repository's default branch, read from the forge. */
192
+ base?: string;
193
+ title: string;
194
+ body: string;
195
+ /**
196
+ * Open it as a draft: GitHub's `draft`, GitLab's `Draft: ` title prefix,
197
+ * Forgejo's `WIP: ` title prefix (it has no draft field).
198
+ */
199
+ draft?: boolean;
200
+ }
201
+
202
+ /** A pull request (merge request) as the forge answered it. */
203
+ export interface PullRequest {
204
+ /** The pull request's number, or the merge request's iid. */
205
+ number: number;
206
+ url?: string;
207
+ title: string;
208
+ body: string;
209
+ /** Its head (source) branch. */
210
+ head: string;
211
+ /** Its base (target) branch. */
212
+ base?: string;
213
+ }
214
+
149
215
  export interface PrForge {
150
216
  kind: ForgeKind;
151
217
  /** Lower-cased, such as `github.com`. */
@@ -208,6 +274,22 @@ export interface PrForge {
208
274
  approvers(pr: number): Promise<string[]>;
209
275
  /** The merged pull request that produced `sha`, or null. */
210
276
  pullRequestFor(sha: string): Promise<number | null>;
277
+ /**
278
+ * Open a pull request (a merge request on GitLab) from `head` into `base`,
279
+ * or into the default branch when `base` is not given. Over REST on every
280
+ * forge, so it works on Forgejo, where `gh pr create` cannot (#3798).
281
+ */
282
+ openPullRequest(spec: PullRequestSpec): Promise<PullRequest>;
283
+ /**
284
+ * The OPEN pull request whose head is branch `head` in this repository (a
285
+ * fork's branch of the same name is not it), or undefined. The oldest when
286
+ * there are several.
287
+ */
288
+ findPullRequest(head: string): Promise<PullRequest | undefined>;
289
+ /** Change an open pull request's title or body. */
290
+ editPullRequest(pr: number, change: { title?: string; body?: string }): Promise<PullRequest | undefined>;
291
+ /** Add labels to a pull request by name. Forgejo needs them to exist; GitHub and GitLab make them. */
292
+ addLabels(pr: number, labels: readonly string[]): Promise<void>;
211
293
  }
212
294
 
213
295
  /** A GitHub or Forgejo client. */
@@ -301,8 +383,15 @@ interface Threads {
301
383
  const carries = (body: string, marker: string, match: MarkerMatch["match"]): boolean =>
302
384
  match === "anywhere" ? body.includes(marker) : body.startsWith(marker);
303
385
 
386
+ /** Whether `author` is one of `want`, without case. No `want` (or `""`) takes anyone. */
387
+ const authorIn = (want: MarkerMatch["author"], author: string | undefined): boolean => {
388
+ if (want === undefined || want === "") return true;
389
+ const logins = (typeof want === "string" ? [want] : want).map((l) => l.toLowerCase());
390
+ return author !== undefined && logins.includes(author.toLowerCase());
391
+ };
392
+
304
393
  const ownedBy = (marker: string, opts: MarkerMatch) => (t: Thread): boolean =>
305
- carries(t.body, marker, opts.match) && (!opts.author || t.author?.toLowerCase() === opts.author.toLowerCase());
394
+ carries(t.body, marker, opts.match) && authorIn(opts.author, t.author);
306
395
 
307
396
  function checkMarker(what: string, marker: string, body: string, match: MarkerMatch["match"]): void {
308
397
  if (!marker) throw new Error(`the ${what} needs a marker to be found again`);
@@ -420,6 +509,21 @@ function githubShaped(kind: "github" | "forgejo", opts: GithubForgeOptions): Git
420
509
  return { id, body: str(v.body) ?? "", ...(created ? { created } : {}), ...(str(v.title) !== undefined ? { title: str(v.title) } : {}), ...(author ? { author } : {}), ...(url ? { url } : {}) };
421
510
  };
422
511
  const threads = (items: unknown[]): Thread[] => items.map(thread).filter((t): t is Thread => t !== undefined);
512
+ const pull = (v: unknown): PullRequest | undefined => {
513
+ if (!isObject(v)) return undefined;
514
+ const number = num(v.number);
515
+ const head = isObject(v.head) ? str(v.head.ref) : undefined;
516
+ if (number === undefined) return undefined;
517
+ const base = isObject(v.base) ? str(v.base.ref) : undefined;
518
+ const url = str(v.html_url);
519
+ return { number, title: str(v.title) ?? "", body: str(v.body) ?? "", head: head ?? "", ...(base ? { base } : {}), ...(url ? { url } : {}) };
520
+ };
521
+ /** A pull request whose head branch lives in this repository, not a fork. */
522
+ const fromHere = (v: unknown, head: string): boolean => {
523
+ if (!isObject(v) || !isObject(v.head) || v.head.ref !== head) return false;
524
+ const full = isObject(v.head.repo) ? str(v.head.repo.full_name) : undefined;
525
+ return full === undefined || full.toLowerCase() === opts.repo.toLowerCase();
526
+ };
423
527
  const t: Threads = {
424
528
  comments: async (pr) => threads(await api.list(`${repo}/issues/${pr}/comments`, pageParams, pageSize)),
425
529
  addComment: async (pr, body) => thread(await api.call("POST", `${repo}/issues/${pr}/comments`, { body })),
@@ -445,7 +549,7 @@ function githubShaped(kind: "github" | "forgejo", opts: GithubForgeOptions): Git
445
549
  ...threadCalls(t),
446
550
  async setStatus(sha, status) {
447
551
  await api.call("POST", `${repo}/statuses/${sha}`, {
448
- state: status.state,
552
+ state: statusStateFor(kind, status.state),
449
553
  context: status.context,
450
554
  description: status.description.slice(0, GITHUB_DESCRIPTION),
451
555
  ...(status.url ? { target_url: status.url } : {}),
@@ -483,9 +587,44 @@ function githubShaped(kind: "github" | "forgejo", opts: GithubForgeOptions): Git
483
587
  const pick = (exact ?? merged[0]) as Record<string, unknown> | undefined;
484
588
  return pick && typeof pick.number === "number" ? pick.number : null;
485
589
  },
590
+ async openPullRequest(spec) {
591
+ const base = spec.base ?? (await defaultBranchOf(api, repo, "default_branch"));
592
+ const title = spec.draft && kind === "forgejo" ? `WIP: ${spec.title}` : spec.title;
593
+ const made = await api.call("POST", `${repo}/pulls`, {
594
+ title,
595
+ body: spec.body,
596
+ head: spec.head,
597
+ base,
598
+ ...(spec.draft && kind === "github" ? { draft: true } : {}),
599
+ });
600
+ return pull(made) ?? { number: 0, title, body: spec.body, head: spec.head, base };
601
+ },
602
+ async findPullRequest(head) {
603
+ // GitHub filters by `owner:branch` server-side; Forgejo's list takes no
604
+ // head filter, so every open page is read and matched here.
605
+ const owner = opts.repo.split("/")[0];
606
+ const query = kind === "github" ? `?state=open&head=${encodeURIComponent(`${owner}:${head}`)}` : "?state=open";
607
+ const open = (await api.list(`${repo}/pulls${query}`, pageParams, pageSize)).filter((p) => fromHere(p, head));
608
+ return open.map(pull).filter((p): p is PullRequest => p !== undefined).sort((a, b) => a.number - b.number)[0];
609
+ },
610
+ async editPullRequest(pr, change) {
611
+ return pull(await api.call("PATCH", `${repo}/pulls/${pr}`, { ...(change.title !== undefined ? { title: change.title } : {}), ...(change.body !== undefined ? { body: change.body } : {}) }));
612
+ },
613
+ async addLabels(pr, labels) {
614
+ if (labels.length === 0) return;
615
+ await api.call("POST", `${repo}/issues/${pr}/labels`, { labels: [...labels] });
616
+ },
486
617
  };
487
618
  }
488
619
 
620
+ /** The repository's default branch, from `GET` on the repository (`/repos/{repo}`, `/projects/{id}`). */
621
+ async function defaultBranchOf(api: Transport, path: string, field: string): Promise<string> {
622
+ const r = await api.call("GET", path);
623
+ const branch = isObject(r) ? str(r[field]) : undefined;
624
+ if (!branch) throw new Error(`${path} names no default branch to open the pull request against; pass base`);
625
+ return branch;
626
+ }
627
+
489
628
  /** A GitHub client (github.com or GitHub Enterprise Server). */
490
629
  export function githubForge(opts: GithubForgeOptions): GithubShapedForge {
491
630
  return githubShaped("github", opts);
@@ -515,8 +654,6 @@ export interface GitlabForgeOptions {
515
654
  fetch?: ForgeFetch;
516
655
  }
517
656
 
518
- const GITLAB_STATE: Record<ForgeStatus["state"], string> = { pending: "pending", success: "success", failure: "failed" };
519
-
520
657
  /**
521
658
  * A GitLab client. Writing notes needs an access token with the `api` scope
522
659
  * on most instances: the job token reaches only the endpoints the instance's
@@ -545,6 +682,14 @@ export function gitlabForge(opts: GitlabForgeOptions): PrForge {
545
682
  return { id, body: str(v.description) ?? "", ...(created ? { created } : {}), ...(str(v.title) !== undefined ? { title: str(v.title) } : {}), ...(author ? { author } : {}), ...(url ? { url } : {}) };
546
683
  };
547
684
  const notes = `${project}/merge_requests`;
685
+ const mergeRequest = (v: unknown): PullRequest | undefined => {
686
+ if (!isObject(v)) return undefined;
687
+ const number = num(v.iid);
688
+ if (number === undefined) return undefined;
689
+ const base = str(v.target_branch);
690
+ const url = str(v.web_url);
691
+ return { number, title: str(v.title) ?? "", body: str(v.description) ?? "", head: str(v.source_branch) ?? "", ...(base ? { base } : {}), ...(url ? { url } : {}) };
692
+ };
548
693
  const t: Threads = {
549
694
  comments: async (mr) =>
550
695
  (await api.list(`${notes}/${mr}/notes`, pageParams, 100))
@@ -577,7 +722,7 @@ export function gitlabForge(opts: GitlabForgeOptions): PrForge {
577
722
  async setStatus(sha, status) {
578
723
  try {
579
724
  await api.call("POST", `${project}/statuses/${sha}`, {
580
- state: GITLAB_STATE[status.state],
725
+ state: statusStateFor("gitlab", status.state),
581
726
  name: status.context,
582
727
  description: status.description.slice(0, GITLAB_DESCRIPTION),
583
728
  ...(status.url ? { target_url: status.url } : {}),
@@ -606,6 +751,28 @@ export function gitlabForge(opts: GitlabForgeOptions): PrForge {
606
751
  ?? mrs.find((m) => isObject(m) && m.state === "merged");
607
752
  return isObject(merged) && typeof merged.iid === "number" ? merged.iid : null;
608
753
  },
754
+ async openPullRequest(spec) {
755
+ const base = spec.base ?? (await defaultBranchOf(api, project, "default_branch"));
756
+ const title = spec.draft ? `Draft: ${spec.title}` : spec.title;
757
+ const made = await api.call("POST", notes, { source_branch: spec.head, target_branch: base, title, description: spec.body });
758
+ return mergeRequest(made) ?? { number: 0, title, body: spec.body, head: spec.head, base };
759
+ },
760
+ async findPullRequest(head) {
761
+ const open = await api.list(`${notes}?state=opened&source_branch=${encodeURIComponent(head)}`, pageParams, 100);
762
+ return open
763
+ // A fork's merge request into this project has another source project.
764
+ .filter((m) => isObject(m) && m.source_branch === head && (m.source_project_id === undefined || m.source_project_id === m.project_id))
765
+ .map(mergeRequest)
766
+ .filter((m): m is PullRequest => m !== undefined)
767
+ .sort((a, b) => a.number - b.number)[0];
768
+ },
769
+ async editPullRequest(mr, change) {
770
+ return mergeRequest(await api.call("PUT", `${notes}/${mr}`, { ...(change.title !== undefined ? { title: change.title } : {}), ...(change.body !== undefined ? { description: change.body } : {}) }));
771
+ },
772
+ async addLabels(mr, labels) {
773
+ if (labels.length === 0) return;
774
+ await api.call("PUT", `${notes}/${mr}`, { add_labels: labels.join(",") });
775
+ },
609
776
  };
610
777
  }
611
778
 
@@ -250,7 +250,7 @@ export function responseAsk(response: unknown): ModelAsk {
250
250
  const USAGE = [
251
251
  "chant workspace points [--open] [--kind <kind file>] [--at <rev>] [--json]",
252
252
  "chant workspace points ask <point> --inputs <file|-|json> [--candidates <file|-|json>] [--response <file>] [--subject <id>] [--kind <kind file>] [--dry-run]",
253
- "chant workspace points answer <id> --answer <value> --by <name> [--by <name>...] [--note <text>] [--relayed-by <principal>] [--kind <kind file>] [--dry-run]",
253
+ "chant workspace points answer <id> --answer <value> --by <name> [--by <name>...] [--note <text>] [--relayed-by <principal>] [--kind <kind file>] [--resume] [--dry-run]",
254
254
  "chant workspace points retract <id> --by <name> [--by <name>...] [--note <text>] [--kind <kind file>] [--dry-run]",
255
255
  ].join("\n");
256
256
 
@@ -311,7 +311,12 @@ export async function runWorkspacePoints(ctx: CommandContext): Promise<number> {
311
311
  if (!id) return print(usage("answer", "answer needs the question's id"));
312
312
  if (args.answer === undefined) return print(usage("answer", "--answer <value> is required"));
313
313
  if (!args.bys || args.bys.length === 0) return print(usage("answer", "--by <name> is required, once for each person who answered"));
314
- return print(await answerPoint({ cwd, id, answer: args.answer, by: args.bys, note: args.note, relayedBy: args.relayedBy, kind: args.kind, dryRun: args.dryRun }));
314
+ const answered = await answerPoint({ cwd, id, answer: args.answer, by: args.bys, note: args.note, relayedBy: args.relayedBy, kind: args.kind, dryRun: args.dryRun });
315
+ const code = print(answered);
316
+ // --resume (#3835): start the job waiting at the gate the question names, as `chant approve --resume` does.
317
+ if (code !== 0 || args.dryRun || !(args.resumeRun || args.resume !== undefined) || !("question" in answered)) return code;
318
+ const { resumeAfterAnswer } = await import("../cli/handlers/gate-resume");
319
+ return resumeAfterAnswer(answered.question, cwd);
315
320
  }
316
321
  if (verb === "retract") {
317
322
  const id = args.extraPositional2;
@@ -99,6 +99,13 @@ export interface RecordsQuery {
99
99
  workGaps?: boolean;
100
100
  /** List only the records that are modified or new against `HEAD` (#3160). Needs git and a working-tree read. */
101
101
  uncommitted?: boolean;
102
+ /**
103
+ * For an answer kind read in the working tree (#3833): fetch `chant/lifecycle`
104
+ * first, best effort, as `points answer` does, so an answer held there by
105
+ * another clone or a CI job is listed. The command sets it; other readers
106
+ * read the local ledger as it is.
107
+ */
108
+ fetchLedger?: boolean;
102
109
  }
103
110
 
104
111
  /**
@@ -146,6 +153,14 @@ export type RecordView = RecordEntry & {
146
153
  * steward keeps on the lifecycle ledger (#2786) laid over it.
147
154
  */
148
155
  answers?: WorkAnswer[];
156
+ /**
157
+ * For a record of an answer kind (one with an `answers` block) read in the
158
+ * working tree (#3833): where the record is held on the lifecycle ledger,
159
+ * `chant/lifecycle:<path>`, as `points` names it for a question, or null
160
+ * when it is the working tree's file. A record held on the ledger has no
161
+ * `worktree` or `lastWrite`, since the checkout doesn't hold it.
162
+ */
163
+ ledger?: string | null;
149
164
  };
150
165
 
151
166
  /** The role in the trust policy whose holders' verdicts the quorum does not count (#2671). */
@@ -398,7 +413,19 @@ export async function queryRecords(query: RecordsQuery): Promise<RecordsDocument
398
413
  // A revision has no working tree to hold anything uncommitted; the CLI and the MCP tool refuse the pair first.
399
414
  if (query.uncommitted && query.at !== undefined) throw new Error("--uncommitted reads the working tree and takes no --at");
400
415
  try {
401
- const { loaded, root, top, at, workspaceRoot, tree, result } = await readRecordsFor(query);
416
+ // An answer kind's records include the answers held on the lifecycle ledger (#2786, #3815), as `points` lists them (#3833).
417
+ let heldOnLedger: Map<string, string> | undefined;
418
+ const { loaded, root, top, at, workspaceRoot, tree, result } = await readRecordsFor({
419
+ ...query,
420
+ overlay: async (base, { loaded: l, root: r }) => {
421
+ if (!l.kind.answers) return base;
422
+ const { readLedgerAnswers, refreshLedger, withLedgerAnswers } = await import("./answers-ledger");
423
+ if (query.fetchLedger) await refreshLedger(l.file);
424
+ const ledger = await readLedgerAnswers({ file: l.file, name: l.kind.name, dirRel: relative(r, l.dir).split(sep).join("/") || "." });
425
+ heldOnLedger = new Map([...ledger.byPath.values()].map((a) => [a.path, a.ledger]));
426
+ return withLedgerAnswers(base, ledger);
427
+ },
428
+ });
402
429
  // Provenance, judged by the policy at base and never by the tree read (#2547).
403
430
  const base = top ? resolveBase(top, query.base) : { commit: null, from: null };
404
431
  const policy = top ? policyAtBase(top, base) : policyAtBase(root, base);
@@ -426,6 +453,7 @@ export async function queryRecords(query: RecordsQuery): Promise<RecordsDocument
426
453
  ...(seals ? authorSeal(loaded.kind, r, recordPolicy, seals.checkRecordSeal) : {}),
427
454
  });
428
455
  }
456
+ if (heldOnLedger) for (const r of records) r.ledger = heldOnLedger.get(r.path) ?? null;
429
457
  if (loaded.kind.approval && !loaded.kind.work && top) {
430
458
  const decided = decidedCommits(top, at ?? "HEAD", records.map((r) => r.path), loaded.kind);
431
459
  for (const r of records) r.decidedIn = decided.get(r.path) ?? null;
@@ -461,13 +489,15 @@ export async function queryRecords(query: RecordsQuery): Promise<RecordsDocument
461
489
  if (at === null && top) {
462
490
  const { head, ...rest } = readCheckoutHead(top, query.base);
463
491
  const dir = relative(root, loaded.dir).split(sep).join("/") || ".";
492
+ // A record held on the ledger is not the checkout's: it has no worktree state, and is never uncommitted (#3833).
493
+ const inTree = records.filter((r) => !r.ledger);
464
494
  const { states, deleted } = worktreeStates(top, head, dir, records.map((r) => r.path), new RegExp(loaded.kind.location.match));
465
- for (const r of records) r.worktree = states.get(r.path) ?? "new";
495
+ for (const r of inTree) r.worktree = states.get(r.path) ?? "new";
466
496
  const lastWrite = lastWriteReader(top);
467
- for (const r of records) r.lastWrite = lastWrite(r.path, readTextOrNull(join(top, ...r.path.split("/"))));
497
+ for (const r of inTree) r.lastWrite = lastWrite(r.path, readTextOrNull(join(top, ...r.path.split("/"))));
468
498
  checkout = { branch: rest.branch, head, base: rest.base, baseFrom: rest.baseFrom, deleted };
469
499
  if (query.uncommitted) {
470
- listed = records.filter((r) => r.worktree !== "committed");
500
+ listed = inTree.filter((r) => r.worktree !== "committed");
471
501
  summary = { ...summary, total: listed.length, valid: listed.filter((r) => r.valid).length, invalid: listed.filter((r) => !r.valid).length };
472
502
  }
473
503
  } else if (query.uncommitted && !top) {
@@ -664,7 +694,7 @@ export async function runWorkspaceRecords(ctx: CommandContext): Promise<number>
664
694
  else console.log(formatSince(since));
665
695
  return "error" in since ? 1 : 0;
666
696
  }
667
- const doc = await queryRecords({ kind: args.kind, current: args.current, uncommitted: args.uncommitted, at: args.at, base: args.base, cwd: process.cwd() });
697
+ const doc = await queryRecords({ kind: args.kind, current: args.current, uncommitted: args.uncommitted, at: args.at, base: args.base, cwd: process.cwd(), fetchLedger: true });
668
698
  if (args.json) {
669
699
  console.log(JSON.stringify(doc, null, 2));
670
700
  } else if ("error" in doc) {
@@ -728,7 +758,7 @@ async function runDeclaredRecords(args: CommandContext["args"]): Promise<number>
728
758
  return 1;
729
759
  }
730
760
  if (args.since !== undefined) return runDeclaredSince(declared, args);
731
- const set = await queryDeclaredRecords(declared, { current: args.current, uncommitted: args.uncommitted, at: args.at, base: args.base, cwd: process.cwd() });
761
+ const set = await queryDeclaredRecords(declared, { current: args.current, uncommitted: args.uncommitted, at: args.at, base: args.base, cwd: process.cwd(), fetchLedger: true });
732
762
  if (args.json) console.log(JSON.stringify(set, null, 2));
733
763
  for (const doc of args.json ? [] : set.kinds) {
734
764
  if ("error" in doc) {
@@ -827,7 +857,8 @@ function formatRecords(
827
857
  const attested = r.provenance.level === "attested" ? ` attested by ${r.provenance.principal}` : "";
828
858
  const sealed = r.attested === true ? ` sealed by ${(r.data?.[RECORD_SEAL_FIELD] as { signer: string }).signer}` : "";
829
859
  const uncommitted = r.worktree === "new" || r.worktree === "modified" ? ` (${r.worktree}, uncommitted)` : "";
830
- lines.push(`${(r.id ?? "-").padEnd(idWidth)} ${(r.state ?? "-").padEnd(stateWidth)} ${title}${superseded}${remediated}${attested}${sealed}${uncommitted}${flag}`);
860
+ const ledger = r.ledger ? ` (held on ${r.ledger})` : "";
861
+ lines.push(`${(r.id ?? "-").padEnd(idWidth)} ${(r.state ?? "-").padEnd(stateWidth)} ${title}${superseded}${remediated}${attested}${sealed}${uncommitted}${ledger}${flag}`);
831
862
  if (r.ready !== undefined) {
832
863
  const blocked = (r.blockedBy ?? []).map((b) => `${b.id} (${b.state ?? "unknown"})`).join(", ");
833
864
  const implemented = (r.implements ?? []).map((d) => `${d.id} (${d.state ?? "unknown"})`).join(", ");
@@ -327,6 +327,10 @@
327
327
  "type": "array",
328
328
  "items": { "$ref": "#/$defs/workAnswer" }
329
329
  },
330
+ "ledger": {
331
+ "type": ["string", "null"],
332
+ "description": "Added in contract 1 by #3833, on every record of an answer kind (one with an answers block) read in the working tree; absent under --at. Where the record is held on the lifecycle ledger, chant/lifecycle:<path on the branch>, as chant workspace points names it for a question, or null when it is the working tree's file. An answer held on the ledger (a question a steward asked, or one asked with --store ledger) reads as if it sat in the kind's directory and wins over a file of the same name; it has no worktree or lastWrite and is never listed by --uncommitted. The command fetches chant/lifecycle first, best effort."
333
+ },
330
334
  "lease": {
331
335
  "description": "Added in contract 1 by #2732, on every record of a work kind with an id, read in the working tree (absent under --at). The item's active work lease (chant workspace work claim), or null when nobody holds one live. Read from the local lease refs, refs/chant/lease/[_members/<member>/]work/<id> and the remote's as last fetched, of the ledger owning the work kind file; records never fetches.",
332
336
  "oneOf": [{ "type": "null" }, { "$ref": "#/$defs/workLease" }]