@ulysses-ai/create-workspace 0.18.0-beta.0 → 0.20.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 (112) hide show
  1. package/lib/init.mjs +4 -1
  2. package/lib/payload.mjs +18 -1
  3. package/lib/payload.test.mjs +55 -0
  4. package/lib/scaffold.mjs +23 -6
  5. package/lib/scaffold.test.mjs +59 -0
  6. package/lib/upgrade.mjs +20 -0
  7. package/lib/upgrade.test.mjs +119 -0
  8. package/package.json +3 -3
  9. package/template/CLAUDE.md.tmpl +3 -0
  10. package/template/{.claude → _claude}/lib/freshness.mjs +20 -7
  11. package/template/_claude/lib/registry-check.mjs +172 -0
  12. package/template/{.claude → _claude}/rules/forge-operations.md +6 -0
  13. package/template/{.claude → _claude}/rules/memory-guidance.md +4 -0
  14. package/template/{.claude → _claude}/rules/task-list-mirroring.md +6 -0
  15. package/template/{.claude → _claude}/scripts/build-workspace-context.mjs +25 -14
  16. package/template/{.claude → _claude}/scripts/chat-record.mjs +62 -21
  17. package/template/_claude/scripts/classify-update.mjs +117 -0
  18. package/template/{.claude → _claude}/scripts/cleanup-work-session.mjs +4 -2
  19. package/template/{.claude → _claude}/scripts/context-footprint.mjs +139 -30
  20. package/template/{.claude → _claude}/scripts/forges/github.mjs +2 -1
  21. package/template/{.claude → _claude}/scripts/forges/interface.mjs +5 -4
  22. package/template/_claude/scripts/merge-mode.mjs +61 -0
  23. package/template/{.claude → _claude}/scripts/migrate-canonical-priority.mjs +7 -1
  24. package/template/_claude/scripts/migrate-claude-md-freshness-include.mjs +55 -0
  25. package/template/{.claude → _claude}/scripts/migrate-session-layout.mjs +19 -8
  26. package/template/{.claude → _claude}/scripts/migrate-sessions.mjs +444 -121
  27. package/template/_claude/scripts/task-pr.mjs +555 -0
  28. package/template/{.claude → _claude}/scripts/task-worktree.mjs +26 -16
  29. package/template/{.claude → _claude}/scripts/trackers/github-issues.mjs +11 -0
  30. package/template/{.claude → _claude}/scripts/trackers/interface.mjs +8 -0
  31. package/template/{.claude → _claude}/skills/braindump/SKILL.md +1 -0
  32. package/template/{.claude → _claude}/skills/complete-work/SKILL.md +25 -77
  33. package/template/{.claude → _claude}/skills/context-placement/SKILL.md +8 -5
  34. package/template/{.claude → _claude}/skills/goal-driven-work/SKILL.md +1 -1
  35. package/template/{.claude → _claude}/skills/handoff/SKILL.md +1 -0
  36. package/template/{.claude → _claude}/skills/maintenance/SKILL.md +49 -17
  37. package/template/{.claude → _claude}/skills/migrate-sessions/SKILL.md +17 -5
  38. package/template/{.claude → _claude}/skills/release/SKILL.md +6 -2
  39. package/template/{.claude → _claude}/skills/start-work/SKILL.md +5 -5
  40. package/template/{.claude → _claude}/skills/workspace-init/SKILL.md +20 -14
  41. package/template/_claude/skills/workspace-update/SKILL.md +177 -0
  42. package/template/_gitignore +3 -0
  43. package/template/workspace.json.tmpl +1 -1
  44. package/template/.claude/lib/registry-check.mjs +0 -106
  45. package/template/.claude/scripts/migrate-claude-md-freshness-include.mjs +0 -30
  46. package/template/.claude/skills/workspace-update/SKILL.md +0 -134
  47. /package/template/{.claude → _claude}/agents/aside-researcher.md +0 -0
  48. /package/template/{.claude → _claude}/agents/implementer.md +0 -0
  49. /package/template/{.claude → _claude}/agents/researcher.md +0 -0
  50. /package/template/{.claude → _claude}/agents/reviewer.md +0 -0
  51. /package/template/{.claude → _claude}/hooks/_utils.mjs +0 -0
  52. /package/template/{.claude → _claude}/hooks/bash-output-advisory.mjs +0 -0
  53. /package/template/{.claude → _claude}/hooks/post-compact.mjs +0 -0
  54. /package/template/{.claude → _claude}/hooks/pre-compact.mjs +0 -0
  55. /package/template/{.claude → _claude}/hooks/repo-write-detection.mjs +0 -0
  56. /package/template/{.claude → _claude}/hooks/session-end.mjs +0 -0
  57. /package/template/{.claude → _claude}/hooks/session-start.mjs +0 -0
  58. /package/template/{.claude → _claude}/hooks/subagent-start.mjs +0 -0
  59. /package/template/{.claude → _claude}/hooks/version-freshness-check.mjs +0 -0
  60. /package/template/{.claude → _claude}/hooks/workspace-update-check.mjs +0 -0
  61. /package/template/{.claude → _claude}/lib/require-node.mjs +0 -0
  62. /package/template/{.claude → _claude}/lib/session-frontmatter.mjs +0 -0
  63. /package/template/{.claude → _claude}/recipes/migrate-from-notion.md +0 -0
  64. /package/template/{.claude → _claude}/rules/agent-rules.md.skip +0 -0
  65. /package/template/{.claude → _claude}/rules/cloud-infrastructure.md.skip +0 -0
  66. /package/template/{.claude → _claude}/rules/coherent-revisions.md +0 -0
  67. /package/template/{.claude → _claude}/rules/config-review.md.skip +0 -0
  68. /package/template/{.claude → _claude}/rules/documentation.md.skip +0 -0
  69. /package/template/{.claude → _claude}/rules/git-conventions.md +0 -0
  70. /package/template/{.claude → _claude}/rules/goal-driven-work.md +0 -0
  71. /package/template/{.claude → _claude}/rules/honest-pushback.md +0 -0
  72. /package/template/{.claude → _claude}/rules/local-dev-environment.md.skip +0 -0
  73. /package/template/{.claude → _claude}/rules/product-integrity.md.skip +0 -0
  74. /package/template/{.claude → _claude}/rules/scope-guard.md.skip +0 -0
  75. /package/template/{.claude → _claude}/rules/superpowers-workflow.md.skip +0 -0
  76. /package/template/{.claude → _claude}/rules/token-economics.md.skip +0 -0
  77. /package/template/{.claude → _claude}/rules/work-item-tracking.md +0 -0
  78. /package/template/{.claude → _claude}/rules/workspace-structure.md +0 -0
  79. /package/template/{.claude → _claude}/scripts/add-repo-to-session.mjs +0 -0
  80. /package/template/{.claude → _claude}/scripts/capture-context.mjs +0 -0
  81. /package/template/{.claude → _claude}/scripts/create-work-session.mjs +0 -0
  82. /package/template/{.claude → _claude}/scripts/forges/gitlab.mjs +0 -0
  83. /package/template/{.claude → _claude}/scripts/generate-claude-local.mjs +0 -0
  84. /package/template/{.claude → _claude}/scripts/migrate-open-work.mjs +0 -0
  85. /package/template/{.claude → _claude}/scripts/migrate-to-workspace-context.mjs +0 -0
  86. /package/template/{.claude → _claude}/scripts/sweep-references.mjs +0 -0
  87. /package/template/{.claude → _claude}/scripts/sync-tasks.mjs +0 -0
  88. /package/template/{.claude → _claude}/scripts/workspace-diagnostics.mjs +0 -0
  89. /package/template/{.claude → _claude}/settings.json +0 -0
  90. /package/template/{.claude → _claude}/skills/aside/SKILL.md +0 -0
  91. /package/template/{.claude → _claude}/skills/build-docs-site/SKILL.md +0 -0
  92. /package/template/{.claude → _claude}/skills/build-docs-site/checklists/framing.md +0 -0
  93. /package/template/{.claude → _claude}/skills/build-docs-site/checklists/pitfalls.md +0 -0
  94. /package/template/{.claude → _claude}/skills/build-docs-site/checklists/review.md +0 -0
  95. /package/template/{.claude → _claude}/skills/build-docs-site/scripts/bulk-fill-migration.py +0 -0
  96. /package/template/{.claude → _claude}/skills/build-docs-site/scripts/forbidden-word-grep.mjs +0 -0
  97. /package/template/{.claude → _claude}/skills/build-docs-site/scripts/leak-grep.mjs +0 -0
  98. /package/template/{.claude → _claude}/skills/build-docs-site/templates/custom.css.tmpl +0 -0
  99. /package/template/{.claude → _claude}/skills/build-docs-site/templates/docusaurus.config.ts.tmpl +0 -0
  100. /package/template/{.claude → _claude}/skills/build-docs-site/templates/primitives/Arrow.tsx +0 -0
  101. /package/template/{.claude → _claude}/skills/build-docs-site/templates/primitives/Box.tsx +0 -0
  102. /package/template/{.claude → _claude}/skills/build-docs-site/templates/primitives/DiagramContainer.tsx +0 -0
  103. /package/template/{.claude → _claude}/skills/build-docs-site/templates/primitives/Region.tsx +0 -0
  104. /package/template/{.claude → _claude}/skills/build-docs-site/templates/primitives/SectionTitle.tsx +0 -0
  105. /package/template/{.claude → _claude}/skills/build-docs-site/templates/primitives/tokens.ts +0 -0
  106. /package/template/{.claude → _claude}/skills/build-docs-site/templates/sidebars.ts.tmpl +0 -0
  107. /package/template/{.claude → _claude}/skills/build-docs-site/templates/spec.md.tmpl +0 -0
  108. /package/template/{.claude → _claude}/skills/pause-work/SKILL.md +0 -0
  109. /package/template/{.claude → _claude}/skills/promote/SKILL.md +0 -0
  110. /package/template/{.claude → _claude}/skills/setup-tracker/SKILL.md +0 -0
  111. /package/template/{.claude → _claude}/skills/sync-work/SKILL.md +0 -0
  112. /package/template/{.mcp.json → _mcp.json} +0 -0
