@ulysses-ai/create-workspace 0.19.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.
- package/lib/upgrade.mjs +20 -0
- package/lib/upgrade.test.mjs +119 -0
- package/package.json +3 -3
- package/template/CLAUDE.md.tmpl +3 -0
- package/template/_claude/lib/freshness.mjs +20 -7
- package/template/_claude/lib/registry-check.mjs +80 -14
- package/template/_claude/scripts/chat-record.mjs +27 -19
- package/template/_claude/scripts/classify-update.mjs +117 -0
- package/template/_claude/scripts/cleanup-work-session.mjs +4 -2
- package/template/_claude/scripts/merge-mode.mjs +61 -0
- package/template/_claude/scripts/migrate-canonical-priority.mjs +7 -1
- package/template/_claude/scripts/migrate-claude-md-freshness-include.mjs +35 -10
- package/template/_claude/scripts/migrate-session-layout.mjs +19 -8
- package/template/_claude/scripts/migrate-sessions.mjs +444 -121
- package/template/_claude/scripts/task-pr.mjs +213 -105
- package/template/_claude/scripts/task-worktree.mjs +26 -16
- package/template/_claude/skills/complete-work/SKILL.md +16 -12
- package/template/_claude/skills/migrate-sessions/SKILL.md +17 -5
- package/template/_claude/skills/start-work/SKILL.md +4 -4
- package/template/_claude/skills/workspace-init/SKILL.md +20 -14
- package/template/_claude/skills/workspace-update/SKILL.md +73 -30
- package/template/_gitignore +3 -0
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.
|
|
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": "
|
|
29
|
+
"create-workspace": "bin/create.mjs"
|
|
30
30
|
},
|
|
31
31
|
"files": [
|
|
32
32
|
"bin/",
|
package/template/CLAUDE.md.tmpl
CHANGED
|
@@ -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
|
|
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 = {
|
|
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.
|
|
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
|
}
|
|
@@ -51,45 +51,111 @@ export function compareVersions(a, b) {
|
|
|
51
51
|
return 0;
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
-
const
|
|
54
|
+
const DIST_TAGS_URL = 'https://registry.npmjs.org/-/package/@ulysses-ai/create-workspace/dist-tags';
|
|
55
55
|
const DEFAULT_TIMEOUT_MS = 3000;
|
|
56
56
|
|
|
57
57
|
/**
|
|
58
|
-
*
|
|
59
|
-
*
|
|
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.
|
|
60
114
|
*
|
|
61
115
|
* Caller injects fetchFn for testing. Default uses global fetch (Node 18+).
|
|
62
116
|
*/
|
|
63
|
-
export async function getLatestVersion({ fetchFn = fetch, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
|
|
117
|
+
export async function getLatestVersion({ current = null, fetchFn = fetch, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
|
|
64
118
|
const controller = new AbortController();
|
|
65
119
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
120
|
+
const empty = { version: null, channel: null, tags: null, prerelease: null };
|
|
66
121
|
try {
|
|
67
|
-
const res = await fetchFn(
|
|
122
|
+
const res = await fetchFn(DIST_TAGS_URL, { signal: controller.signal });
|
|
68
123
|
if (!res.ok) {
|
|
69
|
-
return {
|
|
124
|
+
return { ...empty, error: `registry returned ${res.status} ${res.statusText || ''}`.trim() };
|
|
70
125
|
}
|
|
71
126
|
const body = await res.json();
|
|
72
|
-
|
|
73
|
-
|
|
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' };
|
|
74
136
|
}
|
|
75
|
-
return { version:
|
|
137
|
+
return { version: picked.version, channel: picked.channel, tags, prerelease: picked.prerelease, error: null };
|
|
76
138
|
} catch (err) {
|
|
77
|
-
return {
|
|
139
|
+
return { ...empty, error: err?.message || String(err) };
|
|
78
140
|
} finally {
|
|
79
141
|
clearTimeout(timer);
|
|
80
142
|
}
|
|
81
143
|
}
|
|
82
144
|
|
|
83
145
|
/**
|
|
84
|
-
* Read the version cache file. Returns the parsed object if it has a
|
|
85
|
-
* `
|
|
86
|
-
* and shape mismatches
|
|
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.
|
|
87
151
|
*/
|
|
88
152
|
export function readCache(path) {
|
|
89
153
|
if (!existsSync(path)) return null;
|
|
90
154
|
try {
|
|
91
155
|
const data = JSON.parse(readFileSync(path, 'utf-8'));
|
|
92
|
-
if (typeof data
|
|
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;
|
|
93
159
|
return data;
|
|
94
160
|
} catch {
|
|
95
161
|
return null;
|
|
@@ -21,8 +21,8 @@
|
|
|
21
21
|
// node chat-record.mjs --root <dir> --read <chat-name>
|
|
22
22
|
// node chat-record.mjs --root <dir> --reconcile --session-id <id> --name <n>
|
|
23
23
|
// node chat-record.mjs --root <dir> --whoami
|
|
24
|
-
// node chat-record.mjs --root <dir> --add-task --chat <n> --
|
|
25
|
-
// node chat-record.mjs --root <dir> --remove-task --chat <n> --work-item <id> [--repo <r>]
|
|
24
|
+
// node chat-record.mjs --root <dir> --add-task --chat <n> --branch <b> [--repo <r>] [--work-item <id>]
|
|
25
|
+
// node chat-record.mjs --root <dir> --remove-task --chat <n> (--work-item <id> | --branch <b>) [--repo <r>]
|
|
26
26
|
//
|
|
27
27
|
// --add-task / --remove-task report `action`: "added" | "updated" for
|
|
28
28
|
// --add-task, "removed" | "unchanged" for --remove-task. They also keep the
|
|
@@ -191,20 +191,25 @@ function resolveChatName(root, { sessionId, registryName = null } = {}) {
|
|
|
191
191
|
}
|
|
192
192
|
|
|
193
193
|
|
|
194
|
-
// A task is a
|
|
195
|
-
// created on demand and disappears when it
|
|
196
|
-
// durable except the issue and the commits, so
|
|
197
|
-
// pointer, never a copy of the issue.
|
|
194
|
+
// A task is a branch plus the repo it lands in, plus the tracker issue when
|
|
195
|
+
// the workspace has one. It is created on demand and disappears when it
|
|
196
|
+
// merges — nothing about it is durable except the issue and the commits, so
|
|
197
|
+
// the record holds only the pointer, never a copy of the issue.
|
|
198
198
|
//
|
|
199
199
|
// Identity is workItem + repo: the same issue can legitimately be open against
|
|
200
200
|
// two repos in a multi-repo task, and re-recording the same one must update
|
|
201
|
-
// rather than duplicate. /start-work is not always run exactly once.
|
|
202
|
-
|
|
203
|
-
|
|
201
|
+
// rather than duplicate. /start-work is not always run exactly once. A task
|
|
202
|
+
// recorded without a tracker (gh:173) carries `workItem: null` and is keyed
|
|
203
|
+
// by branch + repo instead — the branch is the only identity it has — so
|
|
204
|
+
// both add and remove take the branch for those entries.
|
|
205
|
+
function addTask(root, chatName, { workItem = null, branch, repo = null } = {}) {
|
|
204
206
|
if (!branch) throw new Error('addTask: branch is required');
|
|
205
207
|
const rec = readRecord(root, chatName);
|
|
206
208
|
if (!rec) throw new Error(`addTask: no chat record for "${chatName}"`);
|
|
207
|
-
const i = rec.tasks.findIndex(
|
|
209
|
+
const i = rec.tasks.findIndex(
|
|
210
|
+
(t) => (t.workItem ?? null) === workItem && (t.repo ?? null) === repo
|
|
211
|
+
&& (workItem !== null || t.branch === branch),
|
|
212
|
+
);
|
|
208
213
|
const task = { workItem, branch, repo };
|
|
209
214
|
if (i >= 0) rec.tasks[i] = task;
|
|
210
215
|
else rec.tasks.push(task);
|
|
@@ -212,12 +217,15 @@ function addTask(root, chatName, { workItem, branch, repo = null } = {}) {
|
|
|
212
217
|
return { record: rec, action: i >= 0 ? 'updated' : 'added', updated: i >= 0 };
|
|
213
218
|
}
|
|
214
219
|
|
|
215
|
-
function removeTask(root, chatName, { workItem, repo = null } = {}) {
|
|
216
|
-
if (!
|
|
220
|
+
function removeTask(root, chatName, { workItem = null, branch = null, repo = null } = {}) {
|
|
221
|
+
if (workItem === null && !branch) throw new Error('removeTask: workItem or branch is required');
|
|
217
222
|
const rec = readRecord(root, chatName);
|
|
218
223
|
if (!rec) throw new Error(`removeTask: no chat record for "${chatName}"`);
|
|
219
224
|
const before = rec.tasks.length;
|
|
220
|
-
rec.tasks = rec.tasks.filter(
|
|
225
|
+
rec.tasks = rec.tasks.filter(
|
|
226
|
+
(t) => !((t.workItem ?? null) === workItem && (t.repo ?? null) === repo
|
|
227
|
+
&& (workItem !== null || t.branch === branch)),
|
|
228
|
+
);
|
|
221
229
|
writeRecord(root, rec);
|
|
222
230
|
return { record: rec, action: before - rec.tasks.length > 0 ? 'removed' : 'unchanged', removed: before - rec.tasks.length };
|
|
223
231
|
}
|
|
@@ -268,11 +276,11 @@ function parseArgs(argv) {
|
|
|
268
276
|
if (args.mode === 'reconcile' && (!args.sessionId || !args.name)) {
|
|
269
277
|
throw new Error('--reconcile requires --session-id and --name');
|
|
270
278
|
}
|
|
271
|
-
if (args.mode === 'add-task' && (!args.chat || !args.
|
|
272
|
-
throw new Error('--add-task requires --chat
|
|
279
|
+
if (args.mode === 'add-task' && (!args.chat || !args.branch)) {
|
|
280
|
+
throw new Error('--add-task requires --chat and --branch (--work-item is optional — omitted without a tracker)');
|
|
273
281
|
}
|
|
274
|
-
if (args.mode === 'remove-task' && (!args.chat || !args.workItem)) {
|
|
275
|
-
throw new Error('--remove-task requires --chat and --work-item');
|
|
282
|
+
if (args.mode === 'remove-task' && (!args.chat || (!args.workItem && !args.branch))) {
|
|
283
|
+
throw new Error('--remove-task requires --chat and --work-item or --branch');
|
|
276
284
|
}
|
|
277
285
|
return args;
|
|
278
286
|
}
|
|
@@ -292,9 +300,9 @@ function main() {
|
|
|
292
300
|
if (args.mode === 'list') out = listRecords(args.root);
|
|
293
301
|
else if (args.mode === 'read') out = readRecord(args.root, args.chat);
|
|
294
302
|
else if (args.mode === 'add-task') {
|
|
295
|
-
out = addTask(args.root, args.chat, { workItem: args.workItem, branch: args.branch, repo: args.repo });
|
|
303
|
+
out = addTask(args.root, args.chat, { workItem: args.workItem ?? null, branch: args.branch, repo: args.repo });
|
|
296
304
|
} else if (args.mode === 'remove-task') {
|
|
297
|
-
out = removeTask(args.root, args.chat, { workItem: args.workItem, repo: args.repo });
|
|
305
|
+
out = removeTask(args.root, args.chat, { workItem: args.workItem ?? null, branch: args.branch, repo: args.repo });
|
|
298
306
|
} else out = reconcile(args.root, { sessionId: args.sessionId, name: args.name });
|
|
299
307
|
process.stdout.write(`${JSON.stringify(out, null, 2)}\n`);
|
|
300
308
|
}
|
|
@@ -0,0 +1,117 @@
|
|
|
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: { "new": [...], "identical": [...], "differs": [...] }
|
|
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
|
+
//
|
|
18
|
+
// Only verbatim-installed files are classified: everything under .claude/,
|
|
19
|
+
// plus .mcp.json and .claudeignore. The payload's templates (*.tmpl, which
|
|
20
|
+
// install with {{project-name}} substitution), _gitignore (merged line-by-line
|
|
21
|
+
// into the workspace's .gitignore), and .manifest.json (payload metadata) are
|
|
22
|
+
// handled by their own steps in /workspace-update and are excluded here.
|
|
23
|
+
|
|
24
|
+
import {
|
|
25
|
+
existsSync,
|
|
26
|
+
readFileSync,
|
|
27
|
+
readdirSync,
|
|
28
|
+
statSync,
|
|
29
|
+
realpathSync,
|
|
30
|
+
} from 'node:fs';
|
|
31
|
+
import { join, resolve } from 'node:path';
|
|
32
|
+
import { fileURLToPath } from 'node:url';
|
|
33
|
+
|
|
34
|
+
function isMainModule(metaUrl) {
|
|
35
|
+
if (!process.argv[1]) return false;
|
|
36
|
+
try {
|
|
37
|
+
return realpathSync(fileURLToPath(metaUrl)) === realpathSync(process.argv[1]);
|
|
38
|
+
} catch { return false; }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function parseArgs(argv) {
|
|
42
|
+
const args = { root: process.cwd(), payload: null };
|
|
43
|
+
for (let i = 2; i < argv.length; i++) {
|
|
44
|
+
const a = argv[i];
|
|
45
|
+
if (a === '--root') args.root = argv[++i];
|
|
46
|
+
else if (a === '--payload') args.payload = argv[++i];
|
|
47
|
+
else throw new Error(`Unknown arg: ${a}`);
|
|
48
|
+
}
|
|
49
|
+
return args;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Payload-relative paths that install verbatim at the same relative path.
|
|
53
|
+
// Everything else in the payload is a template or metadata handled elsewhere.
|
|
54
|
+
const VERBATIM_ROOTS = ['.claude', '.mcp.json', '.claudeignore'];
|
|
55
|
+
|
|
56
|
+
function isClassified(payloadRelPath) {
|
|
57
|
+
const first = payloadRelPath.split('/')[0];
|
|
58
|
+
return VERBATIM_ROOTS.includes(first);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function* walkFiles(dir, prefix = '') {
|
|
62
|
+
let entries;
|
|
63
|
+
try {
|
|
64
|
+
entries = readdirSync(dir).sort();
|
|
65
|
+
} catch {
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
for (const name of entries) {
|
|
69
|
+
const rel = prefix ? `${prefix}/${name}` : name;
|
|
70
|
+
const full = join(dir, name);
|
|
71
|
+
let st;
|
|
72
|
+
try { st = statSync(full); } catch { continue; }
|
|
73
|
+
if (st.isDirectory()) yield* walkFiles(full, rel);
|
|
74
|
+
else if (st.isFile()) yield rel;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function classifyUpdate({ root, payload }) {
|
|
79
|
+
const absRoot = resolve(root);
|
|
80
|
+
const absPayload = resolve(payload ?? join(absRoot, '.workspace-update'));
|
|
81
|
+
if (!existsSync(absPayload)) {
|
|
82
|
+
throw new Error(`No payload found at ${absPayload} — run npx @ulysses-ai/create-workspace --upgrade first`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const result = { new: [], identical: [], differs: [] };
|
|
86
|
+
for (const rel of walkFiles(absPayload)) {
|
|
87
|
+
if (!isClassified(rel)) continue;
|
|
88
|
+
const installed = join(absRoot, rel);
|
|
89
|
+
if (!existsSync(installed)) {
|
|
90
|
+
result.new.push(rel);
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
const payloadBytes = readFileSync(join(absPayload, rel));
|
|
94
|
+
const installedBytes = readFileSync(installed);
|
|
95
|
+
if (Buffer.compare(payloadBytes, installedBytes) === 0) {
|
|
96
|
+
result.identical.push(rel);
|
|
97
|
+
} else {
|
|
98
|
+
result.differs.push(rel);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return result;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function main() {
|
|
105
|
+
const args = parseArgs(process.argv);
|
|
106
|
+
const result = classifyUpdate({ root: args.root, payload: args.payload });
|
|
107
|
+
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (isMainModule(import.meta.url)) {
|
|
111
|
+
try {
|
|
112
|
+
main();
|
|
113
|
+
} catch (err) {
|
|
114
|
+
process.stderr.write(`classify-update: ${err.message}\n`);
|
|
115
|
+
process.exit(1);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
@@ -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: `
|
|
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: `
|
|
214
|
+
skipped.push({ step: 'discovery', reason: `branch discovered from the worktree: ${branch}` });
|
|
213
215
|
}
|
|
214
216
|
}
|
|
215
217
|
|