@ulysses-ai/create-workspace 0.22.0-beta.0 → 0.23.1-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.
@@ -25,7 +25,11 @@
25
25
  // of the git common dir), while the worktree's own
26
26
  // branch is only named in an info line and its dirty
27
27
  // tracked tree is skipped as info: in-flight work, not
28
- // drift (gh:183)
28
+ // drift (gh:183). The launcher's
29
+ // .claude/skills/workspace-update/ modification an
30
+ // --upgrade leaves when its content equals the staged
31
+ // payload's is the expected bootstrap, reported as
32
+ // info, never a dirty-tree warning (gh:190)
29
33
  // 5. auto-files — workspace-context catalogs current (the same
30
34
  // semantics as build-workspace-context.mjs --check)
31
35
  // 6. budget — always-loaded context within
@@ -82,6 +86,7 @@ import {
82
86
  readIgnorePrefixes,
83
87
  isIgnored,
84
88
  } from './build-workspace-context.mjs';
89
+ import { hashBytes } from './template-baseline.mjs';
85
90
  import { refreshIfStale } from '../lib/freshness.mjs';
86
91
  import { parseSessionContent } from '../lib/session-frontmatter.mjs';
87
92
 
@@ -157,7 +162,10 @@ export async function runAudit({
157
162
  // array — a structural delimiter, never a byte that could appear in the
158
163
  // values (NUL separators made git treat this file as binary).
159
164
  const seenFindings = new Set();
160
- const add = (section, severity, file, message) => {
165
+ // opts.noFromUpdate: expected-absent findings (a machine-local import
166
+ // missing inside a task worktree) stay ambient info even when their file
167
+ // is on the --changed list — the update did not cause them (gh:190).
168
+ const add = (section, severity, file, message, opts = {}) => {
161
169
  const key = JSON.stringify([section, severity, file, message]);
162
170
  if (seenFindings.has(key)) return;
163
171
  seenFindings.add(key);
@@ -166,7 +174,7 @@ export async function runAudit({
166
174
  severity,
167
175
  file,
168
176
  message,
169
- ...(changedSet.has(file) ? { fromUpdate: true } : {}),
177
+ ...(changedSet.has(file) && !opts.noFromUpdate ? { fromUpdate: true } : {}),
170
178
  });
171
179
  };
172
180
 
@@ -323,7 +331,10 @@ export async function runAudit({
323
331
  const posix = toPosix(spec);
324
332
  const base = posix.split('/').pop();
325
333
  if (base.startsWith('local-only-')) {
326
- add('cross-reference', 'info', 'CLAUDE.md', `@${posix} is absent — machine-local, expected on other machines`);
334
+ // Machine-local files never materialize inside a task worktree (the
335
+ // update flow audits from one), so this is ambient, never something
336
+ // the update caused (gh:190).
337
+ add('cross-reference', 'info', 'CLAUDE.md', `@${posix} is absent — machine-local, expected on other machines`, { noFromUpdate: true });
327
338
  } else if (posix === 'CODEBASE.md') {
328
339
  add('cross-reference', 'info', 'CLAUDE.md', '@CODEBASE.md is absent — optional stub, /workspace-init generates it on request');
329
340
  } else if (isAutoFileRel(posix, wcDir)) {
@@ -379,6 +390,9 @@ export async function runAudit({
379
390
  return relToWC.split('/').includes('archive');
380
391
  };
381
392
 
393
+ // Resolved lifecycles are closed out, not defects — however many there
394
+ // are, they surface as ONE info line, not one per file (gh:190).
395
+ const resolvedFiles = [];
382
396
  for (let i = 0; i < files.length; i++) {
383
397
  const rel = rels[i];
384
398
  if (ignored.has(rel) || isHistorical(rel)) continue;
@@ -435,12 +449,17 @@ export async function runAudit({
435
449
  }
436
450
  }
437
451
  if (f.lifecycle === 'resolved') {
438
- add('frontmatter', 'info', rel, 'lifecycle resolved — confirm /complete-work has processed it');
452
+ resolvedFiles.push(rel);
439
453
  }
440
454
  if ('confidence' in f && !['high', 'medium', 'low'].includes(f.confidence)) {
441
455
  add('frontmatter', 'warning', rel, `confidence '${f.confidence}' is not one of high, medium, low`);
442
456
  }
443
457
  }
458
+ if (resolvedFiles.length > 0) {
459
+ const names = resolvedFiles.slice(0, 3).map((rel) => rel.split('/').pop());
460
+ add('frontmatter', 'info', wcDir,
461
+ `${resolvedFiles.length} lifecycle resolved file(s) — confirm /complete-work has processed them (${names.join(', ')}${resolvedFiles.length > 3 ? ', …' : ''})`);
462
+ }
444
463
  })();
445
464
 
446
465
  // ---------- 4. git state ----------
@@ -463,11 +482,31 @@ export async function runAudit({
463
482
  add('git', 'warning', '.',
464
483
  `launcher is on branch '${gitInfo.launcherBranch}' — it stays on its default branch ('${gitInfo.defaultBranch}')`);
465
484
  }
485
+ // --upgrade replaces the launcher's workspace-update skill before the
486
+ // payload is applied, and the merged PR delivers the same content back:
487
+ // a modification that equals the staged payload's copy is the expected
488
+ // bootstrap, not drift — info, and out of the dirty warning (gh:190).
489
+ const porcelainPath = (line) => line.slice(line.indexOf(' ') + 1).split(' -> ')[0];
466
490
  const launcherDirty = gitInfo.launcherPorcelain.filter((l) => !l.startsWith('??'));
467
- if (launcherDirty.length > 0) {
468
- const paths = launcherDirty.slice(0, 5).map((l) => l.slice(l.indexOf(' ') + 1).replace(/ -> /, ' → '));
491
+ const bootstrap = launcherDirty.filter((l) => {
492
+ const p = porcelainPath(l);
493
+ if (!p.startsWith('.claude/skills/workspace-update/')) return false;
494
+ try {
495
+ return hashBytes(readFileSync(join(gitInfo.launcherRoot, p)))
496
+ === hashBytes(readFileSync(join(gitInfo.launcherRoot, '.workspace-update', p)));
497
+ } catch {
498
+ return false; // no staged payload copy to compare against
499
+ }
500
+ });
501
+ if (bootstrap.length > 0) {
502
+ add('git', 'info', '.',
503
+ "launcher's .claude/skills/workspace-update/ replaced by --upgrade (matches the staged payload) — expected until the update merges");
504
+ }
505
+ const drift = launcherDirty.filter((l) => !bootstrap.includes(l));
506
+ if (drift.length > 0) {
507
+ const paths = drift.slice(0, 5).map((l) => l.slice(l.indexOf(' ') + 1).replace(/ -> /, ' → '));
469
508
  add('git', 'warning', '.',
470
- `launcher has ${launcherDirty.length} tracked file(s) with uncommitted changes: ${paths.join(', ')}${launcherDirty.length > 5 ? ', …' : ''}`);
509
+ `launcher has ${drift.length} tracked file(s) with uncommitted changes: ${paths.join(', ')}${drift.length > 5 ? ', …' : ''}`);
471
510
  }
472
511
  add('git', 'info', '.',
473
512
  'uncommitted-changes check skipped — the worktree is expected to carry in-flight changes');
@@ -26,14 +26,97 @@ function readWorkspace(rootDir) {
26
26
  }
27
27
  }
28
28
 
29
- // Same remote shapes the forge adapters resolve a repo from. A URL that
30
- // does not match is not forge-hosted, and the forge path supports
31
- // forge-hosted repos only — a local/bare remote has no PR concept to aim at.
32
- const FORGE_REMOTE_RE = /github\.com[:/]([^/]+)\/([^/.]+?)(?:\.git)?$/;
29
+ // Same remote shapes the forge adapters resolve a repo from: scp-style SSH
30
+ // (`git@github.com:owner/name.git`, `git@gitlab.com:group/sub/name.git`),
31
+ // scheme URLs (`ssh://git@host/...`, `https://host/...`, optional port), with
32
+ // or without a trailing `.git`. A URL that does not match is not
33
+ // forge-hosted, and the forge path supports forge-hosted repos only — a
34
+ // local/bare remote has no PR concept to aim at.
35
+ //
36
+ // GitLab repos may sit in nested groups (`group/sub/project` at any depth),
37
+ // so the parser returns the full `slug` plus `host` and `forge` type; GitHub
38
+ // remains exactly two segments (owner/name). `hosts` names additional
39
+ // self-managed GitLab hosts (workspace.forge.host) — gitlab.com is always
40
+ // recognised, and a URL on any other host parses as nothing.
41
+ function parseForgeRemote(url, { hosts = [] } = {}) {
42
+ const parsed = parseRemoteUrl(url);
43
+ if (!parsed) return null;
44
+ const { host, path: segments } = parsed;
45
+ if (segments.length < 2) return null;
46
+ if (host === 'github.com') {
47
+ if (segments.length !== 2) return null;
48
+ return { owner: segments[0], name: segments[1], slug: segments.join('/'), host, forge: 'github' };
49
+ }
50
+ const gitlabHosts = new Set(['gitlab.com', ...hosts.map((h) => String(h).toLowerCase())]);
51
+ if (gitlabHosts.has(host)) {
52
+ return {
53
+ owner: segments.slice(0, -1).join('/'),
54
+ name: segments[segments.length - 1],
55
+ slug: segments.join('/'),
56
+ host,
57
+ forge: 'gitlab',
58
+ };
59
+ }
60
+ return null;
61
+ }
62
+
63
+ // Split any remote URL into host + path segments, or null when it is not a
64
+ // forge-shaped remote at all (local path, file:// URL, …).
65
+ const DEFAULT_PORTS = { https: 443, http: 80, ssh: 22, git: 9418 };
66
+ const SCHEME_URL_RE = /^(?<scheme>https?|ssh|git):\/\/(?:[^@/]+@)?(?<host>[^:/]+)(?::(?<port>\d+))?\/(?<path>.+)$/;
33
67
 
34
- function parseForgeRemote(url) {
35
- const m = String(url).trim().match(FORGE_REMOTE_RE);
36
- return m ? { owner: m[1], name: m[2] } : null;
68
+ function parseRemoteUrl(url) {
69
+ const s = String(url).trim().replace(/\/+$/, '');
70
+ // scp-style: user@host:path — the colon separator, no scheme.
71
+ const scp = s.match(/^[^@/]+@([^:/]+):(.+)$/);
72
+ if (scp) return { host: scp[1].toLowerCase(), path: splitRemotePath(scp[2]) };
73
+ // scheme://[user@]host[:port]/path — an explicit non-default port stays
74
+ // part of the host so a configured self-managed host can carry it
75
+ // (gitlab.example.com:8443); a default port drops, so github.com:443
76
+ // still reads as github.com.
77
+ const m = s.match(SCHEME_URL_RE);
78
+ if (m) {
79
+ const { scheme, host, port, path } = m.groups;
80
+ const hostWithPort = port && Number(port) !== DEFAULT_PORTS[scheme] ? `${host}:${port}` : host;
81
+ return { host: hostWithPort.toLowerCase(), path: splitRemotePath(path) };
82
+ }
83
+ return null;
84
+ }
85
+
86
+ function splitRemotePath(p) {
87
+ return p.replace(/\.git$/, '').split('/').filter(Boolean);
88
+ }
89
+
90
+ // Self-managed GitLab hosts configured for this workspace — the value of
91
+ // workspace.forge.host, when set, is a GitLab host however the forge `type`
92
+ // reads (a mixed workspace may leave type unset entirely).
93
+ function forgeHosts(ws) {
94
+ return [ws?.workspace?.forge?.host].filter(Boolean);
95
+ }
96
+
97
+ // The per-repo forge config: the workspace block (host, any shared
98
+ // settings) with the repo's own identity layered on. The origin's host and
99
+ // slug win over any workspace-level `type` — a workspace may mix GitHub
100
+ // and GitLab repos, and each repo's origin names where its PRs live, so
101
+ // one global type cannot speak for both. `parsed` is parseForgeRemote's
102
+ // result; a null (unparseable origin) yields the workspace block as-is.
103
+ function perRepoForge(ws, parsed) {
104
+ if (!parsed) return { ...(ws?.workspace?.forge ?? {}) };
105
+ return { ...(ws?.workspace?.forge ?? {}), type: parsed.forge, host: parsed.host, repo: parsed.slug };
106
+ }
107
+
108
+ // Everything a caller outside task-pr needs to build one repo's forge:
109
+ // read the repo's origin, parse it, and hand back a config createForge can
110
+ // take. The adapter type comes from the repo's own host, so a GitLab repo
111
+ // gets the gitlab adapter even when workspace.forge names no type (or a
112
+ // different one) — this is the helper the /release skill uses, so its
113
+ // forge always matches the repo being released.
114
+ function forgeConfigForRepo(rootDir, repo, ws, deps = {}) {
115
+ const gitFn = deps.gitFn ?? spawnSync;
116
+ const res = gitFn('git', ['-C', repoDirFor(rootDir, repo), 'remote', 'get-url', 'origin'], { encoding: 'utf8' });
117
+ const origin = res.error || res.status !== 0 ? null : String(res.stdout || '').trim();
118
+ const parsed = origin ? parseForgeRemote(origin, { hosts: forgeHosts(ws) }) : null;
119
+ return perRepoForge(ws, parsed);
37
120
  }
38
121
 
39
122
  /**
@@ -43,9 +126,10 @@ function parseForgeRemote(url) {
43
126
  * right call for a clone whose origin is a third-party upstream nobody
44
127
  * here may push to) or when the repo has no origin remote at all;
45
128
  * "forge" — pushed and PR'd — when its origin parses as a forge-hosted
46
- * owner/name. An origin that is neither (say a local bare mirror with no
47
- * override) resolves to null: the caller stops with the override spelled
48
- * out rather than pushing somewhere that cannot host a PR.
129
+ * repo (github.com, gitlab.com, or the configured self-managed host). An
130
+ * origin that is neither (say a local bare mirror with no override)
131
+ * resolves to null: the caller stops with the override spelled out rather
132
+ * than pushing somewhere that cannot host a PR.
49
133
  */
50
134
  function mergeModeFor(root, repo, deps = {}) {
51
135
  const gitFn = deps.gitFn ?? spawnSync;
@@ -55,7 +139,7 @@ function mergeModeFor(root, repo, deps = {}) {
55
139
  if (override === 'local') return 'local';
56
140
  const res = gitFn('git', ['-C', repoDirFor(rootDir, repo), 'remote', 'get-url', 'origin'], { encoding: 'utf8' });
57
141
  if (res.error || res.status !== 0) return 'local'; // no origin — nowhere to push
58
- return parseForgeRemote(String(res.stdout || '').trim()) ? 'forge' : null;
142
+ return parseForgeRemote(String(res.stdout || '').trim(), { hosts: forgeHosts(ws) }) ? 'forge' : null;
59
143
  }
60
144
 
61
- export { WORKSPACE_REPO, repoDirFor, readWorkspace, parseForgeRemote, mergeModeFor };
145
+ export { WORKSPACE_REPO, repoDirFor, readWorkspace, parseForgeRemote, forgeHosts, perRepoForge, forgeConfigForRepo, mergeModeFor };