@ulysses-ai/create-workspace 0.23.2-beta.0 → 0.24.0-beta.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.
Files changed (36) hide show
  1. package/package.json +2 -1
  2. package/template/CLAUDE.md.tmpl +2 -2
  3. package/template/_claude/hooks/_utils.mjs +43 -0
  4. package/template/_claude/hooks/session-start.mjs +10 -0
  5. package/template/_claude/rules/work-item-tracking.md +6 -3
  6. package/template/_claude/rules/workspace-structure.md +1 -1
  7. package/template/_claude/scripts/chat-record.mjs +43 -26
  8. package/template/_claude/scripts/classify-update.mjs +194 -32
  9. package/template/_claude/scripts/forges/github.mjs +51 -1
  10. package/template/_claude/scripts/forges/gitlab.mjs +69 -0
  11. package/template/_claude/scripts/forges/interface.mjs +19 -2
  12. package/template/_claude/scripts/maintenance-audit.mjs +325 -3
  13. package/template/_claude/scripts/merge-mode.mjs +22 -1
  14. package/template/_claude/scripts/migrate-sessions.mjs +212 -34
  15. package/template/_claude/scripts/release-leak-audit.mjs +480 -0
  16. package/template/_claude/scripts/task-pr.mjs +316 -72
  17. package/template/_claude/scripts/task-worktree.mjs +9 -1
  18. package/template/_claude/scripts/template-merge.mjs +21 -4
  19. package/template/_claude/scripts/template-modifications.mjs +152 -0
  20. package/template/_claude/scripts/trackers/github-issues.mjs +110 -1
  21. package/template/_claude/scripts/trackers/gitlab-issues.mjs +238 -5
  22. package/template/_claude/scripts/trackers/interface.mjs +47 -0
  23. package/template/_claude/skills/braindump/SKILL.md +1 -1
  24. package/template/_claude/skills/complete-work/SKILL.md +22 -12
  25. package/template/_claude/skills/handoff/SKILL.md +1 -1
  26. package/template/_claude/skills/maintenance/SKILL.md +8 -3
  27. package/template/_claude/skills/migrate-sessions/SKILL.md +20 -13
  28. package/template/_claude/skills/release/SKILL.md +20 -6
  29. package/template/_claude/skills/setup-tracker/SKILL.md +24 -1
  30. package/template/_claude/skills/start-work/SKILL.md +23 -5
  31. package/template/_claude/skills/workspace-update/SKILL.md +23 -15
  32. package/template/workspace.json.tmpl +2 -1
  33. package/lib/init.test.mjs +0 -75
  34. package/lib/payload.test.mjs +0 -210
  35. package/lib/scaffold.test.mjs +0 -79
  36. package/lib/upgrade.test.mjs +0 -547
@@ -20,7 +20,9 @@
20
20
  // --body-file <forge-repo>=<path> ... [--out <file>]
21
21
  // [--force-with-lease]
22
22
  // node task-pr.mjs --merge --root <launcher> --prs <json-from-create>
23
- // [--work-item gh:N|gl:N]
23
+ // [--work-item gh:N|gl:N] [--wait] [--wait-timeout <min>]
24
+ // [--skip-checks] [--approved]
25
+ // node task-pr.mjs --checks --root <launcher> --prs <json-from-create>
24
26
  //
25
27
  // Every repo of the task resolves to a merge mode (mergeModeFor below):
26
28
  // "forge" — its origin is forge-hosted (github.com, gitlab.com, or the
@@ -45,34 +47,75 @@
45
47
  // GitHub `owner/repo#N` reference cannot resolve on GitLab). Prints
46
48
  // `{ prs, empty, pushed }` — prs holds forge entries (mode "forge") and
47
49
  // local entries (mode "local") alike, every entry carrying its commit
48
- // count — and, with --out, writes the same JSON to a file — a mid-run
49
- // failure still writes what has landed so far, so the state survives the
50
- // error. A repo that already has an open PR for the branch gets it
51
- // reused, so a re-run never opens a duplicate.
50
+ // count and its resolved merge-approval policy (mergeApprovalFor below:
51
+ // "ask" or "operator", from repos.{repo}.mergeApproval /
52
+ // workspace.mergeApproval, default "ask" — gh:202) so the skill reads it
53
+ // instead of re-deriving — and, with --out, writes the same JSON to a
54
+ // file — a mid-run failure still writes what has landed so far, so the
55
+ // state survives the error. A repo that already has an open PR for the
56
+ // branch gets it reused, so a re-run never opens a duplicate.
52
57
  //
