@brainervirus/workit-core 8.13.0 → 8.14.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.
@@ -17,6 +17,7 @@
17
17
  // the integration tip before reporting, and nothing else. It only reads, apart
18
18
  // from hiding `.workit-scratch/` in the repository's info/exclude.
19
19
  import { createHash } from "node:crypto";
20
+ import { workspaceTierModel } from "./core/workspaces";
20
21
  import { activeStanding, readLedger, type LedgerActor } from "./ledger";
21
22
  import { branchRef, fanoutFail, planLeads, type FanoutFile, type FanoutResult } from "./fanout";
22
23
  import {
@@ -38,6 +39,8 @@ export type BriefOutcome = {
38
39
  branch: string;
39
40
  base: string;
40
41
  tier: string;
42
+ /** The model `fanout.tierModels` names for the tier on the lead's host, else null. */
43
+ model: string | null;
41
44
  /** Absolute when the slice's worktree exists, else relative to the worker's worktree root. */
42
45
  scratch: string;
43
46
  /** The id a worker exports, or null when its host issues or derives it. */
@@ -171,6 +174,7 @@ export function renderBrief(
171
174
  what: String(row.what),
172
175
  }));
173
176
  const identity = identityOf(input.actor.host);
177
+ const model = workspaceTierModel(cwd, slice.tier, input.actor.host);
174
178
  const minted = workerSession(sessionSafe(input.actor.session), slice.id, input.attempt);
175
179
  const session = identity === "declared" ? minted : null;
176
180
  const fanIn =
