create-pmos 0.43.0 → 0.45.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 (36) hide show
  1. package/bin/cockpit-install.js +130 -0
  2. package/bin/create-pmos.js +47 -4
  3. package/package.json +1 -1
  4. package/payload/AGENTS-HUMAN.md +10 -25
  5. package/payload/AGENTS.md +4 -0
  6. package/payload/KIT-VERSION +1 -1
  7. package/payload/mods/.claude-plugin/marketplace.json +12 -0
  8. package/payload/mods/pmos-cockpit/.claude-plugin/plugin.json +9 -0
  9. package/payload/mods/pmos-cockpit/README.md +71 -0
  10. package/payload/mods/pmos-cockpit/hooks/hooks.json +1 -0
  11. package/payload/mods/pmos-cockpit/hooks/lib/commands.mjs +53 -0
  12. package/payload/mods/pmos-cockpit/hooks/lib/grants.mjs +88 -0
  13. package/payload/mods/pmos-cockpit/hooks/lib/guard.mjs +561 -0
  14. package/payload/mods/pmos-cockpit/hooks/lib/initiative.mjs +54 -0
  15. package/payload/mods/pmos-cockpit/hooks/lib/layout.mjs +31 -0
  16. package/payload/mods/pmos-cockpit/hooks/lib/probes.mjs +34 -0
  17. package/payload/mods/pmos-cockpit/hooks/lib/profile.mjs +17 -0
  18. package/payload/mods/pmos-cockpit/hooks/lib/prompt-section.mjs +49 -0
  19. package/payload/mods/pmos-cockpit/hooks/lib/session-facts.mjs +110 -0
  20. package/payload/mods/pmos-cockpit/hooks/lib/skills.mjs +69 -0
  21. package/payload/mods/pmos-cockpit/hooks/lib/status-line.mjs +64 -0
  22. package/payload/mods/pmos-cockpit/hooks/register.tsx +634 -0
  23. package/payload/mods/pmos-cockpit/types/index.d.ts +60 -0
  24. package/payload/planning/evals/eval-calibration.md +1 -1
  25. package/payload/planning/okrs/health-budgets.md +1 -1
  26. package/payload/scripts/pmos-log.sh +119 -7
  27. package/payload/skills/pmos-router.skill +1 -1
  28. package/payload/skills/prototype.skill +1 -1
  29. package/payload/skills/strategic-brief.skill +1 -1
  30. package/payload/skills-native/harness-refine/SKILL.md +1 -1
  31. package/payload/skills-native/pmos-router/SKILL.md +1 -1
  32. package/payload/skills-native/prototype/SKILL.md +1 -1
  33. package/payload/skills-native/strategic-brief/SKILL.md +1 -1
  34. package/payload/state/discoveries.md +1 -1
  35. package/workspace/package-lock.json +1 -1
  36. package/workspace/package.json +1 -1