53
- // --merge finishes every entry in the file: forge PRs first (squash,
54
- // delete branch) and local branches as a `git merge --ff-only` in the
55
- // repo's source clone — repos/{repo}, or the launcher itself for "." —
56
- // which must sit clean on its default branch, and whose task branch must
57
- // fast-forward or be rebased by hand. The project repos merge first, the
58
- // workspace repo only when every project merge succeeded; a PR the forge
59
- // reports as already MERGED and a local branch already contained in the
60
- // default branch both count as done, which is what makes re-running after
61
- // a partial failure safe. An empty or malformed PRs file — or one naming
62
- // a repo other than "." or a plain repo name, since a local entry's repo
63
- // becomes a path — is refused outright: no entries means nothing to merge
64
- // and nothing to close. Once everything is merged the launcher is pulled
65
- // --ff-only — only when it sits on the workspace default branch (else
66
- // pullSkipped), never when "." was itself merged locally (the launcher
67
- // already has that work), when the workspace repo is local (no forge
68
- // merge happened that a pull could fetch), or when the launcher branch
69
- // has no upstream to pull from — pullSkipped names which — and a failed
70
- // pull is reported as pullFailed in the JSON rather than an error,
71
- // because the merges stand and the issue still closes — and then the
72
- // linked issue closes with a Merged: comment, but only when --work-item
73
- // is given AND a tracker is configured; with no tracker the JSON reports
74
- // closed: null and closeSkipped. On a merge failure it stops and names
75
- // what is still open.
58
+ // --merge finishes every entry in the file, all-or-nothing on CI (gh:201):
59
+ // before the FIRST merge, the checks of every forge PR not already merged
60
+ // are read through the forge adapter (prChecks) — projects and workspace
61
+ // alike, all of them up front. Any failure, or any entry still pending,
62
+ // stops the run with every entry's state listed and nothing merged: a
63
+ // task's PRs describe each other, so none may land ahead of a red sibling.
64
+ // Checks can lag PR creation, so a "none" from a PR opened within the last
65
+ // 90 seconds is re-read before it is accepted — every young "none" per
66
+ // round, one shared 30-second pause, twice at most — the PR's createdAt,
67
+ // from the same prView that detects already-merged PRs, is what tells a
68
+ // young PR from an old one. "pending" stops the run — it is re-runnable
69
+ // once checks finish — unless --wait was given: each round sleeps 20
70
+ // seconds and re-reads every still-pending entry, up to --wait-timeout
71
+ // minutes (default 30) counted from the end of the settle, and when the
72
+ // last pending entry goes terminal every entry is read once more so a
73
+ // green read from early in the wait cannot stand if the PR went red after
74
+ // it. "none" (no CI configured) and "success" proceed to the merges, in
75
+ // file order, un-re-gated. --skip-checks bypasses the gate and is recorded
76
+ // as checksSkipped in the output — the escape hatch for CI broken in ways
77
+ // the PR cannot fix. Local entries skip checks: there is no forge to ask.
78
+ //
79
+ // Who may run those merges is mergeApproval (gh:202): any not-yet-merged
80
+ // entry stamped "operator" — one is enough; the workspace PR may never
81
+ // merge ahead of the project PRs it describes — refuses the run unless
82
+ // --approved is passed, which the skill does only once the operator has
83
+ // said merge in a later turn. Done-by-hand merges need no --approved: a
84
+ // PR the forge reports MERGED, and a local entry whose branch the source
85
+ // clone's default branch already contains, both drop out of the refusal.
86
+ //
87
+ // Forge PRs merge squash + delete branch; local
88
+ // branches merge as a `git merge --ff-only` in the repo's source clone —
89
+ // repos/{repo}, or the launcher itself for "." — which must sit clean on
90
+ // its default branch, and whose task branch must fast-forward or be
91
+ // rebased by hand. The project repos merge first, the workspace repo only
92
+ // when every project merge succeeded; a PR the forge reports as already
93
+ // MERGED and a local branch already contained in the default branch both
94
+ // count as done — and read no checks — which is what makes re-running
95
+ // after a partial failure, or after the operator merged the PRs in the
96
+ // forge UI (gh:202's operator mode), safe. An empty or malformed PRs
97
+ // file — or one naming a repo other than "." or a plain repo name, since
98
+ // a local entry's repo becomes a path — is refused outright: no entries
99
+ // means nothing to merge and nothing to close. Once everything is merged
100
+ // the launcher is pulled --ff-only — only when it sits on the workspace
101
+ // default branch (else pullSkipped), never when "." was itself merged
102
+ // locally (the launcher already has that work), when the workspace repo
103
+ // is local (no forge merge happened that a pull could fetch), or when the
104
+ // launcher branch has no upstream to pull from — pullSkipped names which —
105
+ // and a failed pull is reported as pullFailed in the JSON rather than an
106
+ // error, because the merges stand and the issue still closes — and then
107
+ // the linked issue closes with a Merged: comment, but only when
108
+ // --work-item is given AND a tracker is configured; with no tracker the
109
+ // JSON reports closed: null and closeSkipped. On a merge failure it stops
110
+ // and names what is still open.
111
+ //
112
+ // --checks reads every entry's CI state without merging anything — the
113
+ // same prChecks read --merge gates on, surfaced for the summary
114
+ // /complete-work presents before the merge question (and before stopping
115
+ // in operator mode). Prints { checks: [...] } — one { repo, mode, state,
116
+ // url, failing } per forge PR, plus the adapter's note when it carries
117
+ // one (GitLab's manual-pipeline case); a local entry reports "none":
118
+ // there is no forge to ask.
76
119
 
77
120
  import '../lib/require-node.mjs';
