@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.
- package/README.md +1 -1
- package/lib/init.mjs +9 -0
- package/lib/init.test.mjs +75 -0
- package/lib/scaffold.mjs +8 -0
- package/lib/scaffold.test.mjs +20 -0
- package/package.json +1 -1
- package/template/CLAUDE.md.tmpl +2 -2
- package/template/_claude/hooks/workspace-update-check.mjs +4 -4
- package/template/_claude/scripts/build-workspace-context.mjs +2 -0
- package/template/_claude/scripts/classify-update.mjs +368 -17
- package/template/_claude/scripts/maintenance-audit.mjs +697 -0
- package/template/_claude/scripts/template-baseline.mjs +215 -0
- package/template/_claude/skills/maintenance/SKILL.md +48 -108
- package/template/_claude/skills/workspace-init/SKILL.md +6 -0
- package/template/_claude/skills/workspace-update/SKILL.md +71 -37
- package/template/workspace.json.tmpl +0 -1
|
@@ -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
|
+
}
|