@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
@@ -9,12 +9,14 @@
9
9
  // Source of truth: the filesystem. Hand edits are overwritten on regeneration.
10
10
  // Gitignored files are excluded automatically. .indexignore adds prefix excludes.
11
11
  //
12
- // Canonical files honor a configurable byte budget. Each locked file declares
12
+ // Canonical files honor an opt-in byte budget. Each locked file declares
13
13
  // `priority: critical | reference` in its frontmatter (default: critical).
14
14
  // Section-level `<!-- canonical:trim --> ... <!-- canonical:end-trim -->` markers
15
- // fence droppable spans inside reference files. When canonical body bytes exceed
16
- // the budget, the builder trims reference files first, then stubs them, in that
17
- // deterministic order. Critical files are never modified.
15
+ // fence droppable spans inside reference files. When a budget is set and the
16
+ // canonical body exceeds it, the builder trims reference files first, then
17
+ // stubs them, in that deterministic order. Critical files are never modified.
18
+ // `workspace.canonicalBudgetBytes` is off unless it holds a number: absent or
19
+ // null means every locked file ships in full and nothing is trimmed.
18
20
  //
19
21
  // Usage:
20
22
  // node build-workspace-context.mjs --write [--root <workspace-root>]
@@ -25,6 +27,7 @@
25
27
  // 0 — all artifacts current and canonical within budget
26
28
  // 1 — at least one artifact missing or stale (regenerate via --write)
27
29
  // 2 — artifacts current, but canonical body exceeds budget after trimming and stubbing
30
+ // (only reachable when a budget is set; off means nothing can exceed it)
28
31
  // Stale wins over over-budget when both apply.
29
32
 
30
33
  import { readFileSync, writeFileSync, readdirSync, statSync, existsSync, realpathSync } from 'node:fs';
@@ -301,16 +304,19 @@ export function extractCanonicalVariants({ name, rawContent }) {
301
304
  }
302
305
 
303
306
  /**
304
- * Read `workspace.canonicalBudgetBytes` from `workspace.json`.
307
+ * Read `workspace.canonicalBudgetBytes` from `workspace.json`. The budget is
308
+ * opt-in — the whole always-loaded set is measured by `alwaysLoadedBudgetBytes`
309
+ * instead, so a canonical-only ceiling is something a workspace opts into.
305
310
  * Returns:
306
- * - The integer value when set to a non-negative integer.
307
- * - 0 when set to 0 or a negative number (treated as disabled).
308
- * - DEFAULT_CANONICAL_BUDGET when the field is absent or workspace.json is missing.
311
+ * - 0 when the field is absent or `null`, or workspace.json is missing (off).
312
+ * - 0 when set to 0 or a negative number (treated as off).
313
+ * - The integer value when set to a positive number.
314
+ * - DEFAULT_CANONICAL_BUDGET when set to a non-number (invalid, rejected as before).
309
315
  * - DEFAULT_CANONICAL_BUDGET (with a stderr warning) when workspace.json fails to parse.
310
316
  */