78
121
  import { readFileSync, writeFileSync, mkdirSync, existsSync, realpathSync } from 'node:fs';
@@ -80,7 +123,7 @@ import { resolve, dirname } from 'node:path';
80
123
  import { spawnSync } from 'node:child_process';
81
124
  import { fileURLToPath } from 'node:url';
82
125
  import { taskWorktreePath, defaultBranchFor } from './task-worktree.mjs';
83
- import { WORKSPACE_REPO, repoDirFor, readWorkspace, parseForgeRemote, forgeHosts, perRepoForge, mergeModeFor } from './merge-mode.mjs';
126
+ import { WORKSPACE_REPO, repoDirFor, readWorkspace, parseForgeRemote, forgeHosts, perRepoForge, mergeModeFor, mergeApprovalFor } from './merge-mode.mjs';
84
127
  import { readRecord } from './chat-record.mjs';
85
128
  import { createForge } from './forges/interface.mjs';
86
129
  import { createTracker } from './trackers/interface.mjs';
@@ -243,8 +286,11 @@ async function createPrs(args, deps) {
243
286
 
244
287
  // Mode first, because the commit count itself depends on it: a local
245
288
  // repo counts against its local default branch, not the origin ref that
246
- // never advances (gh:173).
289
+ // never advances (gh:173). The merge-approval policy (gh:202) resolves
290
+ // the same way — per repo, with the workspace repo reading its own key —
291
+ // and rides every entry below so the skill never re-derives it.
247
292
  for (const t of targets) t.mode = mergeModeFor(rootDir, t.repo, { gitFn: deps.gitFn });
293
+ for (const t of targets) t.mergeApproval = mergeApprovalFor(rootDir, t.repo);
248
294
  for (const t of targets) t.commits = commitsOverBase(deps.gitFn, t, args.branch);
249
295
  const empty = targets.filter((t) => t.commits === 0).map((t) => t.repo);
250
296
  const active = targets.filter((t) => t.commits !== 0);
@@ -294,7 +340,7 @@ async function createPrs(args, deps) {
294
340
  .filter((t) => t.mode === 'local')
295
341
  .map((t) => ({
296
342
  repo: t.repo, mode: 'local', branch: args.branch, base: t.defaultBranch,
297
- worktree: t.worktree, commits: t.commits,
343
+ worktree: t.worktree, commits: t.commits, mergeApproval: t.mergeApproval,
298
344
  }));
299
345
  const pushed = [];
300
346
  try {
@@ -317,7 +363,7 @@ async function createPrs(args, deps) {
317
363
  const pr = existing ?? await forge.prCreate({ title, body, head: args.branch, base: t.defaultBranch });
318
364
  prs.push({
319
365
  repo: t.repo, mode: 'forge', owner: t.owner, name: t.name,
320
- forge: t.forge, host: t.host,
366
+ forge: t.forge, host: t.host, mergeApproval: t.mergeApproval,
321
367
  number: pr.number, id: pr.id, url: pr.url, isWorkspace: t.isWorkspace, commits: t.commits,
322
368
  });
323
369
  }
@@ -368,31 +414,41 @@ function mergeLocalEntry(gitFn, rootDir, entry) {
368
414
  return { alreadyMerged: false };
369
415
  }
370
416
 
371
- async function mergePrs(args, deps) {
372
- const rootDir = resolve(args.root);
373
- const ws = readWorkspace(rootDir);
417
+ // A local entry's "already merged" is git's to say: the task branch is
418
+ // contained in the source clone's default branch — the same containment
419
+ // mergeLocalEntry acts on. An operator entry this far along needs no
420
+ // --approved: the merge already happened, by hand or in an earlier run,
421
+ // which is what lets a re-run finish the task.
422
+ function localEntryMerged(gitFn, rootDir, entry) {
423
+ const repoDir = repoDirFor(rootDir, entry.repo);
424
+ const base = entry.base || defaultBranchFor(rootDir, entry.repo, gitFn);
425
+ return gitCheck(gitFn, repoDir, ['merge-base', '--is-ancestor', entry.branch, base]).status === 0;
426
+ }
374
427
 
428
+ // Entries written before modes existed carry no `mode`; they are forge
429
+ // PRs, the only kind --create used to emit.
430
+ const modeOfEntry = (p) => p?.mode ?? 'forge';
431
+
432
+ // The PRs file --create wrote, validated once for --merge and --checks
433
+ // alike. An empty or malformed file is a refusal, not a no-op: with
434
+ // nothing to merge there is nothing that earns an issue close, and
435
+ // "Merged: " with no URLs would tell the tracker a lie. A local entry's
436
+ // repo becomes a path under repos/, so the file cannot be allowed to name
437
+ // anything but "." or a plain single segment.
438
+ function loadPrs(path) {
375
439
  let parsed;
376
440
  try {
377
- parsed = JSON.parse(stripBom(readFileSync(args.prs, 'utf-8')));
441
+ parsed = JSON.parse(stripBom(readFileSync(path, 'utf-8')));
378
442
  } catch (err) {
379
- throw new Error(`cannot read PRs file ${args.prs}: ${err.message}`);
443
+ throw new Error(`cannot read PRs file ${path}: ${err.message}`);
380
444
  }
381
- // An empty or malformed file is a refusal, not a no-op: with nothing to
382
- // merge there is nothing that earns an issue close, and "Merged: " with
383
- // no URLs would tell the tracker a lie.
384
445
  if (!parsed || !Array.isArray(parsed.prs)) {
385
- throw new Error(`PRs file ${args.prs} is malformed — expected the JSON from a --create run ({ prs: [...] })`);
446
+ throw new Error(`PRs file ${path} is malformed — expected the JSON from a --create run ({ prs: [...] })`);
386
447
  }
387
448
  const prs = parsed.prs;
388
449
  if (prs.length === 0) {
389
- throw new Error(`PRs file ${args.prs} lists no PRs — nothing to merge and nothing to close; --merge refuses to close an issue on an empty merge`);
450
+ throw new Error(`PRs file ${path} lists no PRs — nothing to merge, nothing to check, nothing to close; an empty merge never earns an issue close`);
390
451
  }
391
- // Entries written before modes existed carry no `mode`; they are forge
392
- // PRs, the only kind --create used to emit. A local entry's repo becomes
393
- // a path under repos/, so the file cannot be allowed to name anything
394
- // but "." or a plain single segment.
395
- const modeOf = (p) => p?.mode ?? 'forge';
396
452
  const REPO_NAME_RE = /^[A-Za-z0-9._-]+$/;
397
453
  for (const p of prs) {
398
454
  if (!p) throw new Error(`PRs file entry is missing owner/name/id: ${JSON.stringify(p)}`);
@@ -400,36 +456,168 @@ async function mergePrs(args, deps) {
400
456
  && (typeof p.repo !== 'string' || p.repo === '..' || !REPO_NAME_RE.test(p.repo))) {
401
457
  throw new Error(`PRs file entry has an invalid repo name: ${JSON.stringify(p.repo)} — expected "." or a plain repo name`);
402
458
  }
403
- if (modeOf(p) === 'local') {
459
+ if (modeOfEntry(p) === 'local') {
404
460
  if (!p.branch) throw new Error(`PRs file entry is missing branch: ${JSON.stringify(p)}`);
405
461
  } else if (!p.owner || !p.name || !p.id) {
406
462
  throw new Error(`PRs file entry is missing owner/name/id: ${JSON.stringify(p)}`);
407
463
  }
408
464
  }
409
- if (prs.some((p) => modeOf(p) === 'forge')) assertForgeEnabled(ws);
410
- // Entries carry their repo's forge and host since GitLab support landed;
411
- // older files predate the fields and fall back to the workspace block —
412
- // the GitHub default those runs were built under.
413
- const forgeFor = (p) => deps.forgeFactory({
465
+ return prs;
466
+ }
467
+
468
+ // Entries carry their repo's forge and host since GitLab support landed;
469
+ // older files predate the fields and fall back to the workspace block —
470
+ // the GitHub default those runs were built under.
471
+ function entryForge(ws, deps, p) {
472
+ return deps.forgeFactory({
414
473
  ...(ws.workspace?.forge ?? {}),
415
474
  ...(p.forge ? { type: p.forge, host: p.host } : {}),
416
475
  repo: `${p.owner}/${p.name}`,
417
476
  });
477
+ }
478
+
479
+ // --wait polls the still-pending PRs at this interval, giving up after
480
+ // this many minutes unless --wait-timeout said otherwise.
481
+ const CHECK_POLL_MS = 20000;
482
+ const CHECK_WAIT_DEFAULT_MIN = 30;
483
+
484
+ // The settle (gh:201): GitHub's checks and GitLab's pipeline both lag PR
485
+ // creation, so a "no checks yet" from a PR younger than this is re-read
486
+ // before it is believed — every young one per round, one shared pause,
487
+ // at most two rounds.
488
+ const YOUNG_PR_MS = 90000;
489
+ const SETTLE_MS = 30000;
490
+ const SETTLE_TRIES = 2;
491
+
492
+ // The pre-flight CI gate (gh:201): every forge PR that is not already
493
+ // merged is checked BEFORE the first merge, so the run is all-or-nothing —
494
+ // a red PR can never leave its siblings merged behind it.
495
+ //
496
+ // The read runs over every entry at once, in phases. A first pass of
497
+ // prChecks. Then the settle: entries that read 'none' while young are
498
+ // re-read after a shared pause, twice at most — a fresh 'none' can be a
499
+ // race rather than a verdict, and one pause serves every entry that needs
500
+ // it; an entry of unknown age (prView failed, or the forge reports no
501
+ // createdAt) is taken at its word. Then, under --wait, the poll: each
502
+ // round sleeps once and re-reads every entry still pending, under a
503
+ // deadline computed after the settle so its pauses do not eat the wait
504
+ // budget — and when the last pending entry goes terminal, every entry is
505
+ // read once more, because a PR read green early may have gone red while
506
+ // the wait lasted and the merge decision needs every entry's current
507
+ // word. Without --wait a pending entry stops the run (re-runnable once
508
+ // checks finish). Anything not green throws with every entry's state
509
+ // listed. Local entries never reach this — there is no forge to ask — and
510
+ // already-merged PRs are skipped unchecked: their CI question is settled.
511
+ async function preFlightChecks(ws, deps, prs, args, alreadyMerged, createdAtById) {
512
+ if (args.skipChecks) return;
513
+ const entries = prs.filter((p) => modeOfEntry(p) === 'forge' && !alreadyMerged.has(p.id));
514
+ if (entries.length === 0) return;
515
+ const forges = new Map(entries.map((p) => [p.id, entryForge(ws, deps, p)]));
516
+ const states = new Map();
517
+ for (const p of entries) {
518
+ states.set(p.id, await forges.get(p.id).prChecks({ id: p.id }));
519
+ }
520
+ // The settle set is fixed at this first read: a PR young enough to
521
+ // qualify keeps both retries even as the pauses themselves age it past
522
+ // the window.
523
+ let settling = entries.filter((p) => {
524
+ if (states.get(p.id).state !== 'none') return false;
525
+ const born = Date.parse(createdAtById.get(p.id) ?? '');
526
+ return Number.isFinite(born) && Date.now() - born < YOUNG_PR_MS;
527
+ });
528
+ for (let round = 0; round < SETTLE_TRIES && settling.length > 0; round += 1) {
529
+ await deps.sleepFn(SETTLE_MS);
530
+ for (const p of settling) {
531
+ states.set(p.id, await forges.get(p.id).prChecks({ id: p.id }));
532
+ }
533
+ settling = settling.filter((p) => states.get(p.id).state === 'none');
534
+ }
535
+ const waitMin = args.waitTimeout ? Number(args.waitTimeout) : CHECK_WAIT_DEFAULT_MIN;
536
+ if (args.wait) {
537
+ const deadline = Date.now() + waitMin * 60000;
538
+ while (entries.some((p) => states.get(p.id).state === 'pending') && Date.now() < deadline) {
539
+ await deps.sleepFn(CHECK_POLL_MS);
540
+ for (const p of entries) {
541
+ if (states.get(p.id).state !== 'pending') continue;
542
+ states.set(p.id, await forges.get(p.id).prChecks({ id: p.id }));
543
+ }
544
+ }
545
+ if (!entries.some((p) => states.get(p.id).state === 'pending')) {
546
+ // The wait paid off — confirm it with one read of everything: an
547
+ // early green cannot stand if the PR went red after it.
548
+ for (const p of entries) {
549
+ states.set(p.id, await forges.get(p.id).prChecks({ id: p.id }));
550
+ }
551
+ }
552
+ }
553
+ const blocked = entries.filter((p) => states.get(p.id).state !== 'success' && states.get(p.id).state !== 'none');
554
+ if (blocked.length === 0) return;
555
+ const lines = entries.map((p) => {
556
+ const c = states.get(p.id);
557
+ const jobs = c.state === 'failure' && c.failing?.length
558
+ ? ` — failing: ${c.failing.map((f) => `${f.name} ${f.url}`).join(', ')}`
559
+ : '';
560
+ const note = c.note ? ` (${c.note})` : '';
561
+ const timedOut = args.wait && c.state === 'pending' ? ' (still pending at the --wait-timeout)' : '';
562
+ return `${p.repo}: ${c.state}${note}${timedOut}${jobs} ${c.url ?? ''}`.trim();
563
+ });
564
+ const hint = args.wait
565
+ ? `Still pending after ${waitMin} minutes — re-run --merge later.`
566
+ : 'Re-run --merge once they finish, or pass --wait to poll here.';
567
+ throw new Error(`CI checks are not green — nothing merged: ${lines.join('; ')}. ${hint}`);
568
+ }
569
+
570
+ async function mergePrs(args, deps) {
571
+ const rootDir = resolve(args.root);
572
+ const ws = readWorkspace(rootDir);
573
+
574
+ const prs = loadPrs(args.prs);
575
+ if (prs.some((p) => modeOfEntry(p) === 'forge')) assertForgeEnabled(ws);
418
576
 
419
577
  // State first, so a re-run knows what an earlier run already finished.
420
578
  // A PR the forge reports MERGED is done; anything else is offered to
421
579
  // prMerge, which reports its own failure if the PR cannot merge. If the
422
580
  // view itself fails, fall through to the merge attempt — that path
423
- // produces the honest error when something is genuinely wrong.
581
+ // produces the honest error when something is genuinely wrong. createdAt
582
+ // rides the same view: it is what the settle uses to tell a genuinely
583
+ // check-less PR from one whose checks have not landed yet.
424
584
  const alreadyMerged = new Set();
585
+ const createdAtById = new Map();
425
586
  for (const p of prs) {
426
- if (modeOf(p) !== 'forge') continue;
587
+ if (modeOfEntry(p) !== 'forge') continue;
427
588
  try {
428
- const view = await forgeFor(p).prView({ id: p.id });
589
+ const view = await entryForge(ws, deps, p).prView({ id: p.id });
429
590
  if (view?.state === 'MERGED') alreadyMerged.add(p.id);
591
+ if (view?.createdAt) createdAtById.set(p.id, view.createdAt);
430
592
  } catch { /* state unknown — the merge attempt below decides */ }
431
593
  }
432
594
 
595
+ // gh:202: an "operator" entry hands the merge decision to a person. One
596
+ // anywhere governs the whole task — the workspace PR may never merge
597
+ // ahead of the project PRs it describes, so the task cannot merge
598
+ // part-way — and the run refuses until the operator says merge in a
599
+ // later turn (--approved, which the skill passes only then) or merges
600
+ // without this script: a forge PR merged in the UI is alreadyMerged
601
+ // above, and a local entry whose branch the source clone already
602
+ // contains is just as done (localEntryMerged — the same containment
603
+ // mergeLocalEntry acts on). Entries from before the field existed carry
604
+ // none and read as "ask".
605
+ if (!args.approved) {
606
+ const waiting = prs.filter((p) => {
607
+ if (alreadyMerged.has(p.id) || (p.mergeApproval ?? 'ask') !== 'operator') return false;
608
+ return modeOfEntry(p) === 'local' ? !localEntryMerged(deps.gitFn, rootDir, p) : true;
609
+ });
610
+ if (waiting.length > 0) {
611
+ throw new Error(
612
+ `mergeApproval "operator" is set for ${waiting.map((p) => p.repo).join(', ')} — this task's merges wait for the operator. Re-run --merge with --approved once they have said merge, or after the merges are done by hand: a PR in the forge UI, a local branch fast-forwarded into its default branch.`,
613
+ );
614
+ }
615
+ }
616
+
617
+ // The CI gate runs before the first merge, over every PR at once, so a
618
+ // red one leaves nothing merged behind it (gh:201).
619
+ await preFlightChecks(ws, deps, prs, args, alreadyMerged, createdAtById);
620
+
433
621
  // Ordering rule: the project repos merge first — forge PRs and local
434
622
  // branches alike, in file order — and the workspace repo last, only when
435
623
  // every project merge succeeded: the workspace branch's promoted context
@@ -438,15 +626,19 @@ async function mergePrs(args, deps) {
438
626
  const failures = [];
439
627
  const workspaceEntry = prs.find((p) => p.repo === WORKSPACE_REPO) ?? null;
440
628
  const mergeOne = async (p) => {
441
- if (modeOf(p) === 'local') {
629
+ if (modeOfEntry(p) === 'local') {
442
630
  await mergeLocalEntry(deps.gitFn, rootDir, p);
443
631
  } else if (!alreadyMerged.has(p.id)) {
444
- await forgeFor(p).prMerge({ id: p.id, strategy: 'squash', deleteBranch: true });
632
+ await entryForge(ws, deps, p).prMerge({ id: p.id, strategy: 'squash', deleteBranch: true });
445
633
  }
446
634
  merged.push(p);
447
635
  };
448
636
  for (const p of prs.filter((x) => x !== workspaceEntry)) {
449
- try { await mergeOne(p); } catch (err) { failures.push(`${p.repo}: ${err.message}`); }
637
+ try {
638
+ await mergeOne(p);
639
+ } catch (err) {
640
+ failures.push(`${p.repo}: ${err.message}`);
641
+ }
450
642
  }
451
643
  if (failures.length === 0 && workspaceEntry) {
452
644
  try { await mergeOne(workspaceEntry); } catch (err) { failures.push(`${workspaceEntry.repo}: ${err.message}`); }
@@ -465,12 +657,15 @@ async function mergePrs(args, deps) {
465
657
  // reported flag rather than an error (the merges stand and the issue
466
658
  // still closes), and the user is told to pull by hand before teardown.
467
659
  const result = {
468
- merged: merged.map((p) => (modeOf(p) === 'local'
660
+ merged: merged.map((p) => (modeOfEntry(p) === 'local'
469
661
  ? { repo: p.repo, mode: 'local', branch: p.branch, base: p.base }
470
662
  : { repo: p.repo, mode: 'forge', number: p.number, url: p.url })),
471
663
  closed: null,
472
664
  };
473
- if (merged.some((p) => p.repo === WORKSPACE_REPO && modeOf(p) === 'local')) {
665
+ // The bypass is logged, not silent: whoever reads the JSON can see the
666
+ // gate was skipped, not passed.
667
+ if (args.skipChecks) result.checksSkipped = true;
668
+ if (merged.some((p) => p.repo === WORKSPACE_REPO && modeOfEntry(p) === 'local')) {
474
669
  result.pullSkipped = 'workspace merged locally';
475
670
  } else {
476
671
  const launcherBranch = gitOut(deps.gitFn, rootDir, ['branch', '--show-current']) || '(detached HEAD)';
@@ -497,7 +692,7 @@ async function mergePrs(args, deps) {
497
692
  result.closeSkipped = 'no tracker configured';
498
693
  } else {
499
694
  const tracker = deps.trackerFactory(ws.workspace.tracker);
500
- const parts = merged.map((p) => (modeOf(p) === 'local' ? `${p.repo} ${p.branch}->${p.base}` : p.url));
695
+ const parts = merged.map((p) => (modeOfEntry(p) === 'local' ? `${p.repo} ${p.branch}->${p.base}` : p.url));
501
696
  await tracker.closeIssue(args.workItem, { comment: `Merged: ${parts.join(' ')}` });
502
697
  result.closed = args.workItem;
503
698
  }
@@ -505,13 +700,38 @@ async function mergePrs(args, deps) {
505
700
  return result;
506
701
  }
507
702
 
508
- const MODE_FLAGS = new Set(['--create', '--merge']);
509
- const VALUE_FLAGS = new Set(['--root', '--branch', '--work-item', '--chat', '--prs', '--out']);
703
+ // --checks (gh:201/gh:202): read every entry's CI through the same
704
+ // prChecks call --merge gates on, without merging anything — the state
705
+ // /complete-work shows before the merge question, and before stopping in
706
+ // operator mode. Local entries report 'none': there is no forge to ask.
707
+ async function readChecks(args, deps) {
708
+ const rootDir = resolve(args.root);
709
+ const ws = readWorkspace(rootDir);
710
+ const prs = loadPrs(args.prs);
711
+ if (prs.some((p) => modeOfEntry(p) === 'forge')) assertForgeEnabled(ws);
712
+ const checks = [];
713
+ for (const p of prs) {
714
+ if (modeOfEntry(p) === 'local') {
715
+ checks.push({ repo: p.repo, mode: 'local', state: 'none', url: null, failing: [] });
716
+ continue;
717
+ }
718
+ const result = await entryForge(ws, deps, p).prChecks({ id: p.id });
719
+ checks.push({
720
+ repo: p.repo, mode: 'forge', state: result.state, url: result.url,
721
+ failing: result.failing ?? [], ...(result.note ? { note: result.note } : {}),
722
+ });
723
+ }
724
+ return { checks };
725
+ }
726
+
727
+ const MODE_FLAGS = new Set(['--create', '--merge', '--checks']);
728
+ const VALUE_FLAGS = new Set(['--root', '--branch', '--work-item', '--chat', '--prs', '--out', '--wait-timeout']);
510
729
 
511
730
  function parseArgs(argv) {
512
731
  const args = {
513
732
  root: '.', mode: null, branch: null, workItem: null, chat: null,
514
733
  repos: [], bodyFiles: new Map(), prs: null, out: null, forceWithLease: false,
734
+ wait: false, waitTimeout: null, skipChecks: false, approved: false,
515
735
  };
516
736
  const rest = argv.slice(2);
517
737
  const value = (i, flag) => {
@@ -522,7 +742,7 @@ function parseArgs(argv) {
522
742
  for (let i = 0; i < rest.length; i += 1) {
523
743
  const a = rest[i];
524
744
  if (MODE_FLAGS.has(a)) {
525
- if (args.mode) throw new Error(`only one of --create, --merge may be given (already have --${args.mode})`);
745
+ if (args.mode) throw new Error(`only one of --create, --merge, --checks may be given (already have --${args.mode})`);
526
746
  args.mode = a.slice(2);
527
747
  continue;
528
748
  }
@@ -536,6 +756,9 @@ function parseArgs(argv) {
536
756
  continue;
537
757
  }
538
758
  if (a === '--force-with-lease') { args.forceWithLease = true; continue; }
759
+ if (a === '--wait') { args.wait = true; continue; }
760
+ if (a === '--skip-checks') { args.skipChecks = true; continue; }
761
+ if (a === '--approved') { args.approved = true; continue; }
539
762
  if (VALUE_FLAGS.has(a)) {
540
763
  args[a.slice(2).replace(/-([a-z])/g, (_, c) => c.toUpperCase())] = value(i, a);
541
764
  i += 1;
@@ -543,13 +766,17 @@ function parseArgs(argv) {
543
766
  }
544
767
  throw new Error(`unknown argument: ${a}`);
545
768
  }
546
- if (!args.mode) throw new Error('one of --create, --merge is required');
769
+ if (!args.mode) throw new Error('one of --create, --merge, --checks is required');
547
770
  if (args.mode === 'create') {
548
771
  if (!args.branch) throw new Error('--create requires --branch');
549
772
  if (!args.chat && args.repos.length === 0) throw new Error('--create requires --chat or at least one --repo');
550
- if (args.prs) throw new Error('--prs is only valid with --merge');
773
+ if (args.prs) throw new Error('--prs is only valid with --merge or --checks');
774
+ if (args.wait) throw new Error('--wait is only valid with --merge');
775
+ if (args.waitTimeout) throw new Error('--wait-timeout is only valid with --merge');
776
+ if (args.skipChecks) throw new Error('--skip-checks is only valid with --merge');
777
+ if (args.approved) throw new Error('--approved is only valid with --merge');
551
778
  } else {
552
- if (!args.prs) throw new Error('--merge requires --prs');
779
+ if (!args.prs) throw new Error(`--${args.mode} requires --prs`);
553
780
  for (const flag of ['branch', 'chat']) {
554
781
  if (args[flag]) throw new Error(`--${flag} is only valid with --create`);
555
782
  }
@@ -557,14 +784,28 @@ function parseArgs(argv) {
557
784
  if (args.bodyFiles.size > 0) throw new Error('--body-file is only valid with --create');
558
785
  if (args.forceWithLease) throw new Error('--force-with-lease is only valid with --create');
559
786
  if (args.out) throw new Error('--out is only valid with --create');
787
+ if (args.mode === 'checks') {
788
+ if (args.workItem) throw new Error('--work-item is only valid with --create or --merge');
789
+ if (args.wait) throw new Error('--wait is only valid with --merge');
790
+ if (args.waitTimeout) throw new Error('--wait-timeout is only valid with --merge');
791
+ if (args.skipChecks) throw new Error('--skip-checks is only valid with --merge');
792
+ if (args.approved) throw new Error('--approved is only valid with --merge');
793
+ } else if (args.waitTimeout) {
794
+ if (!args.wait) throw new Error('--wait-timeout is only valid with --wait');
795
+ const n = Number(args.waitTimeout);
796
+ if (!Number.isFinite(n) || n <= 0) {
797
+ throw new Error(`--wait-timeout needs a positive number of minutes, got: ${args.waitTimeout}`);
798
+ }
799
+ }
560
800
  }
561
801
  return args;
562
802
  }
563
803
 
564
804
  /**
565
805
  * Run one task-pr command. `argv` is a process.argv-shaped array; `deps`
566
- * is injectable so tests can fake git, the forge, and the tracker:
567
- * { gitFn, forgeFactory, trackerFactory }
806
+ * is injectable so tests can fake git, the forge, the tracker, and the
807
+ * --wait polling sleep:
808
+ * { gitFn, forgeFactory, trackerFactory, sleepFn }
568
809
  * Resolves with the command's JSON result; throws on any failure.
569
810
  */
570
811
  async function run(argv, deps = {}) {
@@ -572,10 +813,13 @@ async function run(argv, deps = {}) {
572
813
  gitFn: spawnSync,
573
814
  forgeFactory: createForge,
574
815
  trackerFactory: createTracker,
816
+ sleepFn: (ms) => new Promise((r) => setTimeout(r, ms)),
575
817
  ...deps,
576
818
  };
577
819
  const args = parseArgs(argv);
578
- return args.mode === 'create' ? createPrs(args, resolved) : mergePrs(args, resolved);
820
+ if (args.mode === 'create') return createPrs(args, resolved);
821
+ if (args.mode === 'merge') return mergePrs(args, resolved);
822
+ return readChecks(args, resolved);
579
823
  }
580
824
 
581
825
  async function main() {
@@ -591,4 +835,4 @@ if (isMainModule(import.meta.url)) {
591
835
  }
592
836
 
593
837
  export { run, parseArgs };
594
- export { parseForgeRemote, mergeModeFor, perRepoForge, forgeConfigForRepo } from './merge-mode.mjs';
838
+ export { parseForgeRemote, mergeModeFor, mergeApprovalFor, perRepoForge, forgeConfigForRepo } from './merge-mode.mjs';
@@ -445,12 +445,19 @@ function resolveSessionId(rootDir, chat, sessionId, env) {
445
445
  return (env && env.CLAUDE_CODE_SESSION_ID) || null;
446
446
  }
447
447
 
448
+ // Claude Code's own agent worktrees (isolation: worktree) sit at the same
449
+ // .claude/worktrees/{slug} paths on worktree-agent-* branches (gh:205). They
450
+ // are session machinery, not the task lifecycle — detection declines them so
451
+ // /complete-work never treats one as a task worktree.
452
+ const AGENT_BRANCH_PREFIX = 'worktree-agent-';
453
+
448
454
  // One task worktree's detect payload, or null when {path} does not really
449
455
  // hold a task: a stale plain directory under .claude/worktrees/ would
450
456
  // otherwise detect through cwd and resolve to the repo around it — for the
451
457
  // workspace layout, to the launcher itself, whose default branch
452
458
  // /complete-work would then rebase and push. So require git to confirm the
453
- // path is a worktree root, and never report the repo's default branch.
459
+ // path is a worktree root, and never report the repo's default branch or a
460
+ // Claude Code agent branch (worktree-agent-*).
454
461
  function taskWorktreeInfo(gitFn, rootReal, repo, path, chat) {
455
462
  try {
456
463
  const res = gitFn('git', ['-C', path, 'rev-parse', '--show-toplevel'], { encoding: 'utf8' });
@@ -458,6 +465,7 @@ function taskWorktreeInfo(gitFn, rootReal, repo, path, chat) {
458
465
  if (!samePath(String(res.stdout).trim(), path)) return null;
459
466
  const branch = currentBranch(gitFn, path);
460
467
  if (branch && branch === defaultBranchFor(rootReal, repo, gitFn)) return null;
468
+ if (branch && branch.startsWith(AGENT_BRANCH_PREFIX)) return null;
461
469
  const out = { model: 'task', source: 'worktree', repo, branch, path };
462
470
  const tasks = matchingTasks(rootReal, chat, branch);
463
471
  if (tasks) out.tasks = tasks;