@brainervirus/workit-core 8.11.0 → 8.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -143,6 +143,12 @@ const workspaceConfigSchema = z
143
143
  defaultEndpoint: z.unknown().optional(),
144
144
  /** How a normal-risk behavior change is verified (S17); same rules as grants. */
145
145
  verification: z.enum(["self", "independent"]).optional(),
146
+ /**
147
+ * Whether commits get the `Workit-Session:` trailer (default true). With
148
+ * `false`, `workit git commit` and the session hook write none and
149
+ * authorship lives in the ledger only (`commit.recorded`).
150
+ */
151
+ trailers: z.boolean().optional(),
146
152
  /** Autonomy grants (D15): only ever set here, by the user. */
147
153
  autonomy: z
148
154
  .object(
@@ -633,8 +639,14 @@ const runtimeWorkspaceIndexSchema = z
633
639
  /** The workspaces.json entry matching cwd, checked only for name/glob (D17). */
634
640
  const matchRuntimeWorkspace = (
635
641
  cwd: string,
636
- ): { file: string; selected: Record<string, unknown> & { name: string } } | null => {
637
- const file = path.join(configDir(), "workspaces.json");
642
+ dir: string = configDir(),
643
+ ): {
644
+ file: string;
645
+ selected: Record<string, unknown> & { name: string };
646
+ /** Every entry whose glob matches cwd, the selected one included. */
647
+ matches: Array<Record<string, unknown> & { name: string; glob: string }>;
648
+ } | null => {
649
+ const file = path.join(dir, "workspaces.json");
638
650
  let raw: string;
639
651
  try {
640
652
  raw = readFileSync(file, "utf8");
@@ -666,7 +678,7 @@ const matchRuntimeWorkspace = (
666
678
  const matches = matchingEntries(cwd, entries ?? []);
667
679
  const workspaceName = process.env.WORKFLOW_WORKSPACE_NAME?.trim() || undefined;
668
680
  const selected = selectWorkspaceMatch(matches, cwd, workspaceName);
669
- return selected ? { file, selected } : null;
681
+ return selected ? { file, selected, matches } : null;
670
682
  };
671
683
 
672
684
  /**
@@ -683,6 +695,37 @@ export const resolveRuntimeReleaseTracks = (
683
695
  : null;
684
696
  };
685
697
 
698
+ /**
699
+ * Whether commits in cwd's workspace carry the `Workit-Session:` trailer.
700
+ * The selected workspace's own `trailers` wins; unset, it inherits from the
701
+ * nearest enclosing workspace that also matches cwd (a repo workspace nested
702
+ * in `work`), and defaults to true. Read leniently: no workspace, or an
703
+ * unreadable workspaces.json, keeps the trailer. `from` names the entry the
704
+ * value came from (null for the default).
705
+ */
706
+ export const workspaceTrailers = (
707
+ cwd: string,
708
+ dir?: string,
709
+ ): { enabled: boolean; workspace: string | null; from: string | null } => {
710
+ let matched: ReturnType<typeof matchRuntimeWorkspace>;
711
+ try {
712
+ matched = matchRuntimeWorkspace(cwd, dir);
713
+ } catch {
714
+ return { enabled: true, workspace: null, from: null };
715
+ }
716
+ if (!matched) return { enabled: true, workspace: null, from: null };
717
+ const { selected, matches } = matched;
718
+ const enclosing = matches
719
+ .filter((entry) => entry !== selected)
720
+ .toSorted((a, b) => globSpecificity(b.glob) - globSpecificity(a.glob));
721
+ const source = [selected, ...enclosing].find((entry) => typeof entry.trailers === "boolean");
722
+ return {
723
+ enabled: source?.trailers !== false,
724
+ workspace: selected.name,
725
+ from: source?.name ?? null,
726
+ };
727
+ };
728
+
686
729
  const resolveRuntimeWorkspaceCandidate = (
687
730
  cwd: string,
688
731
  kind: RuntimeWorkspacePolicy,
@@ -7,6 +7,10 @@
7
7
  // `<branch>-try<n>` (refused when another slice has that branch), worktree
8
8
  // `<worktree>+try<n>` and session `<lead>-w-<slice>+try<n>`: slice ids cannot
9
9
  // contain `+`, so no attempt collides with another slice's path or session.
10
+ // The lead's host (WORKIT_HOST, or its own session variable) picks the identity
11
+ // and git rules: Claude Code workers use the WORKIT_SESSION_ID in their env (the lead's: SubagentStart issues ids only to judges) and plain
12
+ // git; OpenCode, Pi and Codex workers export nothing (the host derives the id);
13
+ // Cursor, CI and plain terminals export `<lead>-w-<slice>`.
10
14
  // The lead must have a WORKIT_SESSION_ID: worker ids derive from it. A
11
15
  // worker id longer than the ledger's 128 characters hashes its slice part. The fan-in line follows the plan's `fanIn`:
12
16
  // one PR per slice forbids every merge; integration mode has the worker merge
@@ -36,7 +40,8 @@ export type BriefOutcome = {
36
40
  tier: string;
37
41
  /** Absolute when the slice's worktree exists, else relative to the worker's worktree root. */
38
42
  scratch: string;
39
- session: string;
43
+ /** The id a worker exports, or null when its host issues or derives it. */
44
+ session: string | null;
40
45
  standing: Array<{ id: string; what: string }>;
41
46
  text: string;
42
47
  };
@@ -49,15 +54,61 @@ const REPORT = [
49
54
  " anything out of scope as a follow-up, not a diff.",
50
55
  ];
51
56
 
52
- const rules = (mode: BriefMode, branch: string, base: string): string[] => [
53
- "RULES:",
54
- mode === "new"
55
- ? ` 1. First command: \`workit git branch ${branch} --base ${base}\` (your worktree may start on another name).`
56
- : ` 1. First command: \`git switch ${branch}\`, then continue from its head.`,
57
- ' 2. Decide ambiguities yourself: `workit ledger ruling "<what>" --why "<why>" --cost-if-wrong "<cost>"`; stop only for an irreversible, security-sensitive or out-of-worktree action.',
58
- " 3. Commit with `workit git commit -m <msg> -- <paths in SCOPE>`; push only if this brief says so.",
59
- " 4. Never record a verdict on your own work.",
60
- ];
57
+ /**
58
+ * How a worker's identity is settled on the lead's host (host-session.ts,
59
+ * docs/agents/hosts.md):
60
+ * - `issued`: Claude Code. SubagentStart issues ids only to verifiers and
61
+ * reviewers, so an implementer keeps the lead's WORKIT_SESSION_ID from its
62
+ * env; worktree isolation refuses every `workit git` form, so it uses
63
+ * plain git and never mints an id;
64
+ * - `derived`: OpenCode, Pi, Codex. The worker's shell carries the host's own
65
+ * session id, which exporting WORKIT_SESSION_ID cannot replace;
66
+ * - `declared`: Cursor, CI, a plain terminal. Nothing vouches for an id, so
67
+ * the brief declares `<lead>-w-<slice>`.
68
+ */
69
+ export type BriefIdentity = "issued" | "derived" | "declared";
70
+
71
+ const identityOf = (host: string | null): BriefIdentity => {
72
+ const family = host?.replace(/_(?:cli|desktop)$/, "") ?? "";
73
+ if (family === "claude_code") return "issued";
74
+ if (family === "opencode" || family === "pi" || family === "codex") return "derived";
75
+ return "declared";
76
+ };
77
+
78
+ const identityLines = (identity: BriefIdentity, session: string): string[] => {
79
+ if (identity === "issued")
80
+ return [
81
+ " Session: your env already has WORKIT_SESSION_ID (the lead's id; SubagentStart issues ids only to verifiers and reviewers). Use it as is, in your Workit-Session trailer and workit commands; never export or hand-mint one.",
82
+ ];
83
+ if (identity === "derived")
84
+ return [
85
+ " Session: your host already sets WORKIT_SESSION_ID in your shell; export nothing and never hand-mint one.",
86
+ ];
87
+ return [` export WORKIT_SESSION_ID=${session}`];
88
+ };
89
+
90
+ const rules = (
91
+ identity: BriefIdentity,
92
+ mode: BriefMode,
93
+ branch: string,
94
+ base: string,
95
+ ): string[] => {
96
+ const plain = identity === "issued";
97
+ let first = ` 1. First command: \`workit git branch ${branch} --base ${base}\` (your worktree may start on another name).`;
98
+ if (mode === "resume")
99
+ first = ` 1. First command: \`git switch ${branch}\`, then continue from its head.`;
100
+ else if (plain)
101
+ first = ` 1. First command: \`git fetch origin && git switch -c ${branch} origin/${base}\` (plain git: worktree isolation refuses every \`workit git\` form).`;
102
+ return [
103
+ "RULES:",
104
+ first,
105
+ ' 2. Decide ambiguities yourself: `workit ledger ruling "<what>" --why "<why>" --cost-if-wrong "<cost>"`; stop only for an irreversible, security-sensitive or out-of-worktree action.',
106
+ plain
107
+ ? ' 3. Commit with plain `git commit -m <msg> --trailer "Workit-Session: <the WORKIT_SESSION_ID in your env>" --trailer "<your host attribution trailer>" -- <paths in SCOPE>`; push only if this brief says so.'
108
+ : " 3. Commit with `workit git commit -m <msg> -- <paths in SCOPE>`; push only if this brief says so.",
109
+ " 4. Never record a verdict on your own work.",
110
+ ];
111
+ };
61
112
 
62
113
  /** A session id fragment that the ledger accepts ([A-Za-z0-9_.:@/+-]). */
63
114
  const sessionSafe = (text: string): string => text.replace(/[^A-Za-z0-9_.:@/+-]/gu, "-");
@@ -119,7 +170,9 @@ export function renderBrief(
119
170
  id: row.id,
120
171
  what: String(row.what),
121
172
  }));
122
- const session = workerSession(sessionSafe(input.actor.session), slice.id, input.attempt);
173
+ const identity = identityOf(input.actor.host);
174
+ const minted = workerSession(sessionSafe(input.actor.session), slice.id, input.attempt);
175
+ const session = identity === "declared" ? minted : null;
123
176
  const fanIn =
124
177
  plan.fanIn === "integration"
125
178
  ? [
@@ -156,8 +209,8 @@ export function renderBrief(
156
209
  ...(standing.length
157
210
  ? standing.map((order) => ` - ${order.what}`)
158
211
  : [" - none recorded beyond this brief"]),
159
- ` export WORKIT_SESSION_ID=${session}`,
160
- ...rules(mode, branch, slice.base),
212
+ ...identityLines(identity, minted),
213
+ ...rules(identity, mode, branch, slice.base),
161
214
  ];
162
215
  return {
163
216
  ok: true,
package/src/forge/exec.ts CHANGED
@@ -31,6 +31,8 @@ export type ForgeRunner = (
31
31
  timeoutMs: number;
32
32
  /** Credential for this call (GH_TOKEN / GITLAB_TOKEN); never logged. */
33
33
  token?: string;
34
+ /** Written to stdin (a JSON body for `api --input -`); argv has a size limit. */
35
+ input?: string;
34
36
  },
35
37
  ) => CliRun;
36
38
 
@@ -90,7 +92,9 @@ export function systemRunner(
90
92
  timeout: options.timeoutMs,
91
93
  killSignal: "SIGKILL",
92
94
  windowsHide: true,
93
- stdio: ["ignore", "pipe", "pipe"],
95
+ ...(options.input === undefined
96
+ ? { stdio: ["ignore", "pipe", "pipe"] as const }
97
+ : { input: options.input, stdio: ["pipe", "pipe", "pipe"] as const }),
94
98
  });
95
99
  const error: NodeJS.ErrnoException | undefined = result.error;
96
100
  return {
@@ -248,8 +252,10 @@ export function apiWrite<T>(
248
252
  args: readonly string[],
249
253
  what: string,
250
254
  timeoutMs: number = FORGE_TIMEOUTS.api,
255
+ /** A request body sent on stdin (pair it with `--input -`). */
256
+ input?: string,
251
257
  ): ForgeResult<T> {
252
- const run = runner(bin, args, { timeoutMs });
258
+ const run = runner(bin, args, input === undefined ? { timeoutMs } : { timeoutMs, input });
253
259
  if (run.status !== 0) {
254
260
  const text = redactText(`${run.stderr}\n${run.stdout}`).trim();
255
261
  const reason = forgeReason(run);
@@ -528,6 +528,7 @@ export function createGitHubForge(options: {
528
528
  },
529
529
 
530
530
  merge(number, mergeOptions) {
531
+ const squash = mergeOptions.method === "squash" ? mergeOptions.squashMessage : undefined;
531
532
  // sha= is GitHub's atomic head guard: 409 when the head moved.
532
533
  const merged = apiWrite<{ sha?: string; merged?: boolean; message?: string }>(
533
534
  runner,
@@ -538,12 +539,21 @@ export function createGitHubForge(options: {
538
539
  "-X",
539
540
  "PUT",
540
541
  `repos/${repo}/pulls/${number}/merge`,
541
- "-f",
542
- `sha=${mergeOptions.sha}`,
543
- "-f",
544
- `merge_method=${mergeOptions.method}`,
542
+ ...(squash
543
+ ? ["--input", "-"]
544
+ : ["-f", `sha=${mergeOptions.sha}`, "-f", `merge_method=${mergeOptions.method}`]),
545
545
  ],
546
546
  `merge of pull request #${number}`,
547
+ undefined,
548
+ // The squash message rides stdin: argv has a size limit (E2BIG).
549
+ squash
550
+ ? JSON.stringify({
551
+ sha: mergeOptions.sha,
552
+ merge_method: mergeOptions.method,
553
+ commit_title: squash.subject,
554
+ commit_message: squash.body,
555
+ })
556
+ : undefined,
547
557
  );
548
558
  if (!merged.ok) return merged;
549
559
  if (merged.data.merged === false)
@@ -556,6 +556,7 @@ export function createGitLabForge(options: {
556
556
  },
557
557
 
558
558
  merge(iid, mergeOptions) {
559
+ const squash = mergeOptions.method === "squash" ? mergeOptions.squashMessage : undefined;
559
560
  // GitLab's merge method is a project setting; squash is per request.
560
561
  if (mergeOptions.method === "rebase")
561
562
  return failure(
@@ -573,12 +574,27 @@ export function createGitLabForge(options: {
573
574
  "-X",
574
575
  "PUT",
575
576
  `${project}/merge_requests/${iid}/merge`,
576
- "-f",
577
- `sha=${mergeOptions.sha}`,
578
- "-F",
579
- `squash=${mergeOptions.method === "squash" ? "true" : "false"}`,
577
+ ...(squash
578
+ ? ["--input", "-"]
579
+ : [
580
+ "-f",
581
+ `sha=${mergeOptions.sha}`,
582
+ "-F",
583
+ `squash=${mergeOptions.method === "squash" ? "true" : "false"}`,
584
+ ]),
580
585
  ],
581
586
  `merge of merge request !${iid}`,
587
+ undefined,
588
+ // The squash message rides stdin: argv has a size limit (E2BIG).
589
+ squash
590
+ ? JSON.stringify({
591
+ sha: mergeOptions.sha,
592
+ squash: true,
593
+ squash_commit_message: squash.body
594
+ ? `${squash.subject}\n\n${squash.body}`
595
+ : squash.subject,
596
+ })
597
+ : undefined,
582
598
  );
583
599
  if (!merged.ok) return merged;
584
600
  const value = merged.data;
@@ -16,6 +16,9 @@
16
16
  // The merge call carries the head SHA (GitHub `sha=`, GitLab `sha=`), so a
17
17
  // head that moves after the gates refuses instead of merging unverified code.
18
18
  // --delete-branch deletes with a lease on that same SHA.
19
+ // A squash with --subject/--body, or in a workspace with `trailers: false`,
20
+ // sends its own message (the PR title and the branch's commits by default,
21
+ // on stdin) with `Workit-Session:` lines dropped from each trailer block.
19
22
  import {
20
23
  requireGrant,
21
24
  resolveAutonomy,
@@ -24,8 +27,9 @@ import {
24
27
  } from "../autonomy";
25
28
  import { branchKindOf, flowRoute, gitFlowOf, isProtectedTarget } from "../core/branch";
26
29
  import { vcsConfig } from "../core/vcs-config";
30
+ import { workspaceTrailers } from "../core/workspaces";
27
31
  import { workspaceReleaseTracks } from "../core/release-tracks";
28
- import { deleteRemoteBranch } from "../git/ops";
32
+ import { deleteRemoteBranch, stripSessionTrailers } from "../git/ops";
29
33
  import { spawnSync } from "node:child_process";
30
34
  import { currentBranch, fetchRefs, GIT_TIMEOUTS, remoteRefTip, resolveRef } from "../git/rev";
31
35
  import {
@@ -343,6 +347,9 @@ export type MergeInput = {
343
347
  expect?: { base: string; branch: string };
344
348
  /** Merge without a verdict (`merge: true` only); the reason is recorded. */
345
349
  unverified?: { reason: string };
350
+ /** Squash commit subject and body (`--subject`, `--body`); unset parts use the defaults. */
351
+ subject?: string;
352
+ body?: string;
346
353
  };
347
354
 
348
355
  export type MergeOutcome = {
@@ -469,6 +476,90 @@ export function nextHint(next: string, number: number): string | null {
469
476
  }
470
477
  }
471
478
 
479
+ /**
480
+ * The squash message workit sends, or null to leave the forge default: only a
481
+ * squash with --subject/--body or in a workspace with `trailers: false`.
482
+ * Defaults: the PR title (GitHub appends `(#n)` like its own default) and the
483
+ * branch's commits as GitHub lists them; Workit-Session lines never survive.
484
+ */
485
+ function squashMessageFor(
486
+ cwd: string,
487
+ resolved: ResolvedForge,
488
+ input: MergeInput,
489
+ doc: PrStatusDoc,
490
+ ): ForgeResult<{ subject: string; body: string }> | null {
491
+ if (input.method !== "squash") return null;
492
+ if (input.subject === undefined && input.body === undefined && workspaceTrailers(cwd).enabled)
493
+ return null;
494
+ let subject = input.subject?.trim() ?? "";
495
+ if (!subject) {
496
+ const meta = resolved.forge.prMeta(doc.number);
497
+ if (!meta.ok) return meta;
498
+ subject =
499
+ resolved.forge.kind === "github" ? `${meta.data.title} (#${doc.number})` : meta.data.title;
500
+ }
501
+ // An explicit body is kept byte for byte but for its trailer block.
502
+ const body =
503
+ input.body === undefined
504
+ ? squashCommitList(cwd, resolved, doc.base, doc.head.sha)
505
+ : stripSessionTrailers(input.body, { subject: false });
506
+ return success({ subject, body });
507
+ }
508
+
509
+ /** Bounds of the generated squash body: a forge message, not an archive. */
510
+ const SQUASH_LIST_MAX_COMMITS = 100;
511
+ const SQUASH_LIST_MAX_CHARS = 60_000;
512
+
513
+ /**
514
+ * `* subject` and body of each non-merge commit on `base..head`, oldest
515
+ * first, from the local clone ("" when absent); each commit loses its
516
+ * Workit-Session trailers, and past the bounds the rest is counted.
517
+ */
518
+ function squashCommitList(
519
+ cwd: string,
520
+ resolved: ResolvedForge,
521
+ base: string,
522
+ head: string,
523
+ ): string {
524
+ const from =
525
+ resolveRef(cwd, `refs/remotes/${resolved.baseRemote}/${base}`) ??
526
+ resolveRef(cwd, `refs/heads/${base}`);
527
+ if (!from) return "";
528
+ const log = spawnSync(
529
+ "git",
530
+ ["log", "--reverse", "--no-merges", "--format=%B%x1e", `${from}..${head}`],
531
+ {
532
+ cwd,
533
+ encoding: "utf8",
534
+ timeout: GIT_TIMEOUTS.local,
535
+ killSignal: "SIGKILL",
536
+ maxBuffer: 64 * 1024 * 1024,
537
+ },
538
+ );
539
+ if (log.status !== 0) return "";
540
+ const entries = log.stdout
541
+ .split("\x1e")
542
+ .map((message) => stripSessionTrailers(message.replace(/^\n+/u, ""), { subject: true }))
543
+ .filter((message) => message.trim())
544
+ .map((message) => {
545
+ const [subject = "", ...rest] = message.trimEnd().split("\n");
546
+ const body = rest.join("\n").replace(/^\n+/u, "");
547
+ return body ? `* ${subject}\n\n${body}` : `* ${subject}`;
548
+ });
549
+ const listed: string[] = [];
550
+ let size = 0;
551
+ for (const entry of entries) {
552
+ if (listed.length >= SQUASH_LIST_MAX_COMMITS || size + entry.length > SQUASH_LIST_MAX_CHARS)
553
+ break;
554
+ listed.push(entry);
555
+ size += entry.length + 2;
556
+ }
557
+ const more = entries.length - listed.length;
558
+ return [...listed, ...(more ? [`… and ${more} more commit${more === 1 ? "" : "s"}`] : [])].join(
559
+ "\n\n",
560
+ );
561
+ }
562
+
472
563
  export async function mergePullRequest(
473
564
  cwd: string,
474
565
  resolved: ResolvedForge,
@@ -596,7 +687,13 @@ export async function mergePullRequest(
596
687
  { reason: "needs_verdict", verdict: summary },
597
688
  );
598
689
 
599
- const merged = resolved.forge.merge(doc.number, { sha: head, method: input.method });
690
+ const squashMessage = squashMessageFor(cwd, resolved, input, doc);
691
+ if (squashMessage && !squashMessage.ok) return squashMessage;
692
+ const merged = resolved.forge.merge(doc.number, {
693
+ sha: head,
694
+ method: input.method,
695
+ ...(squashMessage ? { squashMessage: squashMessage.data } : {}),
696
+ });
600
697
  if (!merged.ok) return merged;
601
698
  // Post-verify: the forge must report the PR merged.
602
699
  let state = "open";
@@ -140,6 +140,13 @@ export type CreatePrInput = {
140
140
  draft: boolean;
141
141
  };
142
142
 
143
+ export type MergeOptions = {
144
+ sha: string;
145
+ method: MergeMethod;
146
+ /** Squash only: the commit message the forge writes instead of its default. */
147
+ squashMessage?: { subject: string; body: string };
148
+ };
149
+
143
150
  export type MergeResult = {
144
151
  /** The merge (or squash/rebase tip) commit the forge reported, when it did. */
145
152
  mergeSha: string | null;
@@ -187,7 +194,7 @@ export interface Forge {
187
194
  * Merge with the head guard: the forge refuses (`blocked`, head_moved) when
188
195
  * the PR head is no longer `sha`.
189
196
  */
190
- merge(n: number, options: { sha: string; method: MergeMethod }): ForgeResult<MergeResult>;
197
+ merge(n: number, options: MergeOptions): ForgeResult<MergeResult>;
191
198
  /** Retarget a PR/MR to another base branch (S12 stacks). */
192
199
  updateBase(n: number, base: string): ForgeResult<void>;
193
200
  /** Title, draft flag, base and state of one PR/MR. */
package/src/git/ops.ts CHANGED
@@ -12,7 +12,9 @@
12
12
  // the named paths (`--only` semantics) or, with --all, everything is
13
13
  // committed: unrelated dirt is never swept in implicitly. The acting session
14
14
  // goes into a `Workit-Session:` trailer and a `commit.recorded` ledger row,
15
- // so a verdict from the same session reads as the author's (D18).
15
+ // so a verdict from the same session reads as the author's (D18). A
16
+ // workspace with `trailers: false` gets the ledger row only. `--trailer`
17
+ // adds the caller's own trailers (Co-Authored-By) to the same commit.
16
18
  // - push: never to a protected branch, an exact SHA (a commit that lands while
17
19
  // pushing is not pushed by accident), force only as --force-with-lease with
18
20
  // the tip workit last recorded, and success only when the remote tip
@@ -39,6 +41,7 @@ import { detectCommitFlavor, matchCommitFlavor } from "../core/commit-flavors";
39
41
  import { pushTargetIsStable } from "../core/pr-create";
40
42
  import { recordBranchBase, trackBranchName } from "../core/release-tracks";
41
43
  import { vcsConfig, type ResolvedReleaseTrack } from "../core/vcs-config";
44
+ import { workspaceTrailers } from "../core/workspaces";
42
45
  import { redactText } from "../forge/redact";
43
46
  import { failure, success, type ForgeResult } from "../forge/types";
44
47
  import { appendObserved, readLedger, type LedgerActor } from "../ledger";
@@ -395,6 +398,8 @@ export type CommitOutcome = {
395
398
  /** Changes left uncommitted (not selected). */
396
399
  leftDirty: number;
397
400
  session: string | null;
401
+ /** Whether the commit carries the `Workit-Session:` trailer (workspace `trailers`). */
402
+ sessionTrailer: boolean;
398
403
  /** With --amend: the commit this one replaced. */
399
404
  amended: string | null;
400
405
  /** Follow-ups the agent should know (an amended commit that was already pushed). */
@@ -430,6 +435,51 @@ export function lintCommitMessage(
430
435
  }
431
436
 
432
437
  const SESSION_SAFE = /^[\w.:@/+-]{1,128}$/u;
438
+ /** A `--trailer` the caller adds: one `Key: value` line. */
439
+ const EXTRA_TRAILER = /^[A-Za-z0-9][A-Za-z0-9-]*: *\S[^\n\r]*$/u;
440
+
441
+ const SESSION_KEY = /^workit-session\s*:/iu;
442
+ /** A trailer line (`Key: value`, no leading space) as git reads one. */
443
+ const TRAILER_LINE = /^[A-Za-z0-9][A-Za-z0-9-]*\s*:/u;
444
+
445
+ /**
446
+ * `text` without the `Workit-Session:` lines of its trailer block: the last
447
+ * paragraph, when every line in it is a trailer or a folded continuation
448
+ * (leading whitespace) of one. Anything else, an indented line included, is
449
+ * prose and is left alone; the rest of the text is returned byte for byte.
450
+ * With `subject`, the first paragraph is a subject and never a trailer
451
+ * block (a commit message); without it (a PR body) it can be.
452
+ */
453
+ export function stripSessionTrailers(text: string, options: { subject: boolean }): string {
454
+ const end = text.length - (/\s*$/u.exec(text)?.[0].length ?? 0);
455
+ const lines = text.slice(0, end).split("\n");
456
+ let start = lines.length;
457
+ while (start > 0 && lines[start - 1]?.trim() !== "") start -= 1;
458
+ if (start === 0 && options.subject) return text;
459
+ const block = lines.slice(start);
460
+ if (!block.length || !TRAILER_LINE.test(block[0] ?? "")) return text;
461
+ if (!block.every((line) => TRAILER_LINE.test(line) || /^\s+\S/u.test(line))) return text;
462
+ const kept: string[] = [];
463
+ let dropping = false;
464
+ for (const line of block) {
465
+ if (TRAILER_LINE.test(line)) dropping = SESSION_KEY.test(line);
466
+ if (!dropping) kept.push(line);
467
+ }
468
+ if (kept.length === block.length) return text;
469
+ let head = lines.slice(0, start);
470
+ if (!kept.length) {
471
+ let cut = head.length;
472
+ while (cut > 0 && head[cut - 1]?.trim() === "") cut -= 1;
473
+ head = head.slice(0, cut);
474
+ }
475
+ return [...head, ...kept].join("\n") + text.slice(end);
476
+ }
477
+
478
+ /** The values of the `Workit-Session:` trailers among `trailers`. */
479
+ const sessionValues = (trailers: readonly string[]): string[] =>
480
+ trailers
481
+ .filter((trailer) => SESSION_KEY.test(trailer))
482
+ .map((trailer) => trailer.slice(trailer.indexOf(":") + 1).trim());
433
483
 
434
484
  // Fixed trailer syntax, whatever the user's trailer.* config says.
435
485
  const TRAILER_CONFIG = ["-c", "trailer.separators=:", "-c", "trailer.where=end"];
@@ -522,6 +572,8 @@ export function gitCommit(
522
572
  amend?: boolean;
523
573
  /** Record a commit even when it changes no file. */
524
574
  allowEmpty?: boolean;
575
+ /** Extra `Key: value` trailers for this commit (`--trailer`, e.g. Co-Authored-By). */
576
+ trailers?: readonly string[];
525
577
  actor: LedgerActor;
526
578
  env?: NodeJS.ProcessEnv;
527
579
  },
@@ -529,6 +581,19 @@ export function gitCommit(
529
581
  const given = input.message?.trim() ?? "";
530
582
  if (!given && !input.amend)
531
583
  return failure("invalid_input", "a commit message is required (-m <msg> or -F <file>)");
584
+ const extra = (input.trailers ?? []).map((trailer) => trailer.trim());
585
+ const malformed = extra.find((trailer) => !EXTRA_TRAILER.test(trailer));
586
+ if (malformed !== undefined)
587
+ return failure(
588
+ "invalid_input",
589
+ `--trailer ${JSON.stringify(malformed)} is not one "Key: value" line`,
590
+ `workit git commit -m '<msg>' --trailer "Co-Authored-By: Name <email>"`,
591
+ );
592
+ if (extra.some((trailer) => /^workit-session:/iu.test(trailer)))
593
+ return failure(
594
+ "invalid_input",
595
+ "--trailer cannot set Workit-Session: workit writes it from the acting session",
596
+ );
532
597
  const paths = input.paths ?? [];
533
598
  if (input.all && paths.length) return failure("invalid_input", "pass --all or paths, not both");
534
599
  const progress = operationInProgress(cwd);
@@ -559,6 +624,7 @@ export function gitCommit(
559
624
  }
560
625
  let message = given;
561
626
  let carried: string[] = [];
627
+ let previousTrailers: string[] = [];
562
628
  if (given) {
563
629
  const lint = lintCommitMessage(cwd, given);
564
630
  if (!lint.ok) return failure("blocked", lint.error, lint.correction);
@@ -568,7 +634,8 @@ export function gitCommit(
568
634
  if (!kept.ok)
569
635
  return failure("failed", `could not read the message of ${branch}: ${gitError(kept)}`);
570
636
  // --no-edit keeps the message whole; a new message carries the old trailers over.
571
- if (given) carried = messageTrailers(cwd, kept.stdout.trim());
637
+ previousTrailers = messageTrailers(cwd, kept.stdout.trim());
638
+ if (given) carried = previousTrailers;
572
639
  else message = kept.stdout.trim();
573
640
  }
574
641
  const session = input.actor.session;
@@ -588,10 +655,36 @@ export function gitCommit(
588
655
  const args = ["commit", "--no-edit"];
589
656
  if (input.amend) args.push("--amend");
590
657
  if (input.allowEmpty || emptyAmend) args.push("--allow-empty");
591
- args.push(
592
- "-m",
593
- withTrailers(cwd, message, [...carried, ...(session ? [`Workit-Session: ${session}`] : [])]),
658
+ // Off (`trailers: false`): authorship is the commit.recorded row alone,
659
+ // and an amend drops the Workit-Session trailers the old commit had.
660
+ const trailersOn = workspaceTrailers(cwd).enabled;
661
+ const sessionTrailer = Boolean(session) && trailersOn;
662
+ if (!trailersOn) {
663
+ carried = carried.filter((trailer) => !SESSION_KEY.test(trailer));
664
+ if (!given) message = stripSessionTrailers(message, { subject: true });
665
+ }
666
+ const finalMessage = withTrailers(cwd, message, [
667
+ ...carried,
668
+ ...extra,
669
+ ...(sessionTrailer ? [`Workit-Session: ${session}`] : []),
670
+ ]);
671
+ // Only workit writes Workit-Session: the acting session's, or (trailers
672
+ // on) the amended commit's own. A message line or a `trailer.<alias>.key`
673
+ // config that expands a --trailer into the key never forges one.
674
+ const allowed = new Set([
675
+ ...(trailersOn ? sessionValues(previousTrailers) : []),
676
+ ...(sessionTrailer && session ? [session] : []),
677
+ ]);
678
+ const forged = sessionValues(messageTrailers(cwd, finalMessage)).find(
679
+ (value) => !allowed.has(value),
594
680
  );
681
+ if (forged !== undefined)
682
+ return failure(
683
+ "invalid_input",
684
+ `forged_session_trailer: the message would carry "Workit-Session: ${forged}", which workit did not add; workit writes that trailer itself`,
685
+ "drop the Workit-Session line (or the trailer.<alias>.key config that expands into it)",
686
+ );
687
+ args.push("-m", finalMessage);
595
688
  if (paths.length) {
596
689
  if (paths.some((value) => value.startsWith("-") || !value.trim()))
597
690
  return failure("invalid_input", "paths must not be empty or start with -");
@@ -632,7 +725,8 @@ export function gitCommit(
632
725
  }
633
726
  const committed = git(cwd, args, {
634
727
  timeoutMs: COMMIT_TIMEOUT_MS,
635
- env: input.env,
728
+ // Decided here: the session hook's helper skips its own `workit git trailers` call.
729
+ env: { ...(input.env ?? process.env), WORKIT_TRAILERS: trailersOn ? "on" : "off" },
636
730
  });
637
731
  if (!committed.ok)
638
732
  return failure(
@@ -688,6 +782,7 @@ export function gitCommit(
688
782
  files,
689
783
  leftDirty,
690
784
  session,
785
+ sessionTrailer,
691
786
  amended: previous,
692
787
  notes,
693
788
  recorded: row.ok ? { id: String(row.value.id) } : { error: row.error },