311
317
  export function readWorkspaceBudget(workspaceRoot) {
312
318
  const path = join(workspaceRoot, 'workspace.json');
313
- if (!existsSync(path)) return DEFAULT_CANONICAL_BUDGET;
319
+ if (!existsSync(path)) return 0;
314
320
  let parsed;
315
321
  try {
316
322
  parsed = JSON.parse(readFileSync(path, 'utf-8'));
@@ -319,9 +325,10 @@ export function readWorkspaceBudget(workspaceRoot) {
319
325
  return DEFAULT_CANONICAL_BUDGET;
320
326
  }
321
327
  const ws = parsed && typeof parsed === 'object' ? parsed.workspace : null;
322
- if (!ws || typeof ws !== 'object') return DEFAULT_CANONICAL_BUDGET;
323
- if (!('canonicalBudgetBytes' in ws)) return DEFAULT_CANONICAL_BUDGET;
328
+ if (!ws || typeof ws !== 'object') return 0;
329
+ if (!('canonicalBudgetBytes' in ws)) return 0;
324
330
  const v = ws.canonicalBudgetBytes;
331
+ if (v === null) return 0;
325
332
  if (typeof v !== 'number' || !Number.isFinite(v)) return DEFAULT_CANONICAL_BUDGET;
326
333
  if (v <= 0) return 0;
327
334
  return Math.floor(v);
@@ -359,7 +366,8 @@ export function renderCanonicalBody(resolvedItems) {
359
366
  * 4. Keep stage-3 resolution; status `over-budget`, overBy populated.
360
367
  *
361
368
  * Special cases:
362
- * - budgetBytes <= 0: stage 1 always wins, selection.budgetBytes = null.
369
+ * - budgetBytes <= 0 (off — absent, null, 0, or negative): stage 1 always
370
+ * wins, selection.budgetBytes = null.
363
371
  * - No reference items present and stage 1 fails: status `over-budget`,
364
372
  * no transformation possible. Stderr warning is emitted.
365
373
  */
@@ -380,7 +388,7 @@ export function selectCanonicalContent(items, budgetBytes, opts) {
380
388
  return { name: item.name, priority: item.priority, content: item.full };
381
389
  });
382
390
 
383
- // Disabled-budget path.
391
+ // No-budget path (off because unset, or explicitly disabled).
384
392
  if (!Number.isFinite(budgetBytes) || budgetBytes <= 0) {
385
393
  const resolved = resolveAt(1);
386
394
  return {
@@ -537,6 +545,8 @@ function renderCanonical(resolvedItems, selection) {
537
545
  lines.push(
538
546
  `> Budget: ${selection.budgetBytes} bytes (body); current: ${selection.currentBytes} bytes; status: ${selection.status} (${summarizeSelection(selection)}).`,
539
547
  );
548
+ } else if (selection) {
549
+ lines.push(`> Budget: off; current: ${selection.currentBytes} bytes.`);
540
550
  }
541
551
  lines.push('');
542
552
 
@@ -673,7 +683,8 @@ function regenerateAll(workspaceRoot) {
673
683
  * 0 — all artifacts current and canonical body is within budget.
674
684
  * 1 — at least one artifact is missing or stale on disk. Run `--write`.
675
685
  * 2 — artifacts are current but canonical body exceeds budget after
676
- * trimming and stubbing eligible reference files. Triage via
686
+ * trimming and stubbing eligible reference files (only reachable when
687
+ * a budget is set — off means nothing can exceed it). Triage via
677
688
  * `/maintenance cleanup`. If both stale and over-budget, exit 1.
678
689
  *
679
690
  * `--write` always exits 0 on successful regeneration; over-budget is
@@ -20,6 +20,18 @@
20
20
  // node chat-record.mjs --root <dir> --list
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
+ // node chat-record.mjs --root <dir> --whoami
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
+ //
27
+ // --add-task / --remove-task report `action`: "added" | "updated" for
28
+ // --add-task, "removed" | "unchanged" for --remove-task. They also keep the
29
+ // older `updated` boolean / `removed` count fields — `updated` means "an
30
+ // existing entry was replaced", not "anything changed" — but new callers
31
+ // should read `action`. --whoami prints this chat's record name by matching
32
+ // $CLAUDE_CODE_SESSION_ID against the records' sessionId, nothing and exit
33
+ // 1 when there is no match: the `Chat record:` hook line can be missing
34
+ // after context compaction, and this is the recovery path.
23
35
 
24
36
  import {
25
37
  readFileSync, writeFileSync, existsSync, mkdirSync,
@@ -179,35 +191,54 @@ function resolveChatName(root, { sessionId, registryName = null } = {}) {
179
191
  }
180
192
 
181
193
 
182
- // A task is a tracker issue plus a branch plus the repo it lands in. It is
183
- // created on demand and disappears when it merges — nothing about it is
184
- // durable except the issue and the commits, so the record holds only the
185
- // 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.
186
198
  //
187
199
  // Identity is workItem + repo: the same issue can legitimately be open against
188
200
  // two repos in a multi-repo task, and re-recording the same one must update
189
- // rather than duplicate. /start-work is not always run exactly once.
190
- function addTask(root, chatName, { workItem, branch, repo = null } = {}) {
191
- 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 } = {}) {
192
206
  if (!branch) throw new Error('addTask: branch is required');
193
207
  const rec = readRecord(root, chatName);
194
208
  if (!rec) throw new Error(`addTask: no chat record for "${chatName}"`);
195
- 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
+ );
196
213
  const task = { workItem, branch, repo };
197
214
  if (i >= 0) rec.tasks[i] = task;
198
215
  else rec.tasks.push(task);
199
216
  writeRecord(root, rec);
200
- return { record: rec, updated: i >= 0 };
217
+ return { record: rec, action: i >= 0 ? 'updated' : 'added', updated: i >= 0 };
201
218
  }
202
219
 
203
- function removeTask(root, chatName, { workItem, repo = null } = {}) {
204
- 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');
205
222
  const rec = readRecord(root, chatName);
206
223
  if (!rec) throw new Error(`removeTask: no chat record for "${chatName}"`);
207
224
  const before = rec.tasks.length;
208
- 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
+ );
209
229
  writeRecord(root, rec);
210
- return { record: rec, removed: before - rec.tasks.length };
230
+ return { record: rec, action: before - rec.tasks.length > 0 ? 'removed' : 'unchanged', removed: before - rec.tasks.length };
231
+ }
232
+
233
+ // The record name for the chat running now. The SessionStart hook injects
234
+ // a `Chat record:` line, but compaction can drop it; the sessionId the
235
+ // hook keyed on is stable, so matching it against the records recovers the
236
+ // name without guessing. Null when the env var is unset or nothing matches.
237
+ function whoami(root, { env = process.env } = {}) {
238
+ const sid = env.CLAUDE_CODE_SESSION_ID;
239
+ if (!sid) return null;
240
+ const mine = listRecords(root).find((r) => r.sessionId === sid);
241
+ return mine ? mine.chat : null;
211
242
  }
212
243
 
213
244
  // Scope is what a chat declares it owns. The Aug 26 coordination burst had
@@ -230,6 +261,7 @@ function parseArgs(argv) {
230
261
  if (a === '--list') { args.mode = 'list'; continue; }
231
262
  if (a === '--read') { args.mode = 'read'; args.chat = rest[++i]; continue; }
232
263
  if (a === '--reconcile') { args.mode = 'reconcile'; continue; }
264
+ if (a === '--whoami') { args.mode = 'whoami'; continue; }
233
265
  if (a === '--add-task') { args.mode = 'add-task'; continue; }
234
266
  if (a === '--remove-task') { args.mode = 'remove-task'; continue; }
235
267
  if (a === '--chat') { args.chat = rest[++i]; continue; }
@@ -240,28 +272,37 @@ function parseArgs(argv) {
240
272
  if (a === '--name') { args.name = rest[++i]; continue; }
241
273
  throw new Error(`unknown argument: ${a}`);
242
274
  }
243
- if (!args.mode) throw new Error('one of --list, --read <chat>, --reconcile is required');
275
+ if (!args.mode) throw new Error('one of --list, --read <chat>, --reconcile, --whoami is required');
244
276
  if (args.mode === 'reconcile' && (!args.sessionId || !args.name)) {
245
277
  throw new Error('--reconcile requires --session-id and --name');
246
278
  }
247
- if (args.mode === 'add-task' && (!args.chat || !args.workItem || !args.branch)) {
248
- 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)');
249
281
  }
250
- if (args.mode === 'remove-task' && (!args.chat || !args.workItem)) {
251
- 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');
252
284
  }
253
285
  return args;
254
286
  }
255
287
 
256
288
  function main() {
257
289
  const args = parseArgs(process.argv);
290
+ if (args.mode === 'whoami') {
291
+ // A bare name, not JSON: the caller wants something to put on a command
292
+ // line. No match prints nothing and exits 1 — the caller decides what
293
+ // an unidentified chat means.
294
+ const name = whoami(args.root);
295
+ if (name === null) process.exit(1);
296
+ process.stdout.write(`${name}\n`);
297
+ return;
298
+ }
258
299
  let out;
259
300
  if (args.mode === 'list') out = listRecords(args.root);
260
301
  else if (args.mode === 'read') out = readRecord(args.root, args.chat);
261
302
  else if (args.mode === 'add-task') {
262
- 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 });
263
304
  } else if (args.mode === 'remove-task') {
264
- 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 });
265
306
  } else out = reconcile(args.root, { sessionId: args.sessionId, name: args.name });
