@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
@@ -7,13 +7,32 @@
7
7
  // some still live. This script is the mechanical half of draining them:
8
8
  //
9
9
  // --inventory read-only evidence + a proposal (ACTIVE / ABANDONED /
10
- // MERGEABLE / UNKNOWN / REMOVE_SHELL / LEAVE) per session
10
+ // MERGEABLE / UNKNOWN / REMOVE_SHELL / LEAVE) per session,
11
+ // listing every worktree's remotes by name, fetch URL, and
12
+ // (when it differs) push URL(s)
11
13
  // --backup tag each tip the session holds that no remote branch or
12
- // tag already points at, push the tag, verify it
14
+ // tag already points at — LOCAL tags only, nothing pushed
13
15
  // (--dry-run reports the plan with no side effects)
16
+ // --backup --remote
17
+ // push mode: pushes only where explicitly allowed, via
18
+ // --remote-allow <repo>=<remote> (repeatable; "." is the
19
+ // workspace repo) or --remote-allow-all. With neither it
20
+ // pushes nothing, lists the exact URL(s) every repo would
21
+ // push to, and exits non-zero — a plan, not a refusal of
22
+ // the whole idea. A repo's resolved remote can be anyone's
23
+ // (a third-party upstream counts as "origin" too), so an
24
+ // unallowed push is never a guess the script gets to make.
25
+ // Pushing approves and verifies against the PUSH URL(s)
26
+ // (remote.<name>.pushurl, url.*.pushInsteadOf — resolved
27
+ // by git itself), never the fetch URL: a remote whose push
28
+ // targets differ from its fetch URL needs an explicit
29
+ // --remote-allow entry naming it, so no approval is ever
30
+ // granted against a URL the push does not go to.
14
31
  // --archive move the session out of the active lifecycle — the whole
15
32
  // folder is renamed into {sessions}/.archived/ and git's
16
- // worktree links are repaired to follow it
33
+ // worktree links are repaired to follow it. Refuses when a
34
+ // worktree holds uncommitted changes (an edited session.md
35
+ // counts) unless --allow-uncommitted says leave them be.
17
36
  // --enable-task-model
18
37
  // flip workspace.sessionModel to "task" (accepts a task
19
38
  // worktree root — the one mode allowed off the launcher)
@@ -287,6 +306,57 @@ function remotesOf(gitFn, path) {
287
306
  return okLines(res);
288
307
  }
289
308
 
309
+ // The URL of every configured remote, read from local config. This never
310
+ // touches the network, so it answers even when a remote is unreachable or
311
+ // has never been fetched — the one fact that stops "origin" from being an
312
+ // anonymous label. A repo whose origin is a third-party upstream shows
313
+ // that URL here, whatever ls-remote later says about the branch.
314
+ function remoteUrlsOf(gitFn, path, remotes) {
315
+ const urls = {};
316
+ for (const remote of remotes) {
317
+ const res = run(gitFn, path, ['remote', 'get-url', remote]);
318
+ urls[remote] = res.status === 0 ? String(res.stdout).trim() : null;
319
+ }
320
+ return urls;
321
+ }
322
+
323
+ // The URL of one remote, or null when it cannot be read (fail closed —
324
+ // a push plan that cannot name where it would push is not a plan).
325
+ function remoteUrlOf(gitFn, repoDir, remote) {
326
+ if (!remote) return null;
327
+ const res = run(gitFn, repoDir, ['remote', 'get-url', remote]);
328
+ return res.status === 0 ? String(res.stdout).trim() : null;
329
+ }
330
+
331
+ // Where `git push <remote>` would ACTUALLY send, resolved by git itself:
332
+ // every configured pushurl (it is multi-valued — a push goes to each),
333
+ // falling back to the fetch URL, with url.*.pushInsteadOf rewriting
334
+ // applied (`remote get-url` expands insteadOf/pushInsteadOf). Offline,
335
+ // like the fetch URL. The operator reads the fetch URL as "the remote",
336
+ // but their approval lands on the push URL(s) — a remote whose push
337
+ // targets differ from its fetch URL must be approved against the push
338
+ // targets, never against the fetch URL it will not go to.
339
+ function pushUrlsFor(gitFn, repoDir, remote) {
340
+ if (!remote) return [];
341
+ const res = run(gitFn, repoDir, ['remote', 'get-url', '--push', '--all', remote]);
342
+ return res.status === 0 ? okLines(res).filter(Boolean) : null;
343
+ }
344
+
345
+ function pushUrlsOf(gitFn, path, remotes) {
346
+ const urls = {};
347
+ for (const remote of remotes) {
348
+ urls[remote] = pushUrlsFor(gitFn, path, remote);
349
+ }
350
+ return urls;
351
+ }
352
+
353
+ // Do this push target(s) say the same thing as the fetch URL the operator
354
+ // sees? Anything else — a decoy pushurl, a pushInsteadOf rewrite, several
355
+ // pushurls — means the fetch URL is not where a push lands.
356
+ function pushMatchesFetch(fetchUrl, pushUrls) {
357
+ return fetchUrl != null && pushUrls != null && pushUrls.length === 1 && pushUrls[0] === fetchUrl;
358
+ }
359
+
290
360
  // Does {branch} exist on {remote}, and at what commit? Local tracking
291
361
  // refs prove nothing (they are stale the moment anything fetches), so
292
362
  // the remote is asked directly. Timeouts and failures degrade to
