@ulysses-ai/create-workspace 0.18.0-beta.0 → 0.19.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/lib/init.mjs +4 -1
- package/lib/payload.mjs +18 -1
- package/lib/payload.test.mjs +55 -0
- package/lib/scaffold.mjs +23 -6
- package/lib/scaffold.test.mjs +59 -0
- package/package.json +1 -1
- package/template/{.claude → _claude}/rules/forge-operations.md +6 -0
- package/template/{.claude → _claude}/rules/memory-guidance.md +4 -0
- package/template/{.claude → _claude}/rules/task-list-mirroring.md +6 -0
- package/template/{.claude → _claude}/scripts/build-workspace-context.mjs +25 -14
- package/template/{.claude → _claude}/scripts/chat-record.mjs +37 -4
- package/template/{.claude → _claude}/scripts/context-footprint.mjs +139 -30
- package/template/{.claude → _claude}/scripts/forges/github.mjs +2 -1
- package/template/{.claude → _claude}/scripts/forges/interface.mjs +5 -4
- package/template/_claude/scripts/task-pr.mjs +447 -0
- package/template/{.claude → _claude}/scripts/trackers/github-issues.mjs +11 -0
- package/template/{.claude → _claude}/scripts/trackers/interface.mjs +8 -0
- package/template/{.claude → _claude}/skills/braindump/SKILL.md +1 -0
- package/template/{.claude → _claude}/skills/complete-work/SKILL.md +13 -69
- package/template/{.claude → _claude}/skills/context-placement/SKILL.md +8 -5
- package/template/{.claude → _claude}/skills/goal-driven-work/SKILL.md +1 -1
- package/template/{.claude → _claude}/skills/handoff/SKILL.md +1 -0
- package/template/{.claude → _claude}/skills/maintenance/SKILL.md +49 -17
- package/template/{.claude → _claude}/skills/release/SKILL.md +6 -2
- package/template/{.claude → _claude}/skills/start-work/SKILL.md +2 -2
- package/template/workspace.json.tmpl +1 -1
- /package/template/{.claude → _claude}/agents/aside-researcher.md +0 -0
- /package/template/{.claude → _claude}/agents/implementer.md +0 -0
- /package/template/{.claude → _claude}/agents/researcher.md +0 -0
- /package/template/{.claude → _claude}/agents/reviewer.md +0 -0
- /package/template/{.claude → _claude}/hooks/_utils.mjs +0 -0
- /package/template/{.claude → _claude}/hooks/bash-output-advisory.mjs +0 -0
- /package/template/{.claude → _claude}/hooks/post-compact.mjs +0 -0
- /package/template/{.claude → _claude}/hooks/pre-compact.mjs +0 -0
- /package/template/{.claude → _claude}/hooks/repo-write-detection.mjs +0 -0
- /package/template/{.claude → _claude}/hooks/session-end.mjs +0 -0
- /package/template/{.claude → _claude}/hooks/session-start.mjs +0 -0
- /package/template/{.claude → _claude}/hooks/subagent-start.mjs +0 -0
- /package/template/{.claude → _claude}/hooks/version-freshness-check.mjs +0 -0
- /package/template/{.claude → _claude}/hooks/workspace-update-check.mjs +0 -0
- /package/template/{.claude → _claude}/lib/freshness.mjs +0 -0
- /package/template/{.claude → _claude}/lib/registry-check.mjs +0 -0
- /package/template/{.claude → _claude}/lib/require-node.mjs +0 -0
- /package/template/{.claude → _claude}/lib/session-frontmatter.mjs +0 -0
- /package/template/{.claude → _claude}/recipes/migrate-from-notion.md +0 -0
- /package/template/{.claude → _claude}/rules/agent-rules.md.skip +0 -0
- /package/template/{.claude → _claude}/rules/cloud-infrastructure.md.skip +0 -0
- /package/template/{.claude → _claude}/rules/coherent-revisions.md +0 -0
- /package/template/{.claude → _claude}/rules/config-review.md.skip +0 -0
- /package/template/{.claude → _claude}/rules/documentation.md.skip +0 -0
- /package/template/{.claude → _claude}/rules/git-conventions.md +0 -0
- /package/template/{.claude → _claude}/rules/goal-driven-work.md +0 -0
- /package/template/{.claude → _claude}/rules/honest-pushback.md +0 -0
- /package/template/{.claude → _claude}/rules/local-dev-environment.md.skip +0 -0
- /package/template/{.claude → _claude}/rules/product-integrity.md.skip +0 -0
- /package/template/{.claude → _claude}/rules/scope-guard.md.skip +0 -0
- /package/template/{.claude → _claude}/rules/superpowers-workflow.md.skip +0 -0
- /package/template/{.claude → _claude}/rules/token-economics.md.skip +0 -0
- /package/template/{.claude → _claude}/rules/work-item-tracking.md +0 -0
- /package/template/{.claude → _claude}/rules/workspace-structure.md +0 -0
- /package/template/{.claude → _claude}/scripts/add-repo-to-session.mjs +0 -0
- /package/template/{.claude → _claude}/scripts/capture-context.mjs +0 -0
- /package/template/{.claude → _claude}/scripts/cleanup-work-session.mjs +0 -0
- /package/template/{.claude → _claude}/scripts/create-work-session.mjs +0 -0
- /package/template/{.claude → _claude}/scripts/forges/gitlab.mjs +0 -0
- /package/template/{.claude → _claude}/scripts/generate-claude-local.mjs +0 -0
- /package/template/{.claude → _claude}/scripts/migrate-canonical-priority.mjs +0 -0
- /package/template/{.claude → _claude}/scripts/migrate-claude-md-freshness-include.mjs +0 -0
- /package/template/{.claude → _claude}/scripts/migrate-open-work.mjs +0 -0
- /package/template/{.claude → _claude}/scripts/migrate-session-layout.mjs +0 -0
- /package/template/{.claude → _claude}/scripts/migrate-sessions.mjs +0 -0
- /package/template/{.claude → _claude}/scripts/migrate-to-workspace-context.mjs +0 -0
- /package/template/{.claude → _claude}/scripts/sweep-references.mjs +0 -0
- /package/template/{.claude → _claude}/scripts/sync-tasks.mjs +0 -0
- /package/template/{.claude → _claude}/scripts/task-worktree.mjs +0 -0
- /package/template/{.claude → _claude}/scripts/workspace-diagnostics.mjs +0 -0
- /package/template/{.claude → _claude}/settings.json +0 -0
- /package/template/{.claude → _claude}/skills/aside/SKILL.md +0 -0
- /package/template/{.claude → _claude}/skills/build-docs-site/SKILL.md +0 -0
- /package/template/{.claude → _claude}/skills/build-docs-site/checklists/framing.md +0 -0
- /package/template/{.claude → _claude}/skills/build-docs-site/checklists/pitfalls.md +0 -0
- /package/template/{.claude → _claude}/skills/build-docs-site/checklists/review.md +0 -0
- /package/template/{.claude → _claude}/skills/build-docs-site/scripts/bulk-fill-migration.py +0 -0
- /package/template/{.claude → _claude}/skills/build-docs-site/scripts/forbidden-word-grep.mjs +0 -0
- /package/template/{.claude → _claude}/skills/build-docs-site/scripts/leak-grep.mjs +0 -0
- /package/template/{.claude → _claude}/skills/build-docs-site/templates/custom.css.tmpl +0 -0
- /package/template/{.claude → _claude}/skills/build-docs-site/templates/docusaurus.config.ts.tmpl +0 -0
- /package/template/{.claude → _claude}/skills/build-docs-site/templates/primitives/Arrow.tsx +0 -0
- /package/template/{.claude → _claude}/skills/build-docs-site/templates/primitives/Box.tsx +0 -0
- /package/template/{.claude → _claude}/skills/build-docs-site/templates/primitives/DiagramContainer.tsx +0 -0
- /package/template/{.claude → _claude}/skills/build-docs-site/templates/primitives/Region.tsx +0 -0
- /package/template/{.claude → _claude}/skills/build-docs-site/templates/primitives/SectionTitle.tsx +0 -0
- /package/template/{.claude → _claude}/skills/build-docs-site/templates/primitives/tokens.ts +0 -0
- /package/template/{.claude → _claude}/skills/build-docs-site/templates/sidebars.ts.tmpl +0 -0
- /package/template/{.claude → _claude}/skills/build-docs-site/templates/spec.md.tmpl +0 -0
- /package/template/{.claude → _claude}/skills/migrate-sessions/SKILL.md +0 -0
- /package/template/{.claude → _claude}/skills/pause-work/SKILL.md +0 -0
- /package/template/{.claude → _claude}/skills/promote/SKILL.md +0 -0
- /package/template/{.claude → _claude}/skills/setup-tracker/SKILL.md +0 -0
- /package/template/{.claude → _claude}/skills/sync-work/SKILL.md +0 -0
- /package/template/{.claude → _claude}/skills/workspace-init/SKILL.md +0 -0
- /package/template/{.claude → _claude}/skills/workspace-update/SKILL.md +0 -0
- /package/template/{.mcp.json → _mcp.json} +0 -0
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// PR creation and merge for the task lifecycle (gh:163).
|
|
3
|
+
//
|
|
4
|
+
// The task path of /complete-work used to end with two hand-written
|
|
5
|
+
// JavaScript blocks — one to push and open a PR per repo, one to merge them
|
|
6
|
+
// in order and close the issue. Every run re-typed them, and every
|
|
7
|
+
// re-typing was a fresh chance to drop a guard: origins must be parsed
|
|
8
|
+
// before anything is pushed, body files must exist for every repo that
|
|
9
|
+
// gets a PR, and the workspace PR must never merge ahead of the project
|
|
10
|
+
// PRs it describes. This script is those blocks written once.
|
|
11
|
+
//
|
|
12
|
+
// It runs from the launcher and owns only this slice: the rebase and the
|
|
13
|
+
// drawer routing happen in the skill's earlier steps, and teardown stays
|
|
14
|
+
// in task-worktree.mjs --remove.
|
|
15
|
+
//
|
|
16
|
+
// Usage:
|
|
17
|
+
// node task-pr.mjs --create --root <launcher> --branch <branch>
|
|
18
|
+
// [--work-item gh:N] [--chat <name> | --repo <r> ...]
|
|
19
|
+
// --body-file <repo>=<path> ... [--out <file>]
|
|
20
|
+
// [--force-with-lease]
|
|
21
|
+
// node task-pr.mjs --merge --root <launcher> --prs <json-from-create>
|
|
22
|
+
// [--work-item gh:N]
|
|
23
|
+
//
|
|
24
|
+
// --create resolves the task's repos from the chat record's entries for
|
|
25
|
+
// the branch (--chat) or from repeated --repo flags, skips repos whose
|
|
26
|
+
// branch has no commits over origin/{default} (reported as empty), pushes
|
|
27
|
+
// each remaining branch, and opens one PR per repo through a per-repo
|
|
28
|
+
// forge. The PR title is the linked issue's title when --work-item is
|
|
29
|
+
// given, else the branch's first commit subject; the body is the repo's
|
|
30
|
+
// body file with `Closes <ref>` appended. Prints `{ prs, empty, pushed }`
|
|
31
|
+
// and, with --out, writes the same JSON to a file — a mid-run failure
|
|
32
|
+
// still writes what has landed so far, so the state survives the error.
|
|
33
|
+
// A repo that already has an open PR for the branch gets it reused, so a
|
|
34
|
+
// re-run never opens a duplicate.
|
|
35
|
+
//
|
|
36
|
+
// --merge merges the project PRs first (squash, delete branch), the
|
|
37
|
+
// workspace PR only when every project merge succeeded — a PR the forge
|
|
38
|
+
// reports as already MERGED counts as done and is not merged again, which
|
|
39
|
+
// is what makes re-running after a partial failure safe. An empty or
|
|
40
|
+
// malformed PRs file is refused outright: no PRs means nothing to merge
|
|
41
|
+
// and nothing to close. Once every PR is merged the launcher is pulled
|
|
42
|
+
// --ff-only — only when it sits on the workspace default branch (else
|
|
43
|
+
// pullSkipped), and a failed pull is reported as pullFailed in the JSON
|
|
44
|
+
// rather than an error, because the merges stand and the issue still
|
|
45
|
+
// closes — and then the linked issue closes with a Merged: comment. On a
|
|
46
|
+
// merge failure it stops and names the PRs still open.
|
|
47
|
+
|
|
48
|
+
import '../lib/require-node.mjs';
|
|
49
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync, realpathSync } from 'node:fs';
|
|
50
|
+
import { join, resolve, dirname } from 'node:path';
|
|
51
|
+
import { spawnSync } from 'node:child_process';
|
|
52
|
+
import { fileURLToPath } from 'node:url';
|
|
53
|
+
import { taskWorktreePath, defaultBranchFor, WORKSPACE_REPO } from './task-worktree.mjs';
|
|
54
|
+
import { readRecord } from './chat-record.mjs';
|
|
55
|
+
import { createForge } from './forges/interface.mjs';
|
|
56
|
+
import { createTracker } from './trackers/interface.mjs';
|
|
57
|
+
|
|
58
|
+
function isMainModule(metaUrl) {
|
|
59
|
+
if (!process.argv[1]) return false;
|
|
60
|
+
try {
|
|
61
|
+
return realpathSync(fileURLToPath(metaUrl)) === realpathSync(process.argv[1]);
|
|
62
|
+
} catch { return false; }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Same remote shapes the forge adapters resolve a repo from. A URL that
|
|
66
|
+
// does not match is not forge-hosted, and this path supports forge-hosted
|
|
67
|
+
// repos only — a local/bare remote has no PR concept to aim at.
|
|
68
|
+
const FORGE_REMOTE_RE = /github\.com[:/]([^/]+)\/([^/.]+?)(?:\.git)?$/;
|
|
69
|
+
|
|
70
|
+
function parseForgeRemote(url) {
|
|
71
|
+
const m = String(url).trim().match(FORGE_REMOTE_RE);
|
|
72
|
+
return m ? { owner: m[1], name: m[2] } : null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function readWorkspace(rootDir) {
|
|
76
|
+
try {
|
|
77
|
+
return JSON.parse(readFileSync(join(rootDir, 'workspace.json'), 'utf-8'));
|
|
78
|
+
} catch {
|
|
79
|
+
throw new Error(`cannot read ${join(rootDir, 'workspace.json')} — is --root the launcher?`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// `workspace.forge: false` is an explicit opt-out; spreading it into a
|
|
84
|
+
// per-repo config would silently produce a default GitHub forge instead.
|
|
85
|
+
function assertForgeEnabled(ws) {
|
|
86
|
+
if (ws?.workspace?.forge === false) {
|
|
87
|
+
throw new Error('workspace.forge is false — forge operations are disabled here. Nothing was pushed; open the PR by hand.');
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Branch names become refs, refspecs, and (via the slug) paths; git's own
|
|
92
|
+
// format check is the authority, and its --branch variant also rejects
|
|
93
|
+
// names a later git call could mistake for an option. It needs no
|
|
94
|
+
// repository, so it runs before any other git call this script makes.
|
|
95
|
+
function assertBranchName(gitFn, branch) {
|
|
96
|
+
const res = gitFn('git', ['check-ref-format', '--branch', branch], { encoding: 'utf8' });
|
|
97
|
+
if (res.error || res.status !== 0) throw new Error(`invalid branch name: ${branch}`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// The --prs file may have been written by a BOM-emitting writer (PowerShell
|
|
101
|
+
// redirection among them); JSON.parse chokes on the marker, so strip it.
|
|
102
|
+
function stripBom(text) {
|
|
103
|
+
return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function writeOut(path, payload) {
|
|
107
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
108
|
+
writeFileSync(path, `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function gitCheck(gitFn, cwd, args) {
|
|
112
|
+
const res = gitFn('git', ['-C', cwd, ...args], { encoding: 'utf8' });
|
|
113
|
+
if (res.error) throw new Error(`git: ${res.error.message}`);
|
|
114
|
+
return res;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function gitOut(gitFn, cwd, args) {
|
|
118
|
+
const res = gitCheck(gitFn, cwd, args);
|
|
119
|
+
if (res.status !== 0) {
|
|
120
|
+
throw new Error(`git -C ${cwd} ${args.join(' ')} failed: ${String(res.stderr || '').trim()}`);
|
|
121
|
+
}
|
|
122
|
+
return String(res.stdout || '').trim();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// The merge base for "is this branch empty" and for the fallback PR title.
|
|
126
|
+
// origin/{default} is the truth the PR will merge against; the local branch
|
|
127
|
+
// covers a repo whose remote-tracking ref is missing.
|
|
128
|
+
function baseRefFor(gitFn, worktree, defaultBranch) {
|
|
129
|
+
for (const ref of [`origin/${defaultBranch}`, defaultBranch]) {
|
|
130
|
+
if (gitCheck(gitFn, worktree, ['rev-parse', '--verify', '--quiet', ref]).status === 0) return ref;
|
|
131
|
+
}
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function commitsOverBase(gitFn, worktree, branch, defaultBranch) {
|
|
136
|
+
const base = baseRefFor(gitFn, worktree, defaultBranch);
|
|
137
|
+
// No base ref at all: the branch cannot be proven empty, so it proceeds.
|
|
138
|
+
if (!base) return null;
|
|
139
|
+
return parseInt(gitOut(gitFn, worktree, ['rev-list', '--count', `${base}..${branch}`]), 10) || 0;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function firstCommitSubject(gitFn, worktree, branch, defaultBranch) {
|
|
143
|
+
const base = baseRefFor(gitFn, worktree, defaultBranch);
|
|
144
|
+
const range = base ? `${base}..${branch}` : branch;
|
|
145
|
+
const subjects = gitOut(gitFn, worktree, ['log', '--reverse', '--format=%s', range])
|
|
146
|
+
.split(/\r?\n/).filter((l) => l.trim() !== '');
|
|
147
|
+
if (subjects.length > 0) return subjects[0].trim();
|
|
148
|
+
return gitOut(gitFn, worktree, ['log', '-1', '--format=%s', branch]);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function pushBranch(gitFn, worktree, repo, branch, { forceWithLease = false } = {}) {
|
|
152
|
+
// `--` ends option parsing so the refspec can never read as a flag — git
|
|
153
|
+
// accepts it on push. It is deliberately NOT used on the rev-parse /
|
|
154
|
+
// rev-list / log calls, where git would flip the argument to a path.
|
|
155
|
+
const res = gitCheck(gitFn, worktree, [
|
|
156
|
+
'push', '-u', 'origin', ...(forceWithLease ? ['--force-with-lease'] : []), '--', branch,
|
|
157
|
+
]);
|
|
158
|
+
if (res.status === 0) return;
|
|
159
|
+
const stderr = String(res.stderr || '').trim();
|
|
160
|
+
if (!forceWithLease && /non-fast-forward|fetch first/i.test(stderr)) {
|
|
161
|
+
throw new Error(
|
|
162
|
+
`repo "${repo}": push rejected as non-fast-forward — the branch already exists on origin and the rebase rewrote it. Confirm and re-run with --force-with-lease; this script never forces on its own.`,
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
throw new Error(`repo "${repo}": git push failed: ${stderr}`);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function resolveTaskRepos(rootDir, { chat, branch, repos }) {
|
|
169
|
+
if (repos.length > 0) return [...new Set(repos)];
|
|
170
|
+
if (!chat) {
|
|
171
|
+
throw new Error("--create needs the task's repos: pass --chat <name> (its record entries for the branch) or repeat --repo");
|
|
172
|
+
}
|
|
173
|
+
const rec = readRecord(rootDir, chat);
|
|
174
|
+
const entries = (rec?.tasks ?? []).filter((t) => t.branch === branch && t.repo);
|
|
175
|
+
if (entries.length === 0) {
|
|
176
|
+
throw new Error(`chat record "${chat}" has no task entries for branch ${branch} — pass --repo explicitly`);
|
|
177
|
+
}
|
|
178
|
+
return [...new Set(entries.map((t) => t.repo))];
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async function createPrs(args, deps) {
|
|
182
|
+
assertBranchName(deps.gitFn, args.branch);
|
|
183
|
+
const rootDir = resolve(args.root);
|
|
184
|
+
const ws = readWorkspace(rootDir);
|
|
185
|
+
assertForgeEnabled(ws);
|
|
186
|
+
|
|
187
|
+
const targets = resolveTaskRepos(rootDir, args).map((repo) => {
|
|
188
|
+
const worktree = taskWorktreePath(rootDir, repo, args.branch);
|
|
189
|
+
if (!existsSync(worktree)) {
|
|
190
|
+
throw new Error(`no task worktree at ${worktree} for repo "${repo}" — create it with task-worktree.mjs --create`);
|
|
191
|
+
}
|
|
192
|
+
return {
|
|
193
|
+
repo, worktree, isWorkspace: repo === WORKSPACE_REPO,
|
|
194
|
+
defaultBranch: defaultBranchFor(rootDir, repo, deps.gitFn),
|
|
195
|
+
};
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
for (const t of targets) t.commits = commitsOverBase(deps.gitFn, t.worktree, args.branch, t.defaultBranch);
|
|
199
|
+
const empty = targets.filter((t) => t.commits === 0).map((t) => t.repo);
|
|
200
|
+
const active = targets.filter((t) => t.commits !== 0);
|
|
201
|
+
|
|
202
|
+
// Every guard runs before the first push: a repo whose origin cannot
|
|
203
|
+
// host a PR, or whose body file is missing, must stop the whole task
|
|
204
|
+
// with nothing pushed and nothing half-opened.
|
|
205
|
+
for (const t of active) {
|
|
206
|
+
const url = gitOut(deps.gitFn, t.worktree, ['remote', 'get-url', 'origin']);
|
|
207
|
+
const parsed = parseForgeRemote(url);
|
|
208
|
+
if (!parsed) {
|
|
209
|
+
throw new Error(`repo "${t.repo}": origin (${url}) is not a forge-hosted repository — stopped before pushing anything. Complete this repo under the session model.`);
|
|
210
|
+
}
|
|
211
|
+
Object.assign(t, parsed);
|
|
212
|
+
}
|
|
213
|
+
for (const t of active) {
|
|
214
|
+
const bodyFile = args.bodyFiles.get(t.repo);
|
|
215
|
+
if (!bodyFile) {
|
|
216
|
+
throw new Error(`repo "${t.repo}" has commits to merge but no --body-file — one is required for every repo that gets a PR`);
|
|
217
|
+
}
|
|
218
|
+
if (!existsSync(bodyFile)) {
|
|
219
|
+
throw new Error(`repo "${t.repo}": body file not found: ${bodyFile}`);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
let issueTitle = null;
|
|
224
|
+
let issueRefs = null;
|
|
225
|
+
if (args.workItem) {
|
|
226
|
+
const tracker = deps.trackerFactory(ws.workspace?.tracker);
|
|
227
|
+
issueTitle = (await tracker.getIssue(args.workItem)).title;
|
|
228
|
+
issueRefs = new Map(active.map((t) => [t.repo, tracker.issueRef(args.workItem, { fromRepo: `${t.owner}/${t.name}` })]));
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const prs = [];
|
|
232
|
+
const pushed = [];
|
|
233
|
+
try {
|
|
234
|
+
for (const t of active) {
|
|
235
|
+
pushBranch(deps.gitFn, t.worktree, t.repo, args.branch, { forceWithLease: args.forceWithLease });
|
|
236
|
+
pushed.push(t.repo);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
for (const t of active) {
|
|
240
|
+
const forge = deps.forgeFactory({ ...(ws.workspace?.forge ?? {}), repo: `${t.owner}/${t.name}` });
|
|
241
|
+
// Idempotency: a re-run must not open a second PR for a branch that
|
|
242
|
+
// already has one open against the same base. Reuse it as-is —
|
|
243
|
+
// re-titling or re-bodying an existing PR is a decision, not a
|
|
244
|
+
// default, and the one already open is the one reviewers watch.
|
|
245
|
+
const existing = (await forge.prList({ state: 'open', head: args.branch, base: t.defaultBranch }))
|
|
246
|
+
.find((p) => p.headRefName === args.branch && p.baseRefName === t.defaultBranch);
|
|
247
|
+
const title = issueTitle ?? firstCommitSubject(deps.gitFn, t.worktree, args.branch, t.defaultBranch);
|
|
248
|
+
let body = readFileSync(args.bodyFiles.get(t.repo), 'utf8').replace(/\s*$/, '');
|
|
249
|
+
if (issueRefs) body = `${body}\n\nCloses ${issueRefs.get(t.repo)}\n`;
|
|
250
|
+
const pr = existing ?? await forge.prCreate({ title, body, head: args.branch, base: t.defaultBranch });
|
|
251
|
+
prs.push({
|
|
252
|
+
repo: t.repo, owner: t.owner, name: t.name,
|
|
253
|
+
number: pr.number, id: pr.id, url: pr.url, isWorkspace: t.isWorkspace,
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
} catch (err) {
|
|
257
|
+
// A failure after the first push must not lose the run's state: the
|
|
258
|
+
// pushes and PRs that landed go to --out so a re-run (or a --merge of
|
|
259
|
+
// what exists) starts from reality. Before the first push nothing has
|
|
260
|
+
// happened, and an earlier run's file stays untouched rather than
|
|
261
|
+
// being clobbered with a no-op result.
|
|
262
|
+
if (args.out && (pushed.length > 0 || prs.length > 0)) {
|
|
263
|
+
writeOut(args.out, { prs, empty, pushed });
|
|
264
|
+
}
|
|
265
|
+
throw err;
|
|
266
|
+
}
|
|
267
|
+
const result = { prs, empty, pushed };
|
|
268
|
+
if (args.out) writeOut(args.out, result);
|
|
269
|
+
return result;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
async function mergePrs(args, deps) {
|
|
273
|
+
const rootDir = resolve(args.root);
|
|
274
|
+
const ws = readWorkspace(rootDir);
|
|
275
|
+
assertForgeEnabled(ws);
|
|
276
|
+
|
|
277
|
+
let parsed;
|
|
278
|
+
try {
|
|
279
|
+
parsed = JSON.parse(stripBom(readFileSync(args.prs, 'utf-8')));
|
|
280
|
+
} catch (err) {
|
|
281
|
+
throw new Error(`cannot read PRs file ${args.prs}: ${err.message}`);
|
|
282
|
+
}
|
|
283
|
+
// An empty or malformed file is a refusal, not a no-op: with nothing to
|
|
284
|
+
// merge there is nothing that earns an issue close, and "Merged: " with
|
|
285
|
+
// no URLs would tell the tracker a lie.
|
|
286
|
+
if (!parsed || !Array.isArray(parsed.prs)) {
|
|
287
|
+
throw new Error(`PRs file ${args.prs} is malformed — expected the JSON from a --create run ({ prs: [...] })`);
|
|
288
|
+
}
|
|
289
|
+
const prs = parsed.prs;
|
|
290
|
+
if (prs.length === 0) {
|
|
291
|
+
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`);
|
|
292
|
+
}
|
|
293
|
+
for (const p of prs) {
|
|
294
|
+
if (!p || !p.owner || !p.name || !p.id) {
|
|
295
|
+
throw new Error(`PRs file entry is missing owner/name/id: ${JSON.stringify(p)}`);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
const forgeFor = (p) => deps.forgeFactory({ ...(ws.workspace?.forge ?? {}), repo: `${p.owner}/${p.name}` });
|
|
299
|
+
|
|
300
|
+
// State first, so a re-run knows what an earlier run already finished.
|
|
301
|
+
// A PR the forge reports MERGED is done; anything else is offered to
|
|
302
|
+
// prMerge, which reports its own failure if the PR cannot merge. If the
|
|
303
|
+
// view itself fails, fall through to the merge attempt — that path
|
|
304
|
+
// produces the honest error when something is genuinely wrong.
|
|
305
|
+
const alreadyMerged = new Set();
|
|
306
|
+
for (const p of prs) {
|
|
307
|
+
try {
|
|
308
|
+
const view = await forgeFor(p).prView({ id: p.id });
|
|
309
|
+
if (view?.state === 'MERGED') alreadyMerged.add(p.id);
|
|
310
|
+
} catch { /* state unknown — the merge attempt below decides */ }
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const merged = [];
|
|
314
|
+
const failures = [];
|
|
315
|
+
for (const p of prs.filter((x) => !x.isWorkspace)) {
|
|
316
|
+
if (alreadyMerged.has(p.id)) { merged.push(p); continue; }
|
|
317
|
+
try {
|
|
318
|
+
await forgeFor(p).prMerge({ id: p.id, strategy: 'squash', deleteBranch: true });
|
|
319
|
+
merged.push(p);
|
|
320
|
+
} catch (err) { failures.push(`${p.repo}: ${err.message}`); }
|
|
321
|
+
}
|
|
322
|
+
const workspacePr = prs.find((x) => x.isWorkspace) ?? null;
|
|
323
|
+
if (failures.length === 0 && workspacePr) {
|
|
324
|
+
if (alreadyMerged.has(workspacePr.id)) {
|
|
325
|
+
merged.push(workspacePr);
|
|
326
|
+
} else {
|
|
327
|
+
try {
|
|
328
|
+
await forgeFor(workspacePr).prMerge({ id: workspacePr.id, strategy: 'squash', deleteBranch: true });
|
|
329
|
+
merged.push(workspacePr);
|
|
330
|
+
} catch (err) { failures.push(`${workspacePr.repo}: ${err.message}`); }
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
if (failures.length > 0) {
|
|
334
|
+
const open = prs.filter((p) => !merged.includes(p)).map((p) => `${p.repo}: ${p.url}`);
|
|
335
|
+
throw new Error(
|
|
336
|
+
`merge stopped (${failures.join('; ')}). PRs still open: ${open.join(', ')}. The workspace PR is never merged ahead of the project PRs — fix the failure and re-run --merge.`,
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// Every PR in the file is merged from here on. The launcher pull is a
|
|
341
|
+
// convenience, not a gate: it happens only when the launcher sits on the
|
|
342
|
+
// workspace default branch, a failure is a reported flag rather than an
|
|
343
|
+
// error (the merges stand and the issue still closes), and the user is
|
|
344
|
+
// told to pull by hand before teardown.
|
|
345
|
+
const result = { merged: merged.map(({ repo, number, url }) => ({ repo, number, url })), closed: null };
|
|
346
|
+
const launcherBranch = gitOut(deps.gitFn, rootDir, ['branch', '--show-current']) || '(detached HEAD)';
|
|
347
|
+
if (launcherBranch !== defaultBranchFor(rootDir, WORKSPACE_REPO, deps.gitFn)) {
|
|
348
|
+
result.pullSkipped = `launcher on ${launcherBranch}`;
|
|
349
|
+
} else {
|
|
350
|
+
const pull = gitCheck(deps.gitFn, rootDir, ['pull', '--ff-only']);
|
|
351
|
+
if (pull.status !== 0) result.pullFailed = true;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
if (args.workItem) {
|
|
355
|
+
const tracker = deps.trackerFactory(ws.workspace?.tracker);
|
|
356
|
+
await tracker.closeIssue(args.workItem, { comment: `Merged: ${merged.map((p) => p.url).join(' ')}` });
|
|
357
|
+
result.closed = args.workItem;
|
|
358
|
+
}
|
|
359
|
+
return result;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
const MODE_FLAGS = new Set(['--create', '--merge']);
|
|
363
|
+
const VALUE_FLAGS = new Set(['--root', '--branch', '--work-item', '--chat', '--prs', '--out']);
|
|
364
|
+
|
|
365
|
+
function parseArgs(argv) {
|
|
366
|
+
const args = {
|
|
367
|
+
root: '.', mode: null, branch: null, workItem: null, chat: null,
|
|
368
|
+
repos: [], bodyFiles: new Map(), prs: null, out: null, forceWithLease: false,
|
|
369
|
+
};
|
|
370
|
+
const rest = argv.slice(2);
|
|
371
|
+
const value = (i, flag) => {
|
|
372
|
+
const v = rest[i + 1];
|
|
373
|
+
if (v === undefined || v.startsWith('--')) throw new Error(`${flag} requires a value`);
|
|
374
|
+
return v;
|
|
375
|
+
};
|
|
376
|
+
for (let i = 0; i < rest.length; i += 1) {
|
|
377
|
+
const a = rest[i];
|
|
378
|
+
if (MODE_FLAGS.has(a)) {
|
|
379
|
+
if (args.mode) throw new Error(`only one of --create, --merge may be given (already have --${args.mode})`);
|
|
380
|
+
args.mode = a.slice(2);
|
|
381
|
+
continue;
|
|
382
|
+
}
|
|
383
|
+
if (a === '--repo') { args.repos.push(value(i, a)); i += 1; continue; }
|
|
384
|
+
if (a === '--body-file') {
|
|
385
|
+
const v = value(i, a);
|
|
386
|
+
const eq = v.indexOf('=');
|
|
387
|
+
if (eq <= 0) throw new Error(`--body-file expects <repo>=<path>, got: ${v}`);
|
|
388
|
+
args.bodyFiles.set(v.slice(0, eq), v.slice(eq + 1));
|
|
389
|
+
i += 1;
|
|
390
|
+
continue;
|
|
391
|
+
}
|
|
392
|
+
if (a === '--force-with-lease') { args.forceWithLease = true; continue; }
|
|
393
|
+
if (VALUE_FLAGS.has(a)) {
|
|
394
|
+
args[a.slice(2).replace(/-([a-z])/g, (_, c) => c.toUpperCase())] = value(i, a);
|
|
395
|
+
i += 1;
|
|
396
|
+
continue;
|
|
397
|
+
}
|
|
398
|
+
throw new Error(`unknown argument: ${a}`);
|
|
399
|
+
}
|
|
400
|
+
if (!args.mode) throw new Error('one of --create, --merge is required');
|
|
401
|
+
if (args.mode === 'create') {
|
|
402
|
+
if (!args.branch) throw new Error('--create requires --branch');
|
|
403
|
+
if (!args.chat && args.repos.length === 0) throw new Error('--create requires --chat or at least one --repo');
|
|
404
|
+
if (args.prs) throw new Error('--prs is only valid with --merge');
|
|
405
|
+
} else {
|
|
406
|
+
if (!args.prs) throw new Error('--merge requires --prs');
|
|
407
|
+
for (const flag of ['branch', 'chat']) {
|
|
408
|
+
if (args[flag]) throw new Error(`--${flag} is only valid with --create`);
|
|
409
|
+
}
|
|
410
|
+
if (args.repos.length > 0) throw new Error('--repo is only valid with --create');
|
|
411
|
+
if (args.bodyFiles.size > 0) throw new Error('--body-file is only valid with --create');
|
|
412
|
+
if (args.forceWithLease) throw new Error('--force-with-lease is only valid with --create');
|
|
413
|
+
if (args.out) throw new Error('--out is only valid with --create');
|
|
414
|
+
}
|
|
415
|
+
return args;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Run one task-pr command. `argv` is a process.argv-shaped array; `deps`
|
|
420
|
+
* is injectable so tests can fake git, the forge, and the tracker:
|
|
421
|
+
* { gitFn, forgeFactory, trackerFactory }
|
|
422
|
+
* Resolves with the command's JSON result; throws on any failure.
|
|
423
|
+
*/
|
|
424
|
+
async function run(argv, deps = {}) {
|
|
425
|
+
const resolved = {
|
|
426
|
+
gitFn: spawnSync,
|
|
427
|
+
forgeFactory: createForge,
|
|
428
|
+
trackerFactory: createTracker,
|
|
429
|
+
...deps,
|
|
430
|
+
};
|
|
431
|
+
const args = parseArgs(argv);
|
|
432
|
+
return args.mode === 'create' ? createPrs(args, resolved) : mergePrs(args, resolved);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
async function main() {
|
|
436
|
+
const out = await run(process.argv);
|
|
437
|
+
process.stdout.write(`${JSON.stringify(out, null, 2)}\n`);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
if (isMainModule(import.meta.url)) {
|
|
441
|
+
main().catch((err) => {
|
|
442
|
+
process.stderr.write(`task-pr: ${err.message}\n`);
|
|
443
|
+
process.exit(2);
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
export { run, parseArgs, parseForgeRemote };
|
|
@@ -107,6 +107,16 @@ export function createGithubAdapter(config, { spawnFn = nodeSpawnSync } = {}) {
|
|
|
107
107
|
gh(['issue', 'comment', String(num), '--repo', repo, '--body-file', '-'], { input: body });
|
|
108
108
|
}
|
|
109
109
|
|
|
110
|
+
// A closing reference for PR bodies: `#N` closes an issue in the PR's own
|
|
111
|
+
// repo, `owner/repo#N` reaches one living elsewhere. GitHub only honors a
|
|
112
|
+
// bare `#N` when the PR and the issue share a repo, so callers pass the
|
|
113
|
+
// PR's repo as fromRepo and this decides which shape closes the right
|
|
114
|
+
// issue. No `fromRepo` means the PR is in this adapter's own repo.
|
|
115
|
+
function issueRef(issueId, { fromRepo } = {}) {
|
|
116
|
+
const num = parseIssueNumber(issueId);
|
|
117
|
+
return fromRepo && fromRepo !== repo ? `${repo}#${num}` : `#${num}`;
|
|
118
|
+
}
|
|
119
|
+
|
|
110
120
|
async function closeIssue(issueId, { comment: commentBody } = {}) {
|
|
111
121
|
const num = parseIssueNumber(issueId);
|
|
112
122
|
if (commentBody) {
|
|
@@ -142,6 +152,7 @@ export function createGithubAdapter(config, { spawnFn = nodeSpawnSync } = {}) {
|
|
|
142
152
|
createIssue,
|
|
143
153
|
comment,
|
|
144
154
|
closeIssue,
|
|
155
|
+
issueRef,
|
|
145
156
|
ensureLabels,
|
|
146
157
|
ensureMilestone,
|
|
147
158
|
get identity() { return `github-issues:${repo}`; },
|
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
// Tracker adapter interface. Skills import only from this module.
|
|
2
2
|
// See design-tracker-abstraction.md for the full Issue shape and method contracts.
|
|
3
|
+
//
|
|
4
|
+
// Method contract added alongside task-pr.mjs (gh:163):
|
|
5
|
+
//
|
|
6
|
+
// issueRef(issueId, { fromRepo })
|
|
7
|
+
// → the closing reference for a PR body: "#N" when fromRepo is (or
|
|
8
|
+
// defaults to) this adapter's own repo, "owner/repo#N" otherwise —
|
|
9
|
+
// only the first form closes an issue in the PR's own repo, so a PR
|
|
10
|
+
// in any other repo must name the tracker's repo explicitly.
|
|
3
11
|
|
|
4
12
|
import '../../lib/require-node.mjs';
|
|
5
13
|
import { createGithubAdapter } from './github-issues.mjs';
|
|
@@ -30,6 +30,7 @@ Under the task model — `workspace.sessionModel` is `"task"` in `workspace.json
|
|
|
30
30
|
|
|
31
31
|
- Default behavior: write `braindump_{topic}.md` directly into that chat's drawer at `workspace-scratchpad/chats/{chat}/` — the drawer sits outside `workspace-context/`, so `capture-context.mjs` is not involved
|
|
32
32
|
- No commit for drawer writes: the drawer is gitignored and machine-local; `/complete-work` lists it and asks what to promote into `workspace-context/`
|
|
33
|
+
- While a task is active, add `workItem: {id}` to the file's frontmatter: `/complete-work` offers a drawer item only to the task that owns it, so the tag keeps this capture out of another task's promotion list
|
|
33
34
|
|
|
34
35
|
When called from the workspace root with no active session — every other case, including a `sessionModel: "session"` workspace (the `Chat record:` line is injected in every chat, so it alone does not select the drawer):
|
|
35
36
|
|
|
@@ -20,7 +20,7 @@ node "{launcher-root}/.claude/scripts/task-worktree.mjs" --root "{launcher-root}
|
|
|
20
20
|
```
|
|
21
21
|
|
|
22
22
|
- `{launcher-root}` is the absolute path on the `Workspace root:` line the SessionStart hook injects. If that line is absent, derive it from git: run `git rev-parse --git-common-dir` (when it prints a relative path, resolve it against the cwd) and take its parent directory. That derivation lands on the source clone `…/repos/{repo}` when run from inside a **project** task worktree — there the launcher is two levels up; from inside a `.` worktree (`.claude/worktrees/{slug}`) the parent already is the launcher.
|
|
23
|
-
- `{chat}` is the name from the `Chat record:` line the SessionStart hook injects. If that line is absent, omit `--chat` — detection then relies on cwd alone.
|
|
23
|
+
- `{chat}` is the name from the `Chat record:` line the SessionStart hook injects. If that line is absent, run `node .claude/scripts/chat-record.mjs --whoami --root "{launcher-root}"` first — compaction can drop the hook line, and this recovers the name by matching the chat's session id against the records. When that too prints nothing (exit 1), omit `--chat` — detection then relies on cwd alone.
|
|
24
24
|
- `model: session` → continue with this flow (read the session tracker as below), taking `{session-name}` from the detect result's `sessionName`.
|
|
25
25
|
- `model: task` → go to **Task completion (session model v2)**. The result's `tasks` come from the chat record; if several are open, ask the user which one to complete — group by branch, a multi-repo task is several entries sharing a branch.
|
|
26
26
|
- `model: none` → "No active work session. Nothing to complete."
|
|
@@ -379,7 +379,7 @@ If several tasks are open, ask the user which one to complete — group by branc
|
|
|
379
379
|
|
|
380
380
|
1. **Rebase each task worktree onto `origin/{defaultBranch}`** (`git -C "{worktree}" fetch origin`, then `git -C "{worktree}" rebase "origin/{defaultBranch}"`). Freshness first: the PR in step 3 must describe the branch as it will merge. If conflicts arise, STOP and present them — do not auto-resolve.
|
|
381
381
|
|
|
382
|
-
2. **Route durable thinking.** List
|
|
382
|
+
2. **Route durable thinking.** List the chat drawer `{launcher-root}/workspace-scratchpad/chats/{chat}/` and ask which items should graduate into `workspace-context/`. The offer is filtered by task: an item whose frontmatter carries a `workItem:` is listed only when it names this task's `{workItem}`, an item with no `workItem:` is always listed, and an item tagged for another open task is not this completion's to promote — it belongs to that task's completion. Leftovers of an earlier completion attempt are never offered: the `pr-{slug}-*.md` bodies and the `prs-{slug}.json` state file are step 3–4 machinery, not thinking. Drafting skills (`/braindump`, `/handoff`, designs and plans) tag their drawer writes with `workItem:` while a task is active so this filter has something to filter on. If nothing is chosen, skip this step — no `.` worktree is needed yet. Do NOT invoke `/promote`: it writes relative to cwd and commits per item, which at the launcher lands on the default branch — exactly the hole this flow closes, and it does not know about drawer items. For the chosen items, first create the workspace worktree on the task's branch and record it alongside the project entries:
|
|
383
383
|
|
|
384
384
|
```bash
|
|
385
385
|
node "{launcher-root}/.claude/scripts/task-worktree.mjs" --root "{launcher-root}" --create --repo "." --branch "{branch}"
|
|
@@ -396,58 +396,18 @@ If several tasks are open, ask the user which one to complete — group by branc
|
|
|
396
396
|
|
|
397
397
|
The drawer is per-chat, so it survives task teardown — but it is machine-local and backed up nowhere, and this review, with the work fresh in mind, is the moment to decide what graduates. Items left behind are not lost, only unreviewed.
|
|
398
398
|
|
|
399
|
-
3. **
|
|
399
|
+
3. **Write one PR body per repo into the drawer, then create every PR with `task-pr.mjs --create`** — never `gh pr` directly. For each repo of the task, `{worktree}` included when it exists, write `{launcher-root}/workspace-scratchpad/chats/{chat}/pr-{slug}-{repo}.md` (with `{repo}` rendered as `workspace` for `.`). Each body carries a short summary of what changed and why, then a `## Verification` section stating how the change was checked — the commands run and their results. The drawer is machine-local and gitignored, so these files never touch a branch, and `--create` requires one for every repo whose branch has commits. Then one command does the rest:
|
|
400
400
|
|
|
401
401
|
```bash
|
|
402
|
-
|
|
402
|
+
node "{launcher-root}/.claude/scripts/task-pr.mjs" --create --root "{launcher-root}" --branch "{branch}" \
|
|
403
|
+
--work-item "{workItem}" --chat "{chat}" \
|
|
404
|
+
--body-file "{repo}={launcher-root}/workspace-scratchpad/chats/{chat}/pr-{slug}-{repo}.md" \
|
|
405
|
+
--out "{launcher-root}/workspace-scratchpad/chats/{chat}/prs-{slug}.json"
|
|
403
406
|
```
|
|
404
407
|
|
|
405
|
-
|
|
408
|
+
Omit `--work-item` when the task has none, and repeat `--body-file` once per repo. `--chat` resolves the task's repos from this chat's record entries for the branch; when detection came from cwd alone, pass `--repo "{repo}"` once per repo instead. The script skips repos whose branch has no commits over `origin/{defaultBranch}` (reporting them as `empty` — no push, no PR, no body file needed, torn down in step 5 like any other; a workspace branch that collected no promotions gets no PR), parses each worktree's own origin into `{owner}/{name}` and stops **before pushing anything** if any origin is not forge-hosted — such a repo is completed under the session model — then pushes `-u origin {branch}` and opens one PR per remaining repo through a per-repo forge aimed at that worktree's own remote, never the launcher's. The workspace repo (`.`) is handled exactly like a project repo. The PR title is the linked issue's title, or the branch's first commit subject without a `{workItem}`; the body is the drawer file with `Closes <ref>` appended, where `<ref>` is `#N` when the PR's repo is the tracker's repo and `{tracker-repo}#N` otherwise — only the first form closes an issue in the PR's own repo. If the push is rejected as non-fast-forward — the branch already existed on origin and step 1's rebase rewrote it — the script stops and says so; ask the user, and only on explicit confirmation re-run the same command with `--force-with-lease` (the script never forces on its own). If `workspace.forge` is `false` in `workspace.json`, the script refuses before pushing anything: forge operations are disabled in this workspace and the PR is opened by hand. `--out` writes the run's JSON to `prs-{slug}.json` for step 4 — `{ prs: [{ repo, owner, name, number, id, url, isWorkspace }], empty: [repo…], pushed: [repo…] }`, printed to stdout as well — instead of shell redirection, so the command is identical on every shell. The run is idempotent: a repo whose branch already has an open PR against `{defaultBranch}` gets that PR reused, never duplicated, and if the run fails partway the file still records what was pushed and opened so far — fix the cause and re-run the same command; it completes the set.
|
|
406
409
|
|
|
407
|
-
|
|
408
|
-
git -C "{worktree}" remote get-url origin # → parse {owner}/{name} FIRST
|
|
409
|
-
```
|
|
410
|
-
|
|
411
|
-
If the URL does not parse into `{owner}/{name}` (a local/bare remote) or is a forge the adapter does not support, STOP before pushing anything: the task path supports forge-hosted repos only in this stage — complete that repo under the session model.
|
|
412
|
-
|
|
413
|
-
```bash
|
|
414
|
-
git -C "{worktree}" push -u origin "{branch}"
|
|
415
|
-
```
|
|
416
|
-
|
|
417
|
-
If the push is rejected as non-fast-forward — the branch already existed on origin and step 1 rebased it — ask the user before retrying with `git -C "{worktree}" push --force-with-lease`. Never force without asking.
|
|
418
|
-
|
|
419
|
-
If `workspace.forge` is `false` in `workspace.json`, STOP here: forge operations are disabled in this workspace — the push above is done, the PR is opened by hand.
|
|
420
|
-
|
|
421
|
-
```javascript
|
|
422
|
-
// Run from {launcher-root} (see above). Imports stay relative: an absolute
|
|
423
|
-
// path is not a valid ESM specifier on Windows.
|
|
424
|
-
import { createForge } from './.claude/scripts/forges/interface.mjs';
|
|
425
|
-
import { createTracker } from './.claude/scripts/trackers/interface.mjs';
|
|
426
|
-
import { readFileSync } from 'node:fs';
|
|
427
|
-
const ws = JSON.parse(readFileSync('{launcher-root}/workspace.json', 'utf-8'));
|
|
428
|
-
|
|
429
|
-
// Constructed per repo, so the adapter never resolves the launcher's own remote.
|
|
430
|
-
const forge = createForge({ ...ws.workspace?.forge, repo: '{owner}/{name}' });
|
|
431
|
-
// For the workspace repo (.): from the workspace worktree's own origin.
|
|
432
|
-
const wsForge = createForge({ ...ws.workspace?.forge, repo: '{ws-owner}/{ws-name}' });
|
|
433
|
-
|
|
434
|
-
// PR title: the linked issue's title — tracker.getIssue(workItem).title —
|
|
435
|
-
// or, with no workItem, the subject of the branch's first commit beyond
|
|
436
|
-
// the base: git log "origin/{defaultBranch}..HEAD" --reverse --format=%s
|
|
437
|
-
// (run in "{worktree}").
|
|
438
|
-
const title = workItem
|
|
439
|
-
? (await createTracker(ws.workspace.tracker).getIssue(workItem)).title
|
|
440
|
-
: firstCommitSubject;
|
|
441
|
-
|
|
442
|
-
// PR body: one line per commit from
|
|
443
|
-
// git -C "{worktree}" log "origin/{defaultBranch}..HEAD" --oneline,
|
|
444
|
-
// then a blank line and `Closes {workItem}` when a workItem exists.
|
|
445
|
-
const pr = await forge.prCreate({ title, body, head: '{branch}', base: '{defaultBranch}' });
|
|
446
|
-
// The workspace PR, when step 3 did not skip "." as empty:
|
|
447
|
-
const wsPr = await wsForge.prCreate({ title: `context: {branch} task`, body: workspacePrBody, head: '{branch}', base: '{defaultBranch}' });
|
|
448
|
-
```
|
|
449
|
-
|
|
450
|
-
4. **Ask before merging, then merge, then close the linked issue.** Present a summary per repo that got a PR — the workspace repo included — and ask once:
|
|
410
|
+
4. **Ask before merging, then run `task-pr.mjs --merge`, which also closes the linked issue.** When step 3 reported `prs: []` — every repo was empty — there is nothing to merge: skip this step, say so, and leave the linked issue open for the user to decide (close it by hand, or keep the task going); `--merge` itself refuses an empty PRs file for exactly that reason. Otherwise present a summary per repo that got a PR — the workspace repo included — built from `--create`'s output, and ask once:
|
|
451
411
|
|
|
452
412
|
```
|
|
453
413
|
Task complete:
|
|
@@ -466,30 +426,14 @@ If several tasks are open, ask the user which one to complete — group by branc
|
|
|
466
426
|
|
|
467
427
|
On "n", stop: the PRs stay open and the worktrees, branches, and record entries stay in place — say so.
|
|
468
428
|
|
|
469
|
-
On "y"
|
|
470
|
-
|
|
471
|
-
```javascript
|
|
472
|
-
for (const pr of projectPrs) {
|
|
473
|
-
await forge.prMerge({ id: pr.id, strategy: 'squash', deleteBranch: true });
|
|
474
|
-
}
|
|
475
|
-
if (wsPr) await wsForge.prMerge({ id: wsPr.id, strategy: 'squash', deleteBranch: true }); // workspace PR, last
|
|
476
|
-
```
|
|
477
|
-
|
|
478
|
-
Pull the launcher only after the workspace PR actually merged — it is still on its default branch, waiting on that merge; when `.` had no PR, pull after the project merges instead:
|
|
429
|
+
On "y":
|
|
479
430
|
|
|
480
431
|
```bash
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
Then close the linked issue — only if a `{workItem}` exists — with a one-line comment naming the merged PR URL(s):
|
|
485
|
-
|
|
486
|
-
```javascript
|
|
487
|
-
const tracker = createTracker(ws.workspace.tracker);
|
|
488
|
-
const comment = `Merged: ${prUrls.join(' ')}`; // prUrls = the .url of each PR merged above
|
|
489
|
-
await tracker.closeIssue(workItem, { comment });
|
|
432
|
+
node "{launcher-root}/.claude/scripts/task-pr.mjs" --merge --root "{launcher-root}" \
|
|
433
|
+
--prs "{launcher-root}/workspace-scratchpad/chats/{chat}/prs-{slug}.json" --work-item "{workItem}"
|
|
490
434
|
```
|
|
491
435
|
|
|
492
|
-
|
|
436
|
+
The script checks each PR's state first, then merges the project PRs (squash, delete branch), then the workspace PR only when every project merge succeeded — the workspace branch's promoted context describes the project merges and must never merge ahead of them. A PR the forge already reports as merged counts as done and is never merged twice, which is what makes a re-run safe: the run that finally gets every PR merged also does the pull and the close. The launcher is pulled `--ff-only` only when it sits on the workspace default branch — it waited there on the workspace merge, and a project-only task pulls after the project merges; when it sits on some other branch the JSON reports `pullSkipped: "launcher on <branch>"`, and a failed pull is reported as `pullFailed: true` rather than an error, because the merges stand and the issue still closes — in both cases tell the user to pull the launcher by hand before step 5. The close comes last, with a one-line comment naming the merged PR URL(s): merge precedes close because an issue closed before its PR merges points at work that never landed; without a `{workItem}` (no tracker, or the task was never recorded) the close is skipped and the script says so. On any merge failure the script stops, names the PRs still open, and exits non-zero — nothing is torn down, and re-running `--merge` once the failure is fixed is safe. After a successful merge, delete the drawer's `pr-{slug}-*.md` body files and the spent `prs-{slug}.json`.
|
|
493
437
|
|
|
494
438
|
5. **Tear down only what finished — worktree first, then the record entry**, and only for a repo whose PR merged in step 4, or whose branch was empty and never pushed in step 3; the workspace repo included (`--repo "."`). If a repo's push, PR, or merge failed, or the user declined the merge, leave that repo's worktree, branch, and record entry exactly in place and say so: `--delete-branch` would otherwise `branch -D` commits that exist nowhere but the local worktree. For `.` the script never deletes the branch checked out at the launcher root.
|
|
495
439
|
|