266
307
  process.stdout.write(`${JSON.stringify(out, null, 2)}\n`);
267
308
  }
@@ -278,5 +319,5 @@ if (isMainModule(import.meta.url)) {
278
319
  export {
279
320
  recordPath, drawerPath, emptyRecord, readRecord, writeRecord,
280
321
  listRecords, reconcile, parseArgs, readSessionRegistry, resolveChatName,
281
- addTask, removeTask, setScope, CHATS_DIR,
322
+ addTask, removeTask, setScope, whoami, CHATS_DIR,
282
323
  };
@@ -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: `Tracker missing repos; discovered ${found.length} from ${nestedReposDir}: ${found.join(', ')}`,
153
+ reason: `repos discovered from disk: ${found.length} (${found.join(', ')})`,
152
154
  });
153
155
  }
154
156
  } catch (err) {
@@ -209,7 +211,7 @@ if (!branch && existsSync(wsWorktree)) {
209
211
  const res = git(wsWorktree, ['rev-parse', '--abbrev-ref', 'HEAD']);
210
212
  if (res.ok && res.out.trim() !== '' && res.out.trim() !== 'HEAD') {
211
213
  branch = res.out.trim();
212
- skipped.push({ step: 'discovery', reason: `Tracker missing branch; discovered from worktree: ${branch}` });
214
+ skipped.push({ step: 'discovery', reason: `branch discovered from the worktree: ${branch}` });
213
215
  }
214
216
  }
215
217