package/lib/init.mjs CHANGED
@@ -20,7 +20,10 @@ export async function initWorkspace(targetDir) {
20
20
  const { toVersion, payloadDir } = stagePayload(targetDir, { action: 'init' });
21
21
  console.log(` Staged template payload (v${toVersion})`);
22
22
 
23
- // Install only the bootstrap skills needed to complete initialization
23
+ // Install only the bootstrap skills needed to complete initialization.
24
+ // The payload keeps live names (.claude/, .mcp.json) — stagePayload()
25
+ // maps them back from the template's inert names so the staged layout
26
+ // stays readable by skills already installed in the workspace.
24
27
  const bootstrapSkills = ['workspace-init', 'workspace-update'];
25
28
  const payloadSkills = join(payloadDir, '.claude', 'skills');
26
29
  const targetSkills = join(targetDir, '.claude', 'skills');
package/lib/payload.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  // lib/payload.mjs
2
- import { existsSync, cpSync, rmSync, mkdirSync, writeFileSync, readFileSync } from 'fs';
2
+ import { existsSync, cpSync, rmSync, mkdirSync, writeFileSync, readFileSync, renameSync } from 'fs';
3
3
  import { join, dirname } from 'path';
4
4
  import { fileURLToPath } from 'url';
5
5
 
@@ -22,6 +22,23 @@ export function stagePayload(targetDir, { action, fromVersion = null }) {
22
22
  // Copy template to payload directory
23
23
  cpSync(TEMPLATE_DIR, payloadDir, { recursive: true });
24
24
 
25
+ // The template stores .claude/ and .mcp.json under the inert names
26
+ // _claude/ and _mcp.json (Claude Code protects the live names from
27
+ // headless edits). The staged payload is an on-disk format shared with
28
+ // the /workspace-update skill ALREADY INSTALLED in the workspace, which
29
+ // reads .workspace-update/.claude/... — so it must keep the live names
30
+ // exactly as older package versions staged them. _gitignore stays inert:
31
+ // the skills have always merged it under that name.
32
+ for (const [from, to] of [
33
+ ['_claude', '.claude'],
34
+ ['_mcp.json', '.mcp.json'],
35
+ ]) {
36
+ const src = join(payloadDir, from);
37
+ if (existsSync(src)) {
38
+ renameSync(src, join(payloadDir, to));
39
+ }
40
+ }
41
+
25
42
  // Write manifest
26
43
  const manifest = {
27
44
  action,
@@ -0,0 +1,55 @@
1
+ #!/usr/bin/env node
2
+ // Unit tests for payload.mjs
3
+ // Run: node lib/payload.test.mjs
4
+ //
5
+ // The staged .workspace-update/ payload is an on-disk format shared with
6
+ // the /workspace-update skill already installed in existing workspaces,
7
+ // which reads .workspace-update/.claude/... and .workspace-update/.mcp.json.
8
+ // These tests pin that layout even though template/ itself stores the
9
+ // protected paths under the inert names _claude/ and _mcp.json.
10
+ import { stagePayload, cleanPayload } from './payload.mjs';
11
+ import { mkdtempSync, rmSync, existsSync } from 'fs';
12
+ import { join } from 'path';
13
+ import { tmpdir } from 'os';
14
+
15
+ let failed = 0;
16
+ let passed = 0;
17
+ function check(label, ok) {
18
+ if (ok) { passed++; } else {
19
+ failed++;
20
+ console.error(` FAIL: ${label}`);
21
+ }
22
+ }
23
+
24
+ const root = mkdtempSync(join(tmpdir(), 'payload-test-'));
25
+
26
+ try {
27
+ const { payloadDir, toVersion } = stagePayload(root, { action: 'init' });
28
+
29
+ check('payload staged at .workspace-update/', existsSync(payloadDir));
30
+ check('manifest written', existsSync(join(payloadDir, '.manifest.json')));
31
+ check('returns the package version', typeof toVersion === 'string' && toVersion.length > 0);
32
+
33
+ // The payload keeps the live names older workspaces' skills expect
34
+ check('.claude/ staged under live name', existsSync(join(payloadDir, '.claude')));
35
+ check('.claude/skills/workspace-update/SKILL.md staged', existsSync(join(payloadDir, '.claude', 'skills', 'workspace-update', 'SKILL.md')));
36
+ check('.claude/settings.json staged', existsSync(join(payloadDir, '.claude', 'settings.json')));
37
+ check('.mcp.json staged under live name', existsSync(join(payloadDir, '.mcp.json')));
38
+ check('_gitignore stays inert (skills merge it under that name)', existsSync(join(payloadDir, '_gitignore')));
39
+
40
+ // The template's inert names must not leak into the payload
41
+ check('no _claude/ in payload', !existsSync(join(payloadDir, '_claude')));
42
+ check('no _mcp.json in payload', !existsSync(join(payloadDir, '_mcp.json')));
43
+
44
+ // cleanPayload removes the staging directory
45
+ cleanPayload(root);
46
+ check('cleanPayload removes .workspace-update/', !existsSync(payloadDir));
47
+ } finally {
48
+ rmSync(root, { recursive: true, force: true });
49
+ }
50
+
51
+ if (failed > 0) {
52
+ console.error(`${failed} check(s) failed, ${passed} passed`);
53
+ process.exit(1);
54
+ }
55
+ console.log(`${passed} checks passed`);
package/lib/scaffold.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { cpSync, mkdirSync, readFileSync, writeFileSync, renameSync, existsSync } from 'fs';
1
+ import { cpSync, mkdirSync, readFileSync, writeFileSync, renameSync, existsSync, rmSync, statSync } from 'fs';
2
2
  import { join, dirname } from 'path';
3
3
  import { fileURLToPath } from 'url';
4
4
 
@@ -26,11 +26,28 @@ export async function scaffold(answers) {
26
26
  // scripts and hooks first need them — we do NOT pre-create them here.
27
27
  mkdirSync(join(directory, 'workspace-context', 'locked'), { recursive: true });
28
28
 
29
- // Rename _gitignore to .gitignore
30
- const gitignoreSrc = join(directory, '_gitignore');
31
- const gitignoreDest = join(directory, '.gitignore');
32
- if (existsSync(gitignoreSrc)) {
33
- renameSync(gitignoreSrc, gitignoreDest);
29
+ // The template stores some files under inert names — _gitignore,
30
+ // _claude/, _mcp.json — because Claude Code treats .claude/ directories
31
+ // and .mcp.json files as protected paths and would block automated edits
32
+ // to the template. Install each under its live name in the target.
33
+ const inertNames = [
34
+ { from: '_gitignore', to: '.gitignore' },
35
+ { from: '_claude', to: '.claude' },
36
+ { from: '_mcp.json', to: '.mcp.json' },
37
+ ];
38
+ for (const { from, to } of inertNames) {
39
+ const src = join(directory, from);
40
+ const dest = join(directory, to);
41
+ if (!existsSync(src)) continue;
42
+ if (existsSync(dest) && statSync(src).isDirectory()) {
43
+ // Pre-populated target: merge into the existing directory — template
44
+ // files overwrite same-named ones, extra local files survive — then
45
+ // drop the inert copy.
46
+ cpSync(src, dest, { recursive: true });
47
+ rmSync(src, { recursive: true });
48
+ } else {
49
+ renameSync(src, dest);
50
+ }
34
51
  }
35
52
 
36
53
  // Process CLAUDE.md template
@@ -0,0 +1,59 @@
1
+ #!/usr/bin/env node
2
+ // End-to-end test for scaffold.mjs
3
+ // Run: node lib/scaffold.test.mjs
4
+ //
5
+ // The template stores .gitignore, .claude/, and .mcp.json under the inert
6
+ // names _gitignore, _claude/, and _mcp.json (Claude Code protects the live
7
+ // names from headless edits). scaffold() must install every one under its
8
+ // live name and leave no inert leftovers behind.
9
+ import { scaffold } from './scaffold.mjs';
10
+ import { mkdtempSync, rmSync, existsSync, readFileSync } from 'fs';
11
+ import { join } from 'path';
12
+ import { tmpdir } from 'os';
13
+
14
+ let failed = 0;
15
+ let passed = 0;
16
+ function check(label, ok) {
17
+ if (ok) { passed++; } else {
18
+ failed++;
19
+ console.error(` FAIL: ${label}`);
20
+ }
21
+ }
22
+
23
+ const directory = mkdtempSync(join(tmpdir(), 'scaffold-test-'));
24
+
25
+ try {
26
+ await scaffold({
27
+ name: 'demo',
28
+ directory,
29
+ repos: [],
30
+ userName: 'tester',
31
+ activateRules: [],
32
+ });
33
+
34
+ // Live names installed
35
+ check('.claude/ directory installed', existsSync(join(directory, '.claude')));
36
+ check('.claude/settings.json installed', existsSync(join(directory, '.claude', 'settings.json')));
37
+ check('.claude/settings.local.json written', existsSync(join(directory, '.claude', 'settings.local.json')));
38
+ check('.claude/rules/ installed', existsSync(join(directory, '.claude', 'rules')));
39
+ check('.claude/rules/coherent-revisions.md installed', existsSync(join(directory, '.claude', 'rules', 'coherent-revisions.md')));
40
+ check('.mcp.json installed', existsSync(join(directory, '.mcp.json')));
41
+ check('.gitignore installed', existsSync(join(directory, '.gitignore')));
42
+
43
+ // .mcp.json is the template's payload, unmodified
44
+ const mcp = JSON.parse(readFileSync(join(directory, '.mcp.json'), 'utf8'));
45
+ check('.mcp.json parses with mcpServers object', typeof mcp.mcpServers === 'object' && mcp.mcpServers !== null);
46
+
47
+ // Inert names are gone
48
+ check('no _claude/ leftover', !existsSync(join(directory, '_claude')));
49
+ check('no _mcp.json leftover', !existsSync(join(directory, '_mcp.json')));
50
+ check('no _gitignore leftover', !existsSync(join(directory, '_gitignore')));
51
+ } finally {
52
+ rmSync(directory, { recursive: true, force: true });
53
+ }
54
+
55
+ if (failed > 0) {
56
+ console.error(`${failed} check(s) failed, ${passed} passed`);
57
+ process.exit(1);
58
+ }
59
+ console.log(`${passed} checks passed`);
package/lib/upgrade.mjs CHANGED
@@ -1,8 +1,26 @@
1
1
  // lib/upgrade.mjs
2
2
  import { existsSync, readFileSync } from 'fs';
3
3
  import { join } from 'path';
4
+ import { spawnSync } from 'child_process';
4
5
  import { stagePayload } from './payload.mjs';
5
6
 
7
+ // .workspace-update/ is a transient staging area and is gitignored from
8
+ // v0.19.0 on — but workspaces upgraded from older templates may still track
9
+ // it from a previous run. A tracked payload gets committed, shared with
10
+ // teammates, and confuses /workspace-update, so surface it loudly.
11
+ function warnIfPayloadTracked(targetDir) {
12
+ const r = spawnSync('git', ['ls-files', '.workspace-update'], {
13
+ cwd: targetDir,
14
+ encoding: 'utf-8',
15
+ });
16
+ // Not a git repo (or git unavailable) — nothing can be tracked.
17
+ if (r.status !== 0) return;
18
+ const tracked = (r.stdout || '').split('\n').filter(Boolean);
19
+ if (tracked.length === 0) return;
20
+ console.error(` Warning: .workspace-update/ is tracked by git (${tracked.length} file(s)) — it is a transient staging area.`);
21
+ console.error(` Untrack it and commit:\n git rm -r --cached .workspace-update\n git commit -m "chore: untrack .workspace-update payload"\n`);
22
+ }
23
+
6
24
  export async function upgradeWorkspace(targetDir) {
7
25
  const workspaceJsonPath = join(targetDir, 'workspace.json');
8
26
 
@@ -24,6 +42,8 @@ export async function upgradeWorkspace(targetDir) {
24
42
  process.exit(1);
25
43
  }
26
44
 
45
+ warnIfPayloadTracked(targetDir);
46
+
27
47
  const fromVersion = config.workspace?.templateVersion || 'unknown';
28
48
 
29
49
  // Stage payload
@@ -0,0 +1,119 @@
1
+ #!/usr/bin/env node
2
+ // Unit tests for upgrade.mjs
3
+ // Run: node lib/upgrade.test.mjs
4
+ //
5
+ // Covers the tracked-payload warning: .workspace-update/ is gitignored from
6
+ // v0.19.0, but a workspace upgraded from an older template may still carry a
7
+ // tracked payload from a previous run — --upgrade must say so.
8
+ import { upgradeWorkspace } from './upgrade.mjs';
9
+ import { mkdtempSync, rmSync, writeFileSync, existsSync, mkdirSync } from 'fs';
10
+ import { join } from 'path';
11
+ import { tmpdir } from 'os';
12
+ import { execSync, spawnSync } from 'child_process';
13
+
14
+ let failed = 0;
15
+ let passed = 0;
16
+ function check(label, ok) {
17
+ if (ok) { passed++; } else {
18
+ failed++;
19
+ console.error(` FAIL: ${label}`);
20
+ }
21
+ }
22
+
23
+ function captureConsole(fn) {
24
+ const out = { log: [], error: [] };
25
+ const origLog = console.log;
26
+ const origError = console.error;
27
+ console.log = (...a) => { out.log.push(a.join(' ')); };
28
+ console.error = (...a) => { out.error.push(a.join(' ')); };
29
+ try {
30
+ return { result: fn(), out };
31
+ } finally {
32
+ console.log = origLog;
33
+ console.error = origError;
34
+ }
35
+ }
36
+
37
+ function buildWorkspace() {
38
+ const root = mkdtempSync(join(tmpdir(), 'upgrade-test-'));
39
+ execSync('git init -q -b main', { cwd: root, stdio: 'pipe' });
40
+ execSync('git config user.email test@example.com', { cwd: root, stdio: 'pipe' });
41
+ execSync('git config user.name Test', { cwd: root, stdio: 'pipe' });
42
+ writeFileSync(join(root, 'workspace.json'), JSON.stringify({
43
+ workspace: { name: 'demo', initialized: true, templateVersion: '0.15.0' },
44
+ repos: {},
45
+ }, null, 2) + '\n');
46
+ execSync('git add workspace.json', { cwd: root, stdio: 'pipe' });
47
+ execSync('git commit -q -m init', { cwd: root, stdio: 'pipe' });
48
+ return root;
49
+ }
50
+
51
+ function lsFilesPayload(root) {
52
+ return spawnSync('git', ['ls-files', '.workspace-update'], {
53
+ cwd: root,
54
+ encoding: 'utf-8',
55
+ }).stdout.split('\n').filter(Boolean);
56
+ }
57
+
58
+ console.log('# upgrade');
59
+
60
+ // 1. Clean workspace: payload staged, no tracked-payload warning, and the
61
+ // freshly staged payload is not tracked.
62
+ {
63
+ const root = buildWorkspace();
64
+ try {
65
+ const { out } = captureConsole(() => upgradeWorkspace(root));
66
+ const stderr = out.error.join('\n');
67
+ check('no tracked-payload warning on a clean workspace',
68
+ !stderr.includes('git rm -r --cached'));
69
+ check('payload staged', existsSync(join(root, '.workspace-update', '.manifest.json')));
70
+ check('freshly staged payload is not tracked', lsFilesPayload(root).length === 0);
71
+ check('staging reported', out.log.join('\n').includes('Staged template payload'));
72
+ } finally {
73
+ rmSync(root, { recursive: true, force: true });
74
+ }
75
+ }
76
+
77
+ // 2. Workspace tracking .workspace-update/ from a pre-v0.19 upgrade: the
78
+ // warning fires and names the untrack command.
79
+ {
80
+ const root = buildWorkspace();
81
+ try {
82
+ // Simulate the old behavior: a payload committed tracked.
83
+ const payload = join(root, '.workspace-update');
84
+ mkdirSync(payload, { recursive: true });
85
+ writeFileSync(join(payload, '.manifest.json'), '{"action":"upgrade"}\n');
86
+ execSync('git add -f .workspace-update', { cwd: root, stdio: 'pipe' });
87
+ execSync('git commit -q -m "track payload"', { cwd: root, stdio: 'pipe' });
88
+ check('fixture really tracks the payload', lsFilesPayload(root).length > 0);
89
+
90
+ const { out } = captureConsole(() => upgradeWorkspace(root));
91
+ const stderr = out.error.join('\n');
92
+ check('tracked-payload warning fires', stderr.includes('.workspace-update/') && stderr.includes('tracked by git'));
93
+ check('warning names the untrack command', stderr.includes('git rm -r --cached .workspace-update'));
94
+ } finally {
95
+ rmSync(root, { recursive: true, force: true });
96
+ }
97
+ }
98
+
99
+ // 3. Not a git repo at all: staging still works, warning stays silent.
100
+ {
101
+ const root = mkdtempSync(join(tmpdir(), 'upgrade-nogit-'));
102
+ try {
103
+ writeFileSync(join(root, 'workspace.json'), JSON.stringify({
104
+ workspace: { name: 'demo', initialized: true, templateVersion: '0.15.0' },
105
+ repos: {},
106
+ }, null, 2) + '\n');
107
+ const { out } = captureConsole(() => upgradeWorkspace(root));
108
+ check('no warning outside a git repo', !out.error.join('\n').includes('tracked by git'));
109
+ check('payload staged outside a git repo', existsSync(join(root, '.workspace-update', '.manifest.json')));
110
+ } finally {
111
+ rmSync(root, { recursive: true, force: true });
112
+ }
113
+ }
114
+
115
+ if (failed > 0) {
116
+ console.error(`${failed} check(s) failed, ${passed} passed`);
117
+ process.exit(1);
118
+ }
119
+ console.log(`${passed} checks passed`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ulysses-ai/create-workspace",
3
- "version": "0.18.0-beta.0",
3
+ "version": "0.20.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",
@@ -18,7 +18,7 @@
18
18
  },
19
19
  "repository": {
20
20
  "type": "git",
21
- "url": "https://github.com/ukt-solutions/create-ulysses-workspace.git"
21
+ "url": "git+https://github.com/ukt-solutions/create-ulysses-workspace.git"
22
22
  },
23
23
  "license": "MIT",
24
24
  "type": "module",
@@ -26,7 +26,7 @@
26
26
  "node": ">=20.9.0"
27
27
  },
28
28
  "bin": {
29
- "create-workspace": "./bin/create.mjs"
29
+ "create-workspace": "bin/create.mjs"
30
30
  },
31
31
  "files": [
32
32
  "bin/",
@@ -33,6 +33,9 @@ This is a claude-workspace. All conventions are defined in .claude/rules/.
33
33
  - `/workspace-update` — apply template updates (runs maintenance before/after)
34
34
  - `/setup-tracker` — wire this workspace to an issue tracker (GitHub Issues shipped; others pluggable)
35
35
  - `/maintenance [audit|cleanup]` — workspace health checks and cleanup
36
+ - `/context-placement` — decide where durable content belongs and what each destination costs before writing it
37
+ - `/goal-driven-work` — run multi-phase autonomous work under `/goal` with phase artifacts and agent-team dispatch
38
+ - `/migrate-sessions` — drain session-model work sessions and switch the workspace to the task lifecycle
36
39
  - `/build-docs-site` — build a comprehensive Docusaurus documentation site for a project
37
40
 
38
41
  ## Compact Instructions
@@ -1,21 +1,29 @@
1
1
  import './require-node.mjs';
2
2
  import { existsSync, readFileSync, writeFileSync, unlinkSync } from 'fs';
3
3
  import { join } from 'path';
4
- import { compareVersions, getLatestVersion, readCache, writeCache } from './registry-check.mjs';
4
+ import { compareVersions, getLatestVersion, pickComparisonVersion, readCache, writeCache } from './registry-check.mjs';
5
5
 
6
6
  const BANNER_FILENAME = 'local-only-template-freshness.md';
7
7
  const CACHE_FILENAME = '.version-check.json';
8
8
 
9
9
  /**
10
10
  * Refresh the version cache if stale, then write or delete the banner file
11
- * based on a comparison of workspace.templateVersion to the latest npm version.
11
+ * based on a comparison of workspace.templateVersion to the npm dist-tag
12
+ * matching its release channel (see pickComparisonVersion).
13
+ *
14
+ * The cache stores the raw dist-tags rather than the chosen version, so a
15
+ * channel switch between cache write and read (e.g. an upgrade from a beta
16
+ * to a stable release inside the TTL) still picks the right tag.
12
17
  *
13
18
  * Returns:
14
19
  * { status: 'outdated', current, latest, checkedAt }
15
- * { status: 'current', current, latest, checkedAt }
20
+ * { status: 'current', current, latest, prerelease, checkedAt }
16
21
  * { status: 'unknown', current, latest: null, checkedAt: null }
17
22
  * { skipped: 'uninitialized' }
18
23
  *
24
+ * `prerelease` on a current workspace is a newer `beta` dist-tag, offered
25
+ * as information only — it never marks the install stale.
26
+ *
19
27
  * Pure I/O is parameterized via fetchFn / nowFn for testability.
20
28
  */
21
29
  export async function refreshIfStale({
@@ -46,9 +54,9 @@ export async function refreshIfStale({
46
54
  const stale = cacheAgeMs > ttlMs;
47
55
 
48
56
  if (stale) {
49
- const fresh = await getLatestVersion({ fetchFn });
57
+ const fresh = await getLatestVersion({ current, fetchFn });
50
58
  if (fresh.version) {
51
- cache = { latestVersion: fresh.version, checkedAt: now.toISOString() };
59
+ cache = { tags: fresh.tags, checkedAt: now.toISOString() };
52
60
  writeCache(cachePath, cache);
53
61
  }
54
62
  // On fetch error, keep whatever cache we already had (could be null).
@@ -60,7 +68,12 @@ export async function refreshIfStale({
60
68
  return { status: 'unknown', current, latest: null, checkedAt: null };
61
69
  }
62
70
 
63
- const latest = cache.latestVersion;
71
+ const { version: latest, prerelease } = pickComparisonVersion(current, cache.tags);
72
+ if (!latest) {
73
+ // Cached tags carry nothing comparable for this channel. Refuse to
74
+ // guess rather than compare against the wrong tag.
75
+ return { status: 'unknown', current, latest: null, checkedAt: cache.checkedAt };
76
+ }
64
77
  const cmp = compareVersions(current, latest);
65
78
  if (cmp < 0) {
66
79
  writeFileSync(
@@ -70,6 +83,6 @@ export async function refreshIfStale({
70
83
  return { status: 'outdated', current, latest, checkedAt: cache.checkedAt };
71
84
  } else {
72
85
  if (existsSync(bannerPath)) unlinkSync(bannerPath);
73
- return { status: 'current', current, latest, checkedAt: cache.checkedAt };
86
+ return { status: 'current', current, latest, prerelease, checkedAt: cache.checkedAt };
74
87
  }
75
88
  }
@@ -0,0 +1,172 @@
1
+ import './require-node.mjs';
2
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
3
+ import { dirname } from 'path';
4
+
5
+ /**
6
+ * SemVer 2.0 comparison limited to the formats this scaffolder publishes:
7
+ * `x.y.z` and `x.y.z-prerelease.N`. Returns -1, 0, or 1.
8
+ *
9
+ * Rules:
10
+ * - Compare major, minor, patch numerically.
11
+ * - A pre-release version is older than the same x.y.z without a tag.
12
+ * - Pre-release identifiers compare per-identifier; numeric identifiers
13
+ * compare numerically (so `beta.10 > beta.2`), non-numeric lexically.
14
+ */
15
+ export function compareVersions(a, b) {
16
+ if (a === b) return 0;
17
+ const [aBase, aPre] = a.split('-', 2);
18
+ const [bBase, bPre] = b.split('-', 2);
19
+ const aParts = aBase.split('.').map(Number);
20
+ const bParts = bBase.split('.').map(Number);
21
+ for (let i = 0; i < 3; i++) {
22
+ if ((aParts[i] || 0) < (bParts[i] || 0)) return -1;
23
+ if ((aParts[i] || 0) > (bParts[i] || 0)) return 1;
24
+ }
25
+ if (!aPre && !bPre) return 0;
26
+ if (!aPre && bPre) return 1;
27
+ if (aPre && !bPre) return -1;
28
+ const aIds = aPre.split('.');
29
+ const bIds = bPre.split('.');
30
+ const len = Math.max(aIds.length, bIds.length);
31
+ for (let i = 0; i < len; i++) {
32
+ const ai = aIds[i];
33
+ const bi = bIds[i];
34
+ if (ai === undefined) return -1;
35
+ if (bi === undefined) return 1;
36
+ const aNum = /^\d+$/.test(ai);
37
+ const bNum = /^\d+$/.test(bi);
38
+ if (aNum && bNum) {
39
+ const an = Number(ai), bn = Number(bi);
40
+ if (an < bn) return -1;
41
+ if (an > bn) return 1;
42
+ } else if (aNum && !bNum) {
43
+ return -1;
44
+ } else if (!aNum && bNum) {
45
+ return 1;
46
+ } else {
47
+ if (ai < bi) return -1;
48
+ if (ai > bi) return 1;
49
+ }
50
+ }
51
+ return 0;
52
+ }
53
+
54
+ const DIST_TAGS_URL = 'https://registry.npmjs.org/-/package/@ulysses-ai/create-workspace/dist-tags';
55
+ const DEFAULT_TIMEOUT_MS = 3000;
56
+
57
+ /**
58
+ * Which release channel an installed version rides: `stable` for a plain
59
+ * `x.y.z`, otherwise the first pre-release identifier (`0.19.0-beta.3` →
60
+ * `beta`). Returns null when the string isn't a version this scaffolder
61
+ * publishes.
62
+ */
63
+ export function channelOf(version) {
64
+ if (typeof version !== 'string') return null;
65
+ const match = /^(\d+)\.(\d+)\.(\d+)(?:-(.+))?$/.exec(version);
66
+ if (!match) return null;
67
+ return match[4] ? match[4].split('.')[0] : 'stable';
68
+ }
69
+
70
+ /**
71
+ * Pick the registry version an installed version should compare against,
72
+ * given the package's dist-tags.
73
+ *
74
+ * A pre-release install tracks the highest semver among `latest` and its
75
+ * own channel's tag, so a lagging `latest` (which has sat behind `beta`
76
+ * for whole release cycles) never masks a newer build on the channel the
77
+ * workspace actually rides. A stable install compares against `latest`
78
+ * alone; when the `beta` tag outruns `latest`, that version is returned
79
+ * separately as an available pre-release, so callers can surface it
80
+ * without calling the install stale.
81
+ *
82
+ * Returns { version, channel, prerelease } — `version` is null when no
83
+ * usable tag is present.
84
+ */
85
+ export function pickComparisonVersion(current, tags) {
86
+ const channel = channelOf(current) || 'stable';
87
+ const candidates = [];
88
+ if (typeof tags?.latest === 'string') candidates.push(tags.latest);
89
+ if (channel !== 'stable' && typeof tags?.[channel] === 'string') candidates.push(tags[channel]);
90
+ let version = null;
91
+ for (const candidate of candidates) {
92
+ if (version === null || compareVersions(candidate, version) > 0) version = candidate;
93
+ }
94
+ let prerelease = null;
95
+ if (
96
+ channel === 'stable' &&
97
+ typeof tags?.latest === 'string' &&
98
+ typeof tags?.beta === 'string' &&
99
+ compareVersions(tags.beta, tags.latest) > 0
100
+ ) {
101
+ prerelease = tags.beta;
102
+ }
103
+ return { version, channel, prerelease };
104
+ }
105
+
106
+ /**
107
+ * Fetch the scaffolder's dist-tags from the npm registry and pick the
108
+ * version to compare the installed `current` version against.
109
+ * Returns { version, channel, tags, prerelease, error } — `error` is null
110
+ * exactly when `version` is non-null, and the other fields are null on
111
+ * failure. `tags` is the dist-tag map as published, `channel` is the
112
+ * installed version's channel, and `prerelease` is the `beta` build when
113
+ * one outruns `latest` from a stable install.
114
+ *
115
+ * Caller injects fetchFn for testing. Default uses global fetch (Node 18+).
116
+ */
117
+ export async function getLatestVersion({ current = null, fetchFn = fetch, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
118
+ const controller = new AbortController();
119
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
120
+ const empty = { version: null, channel: null, tags: null, prerelease: null };
121
+ try {
122
+ const res = await fetchFn(DIST_TAGS_URL, { signal: controller.signal });
123
+ if (!res.ok) {
124
+ return { ...empty, error: `registry returned ${res.status} ${res.statusText || ''}`.trim() };
125
+ }
126
+ const body = await res.json();
127
+ const tags = {};
128
+ if (body && typeof body === 'object' && !Array.isArray(body)) {
129
+ for (const [tag, value] of Object.entries(body)) {
130
+ if (typeof value === 'string') tags[tag] = value;
131
+ }
132
+ }
133
+ const picked = pickComparisonVersion(current, tags);
134
+ if (!picked.version) {
135
+ return { ...empty, error: 'registry response missing dist-tags' };
136
+ }
137
+ return { version: picked.version, channel: picked.channel, tags, prerelease: picked.prerelease, error: null };
138
+ } catch (err) {
139
+ return { ...empty, error: err?.message || String(err) };
140
+ } finally {
141
+ clearTimeout(timer);
142
+ }
143
+ }
144
+
145
+ /**
146
+ * Read the version cache file. Returns the parsed object if it has a
147
+ * `tags` object holding at least one string dist-tag; otherwise null.
148
+ * Treats missing file, malformed JSON, and shape mismatches (including
149
+ * caches written before dist-tag support, which had a bare `latestVersion`)
150
+ * all as "no cache" — the next fetch rewrites the file in the new shape.
151
+ */
152
+ export function readCache(path) {
153
+ if (!existsSync(path)) return null;
154
+ try {
155
+ const data = JSON.parse(readFileSync(path, 'utf-8'));
156
+ if (!data || typeof data !== 'object' || Array.isArray(data)) return null;
157
+ if (!data.tags || typeof data.tags !== 'object' || Array.isArray(data.tags)) return null;
158
+ if (!Object.values(data.tags).some((v) => typeof v === 'string')) return null;
159
+ return data;
160
+ } catch {
161
+ return null;
162
+ }
163
+ }
164
+
165
+ /**
166
+ * Write the version cache file, creating parent directories as needed.
167
+ */
168
+ export function writeCache(path, data) {
169
+ const dir = dirname(path);
170
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
171
+ writeFileSync(path, JSON.stringify(data, null, 2) + '\n');
172
+ }
@@ -1,3 +1,9 @@
1
+ ---
2
+ paths:
3
+ - "**/.claude/skills/**"
4
+ - "**/.claude/scripts/**"
5
+ ---
6
+
1
7
  Activate this rule if the workspace creates PRs, watches CI runs, or interacts with releases from skills. Sibling to `work-item-tracking.md` (which covers issues); together they cover everything a workspace does against a code-hosting forge.
2
8
 
3
9
  # Forge Operations
@@ -32,6 +32,10 @@ not need to be in context while editing documentation.
32
32
  node .claude/scripts/context-footprint.mjs --root . --add <bytes> --as <destination>
33
33
  ```
34
34
 
35
+ A rule that applies only to certain files should carry `paths:` frontmatter — it costs nothing
36
+ until a matching file is touched — and the same script checks the live total against
37
+ `workspace.alwaysLoadedBudgetBytes`.
38
+
35
39
  ## The canonical test
36
40
 
37
41
  Canonical describes what *is* and what *to do*, never what *to think*.
@@ -1,3 +1,9 @@
1
+ ---
2
+ paths:
3
+ - "work-sessions/**"
4
+ - "**/session.md"
5
+ ---
6
+
1
7
  # Task List Mirroring
2
8
 
3
9
  The Claude Code `TodoWrite` checklist is a live mirror of the workspace lifecycle. The durable backing store is a `## Tasks` section in `session.md`, round-tripped by `.claude/scripts/sync-tasks.mjs`. This rule defines the contract.