@ulysses-ai/create-workspace 0.19.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.
Files changed (28) hide show
  1. package/README.md +1 -1
  2. package/lib/upgrade.mjs +20 -0
  3. package/lib/upgrade.test.mjs +119 -0
  4. package/package.json +3 -3
  5. package/template/CLAUDE.md.tmpl +5 -2
  6. package/template/_claude/hooks/workspace-update-check.mjs +4 -4
  7. package/template/_claude/lib/freshness.mjs +20 -7
  8. package/template/_claude/lib/registry-check.mjs +80 -14
  9. package/template/_claude/scripts/build-workspace-context.mjs +2 -0
  10. package/template/_claude/scripts/chat-record.mjs +27 -19
  11. package/template/_claude/scripts/classify-update.mjs +212 -0
  12. package/template/_claude/scripts/cleanup-work-session.mjs +4 -2
  13. package/template/_claude/scripts/maintenance-audit.mjs +0 -0
  14. package/template/_claude/scripts/merge-mode.mjs +61 -0
  15. package/template/_claude/scripts/migrate-canonical-priority.mjs +7 -1
  16. package/template/_claude/scripts/migrate-claude-md-freshness-include.mjs +35 -10
  17. package/template/_claude/scripts/migrate-session-layout.mjs +19 -8
  18. package/template/_claude/scripts/migrate-sessions.mjs +444 -121
  19. package/template/_claude/scripts/task-pr.mjs +213 -105
  20. package/template/_claude/scripts/task-worktree.mjs +26 -16
  21. package/template/_claude/skills/complete-work/SKILL.md +16 -12
  22. package/template/_claude/skills/maintenance/SKILL.md +48 -108
  23. package/template/_claude/skills/migrate-sessions/SKILL.md +17 -5
  24. package/template/_claude/skills/start-work/SKILL.md +4 -4
  25. package/template/_claude/skills/workspace-init/SKILL.md +20 -14
  26. package/template/_claude/skills/workspace-update/SKILL.md +93 -40
  27. package/template/_gitignore +3 -0
  28. package/template/workspace.json.tmpl +0 -1
