@ulysses-ai/create-workspace 0.20.0-beta.0 → 0.21.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 CHANGED
@@ -62,7 +62,7 @@ cd my-workspace
62
62
  npx @ulysses-ai/create-workspace@beta --upgrade
63
63
  ```
64
64
 
65
- This stages the new template payload to `.workspace-update/` without changing anything yet. Open Claude Code and run `/workspace-update` — the skill applies each change interactively (asks how to resolve any file you've customized) and runs a maintenance audit before and after.
65
+ This stages the new template payload to `.workspace-update/` without changing anything yet. Open Claude Code and run `/workspace-update` — the skill applies each change interactively (asks how to resolve any file you've customized) and verifies the result with a scripted maintenance audit.
66
66
 
67
67
  ## Why "Ulysses"?
68
68
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ulysses-ai/create-workspace",
3
- "version": "0.20.0-beta.0",
3
+ "version": "0.21.0-beta.0",
4
4
  "description": "A workspace convention for Claude Code: sessions, handoffs, and shared context as files in git",
5
5
  "keywords": [
6
6
  "claude",
@@ -30,9 +30,9 @@ This is a claude-workspace. All conventions are defined in .claude/rules/.
30
30
  - `/promote` — move personal memory to shared context
31
31
  - `/release [version]` — cut a versioned release: bump, tag, forge release with generated notes
32
32
  - `/sync-work` — push branches without ceremony
33
- - `/workspace-update` — apply template updates (runs maintenance before/after)
33
+ - `/workspace-update` — apply template updates, verified by a post-update audit
34
34
  - `/setup-tracker` — wire this workspace to an issue tracker (GitHub Issues shipped; others pluggable)
35
- - `/maintenance [audit|cleanup]` — workspace health checks and cleanup
35
+ - `/maintenance [audit|cleanup]` — scripted health audit and cleanup
36
36
  - `/context-placement` — decide where durable content belongs and what each destination costs before writing it
37
37
  - `/goal-driven-work` — run multi-phase autonomous work under `/goal` with phase artifacts and agent-team dispatch
38
38
  - `/migrate-sessions` — drain session-model work sessions and switch the workspace to the task lifecycle
@@ -26,17 +26,17 @@ const stale = manifest?.timestamp
26
26
  const urgency = stale
27
27
  ? `URGENT: This update payload has been pending since ${manifest.timestamp}. It was not completed in a previous session. `
28
28
  : '';
29
- const skipAudit = stale
30
- ? 'Skip the pre-update audit and proceed directly to comparing and applying changes. '
29
+ const staleHint = stale
30
+ ? 'This payload survived a previous session — classify and apply the changes directly, and still run the post-update verification the skill ends with. '
31
31
  : '';
32
32
 
33
33
  if (action === 'init' || !initialized) {
34
34
  respond(`MANDATORY: ${urgency}A workspace init payload (template v${version}) is pending at .workspace-update/.
35
35
  Read .workspace-update/.claude/skills/workspace-init/SKILL.md and follow it before doing anything else.
36
- ${skipAudit}Do not proceed with the user's request until initialization is complete.`);
36
+ ${staleHint}Do not proceed with the user's request until initialization is complete.`);
37
37
  } else {
38
38
  const from = manifest?.fromVersion || 'unknown';
39
39
  respond(`MANDATORY: ${urgency}A workspace upgrade payload (v${from} → v${version}) is pending at .workspace-update/.
40
40
  Read .workspace-update/.claude/skills/workspace-update/SKILL.md and follow it before doing anything else.
41
- ${skipAudit}Do not proceed with the user's request until the update is complete.`);
41
+ ${staleHint}Do not proceed with the user's request until the update is complete.`);
42
42
  }
@@ -765,4 +765,6 @@ export {
765
765
  stripFrontmatter,
766
766
  gitIgnoredPaths,
767
767
  isLocalOnlyName,
768
+ readIgnorePrefixes,
769
+ isIgnored,
768
770
  };
@@ -10,10 +10,21 @@
10
10
  // this file from <workspace>/.workspace-update/.claude/scripts/)
11
11
  // --payload the staged payload; defaults to <root>/.workspace-update
12
12
  //
13
- // Prints JSON: { "new": [...], "identical": [...], "differs": [...] }
13
+ // Prints JSON with five lists:
14
14
  // new — no installed counterpart; safe to batch-apply after one confirm
15
15
  // identical — installed file already equals the payload byte-for-byte
16
16
  // differs — installed file differs; needs a per-file decision
17
+ // activated — the payload ships rules/{name}.md.skip while the workspace
18
+ // deliberately keeps {name}.md active; nothing to install, the
19
+ // active rule stays (gh:180)
20
+ // removed — installed file with no payload counterpart: the template
21
+ // stopped shipping it. Excludes what the workspace owns:
22
+ // *.test.mjs (the npm tarball does not ship tests, dev-checkout
23
+ // installs do — every test file would otherwise read as
24
+ // removed), anything gitignored (machine-local), paths under
25
+ // .claude/worktrees/, and entries of workspace.json →
26
+ // workspace.localFiles (array of .claude/-relative paths or
27
+ // globs for files this workspace owns) (gh:180)
17
28
  //
18
29
  // Only verbatim-installed files are classified: everything under .claude/,
19
30
  // plus .mcp.json and .claudeignore. The payload's templates (*.tmpl, which
@@ -30,6 +41,7 @@ import {
30
41
  } from 'node:fs';
31
42
  import { join, resolve } from 'node:path';
32
43
  import { fileURLToPath } from 'node:url';
44
+ import { gitIgnoredPaths } from './build-workspace-context.mjs';
33
45
 
