@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.
- package/lib/payload.mjs +170 -2
- package/lib/payload.test.mjs +158 -3
- package/lib/upgrade.mjs +148 -6
- package/lib/upgrade.test.mjs +319 -15
- package/package.json +1 -1
- package/template/_claude/rules/forge-operations.md +27 -6
- package/template/_claude/scripts/chat-record.mjs +51 -4
- package/template/_claude/scripts/classify-update.mjs +356 -28
- package/template/_claude/scripts/cleanup-work-session.mjs +64 -3
- package/template/_claude/scripts/forges/gitlab.mjs +450 -18
- package/template/_claude/scripts/forges/interface.mjs +39 -6
- package/template/_claude/scripts/maintenance-audit.mjs +47 -8
- package/template/_claude/scripts/merge-mode.mjs +96 -12
- package/template/_claude/scripts/migrate-sessions.mjs +322 -32
- package/template/_claude/scripts/task-pr.mjs +52 -13
- package/template/_claude/scripts/task-worktree.mjs +79 -10
- package/template/_claude/scripts/template-baseline.mjs +33 -9
- package/template/_claude/scripts/trackers/gitlab-issues.mjs +276 -0
- package/template/_claude/scripts/trackers/interface.mjs +3 -0
- package/template/_claude/skills/complete-work/SKILL.md +7 -4
- package/template/_claude/skills/migrate-sessions/SKILL.md +28 -8
- package/template/_claude/skills/release/SKILL.md +24 -8
- package/template/_claude/skills/setup-tracker/SKILL.md +46 -12
- package/template/_claude/skills/start-work/SKILL.md +15 -2
- package/template/_claude/skills/workspace-update/SKILL.md +51 -26
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
468
|
-
const
|
|
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 ${
|
|
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
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
|
|
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
|
|
35
|
-
const
|
|
36
|
-
|
|
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
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
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 };
|