@@ -0,0 +1,212 @@
1
+ #!/usr/bin/env node
2
+ // Classify an upgrade payload's files against the workspace so /workspace-update
3
+ // can batch the safe cases and ask only where a decision is needed.
4
+ //
5
+ // Usage:
6
+ // node classify-update.mjs [--root <dir>] [--payload <dir>]
7
+ //
8
+ // --root workspace root; defaults to the current working directory (never
9
+ // derived from this script's location — the upgrade payload runs
10
+ // this file from <workspace>/.workspace-update/.claude/scripts/)
11
+ // --payload the staged payload; defaults to <root>/.workspace-update
12
+ //
13
+ // Prints JSON with five lists:
14
+ // new — no installed counterpart; safe to batch-apply after one confirm
15
+ // identical — installed file already equals the payload byte-for-byte
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)
28
+ //
29
+ // Only verbatim-installed files are classified: everything under .claude/,
30
+ // plus .mcp.json and .claudeignore. The payload's templates (*.tmpl, which
31
+ // install with {{project-name}} substitution), _gitignore (merged line-by-line
32
+ // into the workspace's .gitignore), and .manifest.json (payload metadata) are
33
+ // handled by their own steps in /workspace-update and are excluded here.
34
+
35
+ import {
36
+ existsSync,
37
+ readFileSync,
38
+ readdirSync,
39
+ statSync,
40
+ realpathSync,
41
+ } from 'node:fs';
42
+ import { join, resolve } from 'node:path';
43
+ import { fileURLToPath } from 'node:url';
44
+ import { gitIgnoredPaths } from './build-workspace-context.mjs';
45
+
46
+ function isMainModule(metaUrl) {
47
+ if (!process.argv[1]) return false;
48
+ try {
49
+ return realpathSync(fileURLToPath(metaUrl)) === realpathSync(process.argv[1]);
50
+ } catch { return false; }
51
+ }
52
+
53
+ function parseArgs(argv) {
54
+ const args = { root: process.cwd(), payload: null };
55
+ for (let i = 2; i < argv.length; i++) {
56
+ const a = argv[i];
57
+ if (a === '--root') args.root = argv[++i];
58
+ else if (a === '--payload') args.payload = argv[++i];
59
+ else throw new Error(`Unknown arg: ${a}`);
60
+ }
61
+ return args;
62
+ }
63
+
64
+ // Payload-relative paths that install verbatim at the same relative path.
65
+ // Everything else in the payload is a template or metadata handled elsewhere.
66
+ const VERBATIM_ROOTS = ['.claude', '.mcp.json', '.claudeignore'];
67
+
68
+ function isClassified(payloadRelPath) {
69
+ const first = payloadRelPath.split('/')[0];
70
+ return VERBATIM_ROOTS.includes(first);
71
+ }
72
+
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) {
79
+ let entries;
80
+ try {
81
+ entries = readdirSync(dir).sort();
82
+ } catch {
83
+ return;
84
+ }
85
+ for (const name of entries) {
86
+ if (skipDirs && skipDirs.has(name)) continue;
87
+ const rel = prefix ? `${prefix}/${name}` : name;
88
+ const full = join(dir, name);
89
+ let st;
90
+ try { st = statSync(full); } catch { continue; }
91
+ if (st.isDirectory()) yield* walkFiles(full, rel, skipDirs);
92
+ else if (st.isFile()) yield rel;
93
+ }
94
+ }
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
+
147
+ export function classifyUpdate({ root, payload }) {
148
+ const absRoot = resolve(root);
149
+ const absPayload = resolve(payload ?? join(absRoot, '.workspace-update'));
150
+ if (!existsSync(absPayload)) {
151
+ throw new Error(`No payload found at ${absPayload} — run npx @ulysses-ai/create-workspace --upgrade first`);
152
+ }
153
+
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
+ }
168
+ const installed = join(absRoot, rel);
169
+ if (!existsSync(installed)) {
170
+ result.new.push(rel);
171
+ continue;
172
+ }
173
+ const payloadBytes = readFileSync(join(absPayload, rel));
174
+ const installedBytes = readFileSync(installed);
175
+ if (Buffer.compare(payloadBytes, installedBytes) === 0) {
176
+ result.identical.push(rel);
177
+ } else {
178
+ result.differs.push(rel);
179
+ }
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
+ }
196
+ return result;
197
+ }
198
+
199
+ function main() {
200
+ const args = parseArgs(process.argv);
201
+ const result = classifyUpdate({ root: args.root, payload: args.payload });
202
+ process.stdout.write(JSON.stringify(result, null, 2) + '\n');
203
+ }
204
+
205
+ if (isMainModule(import.meta.url)) {
206
+ try {
207
+ main();
208
+ } catch (err) {
209
+ process.stderr.write(`classify-update: ${err.message}\n`);
210
+ process.exit(1);
211
+ }
212
+ }
@@ -146,9 +146,11 @@ if (repos.length === 0) {
146
146
  if (found.length > 0) {
147
147
  repos = found;
148
148
  discovered = true;
149
+ // Informational wording: discovery is a normal success path here
150
+ // (gh:172) — not a warning about the tracker.
149
151
  skipped.push({
150
152
  step: 'discovery',
151
- reason: `Tracker missing repos; discovered ${found.length} from ${nestedReposDir}: ${found.join(', ')}`,
153
+ reason: `repos discovered from disk: ${found.length} (${found.join(', ')})`,
152
154
  });
153
155
  }
154
156
  } catch (err) {
@@ -209,7 +211,7 @@ if (!branch && existsSync(wsWorktree)) {
209
211
  const res = git(wsWorktree, ['rev-parse', '--abbrev-ref', 'HEAD']);
210
212
  if (res.ok && res.out.trim() !== '' && res.out.trim() !== 'HEAD') {
211
213
  branch = res.out.trim();
212
- skipped.push({ step: 'discovery', reason: `Tracker missing branch; discovered from worktree: ${branch}` });
214
+ skipped.push({ step: 'discovery', reason: `branch discovered from the worktree: ${branch}` });
213
215
  }
214
216
  }
215
217
 
@@ -0,0 +1,61 @@
1
+ // Merge-mode resolution for the task lifecycle (gh:173), shared by
2
+ // task-worktree.mjs (which branch a new task worktree starts from) and
3
+ // task-pr.mjs (push-and-PR versus merge-in-the-source-clone). It lives in
4
+ // its own module because those two import each other's helpers: housing
5
+ // the rule here keeps its definition singular without a circular import.
6
+
7
+ import { readFileSync } from 'node:fs';
8
+ import { join, resolve } from 'node:path';
9
+ import { spawnSync } from 'node:child_process';
10
+
11
+ // The workspace repo (the launcher) is addressed as "." — the one repo
12
+ // name that is not a directory under repos/.
13
+ const WORKSPACE_REPO = '.';
14
+
15
+ // "." is the workspace repo itself — the git repo at the root. Every other
16
+ // name is a directory under repos/.
17
+ function repoDirFor(rootDir, repo) {
18
+ return repo === WORKSPACE_REPO ? rootDir : join(rootDir, 'repos', repo);
19
+ }
20
+
21
+ function readWorkspace(rootDir) {
22
+ try {
23
+ return JSON.parse(readFileSync(join(rootDir, 'workspace.json'), 'utf-8'));
24
+ } catch {
25
+ throw new Error(`cannot read ${join(rootDir, 'workspace.json')} — is --root the launcher?`);
26
+ }
27
+ }
28
+
29
+ // Same remote shapes the forge adapters resolve a repo from. A URL that
30
+ // does not match is not forge-hosted, and the forge path supports
31
+ // forge-hosted repos only — a local/bare remote has no PR concept to aim at.
32
+ const FORGE_REMOTE_RE = /github\.com[:/]([^/]+)\/([^/.]+?)(?:\.git)?$/;
33
+
34
+ function parseForgeRemote(url) {
35
+ const m = String(url).trim().match(FORGE_REMOTE_RE);
36
+ return m ? { owner: m[1], name: m[2] } : null;
37
+ }
38
+
39
+ /**
40
+ * Resolve how a task repo merges: "local" — nothing pushed, merged in the
41
+ * repo's own source clone — when workspace.json asks for it
42
+ * (repos.{repo}.merge, or workspace.merge for the workspace repo; the
43
+ * right call for a clone whose origin is a third-party upstream nobody
44
+ * here may push to) or when the repo has no origin remote at all;
45
+ * "forge" — pushed and PR'd — when its origin parses as a forge-hosted
46
+ * owner/name. An origin that is neither (say a local bare mirror with no
47
+ * override) resolves to null: the caller stops with the override spelled
48
+ * out rather than pushing somewhere that cannot host a PR.
49
+ */
50
+ function mergeModeFor(root, repo, deps = {}) {
51
+ const gitFn = deps.gitFn ?? spawnSync;
52
+ const rootDir = resolve(root);
53
+ const ws = readWorkspace(rootDir);
54
+ const override = repo === WORKSPACE_REPO ? ws?.workspace?.merge : ws?.repos?.[repo]?.merge;
55
+ if (override === 'local') return 'local';
56
+ const res = gitFn('git', ['-C', repoDirFor(rootDir, repo), 'remote', 'get-url', 'origin'], { encoding: 'utf8' });
57
+ if (res.error || res.status !== 0) return 'local'; // no origin — nowhere to push
58
+ return parseForgeRemote(String(res.stdout || '').trim()) ? 'forge' : null;
59
+ }
60
+
61
+ export { WORKSPACE_REPO, repoDirFor, readWorkspace, parseForgeRemote, mergeModeFor };
@@ -7,7 +7,9 @@
7
7
  // node migrate-canonical-priority.mjs [--root <path>]
8
8
  //
9
9
  // Walks <root>/workspace-context/shared/locked/*.md. For each file:
10
- // - Skip non-.md files and files without parseable frontmatter (warn to stderr).
10
+ // - Skip non-.md files, local-only-* files (machine-local drafts that
11
+ // happen to sit under shared/locked/ — never canonical), and files
12
+ // without parseable frontmatter (warn to stderr).
11
13
  // - If `priority` is already set (any value), leave the file untouched.
12
14
  // - Otherwise add `priority: critical` losslessly via updateSessionContent.
13
15
  //
@@ -62,6 +64,10 @@ export function migrateCanonicalPriority({ root }) {
62
64
 
63
65
  for (const name of entries) {
64
66
  if (!name.endsWith('.md')) continue;
67
+ // local-only-* files are machine-local and gitignored — they are never
68
+ // canonical, so back-filling a canonical priority field on them would be
69
+ // both wrong and a surprise on the next build-workspace-context run.
70
+ if (name.startsWith('local-only-')) continue;
65
71
  const full = join(lockedDir, name);
66
72
  let st;
67
73
  try { st = statSync(full); } catch { continue; }
@@ -2,10 +2,16 @@
2
2
  // Idempotent migrator: ensures CLAUDE.md includes @local-only-template-freshness.md.
3
3
  // Appends one line at end if missing. Preserves the rest of the file byte-for-byte.
4
4
  //
5
- // Run standalone: node .claude/scripts/migrate-claude-md-freshness-include.mjs
5
+ // Run standalone: node migrate-claude-md-freshness-include.mjs [--root <dir>]
6
6
  // Or import { runMigration } and call programmatically.
7
- import { existsSync, readFileSync, writeFileSync } from 'fs';
8
- import { join, dirname, resolve } from 'path';
7
+ //
8
+ // The root is --root when given, else the current working directory. It is
9
+ // never derived from this script's location: the upgrade payload runs this
10
+ // file from <workspace>/.workspace-update/.claude/scripts/, and a
11
+ // script-relative root would point inside the payload instead of at the
12
+ // workspace.
13
+ import { existsSync, readFileSync, writeFileSync, realpathSync } from 'fs';
14
+ import { join, resolve } from 'path';
9
15
  import { fileURLToPath } from 'url';
10
16
 
11
17
  const INCLUDE_LINE = '@local-only-template-freshness.md';
@@ -20,11 +26,30 @@ export function runMigration({ workspaceRoot }) {
20
26
  return { action: 'appended' };
21
27
  }
22
28
 
23
- // CLI entry point — workspace root is two levels up from this file
24
- // (.claude/scripts/migrate-... → workspace root).
25
- if (import.meta.url === `file://${process.argv[1]}`) {
26
- const here = dirname(fileURLToPath(import.meta.url));
27
- const root = resolve(here, '..', '..');
28
- const result = runMigration({ workspaceRoot: root });
29
- console.log(JSON.stringify(result));
29
+ function isMainModule(metaUrl) {
30
+ if (!process.argv[1]) return false;
31
+ try {
32
+ return realpathSync(fileURLToPath(metaUrl)) === realpathSync(process.argv[1]);
33
+ } catch { return false; }
34
+ }
35
+
36
+ function parseArgs(argv) {
37
+ const args = { root: process.cwd() };
38
+ for (let i = 2; i < argv.length; i++) {
39
+ const a = argv[i];
40
+ if (a === '--root') args.root = argv[++i];
41
+ else throw new Error(`Unknown arg: ${a}`);
42
+ }
43
+ return args;
44
+ }
45
+
46
+ if (isMainModule(import.meta.url)) {
47
+ try {
48
+ const args = parseArgs(process.argv);
49
+ const result = runMigration({ workspaceRoot: resolve(args.root) });
50
+ console.log(JSON.stringify(result));
51
+ } catch (err) {
52
+ console.error(`migrate-claude-md-freshness-include: ${err.message}`);
53
+ process.exit(1);
54
+ }
30
55
  }
@@ -22,15 +22,26 @@ import {
22
22
  readdirSync,
23
23
  copyFileSync,
24
24
  statSync,
25
+ realpathSync,
25
26
  } from 'fs';
26
27
  import { join } from 'path';
28
+ import { fileURLToPath } from 'url';
27
29
  import {
28
- getWorkspaceRoot,
29
30
  getWorkspacePaths,
30
31
  getMainRoot,
31
32
  readJSON,
32
33
  } from '../hooks/_utils.mjs';
33
34
 
35
+ // Compare real paths: macOS temp dirs are symlinked (/var → /private/var),
36
+ // so a naive import.meta.url === `file://${process.argv[1]}` silently fails
37
+ // to detect main-module runs from tmpdir fixtures.
38
+ function isMainModule(metaUrl) {
39
+ if (!process.argv[1]) return false;
40
+ try {
41
+ return realpathSync(fileURLToPath(metaUrl)) === realpathSync(process.argv[1]);
42
+ } catch { return false; }
43
+ }
44
+
34
45
  export function migrateSession(root, sessionName) {
35
46
  const { workSessionsDir } = getWorkspacePaths(root);
36
47
  const sessionFolder = join(workSessionsDir, sessionName);
@@ -189,19 +200,19 @@ export function migrateMain(root) {
189
200
  }
190
201
 
191
202
  // CLI entry
192
- if (import.meta.url === `file://${process.argv[1]}`) {
203
+ if (isMainModule(import.meta.url)) {
193
204
  const args = process.argv.slice(2);
194
205
  const getArg = (name) => {
195
206
  const i = args.indexOf(`--${name}`);
196
207
  return i >= 0 && args[i + 1] ? args[i + 1] : null;
197
208
  };
198
209
 
199
- // The script lives in either the launcher's .claude/scripts/ or a session
200
- // worktree's .claude/scripts/. getWorkspaceRoot returns the script's
201
- // grandparent; when that's a worktree, promote it to the launcher via
202
- // the .active-session.json pointer.
203
- const inferred = getWorkspaceRoot(import.meta.url);
204
- const root = getArg('root') || getMainRoot(inferred);
210
+ // The workspace root is --root when given, else the cwd — promoted to the
211
+ // launcher via the .active-session.json pointer when the cwd is a session
212
+ // worktree. It is never derived from this script's location: an upgrade
213
+ // payload runs this file from <workspace>/.workspace-update/.claude/scripts/,
214
+ // and a script-relative root would point inside the payload.
215
+ const root = getArg('root') || getMainRoot(process.cwd());
205
216
 
206
217
  const runAll = args.includes('--all');
207
218
  const runMain = args.includes('--main');