@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.
Files changed (34) hide show
  1. package/package.json +2 -1
  2. package/template/CLAUDE.md.tmpl +2 -2
  3. package/template/_claude/hooks/_utils.mjs +43 -0
  4. package/template/_claude/hooks/session-start.mjs +10 -0
  5. package/template/_claude/rules/work-item-tracking.md +6 -3
  6. package/template/_claude/rules/workspace-structure.md +1 -1
  7. package/template/_claude/scripts/chat-record.mjs +43 -26
  8. package/template/_claude/scripts/classify-update.mjs +194 -32
  9. package/template/_claude/scripts/forges/github.mjs +51 -1
  10. package/template/_claude/scripts/forges/gitlab.mjs +69 -0
  11. package/template/_claude/scripts/forges/interface.mjs +19 -2
  12. package/template/_claude/scripts/maintenance-audit.mjs +325 -3
  13. package/template/_claude/scripts/merge-mode.mjs +22 -1
  14. package/template/_claude/scripts/release-leak-audit.mjs +480 -0
  15. package/template/_claude/scripts/task-pr.mjs +316 -72
  16. package/template/_claude/scripts/task-worktree.mjs +9 -1
  17. package/template/_claude/scripts/template-merge.mjs +21 -4
  18. package/template/_claude/scripts/template-modifications.mjs +152 -0
  19. package/template/_claude/scripts/trackers/github-issues.mjs +110 -1
  20. package/template/_claude/scripts/trackers/gitlab-issues.mjs +221 -2
  21. package/template/_claude/scripts/trackers/interface.mjs +47 -0
  22. package/template/_claude/skills/braindump/SKILL.md +1 -1
  23. package/template/_claude/skills/complete-work/SKILL.md +22 -12
  24. package/template/_claude/skills/handoff/SKILL.md +1 -1
  25. package/template/_claude/skills/maintenance/SKILL.md +8 -3
  26. package/template/_claude/skills/release/SKILL.md +20 -6
  27. package/template/_claude/skills/setup-tracker/SKILL.md +24 -1
  28. package/template/_claude/skills/start-work/SKILL.md +23 -5
  29. package/template/_claude/skills/workspace-update/SKILL.md +23 -15
  30. package/template/workspace.json.tmpl +2 -1
  31. package/lib/init.test.mjs +0 -75
  32. package/lib/payload.test.mjs +0 -210
  33. package/lib/scaffold.test.mjs +0 -79
  34. 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
- // json may name additional fields to pass through
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
- export { WORKSPACE_REPO, repoDirFor, readWorkspace, parseForgeRemote, forgeHosts, perRepoForge, forgeConfigForRepo, mergeModeFor };
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 };