@@ -304,44 +374,53 @@ function lsRemoteBranch(gitFn, cwd, remote, branch) {
304
374
  //
305
375
  // For each remote holding the branch, record how the local tip relates:
306
376
  // same / local-ahead / local-behind / diverged (with counts) when the
307
- // remote commit is known locally, not-fetched when it is not. This is
308
- // what tells the operator "finishing needs a force-push decision" BEFORE
309
- // they choose Finish, rather than after /complete-work's push bounces.
310
- function remoteStatesFor(gitFn, wtPath, branch, head) {
377
+ // remote commit is known locally, not-fetched when it is not. Every entry
378
+ // carries the remote's URL (from local config, so it survives an
379
+ // unreachable or never-fetched remote). This is what tells the operator
380
+ // "finishing needs a force-push decision" BEFORE they choose Finish,
381
+ // rather than after /complete-work's push bounces. A detached worktree
382
+ // still lists its remotes — with state 'no-branch', never as "no remotes".
383
+ function remoteStatesFor(gitFn, wtPath, branch, head, remotesList, urls) {
311
384
  const out = {};
312
- if (!branch) return out;
313
- for (const remote of remotesOf(gitFn, wtPath)) {
385
+ if (!branch) {
386
+ for (const remote of remotesList) {
387
+ out[remote] = { url: urls[remote] ?? null, exists: false, sha: null, state: 'no-branch' };
388
+ }
389
+ return out;
390
+ }
391
+ for (const remote of remotesList) {
392
+ const url = urls[remote] ?? null;
314
393
  const probe = lsRemoteBranch(gitFn, wtPath, remote, branch);
315
394
  if (probe.exists === 'unknown') {
316
- out[remote] = { exists: false, sha: null, state: 'unknown' };
395
+ out[remote] = { url, exists: false, sha: null, state: 'unknown' };
317
396
  continue;
318
397
  }
319
398
  if (!probe.exists) {
320
- out[remote] = { exists: false, sha: null, state: 'none' };
399
+ out[remote] = { url, exists: false, sha: null, state: 'none' };
321
400
  continue;
322
401
  }
323
402
  const { sha } = probe;
324
403
  if (sha === head) {
325
- out[remote] = { exists: true, sha, state: 'same' };
404
+ out[remote] = { url, exists: true, sha, state: 'same' };
326
405
  continue;
327
406
  }
328
407
  const known = run(gitFn, wtPath, ['cat-file', '-e', `${sha}^{commit}`]).status === 0;
329
408
  if (!known) {
330
- out[remote] = { exists: true, sha, state: 'not-fetched' };
409
+ out[remote] = { url, exists: true, sha, state: 'not-fetched' };
331
410
  continue;
332
411
  }
333
412
  const lr = run(gitFn, wtPath, ['rev-list', '--left-right', '--count', `${sha}...HEAD`]);
334
413
  if (lr.status !== 0) {
335
- out[remote] = { exists: true, sha, state: 'unknown' };
414
+ out[remote] = { url, exists: true, sha, state: 'unknown' };
336
415
  continue;
337
416
  }
338
417
  const counts = String(lr.stdout).trim().split(/\s+/).map(Number);
339
418
  const remoteOnly = counts[0]; // left side: commits only the remote has
340
419
  const localOnly = counts[1]; // right side: commits only HEAD has
341
- if (remoteOnly === 0 && localOnly === 0) out[remote] = { exists: true, sha, state: 'same' };
342
- else if (localOnly > 0 && remoteOnly === 0) out[remote] = { exists: true, sha, state: 'local-ahead', ahead: localOnly };
343
- else if (localOnly === 0 && remoteOnly > 0) out[remote] = { exists: true, sha, state: 'local-behind', behind: remoteOnly };
344
- else out[remote] = { exists: true, sha, state: 'diverged', ahead: localOnly, behind: remoteOnly };
420
+ if (remoteOnly === 0 && localOnly === 0) out[remote] = { url, exists: true, sha, state: 'same' };
421
+ else if (localOnly > 0 && remoteOnly === 0) out[remote] = { url, exists: true, sha, state: 'local-ahead', ahead: localOnly };
422
+ else if (localOnly === 0 && remoteOnly > 0) out[remote] = { url, exists: true, sha, state: 'local-behind', behind: remoteOnly };
423
+ else out[remote] = { url, exists: true, sha, state: 'diverged', ahead: localOnly, behind: remoteOnly };
345
424
  }
346
425
  return out;
347
426
  }
@@ -489,7 +568,10 @@ function inspectWorktree(gitFn, rootDir, kind, repo, wtPath) {
489
568
  info.contentFiles = countContentFiles(gitFn, wtPath, defaultBranch);
490
569
  info.dirtyContent = dirtyRecords.filter((r) => !isSessionArtifact(r.path)).length;
491
570
  }
492
- info.remotes = remoteStatesFor(gitFn, wtPath, branch, head);
571
+ const remotesList = remotesOf(gitFn, wtPath);
572
+ info.remoteUrls = remoteUrlsOf(gitFn, wtPath, remotesList);
573
+ info.remotePushUrls = pushUrlsOf(gitFn, wtPath, remotesList);
574
+ info.remotes = remoteStatesFor(gitFn, wtPath, branch, head, remotesList, info.remoteUrls);
493
575
  info.backedBy = backedByRemote(info.remotes);
494
576
  return info;
495
577
  }
@@ -1022,13 +1104,15 @@ function peeledTagSha(gitFn, cwd, tag) {
1022
1104
  return res.status === 0 ? String(res.stdout).trim() : null;
1023
1105
  }
1024
1106
 
1025
- // Does {tag} exist on {remote} at exactly {commit}? Deliberately no
1026
- // refspec pattern: a pattern filters out the peeled `^{}` line (the
1027
- // tag object's sha is not the commit's), and the peeled line is exactly
1028
- // what "at this commit" needs. An annotated tag answers via the peel; a
1029
- // lightweight tag's only line already is the commit.
1030
- function tagOnRemoteAt(gitFn, cwd, remote, tag, commit) {
1031
- const res = gitFn('git', ['-C', cwd, 'ls-remote', '--tags', remote], netOpts(LS_REMOTE_TIMEOUT_MS));
1107
+ // Does {tag} exist at exactly {commit} on one specific URL — the push
1108
+ // URL, which is where the push actually landed, not a remote name whose
1109
+ // fetch URL may point elsewhere? Deliberately no refspec pattern: a
1110
+ // pattern filters out the peeled `^{}` line (the tag object's sha is not
1111
+ // the commit's), and the peeled line is exactly what "at this commit"
1112
+ // needs. An annotated tag answers via the peel; a lightweight tag's only
1113
+ // line already is the commit.
1114
+ function tagHeldAtUrl(gitFn, cwd, url, tag, commit) {
1115
+ const res = gitFn('git', ['-C', cwd, 'ls-remote', '--tags', url], netOpts(LS_REMOTE_TIMEOUT_MS));
1032
1116
  if (res.error || res.status !== 0) return false;
1033
1117
  const peeledRef = `refs/tags/${tag}^{}`;
1034
1118
  const plainRef = `refs/tags/${tag}`;
@@ -1041,15 +1125,20 @@ function tagOnRemoteAt(gitFn, cwd, remote, tag, commit) {
1041
1125
  return plainSha === commit;
1042
1126
  }
1043
1127
 
1044
- // S4: where a backup tag is pushed — branch.<b>.pushRemote, then
1045
- // remote.pushDefault, then branch.<b>.remote, then origin, then the
1046
- // first configured remote. An explicit --remote overrides everything.
1047
- function resolvePushRemote(gitFn, repoDir, branch, override, remotes) {
1048
- if (override) {
1049
- return remotes.includes(override)
1050
- ? { remote: override }
1051
- : { remote: null, reason: `--remote ${override} is not configured for this repo (has: ${remotes.join(', ') || 'none'})` };
1052
- }
1128
+ // A pushed tag counts as backed up only where the push really went: every
1129
+ // push URL of the remote must hold it at the commit (`git push` sends to
1130
+ // each pushurl).
1131
+ function tagOnPushUrls(gitFn, cwd, pushUrls, tag, commit) {
1132
+ if (!pushUrls || pushUrls.length === 0) return false;
1133
+ return pushUrls.every((u) => tagHeldAtUrl(gitFn, cwd, u, tag, commit));
1134
+ }
1135
+
1136
+ // S4: where a backup tag would be pushed — branch.<b>.pushRemote, then
1137
+ // remote.pushDefault, then branch.<b>.remote, then origin, then the first
1138
+ // configured remote. What this resolves is only a CANDIDATE: push mode
1139
+ // acts on it solely where --remote-allow names it or --remote-allow-all
1140
+ // blesses every repo.
1141
+ function resolvePushRemote(gitFn, repoDir, branch, remotes) {
1053
1142
  const cfg = (key) => {
1054
1143
  const res = run(gitFn, repoDir, ['config', '--get', key]);
1055
1144
  return res.status === 0 ? String(res.stdout).trim() : null;
@@ -1069,16 +1158,40 @@ function resolvePushRemote(gitFn, repoDir, branch, override, remotes) {
1069
1158
  /**
1070
1159
  * Back up every tip the session holds that no qualifying remote already
1071
1160
  * has as a branch or tag: an annotated, session-scoped `drain/{session}/…`
1072
- * tag at the tip, pushed to the resolved remote and verified there — an
1073
- * off-machine copy of the session's commits before it is archived, and
1074
- * the thing to check before the operator ever deletes an archive. With dryRun the
1075
- * plan is reported per tip (remote, tag, already-safe) with no side
1076
- * effects — backup is a decision, and the operator sees exactly what
1077
- * would be pushed where before saying yes. Idempotent — an existing tag
1078
- * at the same commit is fine; at a different commit (the branch moved)
1079
- * the operator must decide, so it refuses.
1161
+ * tag at the tip. Local tags only — a backup run never pushes on its own.
1162
+ *
1163
+ * Push mode (`remote: true`, CLI `--backup --remote`) adds pushing, and
1164
+ * pushing is allowed nowhere by default: a `remoteAllow` entry
1165
+ * (`repo=remote`, "." for the workspace repo) permits one repo at a time,
1166
+ * `remoteAllowAll` permits every repo's resolved remote (it wins wherever
1167
+ * both are given — an entry still pins which remote that one repo uses).
1168
+ * With neither, the run lists the exact URL(s) every repo would push to
1169
+ * and exits non-zero — nothing tagged, nothing pushed, a plan handed back
1170
+ * for per-repo decisions. A resolved remote is only a candidate: "origin"
1171
+ * can be a third-party upstream the operator must never push backup tags
1172
+ * to, and the URL (not the name) is what tells them apart.
1173
+ *
1174
+ * Every push decision is made against the PUSH URL(s) — resolved by git
1175
+ * via `remote get-url --push --all`, so pushurl and url.*.pushInsteadOf
1176
+ * are included — because that is where `git push` sends. A remote whose
1177
+ * push URL(s) differ from its fetch URL is refused under allow-all: only
1178
+ * an explicit `--remote-allow <repo>=<remote>` blesses it, and the refusal
1179
+ * prints the push targets, so the approval is always against where the
1180
+ * push actually goes. After pushing, the tag is verified on every push
1181
+ * URL. Where pushing is not allowed, the local tag IS the backup.
1182
+ * Idempotent — an existing tag at the same commit is fine; at a different
1183
+ * commit (the branch moved) the operator must decide, so it refuses. With
1184
+ * dryRun the plan is reported per tip with no side effects.
1080
1185
  */
1081
- function backupSession(root, { session, remote: remoteOverride = null, dryRun = false, gitFn = spawnSync, cwd = process.cwd() } = {}) {
1186
+ function backupSession(root, {
1187
+ session,
1188
+ remote = false,
1189
+ remoteAllow = [],
1190
+ remoteAllowAll = false,
1191
+ dryRun = false,
1192
+ gitFn = spawnSync,
1193
+ cwd = process.cwd(),
1194
+ } = {}) {
1082
1195
  const rootDir = resolveRoot(root);
1083
1196
  if (!isSessionSegment(session)) {
1084
1197
  throw new Error(`session name must be a single path segment, got: ${session}`);
@@ -1087,6 +1200,20 @@ function backupSession(root, { session, remote: remoteOverride = null, dryRun =
1087
1200
  const { folder, refusal } = sessionFolderGuards(rootDir, sessionsDir, session, cwd);
1088
1201
  if (refusal) return refusal;
1089
1202
 
1203
+ // --remote-allow entries parse before anything else acts: repo ("." is
1204
+ // the workspace repo) = remote name, both single clean segments.
1205
+ const allowMap = new Map();
1206
+ for (const entry of remoteAllow) {
1207
+ const eq = entry.indexOf('=');
1208
+ const repo = eq > 0 ? entry.slice(0, eq) : null;
1209
+ const remoteName = eq >= 0 && eq < entry.length - 1 ? entry.slice(eq + 1) : null;
1210
+ const shaped = repo && (repo === WORKSPACE_REPO || isRepoSegment(repo)) && remoteName && !/\s/.test(remoteName);
1211
+ if (!shaped) {
1212
+ return { refused: true, reasons: [`--remote-allow expects <repo>=<remote> ("." is the workspace repo), got: ${entry}`] };
1213
+ }
1214
+ allowMap.set(repo, remoteName);
1215
+ }
1216
+
1090
1217
  const del = sessionTips(gitFn, rootDir, folder);
1091
1218
  const reasons = [...validateTrackerShape(gitFn, rootDir, del)];
1092
1219
  if (existsSync(join(folder, 'workspace', '.git'))) {
@@ -1097,81 +1224,198 @@ function backupSession(root, { session, remote: remoteOverride = null, dryRun =
1097
1224
  const planned = [];
1098
1225
  if (reasons.length === 0 && existsSync(join(folder, 'workspace', '.git'))) {
1099
1226
  const safety = makeSafety(gitFn, rootDir);
1100
- for (const tip of del.tips) {
1101
- const verdict = tipSafety(safety, tip.repoDir, tip.sha);
1102
- if (verdict.safe) {
1103
- // Already provably on a qualifying remote — pushing a tag for it
1104
- // would only add noise (and possibly land in a public repo).
1105
- skipped.push({ repo: tip.repo, ref: tip.ref ?? 'HEAD', commit: tip.sha, safeOn: verdict.remote, safeRef: verdict.ref });
1106
- continue;
1107
- }
1108
- const shortSha = tip.sha.slice(0, 10);
1109
- const tagName = tip.ref
1110
- ? `drain/${session}/${slugForBranch(tip.ref)}`
1111
- : `drain/${session}/${tip.kind === 'workspace' ? 'workspace' : tip.repo}-detached-${shortSha}`;
1112
- const tagDir = tip.repoDir;
1113
- const remotes = safety.remotes(tagDir);
1114
- if (remotes.length === 0) {
1115
- reasons.push(`${repoLabel(tip)} has no remote to push the backup tag ${tagName} to — decide manually where to back up ${tip.ref ?? shortSha}`);
1116
- continue;
1117
- }
1118
- const { remote, reason } = resolvePushRemote(gitFn, tagDir, tip.ref, remoteOverride, remotes);
1119
- if (!remote) {
1120
- reasons.push(reason || `${repoLabel(tip)}: no push remote resolves — decide manually`);
1227
+
1228
+ // An allow entry must name a repo this session holds and a remote
1229
+ // that repo actually configures — both checked before anything is
1230
+ // tagged, so a typo refuses the whole run instead of silently
1231
+ // narrowing the push.
1232
+ const knownRepos = [...new Set(del.tips.map((t) => t.repo))];
1233
+ for (const [repo, remoteName] of allowMap) {
1234
+ if (!knownRepos.includes(repo)) {
1235
+ reasons.push(`--remote-allow names repo "${repo}", which this session does not hold (${knownRepos.join(', ') || 'none'})`);
1121
1236
  continue;
1122
1237
  }
1123
- const existing = peeledTagSha(gitFn, tagDir, tagName);
1124
- if (existing && existing !== tip.sha) {
1125
- reasons.push(`${repoLabel(tip)}: tag ${tagName} already points at ${existing.slice(0, 10)}…, not the current tip (${shortSha}…); the branch moved — decide manually`);
1126
- continue;
1238
+ const tip = del.tips.find((t) => t.repo === repo);
1239
+ const remotes = safety.remotes(tip.repoDir);
1240
+ if (!remotes.includes(remoteName)) {
1241
+ reasons.push(`--remote-allow names remote "${remoteName}" for ${repoLabel(tip)}, which is not configured (has: ${remotes.join(', ') || 'none'})`);
1127
1242
  }
1128
- if (dryRun) {
1129
- planned.push({
1130
- repo: tip.repo,
1131
- branch: tip.ref,
1132
- detached: !tip.ref,
1133
- commit: tip.sha,
1134
- tag: tagName,
1135
- remote,
1136
- alreadySafe: false,
1137
- wouldCreate: !existing,
1243
+ }
1244
+
1245
+ // Pass 1, no side effects: classify each tip, compute the tag it would
1246
+ // get, and resolve where a push would land — the remote's PUSH URL(s)
1247
+ // (where `git push` sends), alongside the fetch URL (what the operator
1248
+ // reads as "the remote"). When they differ, only an explicit allow
1249
+ // entry naming that remote may proceed: allow-all blesses fetch URLs
1250
+ // it has shown, never a push target it has not.
1251
+ const pending = [];
1252
+ if (reasons.length === 0) {
1253
+ for (const tip of del.tips) {
1254
+ const verdict = tipSafety(safety, tip.repoDir, tip.sha);
1255
+ if (verdict.safe) {
1256
+ // Already provably on a qualifying remote — pushing a tag for it
1257
+ // would only add noise (and possibly land in a public repo).
1258
+ skipped.push({ repo: tip.repo, ref: tip.ref ?? 'HEAD', commit: tip.sha, safeOn: verdict.remote, safeRef: verdict.ref });
1259
+ continue;
1260
+ }
1261
+ const shortSha = tip.sha.slice(0, 10);
1262
+ const tagName = tip.ref
1263
+ ? `drain/${session}/${slugForBranch(tip.ref)}`
1264
+ : `drain/${session}/${tip.kind === 'workspace' ? 'workspace' : tip.repo}-detached-${shortSha}`;
1265
+ const existing = peeledTagSha(gitFn, tip.repoDir, tagName);
1266
+ if (existing && existing !== tip.sha) {
1267
+ reasons.push(`${repoLabel(tip)}: tag ${tagName} already points at ${existing.slice(0, 10)}…, not the current tip (${shortSha}…); the branch moved — decide manually`);
1268
+ continue;
1269
+ }
1270
+ const allowedRemote = allowMap.get(tip.repo) ?? null;
1271
+ const remotes = safety.remotes(tip.repoDir);
1272
+ // Precedence: an allow entry pins this repo's remote (and is the
1273
+ // only thing that blesses a push/fetch URL divergence); allow-all
1274
+ // covers every OTHER repo with its resolved candidate.
1275
+ const explicitlyAllowed = allowedRemote != null && remotes.includes(allowedRemote);
1276
+ const resolved = explicitlyAllowed
1277
+ ? allowedRemote
1278
+ : resolvePushRemote(gitFn, tip.repoDir, tip.ref, remotes).remote;
1279
+ const fetchUrl = remoteUrlOf(gitFn, tip.repoDir, resolved);
1280
+ const pushUrls = pushUrlsFor(gitFn, tip.repoDir, resolved);
1281
+ const willPush = remote && (remoteAllowAll || explicitlyAllowed) && resolved != null;
1282
+ if (willPush && pushUrls == null) {
1283
+ // Fail closed: an approval that cannot name the destination is
1284
+ // not an approval.
1285
+ reasons.push(`${repoLabel(tip)}: could not resolve where a push to "${resolved}" would land (git remote get-url --push --all failed) — fix the remote config or push the tag manually`);
1286
+ continue;
1287
+ }
1288
+ if (willPush && !pushMatchesFetch(fetchUrl, pushUrls) && !explicitlyAllowed) {
1289
+ reasons.push(
1290
+ `${repoLabel(tip)}: remote "${resolved}" fetches from ${fetchUrl ?? '(url unreadable)'} but pushes to ${pushUrls.join(', ')}`
1291
+ + ` — allow-all blesses fetch URLs only; check those push targets and re-run with --remote-allow ${tip.repo}=${resolved}`,
1292
+ );
1293
+ continue;
1294
+ }
1295
+ pending.push({
1296
+ tip,
1297
+ tagName,
1298
+ existing,
1299
+ shortSha,
1300
+ allowed: remoteAllowAll || explicitlyAllowed,
1301
+ remote: resolved,
1302
+ url: fetchUrl,
1303
+ pushUrls,
1138
1304
  });
1139
- continue;
1140
1305
  }
1141
- let createdThisRun = false;
1142
- if (!existing) {
1143
- const created = run(gitFn, tagDir, ['tag', '-a', tagName, '-m', `backup before draining session ${session}`, tip.sha]);
1144
- if (created.status !== 0) {
1145
- reasons.push(`${repoLabel(tip)}: git tag ${tagName} failed: ${String(created.stderr || '').trim()}`);
1306
+ }
1307
+
1308
+ // Push mode with no allow at all is a plan, not a start: list every
1309
+ // target's exact push URL(s) — and the fetch URL when they differ —
1310
+ // and hand the per-repo decision back untouched. The plan exits
1311
+ // non-zero wet or dry: pushing is still waiting on a decision.
1312
+ if (reasons.length === 0 && remote && allowMap.size === 0 && !remoteAllowAll && pending.length > 0) {
1313
+ const targets = pending.map((p) => ({
1314
+ repo: p.tip.repo,
1315
+ branch: p.tip.ref ?? null,
1316
+ commit: p.tip.sha,
1317
+ tag: p.tagName,
1318
+ remote: p.remote,
1319
+ url: p.url,
1320
+ pushUrls: p.pushUrls,
1321
+ }));
1322
+ const reasonLines = [
1323
+ 'push mode pushes nothing without --remote-allow <repo>=<remote> (repeatable) or --remote-allow-all',
1324
+ ...pending.map((p) => {
1325
+ if (!p.remote) return `${repoLabel(p.tip)} has no remote — its backup stays a local tag`;
1326
+ return pushMatchesFetch(p.url, p.pushUrls)
1327
+ ? `${repoLabel(p.tip)} would push ${p.tagName} to ${p.remote} (${p.url ?? 'url unreadable'})`
1328
+ : `${repoLabel(p.tip)} would push ${p.tagName} to ${p.remote}: pushes to ${(p.pushUrls ?? []).join(', ') || '(push url unreadable)'} (fetch URL ${p.url ?? 'unreadable'})`;
1329
+ }),
1330
+ ];
1331
+ if (dryRun) return { session, dryRun: true, needsAllow: true, targets, reasons: reasonLines };
1332
+ return { refused: true, needsAllow: true, targets, reasons: reasonLines };
1333
+ }
1334
+
1335
+ // Pass 2: act. A pass-1 reason aborts before anything is tagged —
1336
+ // every acting run creates the local tag first (the baseline backup),
1337
+ // and pushing happens only where allowed; a refusal must not leave
1338
+ // half the session tagged behind it.
1339
+ if (reasons.length === 0) {
1340
+ for (const p of pending) {
1341
+ if (dryRun) {
1342
+ // The plan never hides a destination: every tip names its
1343
+ // candidate remote and push URL(s); willPush says whether this
1344
+ // run would actually push there.
1345
+ planned.push({
1346
+ repo: p.tip.repo,
1347
+ branch: p.tip.ref,
1348
+ detached: !p.tip.ref,
1349
+ commit: p.tip.sha,
1350
+ tag: p.tagName,
1351
+ remote: p.remote,
1352
+ url: p.url,
1353
+ pushUrls: p.pushUrls,
1354
+ willPush: Boolean(p.allowed && p.remote),
1355
+ wouldCreate: !p.existing,
1356
+ });
1146
1357
  continue;
1147
1358
  }
1148
- createdThisRun = true;
1149
- }
1150
- const pushed = gitFn('git', ['-C', tagDir, 'push', remote, `refs/tags/${tagName}`], netOpts(PUSH_TIMEOUT_MS));
1151
- if (pushed.error || pushed.status !== 0) {
1152
- // A local tag that never reached a remote masquerades as a
1153
- // backup (and local refs prove nothing) — remove the one we made.
1154
- if (createdThisRun) run(gitFn, tagDir, ['tag', '-d', tagName]);
1155
- const detail = pushed.error ? `timed out after ${PUSH_TIMEOUT_MS / 1000}s` : String(pushed.stderr || '').trim();
1156
- reasons.push(`${repoLabel(tip)}: pushing ${tagName} to ${remote} failed (${detail}) — treat ${tip.ref ?? shortSha} as unbacked`);
1157
- continue;
1158
- }
1159
- const verified = tagOnRemoteAt(gitFn, tagDir, remote, tagName, tip.sha);
1160
- if (!verified) {
1161
- if (createdThisRun) run(gitFn, tagDir, ['tag', '-d', tagName]);
1162
- reasons.push(`${repoLabel(tip)}: tag ${tagName} not found on ${remote} after pushing — treat ${tip.ref ?? shortSha} as unbacked`);
1163
- continue;
1359
+ let createdThisRun = false;
1360
+ if (!p.existing) {
1361
+ const created = run(gitFn, p.tip.repoDir, ['tag', '-a', p.tagName, '-m', `backup before draining session ${session}`, p.tip.sha]);
1362
+ if (created.status !== 0) {
1363
+ reasons.push(`${repoLabel(p.tip)}: git tag ${p.tagName} failed: ${String(created.stderr || '').trim()}`);
1364
+ continue;
1365
+ }
1366
+ createdThisRun = true;
1367
+ }
1368
+ if (!remote || !p.allowed || !p.remote) {
1369
+ const why = !remote
1370
+ ? 'local backup only — push mode is off'
1371
+ : p.remote ? 'not allowed here (no --remote-allow entry) — the local tag is the backup' : 'no remote configured — the local tag is the backup';
1372
+ branches.push({
1373
+ repo: p.tip.repo,
1374
+ branch: p.tip.ref,
1375
+ detached: !p.tip.ref,
1376
+ tag: p.tagName,
1377
+ commit: p.tip.sha,
1378
+ pushed: false,
1379
+ localOnly: true,
1380
+ remote: null,
1381
+ url: null,
1382
+ wouldPushTo: p.remote ? { remote: p.remote, url: p.url, pushUrls: p.pushUrls } : null,
1383
+ reason: why,
1384
+ });
1385
+ continue;
1386
+ }
1387
+ const pushTargets = (p.pushUrls && p.pushUrls.length > 0 ? p.pushUrls : [p.url]).filter(Boolean).join(', ');
1388
+ const pushed = gitFn('git', ['-C', p.tip.repoDir, 'push', p.remote, `refs/tags/${p.tagName}`], netOpts(PUSH_TIMEOUT_MS));
1389
+ if (pushed.error || pushed.status !== 0) {
1390
+ // A local tag that never reached a remote masquerades as a
1391
+ // backup (and local refs prove nothing) — remove the one we made.
1392
+ if (createdThisRun) run(gitFn, p.tip.repoDir, ['tag', '-d', p.tagName]);
1393
+ const detail = pushed.error ? `timed out after ${PUSH_TIMEOUT_MS / 1000}s` : String(pushed.stderr || '').trim();
1394
+ reasons.push(`${repoLabel(p.tip)}: pushing ${p.tagName} to ${p.remote} (${pushTargets || 'url unreadable'}) failed (${detail}) — treat ${p.tip.ref ?? p.shortSha} as unbacked`);
1395
+ continue;
1396
+ }
1397
+ // Verified where the push really went: every push URL must hold
1398
+ // the tag at the commit. The fetch URL is irrelevant here — a
1399
+ // decoy pushurl is exactly the case it would lie about.
1400
+ const verified = tagOnPushUrls(gitFn, p.tip.repoDir, p.pushUrls, p.tagName, p.tip.sha);
1401
+ if (!verified) {
1402
+ if (createdThisRun) run(gitFn, p.tip.repoDir, ['tag', '-d', p.tagName]);
1403
+ reasons.push(`${repoLabel(p.tip)}: tag ${p.tagName} not found at the push URL(s) ${pushTargets || '(unreadable)'} after pushing — treat ${p.tip.ref ?? p.shortSha} as unbacked`);
1404
+ continue;
1405
+ }
1406
+ branches.push({
1407
+ repo: p.tip.repo,
1408
+ branch: p.tip.ref,
1409
+ detached: !p.tip.ref,
1410
+ tag: p.tagName,
1411
+ commit: p.tip.sha,
1412
+ pushed: true,
1413
+ verified: true,
1414
+ remote: p.remote,
1415
+ url: p.url,
1416
+ pushUrls: p.pushUrls,
1417
+ });
1164
1418
  }
1165
- branches.push({
1166
- repo: tip.repo,
1167
- branch: tip.ref,
1168
- detached: !tip.ref,
1169
- tag: tagName,
1170
- commit: tip.sha,
1171
- remote,
1172
- pushed: true,
1173
- verified: true,
1174
- });
1175
1419
  }
1176
1420
  }
1177
1421
  if (reasons.length > 0) return { refused: true, reasons };
@@ -1315,11 +1559,15 @@ function repairAndVerify(gitFn, owned, worktreePaths, prunableBefore) {
1315
1559
  * outside this workspace; a submodule checkout is present (its link is
1316
1560
  * not a worktree link and `worktree repair` cannot fix it); git's own
1317
1561
  * list of worktrees names one under the folder that the walk did not
1318
- * find; or the archive directory is a symlink or resolves outside the
1319
- * workspace. If anything fails after the rename, the folder is renamed
1320
- * back and repaired, and the result reports the verified state.
1562
+ * find; a worktree holds uncommitted or untracked changes — an edited
1563
+ * session.md counts (unless allowUncommitted: they would ride along
1564
+ * fine, but they deserve a decision: commit them to the session branch,
1565
+ * or explicitly accept archiving them mid-edit); or the archive
1566
+ * directory is a symlink or resolves outside the workspace. If anything
1567
+ * fails after the rename, the folder is renamed back and repaired, and
1568
+ * the result reports the verified state.
1321
1569
  */
1322
- function archiveSession(root, { session, gitFn = spawnSync, cwd = process.cwd(), now = Date.now() } = {}) {
1570
+ function archiveSession(root, { session, allowUncommitted = false, gitFn = spawnSync, cwd = process.cwd(), now = Date.now() } = {}) {
1323
1571
  const rootDir = resolveRoot(root);
1324
1572
  if (!isSessionSegment(session)) {
1325
1573
  throw new Error(`session name must be a single path segment not starting with ".", got: ${session}`);
@@ -1364,6 +1612,27 @@ function archiveSession(root, { session, gitFn = spawnSync, cwd = process.cwd(),
1364
1612
  }
1365
1613
  }
1366
1614
  }
1615
+ // Uncommitted work rides along safely, but silently archiving it buries
1616
+ // it outside the lifecycle: notes committed to the session branch can
1617
+ // still be read and merged, while edits sitting uncommitted in an
1618
+ // archive effectively cannot. So an acting archive names every dirty
1619
+ // worktree — an uncommitted session.md included — and leaves the
1620
+ // decision to the operator.
1621
+ if (!allowUncommitted) {
1622
+ for (const f of found) {
1623
+ const wtPath = f.rel === '.' ? folder : join(folder, f.rel);
1624
+ const rel = relative(rootDir, wtPath);
1625
+ const records = statusRecords(gitFn, wtPath);
1626
+ if (!records) {
1627
+ reasons.push(`could not read the status of ${rel} — refusing rather than guessing; check it yourself before re-running with --allow-uncommitted`);
1628
+ continue;
1629
+ }
1630
+ if (records.length > 0) {
1631
+ const sample = records.slice(0, 3).map((r) => r.path).join(', ');
1632
+ reasons.push(`${rel} has ${records.length} uncommitted change(s) (${sample}${records.length > 3 ? ', …' : ''}) — commit them to the session branch, or re-run with --allow-uncommitted to archive them uncommitted`);
1633
+ }
1634
+ }
1635
+ }
1367
1636
  if (reasons.length > 0) return { refused: true, reasons };
1368
1637
 
1369
1638
  const archiveDir = join(sessionsDir, '.archived');
@@ -1470,6 +1739,21 @@ function renderTable(result) {
1470
1739
  .join(' ') || 'no remotes';
1471
1740
  const extra = w.kind === 'workspace' ? ` content:${w.contentFiles}` : '';
1472
1741
  lines.push(` ${w.kind === 'workspace' ? '(workspace)' : w.repo} ${w.branch ?? 'detached'} ahead:${w.ahead ?? '?'} dirty:${w.dirty}${extra} [${remotes}]`);
1742
+ // The state bracket alone is ambiguous — "origin:none" says the remote
1743
+ // holds no copy of this branch, not that there is no origin. The URL
1744
+ // line settles it: name = exact URL for every configured remote, plus
1745
+ // the push URL(s) whenever a push would land somewhere else — the
1746
+ // fetch URL is what the operator reads, the push URL is where their
1747
+ // approval goes.
1748
+ const urls = Object.entries(w.remoteUrls || {});
1749
+ if (urls.length > 0) {
1750
+ const pushUrls = w.remotePushUrls || {};
1751
+ lines.push(` remotes: ${urls.map(([n, u]) => {
1752
+ const pushes = pushUrls[n];
1753
+ const diverged = pushes != null && !(pushes.length === 1 && pushes[0] === u);
1754
+ return `${n} = ${u ?? '(url unreadable)'}${diverged ? ` (pushes to: ${pushes.join(', ')})` : ''}`;
1755
+ }).join(', ')}`);
1756
+ }
1473
1757
  }
1474
1758
  for (const r of s.reasons || []) lines.push(` · ${r}`);
1475
1759
  for (const w of s.warnings || []) lines.push(` ! ${w.message}`);
@@ -1482,11 +1766,27 @@ const VALUE_FLAGS = new Map([
1482
1766
  ['--root', 'root'],
1483
1767
  ['--session', 'session'],
1484
1768
  ['--active-days', 'activeDays'],
1769
+ ['--remote-allow', 'remoteAllow'], // repeatable: <repo>=<remote>
1770
+ ]);
1771
+ const BOOL_FLAGS = new Map([
1772
+ ['--dry-run', 'dryRun'],
1485
1773
  ['--remote', 'remote'],
1774
+ ['--remote-allow-all', 'remoteAllowAll'],
1775
+ ['--allow-uncommitted', 'allowUncommitted'],
1486
1776
  ]);
1487
1777
 
1488
1778
  function parseArgs(argv) {
1489
- const args = { root: '.', mode: null, session: null, activeDays: null, remote: null, dryRun: false };
1779
+ const args = {
1780
+ root: '.',
1781
+ mode: null,
1782
+ session: null,
1783
+ activeDays: null,
1784
+ remoteAllow: [],
1785
+ dryRun: false,
1786
+ remote: false,
1787
+ remoteAllowAll: false,
1788
+ allowUncommitted: false,
1789
+ };
1490
1790
  const rest = argv.slice(2);
1491
1791
  for (let i = 0; i < rest.length; i += 1) {
1492
1792
  const a = rest[i];
@@ -1495,12 +1795,14 @@ function parseArgs(argv) {
1495
1795
  args.mode = a.slice(2);
1496
1796
  continue;
1497
1797
  }
1498
- if (a === '--dry-run') { args.dryRun = true; continue; }
1798
+ const boolKey = BOOL_FLAGS.get(a);
1799
+ if (boolKey) { args[boolKey] = true; continue; }
1499
1800
  const key = VALUE_FLAGS.get(a);
1500
1801
  if (key) {
1501
1802
  const v = rest[i + 1];
1502
1803
  if (v === undefined || v.startsWith('--')) throw new Error(`${a} requires a value`);
1503
- args[key] = v;
1804
+ if (a === '--remote-allow') args.remoteAllow.push(v);
1805
+ else args[key] = v;
1504
1806
  i += 1;
1505
1807
  continue;
1506
1808
  }
@@ -1522,9 +1824,19 @@ function parseArgs(argv) {
1522
1824
  if (!Number.isInteger(n) || n <= 0) throw new Error('--active-days must be a positive integer');
1523
1825
  args.activeDays = n;
1524
1826
  }
1525
- if (args.remote != null && args.mode !== 'backup') {
1827
+ if (args.remote && args.mode !== 'backup') {
1526
1828
  throw new Error('--remote is only valid with --backup');
1527
1829
  }
1830
+ const hasAllow = args.remoteAllow.length > 0 || args.remoteAllowAll;
1831
+ if (hasAllow && args.mode !== 'backup') {
1832
+ throw new Error('--remote-allow/--remote-allow-all are only valid with --backup');
1833
+ }
1834
+ if (hasAllow && !args.remote) {
1835
+ throw new Error('--remote-allow/--remote-allow-all require --remote (push mode)');
1836
+ }
1837
+ if (args.allowUncommitted && args.mode !== 'archive') {
1838
+ throw new Error('--allow-uncommitted is only valid with --archive');
1839
+ }
1528
1840
  if (args.dryRun && args.mode !== 'backup') {
1529
1841
  throw new Error('--dry-run is only valid with --backup');
1530
1842
  }
@@ -1545,9 +1857,15 @@ function main() {
1545
1857
  out = inventory(rootDir, { activeDays: args.activeDays ?? 14 });
1546
1858
  process.stderr.write(renderTable(out));
1547
1859
  } else if (args.mode === 'backup') {
1548
- out = backupSession(rootDir, { session: args.session, remote: args.remote, dryRun: args.dryRun });
1860
+ out = backupSession(rootDir, {
1861
+ session: args.session,
1862
+ remote: args.remote,
1863
+ remoteAllow: args.remoteAllow,
1864
+ remoteAllowAll: args.remoteAllowAll,
1865
+ dryRun: args.dryRun,
1866
+ });
1549
1867
  } else if (args.mode === 'archive') {
1550
- out = archiveSession(rootDir, { session: args.session });
1868
+ out = archiveSession(rootDir, { session: args.session, allowUncommitted: args.allowUncommitted });
1551
1869
  } else {
1552
1870
  out = enableTaskModel(rootDir);
1553
1871
  }
@@ -1555,6 +1873,11 @@ function main() {
1555
1873
  if (out && out.refused) {
1556
1874
  process.stderr.write(`migrate-sessions: refused — ${out.reasons.join('; ')}\n`);
1557
1875
  code = 1;
1876
+ } else if (out && out.needsAllow) {
1877
+ // The dry-run push plan exits non-zero like its non-dry form: either
1878
+ // way pushing is still waiting on an explicit allow.
1879
+ process.stderr.write('migrate-sessions: push plan — pushing needs --remote-allow <repo>=<remote> or --remote-allow-all; nothing was pushed\n');
1880
+ code = 1;
1558
1881
  }
1559
1882
  return code;
1560
1883
  }