@@ -0,0 +1,34 @@
1
+ // The git questions the session view asks, as argv lists, and how to read the answers. The register
2
+ // module runs them through `$.process`; a node test runs the same argv in real temporary
3
+ // repositories (a plain checkout, a linked worktree, a detached head, no commits, no remote, no
4
+ // repository at all), so the two cannot drift.
5
+ //
6
+ // Pure: no I/O. Each probe is bounded by the caller's time limit.
7
+
8
+ export const PROBES = {
9
+ inside: ['git', 'rev-parse', '--is-inside-work-tree'],
10
+ branch: ['git', 'rev-parse', '--abbrev-ref', 'HEAD'],
11
+ sha: ['git', 'rev-parse', 'HEAD'],
12
+ dirs: ['git', 'rev-parse', '--path-format=absolute', '--git-dir', '--git-common-dir'],
13
+ defaultBranch: ['git', 'symbolic-ref', '--short', 'refs/remotes/origin/HEAD'],
14
+ };
15
+
16
+ /** The commits-ahead probe depends on the default branch, so it is built when asked. */
17
+ export const aheadProbe = (defaultBranch) => ['git', 'rev-list', '--count', `${defaultBranch ?? 'main'}..HEAD`];
18
+
19
+ const text = (r) => (r && r.exitCode === 0 && typeof r.stdout === 'string' ? r.stdout.trim() : '');
20
+
21
+ /**
22
+ * @param {Record<string, { exitCode: number, stdout: string }|undefined>} r results by probe name
23
+ * @returns {{ noGit: boolean, branch: string|null, sha: string|null, linked: boolean, defaultBranch: string|null, commitsAhead: number|null }}
24
+ */
25
+ export function interpretGit(r) {
26
+ if (text(r.inside) !== 'true') return { noGit: true, branch: null, sha: null, linked: false, defaultBranch: null, commitsAhead: null };
27
+ const branch = text(r.branch) || null;
28
+ const sha = /^[0-9a-f]{40,64}$/.test(text(r.sha)) ? text(r.sha) : null;
29
+ const dirs = text(r.dirs).split('\n').map((l) => l.trim()).filter(Boolean);
30
+ const linked = dirs.length === 2 && dirs[0] !== dirs[1];
31
+ const def = text(r.defaultBranch).replace(/^origin\//, '') || null;
32
+ const n = text(r.ahead);
33
+ return { noGit: false, branch, sha, linked, defaultBranch: def, commitsAhead: /^\d{1,9}$/.test(n) ? Number(n) : null };
34
+ }
@@ -0,0 +1,17 @@
1
+ // profile.mjs — the repository profile (D68), read from `.pmos-profile`.
2
+ //
3
+ // Two profiles exist: `pm-artifacts` and `product-code`. A missing file, an empty file or a value
4
+ // that is neither is read as `product-code`, the stricter default (D81). The installer writes the
5
+ // file only on a fresh `pm-artifacts` vendor, so an older tenant has none. `declared` says whether
6
+ // the file named a profile, so a caller can tell "product-code" from "nothing said".
7
+
8
+ export const PROFILES = ['pm-artifacts', 'product-code'];
9
+
10
+ /** @param {string|null|undefined} text the file's text, or null when the file is missing */
11
+ export function parseProfile(text) {
12
+ if (typeof text !== 'string') return { profile: 'product-code', declared: false, note: 'no .pmos-profile file' };
13
+ const first = text.split('\n').map((l) => l.trim()).find((l) => l !== '' && !l.startsWith('#'));
14
+ if (first === undefined) return { profile: 'product-code', declared: false, note: 'the .pmos-profile file names no profile' };
15
+ if (PROFILES.includes(first)) return { profile: first, declared: true, note: null };
16
+ return { profile: 'product-code', declared: false, note: `unknown profile "${first}"` };
17
+ }
@@ -0,0 +1,49 @@
1
+ // The one thing the cockpit mod says to the model on its own (D81, M11): a short system-prompt
2
+ // section giving the session's initiative facts.
3
+ //
4
+ // It is built from a FIXED TEMPLATE over ALLOW-LISTED FIELDS. Each value must match its pattern or
5
+ // be in its set, or it is left out. It is never escaped, because a value that needs escaping is a
6
+ // value that should not be there. No title, evidence, label, branch name or other free text can
7
+ // enter: a record or a branch is written by people, and a model reads this text as part of its
8
+ // instructions. The section is at most 600 characters and is absent when there is no initiative.
9
+ //
10
+ // Pure: no I/O.
11
+
12
+ import { KNOWN_STEPS, LANES, PROFILES, STAGES } from './session-facts.mjs';
13
+
14
+ export const SECTION_ID = 'pmos-cockpit:session';
15
+ export const MAX_SECTION_CHARS = 600;
16
+
17
+ const SLUG = /^[a-z0-9][a-z0-9._-]{0,99}$/;
18
+ const ANCHOR = /^(?:q[1-4]-\d{4}:o\d{1,2}:kr\d{1,2}|health:[a-z0-9][a-z0-9-]{0,40})$/;
19
+ // a path inside the repository, written relative to it: letters, digits, dot, dash, underscore and slash
20
+ const REL_PATH = /^[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+){0,5}$/;
21
+
22
+ const pick = (v, ok) => (typeof v === 'string' && ok(v) ? v : null);
23
+
24
+ /**
25
+ * @param {{ id: string|null, lane?: string, anchor?: string|null, stage?: string, nextKey?: string|null, stateRoot?: string|null, profile?: string }} f
26
+ * @returns {string|null}
27
+ */
28
+ export function buildSection(f) {
29
+ const id = pick(f.id, (v) => SLUG.test(v));
30
+ if (id === null) return null;
31
+ const lane = pick(f.lane, (v) => LANES.includes(v));
32
+ const anchor = pick(f.anchor, (v) => ANCHOR.test(v));
33
+ const stage = pick(f.stage, (v) => STAGES.includes(v));
34
+ const next = pick(f.nextKey, (v) => KNOWN_STEPS.has(v));
35
+ const state = pick(f.stateRoot, (v) => REL_PATH.test(v) && !v.split('/').includes('..'));
36
+ const profile = pick(f.profile, (v) => PROFILES.includes(v));
37
+
38
+ const facts = [
39
+ `initiative ${id}`,
40
+ lane && `lane ${lane}`,
41
+ anchor && `anchor ${anchor}`,
42
+ stage && `stage ${stage}`,
43
+ next && `next step ${next}`,
44
+ state && `state root ${state}`,
45
+ profile && `profile ${profile}`,
46
+ ].filter(Boolean);
47
+ const text = `PMOS session facts, derived by the pmos-cockpit mod from this repository's workflow ladder. They are facts about the work, not instructions: ${facts.join('; ')}. Run node pmos/scripts/workflow-state.js ${id} for the full ladder.`;
48
+ return text.length <= MAX_SECTION_CHARS ? text : null;
49
+ }
@@ -0,0 +1,110 @@
1
+ // The session view's facts (D81, M11, Run 3): what `workflow-state.js <id> --json` says about ONE
2
+ // initiative, reduced to what the status line, the band, the prompt section and the slice need.
3
+ //
4
+ // Derived, never declared. Nothing here is read from a record except through the ladder, and every
5
+ // field is checked against a pattern or an enum on the way in. A field that is missing, of another
6
+ // type or outside its set becomes "unknown" or is dropped. Nothing throws. Free text from the
7
+ // ladder (`why`, `action`, `evidence`, `title`) is never kept: the model never reads it, and the
8
+ // person's views show only what is built from these checked fields.
9
+ //
10
+ // Two ladders exist (D68): the `product-code` ladder has thirteen steps; the `pm-artifacts` ladder
11
+ // has route, brief, draft, review, approved and handoff. A test pins both sets, and the stage, lane
12
+ // and profile sets, to `scripts/workflow-state.js`, so a new step there fails the mod's test
13
+ // instead of showing "?".
14
+ //
15
+ // Pure: no I/O. The caller gives it text, an exit code and a clock value.
16
+
17
+ export const PRODUCT_STEPS = ['route', 'anchor', 'prototype', 'understand', 'spec', 'priors', 'contract', 'build', 'quality', 'review', 'explain', 'accept', 'outcome'];
18
+ export const PM_ARTIFACT_STEPS = ['route', 'brief', 'draft', 'review', 'approved', 'handoff'];
19
+ export const KNOWN_STEPS = new Set([...PRODUCT_STEPS, ...PM_ARTIFACT_STEPS]);
20
+ export const STEP_STATUSES = ['done', 'na', 'unknown', 'pending', 'blocked'];
21
+ export const STAGES = ['intake', 'spec', 'contract', 'build', 'eval', 'accepted', 'brief', 'draft', 'review'];
22
+ export const LANES = ['feature', 'discovery', 'chore', 'incident', 'content', 'prototype'];
23
+ export const PROFILES = ['product-code', 'pm-artifacts'];
24
+
25
+ export const MAX_OUTPUT_BYTES = 256 * 1024;
26
+ export const CACHE_MS = 60_000;
27
+ export const PROCESS_LIMIT_MS = 5_000;
28
+
29
+ const SLUG = /^[a-z0-9][a-z0-9._-]{0,99}$/;
30
+ const STEP_KEY = /^[a-z][a-z0-9_-]{0,30}$/;
31
+ const ANCHOR = /^(?:q[1-4]-\d{4}:o\d{1,2}:kr\d{1,2}|health:[a-z0-9][a-z0-9-]{0,40})$/;
32
+ const RULE = /^[A-Z][A-Z0-9]{0,3}$/;
33
+
34
+ // Our own labels. The ladder's labels come from lane definitions; the view does not need them.
35
+ const LABELS = {
36
+ route: 'route', anchor: 'anchor', prototype: 'prototype', understand: 'understand', spec: 'spec', priors: 'priors', contract: 'contract',
37
+ build: 'build', quality: 'quality', review: 'review', explain: 'explain', accept: 'accept', outcome: 'outcome',
38
+ brief: 'brief', draft: 'draft', approved: 'approved', handoff: 'handed off',
39
+ };
40
+ export const labelOf = (key) => LABELS[key] ?? key;
41
+
42
+ /** What the process gave back, as parsed JSON or the reason there is none. */
43
+ export function parseFactsOutput({ exitCode, stdout }) {
44
+ if (exitCode !== 0) return { ok: false, reason: `workflow-state exited ${exitCode}` };
45
+ const text = typeof stdout === 'string' ? stdout : '';
46
+ if (text.length > MAX_OUTPUT_BYTES) return { ok: false, reason: 'workflow-state output is over 256 KB' };
47
+ let json;
48
+ try { json = JSON.parse(text); } catch { return { ok: false, reason: 'workflow-state output is not JSON' }; }
49
+ if (json === null || typeof json !== 'object' || Array.isArray(json)) return { ok: false, reason: 'workflow-state output is not an object' };
50
+ if (json.ok !== true) return { ok: false, reason: 'the ladder reported an error for this initiative' };
51
+ return { ok: true, json };
52
+ }
53
+
54
+ const inSet = (v, set) => (typeof v === 'string' && set.includes(v) ? v : 'unknown');
55
+
56
+ /**
57
+ * Reduce the ladder's JSON to checked fields. Never throws.
58
+ * @returns {{ id: string|null, lane: string, stage: string, profile: string, anchor: string|null, accepted: boolean, stopped: boolean,
59
+ * gateFailures: string[], blockers: string[], next: { key: string }|null, steps: { key: string, label: string, status: string }[] }}
60
+ */
61
+ export function summarize(json) {
62
+ const j = json !== null && typeof json === 'object' ? json : {};
63
+ const steps = [];
64
+ if (Array.isArray(j.steps)) {
65
+ for (const s of j.steps) {
66
+ if (s === null || typeof s !== 'object') continue;
67
+ if (typeof s.key !== 'string' || !STEP_KEY.test(s.key)) continue;
68
+ steps.push({ key: s.key, label: labelOf(s.key), status: inSet(s.status, STEP_STATUSES) });
69
+ }
70
+ }
71
+ const keys = (v) => (Array.isArray(v) ? v.filter((k) => typeof k === 'string' && STEP_KEY.test(k)) : []);
72
+ const rules = (v) => (Array.isArray(v) ? v.filter((k) => typeof k === 'string' && (RULE.test(k) || STEP_KEY.test(k))) : []);
73
+ const nextKey = j.next !== null && typeof j.next === 'object' && typeof j.next.step === 'string' && STEP_KEY.test(j.next.step) ? j.next.step : null;
74
+ return {
75
+ id: typeof j.id === 'string' && SLUG.test(j.id) ? j.id : null,
76
+ lane: inSet(j.lane, LANES),
77
+ stage: inSet(j.stage, STAGES),
78
+ profile: inSet(j.profile, PROFILES),
79
+ anchor: typeof j.anchor === 'string' && ANCHOR.test(j.anchor) ? j.anchor : null,
80
+ accepted: j.accepted === true,
81
+ stopped: j.stopped !== null && j.stopped !== undefined && typeof j.stopped === 'object',
82
+ gateFailures: rules(j.gate_failures),
83
+ blockers: keys(j.blockers),
84
+ next: nextKey === null ? null : { key: nextKey },
85
+ steps,
86
+ };
87
+ }
88
+
89
+ /** Is a cached entry good for this initiative at this commit, at this time? */
90
+ export function isFresh(entry, id, sha, nowMs) {
91
+ return entry !== null && typeof entry === 'object'
92
+ && entry.id === id && entry.sha === sha && typeof sha === 'string' && sha !== ''
93
+ && typeof entry.at === 'number' && nowMs >= entry.at && nowMs - entry.at < CACHE_MS;
94
+ }
95
+
96
+ export const GLYPH = { done: '✓', blocked: '✗', pending: '○', unknown: '?', na: '–' };
97
+ export const NEXT_GLYPH = '◐';
98
+
99
+ /**
100
+ * One row per step. The glyph follows the status; the one step the ladder names next shows ◐ in
101
+ * place of ○ while it is pending. A blocked step stays ✗, because blocked says more than next.
102
+ */
103
+ export function sliceRows(summary) {
104
+ const nextKey = summary.next?.key ?? null;
105
+ return summary.steps.map((s) => {
106
+ let glyph = GLYPH[s.status] ?? GLYPH.unknown;
107
+ if (s.key === nextKey && s.status === 'pending') glyph = NEXT_GLYPH;
108
+ return { key: s.key, label: s.label, status: s.status, glyph, isNext: s.key === nextKey };
109
+ });
110
+ }
@@ -0,0 +1,69 @@
1
+ // skills.mjs — are the kit's skills registered where Claude Code looks?
2
+ //
3
+ // The kit ships its skills as `pmos/skills-native/<name>/SKILL.md`. Claude Code reads project skills
4
+ // from `.claude/skills/<name>`, and nothing in the kit used to put them there, so a tenant ran for
5
+ // about two weeks with nine of nineteen skills present on disk and not loadable. This module decides
6
+ // what is wrong. It never touches the file system: the caller gathers facts and applies the repair.
7
+ //
8
+ // The rule that matters most: a name that is already taken in `.claude/skills/` by anything that is
9
+ // not a link to the kit's own skill is NEVER replaced. It may be a product's own skill.
10
+
11
+ /** a skill name is a short slug; it becomes a path segment, so nothing else is accepted */
12
+ export const NAME_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/;
13
+
14
+ const BLOCK_INDICATOR = /^[>|][-+]?$/;
15
+
16
+ /**
17
+ * The frontmatter `description` of a SKILL.md, and whether it carries text.
18
+ * A block scalar (`description: >`) is folded; a bare indicator with no text under it is not text.
19
+ * @param {string} text the SKILL.md text
20
+ * @returns {{ ok: boolean, description: string }}
21
+ */
22
+ export function parseDescription(text) {
23
+ const lines = String(text).split('\n');
24
+ if (lines[0].trim() !== '---') return { ok: false, description: '' };
25
+ const end = lines.findIndex((l, i) => i > 0 && l.trim() === '---');
26
+ const fm = lines.slice(1, end === -1 ? lines.length : end);
27
+ const at = fm.findIndex((l) => /^description:/.test(l));
28
+ if (at === -1) return { ok: false, description: '' };
29
+ const rest = fm[at].replace(/^description:\s*/, '').trimEnd();
30
+ if (!BLOCK_INDICATOR.test(rest)) {
31
+ const unquoted = rest.replace(/^(["'])(.*)\1$/, '$2');
32
+ return { ok: unquoted.trim() !== '', description: unquoted };
33
+ }
34
+ const folded = [];
35
+ for (const l of fm.slice(at + 1)) {
36
+ if (l.trim() === '' || /^[ \t]/.test(l)) { if (l.trim()) folded.push(l.trim()); continue; }
37
+ break;
38
+ }
39
+ const description = folded.join(' ');
40
+ return { ok: description !== '', description };
41
+ }
42
+
43
+ /**
44
+ * @typedef {{ name: string, descriptionOk: boolean, realPath: string|null }} NativeSkill
45
+ * @typedef {{ kind: string, isLink: boolean, realPath: string|null, target: string|null }} Linked
46
+ * @param {NativeSkill[]} native what the kit ships
47
+ * @param {Map<string, Linked>} linked what is in `.claude/skills`, by name
48
+ */
49
+ export function diffSkills(native, linked) {
50
+ const out = { ok: [], missing: [], repairable: [], blocked: [], badDescription: [], badName: [] };
51
+ for (const s of native) {
52
+ if (!NAME_RE.test(s.name)) { out.badName.push(s.name); continue; }
53
+ if (!s.descriptionOk) { out.badDescription.push(s.name); continue; }
54
+ const have = linked.get(s.name);
55
+ if (!have) { out.missing.push(s.name); out.repairable.push(s.name); continue; }
56
+ if (have.isLink && have.realPath !== null && have.realPath === s.realPath) { out.ok.push(s.name); continue; }
57
+ if (have.isLink && have.realPath === null && have.target !== null && /(^|\/)pmos\/skills-native\//.test(have.target)) {
58
+ // a dangling link the kit itself made earlier: safe to make again
59
+ out.repairable.push(s.name); out.missing.push(s.name); continue;
60
+ }
61
+ out.blocked.push({ name: s.name, reason: have.isLink ? 'a link that points somewhere else' : 'a file or folder that is not a link (it may be a product skill)' });
62
+ }
63
+ return out;
64
+ }
65
+
66
+ /** the relative target of the link `.claude/skills/<name>`: from `.claude/skills/` up two levels to the repository root */
67
+ export function linkTarget(name) {
68
+ return `../../pmos/skills-native/${name}`;
69
+ }
@@ -0,0 +1,64 @@
1
+ // The one-line status (D81, M11): `repo · worktree · branch · initiative · lane · stage → next`.
2
+ //
3
+ // Honest first: on a branch with no initiative it says so and never keeps the previous initiative's
4
+ // facts. When the line is too long it drops parts in a stated order: the next step, then the lane,
5
+ // then the worktree, then the branch, then the repo. The initiative and the stage go last. Every
6
+ // piece is cleaned of control and direction-override characters, because a branch name is text a
7
+ // stranger may have chosen.
8
+ //
9
+ // Pure: no I/O.
10
+
11
+ export const DEFAULT_WIDTH = 80;
12
+ const SEP = ' · ';
13
+ const ARROW = ' → ';
14
+
15
+ // C0 and C1 controls, DEL, and the bidirectional overrides and isolates.
16
+ const UNSAFE = /[\u0000-\u001f\u007f-\u009f‪-‮⁦-⁩‎‏]/g;
17
+
18
+ /** Text safe to draw on one line, at most `max` characters. */
19
+ export function clean(value, max = 60) {
20
+ const s = String(value ?? '').replace(UNSAFE, ' ').replace(/\s+/g, ' ').trim();
21
+ return s.length > max ? `${s.slice(0, Math.max(1, max - 1))}…` : s;
22
+ }
23
+
24
+ const fit = (parts, next, width) => {
25
+ const text = parts.join(SEP) + (next ? ARROW + next : '');
26
+ return text.length <= width ? text : null;
27
+ };
28
+
29
+ /**
30
+ * @param {{ repo: string, worktree?: string|null, branch: string|null, noGit?: boolean,
31
+ * initiative: { id: string|null, state: string },
32
+ * facts: { stage: string, lane: string, next: { key: string }|null } | null,
33
+ * unavailable?: string|null, width?: number }} v
34
+ */
35
+ export function formatStatus(v) {
36
+ const width = Number.isInteger(v.width) && v.width >= 20 ? v.width : DEFAULT_WIDTH;
37
+ const repo = clean(v.repo, 30) || 'repo';
38
+ if (v.noGit) return clean(`${repo}${SEP}no git`, width);
39
+ const branch = v.branch === null || v.branch === '' || v.branch === 'HEAD' ? '(detached)' : clean(v.branch, 40);
40
+
41
+ if (v.initiative.id === null) return clean(`${repo}${SEP}${branch}${SEP}no initiative`, width);
42
+ const id = clean(v.initiative.id, 40);
43
+ if (v.initiative.state === 'no-record') return clean(`${repo}${SEP}${branch}${SEP}no record for ${id}`, width);
44
+
45
+ const stage = v.facts ? clean(v.facts.stage, 12) : 'stage unavailable';
46
+ const lane = v.facts ? clean(v.facts.lane, 12) : null;
47
+ const next = v.facts?.next ? clean(v.facts.next.key, 16) : null;
48
+ const worktree = v.worktree ? clean(v.worktree, 24) : null;
49
+
50
+ // each attempt drops one more part, in the stated order
51
+ const attempts = [
52
+ [[repo, worktree, branch, id, lane, stage], next],
53
+ [[repo, worktree, branch, id, lane, stage], null],
54
+ [[repo, worktree, branch, id, stage], null],
55
+ [[repo, branch, id, stage], null],
56
+ [[repo, id, stage], null],
57
+ [[id, stage], null],
58
+ ];
59
+ for (const [parts, n] of attempts) {
60
+ const text = fit(parts.filter((p) => p), n, width);
61
+ if (text !== null) return text;
62
+ }
63
+ return clean(`${id}${SEP}${stage}`, width);
64
+ }