34
46
  function isMainModule(metaUrl) {
35
47
  if (!process.argv[1]) return false;
@@ -58,7 +70,12 @@ function isClassified(payloadRelPath) {
58
70
  return VERBATIM_ROOTS.includes(first);
59
71
  }
60
72
 
61
- function* walkFiles(dir, prefix = '') {
73
+ // Directories never walked when looking for removed files. .claude/worktrees/
74
+ // holds entire nested worktrees — walking them is slow and every file inside
75
+ // is unmanaged by the template.
76
+ const SKIPPED_DIRS = new Set(['worktrees']);
77
+
78
+ function* walkFiles(dir, prefix = '', skipDirs = null) {
62
79
  let entries;
63
80
  try {
64
81
  entries = readdirSync(dir).sort();
@@ -66,15 +83,67 @@ function* walkFiles(dir, prefix = '') {
66
83
  return;
67
84
  }
68
85
  for (const name of entries) {
86
+ if (skipDirs && skipDirs.has(name)) continue;
69
87
  const rel = prefix ? `${prefix}/${name}` : name;
70
88
  const full = join(dir, name);
71
89
  let st;
72
90
  try { st = statSync(full); } catch { continue; }
73
- if (st.isDirectory()) yield* walkFiles(full, rel);
91
+ if (st.isDirectory()) yield* walkFiles(full, rel, skipDirs);
74
92
  else if (st.isFile()) yield rel;
75
93
  }
76
94
  }
77
95
 
96
+ // Installed files under the verbatim-managed roots: the .claude/ tree (minus
97
+ // skipped directories) plus the two standalone files. Nothing else in the
98
+ // workspace root is walked — repos/ and work-sessions/ hold entire worktrees
99
+ // the template never manages.
100
+ function* walkInstalledFiles(absRoot) {
101
+ yield* walkFiles(join(absRoot, '.claude'), '.claude', SKIPPED_DIRS);
102
+ for (const name of ['.mcp.json', '.claudeignore']) {
103
+ if (existsSync(join(absRoot, name))) yield name;
104
+ }
105
+ }
106
+
107
+ /** workspace.json → workspace.localFiles, normalized to .claude/-relative globs. */
108
+ function readLocalFiles(absRoot) {
109
+ const configPath = join(absRoot, 'workspace.json');
110
+ try {
111
+ const config = JSON.parse(readFileSync(configPath, 'utf8'));
112
+ const entries = config?.workspace?.localFiles;
113
+ if (!Array.isArray(entries)) return [];
114
+ return entries
115
+ .filter((e) => typeof e === 'string' && e.length > 0)
116
+ .map((e) => e.replace(/^(\.claude\/)+/, ''));
117
+ } catch {
118
+ return [];
119
+ }
120
+ }
121
+
122
+ /**
123
+ * Match `rel` (a .claude/-relative posix path) against a localFiles entry —
124
+ * an exact path or a glob where `**` spans separators and `*` does not.
125
+ * No glob library: the shapes localFiles needs are these two stars.
126
+ */
127
+ function globMatches(pattern, rel) {
128
+ if (pattern === rel) return true;
129
+ if (!pattern.includes('*')) return false;
130
+ const re = new RegExp(
131
+ `^${pattern.split('**').map(
132
+ (part) => part.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '[^/]*'),
133
+ ).join('.*')}$`,
134
+ );
135
+ return re.test(rel);
136
+ }
137
+
138
+ function isOwnedByWorkspace(rel, localFiles) {
139
+ if (rel.endsWith('.test.mjs')) return true;
140
+ // The template's own .gitignore declares these machine-local.
141
+ if (rel === '.claude/settings.local.json' || rel === '.claude/.active-session.json') return true;
142
+ if (!rel.startsWith('.claude/')) return false;
143
+ const claudeRel = rel.slice('.claude/'.length);
144
+ return localFiles.some((pattern) => globMatches(pattern, claudeRel));
145
+ }
146
+
78
147
  export function classifyUpdate({ root, payload }) {
79
148
  const absRoot = resolve(root);
80
149
  const absPayload = resolve(payload ?? join(absRoot, '.workspace-update'));
@@ -82,9 +151,20 @@ export function classifyUpdate({ root, payload }) {
82
151
  throw new Error(`No payload found at ${absPayload} — run npx @ulysses-ai/create-workspace --upgrade first`);
83
152
  }
84
153
 
85
- const result = { new: [], identical: [], differs: [] };
86
- for (const rel of walkFiles(absPayload)) {
87
- if (!isClassified(rel)) continue;
154
+ const payloadFiles = [...walkFiles(absPayload)].filter(isClassified);
155
+ const payloadSet = new Set(payloadFiles);
156
+
157
+ const result = { new: [], identical: [], differs: [], activated: [], removed: [] };
158
+ for (const rel of payloadFiles) {
159
+ // A .skip rule whose active counterpart is installed was deliberately
160
+ // activated by this workspace: report it as activated, not new.
161
+ if (rel.startsWith('.claude/rules/') && rel.endsWith('.md.skip')) {
162
+ const active = rel.replace(/\.skip$/, '');
163
+ if (existsSync(join(absRoot, active)) && !existsSync(join(absRoot, rel))) {
164
+ result.activated.push({ skip: rel, active });
165
+ continue;
166
+ }
167
+ }
88
168
  const installed = join(absRoot, rel);
89
169
  if (!existsSync(installed)) {
90
170
  result.new.push(rel);
@@ -98,6 +178,21 @@ export function classifyUpdate({ root, payload }) {
98
178
  result.differs.push(rel);
99
179
  }
100
180
  }
181
+
182
+ // Removed: installed verbatim-managed files with no payload counterpart.
183
+ const skipSet = new Set(payloadFiles);
184
+ const localFiles = readLocalFiles(absRoot);
185
+ const installedFiles = [...walkInstalledFiles(absRoot)];
186
+ const gitignored = gitIgnoredPaths(absRoot, installedFiles);
187
+ for (const rel of installedFiles) {
188
+ if (skipSet.has(rel)) continue;
189
+ // An active rule whose .skip twin is in the payload is an activated rule,
190
+ // not a removed one.
191
+ if (rel.startsWith('.claude/rules/') && rel.endsWith('.md') && skipSet.has(`${rel}.skip`)) continue;
192
+ if (gitignored.has(rel)) continue;
193
+ if (isOwnedByWorkspace(rel, localFiles)) continue;
194
+ result.removed.push(rel);
195
+ }
101
196
  return result;
102
197
  }
103
198
 
@@ -16,33 +16,41 @@ Keep the workspace healthy. Combines integrity auditing with active cleanup reco
16
16
 
17
17
  Read-only integrity checks. Reports problems but never modifies files.
18
18
 
19
+ The mechanical checks are scripted (gh:180) — run the audit and present its report:
20
+
21
+ ```bash
22
+ node .claude/scripts/maintenance-audit.mjs --root .
23
+ ```
24
+
25
+ The report prints one block per section — ✓ ok lines, ✗ issues, ⚠ warnings, ℹ infos — then a result summary. Severity drives the exit code: any issue-severity finding → exit `1`, otherwise `0` (a non-zero exit is the report above it, not a crash). `--offline` skips section 7, the only network user; `--json` emits `{issues, summary}` instead of the human report when another step needs the machine-readable numbers.
26
+
27
+ Sections 1–7 below explain what the script checks, so its findings can be interpreted. Each also names what it does not carry — those residual checks Claude performs alongside the script run.
28
+
19
29
  ### 1. Cross-reference consistency
20
- Scan all workspace-context files against each other:
21
- - **Stale references** — file A mentions "2 mandatory rules" but there are now 4
22
- - **Path references** — file A mentions a file that was moved or deleted
23
- - **Contradictions** — file A says "user-scoped is default" but file B says "root is default"
30
+ The script checks the CLAUDE.md skill list against `.claude/skills/` in both directions, and walks CLAUDE.md's `@`-import graph for dangling imports. A missing `local-only-*` file or the optional `CODEBASE.md` stub is info, not an issue — expected states on machines that never generated them.
31
+
32
+ Semantic drift between context files — one saying "2 mandatory rules" when there are now 4, or two files contradicting each other — is not mechanically decidable; cleanup step 10's reconciliation covers it.
24
33
 
25
34
  ### 2. Frontmatter integrity
26
- For each workspace-context `.md` file and each `work-sessions/*/workspace/session.md`:
27
- - Valid frontmatter? (state, lifecycle, type, topic, author, updated; plus name/status/branch/repos for session trackers)
28
- - `branch` field references a branch that still exists?
29
- - `repo`/`repos` field references repos that exist in workspace.json?
30
- - `lifecycle: active` on a file not updated in 7+ days? (stale candidate)
31
- - `lifecycle: resolved` files that should have been processed by /complete-work?
32
- - Session tracker `status: active` but the workspace worktree at `work-sessions/{name}/workspace/` is missing? (orphaned)
33
- - `confidence` field present? Must be one of `high`, `medium`, `low` if set.
35
+ For each non-gitignored workspace-context `.md` and each `work-sessions/*/workspace/session.md`, the script checks:
36
+ - Frontmatter parses
37
+ - Session trackers carry `name`, `status`, `branch`
38
+ - `branch` references a branch that still exists
39
+ - `repo`/`repos` reference repos in workspace.json
40
+ - `lifecycle: active` untouched in 7+ days (stale candidate)
41
+ - `lifecycle: resolved` (info — confirm /complete-work has processed it)
42
+ - `confidence`, when set, is one of `high`, `medium`, `low`
34
43
 
35
44
  ### 3. Workspace structure
36
- - Actual directory layout matches what workspace-structure rule describes?
37
- - CLAUDE.md references skills and rules that actually exist?
38
- - Orphaned rules or skills not referenced anywhere?
39
- - workspace.json repos all present in `repos/`?
45
+ The script checks that workspace.json and CLAUDE.md are present and parseable, that `workspace-context/` and `.claude/rules`, `skills`, `scripts` directories exist, and that every manifest repo is cloned under `repos/`.
40
46
 
41
47
  ### 4. Git state
48
+ The script covers the launcher itself: on its default branch, tracked tree clean. Untracked paths are info — gitignored content is not counted.
49
+
50
+ The worktree-level checks are not carried by the script; perform them alongside it:
42
51
  - Worktrees with no recent commits? (orphaned)
43
52
  - Local branches with no remote tracking? (unpushed work)
44
53
  - Worktrees whose branch has already been merged? (cleanup candidates)
45
- - Workspace repo on expected branch?
46
54
  - Orphan worktree records in project repos — run `git -C repos/{repo} worktree list` for each repo and flag any `prunable` markers. These usually come from a workspace-first teardown (the unsafe order) leaving stale admin records behind. Suggest `git worktree prune` on the affected repo.
47
55
  - Task-model state (gh:146), three checks:
48
56
  - **Unrecorded task worktrees** — list `repos/*/.claude/worktrees/*` and `.claude/worktrees/*`, read each candidate's branch (`git -C "{path}" rev-parse --abbrev-ref HEAD`), and keep only those on a task-prefixed branch (`feature/`, `bugfix/`, `chore/`) — Claude Code's own worktrees carry other branch names, so the prefix filter skips them without guessing a name convention. Cross-reference the chat records (`node .claude/scripts/chat-record.mjs --root . --list`): a task-prefixed worktree no record entry claims is *unrecorded* — it may be a legitimate no-tracker task (those are never recorded), so present it and ask before suggesting `node .claude/scripts/task-worktree.mjs --root . --remove --repo "{repo}" --branch "{branch}"`.
@@ -50,92 +58,29 @@ For each workspace-context `.md` file and each `work-sessions/*/workspace/sessio
50
58
  - **Merged but never completed** — a recorded task branch that already merged. Judge merged-ness by the forge's merged PRs for that repo, matching on head branch — never `git branch --merged`, which a squash merge (never an ancestor) silently misses. `/complete-work` never ran. Suggest running `/complete-work` for that branch (detection from the chat record finds it).
51
59
 
52
60
  ### 5. Workspace-context auto-file integrity
61
+ The script regenerates `workspace-context/index.md`, `canonical.md`, and each `workspace-context/team-member/{user}/index.md` in memory and compares fingerprints — the same semantics as `build-workspace-context.mjs --check`:
62
+ - Missing or stale artifact → issue; regenerate with `node .claude/scripts/build-workspace-context.mjs --write --root .`
63
+ - A missing gitignored per-user index → info (regenerated per machine, the normal fresh-checkout state)
64
+ - Canonical body over budget after trim and stub → warning, deferred to cleanup triage
65
+ - Trimmed or stubbed but within budget → info
53
66
 
54
- `workspace-context/index.md`, `workspace-context/canonical.md`, and each `workspace-context/team-member/{user}/index.md` are auto-generated from frontmatter and locked content. Run the check:
67
+ The canonical budget is opt-in: `workspace.canonicalBudgetBytes` off (absent or `null`) means canonical ships every locked file in full and over-budget cannot occur. When set, selection walks `ok` → `trimmed` → `stubbed` → `over-budget` as the generator gives up progressively more reference content to fit: `trimmedFiles` are reference files whose `<!-- canonical:trim --> ... <!-- canonical:end-trim -->` spans were dropped, `stubbedFiles` are reference files reduced to a one-line breadcrumb. `over-budget` means the budget is still exceeded after stubbing — regeneration cannot fix it; the locked content itself needs triage via `/maintenance cleanup` (step 11). Stale wins over over-budget: a stale canonical is reported as stale, hiding any over-budget condition until it is regenerated.
55
68
 
56
- ```bash
57
- node .claude/scripts/build-workspace-context.mjs --check --root .
58
- ```
59
-
60
- The script reports per-artifact status as JSON and uses three exit codes to distinguish what's wrong:
61
-
62
- - `0` — all artifacts current and, when a canonical budget is set, the rendered canonical fits inside `workspace.canonicalBudgetBytes`.
63
- - `1` — at least one artifact is `missing` or `stale`. Run `--write` to regenerate. `missing` means the artifact does not exist yet; `stale` means it exists but no longer matches its sources (a file was added or deleted, a `description:` changed, a `shared/locked/` file was edited, an `.indexignore` rule was added).
64
- - `2` — artifacts are current but canonical body bytes exceed the budget after the trim and stub stages have already run. Only reachable when a budget is set. Regeneration cannot fix this; the locked content itself needs triage. Stale wins over over-budget when both apply, so a `1` can hide an over-budget condition until you regen.
65
-
66
- The JSON payload always includes a `canonical` block summarizing the budget outcome:
67
-
68
- ```json
69
- {
70
- "status": "current",
71
- "missing": [],
72
- "stale": [],
73
- "canonical": {
74
- "budget": 40960,
75
- "current": 47802,
76
- "overBy": 6842,
77
- "selectionStatus": "stubbed",
78
- "trimmedFiles": ["post-release-discipline"],
79
- "stubbedFiles": ["project-status", "release-flow-recipes"]
80
- }
81
- }
82
- ```
83
-
84
- `selectionStatus` walks `ok` → `trimmed` → `stubbed` → `over-budget` as the script gives up progressively more reference content trying to fit the budget. `trimmedFiles` lists reference files whose `<!-- canonical:trim --> ... <!-- canonical:end-trim -->` spans were dropped; `stubbedFiles` lists reference files whose entire body was replaced with a one-line breadcrumb. `overBy` is present only when `selectionStatus === 'over-budget'` and reports the bytes still over after stubbing.
85
-
86
- The canonical budget is opt-in. `workspace.canonicalBudgetBytes` is off unless workspace.json sets it — absent or `null` means no budget. When off, `canonical.md` ships every locked file in full, the `canonical` block reports `"budget": null` with `selectionStatus: "ok"`, exit `2` cannot occur, and the audit reports one informational line in place of the budget OK/warning line:
87
-
88
- ```
89
- • Canonical budget: off (alwaysLoadedBudgetBytes covers the total)
90
- ```
91
-
92
- No warning accompanies it. To turn the budget back on, set a byte count in workspace.json (e.g. `"canonicalBudgetBytes": 40960`) and regenerate.
93
-
94
- Audit mode reports the status verbatim. When a budget is set and `selectionStatus` is `over-budget`, audit emits the budget violation and recommends `/maintenance cleanup` to triage — regeneration will not resolve it. Cleanup mode runs `--write` when `missing` or `stale`, re-checks, and then enters the budget triage flow described in cleanup step 11 if the post-regen check still reports `over-budget`.
95
-
96
- While the indexes are being read, also flag entries with weak fallbacks: filename-slug-only descriptions (e.g., "project status" with no period) usually indicate the underlying file is missing a `description:` or has no usable opening sentence. Suggest adding `description:` to those source files — the index will pick it up on the next regeneration.
69
+ Residual, not in the script: while reading the indexes, flag filename-slug-only descriptions (e.g., "project status" with no period) — usually the source file is missing a `description:` or has no usable opening sentence. Suggest adding `description:`; the index picks it up on the next regeneration.
97
70
 
98
71
  ### 6. Always-loaded context budget
72
+ The script measures everything Claude reads at launch — CLAUDE.md, its @-imports, and the active rules — against `workspace.alwaysLoadedBudgetBytes`. Rules with `paths:` frontmatter are conditional (they load only when a matching file is touched) and count separately; with no budget in workspace.json the check passes trivially.
99
73
 
100
- Everything Claude reads at launch — CLAUDE.md, its @-imports, and the active rules — is measured against `workspace.alwaysLoadedBudgetBytes`:
101
-
102
- ```bash
103
- node .claude/scripts/context-footprint.mjs --root .
104
- ```
105
-
106
- Rules carrying `paths:` frontmatter are conditional (they load only when a matching file is touched); the script lists them in a separate conditional section and excludes them from the total. With no `alwaysLoadedBudgetBytes` in workspace.json there is no budget and this check passes trivially.
107
-
108
- Within budget → an OK line: `✓ Always-loaded context: 43 KB / 64 KB`. Over budget → a Warning (the workspace still functions; this is drift, not breakage) naming the top contributors and the fixes:
109
-
110
- ```
111
- ⚠ Always-loaded context exceeds budget: 78 KB / 64 KB. Top contributors:
112
- .claude/rules/git-conventions.md (12 KB), CLAUDE.md (9 KB),
113
- .claude/rules/workspace-structure.md (8 KB). Scope situational rules with
114
- paths: frontmatter, or move reference content to shared/.
115
- ```
116
-
117
- The script itself exits `1` when over budget; `/maintenance` reports that as the warning above, not as a failed run.
74
+ Within budget → an OK line (`✓ Always-loaded context: 43 KB / 64 KB`). Over → a warning — the workspace still functions; this is drift, not breakage — naming the top contributors. Fixes: scope situational rules with `paths:` frontmatter, or move reference content to `shared/`.
118
75
 
119
76
  ### 7. Template freshness
77
+ The script invokes `refreshIfStale` with a 24h TTL regardless of `workspace.versionCheck.ambient` — the user explicitly ran `/maintenance` — and reports:
78
+ - `outdated` → warning: `Template v{current} → v{latest} available. Run npx @ulysses-ai/create-workspace --upgrade.`
79
+ - `current` → ✓ Template is up to date (v{latest})
80
+ - `unknown` → warning: could not reach the npm registry
81
+ - `skipped: 'uninitialized'` → info: workspace not initialized, freshness check unavailable
120
82
 
121
- Compare the workspace's pinned template version against the latest published on npm.
122
-
123
- Always invoke `refreshIfStale` from the audit (regardless of `workspace.versionCheck.ambient` — the user explicitly ran `/maintenance`):
124
-
125
- ```javascript
126
- import { refreshIfStale } from './.claude/lib/freshness.mjs';
127
- const result = await refreshIfStale({
128
- workspaceRoot: process.cwd(),
129
- ttlMs: 24 * 60 * 60 * 1000,
130
- });
131
- ```
132
-
133
- Report one of:
134
- - `outdated` → `✗ Template v{current} → v{latest} available. Run npx @ulysses-ai/create-workspace --upgrade.`
135
- - `current` → `✓ Template is up to date (v{latest}).`
136
- - `unknown` (with cache) → `⚠ Could not reach npm registry; last cached latest was v{latest} as of {checkedAt}.`
137
- - `unknown` (no cache) → `⚠ Could not reach npm registry; no cached version on file. Try again when online.`
138
- - `skipped: 'uninitialized'` → `⚠ Workspace not initialized; freshness check unavailable.`
83
+ An `outdated` result also rewrites the `local-only-template-freshness.md` banner and the version cache, as this check always has.
139
84
 
140
85
  ## Cleanup
141
86
 
@@ -173,7 +118,7 @@ When stale candidates are found, surface them as warnings in the output format a
173
118
 
174
119
  ### 11. Canonical budget triage
175
120
 
176
- This step runs only when a canonical budget is set (`workspace.canonicalBudgetBytes` holds a number) and the post-regen `--check` from the cleanup regen pass (Flow step 9) still reports `selectionStatus: 'over-budget'`. With the budget off — absent or `null` in workspace.json — `--check` can never report over-budget, so this step is unreachable. Skip it too if the regular regen pass cleared the budget, or if `--check` was already `ok`, `trimmed`, or `stubbed` after that pass.
121
+ This step runs only when a canonical budget is set (`workspace.canonicalBudgetBytes` holds a number) and the post-regen `--check` from the cleanup regen pass (Flow step 4) still reports `selectionStatus: 'over-budget'`. With the budget off — absent or `null` in workspace.json — `--check` can never report over-budget, so this step is unreachable. Skip it too if the regular regen pass cleared the budget, or if `--check` was already `ok`, `trimmed`, or `stubbed` after that pass.
177
122
 
178
123
  The rest of cleanup is suggestion-list-with-confirmation: surface a candidate, ask before applying, move on. Triage is the one meaningfully more interactive surface in `/maintenance`. It runs as a small REPL: present the budget state and a triage menu, take one action, re-run `--check`, present the menu again with the new state. No suggestion is auto-applied; every action is the user's choice.
179
124
 
@@ -231,8 +176,8 @@ Read `workspace.json`. If `workspace.tracker?.type === 'github-issues'` and `wor
231
176
  This is migration guidance for workspaces created before the `forge` field landed — the field is back-compat with a sensible default, so the unset case is not a bug, just an opportunity to make the implicit explicit. If `workspace.forge.type` is set to a value with no adapter at `.claude/scripts/forges/{type}.mjs`, that IS an error and goes in the Issues section.
232
177
 
233
178
  ### 13. Health metrics
234
- - Canonical budget — read from the same `--check` invocation as step 5. When a budget is set, reported as `current / budget` bytes with the selection status (e.g., `full`, `2 reference files trimmed`); over-budget cases are deferred to the cleanup triage flow rather than re-reported here. When off, report the step 5 one-liner: `• Canonical budget: off (alwaysLoadedBudgetBytes covers the total)`.
235
- - Always-loaded context — read from the same `context-footprint.mjs` invocation as audit step 6, reported the same way (`current / budget` bytes); over-budget is already surfaced as a warning there.
179
+ - Canonical budget — read from the audit script's section 5 outcome (the same regenerate-and-compare it already ran). When a budget is set, reported as `current / budget` bytes with the selection status (e.g., `full`, `2 reference files trimmed`); over-budget cases are deferred to the cleanup triage flow rather than re-reported here. When off, report the step 5 one-liner: `• Canonical budget: off (alwaysLoadedBudgetBytes covers the total)`.
180
+ - Always-loaded context — read from the audit script's section 6 outcome, reported the same way (`current / budget` bytes); over-budget is already surfaced as a warning there.
236
181
  - Number of ephemeral files — flag if accumulating without resolution
237
182
  - Session log stats (if `workspace-scratchpad/session-log.jsonl` exists):
238
183
  - Sessions without capture
@@ -272,16 +217,11 @@ OK (6):
272
217
 
273
218
  ## Flow
274
219
 
275
- 1. Scan workspace-context/ recursively — read all `.md` files and their frontmatter
276
- 2. Read CLAUDE.md — extract skill and rule references
277
- 3. Read workspace.json — extract repo manifest
278
- 4. Check `.claude/rules/`, `.claude/skills/`, `.claude/agents/` against references
279
- 5. Check git state (worktrees, branches, remotes)
280
- 6. Run `node .claude/scripts/build-workspace-context.mjs --check --root .` — capture status. Exit `0` = clean (and within budget when one is set), `1` = artifact missing or stale, `2` = artifacts current but canonical body over budget — only possible with a budget set. The `canonical` block in the JSON output drives both the audit budget line and the cleanup triage decision; `"budget": null` means the canonical budget is off.
281
- 7. Run `node .claude/scripts/context-footprint.mjs --root .` — capture the total and the `BUDGET` line. Exit `0` = within budget or no budget set; exit `1` = over budget, reported as a warning with the top contributors (audit step 6).
282
- 8. Read session-log.jsonl if it exists
283
- 9. If cleanup mode: regenerate the workspace-context auto-files if stale (index.md, canonical.md, per-user team-member indexes); compare files pairwise for overlap; scan for stale cross-references. If post-regen `--check` reports `over-budget`, enter the canonical-budget triage flow described in cleanup step 11.
284
- 10. Compile and present findings grouped by severity
220
+ 1. Run `node .claude/scripts/maintenance-audit.mjs --root .` and present its report — it carries audit sections 1–7. Add `--offline` when there is no network (section 7 is the only network user), or `--json` when step 13's health metrics want the machine-readable `summary.canonical` / `summary.alwaysLoaded` numbers.
221
+ 2. Perform the residual checks the script does not carry — the worktree-level git checks from audit section 4 (orphaned worktrees, unpushed branches, merged worktrees, prunable worktree records, the task-model checks against `chat-record.mjs --list`).
222
+ 3. Read session-log.jsonl if it exists (feeds step 13's session log stats)
223
+ 4. If cleanup mode: run `node .claude/scripts/build-workspace-context.mjs --check --root .` — exit `0` = clean (and within budget when one is set), `1` = artifact missing or stale → regenerate with `--write`, `2` = artifacts current but canonical body over budget (only possible with a budget set). The `canonical` block in the JSON drives the triage decision; `"budget": null` means the canonical budget is off. Then compare context files pairwise for overlap and scan for stale cross-references; if the post-regen `--check` reports `over-budget`, enter the canonical-budget triage flow described in cleanup step 11.
224
+ 5. Compile and present findings grouped by severity (Output Format above): the script's findings plus the residual checks, with cleanup suggestions from steps 3–4 folded in.
285
225
 
286
226
  ## Notes
287
227
  - Audit mode is always read-only — never modifies files
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  name: workspace-update
3
- description: Apply a staged template update to an initialized workspace. The CLI stages a payload in .workspace-update/; this skill processes it. Runs maintenance audit before and after.
3
+ description: Apply a staged template update to an initialized workspace. The CLI stages a payload in .workspace-update/; this skill processes it and verifies the result with a scripted audit.
4
4
  ---
5
5
 
6
6
  # Workspace Update
7
7
 
8
- Apply a staged template update to an initialized workspace. The CLI (`npx @ulysses-ai/create-workspace --upgrade`) stages the payload in `.workspace-update/`. This skill reads and applies it. Runs a maintenance audit before updating and verifies integrity after.
8
+ Apply a staged template update to an initialized workspace. The CLI (`npx @ulysses-ai/create-workspace --upgrade`) stages the payload in `.workspace-update/`. This skill reads and applies it, then verifies the result with the scripted maintenance audit — one audit, at the end, when there is something to verify (gh:180).
9
9
 
10
10
  ## Prerequisites
11
11
 
@@ -22,11 +22,7 @@ Apply a staged template update to an initialized workspace. The CLI (`npx @ulyss
22
22
 
23
23
  ## Flow
24
24
 
25
- ### Step 1: Pre-update health check
26
-
27
- Run `/maintenance audit` (read-only) to surface existing issues. Report findings briefly but **always continue to Step 2 immediately** — do not stop to ask about audit results. The audit is informational, not a gate. Any issues found will be included in the post-update report (Step 5) alongside the update results.
28
-
29
- ### Step 1b: Decide where the update lands
25
+ ### Step 1: Decide where the update lands
30
26
 
31
27
  Check the workspace repo for a remote (`git remote`). This decides where every later step works:
32
28
 
@@ -41,28 +37,28 @@ In the commands below, `{payload}` is `.workspace-update` in the no-remote flow
41
37
 
42
38
  ### Step 2: Classify the payload
43
39
 
44
- Run the classifier — it compares every verbatim-installed payload file (`.claude/**`, `.mcp.json`, `.claudeignore`) against the workspace by content:
40
+ Run the classifier — it compares every verbatim-installed payload file (`.claude/**`, `.mcp.json`, `.claudeignore`) against the workspace by content, and detects files the template no longer ships:
45
41
 
46
42
  ```bash
47
43
  node {payload}/.claude/scripts/classify-update.mjs --root . --payload {payload}
48
44
  ```
49
45
 
50
- It runs from the payload precisely so workspaces that don't have it installed yet can use it; once installed, `node .claude/scripts/classify-update.mjs --root .` is equivalent. Output is JSON with three lists:
46
+ It runs from the payload precisely so workspaces that don't have it installed yet can use it; once installed, `node .claude/scripts/classify-update.mjs --root .` is equivalent. Output is JSON with five lists:
51
47
 
52
48
  - `new` — no installed counterpart; safe to batch-apply (Step 3) behind one confirmation
53
49
  - `identical` — installed file already equals the payload; skip silently
54
50
  - `differs` — installed file was locally modified; needs a per-file decision
51
+ - `activated` — the payload ships `rules/{name}.md.skip` while the workspace keeps `{name}.md` active: the rule was deliberately activated. Nothing to install — the active rule stays.
52
+ - `removed` — installed file with no counterpart in the payload. Tests (`*.test.mjs`), gitignored paths, `.claude/worktrees/`, and files listed in `workspace.json` → `workspace.localFiles` (an array of `.claude/`-relative paths or globs the workspace owns) are excluded automatically, so only real template removals are listed.
55
53
 
56
54
  Templates (`*.tmpl`, which install with `{{project-name}}` substitution), `_gitignore` (merged line-by-line), and `.manifest.json` (payload metadata) are not classified — each is handled by its own sub-step in Step 3.
57
55
 
58
- Also list **removed files**: files present in the local `.claude/{component}/` with no counterpart in `{payload}/.claude/{component}/`.
59
-
60
56
  Report with version info from the manifest:
61
57
  ```
62
- "Template update: v{fromVersion} → v{templateVersion}. {N} new files, {M} locally modified, {R} removed files, {K} unchanged."
58
+ "Template update: v{fromVersion} → v{templateVersion}. {N} new files, {M} locally modified, {A} activated rules, {R} removed files, {K} unchanged."
63
59
  ```
64
60
 
65
- If `new`, `differs`, and the removed list are all empty, report: "Workspace is up to date (template v{templateVersion}). No changes needed."
61
+ If `new`, `differs`, `activated`, and `removed` are all empty, report: "Workspace is up to date (template v{templateVersion}). No changes needed."
66
62
 
67
63
  ### Step 2b: Historical .gitignore safety check
68
64
 
@@ -87,14 +83,14 @@ Batch the safe case, ask on the rest:
87
83
 
88
84
  - **New files (`new`):** present the list once — "Apply these {N} new files? [Y/n]" — and install them all on confirmation. No per-file prompting.
89
85
  - **Locally modified (`differs`):** ask per file — "Template updated {file} but you have local changes. Show diff? [y/N]" — then apply, keep, or merge per the user's decision.
90
- - **Removed in template:** "Template removed {file}. Delete locally? [y/N]" — conservative default.
86
+ - **Removed in template (`removed`):** "Template removed {file}. Delete locally? [y/N]" — conservative default.
87
+ - **Activated rules (`activated`):** keep the active file, install nothing — report: "Rule {name} is active here and optional in the template; your activation is preserved."
91
88
  - **Hook migration (.sh to .mjs):** Detect old `.sh` hooks in `.claude/hooks/` that have `.mjs` replacements in the payload. Offer: "Hook {name}.sh has a .mjs replacement in the update. Replace and update settings.json commands? [Y/n]" — this is a one-time migration for workspaces upgrading from pre-0.2.0
92
89
 
93
90
  Also handle these non-component files from the payload:
94
91
 
95
92
  - **settings.json:** Merge payload values into existing `.claude/settings.json` — do not overwrite user customizations. Add new keys, update hook commands if hooks were migrated, preserve user-added entries.
96
93
  - **workspace.json keys:** Compare the `workspace` object of the payload's `workspace.json.tmpl` with the installed `workspace.json`, key by key. For each key the template ships that the workspace lacks, show its template default and ask before adding. Never remove an existing key just because the template no longer ships it. `canonicalBudgetBytes` is opt-in since v0.19 — leave an existing value alone and mention it can be removed to turn the budget off.
97
- - **Rules renamed to `.skip`:** For each active `.claude/rules/{name}.md` whose template counterpart now ships as `{name}.md.skip`, keep the active file — it was deliberately activated — and tell the user that's what happened.
98
94
  - **CLAUDE.md:** If `{payload}/CLAUDE.md.tmpl` exists, regenerate `CLAUDE.md` from the template. Preserve any user-added sections not present in the template.
99
95
  - **.gitignore:** Merge new entries from the payload's `_gitignore` into the existing `.gitignore` — do not remove user-added lines.
100
96
 
@@ -104,7 +100,7 @@ Read `templateVersion` from `.workspace-update/.manifest.json` and update `templ
104
100
 
105
101
  ### Step 4a: Run idempotent migrators
106
102
 
107
- The payload may include migrator scripts at `{payload}/.claude/scripts/migrate-*.mjs` that bring older workspaces forward in shape. They are idempotent — safe to re-run on already-migrated workspaces. Run each one in document order and surface its action in the upgrade summary.
103
+ Two migrators run on **every** update — both idempotent, safe on already-migrated workspaces. Run each and surface its action in the upgrade summary.
108
104
 
109
105
  ```bash
110
106
  node {payload}/.claude/scripts/migrate-claude-md-freshness-include.mjs --root .
@@ -120,20 +116,20 @@ Output is JSON: `{"action":"appended"|"unchanged"|"skipped"}`.
120
116
  node {payload}/.claude/scripts/migrate-canonical-priority.mjs --root .
121
117
  ```
122
118
 
123
- Output is JSON: `{"status":"applied"|"noop","files":[...]}`. Back-fills `priority: critical` on every `workspace-context/shared/locked/*.md` that lacks the field, preserving today's full-load behavior until the user explicitly demotes a file. Skips `local-only-*` files — they are machine-local, never canonical. Idempotent.
119
+ Output is JSON: `{"status":"applied"|"noop","files":[...]}`. Back-fills `priority: critical` on every `workspace-context/shared/locked/*.md` that lacks the field, preserving today's full-load behavior until the user explicitly demotes a file. Skips `local-only-*` files — they are machine-local, never canonical.
124
120
 
125
- Always run migrators with `--root .`. They resolve the workspace root from `--root` (default: the cwd) and never from their own location — a migrator invoked from the payload without `--root` would look for the workspace inside `.workspace-update/`.
121
+ The other `migrate-*.mjs` in the payload are **one-shot, version-gated migrations**: each applies to a specific step of the template's history and runs only when the manifest's `fromVersion` is older than the version that introduced it. Never run them unconditionally.
126
122
 
127
- Add other migrators here as the template ships them.
123
+ - `migrate-session-layout.mjs` — pre-v0.10.0 → v0.10.0: moves session content from launcher-side paths into each session's workspace worktree.
124
+ - `migrate-to-workspace-context.mjs` — pre-v0.15.0 → v0.15.0: renames `shared-context/` to `workspace-context/` and rebuilds its structure.
125
+ - `migrate-sessions.mjs` — pre-v0.18.0: drains session-model work sessions onto the task lifecycle. Not run inline — it has interactive inventory/backup/archive modes; `/migrate-sessions` drives it (Step 8 nudges when it applies).
126
+ - `migrate-open-work.mjs` — manual, any version: converts the deprecated `open-work.md` into tracker issues. Takes the file path as an argument and needs a configured tracker; run it only if the workspace still carries an `open-work.md` and the user asks.
128
127
 
129
- ### Step 5: Post-update verification
128
+ Always run migrators with `--root .`. They resolve the workspace root from `--root` (default: the cwd) and never from their own location — a migrator invoked from the payload without `--root` would look for the workspace inside `.workspace-update/`.
130
129
 
131
- Run `/maintenance audit` again to verify the update didn't introduce:
132
- - Broken references (new skills not in CLAUDE.md, removed rules still referenced)
133
- - Contradictions between updated rules and existing shared context
134
- - Structural mismatches
130
+ ### Step 5: Post-update verification
135
131
 
136
- Then regenerate the context catalogs — an update that adds or renames files under `.claude/` or `workspace-context/` leaves `index.md`/`canonical.md` stale until they are rebuilt:
132
+ First regenerate the context catalogs — an update that adds or renames files under `.claude/` or `workspace-context/` leaves `index.md`/`canonical.md` stale until they are rebuilt:
137
133
 
138
134
  ```bash
139
135
  node .claude/scripts/build-workspace-context.mjs --write --root .
@@ -142,22 +138,36 @@ node .claude/scripts/build-workspace-context.mjs --check --root .
142
138
 
143
139
  `--check` must exit clean. If it reports drift, re-run `--write` and check again before continuing.
144
140
 
141
+ Then run the scripted audit — once, covering everything (gh:180). Build the changed list — every file this update added or changed: the applied `new` and `differs` files plus the merged ones (`CLAUDE.md`, `workspace.json`, `.gitignore`, `.claude/settings.json`, `.mcp.json` as touched) — one path per line into a temp file such as `workspace-scratchpad/update-changed.txt`, then:
142
+
143
+ ```bash
144
+ node {payload}/.claude/scripts/maintenance-audit.mjs --root . --changed workspace-scratchpad/update-changed.txt
145
+ ```
146
+
147
+ Run it from the payload for the same reason as the classifier in Step 2: the workspace's own copy may predate this update. It reuses the sections of `/maintenance` audit that a script can decide (cross-references, frontmatter, structure, git state, catalog integrity, budgets, freshness) and marks findings on changed files `(from this update)`.
148
+
149
+ Present its report as the verification result. Git-state warnings are expected here — the update's commit hasn't landed yet (Step 7). Delete the temp changed list afterwards.
150
+
151
+ - Findings labeled `(from this update)` — caused by this update; fix before committing (usually a new skill missing from CLAUDE.md's list, or a stale catalog).
152
+ - Other findings — pre-existing; mention briefly.
153
+ - If the audit found issue-severity findings, offer to walk through them with the full `/maintenance` skill. Warnings and infos alone need no follow-up.
154
+
145
155
  Report: "Post-update verification: {N} issues found" or "Post-update verification clean."
146
156
 
147
157
  ### Step 6: Cleanup
148
158
 
149
- Delete the `.workspace-update/` directory at the launcher — in the worktree flow (Step 1b) that happens after the merge and pull in Step 7. The payload has been fully processed and is no longer needed.
159
+ Delete the `.workspace-update/` directory at the launcher — in the worktree flow (Step 1) that happens after the merge and pull in Step 7. The payload has been fully processed and is no longer needed.
150
160
 
151
161
  ### Step 7: Commit
152
162
 
153
- Where the commit lands was decided in Step 1b.
163
+ Where the commit lands was decided in Step 1.
154
164
 
155
165
  - **No remote:** commit in place on the launcher's default branch — the one sanctioned launcher commit:
156
166
  ```bash
157
167
  git add -A
158
168
  git commit -m "chore: update workspace from template v{fromVersion} to v{templateVersion}"
159
169
  ```
160
- - **Remote exists:** from the task worktree created in Step 1b, commit the applied update, push the branch, and open a PR through the forge adapter — `node .claude/scripts/task-pr.mjs` when the workspace has it, otherwise the adapter under `.claude/scripts/forges/`. After the PR merges, pull the launcher, then delete the payload (Step 6).
170
+ - **Remote exists:** from the task worktree created in Step 1, commit the applied update, push the branch, and open a PR through the forge adapter — `node .claude/scripts/task-pr.mjs` when the workspace has it, otherwise the adapter under `.claude/scripts/forges/`. After the PR merges, pull the launcher, then delete the payload (Step 6).
161
171
 
162
172
  Report: "Workspace updated to v{templateVersion}. Restart Claude Code if rules or hooks changed."
163
173
 
@@ -174,4 +184,4 @@ After the update is applied, if the sessions directory (`workspace.workSessionsD
174
184
  - Can be run multiple times safely (idempotent) — if `.workspace-update/` doesn't exist, it reports no payload and exits
175
185
  - Initial setup is handled by `npx @ulysses-ai/create-workspace --init` + `/workspace-init` — this skill is for subsequent updates only
176
186
  - The `.sh` to `.mjs` hook migration is a one-time transition for workspaces created before hooks moved to JavaScript
177
- - The maintenance audits are read-only and non-blocking — they surface issues but don't prevent the update
187
+ - The post-update audit is read-only and non-blocking — it surfaces issues, labels the ones this update caused, and never prevents the update itself
@@ -10,7 +10,6 @@
10
10
  "subagentContextMaxBytes": 32768,
11
11
  "subagentInlineMaxBytes": 8192,
12
12
  "greeting": "Welcome back to {{project-name}}.",
13
- "releaseMode": "per-repo",
14
13
  "sessionModel": "session",
15
14
  "tracker": null,
16
15
  "forge": { "type": "github" }