@ulysses-ai/create-workspace 0.23.3-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/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 +221 -2
- 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/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
|
@@ -16,7 +16,7 @@ import {
|
|
|
16
16
|
MergeRejected,
|
|
17
17
|
} from './interface.mjs';
|
|
18
18
|
|
|
19
|
-
const PR_VIEW_FIELDS = 'number,url,state,title,mergeable,mergeStateStatus,reviewDecision,headRefName,baseRefName,isDraft,mergedAt';
|
|
19
|
+
const PR_VIEW_FIELDS = 'number,url,state,title,mergeable,mergeStateStatus,reviewDecision,headRefName,baseRefName,isDraft,mergedAt,createdAt';
|
|
20
20
|
|
|
21
21
|
export function createGithubAdapter(config, { spawnFn = nodeSpawnSync } = {}) {
|
|
22
22
|
const defaultRepo = resolveRepo(config, spawnFn);
|
|
@@ -106,6 +106,7 @@ export function createGithubAdapter(config, { spawnFn = nodeSpawnSync } = {}) {
|
|
|
106
106
|
baseRefName: raw.baseRefName,
|
|
107
107
|
isDraft: raw.isDraft,
|
|
108
108
|
mergedAt: raw.mergedAt,
|
|
109
|
+
createdAt: raw.createdAt,
|
|
109
110
|
_raw: raw,
|
|
110
111
|
};
|
|
111
112
|
}
|
|
@@ -139,6 +140,54 @@ export function createGithubAdapter(config, { spawnFn = nodeSpawnSync } = {}) {
|
|
|
139
140
|
}));
|
|
140
141
|
}
|
|
141
142
|
|
|
143
|
+
// The PR's CI picture, from `gh pr checks` (which categorizes each check
|
|
144
|
+
// row into a `bucket`: pass, fail, pending, skipping, cancel — the
|
|
145
|
+
// aggregate state derives from those, not from `state`, whose vocabulary
|
|
146
|
+
// is wider and matters less). gh exits non-zero when any check fails or
|
|
147
|
+
// is pending, so the exit status carries nothing the JSON does not. A PR
|
|
148
|
+
// with no CI at all prints no JSON: gh 2.89 exits 1 with "no checks
|
|
149
|
+
// reported on the '<branch>' branch" on stderr — that absence is the
|
|
150
|
+
// answer, 'none', while any other prose (auth, network, not-found) is a
|
|
151
|
+
// real error. A cancelled check never passed and reads as failing; a
|
|
152
|
+
// skipped one is an expected matrix leg and counts as a pass.
|
|
153
|
+
async function prChecks({ id, repo }) {
|
|
154
|
+
if (!id) throw new Error('prChecks: id is required');
|
|
155
|
+
const { number, repo: parsedRepo } = parsePrId(id, repoFor(repo));
|
|
156
|
+
const result = gh(['pr', 'checks', String(number), '--repo', parsedRepo, '--json', 'name,state,bucket,link']);
|
|
157
|
+
let rows;
|
|
158
|
+
try {
|
|
159
|
+
rows = JSON.parse(result.stdout || '');
|
|
160
|
+
} catch {
|
|
161
|
+
const stderr = (result.stderr || result.stdout || '').trim();
|
|
162
|
+
if (/not\s+found|could\s+not\s+resolve/i.test(stderr)) {
|
|
163
|
+
throw new PrNotFound(id);
|
|
164
|
+
}
|
|
165
|
+
if (/no checks reported/i.test(stderr)) {
|
|
166
|
+
return {
|
|
167
|
+
state: 'none',
|
|
168
|
+
url: `https://github.com/${parsedRepo}/pull/${number}/checks`,
|
|
169
|
+
failing: [],
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
throw new Error(`gh pr checks ${number} failed: ${stderr}`);
|
|
173
|
+
}
|
|
174
|
+
if (!Array.isArray(rows)) {
|
|
175
|
+
throw new Error(`gh pr checks ${number} returned unparseable output: ${result.stdout}`);
|
|
176
|
+
}
|
|
177
|
+
const failing = rows
|
|
178
|
+
.filter((c) => c.bucket === 'fail' || c.bucket === 'cancel')
|
|
179
|
+
.map((c) => ({ name: c.name, url: c.link }));
|
|
180
|
+
const state = failing.length > 0 ? 'failure'
|
|
181
|
+
: rows.some((c) => c.bucket === 'pending') ? 'pending'
|
|
182
|
+
: rows.length === 0 ? 'none'
|
|
183
|
+
: 'success';
|
|
184
|
+
return {
|
|
185
|
+
state,
|
|
186
|
+
url: `https://github.com/${parsedRepo}/pull/${number}/checks`,
|
|
187
|
+
failing,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
142
191
|
async function releaseView({ tag, repo }) {
|
|
143
192
|
if (!tag) throw new Error('releaseView: tag is required');
|
|
144
193
|
const target = repoFor(repo);
|
|
@@ -226,6 +275,7 @@ export function createGithubAdapter(config, { spawnFn = nodeSpawnSync } = {}) {
|
|
|
226
275
|
prMerge,
|
|
227
276
|
prView,
|
|
228
277
|
prList,
|
|
278
|
+
prChecks,
|
|
229
279
|
releaseView,
|
|
230
280
|
releaseCreate,
|
|
231
281
|
workflowRunFind,
|
|
@@ -27,6 +27,12 @@
|
|
|
27
27
|
// Watching polls the pipeline until it reaches a status that cannot
|
|
28
28
|
// change on its own — a `manual` job keeps polling, since approving it
|
|
29
29
|
// resumes the pipeline.
|
|
30
|
+
// - `prChecks` reads the MR's `head_pipeline` through `glab api`. There
|
|
31
|
+
// is no `glab mr checks` (the command does not exist — it prints help
|
|
32
|
+
// and exits 0, a trap that would read as "no checks"), and commit
|
|
33
|
+
// statuses would mix external CI with pipeline jobs; head_pipeline is
|
|
34
|
+
// the pipeline for the MR's current head, which is exactly the CI the
|
|
35
|
+
// merge gates on. A `manual` pipeline reads as pending with a note.
|
|
30
36
|
// - `releaseCreate` has no forge-generated-notes equivalent on GitLab:
|
|
31
37
|
// `generateNotes: true` throws NOT_SUPPORTED — callers pass
|
|
32
38
|
// `generateNotes: false` plus explicit `notes`.
|
|
@@ -191,6 +197,7 @@ export function createGitlabAdapter(config, { spawnFn = nodeSpawnSync, pollMs =
|
|
|
191
197
|
baseRefName: raw.target_branch,
|
|
192
198
|
isDraft: !!raw.draft,
|
|
193
199
|
mergedAt: raw.merged_at,
|
|
200
|
+
createdAt: raw.created_at,
|
|
194
201
|
_raw: raw,
|
|
195
202
|
};
|
|
196
203
|
}
|
|
@@ -260,6 +267,67 @@ export function createGitlabAdapter(config, { spawnFn = nodeSpawnSync, pollMs =
|
|
|
260
267
|
return list;
|
|
261
268
|
}
|
|
262
269
|
|
|
270
|
+
// `glab api` prints the entity on success and prose on failure — a
|
|
271
|
+
// parse error means the call did not get what it asked for.
|
|
272
|
+
function parseApi(path, stdout) {
|
|
273
|
+
try {
|
|
274
|
+
return JSON.parse(String(stdout || '').trim() || 'null');
|
|
275
|
+
} catch (err) {
|
|
276
|
+
throw new Error(`glab api ${path} returned unparseable output: ${err.message}`);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// The MR's CI picture (gh:201). No `glab mr checks` exists and commit
|
|
281
|
+
// statuses would mix external CI with pipeline jobs (see the header
|
|
282
|
+
// notes), so the MR entity's head_pipeline is the source: the pipeline
|
|
283
|
+
// for the MR's current head. No pipeline → 'none'. `skipped` counts as
|
|
284
|
+
// success (an expected skip satisfies CI, like GitHub's skipping
|
|
285
|
+
// bucket); `failed`/`canceled` read the pipeline's failed jobs (a full
|
|
286
|
+
// page of them, per_page=100) so the failure names what broke — minus
|
|
287
|
+
// the allow_failure jobs, which are allowed to fail and so did not break
|
|
288
|
+
// it; everything still moving — running, pending, created, preparing,
|
|
289
|
+
// waiting_for_resource, scheduled — is pending, and `manual` is pending
|
|
290
|
+
// too, with a note: approving the manual job turns the pipeline running
|
|
291
|
+
// again.
|
|
292
|
+
async function prChecks({ id, repo }) {
|
|
293
|
+
if (!id) throw new Error('prChecks: id is required');
|
|
294
|
+
const { number, repo: parsedRepo } = parseMrId(id, repoFor(repo));
|
|
295
|
+
const enc = encodeURIComponent(parsedRepo);
|
|
296
|
+
const mrPath = `projects/${enc}/merge_requests/${number}`;
|
|
297
|
+
const result = glab(apiArgs(mrPath));
|
|
298
|
+
if (result.status !== 0) {
|
|
299
|
+
const stderr = (result.stderr || '').trim();
|
|
300
|
+
if (NOT_FOUND_RE.test(stderr)) {
|
|
301
|
+
throw new PrNotFound(id);
|
|
302
|
+
}
|
|
303
|
+
throw new Error(`glab api ${mrPath} failed: ${stderr}`);
|
|
304
|
+
}
|
|
305
|
+
const mr = parseApi(mrPath, result.stdout) ?? {};
|
|
306
|
+
const pipeline = mr.head_pipeline ?? null;
|
|
307
|
+
if (!pipeline || !pipeline.id) {
|
|
308
|
+
return { state: 'none', url: mr.web_url ?? webUrl(parsedRepo, 'merge_requests', number), failing: [] };
|
|
309
|
+
}
|
|
310
|
+
const url = pipeline.web_url ?? webUrl(parsedRepo, 'pipelines', pipeline.id);
|
|
311
|
+
const status = String(pipeline.status || '').toLowerCase();
|
|
312
|
+
if (status === 'success' || status === 'skipped') {
|
|
313
|
+
return { state: 'success', url, failing: [] };
|
|
314
|
+
}
|
|
315
|
+
if (status === 'failed' || status === 'canceled') {
|
|
316
|
+
const jobsPath = `projects/${enc}/pipelines/${pipeline.id}/jobs?scope[]=failed&per_page=100`;
|
|
317
|
+
const jobs = parseApi(jobsPath, glabOrThrow(apiArgs(jobsPath))) ?? [];
|
|
318
|
+
return {
|
|
319
|
+
state: 'failure',
|
|
320
|
+
url,
|
|
321
|
+
failing: jobs.filter((j) => !j.allow_failure).map((j) => ({ name: j.name, url: j.web_url })),
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
const checks = { state: 'pending', url, failing: [] };
|
|
325
|
+
if (status === 'manual') {
|
|
326
|
+
checks.note = 'pipeline waiting on a manual job — approve it in the pipeline UI';
|
|
327
|
+
}
|
|
328
|
+
return checks;
|
|
329
|
+
}
|
|
330
|
+
|
|
263
331
|
async function releaseView({ tag, repo }) {
|
|
264
332
|
if (!tag) throw new Error('releaseView: tag is required');
|
|
265
333
|
const target = repoFor(repo);
|
|
@@ -353,6 +421,7 @@ export function createGitlabAdapter(config, { spawnFn = nodeSpawnSync, pollMs =
|
|
|
353
421
|
prMerge,
|
|
354
422
|
prView,
|
|
355
423
|
prList,
|
|
424
|
+
prChecks,
|
|
356
425
|
releaseView,
|
|
357
426
|
releaseCreate,
|
|
358
427
|
workflowRunFind,
|
|
@@ -15,8 +15,11 @@
|
|
|
15
15
|
// → { merged: true, url }
|
|
16
16
|
// strategy: 'merge' | 'squash' | 'rebase'
|
|
17
17
|
// prView({ id, repo?, json? })
|
|
18
|
-
// → { id, url, state, mergeable, mergeStateStatus, reviewDecision, title
|
|
19
|
-
//
|
|
18
|
+
// → { id, url, state, mergeable, mergeStateStatus, reviewDecision, title,
|
|
19
|
+
// createdAt }
|
|
20
|
+
// json may name additional fields to pass through. createdAt (an ISO
|
|
21
|
+
// timestamp) is when the PR opened — a caller judging whether a
|
|
22
|
+
// "no checks yet" read is a race needs it.
|
|
20
23
|
// prList({ state = 'merged', base?, head?, search?, limit = 100, repo? })
|
|
21
24
|
// → [{ id, number, title, url, headRefName, baseRefName, mergedAt, state }]
|
|
22
25
|
// `base`/`head` filter by target/source branch (e.g. the open PR for a
|
|
@@ -29,6 +32,20 @@
|
|
|
29
32
|
// older results may exist beyond it the returned array carries a
|
|
30
33
|
// non-enumerable `truncated: true` (callers that only map the list
|
|
31
34
|
// never see it).
|
|
35
|
+
// prChecks({ id, repo? })
|
|
36
|
+
// → { state: 'pending' | 'success' | 'failure' | 'none', url,
|
|
37
|
+
// failing: [{ name, url }], note? }
|
|
38
|
+
// The PR's CI picture: 'success' or 'failure' once its checks finished,
|
|
39
|
+
// 'pending' while they run, 'none' when the PR has no checks at all.
|
|
40
|
+
// `failing` lists the failing check runs (name + url) when the state is
|
|
41
|
+
// 'failure'; `url` points where a human looks — the PR's checks page
|
|
42
|
+
// (GitHub) or the head pipeline (GitLab). A GitLab pipeline on a
|
|
43
|
+
// `manual` job reads as pending with a `note` saying so (approving the
|
|
44
|
+
// job resumes the pipeline), and `skipped` counts as success, matching
|
|
45
|
+
// GitHub's skipping bucket. The read is conservative on both forges:
|
|
46
|
+
// GitHub weighs every check on the PR, required or not, and GitLab's
|
|
47
|
+
// `failing` excludes allow_failure jobs — allowed to fail, they do not
|
|
48
|
+
// fail the pipeline.
|
|
32
49
|
// releaseView({ tag, repo? })
|
|
33
50
|
// → { tag, url, name, publishedAt }
|
|
34
51
|
// throws ReleaseNotFound if the tag has no release
|
|
@@ -16,7 +16,12 @@
|
|
|
16
16
|
// .indexignore excludes, anything under an archive/
|
|
17
17
|
// directory) and closed-out lifecycles are skipped
|
|
18
18
|
// 3. structure — workspace.json and CLAUDE.md present and parseable,
|
|
19
|
-
// manifest repos cloned, expected directories there
|
|
19
|
+
// manifest repos cloned, expected directories there,
|
|
20
|
+
// and the template-modification registry sane:
|
|
21
|
+
// legacy workspace.json keys, keys that escape
|
|
22
|
+
// .claude/, and registrations the workspace abandoned
|
|
23
|
+
// are info, a registry that doesn't parse is a
|
|
24
|
+
// warning (gh:194)
|
|
20
25
|
// 4. git — launcher on its default branch with a clean tracked
|
|
21
26
|
// tree. Audited from a linked worktree (task or
|
|
22
27
|
// session), the launcher-level questions — the
|
|
@@ -29,7 +34,16 @@
|
|
|
29
34
|
// .claude/skills/workspace-update/ modification an
|
|
30
35
|
// --upgrade leaves when its content equals the staged
|
|
31
36
|
// payload's is the expected bootstrap, reported as
|
|
32
|
-
// info, never a dirty-tree warning (gh:190)
|
|
37
|
+
// info, never a dirty-tree warning (gh:190). Leftover
|
|
38
|
+
// Claude Code agent worktrees (worktree-agent-*
|
|
39
|
+
// branches, gh:205) are each listed as info with the
|
|
40
|
+
// facts and a strict removable verdict that fails
|
|
41
|
+
// closed — worktree integrity, clean, own-work
|
|
42
|
+
// analysis against the default branch (merge
|
|
43
|
+
// commits, git-cherry patch-id landing), locked,
|
|
44
|
+
// idle over an hour, chat-record claims (unreadable
|
|
45
|
+
// records block removal) — also exposed as
|
|
46
|
+
// summary.agentWorktrees for --json
|
|
33
47
|
// 5. auto-files — workspace-context catalogs current (the same
|
|
34
48
|
// semantics as build-workspace-context.mjs --check)
|
|
35
49
|
// 6. budget — always-loaded context within
|
|
@@ -86,7 +100,8 @@ import {
|
|
|
86
100
|
readIgnorePrefixes,
|
|
87
101
|
isIgnored,
|
|
88
102
|
} from './build-workspace-context.mjs';
|
|
89
|
-
import { hashBytes } from './template-baseline.mjs';
|
|
103
|
+
import { hashBytes, readBaseline } from './template-baseline.mjs';
|
|
104
|
+
import { readTemplateModifications } from './template-modifications.mjs';
|
|
90
105
|
import { refreshIfStale } from '../lib/freshness.mjs';
|
|
91
106
|
import { parseSessionContent } from '../lib/session-frontmatter.mjs';
|
|
92
107
|
|
|
@@ -279,6 +294,55 @@ export async function runAudit({
|
|
|
279
294
|
}
|
|
280
295
|
}
|
|
281
296
|
|
|
297
|
+
// Template-modification registry (gh:194), still under section 3 — it is
|
|
298
|
+
// workspace structure: which files the workspace owns outright and which
|
|
299
|
+
// template files it deliberately edits, and why. Legacy workspace.json
|
|
300
|
+
// keys awaiting migration, keys that escape .claude/ (the registry covers
|
|
301
|
+
// .claude/ paths only), and registrations the workspace has abandoned are
|
|
302
|
+
// info (all are /workspace-update offers, nothing is broken); a registry
|
|
303
|
+
// that doesn't parse silently disables its exclusions and reasons, which
|
|
304
|
+
// is worth a warning.
|
|
305
|
+
{
|
|
306
|
+
const mods = readTemplateModifications(absRoot);
|
|
307
|
+
if (mods.parseError !== null) {
|
|
308
|
+
add('structure', 'warning', '.claude/template-modifications.json',
|
|
309
|
+
`does not parse (${mods.parseError}) — localFiles exclusions and modification reasons are being ignored; fix the JSON`);
|
|
310
|
+
}
|
|
311
|
+
if (mods.legacyKeys.length > 0) {
|
|
312
|
+
add('structure', 'info', 'workspace.json',
|
|
313
|
+
`legacy template-modification key(s) still present: ${mods.legacyKeys.map((k) => `workspace.${k}`).join(', ')} — /workspace-update offers to migrate them into .claude/template-modifications.json`);
|
|
314
|
+
}
|
|
315
|
+
if (mods.ignoredKeys.length > 0) {
|
|
316
|
+
add('structure', 'info', '.claude/template-modifications.json',
|
|
317
|
+
`key(s) outside .claude/ are ignored: ${mods.ignoredKeys.join(', ')} — the registry covers .claude/ paths only (root files are handled by their own merge paths); drop or fix them`);
|
|
318
|
+
}
|
|
319
|
+
const entries = Object.entries(mods.modifications);
|
|
320
|
+
if (entries.length > 0) {
|
|
321
|
+
const baseline = readBaseline(absRoot);
|
|
322
|
+
if (baseline) {
|
|
323
|
+
for (const [key, reason] of entries) {
|
|
324
|
+
const rel = `.claude/${key}`;
|
|
325
|
+
const baselineHash = baseline.files[rel];
|
|
326
|
+
const installed = join(absRoot, rel);
|
|
327
|
+
// Stale = the installed file is back to exactly what the template
|
|
328
|
+
// last shipped (its baseline hash): the recorded edit is gone. A
|
|
329
|
+
// missing file is NOT stale — a deliberate deletion is a live
|
|
330
|
+
// decision the next update's deletedLocally ask will explain with
|
|
331
|
+
// this very reason — and neither is a key naming a directory:
|
|
332
|
+
// only regular files compare.
|
|
333
|
+
if (typeof baselineHash !== 'string') continue;
|
|
334
|
+
let st;
|
|
335
|
+
try { st = statSync(installed); } catch { continue; }
|
|
336
|
+
if (!st.isFile()) continue;
|
|
337
|
+
if (hashBytes(readFileSync(installed)) === baselineHash) {
|
|
338
|
+
add('structure', 'info', '.claude/template-modifications.json',
|
|
339
|
+
`entry for ${rel} is stale — the file matches the template baseline again, the recorded edit is gone (reason: ${reason}); drop the entry`);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
282
346
|
// ---------- 1. cross-reference ----------
|
|
283
347
|
|
|
284
348
|
const claudeMdPath = join(absRoot, 'CLAUDE.md');
|
|
@@ -464,6 +528,7 @@ export async function runAudit({
|
|
|
464
528
|
|
|
465
529
|
// ---------- 4. git state ----------
|
|
466
530
|
|
|
531
|
+
const agentWorktrees = [];
|
|
467
532
|
if (!gitInfo.isRepo) {
|
|
468
533
|
add('git', 'info', '.', 'not a git repository — git checks skipped');
|
|
469
534
|
} else {
|
|
@@ -529,6 +594,262 @@ export async function runAudit({
|
|
|
529
594
|
if (untracked.length > 0) {
|
|
530
595
|
add('git', 'info', '.', `${untracked.length} untracked path(s) — gitignored content is not counted`);
|
|
531
596
|
}
|
|
597
|
+
|
|
598
|
+
// Claude Code agent worktrees (gh:205): a subagent with worktree
|
|
599
|
+
// isolation gets {repo}/.claude/worktrees/agent-{id}/ on a
|
|
600
|
+
// worktree-agent-{id} branch, in the workspace repo and each project
|
|
601
|
+
// repo. Claude Code removes a clean one when the subagent finishes and
|
|
602
|
+
// sweeps the rest past cleanupPeriodDays — but the sweep keeps any
|
|
603
|
+
// worktree holding work, and a subagent that commits without pushing is
|
|
604
|
+
// exactly that, indefinitely. Each leftover is one info finding plus a
|
|
605
|
+
// summary.agentWorktrees entry, and the script decides removal: facts
|
|
606
|
+
// carry a strict removable verdict with the reasons behind it, and
|
|
607
|
+
// cleanup offers removal only for removable: true — safety rests on the
|
|
608
|
+
// script, not on prose re-deriving the facts. The branch prefix and the
|
|
609
|
+
// path shape together keep task worktrees (feature/bugfix/chore
|
|
610
|
+
// branches) and hand-added worktrees out of the list.
|
|
611
|
+
const gitIn = (dir, args) => spawnSync('git', ['-C', dir, ...args], { encoding: 'utf8' });
|
|
612
|
+
const inAgentSlot = (p) => {
|
|
613
|
+
const parts = p.split(sep).join('/').split('/');
|
|
614
|
+
for (let i = 1; i < parts.length - 1; i += 1) {
|
|
615
|
+
if (parts[i] === 'worktrees' && parts[i - 1] === '.claude' && parts[i + 1].startsWith('agent-')) {
|
|
616
|
+
return true;
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
return false;
|
|
620
|
+
};
|
|
621
|
+
const agentEntries = (repoDir) => {
|
|
622
|
+
const res = gitIn(repoDir, ['worktree', 'list', '--porcelain']);
|
|
623
|
+
if (res.status !== 0) return [];
|
|
624
|
+
const out = [];
|
|
625
|
+
let cur = null;
|
|
626
|
+
for (const line of String(res.stdout).split(/\r?\n/)) {
|
|
627
|
+
if (!line) { cur = null; continue; }
|
|
628
|
+
if (line.startsWith('worktree ')) {
|
|
629
|
+
cur = { path: line.slice('worktree '.length), branch: null, locked: false, lockReason: null, prunable: false };
|
|
630
|
+
out.push(cur);
|
|
631
|
+
} else if (cur && line.startsWith('branch ')) {
|
|
632
|
+
cur.branch = line.slice('branch '.length).replace(/^refs\/heads\//, '');
|
|
633
|
+
} else if (cur && line.startsWith('locked')) {
|
|
634
|
+
cur.locked = true;
|
|
635
|
+
cur.lockReason = line.slice('locked'.length).trim() || null;
|
|
636
|
+
} else if (cur && line.startsWith('prunable')) {
|
|
637
|
+
cur.prunable = true;
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
return out.filter((w) => typeof w.branch === 'string'
|
|
641
|
+
&& w.branch.startsWith('worktree-agent-') && inAgentSlot(w.path));
|
|
642
|
+
};
|
|
643
|
+
// Native path resolution (Windows 8.3 short names) with a plain
|
|
644
|
+
// fallback. On win32 the filesystem is case-insensitive and git may
|
|
645
|
+
// spell a path with different casing than Node does, so real-path
|
|
646
|
+
// comparisons there are case-insensitive too.
|
|
647
|
+
const realPathNative = (p) => {
|
|
648
|
+
try { return realpathSync.native(p); } catch { /* fall through */ }
|
|
649
|
+
try { return realpathSync(p); } catch { /* fall through */ }
|
|
650
|
+
return resolve(p);
|
|
651
|
+
};
|
|
652
|
+
const sameRealPath = (a, b) => {
|
|
653
|
+
const x = realPathNative(a);
|
|
654
|
+
const y = realPathNative(b);
|
|
655
|
+
return process.platform === 'win32' ? x.toLowerCase() === y.toLowerCase() : x === y;
|
|
656
|
+
};
|
|
657
|
+
// Counts arrive from git stdout; anything but a plain integer is a null
|
|
658
|
+
// answer, never a coerced zero.
|
|
659
|
+
const parseCount = (stdout) => {
|
|
660
|
+
const t = String(stdout).trim();
|
|
661
|
+
return /^[0-9]+$/.test(t) ? Number(t) : null;
|
|
662
|
+
};
|
|
663
|
+
// A repos/{name}/ directory that is not a git toplevel of its own would
|
|
664
|
+
// make every git query walk up to the launcher and list the launcher's
|
|
665
|
+
// worktrees under that repo's name — skip such directories entirely.
|
|
666
|
+
const isOwnToplevel = (dir) => {
|
|
667
|
+
const res = gitIn(dir, ['rev-parse', '--show-toplevel']);
|
|
668
|
+
if (res.status !== 0) return false;
|
|
669
|
+
return sameRealPath(String(res.stdout).trim(), dir);
|
|
670
|
+
};
|
|
671
|
+
// A worktree-agent-* branch a chat record still lists as a task belongs
|
|
672
|
+
// to a chat, not to cleanup. The records are read directly (the same
|
|
673
|
+
// layout chat-record.mjs owns) rather than imported, so the audit keeps
|
|
674
|
+
// running from the upgrade payload, which stages only part of scripts/.
|
|
675
|
+
// Every way a record can fail to be read — an unreadable chats
|
|
676
|
+
// directory (anything but ENOENT), unparseable JSON, a tasks field
|
|
677
|
+
// present but not an array — makes claims UNKNOWN, and unknown fails
|
|
678
|
+
// closed: no agent worktree is removable while any record is unreadable.
|
|
679
|
+
const claimedBranches = new Set();
|
|
680
|
+
let claimsUnknown = false;
|
|
681
|
+
{
|
|
682
|
+
const chatsDir = join(reposRoot, 'workspace-scratchpad', 'chats');
|
|
683
|
+
let names = [];
|
|
684
|
+
try {
|
|
685
|
+
names = readdirSync(chatsDir);
|
|
686
|
+
} catch (err) {
|
|
687
|
+
if (err?.code !== 'ENOENT') claimsUnknown = true;
|
|
688
|
+
}
|
|
689
|
+
for (const name of names) {
|
|
690
|
+
if (!name.endsWith('.json')) continue;
|
|
691
|
+
let rec;
|
|
692
|
+
try {
|
|
693
|
+
rec = JSON.parse(readFileSync(join(chatsDir, name), 'utf8'));
|
|
694
|
+
} catch {
|
|
695
|
+
claimsUnknown = true;
|
|
696
|
+
continue;
|
|
697
|
+
}
|
|
698
|
+
if (rec && rec.tasks !== undefined && !Array.isArray(rec.tasks)) {
|
|
699
|
+
claimsUnknown = true;
|
|
700
|
+
continue;
|
|
701
|
+
}
|
|
702
|
+
for (const t of Array.isArray(rec?.tasks) ? rec.tasks : []) {
|
|
703
|
+
if (t && typeof t.branch === 'string') claimedBranches.add(t.branch);
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
const reposToScan = [
|
|
708
|
+
{ repo: '.', repoDir: reposRoot },
|
|
709
|
+
...Object.keys(reposManifest).map((name) => ({ repo: name, repoDir: join(reposRoot, 'repos', name) })),
|
|
710
|
+
];
|
|
711
|
+
// git reports worktree paths as the filesystem spells them (a resolved
|
|
712
|
+
// /private/var on macOS where the root arrived as /var), so both sides
|
|
713
|
+
// are realpathed before relativizing — a gone directory falls back to
|
|
714
|
+
// its recorded spelling and reports as an absolute path rather than
|
|
715
|
+
// being skipped.
|
|
716
|
+
const reposAnchor = (() => { try { return realpathSync(reposRoot); } catch { return reposRoot; } })();
|
|
717
|
+
const asReal = (p) => { try { return realpathSync(p); } catch { return p; } };
|
|
718
|
+
// Idle gate: a worktree active within the last hour is not removable,
|
|
719
|
+
// guarding a just-started agent even though Claude Code locks running
|
|
720
|
+
// agent worktrees. Last activity is the newest of the directory's mtime
|
|
721
|
+
// and the HEAD reflog's latest entry — under --date=unix the %gd
|
|
722
|
+
// selector renders as HEAD@{<epoch-seconds>}, the entry's own time
|
|
723
|
+
// (the commit's dates are something else entirely).
|
|
724
|
+
const IDLE_MS = 60 * 60 * 1000;
|
|
725
|
+
const lastActivityMs = (dir) => {
|
|
726
|
+
let ms = NaN;
|
|
727
|
+
try { ms = statSync(dir).mtimeMs; } catch { /* the directory may already be gone */ }
|
|
728
|
+
const rl = gitIn(dir, ['reflog', 'show', '-1', '--date=unix', '--format=%gd', 'HEAD']);
|
|
729
|
+
if (rl.status === 0) {
|
|
730
|
+
const m = /@\{(\d+)\}/.exec(String(rl.stdout));
|
|
731
|
+
if (m) ms = Math.max(ms, Number(m[1]) * 1000);
|
|
732
|
+
}
|
|
733
|
+
return ms;
|
|
734
|
+
};
|
|
735
|
+
for (const { repo, repoDir } of reposToScan) {
|
|
736
|
+
if (!existsSync(repoDir)) continue; // an uncloned repo is section 3's finding
|
|
737
|
+
if (!isOwnToplevel(repoDir)) continue; // not a repo of its own — see isOwnToplevel
|
|
738
|
+
for (const w of agentEntries(repoDir)) {
|
|
739
|
+
const wtRealPath = asReal(w.path);
|
|
740
|
+
const facts = {
|
|
741
|
+
repo,
|
|
742
|
+
branch: w.branch,
|
|
743
|
+
path: toRootRelative(reposAnchor, wtRealPath),
|
|
744
|
+
absolutePath: wtRealPath,
|
|
745
|
+
clean: null,
|
|
746
|
+
commitsBeyond: null,
|
|
747
|
+
patchIdsOnDefault: null,
|
|
748
|
+
locked: w.locked,
|
|
749
|
+
...(w.lockReason ? { lockReason: w.lockReason } : {}),
|
|
750
|
+
};
|
|
751
|
+
const reasons = [];
|
|
752
|
+
// Ref queries run from the repo, not the worktree, so the facts
|
|
753
|
+
// survive a prunable entry whose directory is already gone.
|
|
754
|
+
// Gate 1 — integrity: git must still map this directory to the
|
|
755
|
+
// recorded worktree. A deleted .git file leaves a plain directory
|
|
756
|
+
// whose git queries silently run against the owning repo (which may
|
|
757
|
+
// well be clean) — the toplevel check is what catches that, and a
|
|
758
|
+
// prunable record means the directory is already gone.
|
|
759
|
+
if (w.prunable) {
|
|
760
|
+
reasons.push('prunable worktree record — git no longer resolves this worktree');
|
|
761
|
+
} else {
|
|
762
|
+
const top = gitIn(w.path, ['rev-parse', '--show-toplevel']);
|
|
763
|
+
if (top.status !== 0 || !sameRealPath(String(top.stdout).trim(), w.path)) {
|
|
764
|
+
reasons.push('worktree integrity unverifiable — directory does not match its record');
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
// Gate 2 — clean, untracked files included.
|
|
768
|
+
const wtStatus = gitIn(w.path, ['status', '--porcelain']);
|
|
769
|
+
if (wtStatus.status === 0) facts.clean = String(wtStatus.stdout).trim() === '';
|
|
770
|
+
if (facts.clean === false) reasons.push('dirty worktree');
|
|
771
|
+
else if (facts.clean === null) reasons.push('cleanliness unknown');
|
|
772
|
+
// Gate 3 — no work of its own on the line. The base is the repo's
|
|
773
|
+
// configured default, origin's copy when it exists, else the local
|
|
774
|
+
// ref; with neither there is nothing to measure against.
|
|
775
|
+
const configured = repo === '.'
|
|
776
|
+
? gitInfo.defaultBranch ?? 'main'
|
|
777
|
+
: (reposManifest[repo] && typeof reposManifest[repo].branch === 'string' && reposManifest[repo].branch
|
|
778
|
+
? reposManifest[repo].branch
|
|
779
|
+
: 'main');
|
|
780
|
+
const base = [`origin/${configured}`, configured].find(
|
|
781
|
+
(ref) => gitIn(repoDir, ['rev-parse', '--verify', '--quiet', ref]).status === 0,
|
|
782
|
+
);
|
|
783
|
+
if (!base) {
|
|
784
|
+
reasons.push(`no ${configured} ref to measure the branch against`);
|
|
785
|
+
} else {
|
|
786
|
+
facts.base = base;
|
|
787
|
+
const cnt = gitIn(repoDir, ['rev-list', '--count', `${base}..${w.branch}`]);
|
|
788
|
+
if (cnt.status === 0) facts.commitsBeyond = parseCount(cnt.stdout);
|
|
789
|
+
if (facts.commitsBeyond === null) {
|
|
790
|
+
reasons.push(`commits beyond ${base} unknown`);
|
|
791
|
+
} else if (facts.commitsBeyond > 0) {
|
|
792
|
+
// git cherry marks each branch commit +/- by whether its
|
|
793
|
+
// patch-id is already on the base — all "-" means the branch
|
|
794
|
+
// holds nothing the base lacks, even when --merged sees none of
|
|
795
|
+
// it (rebase and cherry-pick landings). A squash merge leaves
|
|
796
|
+
// the branch's patch-ids unmatched, so it lands on the "+" side
|
|
797
|
+
// — not removable, the safe direction. Patch-ids do not apply
|
|
798
|
+
// to merge commits at all, and cherry must account for every
|
|
799
|
+
// non-merge commit before its verdict is trusted.
|
|
800
|
+
const merges = gitIn(repoDir, ['rev-list', '--merges', '--count', `${base}..${w.branch}`]);
|
|
801
|
+
const noMerges = gitIn(repoDir, ['rev-list', '--no-merges', '--count', `${base}..${w.branch}`]);
|
|
802
|
+
const mergeCount = merges.status === 0 ? parseCount(merges.stdout) : null;
|
|
803
|
+
const noMergeCount = noMerges.status === 0 ? parseCount(noMerges.stdout) : null;
|
|
804
|
+
const cherry = gitIn(repoDir, ['cherry', base, w.branch]);
|
|
805
|
+
const cherryLines = cherry.status === 0
|
|
806
|
+
? String(cherry.stdout).split(/\r?\n/).map((l) => l.trim()).filter(Boolean)
|
|
807
|
+
: null;
|
|
808
|
+
if (mergeCount === null) {
|
|
809
|
+
reasons.push(`landing on ${base} unverifiable — merge count unreadable`);
|
|
810
|
+
} else if (mergeCount > 0) {
|
|
811
|
+
reasons.push(`${mergeCount} merge commit(s) beyond ${base} — patch-ids do not apply to merges`);
|
|
812
|
+
} else if (cherryLines === null || noMergeCount === null || cherryLines.length !== noMergeCount) {
|
|
813
|
+
reasons.push(`landing on ${base} unverifiable — git cherry could not account for every commit`);
|
|
814
|
+
} else if (!cherryLines.every((l) => l.startsWith('-'))) {
|
|
815
|
+
reasons.push(`commit(s) beyond ${base} not landed on it`);
|
|
816
|
+
facts.patchIdsOnDefault = false;
|
|
817
|
+
} else {
|
|
818
|
+
facts.patchIdsOnDefault = true;
|
|
819
|
+
}
|
|
820
|
+
} else {
|
|
821
|
+
facts.patchIdsOnDefault = true; // nothing beyond the base at all
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
// Gate 4 — locked. Claude Code locks a worktree while its agent
|
|
825
|
+
// runs; a stale lock still means removal needs a human decision.
|
|
826
|
+
if (facts.locked) reasons.push(`locked${facts.lockReason ? ` (${facts.lockReason})` : ''}`);
|
|
827
|
+
// Gate 5 — idle (see lastActivityMs above).
|
|
828
|
+
const activity = lastActivityMs(w.path);
|
|
829
|
+
if (Number.isNaN(activity)) reasons.push('last activity unknown');
|
|
830
|
+
else if (nowFn().getTime() - activity < IDLE_MS) reasons.push('active within the last hour');
|
|
831
|
+
// Gate 6 — not claimed (see claimedBranches above). Claims that
|
|
832
|
+
// could not be read fail closed: unknown is not none.
|
|
833
|
+
facts.claimed = !claimsUnknown && claimedBranches.has(w.branch);
|
|
834
|
+
if (claimsUnknown) reasons.push('chat records unreadable — claims unknown');
|
|
835
|
+
else if (facts.claimed) reasons.push('claimed by a chat record');
|
|
836
|
+
facts.removable = reasons.length === 0;
|
|
837
|
+
facts.reasons = reasons;
|
|
838
|
+
agentWorktrees.push(facts);
|
|
839
|
+
const state = facts.clean === null ? 'cleanliness unknown' : facts.clean ? 'clean' : 'dirty';
|
|
840
|
+
const beyond = facts.commitsBeyond === null
|
|
841
|
+
? `commits beyond ${facts.base ?? 'the default branch'} unknown`
|
|
842
|
+
: facts.commitsBeyond === 0
|
|
843
|
+
? `no commits beyond ${facts.base}`
|
|
844
|
+
: `${facts.commitsBeyond} commit(s) beyond ${facts.base}`;
|
|
845
|
+
const bits = [state, beyond];
|
|
846
|
+
if (facts.locked) bits.push(`locked${facts.lockReason ? ` (${facts.lockReason})` : ''}`);
|
|
847
|
+
if (facts.claimed) bits.push('claimed by a chat record');
|
|
848
|
+
add('git', 'info', facts.path,
|
|
849
|
+
`leftover Claude Code agent worktree (${w.branch}): ${bits.join(', ')} — `
|
|
850
|
+
+ (facts.removable ? 'removable' : `not removable (${facts.reasons.join('; ')})`));
|
|
851
|
+
}
|
|
852
|
+
}
|
|
532
853
|
}
|
|
533
854
|
|
|
534
855
|
// ---------- 5. auto-files ----------
|
|
@@ -629,6 +950,7 @@ export async function runAudit({
|
|
|
629
950
|
? { status: canonicalSel.status, budget: canonicalSel.budgetBytes, current: canonicalSel.currentBytes }
|
|
630
951
|
: null,
|
|
631
952
|
freshness,
|
|
953
|
+
agentWorktrees,
|
|
632
954
|
exitCode: count('issue') > 0 ? 1 : 0,
|
|
633
955
|
},
|
|
634
956
|
};
|
|
@@ -142,4 +142,25 @@ function mergeModeFor(root, repo, deps = {}) {
|
|
|
142
142
|
return parseForgeRemote(String(res.stdout || '').trim(), { hosts: forgeHosts(ws) }) ? 'forge' : null;
|
|
143
143
|
}
|
|
144
144
|
|
|
145
|
-
|
|
145
|
+
/**
|
|
146
|
+
* Resolve who approves a task repo's merge (gh:202): "ask" — the default —
|
|
147
|
+
* /complete-work presents the summary and asks once before merging;
|
|
148
|
+
* "operator" — the skill stops after the PRs are created and their checks
|
|
149
|
+
* read, merging only when the operator says so in a later turn (or never:
|
|
150
|
+
* a merge done in the forge UI is detected by --merge's re-run). The
|
|
151
|
+
* setting is per repo — repos.{repo}.mergeApproval, or
|
|
152
|
+
* workspace.mergeApproval for the workspace repo — addressed exactly the
|
|
153
|
+
* way `merge` is (mergeModeFor above). An unrecognized value throws: a
|
|
154
|
+
* typo silently read as "ask" would change who merges without anyone
|
|
155
|
+
* noticing.
|
|
156
|
+
*/
|
|
157
|
+
function mergeApprovalFor(root, repo) {
|
|
158
|
+
const ws = readWorkspace(resolve(root));
|
|
159
|
+
const setting = repo === WORKSPACE_REPO ? 'workspace.mergeApproval' : `repos.${repo}.mergeApproval`;
|
|
160
|
+
const value = repo === WORKSPACE_REPO ? ws?.workspace?.mergeApproval : ws?.repos?.[repo]?.mergeApproval;
|
|
161
|
+
if (value === undefined || value === null) return 'ask';
|
|
162
|
+
if (value === 'ask' || value === 'operator') return value;
|
|
163
|
+
throw new Error(`unknown ${setting} "${value}" — expected "ask" or "operator"`);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export { WORKSPACE_REPO, repoDirFor, readWorkspace, parseForgeRemote, forgeHosts, perRepoForge, forgeConfigForRepo, mergeModeFor, mergeApprovalFor };
|