@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
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/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.19.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",
@@ -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/",
@@ -30,9 +30,12 @@ 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
+ - `/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
@@ -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
  }
@@ -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
  }
@@ -51,45 +51,111 @@ export function compareVersions(a, b) {
51
51
  return 0;
52
52
  }
53
53
 
54
- const REGISTRY_URL = 'https://registry.npmjs.org/@ulysses-ai/create-workspace/latest';
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
- * Fetch the latest version of the scaffolder from the npm registry.
59
- * Returns { version, error } — exactly one of them is non-null.
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(REGISTRY_URL, { signal: controller.signal });
122
+ const res = await fetchFn(DIST_TAGS_URL, { signal: controller.signal });
68
123
  if (!res.ok) {
69
- return { version: null, error: `registry returned ${res.status} ${res.statusText || ''}`.trim() };
124
+ return { ...empty, error: `registry returned ${res.status} ${res.statusText || ''}`.trim() };
70
125
  }
71
126
  const body = await res.json();
72
- if (typeof body?.version !== 'string') {
73
- return { version: null, error: 'registry response missing version field' };
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: body.version, error: null };
137
+ return { version: picked.version, channel: picked.channel, tags, prerelease: picked.prerelease, error: null };
76
138
  } catch (err) {
77
- return { version: null, error: err?.message || String(err) };
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 string
85
- * `latestVersion` field; otherwise null. Treats missing file, malformed JSON,
86
- * and shape mismatches all as "no cache".
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?.latestVersion !== 'string') return null;
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;
@@ -765,4 +765,6 @@ export {
765
765
  stripFrontmatter,
766
766
  gitIgnoredPaths,
767
767
  isLocalOnlyName,
768
+ readIgnorePrefixes,
769
+ isIgnored,
768
770
  };
@@ -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> --work-item <id> --branch <b> [--repo <r>]
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 tracker issue plus a branch plus the repo it lands in. It is
195
- // created on demand and disappears when it merges — nothing about it is
196
- // durable except the issue and the commits, so the record holds only the
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
- function addTask(root, chatName, { workItem, branch, repo = null } = {}) {
203
- if (!workItem) throw new Error('addTask: workItem is required');
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((t) => t.workItem === workItem && (t.repo ?? null) === repo);
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 (!workItem) throw new Error('removeTask: workItem is required');
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((t) => !(t.workItem === workItem && (t.repo ?? null) === repo));
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.workItem || !args.branch)) {
272
- throw new Error('--add-task requires --chat, --work-item and --branch');
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
  }