@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.
- package/package.json +2 -1
- package/template/CLAUDE.md.tmpl +2 -2
- package/template/_claude/hooks/_utils.mjs +43 -0
- package/template/_claude/hooks/session-start.mjs +10 -0
- package/template/_claude/rules/work-item-tracking.md +6 -3
- package/template/_claude/rules/workspace-structure.md +1 -1
- package/template/_claude/scripts/chat-record.mjs +43 -26
- package/template/_claude/scripts/classify-update.mjs +194 -32
- package/template/_claude/scripts/forges/github.mjs +51 -1
- package/template/_claude/scripts/forges/gitlab.mjs +69 -0
- package/template/_claude/scripts/forges/interface.mjs +19 -2
- package/template/_claude/scripts/maintenance-audit.mjs +325 -3
- package/template/_claude/scripts/merge-mode.mjs +22 -1
- package/template/_claude/scripts/migrate-sessions.mjs +212 -34
- package/template/_claude/scripts/release-leak-audit.mjs +480 -0
- package/template/_claude/scripts/task-pr.mjs +316 -72
- package/template/_claude/scripts/task-worktree.mjs +9 -1
- package/template/_claude/scripts/template-merge.mjs +21 -4
- package/template/_claude/scripts/template-modifications.mjs +152 -0
- package/template/_claude/scripts/trackers/github-issues.mjs +110 -1
- package/template/_claude/scripts/trackers/gitlab-issues.mjs +238 -5
- package/template/_claude/scripts/trackers/interface.mjs +47 -0
- package/template/_claude/skills/braindump/SKILL.md +1 -1
- package/template/_claude/skills/complete-work/SKILL.md +22 -12
- package/template/_claude/skills/handoff/SKILL.md +1 -1
- package/template/_claude/skills/maintenance/SKILL.md +8 -3
- package/template/_claude/skills/migrate-sessions/SKILL.md +20 -13
- package/template/_claude/skills/release/SKILL.md +20 -6
- package/template/_claude/skills/setup-tracker/SKILL.md +24 -1
- package/template/_claude/skills/start-work/SKILL.md +23 -5
- package/template/_claude/skills/workspace-update/SKILL.md +23 -15
- package/template/workspace.json.tmpl +2 -1
- package/lib/init.test.mjs +0 -75
- package/lib/payload.test.mjs +0 -210
- package/lib/scaffold.test.mjs +0 -79
- 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
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
//
|
|
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
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
//
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
//
|
|
65
|
-
//
|
|
66
|
-
//
|
|
67
|
-
//
|
|
68
|
-
//
|
|
69
|
-
//
|
|
70
|
-
//
|
|
71
|
-
//
|
|
72
|
-
//
|
|
73
|
-
//
|
|
74
|
-
//
|
|
75
|
-
//
|
|
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
|
-
|
|
372
|
-
|
|
373
|
-
|
|
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(
|
|
441
|
+
parsed = JSON.parse(stripBom(readFileSync(path, 'utf-8')));
|
|
378
442
|
} catch (err) {
|
|
379
|
-
throw new Error(`cannot read PRs file ${
|
|
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 ${
|
|
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 ${
|
|
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 (
|
|
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
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
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 (
|
|
587
|
+
if (modeOfEntry(p) !== 'forge') continue;
|
|
427
588
|
try {
|
|
428
|
-
const view = await
|
|
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 (
|
|
629
|
+
if (modeOfEntry(p) === 'local') {
|
|
442
630
|
await mergeLocalEntry(deps.gitFn, rootDir, p);
|
|
443
631
|
} else if (!alreadyMerged.has(p.id)) {
|
|
444
|
-
await
|
|
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 {
|
|
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) => (
|
|
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
|
-
|
|
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) => (
|
|
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
|
-
|
|
509
|
-
|
|
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(
|
|
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
|
|
567
|
-
*
|
|
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
|
-
|
|
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;
|