@ulysses-ai/create-workspace 0.20.0-beta.0 → 0.22.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.
@@ -0,0 +1,697 @@
1
+ #!/usr/bin/env node
2
+ // Scripted workspace audit: the mechanical, read-only checks of /maintenance
3
+ // Audit sections 1–7 in one shot (gh:180), from a workspace root or from the
4
+ // upgrade payload.
5
+ //
6
+ // Running those sections as prose made the audit slow, variable between runs,
7
+ // and impossible to reuse for post-update verification. This script owns the
8
+ // parts a program can decide:
9
+ //
10
+ // 1. cross-reference — skills vs the CLAUDE.md skill list (both
11
+ // directions), dangling @-imports in CLAUDE.md's
12
+ // import graph
13
+ // 2. frontmatter — live workspace-context/*.md and session trackers
14
+ // parse, reference live branches/repos, and are not
15
+ // stale. Historical material (paths the indexer's
16
+ // .indexignore excludes, anything under an archive/
17
+ // directory) and closed-out lifecycles are skipped
18
+ // 3. structure — workspace.json and CLAUDE.md present and parseable,
19
+ // manifest repos cloned, expected directories there
20
+ // 4. git — launcher on its default branch with a clean tracked
21
+ // tree. Audited from a linked worktree (task or
22
+ // session), the launcher-level questions — the
23
+ // launcher's branch and dirty tree, manifest repos
24
+ // cloned — are asked against the launcher (the parent
25
+ // of the git common dir), while the worktree's own
26
+ // branch is only named in an info line and its dirty
27
+ // tracked tree is skipped as info: in-flight work, not
28
+ // drift (gh:183)
29
+ // 5. auto-files — workspace-context catalogs current (the same
30
+ // semantics as build-workspace-context.mjs --check)
31
+ // 6. budget — always-loaded context within
32
+ // workspace.alwaysLoadedBudgetBytes
33
+ // 7. freshness — template version vs the npm registry
34
+ //
35
+ // Severity, and what each means for the exit code:
36
+ // issue — broken references, unparseable files, structural violations:
37
+ // something that must be fixed. Any issue → exit 1.
38
+ // warning — state drift a human should look at (uncommitted changes,
39
+ // stale context, over budget) that breaks nothing.
40
+ // info — expected or ambient conditions (machine-local files absent,
41
+ // optional stubs, untracked paths).
42
+ //
43
+ // Rather than re-implementing, the checks reuse the shipped helpers:
44
+ // context-footprint.mjs (measure/readBudget/resolveImports) for sections 1
45
+ // and 6, build-workspace-context.mjs (regenerateAll/fingerprint) for section
46
+ // 5, lib/freshness.mjs (refreshIfStale) for section 7. Only git runs as a
47
+ // subprocess, always with an argv array.
48
+ //
49
+ // Section 7 is the one network user, and refreshIfStale also rewrites the
50
+ // local-only-template-freshness.md banner and the version cache — the same
51
+ // writes /maintenance section 7 has always made. --offline skips it.
52
+ //
53
+ // Usage:
54
+ // node maintenance-audit.mjs [--root <dir>] [--json] [--offline]
55
+ // [--changed <path-or-list-file>]...
56
+ //
57
+ // --root <dir> workspace root; defaults to the current working directory.
58
+ // Never derived from this script's location — the upgrade
59
+ // payload runs this file from
60
+ // <workspace>/.workspace-update/.claude/scripts/.
61
+ // --json emit { issues: [{section,severity,file,message,
62
+ // fromUpdate?}], summary } instead of the human report
63
+ // --offline skip section 7 (no network, no banner/cache writes)
64
+ // --changed mark findings whose file is in the list with
65
+ // fromUpdate: true ("(from this update)" in text). The value
66
+ // is one path, or a file containing a newline-separated path
67
+ // list (recognized when the value names an existing file
68
+ // whose every non-empty line is whitespace-free). Repeatable.
69
+ //
70
+ // Exit codes: 0 — no issue-severity findings; 1 — at least one; 2 — argument
71
+ // or filesystem error before any check ran.
72
+
73
+ import { existsSync, readFileSync, readdirSync, statSync, realpathSync } from 'node:fs';
74
+ import { dirname, join, relative, resolve, sep } from 'node:path';
75
+ import { spawnSync } from 'node:child_process';
76
+ import { fileURLToPath } from 'node:url';
77
+ import { measure, readBudget, resolveImports } from './context-footprint.mjs';
78
+ import {
79
+ regenerateAll,
80
+ fingerprint,
81
+ gitIgnoredPaths,
82
+ readIgnorePrefixes,
83
+ isIgnored,
84
+ } from './build-workspace-context.mjs';
85
+ import { refreshIfStale } from '../lib/freshness.mjs';
86
+ import { parseSessionContent } from '../lib/session-frontmatter.mjs';
87
+
88
+ function isMainModule(metaUrl) {
89
+ if (!process.argv[1]) return false;
90
+ try {
91
+ return realpathSync(fileURLToPath(metaUrl)) === realpathSync(process.argv[1]);
92
+ } catch { return false; }
93
+ }
94
+
95
+ const SECTIONS = [
96
+ { key: 'cross-reference', n: 1, label: 'Cross-references', ok: 'skills and @-imports consistent with CLAUDE.md' },
97
+ { key: 'frontmatter', n: 2, label: 'Frontmatter', ok: 'context files parse and reference live branches and repos' },
98
+ { key: 'structure', n: 3, label: 'Workspace structure', ok: 'layout matches the workspace structure' },
99
+ { key: 'git', n: 4, label: 'Git state', ok: 'on the default branch, tracked tree clean' },
100
+ { key: 'auto-files', n: 5, label: 'Context auto-files', ok: 'index.md and canonical.md current' },
101
+ { key: 'budget', n: 6, label: 'Always-loaded budget', ok: '' },
102
+ { key: 'freshness', n: 7, label: 'Template freshness', ok: '' },
103
+ ];
104
+
105
+ const STALE_DAYS = 7;
106
+ const KB = (bytes) => `${(bytes / 1024).toFixed(1)} KB`;
107
+
108
+ function toPosix(p) {
109
+ return p.split(sep).join('/');
110
+ }
111
+
112
+ /** Normalize a possibly-relative, possibly-absolute path against the root. */
113
+ function toRootRelative(absRoot, value) {
114
+ const rel = relative(absRoot, resolve(absRoot, value));
115
+ return rel.startsWith('..') ? toPosix(value) : toPosix(rel);
116
+ }
117
+
118
+ function isAutoFileRel(rel, wcDir) {
119
+ return rel === `${wcDir}/index.md`
120
+ || rel === `${wcDir}/canonical.md`
121
+ || new RegExp(`^${wcDir}/team-member/[^/]+/index\\.md$`).test(rel);
122
+ }
123
+
124
+ /** Extract one `## heading` section, or null when the heading is absent. */
125
+ function extractHeadingSection(text, heading) {
126
+ const lines = text.split(/\r?\n/);
127
+ const start = lines.findIndex((l) => l.trim() === `## ${heading}`);
128
+ if (start === -1) return null;
129
+ const rest = lines.slice(start + 1);
130
+ const end = rest.findIndex((l) => /^##\s/.test(l));
131
+ return (end === -1 ? rest : rest.slice(0, end)).join('\n');
132
+ }
133
+
134
+ /**
135
+ * Run the audit. Returns { issues, summary }; throws only on filesystem
136
+ * errors that make the whole run impossible, never for what it finds.
137
+ * fetchFn/nowFn are injectable so tests never touch the network or the wall
138
+ * clock.
139
+ */
140
+ export async function runAudit({
141
+ root = '.',
142
+ changed = [],
143
+ offline = false,
144
+ fetchFn = fetch,
145
+ nowFn = () => new Date(),
146
+ } = {}) {
147
+ const absRoot = resolve(root);
148
+ const changedSet = new Set(changed.map((c) => toRootRelative(absRoot, c)));
149
+ const findings = [];
150
+ // Identical findings are reported once (gh:180 fix round): a file reached
151
+ // through two walk roots — e.g. a session tracker living inside the
152
+ // workspace-context tree — must not double-report. (The dogfood run's
153
+ // apparent duplicates were subtler still: one release branch is referenced
154
+ // by both its notes-* and questions-* archive artifacts, so the same
155
+ // message appeared under two file paths. Scoping section 2 to live content
156
+ // removes those; this guard removes the exact kind.) The key is a JSON
157
+ // array — a structural delimiter, never a byte that could appear in the
158
+ // values (NUL separators made git treat this file as binary).
159
+ const seenFindings = new Set();
160
+ const add = (section, severity, file, message) => {
161
+ const key = JSON.stringify([section, severity, file, message]);
162
+ if (seenFindings.has(key)) return;
163
+ seenFindings.add(key);
164
+ findings.push({
165
+ section,
166
+ severity,
167
+ file,
168
+ message,
169
+ ...(changedSet.has(file) ? { fromUpdate: true } : {}),
170
+ });
171
+ };
172
+
173
+ // ---------- shared inputs ----------
174
+
175
+ let wsConfig = null;
176
+ const wsJsonPath = join(absRoot, 'workspace.json');
177
+ if (existsSync(wsJsonPath)) {
178
+ try {
179
+ wsConfig = JSON.parse(readFileSync(wsJsonPath, 'utf8'));
180
+ } catch (err) {
181
+ add('structure', 'issue', 'workspace.json', `workspace.json does not parse: ${err.message}`);
182
+ }
183
+ }
184
+ const ws = wsConfig?.workspace && typeof wsConfig.workspace === 'object' ? wsConfig.workspace : {};
185
+ const reposManifest = wsConfig?.repos && typeof wsConfig.repos === 'object' ? wsConfig.repos : {};
186
+ const wcDir = typeof ws.workspaceContextDir === 'string' && ws.workspaceContextDir ? ws.workspaceContextDir : 'workspace-context';
187
+ const sessionsDir = typeof ws.workSessionsDir === 'string' && ws.workSessionsDir ? ws.workSessionsDir : 'work-sessions';
188
+
189
+ const git = (args) => spawnSync('git', args, { cwd: absRoot, encoding: 'utf8' });
190
+ const gitInfo = (() => {
191
+ const inside = git(['rev-parse', '--is-inside-work-tree']);
192
+ if (inside.status !== 0 || String(inside.stdout).trim() !== 'true') return { isRepo: false };
193
+ const cur = git(['rev-parse', '--abbrev-ref', 'HEAD']);
194
+ const branch = cur.status === 0 ? cur.stdout.trim() : null;
195
+ // The launcher's default branch: the remote's HEAD when there is one
196
+ // (origin/main → main), else the configured init default, else main.
197
+ let defaultBranch = null;
198
+ const sym = git(['symbolic-ref', '--short', 'refs/remotes/origin/HEAD']);
199
+ if (sym.status === 0 && sym.stdout.trim()) {
200
+ const parts = sym.stdout.trim().split('/');
201
+ defaultBranch = parts.slice(1).join('/') || parts[0];
202
+ } else {
203
+ const cfg = git(['config', '--get', 'init.defaultBranch']);
204
+ defaultBranch = cfg.status === 0 && cfg.stdout.trim() ? cfg.stdout.trim() : 'main';
205
+ }
206
+ const status = git(['status', '--porcelain']);
207
+ const porcelain = status.status === 0
208
+ ? status.stdout.split('\n').map((l) => l.trim()).filter(Boolean)
209
+ : [];
210
+ const branches = git(['branch', '--all', '--format=%(refname:short)']);
211
+ // A linked worktree (task worktree of the workspace repo, session
212
+ // workspace) has its own git dir under the repo's common dir, so the two
213
+ // rev-parse paths differ. Launcher-level state — cloned repos, the
214
+ // launcher's branch — lives at the launcher, the parent of the common
215
+ // dir; the worktree's own branch and dirty tree are expected mid-task,
216
+ // not drift (gh:183).
217
+ let launcherRoot = null;
218
+ let launcherBranch = null;
219
+ let launcherPorcelain = [];
220
+ const gd = git(['rev-parse', '--git-dir']);
221
+ const cd = git(['rev-parse', '--git-common-dir']);
222
+ if (gd.status === 0 && cd.status === 0) {
223
+ const resolveGitPath = (p) => {
224
+ try { return realpathSync(resolve(absRoot, p.trim())); } catch { return null; }
225
+ };
226
+ const gitDir = resolveGitPath(gd.stdout);
227
+ const commonDir = resolveGitPath(cd.stdout);
228
+ if (gitDir && commonDir && gitDir !== commonDir) {
229
+ launcherRoot = dirname(commonDir);
230
+ const lb = spawnSync('git', ['-C', launcherRoot, 'rev-parse', '--abbrev-ref', 'HEAD'], { encoding: 'utf8' });
231
+ launcherBranch = lb.status === 0 ? lb.stdout.trim() : null;
232
+ const ls = spawnSync('git', ['-C', launcherRoot, 'status', '--porcelain'], { encoding: 'utf8' });
233
+ if (ls.status === 0) {
234
+ launcherPorcelain = ls.stdout.split('\n').map((l) => l.trim()).filter(Boolean);
235
+ }
236
+ }
237
+ }
238
+ return {
239
+ isRepo: true,
240
+ branch,
241
+ defaultBranch,
242
+ porcelain,
243
+ branches: new Set(branches.status === 0 ? branches.stdout.split('\n').map((l) => l.trim()).filter(Boolean) : []),
244
+ launcherRoot,
245
+ launcherBranch,
246
+ launcherPorcelain,
247
+ };
248
+ })();
249
+
250
+ // ---------- 3. structure ----------
251
+
252
+ if (!existsSync(wsJsonPath)) add('structure', 'issue', 'workspace.json', 'workspace.json is missing');
253
+ if (!existsSync(join(absRoot, 'CLAUDE.md'))) {
254
+ add('structure', 'issue', 'CLAUDE.md', 'CLAUDE.md is missing — cross-reference checks skipped');
255
+ }
256
+ if (!existsSync(join(absRoot, wcDir))) {
257
+ add('structure', 'warning', wcDir, `${wcDir}/ does not exist — team knowledge lives there`);
258
+ }
259
+ for (const dir of ['rules', 'skills', 'scripts']) {
260
+ if (!existsSync(join(absRoot, '.claude', dir))) {
261
+ add('structure', 'warning', `.claude/${dir}`, `.claude/${dir}/ does not exist — the workspace structure expects it`);
262
+ }
263
+ }
264
+ // Cloned repos live at the launcher. From a task worktree the audit root's
265
+ // own repos/ holds only nested worktrees, if anything — resolve the clone
266
+ // check against the launcher (gh:183).
267
+ const reposRoot = gitInfo.launcherRoot ?? absRoot;
268
+ for (const name of Object.keys(reposManifest)) {
269
+ if (!existsSync(join(reposRoot, 'repos', name))) {
270
+ add('structure', 'warning', 'workspace.json', `repo '${name}' is in the manifest but repos/${name}/ is not cloned`);
271
+ }
272
+ }
273
+
274
+ // ---------- 1. cross-reference ----------
275
+
276
+ const claudeMdPath = join(absRoot, 'CLAUDE.md');
277
+ if (existsSync(claudeMdPath)) {
278
+ const text = readFileSync(claudeMdPath, 'utf8');
279
+ const skillsDir = join(absRoot, '.claude', 'skills');
280
+ const installed = existsSync(skillsDir)
281
+ ? readdirSync(skillsDir).filter((n) => {
282
+ try { return statSync(join(skillsDir, n)).isDirectory(); } catch { return false; }
283
+ }).sort()
284
+ : [];
285
+
286
+ const skillsSection = extractHeadingSection(text, 'Skills');
287
+ // Claude Code built-in commands can be mentioned alongside skills
288
+ // without being workspace skills — never treat one as a list entry.
289
+ const BUILTIN_COMMANDS = new Set([
290
+ 'goal', 'rename', 'clear', 'compact', 'help', 'config', 'permissions',
291
+ 'model', 'review', 'memory', 'init', 'doctor', 'hooks', 'mcp', 'agents',
292
+ 'resume', 'exit',
293
+ ]);
294
+ const listed = new Set();
295
+ if (skillsSection !== null) {
296
+ // Only a list entry counts as a skill reference: a line whose first
297
+ // backticked token starts with `/name` (the token may carry argument
298
+ // hints, as in `/start-work [handoff|blank]`). A `/name` mentioned
299
+ // inside prose — the /goal-driven-work entry referencing the built-in
300
+ // /goal — is not a skill claim and must not be flagged (gh:180 fix
301
+ // round).
302
+ for (const m of skillsSection.matchAll(/^\s*-\s+`\/([a-z0-9][a-z0-9-]*)[^`]*`/gm)) {
303
+ if (!BUILTIN_COMMANDS.has(m[1])) listed.add(m[1]);
304
+ }
305
+ }
306
+ for (const name of installed) {
307
+ const isListed = skillsSection !== null ? listed.has(name) : text.includes(`/${name}`);
308
+ if (!isListed) {
309
+ add('cross-reference', 'issue', `.claude/skills/${name}/SKILL.md`,
310
+ `skill /${name} is installed but not listed in CLAUDE.md — sessions cannot discover it`);
311
+ }
312
+ }
313
+ for (const name of listed) {
314
+ if (!installed.includes(name)) {
315
+ add('cross-reference', 'issue', 'CLAUDE.md', `CLAUDE.md lists /${name} but .claude/skills/${name}/ does not exist`);
316
+ }
317
+ }
318
+
319
+ // Dangling @-imports across CLAUDE.md's import graph.
320
+ const missing = [];
321
+ resolveImports(claudeMdPath, new Set([claudeMdPath]), missing);
322
+ for (const spec of missing) {
323
+ const posix = toPosix(spec);
324
+ const base = posix.split('/').pop();
325
+ if (base.startsWith('local-only-')) {
326
+ add('cross-reference', 'info', 'CLAUDE.md', `@${posix} is absent — machine-local, expected on other machines`);
327
+ } else if (posix === 'CODEBASE.md') {
328
+ add('cross-reference', 'info', 'CLAUDE.md', '@CODEBASE.md is absent — optional stub, /workspace-init generates it on request');
329
+ } else if (isAutoFileRel(posix, wcDir)) {
330
+ continue; // section 5 owns the auto-generated artifacts
331
+ } else {
332
+ add('cross-reference', 'issue', 'CLAUDE.md', `@${posix} is imported by CLAUDE.md but does not exist`);
333
+ }
334
+ }
335
+ }
336
+
337
+ // ---------- 2. frontmatter ----------
338
+
339
+ (() => {
340
+ const files = [];
341
+ const walk = (dir) => {
342
+ if (!existsSync(dir)) return;
343
+ for (const name of readdirSync(dir).sort()) {
344
+ const full = join(dir, name);
345
+ let st; try { st = statSync(full); } catch { continue; }
346
+ if (st.isDirectory()) walk(full);
347
+ else if (st.isFile() && name.endsWith('.md')) files.push(full);
348
+ }
349
+ };
350
+ walk(join(absRoot, wcDir));
351
+ const sessionsRoot = join(absRoot, sessionsDir);
352
+ if (existsSync(sessionsRoot)) {
353
+ for (const name of readdirSync(sessionsRoot).sort()) {
354
+ const tracker = join(sessionsRoot, name, 'workspace', 'session.md');
355
+ if (existsSync(tracker)) files.push(tracker);
356
+ }
357
+ }
358
+ if (files.length === 0) return; // a missing wcDir is section 3's finding
359
+
360
+ const rels = files.map((f) => toPosix(relative(absRoot, f)));
361
+ // Reuses build-workspace-context's batched check-ignore: gitignored .md
362
+ // files are machine-local (local-only-* drafts, per-user indexes) and
363
+ // not audited. No git → empty set → everything local gets audited.
364
+ const ignored = gitIgnoredPaths(absRoot, rels);
365
+
366
+ // Live-content scoping (gh:180 fix round): historical material is not
367
+ // audited. Release archives reference branches that are gone by design
368
+ // and predate frontmatter conventions, so auditing them buried a healthy
369
+ // workspace under ~85 findings. Historical means: excluded from the
370
+ // index by .indexignore (same interpretation as build-workspace-context
371
+ // — prefix per line, trailing slash matches a directory), or inside any
372
+ // directory named archive/. Session trackers are always live state and
373
+ // never excluded.
374
+ const ignorePrefixes = readIgnorePrefixes(join(absRoot, wcDir));
375
+ const isHistorical = (rel) => {
376
+ if (!rel.startsWith(`${wcDir}/`)) return false;
377
+ const relToWC = rel.slice(wcDir.length + 1);
378
+ if (isIgnored(relToWC, ignorePrefixes)) return true;
379
+ return relToWC.split('/').includes('archive');
380
+ };
381
+
382
+ for (let i = 0; i < files.length; i++) {
383
+ const rel = rels[i];
384
+ if (ignored.has(rel) || isHistorical(rel)) continue;
385
+ if (rel.split('/').pop().startsWith('local-only-')) continue;
386
+ if (isAutoFileRel(rel, wcDir)) continue;
387
+
388
+ const content = readFileSync(files[i], 'utf8');
389
+ const isTracker = rel.startsWith(`${sessionsDir}/`) && rel.endsWith('/workspace/session.md');
390
+ if (!content.startsWith('---')) {
391
+ add('frontmatter', 'warning', rel, 'no YAML frontmatter — workspace-context files carry it by convention');
392
+ continue;
393
+ }
394
+ let parsed;
395
+ try {
396
+ parsed = parseSessionContent(content);
397
+ } catch (err) {
398
+ add('frontmatter', 'issue', rel, `frontmatter does not parse: ${err.message}`);
399
+ continue;
400
+ }
401
+ const f = parsed.fields;
402
+
403
+ if (isTracker) {
404
+ for (const key of ['name', 'status', 'branch']) {
405
+ if (!f[key]) add('frontmatter', 'warning', rel, `session tracker is missing its '${key}' field`);
406
+ }
407
+ }
408
+ // Branch liveness is a property of live files: a resolved or otherwise
409
+ // closed-out file legitimately references a branch deleted at
410
+ // completion. Only active — or unlabeled — files flag it.
411
+ const branchIsLive = !f.lifecycle || f.lifecycle === 'active';
412
+ if (branchIsLive && typeof f.branch === 'string' && f.branch && gitInfo.isRepo && !gitInfo.branches.has(f.branch)) {
413
+ add('frontmatter', 'warning', rel, `branch '${f.branch}' no longer exists`);
414
+ }
415
+ const repoRefs = [];
416
+ if (typeof f.repo === 'string') repoRefs.push(f.repo);
417
+ if (typeof f.repos === 'string') repoRefs.push(f.repos);
418
+ for (const r of Array.isArray(f.repos) ? f.repos : []) {
419
+ if (typeof r === 'string') repoRefs.push(r);
420
+ else if (r && typeof r === 'object' && typeof r.repo === 'string') repoRefs.push(r.repo);
421
+ }
422
+ for (const r of repoRefs) {
423
+ if (r !== '.' && !(r in reposManifest)) {
424
+ add('frontmatter', 'warning', rel, `references repo '${r}' which is not in workspace.json`);
425
+ }
426
+ }
427
+ if (f.lifecycle === 'active' && typeof f.updated === 'string') {
428
+ const t = Date.parse(f.updated);
429
+ if (!Number.isNaN(t)) {
430
+ const ageDays = (nowFn().getTime() - t) / 86400000;
431
+ if (ageDays > STALE_DAYS) {
432
+ add('frontmatter', 'warning', rel,
433
+ `lifecycle active but not updated in ${Math.floor(ageDays)} days — stale candidate`);
434
+ }
435
+ }
436
+ }
437
+ if (f.lifecycle === 'resolved') {
438
+ add('frontmatter', 'info', rel, 'lifecycle resolved — confirm /complete-work has processed it');
439
+ }
440
+ if ('confidence' in f && !['high', 'medium', 'low'].includes(f.confidence)) {
441
+ add('frontmatter', 'warning', rel, `confidence '${f.confidence}' is not one of high, medium, low`);
442
+ }
443
+ }
444
+ })();
445
+
446
+ // ---------- 4. git state ----------
447
+
448
+ if (!gitInfo.isRepo) {
449
+ add('git', 'info', '.', 'not a git repository — git checks skipped');
450
+ } else {
451
+ const dirty = gitInfo.porcelain.filter((l) => !l.startsWith('??'));
452
+ const untracked = gitInfo.porcelain.filter((l) => l.startsWith('??'));
453
+ if (gitInfo.launcherRoot) {
454
+ // Auditing a worktree (task or session): its feature branch and dirty
455
+ // tree are the in-flight work itself, so only the worktree's own checks
456
+ // are skipped — the launcher-level questions are still asked, and
457
+ // answered against the launcher.
458
+ add('git', 'info', '.',
459
+ `auditing from a worktree ('${gitInfo.branch}') — launcher checks resolved against ${toPosix(gitInfo.launcherRoot)}`);
460
+ if (gitInfo.launcherBranch === 'HEAD') {
461
+ add('git', 'warning', '.', 'launcher is in detached HEAD — it sits on its default branch');
462
+ } else if (gitInfo.launcherBranch && gitInfo.launcherBranch !== gitInfo.defaultBranch) {
463
+ add('git', 'warning', '.',
464
+ `launcher is on branch '${gitInfo.launcherBranch}' — it stays on its default branch ('${gitInfo.defaultBranch}')`);
465
+ }
466
+ const launcherDirty = gitInfo.launcherPorcelain.filter((l) => !l.startsWith('??'));
467
+ if (launcherDirty.length > 0) {
468
+ const paths = launcherDirty.slice(0, 5).map((l) => l.slice(l.indexOf(' ') + 1).replace(/ -> /, ' → '));
469
+ add('git', 'warning', '.',
470
+ `launcher has ${launcherDirty.length} tracked file(s) with uncommitted changes: ${paths.join(', ')}${launcherDirty.length > 5 ? ', …' : ''}`);
471
+ }
472
+ add('git', 'info', '.',
473
+ 'uncommitted-changes check skipped — the worktree is expected to carry in-flight changes');
474
+ } else {
475
+ if (gitInfo.branch === 'HEAD') {
476
+ add('git', 'warning', '.', 'detached HEAD — the launcher sits on its default branch');
477
+ } else if (gitInfo.branch !== gitInfo.defaultBranch) {
478
+ add('git', 'warning', '.',
479
+ `on branch '${gitInfo.branch}' — the launcher stays on its default branch ('${gitInfo.defaultBranch}')`);
480
+ }
481
+ if (dirty.length > 0) {
482
+ // Porcelain is "XY <path>" (the leading X is a space for unstaged
483
+ // changes, and the line was trimmed), so the path starts after the
484
+ // first space.
485
+ const paths = dirty.slice(0, 5).map((l) => l.slice(l.indexOf(' ') + 1).replace(/ -> /, ' → '));
486
+ add('git', 'warning', '.',
487
+ `${dirty.length} tracked file(s) with uncommitted changes: ${paths.join(', ')}${dirty.length > 5 ? ', …' : ''}`);
488
+ }
489
+ }
490
+ if (untracked.length > 0) {
491
+ add('git', 'info', '.', `${untracked.length} untracked path(s) — gitignored content is not counted`);
492
+ }
493
+ }
494
+
495
+ // ---------- 5. auto-files ----------
496
+
497
+ let canonicalSel = null;
498
+ (() => {
499
+ let artifacts;
500
+ try {
501
+ artifacts = regenerateAll(absRoot);
502
+ } catch (err) {
503
+ add('auto-files', 'issue', `${wcDir}/`, `catalog build failed: ${err.message}`);
504
+ return;
505
+ }
506
+ if (artifacts.length === 0) return; // a missing wcDir is section 3's finding
507
+ for (const a of artifacts) {
508
+ const rel = toPosix(relative(absRoot, a.path));
509
+ if (!existsSync(a.path)) {
510
+ // Mirrors build-workspace-context --check: a gitignored artifact
511
+ // (per-user indexes) is regenerated per machine, so its absence is
512
+ // the normal fresh-checkout state, not staleness.
513
+ const gitignored = gitInfo.isRepo
514
+ ? spawnSync('git', ['-C', absRoot, 'check-ignore', '-q', rel]).status === 0
515
+ : false;
516
+ if (gitignored) {
517
+ add('auto-files', 'info', rel, `${a.label} is absent — gitignored, regenerated on demand`);
518
+ } else {
519
+ add('auto-files', 'issue', rel,
520
+ `${a.label} is missing — run node .claude/scripts/build-workspace-context.mjs --write`);
521
+ }
522
+ continue;
523
+ }
524
+ if (fingerprint(readFileSync(a.path, 'utf8')) !== fingerprint(a.content)) {
525
+ add('auto-files', 'issue', rel,
526
+ `${a.label} is stale — run node .claude/scripts/build-workspace-context.mjs --write`);
527
+ }
528
+ if (a.label === 'canonical.md') canonicalSel = a.selection;
529
+ }
530
+ if (canonicalSel) {
531
+ if (canonicalSel.status === 'over-budget') {
532
+ add('auto-files', 'warning', `${wcDir}/canonical.md`,
533
+ `canonical body exceeds its budget by ${canonicalSel.overBy} bytes after trimming and stubbing — triage via /maintenance cleanup`);
534
+ } else if (canonicalSel.trimmedFiles.length + canonicalSel.stubbedFiles.length > 0) {
535
+ add('auto-files', 'info', `${wcDir}/canonical.md`,
536
+ `canonical fits its budget with ${canonicalSel.trimmedFiles.length} trimmed and ${canonicalSel.stubbedFiles.length} stubbed reference file(s)`);
537
+ }
538
+ }
539
+ })();
540
+
541
+ // ---------- 6. always-loaded budget ----------
542
+
543
+ let alwaysLoaded = null;
544
+ try {
545
+ const m = measure({ root: absRoot });
546
+ const budgetBytes = readBudget(absRoot);
547
+ alwaysLoaded = {
548
+ totalBytes: m.totalBytes,
549
+ budgetBytes,
550
+ overBudget: budgetBytes !== null && m.totalBytes > budgetBytes,
551
+ };
552
+ if (alwaysLoaded.overBudget) {
553
+ const top = m.files.slice(0, 3).map((f) => `${f.path} (${KB(f.bytes)})`).join(', ');
554
+ add('budget', 'warning', '.',
555
+ `always-loaded context ${m.totalBytes}/${budgetBytes} bytes — top contributors: ${top}`);
556
+ }
557
+ } catch {
558
+ // measure/readBudget throw only on unreadable or malformed inputs the
559
+ // structure section has already reported; nothing to add here.
560
+ }
561
+
562
+ // ---------- 7. freshness ----------
563
+
564
+ let freshness = null;
565
+ if (offline) {
566
+ freshness = { status: 'skipped', reason: 'offline' };
567
+ } else {
568
+ const r = await refreshIfStale({ workspaceRoot: absRoot, ttlMs: 24 * 60 * 60 * 1000, fetchFn });
569
+ if (r.status === 'outdated') {
570
+ add('freshness', 'warning', 'workspace.json',
571
+ `template v${r.current} → v${r.latest} available — run npx @ulysses-ai/create-workspace --upgrade`);
572
+ } else if (r.status === 'unknown') {
573
+ add('freshness', 'warning', 'workspace.json', 'could not reach the npm registry — template freshness unknown');
574
+ } else if (r.skipped === 'uninitialized') {
575
+ add('freshness', 'info', 'workspace.json', 'workspace not initialized — freshness check unavailable');
576
+ }
577
+ freshness = r.skipped ? { status: 'skipped', reason: r.skipped } : r;
578
+ }
579
+
580
+ const count = (severity) => findings.filter((f) => f.severity === severity).length;
581
+ return {
582
+ issues: findings,
583
+ summary: {
584
+ root: absRoot,
585
+ issues: count('issue'),
586
+ warnings: count('warning'),
587
+ infos: count('info'),
588
+ alwaysLoaded,
589
+ canonical: canonicalSel
590
+ ? { status: canonicalSel.status, budget: canonicalSel.budgetBytes, current: canonicalSel.currentBytes }
591
+ : null,
592
+ freshness,
593
+ exitCode: count('issue') > 0 ? 1 : 0,
594
+ },
595
+ };
596
+ }
597
+
598
+ export function renderReport({ issues, summary }) {
599
+ const marks = { issue: '✗', warning: '⚠', info: 'ℹ' };
600
+ // A section with more than this many findings of one severity prints one
601
+ // summary line instead of the list — a report meant to be read caps its
602
+ // own length; --json carries the full list (gh:180 fix round).
603
+ const COLLAPSE_AT = 5;
604
+ const lines = [`Workspace audit — ${summary.root}`, ''];
605
+ for (const sec of SECTIONS) {
606
+ lines.push(`${sec.n}. ${sec.label}`);
607
+ const found = issues.filter((f) => f.section === sec.key);
608
+ if (found.length > 0) {
609
+ for (const severity of ['issue', 'warning', 'info']) {
610
+ const group = found.filter((f) => f.severity === severity);
611
+ if (group.length === 0) continue;
612
+ if (group.length > COLLAPSE_AT) {
613
+ const first3 = group.slice(0, 3).map((f) => f.file).join(', ');
614
+ lines.push(
615
+ ` ${marks[severity]} ${group.length} ${severity}(s) — ${first3}, +${group.length - 3} more (run with --json for the full list)`,
616
+ );
617
+ } else {
618
+ for (const f of group) {
619
+ lines.push(` ${marks[severity]} ${f.file}: ${f.message}${f.fromUpdate ? ' (from this update)' : ''}`);
620
+ }
621
+ }
622
+ }
623
+ } else if (sec.key === 'budget') {
624
+ lines.push(summary.alwaysLoaded && summary.alwaysLoaded.budgetBytes !== null
625
+ ? ` ✓ Always-loaded context: ${KB(summary.alwaysLoaded.totalBytes)} / ${KB(summary.alwaysLoaded.budgetBytes)}`
626
+ : ' ✓ Always-loaded context: no budget set (workspace.alwaysLoadedBudgetBytes absent)');
627
+ } else if (sec.key === 'freshness') {
628
+ const fr = summary.freshness;
629
+ if (fr && fr.status === 'current') lines.push(` ✓ Template is up to date (v${fr.latest}).`);
630
+ else lines.push(` - skipped (--offline)`);
631
+ } else {
632
+ lines.push(` ✓ ${sec.ok}`);
633
+ }
634
+ lines.push('');
635
+ }
636
+ lines.push(
637
+ `Result: ${summary.issues} issue(s), ${summary.warnings} warning(s), ${summary.infos} info — exit ${summary.exitCode}`,
638
+ );
639
+ return lines.join('\n');
640
+ }
641
+
642
+ export function parseArgs(argv) {
643
+ const args = { root: process.cwd(), json: false, offline: false, changed: [] };
644
+ for (let i = 2; i < argv.length; i++) {
645
+ const a = argv[i];
646
+ if (a === '--root') args.root = argv[++i];
647
+ else if (a === '--json') args.json = true;
648
+ else if (a === '--offline') args.offline = true;
649
+ else if (a === '--changed') args.changed.push(argv[++i]);
650
+ else throw new Error(`Unknown argument: ${a}`);
651
+ }
652
+ return args;
653
+ }
654
+
655
+ /**
656
+ * Expand --changed values into root-relative posix paths. A value naming an
657
+ * existing file whose every non-empty line is whitespace-free is read as a
658
+ * newline-separated path list; anything else is one path.
659
+ */
660
+ function expandChanged(absRoot, values) {
661
+ const out = new Set();
662
+ for (const value of values) {
663
+ let readAsList = false;
664
+ if (typeof value === 'string' && value.length > 0) {
665
+ let st; try { st = statSync(value); } catch { st = null; }
666
+ if (st?.isFile()) {
667
+ const lines = readFileSync(value, 'utf8').split('\n').map((l) => l.trim()).filter(Boolean);
668
+ if (lines.length > 0 && lines.every((l) => /^\S+$/.test(l))) {
669
+ for (const l of lines) out.add(toRootRelative(absRoot, l));
670
+ readAsList = true;
671
+ }
672
+ }
673
+ }
674
+ if (!readAsList) out.add(toRootRelative(absRoot, value));
675
+ }
676
+ return out;
677
+ }
678
+
679
+ async function main() {
680
+ const args = parseArgs(process.argv);
681
+ const absRoot = resolve(args.root);
682
+ const changed = expandChanged(absRoot, args.changed);
683
+ const result = await runAudit({ root: absRoot, changed: [...changed], offline: args.offline });
684
+ if (args.json) {
685
+ process.stdout.write(JSON.stringify(result, null, 2) + '\n');
686
+ } else {
687
+ process.stdout.write(renderReport(result) + '\n');
688
+ }
689
+ process.exitCode = result.summary.exitCode;
690
+ }
691
+
692
+ if (isMainModule(import.meta.url)) {
693
+ main().catch((err) => {
694
+ process.stderr.write(`maintenance-audit: ${err.message}\n`);
695
+ process.exit(2);
696
+ });
697
+ }