@@ -200,6 +204,9 @@ export function renderBrief(
200
204
  ...slice.acceptance.map((line) => ` - ${line}`),
201
205
  `VERIFY: ${slice.verify.join("; ")}`,
202
206
  `TIER: ${slice.tier}`,
207
+ ...(model
208
+ ? [` model: ${model.model} (fanout.tierModels in workspace ${model.workspace})`]
209
+ : []),
203
210
  `TIMEBOX: ${slice.timebox ?? DEFAULT_TIMEBOX}; past it without a new commit you will be replaced`,
204
211
  scratchLine,
205
212
  `FORBIDDEN: ${slice.forbidden.join("; ")}`,
@@ -222,6 +229,7 @@ export function renderBrief(
222
229
  branch,
223
230
  base: slice.base,
224
231
  tier: slice.tier,
232
+ model: model?.model ?? null,
225
233
  scratch,
226
234
  session,
227
235
  standing,
@@ -18,7 +18,8 @@
18
18
  import { parseDuration } from "./duration";
19
19
  import { checksState, gatingChecks, missingRequired, type ChecksState } from "./forge/report";
20
20
  import type { ResolvedForge } from "./forge/resolve";
21
- import { checkVerdicts, readLedger, type ReadRow, type VerdictBasis } from "./ledger";
21
+ import { workspaceTierModel } from "./core/workspaces";
22
+ import { actorFromEnv, checkVerdicts, readLedger, type ReadRow, type VerdictBasis } from "./ledger";
22
23
  import { sliceLandings } from "./fanout-check";
23
24
  import { forgeBreaker, inSliceRun } from "./fanout-landed";
24
25
  import {
@@ -74,6 +75,8 @@ export type StatusOutcome = {
74
75
  landingOrder: string[];
75
76
  /** Not started; every dependency landed (or, stacked, its parent has an accepted verdict). */
76
77
  spawnable: string[];
78
+ /** The model `fanout.tierModels` names for each spawnable slice's TIER on this host. */
79
+ spawnModels: Record<string, string>;
77
80
  stuck: string[];
78
81
  next: string;
79
82
  notes: string[];
@@ -85,6 +88,8 @@ export type StatusOptions = {
85
88
  forgeError?: string | null;
86
89
  stuckAfterMs?: number | null;
87
90
  now?: Date;
91
+ /** The lead's host, for `fanout.tierModels`; default: the environment's (WORKIT_HOST…). */
92
+ host?: string | null;
88
93
  };
89
94
 
90
95
  /** A TIMEBOX such as `45 minutes`, `2h` or `1h30m`, in ms; null without a unit or otherwise. */
@@ -279,6 +284,16 @@ export function fanoutStatus(
279
284
  if (row.waitsFor.length && !suggested.includes(id))
280
285
  row.reasons.push(`waits for ${row.waitsFor.join(", ")}`);
281
286
  }
287
+ const host = options.host === undefined ? actorFromEnv(process.env).host : options.host;
288
+ const spawnModels: Record<string, string> = {};
289
+ for (const id of spawnable) {
290
+ const tier = plan.slices.find((slice) => slice.id === id)?.tier;
291
+ const model = tier ? workspaceTierModel(cwd, tier, host) : null;
292
+ if (model) spawnModels[id] = model.model;
293
+ }
294
+ const spawnList = spawnable
295
+ .map((id) => (spawnModels[id] ? `${id} (model ${spawnModels[id]})` : id))
296
+ .join(", ");
282
297
  const stuck = order.filter((id) => out.get(id)?.stuck);
283
298
  const down = breaker.down();
284
299
  if (down)
@@ -300,7 +315,7 @@ export function fanoutStatus(
300
315
  : noPr.length
301
316
  ? `open PRs for ${noPr.join(", ")}: workit git push && workit pr create --fill on each branch`
302
317
  : spawnable.length
303
- ? `spawn ${spawnable.join(", ")} (workit fanout worktree create <slice> on hosts without native worktrees)`
318
+ ? `spawn ${spawnList} (workit fanout worktree create <slice> on hosts without native worktrees)`
304
319
  : order.every((id) => out.get(id)?.landed)
305
320
  ? "every slice has landed"
306
321
  : "wait for workers, then workit fanout status again";
@@ -317,6 +332,7 @@ export function fanoutStatus(
317
332
  slices: order.map((id) => out.get(id) as SliceStatusRow),
318
333
  landingOrder: suggested,
319
334
  spawnable,
335
+ spawnModels,
320
336
  stuck,
321
337
  next,
322
338
  notes,
@@ -35,6 +35,7 @@ import { currentBranch, fetchRefs, GIT_TIMEOUTS, remoteRefTip, resolveRef } from
35
35
  import {
36
36
  appendObserved,
37
37
  checkVerdicts,
38
+ tamperedUnblock,
38
39
  readLedger,
39
40
  type LedgerActor,
40
41
  type VerdictCheck,
@@ -366,6 +367,8 @@ export type MergeOutcome = {
366
367
  grant: { source: AutonomySource };
367
368
  deletedBranch: boolean | { error: string };
368
369
  recorded: { id: string } | { error: string };
370
+ /** The `pr.merge.verified` row confirming `recorded`, or why it was not written. */
371
+ confirmed: { id: string } | { error: string };
369
372
  /**
370
373
  * Release tracks: when the PR landed on a track's production branch, the
371
374
  * branches it must flow back into (mergeBackBranches); empty otherwise.
@@ -420,7 +423,9 @@ export type MergeRefusal = {
420
423
  | "already_merged"
421
424
  | "closed"
422
425
  | "protected_branch"
423
- | "base_mismatch";
426
+ | "base_mismatch"
427
+ | "ledger_tampered"
428
+ | "intent_unrecorded";
424
429
  next?: string;
425
430
  blockers?: string[];
426
431
  verdict?: Pick<VerdictCheck, "head" | "accepted" | "authors">;
@@ -434,6 +439,8 @@ export type MergeResult =
434
439
  error: string;
435
440
  unblock?: string;
436
441
  refusal?: MergeRefusal;
442
+ /** merge_unverified: the `pr.merged` row (verified:false) written when the forge accepted it. */
443
+ recorded?: { id: string } | { error: string };
437
444
  };
438
445
 
439
446
  const refuse = (error: string, unblock: string, refusal: MergeRefusal): MergeResult => ({
@@ -635,7 +642,7 @@ export async function mergePullRequest(
635
642
  { reason: "protected_branch" },
636
643
  );
637
644
  }
638
- const ledger = readLedger(cwd);
645
+ const ledger = readLedger(cwd, { integrity: true });
639
646
  if (!ledger.ok) return ledger;
640
647
  const check = checkVerdicts(cwd, branch, ledger.value.rows);
641
648
  const summary = { head: check.head, accepted: check.accepted, authors: check.authors };
@@ -680,6 +687,12 @@ export async function mergePullRequest(
680
687
  `git fetch && git switch ${branch} && git merge --ff-only @{u} # then re-verify that head`,
681
688
  { reason: "head_mismatch", verdict: summary },
682
689
  );
690
+ else if (!covered && check.accepted.reasons.includes("ledger_tampered"))
691
+ return refuse(
692
+ `ledger_tampered: a ledger row was edited, removed or moved, so no verdict for ${label} is accepted until it is repaired`,
693
+ tamperedUnblock(cwd),
694
+ { reason: "ledger_tampered", verdict: summary },
695
+ );
683
696
  else if (!covered)
684
697
  return refuse(
685
698
  `NEEDS_VERDICT: ${label} has no accepted independent verdict for ${head.slice(0, 12)} (${check.accepted.reasons.join(", ")})`,
@@ -689,12 +702,57 @@ export async function mergePullRequest(
689
702
 
690
703
  const squashMessage = squashMessageFor(cwd, resolved, input, doc);
691
704
  if (squashMessage && !squashMessage.ok) return squashMessage;
705
+ // The intent goes in before the forge write: a merge call that errors after
706
+ // the forge did merge still leaves a record `pr status` reads. A merge that
707
+ // cannot be recorded never starts.
708
+ const intent = appendObserved(cwd, {
709
+ type: "pr.merge.intent",
710
+ actor: input.actor,
711
+ branch,
712
+ head,
713
+ pr: doc.number,
714
+ base: doc.base,
715
+ method: input.method,
716
+ verdictId: verdict.verdictId,
717
+ ...(unverified ? { unverified: unverified.recorded } : {}),
718
+ grant: grant.source,
719
+ });
720
+ if (!intent.ok)
721
+ return refuse(
722
+ `intent_unrecorded: the merge intent for ${label} could not be recorded (${intent.error}); nothing was merged`,
723
+ "fix the ledger (workit ledger check), then merge again",
724
+ { reason: "intent_unrecorded" },
725
+ );
726
+ const intentId = String(intent.value.id);
692
727
  const merged = resolved.forge.merge(doc.number, {
693
728
  sha: head,
694
729
  method: input.method,
695
730
  ...(squashMessage ? { squashMessage: squashMessage.data } : {}),
696
731
  });
697
- if (!merged.ok) return merged;
732
+ if (!merged.ok)
733
+ return {
734
+ ...merged,
735
+ error: `${merged.error} (merge intent ${intentId} is recorded: if the forge merged anyway, workit pr status reads it)`,
736
+ };
737
+ // Recorded as soon as the forge accepts it (N3): a failed read below must
738
+ // not leave a merge that `pr status`, the stop hook and `verify-delivery
739
+ // merged` cannot see. The post-verify then confirms it in its own row.
740
+ const row = appendObserved(cwd, {
741
+ type: "pr.merged",
742
+ actor: input.actor,
743
+ branch,
744
+ head,
745
+ pr: doc.number,
746
+ base: doc.base,
747
+ mergeSha: merged.data.mergeSha,
748
+ method: input.method,
749
+ verdictId: verdict.verdictId,
750
+ ...(unverified ? { unverified: unverified.recorded } : {}),
751
+ grant: grant.source,
752
+ intent: intentId,
753
+ verified: false,
754
+ });
755
+ const recorded = row.ok ? { id: String(row.value.id) } : { error: row.error };
698
756
  // Post-verify: the forge must report the PR merged.
699
757
  let state = "open";
700
758
  for (const delay of [0, ...VERIFY_DELAYS_MS]) {
@@ -707,9 +765,29 @@ export async function mergePullRequest(
707
765
  return {
708
766
  ok: false,
709
767
  code: "failed",
710
- error: `merge_unverified: the forge accepted the merge of ${label} but still reports it ${state}`,
768
+ error: `merge_unverified: the forge accepted the merge of ${label} but still reports it ${state}${"id" in recorded ? `; recorded as pr.merged ${recorded.id} (verified: false)` : `; the pr.merged row could not be written (${recorded.error})`}`,
711
769
  unblock: `workit verify-delivery merged --pr ${doc.number}`,
770
+ recorded,
712
771
  };
772
+ const confirmation =
773
+ "id" in recorded
774
+ ? appendObserved(cwd, {
775
+ type: "pr.merge.verified",
776
+ actor: input.actor,
777
+ branch,
778
+ head,
779
+ pr: doc.number,
780
+ base: doc.base,
781
+ merged: recorded.id,
782
+ mergeSha: merged.data.mergeSha,
783
+ })
784
+ : null;
785
+ const confirmed: MergeOutcome["confirmed"] =
786
+ confirmation === null
787
+ ? { error: "no pr.merged row to confirm" }
788
+ : confirmation.ok
789
+ ? { id: String(confirmation.value.id) }
790
+ : { error: confirmation.error };
713
791
 
714
792
  let deletedBranch: MergeOutcome["deletedBranch"] = false;
715
793
  if (input.deleteBranch) {
@@ -720,19 +798,6 @@ export async function mergePullRequest(
720
798
  deleted.ok || (gone && gone.ok && gone.sha === null) ? true : { error: deleted.error };
721
799
  }
722
800
  const mergeBack = mergeBackFor(cwd, doc.base, branch);
723
- const row = appendObserved(cwd, {
724
- type: "pr.merged",
725
- actor: input.actor,
726
- branch,
727
- head,
728
- pr: doc.number,
729
- base: doc.base,
730
- mergeSha: merged.data.mergeSha,
731
- method: input.method,
732
- verdictId: verdict.verdictId,
733
- ...(unverified ? { unverified: unverified.recorded } : {}),
734
- grant: grant.source,
735
- });
736
801
  return {
737
802
  ok: true,
738
803
  data: {
@@ -747,7 +812,8 @@ export async function mergePullRequest(
747
812
  unverified,
748
813
  grant: { source: grant.source },
749
814
  deletedBranch,
750
- recorded: row.ok ? { id: String(row.value.id) } : { error: row.error },
815
+ recorded,
816
+ confirmed,
751
817
  mergeBack: mergeBack.branches,
752
818
  mergeBackTrack: mergeBack.track,
753
819
  },
@@ -8,8 +8,12 @@ const R = "[REDACTED]";
8
8
  const TOKEN_PATTERNS: readonly RegExp[] = [
9
9
  // GitHub tokens (classic, fine-grained, app, OAuth, refresh).
10
10
  /\b(?:gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,})/gu,
11
- // GitLab tokens: glpat-, glrt-, gldt-, glcbt-, glptt-, glft-, gloas-, …
12
- /\bgl[a-z]{1,8}-[A-Za-z0-9_-]{16,}/gu,
11
+ // GitLab tokens, by every prefix GitLab documents
12
+ // (https://docs.gitlab.com/security/tokens/#token-prefixes) and only those:
13
+ // a bare `gl…-` also matches words such as `global-setup-teardown-hook`.
14
+ /\b(?:glpat|gloas|gldt|glrtr|glrt|glcbt|glptt|glft|glimt|glagent|glwt|glsoat|glffct)-[A-Za-z0-9_-]{20,}/gu,
15
+ // GitLab's session cookie.
16
+ /\b_gitlab_session=[A-Za-z0-9%_-]{16,}/gu,
13
17
  /\bnpm_[A-Za-z0-9]{30,}/gu,
14
18
  /\bxox[abposr]-[A-Za-z0-9-]{10,}/gu,
15
19
  /\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/gu,
@@ -181,6 +181,21 @@ function resolveCredential(
181
181
  `token_host_unverified: push host ${derived.host} only looks like ${kind}; workspace ${workspace?.name ?? "?"} vcs.tokenFile is not sent to it`,
182
182
  `if ${apiHost} is your ${kind} server, set ${kind}.host in ~/.config/workit/vcs.json (or vcs.host for the workspace); otherwise fix the push remote`,
183
183
  );
184
+ // Like youtrack.token: a token other users can read is refused (N14).
185
+ if (process.platform !== "win32") {
186
+ let mode = 0;
187
+ try {
188
+ mode = fs.statSync(tokenFile.trim()).mode;
189
+ } catch {
190
+ // unreadable: reported below
191
+ }
192
+ if ((mode & 0o077) !== 0)
193
+ return failure(
194
+ "blocked",
195
+ `workspace ${workspace?.name ?? "?"} vcs.tokenFile is readable by group or other (mode ${(mode & 0o777).toString(8).padStart(4, "0")})`,
196
+ `chmod 600 ${tokenFile.trim()}`,
197
+ );
198
+ }
184
199
  let token = "";
185
200
  try {
186
201
  token = fs.readFileSync(tokenFile.trim(), "utf8").trim();
package/src/git/ops.ts CHANGED
@@ -49,6 +49,7 @@ import {
49
49
  } from "../core/workspaces";
50
50
  import { redactText } from "../forge/redact";
51
51
  import { failure, success, type ForgeResult } from "../forge/types";
52
+ import { issuedBinding, sessionClaimRefusal } from "../host-session";
52
53
  import { appendObserved, readLedger, type LedgerActor } from "../ledger";
53
54
  import {
54
55
  GIT_TIMEOUTS,
@@ -649,6 +650,25 @@ export function gitCommit(
649
650
  "invalid_input",
650
651
  "WORKIT_SESSION_ID must be 1-128 characters of [A-Za-z0-9_.:@/+-]",
651
652
  );
653
+ // A commit names its author session (commit.recorded, the trailer): the
654
+ // same claim check as a verdict, and an id issued to a verifier or
655
+ // reviewer never authors code (audit mv83zyg6 P2b).
656
+ if (session) {
657
+ const env = input.env ?? process.env;
658
+ if (issuedBinding(cwd, session) !== null)
659
+ return failure(
660
+ "blocked",
661
+ `judge_session: ${session} was issued to a verifier or reviewer subagent; it records verdicts and never authors commits`,
662
+ "commit from the implementer's or the lead's own session (unset WORKIT_SESSION_ID back to the shell's)",
663
+ );
664
+ const refusal = sessionClaimRefusal(env, session, cwd);
665
+ if (refusal)
666
+ return failure(
667
+ "blocked",
668
+ refusal.error,
669
+ "commit from the session this shell really is: never set WORKIT_SESSION_ID by hand for a commit (unset it, or use the id the host put in this shell)",
670
+ );
671
+ }
652
672
  // A commit that only rewords (amend) or is empty on purpose needs no change.
653
673
  const changeRequired = !input.amend && !input.allowEmpty;
654
674
 
@@ -26,7 +26,8 @@ import {
26
26
  withContextLine,
27
27
  } from "./skill-nudge";
28
28
  import { stopDecision } from "./stop";
29
- import { shellWrites, writeGate } from "./write-gate";
29
+ import { liveOwner } from "../core/live-session";
30
+ import { shellGate, writeGate } from "./write-gate";
30
31
  import type { HookDecision, HookEventKind, HookInput, HostAdapter, RenderedHook } from "./protocol";
31
32
 
32
33
  export type HookDeps = { descriptor: HostDescriptor; addendum: string | null };
@@ -108,10 +109,13 @@ const subagentStartText = (
108
109
  const readOnly = `Workit observed ${who} as read-only/agent-guided.`;
109
110
  if (role !== "judge" || !event.agentId) return readOnly;
110
111
  const own = subagentSession(session, event.agentId);
111
- // `ledger verdict` accepts `<lead>:<agent id>` only when this hook issued it.
112
+ // `ledger verdict` accepts `<lead>:<agent id>` only when this hook issued it,
113
+ // and only with the token it hands to this subagent alone.
112
114
  // (An isolated judge's text is rebuilt with cwd "": the hook's own cwd then.)
113
- if (session) recordIssuedSession(input.cwd || process.cwd(), session, own);
114
- return `${readOnly} Its own Workit session is ${own}: record verdicts with \`workit ledger verdict <result> --session ${own} ...\` so the ledger tells it apart from the author.`;
115
+ // Issued once: a replayed SubagentStart for the same id gets no token.
116
+ const token = session ? recordIssuedSession(input.cwd || process.cwd(), session, own) : null;
117
+ const flags = token ? `--session ${own} --token ${token}` : `--session ${own}`;
118
+ return `${readOnly} Its own Workit session is ${own}: record verdicts with \`workit ledger verdict <result> ${flags} ...\` so the ledger tells it apart from the author.${token ? " The token is this subagent's alone: never write it into a report, a file or a commit. --token needs a workit CLI at least as new as this plugin; if the CLI answers that --token is an unknown option, update the workit CLI (workit upgrade --cli) and record again." : ""}`;
115
119
  };
116
120
 
117
121
  export function handleHook(input: HookInput, deps: HookDeps): HookDecision {
@@ -153,10 +157,10 @@ export function handleHook(input: HookInput, deps: HookDeps): HookDecision {
153
157
  if (!postTool) settlePendingCommit(input);
154
158
  const raw = canDeny ? rawGitPre(input, event.command, { nudge: false }) : NONE;
155
159
  if (raw.kind === "deny") return raw;
156
- const writes = usable(descriptor.events["write.pre"].support)
157
- ? shellWrites(event.command)
158
- : { writes: false, targets: null };
159
- const gate = writes.writes ? writeGate(input.cwd, writes.targets) : NONE;
160
+ // The before-write gate and the live-session guard (#274).
161
+ const gate = usable(descriptor.events["write.pre"].support)
162
+ ? shellGate(input.cwd, event.command, liveOwner(input, descriptor.transport), event.dialect)
163
+ : NONE;
160
164
  if (gate.kind === "deny") return gate;
161
165
  // The command will run: note HEAD so the post-tool hook (or the next
162
166
  // command, without one) can tell whether a raw commit moved it.
@@ -179,7 +183,7 @@ export function handleHook(input: HookInput, deps: HookDeps): HookDecision {
179
183
  return NONE;
180
184
  case "write.pre":
181
185
  return usable(descriptor.events["write.pre"].support)
182
- ? writeGate(input.cwd, event.paths)
186
+ ? writeGate(input.cwd, event.paths, liveOwner(input, descriptor.transport))
183
187
  : NONE;
184
188
  case "subagent.start":
185
189
  return {
@@ -19,7 +19,8 @@ export {
19
19
  type SessionHandle,
20
20
  } from "./context";
21
21
  export { shellPolicy } from "./policy";
22
- export { shellWrites, writeGate } from "./write-gate";
22
+ export { shellGate, shellWrites, writeGate } from "./write-gate";
23
+ export { liveOwner } from "../core/live-session";
23
24
  export { asksUser, stopDecision, stopObligation, type StopObligation } from "./stop";
24
25
  export { callKey, noteRawCommit, rawGitPost, rawGitPre } from "./raw-git";
25
26
  export { promptNudge, recordSkillLoad, shellNudge, skillFileIn } from "./skill-nudge";
package/src/hooks/stop.ts CHANGED
@@ -253,9 +253,11 @@ export function stopObligation(
253
253
  if (!session || input.session.agentId || !inWorkitWorkspace(input.cwd)) return null;
254
254
  const branch = currentBranch(input.cwd);
255
255
  if (!branch || guardedBranch(input.cwd, branch)) return null;
256
- const ledger = readLedger(input.cwd);
256
+ // Read with the hash chain: a hand-appended row neither creates nor
257
+ // clears an obligation, and verdicts are judged with integrity (N1).
258
+ const ledger = readLedger(input.cwd, { integrity: true });
257
259
  if (!ledger.ok) return null;
258
- const all = ledger.value.rows;
260
+ const all = ledger.value.rows.filter((row) => row.integrity !== "unverified");
259
261
  const rows = all.filter((row) => row.branch === branch);
260
262
  const mine = rows.filter((row) => sessionOf(row) === session);
261
263
  const commits = new Set(