@brainervirus/workit-core 7.7.0 → 8.0.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brainervirus/workit-core",
3
- "version": "7.7.0",
3
+ "version": "8.0.0",
4
4
  "private": false,
5
5
  "description": "Workit shared core — task, policy, evidence, review, decision, worker, and writer state for agentic coding workflows",
6
6
  "keywords": [
@@ -51,8 +51,8 @@ the base keeps moving, or after 3 failed fix attempts on the same check.
51
51
  flake|infra` per head. Real: reproduce with `workit check`, fix the root
52
52
  cause, batch fixes into one push.
53
53
  6. **Verified.** After the last push a non-author records a verdict
54
- (workit-review). Land only when granted: `workit stack land` (the
55
- contiguous verified run from the root) or `workit pr merge`.
54
+ (workit-review). Land only when granted: `workit stack land` (verified run
55
+ from the root) or `workit pr merge`; `--unverified --reason` only if user-asked.
56
56
  7. **Observe it landed:** `workit verify-delivery pr` or `merge`. After a failed
57
57
  verdict or a check red 3+ times, at any endpoint: offer `/wk-retro` in one
58
58
  line of the final or stop report; never pause the babysit loop for it.
package/src/autonomy.ts CHANGED
@@ -17,8 +17,9 @@
17
17
  // - Defaults (D4) when a kind is absent: push, pr and rerun are allowed;
18
18
  // merge and release need an explicit grant. The default ceiling is a stack
19
19
  // opened, CI green and independently verified: "verified, ready".
20
- // - `merge: "verified"` allows a merge only with an accepted independent
21
- // verdict (S13); `merge: true` is the only way to merge without one.
20
+ // - Every merge needs an accepted independent verdict (S13). `merge: true`
21
+ // also allows an explicit, ledger-recorded `--unverified --reason` bypass;
22
+ // `merge: "verified"` never does.
22
23
  // - A legacy `autoApprove: true | [classes]` maps once to grants (its `merge`
23
24
  // class becomes `merge: "verified"`; `branch`/`commit` are local, dropped).
24
25
  // - Explicitly configured grants require an account (design §2.0 Identity):
@@ -131,7 +132,8 @@ type EndpointContext = {
131
132
  * delivery verbs make (requireGrant): without `push` or `pr` (or without the
132
133
  * account explicit grants need) nothing reaches the forge, so anything above
133
134
  * `commit` acts as `commit`; without `merge`, `merged` acts as `green`. The
134
- * reason names the unblock.
135
+ * reason names the unblock. `merged` stays `merged` under either merge grant;
136
+ * landing still needs an accepted verdict (forge/pr-ops.ts).
135
137
  */
136
138
  function effectiveEndpoint(
137
139
  endpoint: DefaultEndpoint,
@@ -212,8 +214,8 @@ export type GrantDecision =
212
214
  kind: GrantKind;
213
215
  /** Where the permission came from. */
214
216
  source: AutonomySource;
215
- /** A merge must also have an accepted independent verdict (S13). */
216
- requireVerdict: boolean;
217
+ /** `merge: true` only: `--unverified --reason` may bypass the verdict (S13). */
218
+ allowUnverified: boolean;
217
219
  workspace: string | null;
218
220
  }
219
221
  | {
@@ -373,7 +375,7 @@ export function requireGrant(
373
375
  allowed: true,
374
376
  kind,
375
377
  source: configured ? autonomy.source : "default",
376
- requireVerdict: kind === "merge" && value !== true,
378
+ allowUnverified: kind === "merge" && value === true,
377
379
  workspace,
378
380
  };
379
381
  }
@@ -263,99 +263,6 @@ export function prCreate(env: NodeJS.ProcessEnv, cwd: string): Record<string, an
263
263
  (env.WORKIT_EXPECTED_SOURCE_COMMIT && env.WORKIT_EXPECTED_SOURCE_COMMIT !== sourceCommit)
264
264
  )
265
265
  return { error: "approved PR source branch or commit changed before execution" };
266
- const mergeBaseResult =
267
- policy.integration === "merge"
268
- ? spawnSync("git", ["rev-parse", "--verify", `refs/heads/${target}`], {
269
- cwd: root,
270
- encoding: "utf8",
271
- })
272
- : null;
273
- const mergeBaseCommit =
274
- mergeBaseResult?.status === 0 ? (mergeBaseResult.stdout ?? "").trim() : "";
275
- if (
276
- policy.integration === "merge" &&
277
- ((env.WORKIT_EXPECTED_REMOTE && !env.WORKIT_EXPECTED_MERGE_BASE_COMMIT) ||
278
- !mergeBaseCommit ||
279
- (env.WORKIT_EXPECTED_MERGE_BASE_COMMIT &&
280
- env.WORKIT_EXPECTED_MERGE_BASE_COMMIT !== mergeBaseCommit))
281
- )
282
- return { error: "approved merge target branch changed before execution" };
283
-
284
- if (policy.integration === "merge") {
285
- const finish = (): Record<string, any> => {
286
- if (!sourceCommit || !mergeBaseCommit)
287
- return {
288
- error: "merge failed",
289
- mode: "merge",
290
- targetBranch: target,
291
- stderr: "source or target branch commit could not be resolved",
292
- };
293
- const merge = spawnSync("git", ["merge", "--no-ff", sourceCommit, "-m", title], {
294
- cwd: root,
295
- encoding: "utf8",
296
- });
297
- if (merge.status !== 0)
298
- return {
299
- error: "merge failed",
300
- mode: "merge",
301
- targetBranch: target,
302
- stderr: (merge.stderr ?? "").slice(0, 800),
303
- };
304
- const mergedCommit = spawnSync("git", ["rev-parse", "HEAD"], {
305
- cwd: root,
306
- encoding: "utf8",
307
- });
308
- if (mergedCommit.status !== 0)
309
- return { error: "merge commit could not be resolved", mode: "merge" };
310
- const updateTarget = spawnSync(
311
- "git",
312
- ["update-ref", `refs/heads/${target}`, mergedCommit.stdout.trim(), mergeBaseCommit],
313
- { cwd: root, encoding: "utf8" },
314
- );
315
- if (updateTarget.status !== 0)
316
- return { error: "merge target branch changed before update", mode: "merge" };
317
- const push = spawnSync(
318
- "git",
319
- ["push", targetUrl, `${mergedCommit.stdout.trim()}:refs/heads/${target}`],
320
- { cwd: root, encoding: "utf8", env: { ...process.env, PATH: process.env.PATH ?? "" } },
321
- );
322
- if (push.status !== 0)
323
- return {
324
- error: "push failed",
325
- mode: "merge",
326
- targetBranch: target,
327
- stderr: (push.stderr ?? "").slice(0, 800),
328
- };
329
- return {
330
- ok: true,
331
- mode: "merge",
332
- targetBranch: target,
333
- merged: true,
334
- pushed: true,
335
- output: (push.stdout ?? "").trim(),
336
- };
337
- };
338
- const co = spawnSync("git", ["checkout", "--detach", mergeBaseCommit], {
339
- cwd: root,
340
- encoding: "utf8",
341
- });
342
- if (co.status !== 0)
343
- return {
344
- error: `cannot checkout target ${target}`,
345
- mode: "merge",
346
- targetBranch: target,
347
- stderr: (co.stderr ?? "").slice(0, 800),
348
- };
349
- // finish() returns error objects, it never throws, so the best-effort
350
- // `git checkout branch` restore below runs on ALL outcomes (success,
351
- // merge failure, and push failure) — the tree always returns to the
352
- // feature branch. Deliberately not a try/finally: the restore is
353
- // best-effort and its own failure is not actionable on this path.
354
- const result = finish();
355
- spawnSync("git", ["checkout", branch], { cwd: root, encoding: "utf8" }); // best-effort return
356
- return result;
357
- }
358
-
359
266
  const pr = (cfg.pr ?? {}) as Record<string, any>;
360
267
  const body = env.WF_PR_BODY ?? "";
361
268
  const draft = String(env.WF_PR_DRAFT ?? "false").toLowerCase() === "true";
@@ -510,144 +417,5 @@ export function prCreate(env: NodeJS.ProcessEnv, cwd: string): Record<string, an
510
417
  };
511
418
  }
512
419
 
513
- /** Merge the exact approved open PR/MR (squash + delete per pr settings). */
514
- export function mergePr(
515
- cwd: string,
516
- opts: {
517
- target: string;
518
- source: string;
519
- sourceCommit: string;
520
- remote: string;
521
- account: string;
522
- apiHost: string;
523
- },
524
- ): Record<string, any> {
525
- const root = repoRoot(cwd);
526
- const cfg = vcsConfig("load", root);
527
- if (!cfg.ok) return { error: cfg.error ?? "vcs config missing" };
528
- const provider = cfg.provider as string;
529
- if (provider !== "gitlab" && provider !== "github")
530
- return { error: `unsupported provider: ${provider}` };
531
- const identity = vcsCliIdentity(root);
532
- if (!identity.ok) return { error: identity.error };
533
- if (opts.apiHost !== identity.host)
534
- return { error: "approved hosting API host changed before execution" };
535
- const remote = pushRemote(root);
536
- const remoteIdentity = remote ? pushRemoteIdentity(remote) : null;
537
- if (
538
- !remote ||
539
- !remoteIdentity ||
540
- !pushTargetIsStable(root, remote) ||
541
- opts.remote !== remoteIdentity
542
- )
543
- return { error: "approved merge destination changed before execution" };
544
- if (!hostingApiHostMatches(remote, identity.host))
545
- return { error: "hosting API host does not match the merge destination" };
546
- if (identity.username?.toLowerCase() !== opts.account.toLowerCase())
547
- return { error: "approved hosting account changed before execution" };
548
- if (!/^[a-f0-9]{40,64}$/i.test(opts.sourceCommit))
549
- return { error: "approved merge source commit is invalid" };
550
- const currentCommit = spawnSync("git", ["rev-parse", "--verify", `refs/heads/${opts.source}`], {
551
- cwd: root,
552
- encoding: "utf8",
553
- });
554
- if (currentCommit.status !== 0 || currentCommit.stdout.trim() !== opts.sourceCommit)
555
- return { error: "approved merge source commit changed before execution" };
556
- const repository = cliRepository(remote, provider, cfg);
557
- const repoPath = remoteRepoPath(remote)?.path;
558
- if (!repository || !repoPath?.includes("/"))
559
- return { error: "hosting repository could not be resolved" };
560
- if (!hostingCliAvailable(provider))
561
- return { error: `workflow CLI missing (required for ${provider})` };
562
- const owner = repoPath.split("/")[0];
563
- const endpoint =
564
- provider === "github"
565
- ? `repos/${repository.replace(/^[^/]+\//u, "")}/pulls?state=open&head=${encodeURIComponent(`${owner}:${opts.source}`)}&base=${encodeURIComponent(opts.target)}&per_page=100`
566
- : `projects/${encodeURIComponent(repoPath)}/merge_requests?state=opened&source_branch=${encodeURIComponent(opts.source)}&target_branch=${encodeURIComponent(opts.target)}&per_page=100`;
567
- const listOpen = () =>
568
- spawnSync(provider === "github" ? "gh" : "glab", ["api", endpoint], {
569
- cwd: root,
570
- encoding: "utf8",
571
- env: {
572
- ...process.env,
573
- PATH: process.env.PATH ?? "",
574
- ...(provider === "github" ? { GH_HOST: identity.host } : { GITLAB_HOST: identity.host }),
575
- },
576
- });
577
- const listedId = (stdout: string): string | null => {
578
- try {
579
- const records = JSON.parse(stdout) as Array<Record<string, any>>;
580
- if (!Array.isArray(records) || records.length >= 100) return null;
581
- const matches = records.filter((record) =>
582
- provider === "github"
583
- ? record.state === "open" &&
584
- record.head?.ref === opts.source &&
585
- record.head?.sha === opts.sourceCommit &&
586
- record.base?.ref === opts.target
587
- : record.state === "opened" &&
588
- record.source_branch === opts.source &&
589
- record.sha === opts.sourceCommit &&
590
- record.target_branch === opts.target,
591
- );
592
- if (matches.length !== 1) return null;
593
- const id = provider === "github" ? matches[0].number : matches[0].iid;
594
- return typeof id === "number" ? String(id) : null;
595
- } catch {
596
- return null;
597
- }
598
- };
599
- const list = listOpen();
600
- if (list.status !== 0) return { error: "approved merge request could not be resolved" };
601
- const mergeId = listedId(list.stdout ?? "");
602
- if (!mergeId) return { error: "exact approved merge request is not open" };
603
- // The PR/MR target can change after the first lookup. Re-resolve the
604
- // target-filtered request immediately before calling the provider merge CLI.
605
- const current = listOpen();
606
- if (current.status !== 0 || listedId(current.stdout ?? "") !== mergeId)
607
- return { error: "approved merge target or source changed before merge" };
608
- const pr = (cfg.pr ?? {}) as Record<string, any>;
609
- const squash = pr.squashOnMerge !== false;
610
- const removeBranch = pr.removeSourceBranch !== false;
611
- let cmd: string[];
612
- let cmdEnv: NodeJS.ProcessEnv;
613
- if (provider === "gitlab") {
614
- cmd = ["glab", "mr", "merge", mergeId, "--repo", repository, "--sha", opts.sourceCommit];
615
- cmd.push(squash ? "--squash" : "--squash=false");
616
- if (removeBranch) cmd.push("--remove-source-branch");
617
- cmd.push("--yes");
618
- cmdEnv = { ...process.env, PATH: process.env.PATH ?? "", GITLAB_HOST: identity.host };
619
- } else {
620
- cmd = [
621
- "gh",
622
- "pr",
623
- "merge",
624
- mergeId,
625
- "--repo",
626
- repository,
627
- "--match-head-commit",
628
- opts.sourceCommit,
629
- ];
630
- cmd.push(squash ? "--squash" : "--merge");
631
- if (removeBranch) cmd.push("--delete-branch");
632
- // NOTE: gh pr merge has no --yes flag; fully-specified flags merge
633
- // non-interactively on their own.
634
- cmdEnv = { ...process.env, PATH: process.env.PATH ?? "", GH_HOST: identity.host };
635
- }
636
- const result = spawnSync(cmd[0], cmd.slice(1), { cwd: root, encoding: "utf8", env: cmdEnv });
637
- if (result.status !== 0)
638
- return {
639
- error: "merge failed",
640
- provider,
641
- stderr: (result.stderr ?? result.stdout ?? "").trim().slice(0, 800),
642
- };
643
- return {
644
- ok: true,
645
- provider,
646
- targetBranch: opts.target,
647
- sourceBranch: opts.source,
648
- output: (result.stdout ?? "").trim(),
649
- };
650
- }
651
-
652
420
  // Moved to forge/pr-body.ts (S11); re-exported for the managed action path until S16.
653
421
  export { buildBody, parseGhRepo, parseGhIssue };
@@ -9,13 +9,19 @@
9
9
  // merge: three independent gates, all required:
10
10
  // 1. the S10 status document reads READY (required checks green, no
11
11
  // conflicts, threads, review or draft blockers);
12
- // 2. an accepted S13 verdict for that exact head (fresh or carried), unless
13
- // the workspace grants `merge: true`;
12
+ // 2. an accepted S13 verdict for that exact head (fresh or carried). Under
13
+ // `merge: true` only, `--unverified --reason` bypasses it, and the bypass
14
+ // is recorded (`merge.unverified`) before the merge call;
14
15
  // 3. the `merge` grant (autonomy.ts `requireGrant`; S16 fills the defaults).
15
16
  // The merge call carries the head SHA (GitHub `sha=`, GitLab `sha=`), so a
16
17
  // head that moves after the gates refuses instead of merging unverified code.
17
18
  // --delete-branch deletes with a lease on that same SHA.
18
- import { requireGrant, type AutonomySource } from "../autonomy";
19
+ import {
20
+ requireGrant,
21
+ resolveAutonomy,
22
+ type AutonomySource,
23
+ type DefaultEndpoint,
24
+ } from "../autonomy";
19
25
  import { isProtectedTarget } from "../core/branch";
20
26
  import { vcsConfig } from "../core/vcs-config";
21
27
  import { workspaceReleaseTracks } from "../core/release-tracks";
@@ -64,6 +70,20 @@ export type CreateOutcome = {
64
70
  created: boolean;
65
71
  draft: boolean;
66
72
  recorded: { id: string } | { error: string };
73
+ /** The review step and what the effective endpoint does after it. */
74
+ next: string;
75
+ };
76
+
77
+ const AFTER_REVIEW: Record<DefaultEndpoint, string> = {
78
+ commit: "stop here; review comes when the user asks",
79
+ pr: "stop here; review comes when the user asks",
80
+ green: "babysit it to merge-ready, never merging",
81
+ merged: "babysit it, then land it after verification",
82
+ };
83
+
84
+ const createNext = (cwd: string): string => {
85
+ const endpoint = resolveAutonomy(cwd).effectiveEndpoint;
86
+ return `a non-author verifies the head (workit-review); the endpoint is ${endpoint}, so ${AFTER_REVIEW[endpoint]}`;
67
87
  };
68
88
 
69
89
  /**
@@ -231,6 +251,7 @@ export async function createPullRequest(
231
251
  created,
232
252
  draft: input.draft,
233
253
  recorded: row.ok ? { id: String(row.value.id) } : { error: row.error },
254
+ next: createNext(cwd),
234
255
  });
235
256
  }
236
257
 
@@ -247,6 +268,8 @@ export type MergeInput = {
247
268
  * comes from this branch, re-checked right before the merge call.
248
269
  */
249
270
  expect?: { base: string; branch: string };
271
+ /** Merge without a verdict (`merge: true` only); the reason is recorded. */
272
+ unverified?: { reason: string };
250
273
  };
251
274
 
252
275
  export type MergeOutcome = {
@@ -258,6 +281,8 @@ export type MergeOutcome = {
258
281
  method: MergeMethod;
259
282
  mergeSha: string | null;
260
283
  verdict: { required: boolean; accepted: boolean; verdictId: string | null };
284
+ /** Set when `--unverified` bypassed the verdict: the reason and its ledger row. */
285
+ unverified: { reason: string; recorded: string } | null;
261
286
  grant: { source: AutonomySource };
262
287
  deletedBranch: boolean | { error: string };
263
288
  recorded: { id: string } | { error: string };
@@ -294,6 +319,8 @@ const mergeBackFor = (cwd: string, base: string): { track: string | null; branch
294
319
  export type MergeRefusal = {
295
320
  reason:
296
321
  | "grant_required"
322
+ | "unverified_refused"
323
+ | "failed_verdict"
297
324
  | "not_ready"
298
325
  | "needs_verdict"
299
326
  | "head_mismatch"
@@ -346,6 +373,15 @@ export async function mergePullRequest(
346
373
  ): Promise<MergeResult> {
347
374
  const grant = requireGrant(cwd, "merge");
348
375
  if (!grant.allowed) return refuse(grant.error, grant.unblock, { reason: "grant_required" });
376
+ const bypass = input.unverified ?? null;
377
+ if (bypass && !bypass.reason.trim())
378
+ return failure("invalid_input", "--unverified needs --reason <why the user asked for it>");
379
+ if (bypass && !grant.allowUnverified)
380
+ return refuse(
381
+ `unverified_refused: workspace ${grant.workspace ? `"${grant.workspace}"` : "(none)"} grants merge: "verified", which never merges without an accepted independent verdict`,
382
+ "a non-author session verifies the head (workit-review), then merge without --unverified",
383
+ { reason: "unverified_refused" },
384
+ );
349
385
 
350
386
  const number = selectPr(cwd, resolved, { pr: input.pr });
351
387
  if (!number.ok) return number;
@@ -404,34 +440,57 @@ export async function mergePullRequest(
404
440
  { reason: "protected_branch" },
405
441
  );
406
442
  }
443
+ const ledger = readLedger(cwd);
444
+ if (!ledger.ok) return ledger;
445
+ const check = checkVerdicts(cwd, branch, ledger.value.rows);
446
+ const summary = { head: check.head, accepted: check.accepted, authors: check.authors };
447
+ const covered = check.head === head && check.accepted.accepted;
407
448
  let verdict: MergeOutcome["verdict"] = {
408
- required: grant.requireVerdict,
409
- accepted: false,
410
- verdictId: null,
449
+ required: true,
450
+ accepted: covered,
451
+ verdictId:
452
+ covered && typeof check.accepted.verdict?.id === "string" ? check.accepted.verdict.id : null,
411
453
  };
412
- if (grant.requireVerdict) {
413
- const ledger = readLedger(cwd);
414
- if (!ledger.ok) return ledger;
415
- const check = checkVerdicts(cwd, branch, ledger.value.rows);
416
- const summary = { head: check.head, accepted: check.accepted, authors: check.authors };
417
- if (check.head !== head)
418
- return refuse(
419
- `head_mismatch: the verdict check reads ${branch} at ${check.head?.slice(0, 12) ?? "(missing)"} but ${label} is at ${head.slice(0, 12)}`,
420
- `git fetch && git switch ${branch} && git merge --ff-only @{u} # then re-verify that head`,
421
- { reason: "head_mismatch", verdict: summary },
422
- );
423
- if (!check.accepted.accepted)
424
- return refuse(
425
- `NEEDS_VERDICT: ${label} has no accepted independent verdict for ${head.slice(0, 12)} (${check.accepted.reasons.join(", ")})`,
426
- `an independent session verifies the head and runs: workit ledger verdict verified --how "<what was exercised>" --branch ${branch}`,
427
- { reason: "needs_verdict", verdict: summary },
454
+ let unverified: MergeOutcome["unverified"] = null;
455
+ // The bypass covers a missing verdict, never a rejection.
456
+ if (!covered && bypass && check.accepted.reasons.includes("failing_verdict"))
457
+ return refuse(
458
+ `failed_verdict: ${label} has a current independent failed verdict${typeof check.accepted.verdict?.id === "string" ? ` (${check.accepted.verdict.id})` : ""}; --unverified never merges over a rejection`,
459
+ "a new independent verdict on the head supersedes it",
460
+ { reason: "failed_verdict", verdict: summary },
461
+ );
462
+ if (!covered && bypass) {
463
+ // Recorded before the merge call: a bypass that cannot be audited never merges.
464
+ const reason = bypass.reason.trim();
465
+ const recorded = appendObserved(cwd, {
466
+ type: "merge.unverified",
467
+ actor: input.actor,
468
+ branch,
469
+ head,
470
+ pr: doc.number,
471
+ base: doc.base,
472
+ reason,
473
+ grant: grant.source,
474
+ });
475
+ if (!recorded.ok)
476
+ return failure(
477
+ "failed",
478
+ `unverified_unrecorded: the --unverified bypass could not be recorded (${recorded.error}); nothing was merged`,
428
479
  );
429
- verdict = {
430
- required: true,
431
- accepted: true,
432
- verdictId: typeof check.accepted.verdict?.id === "string" ? check.accepted.verdict.id : null,
433
- };
434
- }
480
+ verdict = { required: false, accepted: false, verdictId: null };
481
+ unverified = { reason, recorded: String(recorded.value.id) };
482
+ } else if (check.head !== head)
483
+ return refuse(
484
+ `head_mismatch: the verdict check reads ${branch} at ${check.head?.slice(0, 12) ?? "(missing)"} but ${label} is at ${head.slice(0, 12)}`,
485
+ `git fetch && git switch ${branch} && git merge --ff-only @{u} # then re-verify that head`,
486
+ { reason: "head_mismatch", verdict: summary },
487
+ );
488
+ else if (!covered)
489
+ return refuse(
490
+ `NEEDS_VERDICT: ${label} has no accepted independent verdict for ${head.slice(0, 12)} (${check.accepted.reasons.join(", ")})`,
491
+ `an independent session verifies the head and runs: workit ledger verdict verified --how "<what was exercised>" --branch ${branch}${grant.allowUnverified ? `; or, only if the user asks to merge unverified: workit pr merge --unverified --reason "<why>"` : ""}`,
492
+ { reason: "needs_verdict", verdict: summary },
493
+ );
435
494
 
436
495
  const merged = resolved.forge.merge(doc.number, { sha: head, method: input.method });
437
496
  if (!merged.ok) return merged;
@@ -470,6 +529,7 @@ export async function mergePullRequest(
470
529
  mergeSha: merged.data.mergeSha,
471
530
  method: input.method,
472
531
  verdictId: verdict.verdictId,
532
+ ...(unverified ? { unverified: unverified.recorded } : {}),
473
533
  grant: grant.source,
474
534
  });
475
535
  return {
@@ -483,6 +543,7 @@ export async function mergePullRequest(
483
543
  method: input.method,
484
544
  mergeSha: merged.data.mergeSha,
485
545
  verdict,
546
+ unverified,
486
547
  grant: { source: grant.source },
487
548
  deletedBranch,
488
549
  recorded: row.ok ? { id: String(row.value.id) } : { error: row.error },
package/src/ledger.ts CHANGED
@@ -761,6 +761,8 @@ export type RejectReason =
761
761
  | "no_session"
762
762
  | "author_session"
763
763
  | "failing_verdict"
764
+ /** The only passing current verdict is `type-check-only`, which proves no behavior. */
765
+ | "type_check_only"
764
766
  | "no_verdict";
765
767
 
766
768
  export type VerdictEntry = {
@@ -783,7 +785,10 @@ export type VerdictCheck = {
783
785
  patchId: string | null;
784
786
  /** Newest current verdict (else newest verdict): does it still apply? */
785
787
  current: { basis: VerdictBasis; verdict: ReadRow | null };
786
- /** What merge gates read: an accepted verdict and no current independent failure. */
788
+ /**
789
+ * What merge gates read: an accepted strong (`verified`/`tests-verified`)
790
+ * verdict and no current independent failure. `type-check-only` never counts.
791
+ */
787
792
  accepted: { accepted: boolean; verdict: ReadRow | null; reasons: RejectReason[] };
788
793
  review: ReviewLabel;
789
794
  /** The newest current, strong, self verdict when `review` is self-reviewed. */
@@ -843,10 +848,17 @@ const independenceReasons = (row: ReadRow, authors: Set<string>): RejectReason[]
843
848
  /**
844
849
  * The effective verdict of one kind, walking rows in order: a self verdict
845
850
  * never displaces an independent one, and an independent failed/blocked
846
- * verdict sticks until an independent verdict from a different session (or
847
- * its own session's supersede) replaces it.
851
+ * verdict sticks, for the code it judged, until an independent verdict from a
852
+ * different session (or its own session's supersede) replaces it. A verdict
853
+ * from the same session on different code (`sameCode` false: a real change,
854
+ * not a carry or restack of the failed code) is a re-review of the fix and
855
+ * replaces it.
848
856
  */
849
- function effectiveOf(rows: readonly ReadRow[], authors: Set<string>): ReadRow | null {
857
+ function effectiveOf(
858
+ rows: readonly ReadRow[],
859
+ authors: Set<string>,
860
+ sameCode: (failed: ReadRow, row: ReadRow) => boolean,
861
+ ): ReadRow | null {
850
862
  let effective: ReadRow | null = null;
851
863
  for (const row of rows) {
852
864
  if (!effective) {
@@ -859,7 +871,8 @@ function effectiveOf(rows: readonly ReadRow[], authors: Set<string>): ReadRow |
859
871
  if (
860
872
  effIndependent &&
861
873
  FAILING.has(String(effective.result)) &&
862
- row.actor.session === effective.actor.session
874
+ row.actor.session === effective.actor.session &&
875
+ sameCode(effective, row)
863
876
  )
864
877
  continue;
865
878
  effective = row;
@@ -913,15 +926,24 @@ export function checkVerdicts(cwd: string, branch: string, rows: readonly ReadRo
913
926
  const head = branchHead(cwd, branch);
914
927
  const fallbackBase = defaultBase(cwd);
915
928
  const keyCache = new Map<string, { patchId: string | null; diffHash: string | null }>();
916
- const keyFor = (base: string | null) => {
917
- if (!base || !head) return { patchId: null, diffHash: null };
918
- let key = keyCache.get(base);
929
+ const keysAt = (base: string | null, at: string | null) => {
930
+ if (!base || !at) return { patchId: null, diffHash: null };
931
+ const cacheKey = `${base}\0${at}`;
932
+ let key = keyCache.get(cacheKey);
919
933
  if (!key) {
920
- key = { patchId: patchId(cwd, base, head), diffHash: diffHash(cwd, base, head) };
921
- keyCache.set(base, key);
934
+ key = { patchId: patchId(cwd, base, at), diffHash: diffHash(cwd, base, at) };
935
+ keyCache.set(cacheKey, key);
922
936
  }
923
937
  return key;
924
938
  };
939
+ const keyFor = (base: string | null) => keysAt(base, head);
940
+ // A newer row is judged under the failure's own base, so a row recorded
941
+ // with a different --base cannot make unchanged code look changed.
942
+ const sameCode = (failed: ReadRow, row: ReadRow): boolean =>
943
+ verdictBasis(failed, {
944
+ head: row.head,
945
+ ...keysAt(failed.base ?? fallbackBase, row.head),
946
+ }) !== "stale" || restackCarries(rows, branch, failed.head, row.head);
925
947
  const authors = authorSessions(cwd, rows, branch, { base: fallbackBase, head });
926
948
  const byKind = new Map<string, ReadRow[]>();
927
949
  for (const row of rows)
@@ -931,7 +953,7 @@ export function checkVerdicts(cwd: string, branch: string, rows: readonly ReadRo
931
953
  }
932
954
  const verdicts: VerdictEntry[] = [];
933
955
  for (const [kind, list] of byKind) {
934
- const row = effectiveOf(list, authors);
956
+ const row = effectiveOf(list, authors, sameCode);
935
957
  if (!row) continue;
936
958
  let basis = verdictBasis(row, { head, ...keyFor(row.base ?? fallbackBase) });
937
959
  if (basis === "stale" && restackCarries(rows, branch, row.head, head)) basis = "carried";
@@ -957,7 +979,10 @@ export function checkVerdicts(cwd: string, branch: string, rows: readonly ReadRo
957
979
  const failing = verdicts.find(
958
980
  (entry) => entry.current && entry.independent && FAILING.has(String(entry.verdict.result)),
959
981
  );
960
- const newestAccepted = verdicts.findLast((entry) => entry.accepted) ?? null;
982
+ const newestAccepted =
983
+ verdicts.findLast(
984
+ (entry) => entry.accepted && STRONG_RESULTS.has(String(entry.verdict.result)),
985
+ ) ?? null;
961
986
  const accepted =
962
987
  newestAccepted && !failing
963
988
  ? { accepted: true, verdict: newestAccepted.verdict, reasons: [] }
@@ -966,7 +991,11 @@ export function checkVerdicts(cwd: string, branch: string, rows: readonly ReadRo
966
991
  verdict: failing?.verdict ?? null,
967
992
  reasons: failing
968
993
  ? (["failing_verdict"] as RejectReason[])
969
- : (newestCurrent?.reasons ?? (["no_verdict"] as RejectReason[])),
994
+ : !newestCurrent
995
+ ? (["no_verdict"] as RejectReason[])
996
+ : newestCurrent.reasons.length
997
+ ? newestCurrent.reasons
998
+ : (["type_check_only"] as RejectReason[]),
970
999
  };
971
1000
  const selfEntry = failing
972
1001
  ? undefined
@@ -1344,7 +1373,7 @@ export type RowSummary = {
1344
1373
  };
1345
1374
 
1346
1375
  /** Row types that count only when an observing verb wrote them. */
1347
- const OBSERVED_ONLY: ReadonlySet<string> = new Set(["check", ...PR_ROW_TYPES]);
1376
+ const OBSERVED_ONLY: ReadonlySet<string> = new Set(["check", "merge.unverified", ...PR_ROW_TYPES]);
1348
1377
 
1349
1378
  export function rowLabels(row: ReadRow): RowSummary["labels"] {
1350
1379
  const labels: RowSummary["labels"] = [];
@@ -1390,6 +1419,9 @@ export function summarizeRow(row: ReadRow): RowSummary {
1390
1419
  case "pr.merged":
1391
1420
  summary = `#${row.pr ?? "?"} ${text("method")} at ${(row.head ?? "?").slice(0, 12)}${text("mergeSha") ? ` -> ${text("mergeSha").slice(0, 12)}` : ""}`;
1392
1421
  break;
1422
+ case "merge.unverified":
1423
+ summary = `#${row.pr ?? "?"} unverified merge requested at ${(row.head ?? "?").slice(0, 12)} (reason: ${text("reason")})`;
1424
+ break;
1393
1425
  case "delivery.verified":
1394
1426
  summary = `${text("expect")} delivered at ${(row.head ?? "?").slice(0, 12)}`;
1395
1427
  break;
package/src/stack.ts CHANGED
@@ -996,6 +996,7 @@ export type StopReason =
996
996
  | "not_ready"
997
997
  | "no_verdict"
998
998
  | "needs_verdict"
999
+ | "failed_verdict"
999
1000
  | "grant_required"
1000
1001
  | "max_reached"
1001
1002
  | "merge_refused"
@@ -1096,8 +1097,9 @@ const verdictOf = (cwd: string, branch: string, rows: readonly ReadRow[]) => {
1096
1097
  /**
1097
1098
  * Does the root-most open PR qualify to land? Order: a PR exists and is
1098
1099
  * open, it targets the trunk, the forge says READY, an accepted verdict
1099
- * covers the head (unless the grant is `merge: true`), and the grant allows
1100
- * merging. `ready` says everything but the grant holds ("verified, ready").
1100
+ * covers the head (unless `bypass`: `--unverified` under `merge: true`), and
1101
+ * the grant allows merging. `ready` says everything but the grant holds
1102
+ * ("verified, ready").
1101
1103
  */
1102
1104
  export function qualify(
1103
1105
  ctx: Pick<Ctx, "cwd" | "resolved">,
@@ -1108,6 +1110,8 @@ export function qualify(
1108
1110
  grant: GrantDecision,
1109
1111
  /** Why the PR number no longer belongs to this branch (null: it does). */
1110
1112
  binding: string | null = null,
1113
+ /** `stack land --unverified`: skip the verdict where the grant allows it. */
1114
+ bypass = false,
1111
1115
  ): Qualification {
1112
1116
  const label = entry.pr ? noun(ctx.resolved, entry.pr) : entry.branch;
1113
1117
  const stop = (reason: StopReason, detail: string, unblock: string, ready = false) => ({
@@ -1149,12 +1153,19 @@ export function qualify(
1149
1153
  ? `workit ci wait --pr ${entry.pr}`
1150
1154
  : `workit pr status --pr ${entry.pr}`,
1151
1155
  );
1152
- const requireVerdict = grant.allowed ? grant.requireVerdict : true;
1153
- if (requireVerdict && !verdict.accepted)
1156
+ const allowUnverified = grant.allowed && grant.allowUnverified;
1157
+ // The bypass covers a missing verdict, never a rejection.
1158
+ if (bypass && allowUnverified && verdict.reasons.includes("failing_verdict"))
1159
+ return stop(
1160
+ "failed_verdict",
1161
+ `${label} has a current independent failed verdict for ${doc.head.sha.slice(0, 12)}; --unverified never lands over a rejection`,
1162
+ "a new independent verdict on the head supersedes it",
1163
+ );
1164
+ if (!(bypass && allowUnverified) && !verdict.accepted)
1154
1165
  return stop(
1155
1166
  verdict.reasons.includes("no_verdict") ? "no_verdict" : "needs_verdict",
1156
1167
  `${label} has no accepted independent verdict for ${doc.head.sha.slice(0, 12)} (${verdict.reasons.join(", ") || "none"})`,
1157
- `an independent session verifies ${entry.branch} and runs: workit ledger verdict verified --how "<what was exercised>" --branch ${entry.branch}`,
1168
+ `an independent session verifies ${entry.branch} and runs: workit ledger verdict verified --how "<what was exercised>" --branch ${entry.branch}${allowUnverified ? `; or, only if the user asks to land unverified: workit stack land --unverified --reason "<why>"` : ""}`,
1158
1169
  );
1159
1170
  if (!grant.allowed)
1160
1171
  return stop(
@@ -1701,6 +1712,8 @@ export type LandOptions = {
1701
1712
  dryRun: boolean;
1702
1713
  max: number | null;
1703
1714
  method: MergeMethod;
1715
+ /** Land without verdicts (`merge: true` only); each bypass is recorded. */
1716
+ unverified?: { reason: string } | null;
1704
1717
  /** How long to wait for CI on a PR that was just restacked. */
1705
1718
  timeoutMs: number;
1706
1719
  intervalMs: number;
@@ -1709,7 +1722,8 @@ export type LandOptions = {
1709
1722
  export type LandOutcome = {
1710
1723
  name: string;
1711
1724
  dryRun: boolean;
1712
- landed: Array<{ pr: number; branch: string; mergeSha: string | null }>;
1725
+ /** `unverified`: the ledger row of an `--unverified` bypass, else null. */
1726
+ landed: Array<{ pr: number; branch: string; mergeSha: string | null; unverified: string | null }>;
1713
1727
  /** Dry run: the PRs that qualify now, in order (later ones assume a clean restack). */
1714
1728
  wouldLand: Array<{ pr: number; branch: string }>;
1715
1729
  stoppedAt: {
@@ -1778,6 +1792,23 @@ export async function landStack(
1778
1792
  });
1779
1793
  const mismatch = repoMismatch(ctx, stack);
1780
1794
  if (mismatch) return failWith(mismatch);
1795
+ const bypass = options.unverified ?? null;
1796
+ if (bypass) {
1797
+ const grant = requireGrant(ctx.cwd, "merge");
1798
+ if (!grant.allowed)
1799
+ return failWith(
1800
+ stackFail("blocked", grant.error, grant.unblock, { reason: "grant_required" }),
1801
+ );
1802
+ if (!grant.allowUnverified)
1803
+ return failWith(
1804
+ stackFail(
1805
+ "blocked",
1806
+ `unverified_refused: workspace ${grant.workspace ? `"${grant.workspace}"` : "(none)"} grants merge: "verified", which never lands without an accepted independent verdict`,
1807
+ "a non-author session verifies each head (workit-review), then land without --unverified",
1808
+ { reason: "unverified_refused" },
1809
+ ),
1810
+ );
1811
+ }
1781
1812
 
1782
1813
  // Catch up first: a parent merged elsewhere leaves the rest to restack.
1783
1814
  if (!options.dryRun) {
@@ -1847,6 +1878,7 @@ export async function landStack(
1847
1878
  verdict,
1848
1879
  grant,
1849
1880
  doc ? prBinding(ctx, entry, doc) : null,
1881
+ bypass !== null,
1850
1882
  );
1851
1883
  if (!q.ok) {
1852
1884
  outcome.stoppedAt = {
@@ -1874,6 +1906,7 @@ export async function landStack(
1874
1906
  deleteBranch: false,
1875
1907
  actor: ctx.actor,
1876
1908
  expect: { base: stack.trunk, branch: entry.branch },
1909
+ ...(bypass && !verdict.accepted ? { unverified: bypass } : {}),
1877
1910
  },
1878
1911
  ctx.sleep,
1879
1912
  );
@@ -1891,7 +1924,12 @@ export async function landStack(
1891
1924
  }
1892
1925
  return failWith(stackFail(merged.code, merged.error, merged.unblock));
1893
1926
  }
1894
- outcome.landed.push({ pr, branch: entry.branch, mergeSha: merged.data.mergeSha });
1927
+ outcome.landed.push({
1928
+ pr,
1929
+ branch: entry.branch,
1930
+ mergeSha: merged.data.mergeSha,
1931
+ unverified: merged.data.unverified?.recorded ?? null,
1932
+ });
1895
1933
  entry.merged = { pr, mergeSha: merged.data.mergeSha, at: new Date().toISOString() };
1896
1934
  const written = writeStack(ctx.cwd, stack);
1897
1935
  if (!written.ok) return failWith(written);