create-pmos 0.37.0 → 0.38.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/bin/create-pmos.js +22 -3
- package/package.json +1 -1
- package/payload/AGENTS.md +4 -0
- package/payload/KIT-VERSION +2 -2
- package/payload/okf/core/concepts/frontmatter-format.md +27 -61
- package/payload/okf/core/templates/decision-record-template.md +34 -0
- package/payload/okf/core/templates/index.md +7 -2
- package/payload/okf/core/templates/pm-brief-template.md +34 -0
- package/payload/okf/core/templates/project-plan-template.md +43 -0
- package/payload/okf/core/templates/report-weekly-template.md +35 -0
- package/payload/okf/core/templates/strategy-long-range-template.md +37 -0
- package/payload/okf/core/templates/strategy-one-pager-template.md +34 -0
- package/payload/planning/evals/eval-calibration.md +8 -0
- package/payload/planning/okrs/health-budgets.md +8 -0
- package/payload/scripts/anchor-resolve.js +56 -6
- package/payload/scripts/gate-initiative.sh +32 -0
- package/payload/scripts/okf-upgrade.js +232 -0
- package/payload/scripts/pmos-memory.js +375 -0
- package/payload/scripts/pmos-model.js +239 -0
- package/payload/scripts/pmos-skills.js +274 -0
- package/payload/scripts/workflow-state.js +178 -10
- package/payload/scripts/workflow-sweep.js +2 -1
- package/payload/state/discoveries.md +8 -0
- package/payload/state/index.md +7 -0
- package/payload/state/log.md +7 -0
- package/workspace/README.md +61 -204
- package/workspace/lib/board-cache.js +18 -8
- package/workspace/lib/console-state.js +2 -0
- package/workspace/lib/engine/anchor-resolve.js +56 -6
- package/workspace/lib/engine/pmos-memory.js +375 -0
- package/workspace/lib/engine/pmos-model.js +239 -0
- package/workspace/lib/engine/pmos-skills.js +274 -0
- package/workspace/lib/engine/workflow-state.js +178 -10
- package/workspace/lib/focus.js +5 -6
- package/workspace/lib/memory.js +54 -0
- package/workspace/lib/models.js +50 -0
- package/workspace/lib/preview-kind.js +12 -1
- package/workspace/lib/schedule-logic.js +31 -2
- package/workspace/lib/skill-store.js +5 -1
- package/workspace/lib/skills-registry.js +74 -0
- package/workspace/lib/sync.js +132 -0
- package/workspace/lib/tree-share.js +2 -0
- package/workspace/package-lock.json +1 -1
- package/workspace/package.json +3 -3
- package/workspace/public/pm/app.js +32 -2
- package/workspace/public/pm/data.js +160 -10
- package/workspace/public/pm/lifecycle.js +105 -0
- package/workspace/public/pm/markdown.js +10 -1
- package/workspace/public/pm/modes-live.js +371 -0
- package/workspace/public/pm/modes.js +7 -4
- package/workspace/public/pm/panel-detail.js +22 -2
- package/workspace/public/pm/panel-initiatives.js +57 -12
- package/workspace/public/pm/panel-newsess.js +25 -0
- package/workspace/public/pm/panel-repos.js +130 -4
- package/workspace/public/pm/panel-tabs.js +49 -2
- package/workspace/public/pm/state.js +11 -1
- package/workspace/public/pm/tab-doc.js +160 -26
- package/workspace/public/pm/tab-term.js +2 -0
- package/workspace/public/pm/tab-web.js +89 -0
- package/workspace/public/pm/tokens.css +4 -0
- package/workspace/public/pm/xlsx-read.js +117 -0
- package/workspace/server.js +690 -40
- package/workspace/skills/gather-requirements.skill +53 -0
- package/workspace/skills/memory-groom.skill +46 -0
- package/workspace/skills/report-biweekly.skill +53 -0
- package/workspace/skills/report-daily.skill +53 -0
- package/workspace/skills/report-monthly.skill +53 -0
- package/workspace/skills/report-special.skill +49 -0
- package/workspace/skills/report-weekly.skill +53 -0
- package/workspace/skills/skill-discover.skill +49 -0
- package/workspace/skills/skill-generate.skill +33 -0
|
@@ -134,7 +134,20 @@ const RUNGS = {
|
|
|
134
134
|
},
|
|
135
135
|
};
|
|
136
136
|
|
|
137
|
-
|
|
137
|
+
/* D68 — the pm-artifacts profile's ladder: every PM lane walks brief → draft → review → approved
|
|
138
|
+
* (→ handed off). Keyed `pm:<lane>` because `discovery` and `chore` exist in BOTH profiles with
|
|
139
|
+
* different ladders; the profile comes from the engine's own ladder row, never guessed. */
|
|
140
|
+
const PM_LANES_CLIENT = ['strategy', 'prd', 'project-plan', 'discovery', 'decision-record', 'report', 'chore'];
|
|
141
|
+
const PM_NO_HANDOFF_CLIENT = ['decision-record', 'report', 'chore'];
|
|
142
|
+
for (const l of PM_LANES_CLIENT) {
|
|
143
|
+
const withHandoff = !PM_NO_HANDOFF_CLIENT.includes(l);
|
|
144
|
+
PHASES['pm:' + l] = ['intake', 'brief', 'draft', 'review', 'approved'].concat(withHandoff ? ['handed off'] : []);
|
|
145
|
+
RUNGS['pm:' + l] = Object.assign({ intake: ['route', 'anchor'], brief: ['brief'], draft: ['draft'], review: ['review'], approved: ['approved'] },
|
|
146
|
+
withHandoff ? { 'handed off': ['handoff'] } : {});
|
|
147
|
+
}
|
|
148
|
+
const laneOf = (lane, profile) => (profile === 'pm-artifacts'
|
|
149
|
+
? 'pm:' + (PM_LANES_CLIENT.includes(lane) ? lane : 'prd')
|
|
150
|
+
: (PHASES[lane] ? lane : 'feature'));
|
|
138
151
|
|
|
139
152
|
/**
|
|
140
153
|
* The status a phase inherits from the rungs it folds.
|
|
@@ -214,8 +227,8 @@ function pathIn(text) {
|
|
|
214
227
|
}
|
|
215
228
|
|
|
216
229
|
/** The `steps[]` panel 3 reads: one entry per lane phase, carrying the folded rungs' evidence. */
|
|
217
|
-
function phaseSteps(lane, ladderSteps) {
|
|
218
|
-
const L = laneOf(lane);
|
|
230
|
+
function phaseSteps(lane, ladderSteps, profile) {
|
|
231
|
+
const L = laneOf(lane, profile);
|
|
219
232
|
const by = {};
|
|
220
233
|
for (const s of ladderSteps || []) by[s.key] = s;
|
|
221
234
|
return PHASES[L].map((phase) => {
|
|
@@ -233,6 +246,28 @@ function phaseSteps(lane, ladderSteps) {
|
|
|
233
246
|
|
|
234
247
|
/* ── record derivation ──────────────────────────────────────────────────────────────────────── */
|
|
235
248
|
|
|
249
|
+
/**
|
|
250
|
+
* overhaul-r2 B3/B4 — where the initiative belongs: Domain › Objective › KR › Project, from the
|
|
251
|
+
* resolver's SEPARATE fields (never parsed out of `reason`). A missing level is stated, not blank:
|
|
252
|
+
* a domain anchor has no OKR and says so; a health anchor is upkeep. `line` is the KR's own
|
|
253
|
+
* baseline → target text when the OKR document writes one — the measure the KR moves.
|
|
254
|
+
*/
|
|
255
|
+
function crumbFor(anchor, fm) {
|
|
256
|
+
if (!anchor) return null;
|
|
257
|
+
const parts = [];
|
|
258
|
+
const dom = (fm && fm.domain) || (anchor.kind === 'domain' ? 'domain:' + anchor.domain : null);
|
|
259
|
+
if (dom) parts.push({ k: 'domain', v: String(dom).replace(/^domain:/, '') });
|
|
260
|
+
if (anchor.kind === 'domain') parts.push({ k: 'objective', v: '(no OKR)', gap: true });
|
|
261
|
+
else if (anchor.kind === 'health') parts.push({ k: 'budget', v: 'health:' + anchor.budget + ' — upkeep, not a bet' });
|
|
262
|
+
else if (anchor.kind === 'okr') {
|
|
263
|
+
parts.push({ k: 'objective', v: anchor.objective_name || anchor.objective || '(unresolved)', gap: !anchor.objective_name });
|
|
264
|
+
if (anchor.kr) parts.push({ k: 'KR', v: anchor.kr_name || anchor.kr, gap: !anchor.kr_name });
|
|
265
|
+
else parts.push({ k: 'KR', v: '(objective-level — no KR written)', gap: true });
|
|
266
|
+
} else parts.push({ k: 'anchor', v: '(none — rejected under D59)', gap: true });
|
|
267
|
+
if (fm && fm.project) parts.push({ k: 'project', v: String(fm.project) });
|
|
268
|
+
return { parts, goal: anchor.kind === 'domain' ? anchor.domain_goal || null : null, line: anchor.kr_line || null };
|
|
269
|
+
}
|
|
270
|
+
|
|
236
271
|
/** The `pills` row: lane, then the resolved anchor or an honest rejection, then a declared flow. */
|
|
237
272
|
function pillsFor({ lane, anchor, flow }) {
|
|
238
273
|
const out = [];
|
|
@@ -353,7 +388,7 @@ function recordFrom(payload) {
|
|
|
353
388
|
const row = (payload.ladder && payload.ladder.ok && payload.ladder.row) || null;
|
|
354
389
|
const scope = (payload.ladder && payload.ladder.scope) || null;
|
|
355
390
|
const lane = fm.lane || (row && row.lane) || null;
|
|
356
|
-
const steps = row ? phaseSteps(lane, row.steps) : [];
|
|
391
|
+
const steps = row ? phaseSteps(lane, row.steps, row.profile) : [];
|
|
357
392
|
const ctx = { record: payload.record, prd: fm.prd_path || null, rubric: fm.rubric_path || null };
|
|
358
393
|
// Authored tasks win over derivation, per phase (PRD, "Tasks"). Their absence is not a defect.
|
|
359
394
|
const authored = payload.tasks || {};
|
|
@@ -361,7 +396,7 @@ function recordFrom(payload) {
|
|
|
361
396
|
const byKey = {};
|
|
362
397
|
for (const s of (row && row.steps) || []) byKey[s.key] = s;
|
|
363
398
|
for (const st of steps) {
|
|
364
|
-
const rungs = (RUNGS[laneOf(lane)][st.k] || []).map((k) => byKey[k]).filter(Boolean);
|
|
399
|
+
const rungs = (RUNGS[laneOf(lane, row && row.profile)][st.k] || []).map((k) => byKey[k]).filter(Boolean);
|
|
365
400
|
tasks[st.k] = Array.isArray(authored[st.k]) && authored[st.k].length
|
|
366
401
|
? authored[st.k]
|
|
367
402
|
: derivedTasks(st.k, rungs, ctx);
|
|
@@ -371,6 +406,7 @@ function recordFrom(payload) {
|
|
|
371
406
|
stage: row ? row.stage : null,
|
|
372
407
|
flow: fm.flow || null,
|
|
373
408
|
pills: pillsFor({ lane, anchor: payload.anchor, flow: fm.flow || null }),
|
|
409
|
+
crumb: crumbFor(payload.anchor, fm),
|
|
374
410
|
// What the ladder was DERIVED ON, which is exactly what the footer sentence claims.
|
|
375
411
|
branch: scope ? scope.branch : null,
|
|
376
412
|
answer: answerFor(row),
|
|
@@ -457,6 +493,13 @@ function groupsFromSpine(spine) {
|
|
|
457
493
|
gct: String(b.initiatives.length), hasKr: false, slugs: b.initiatives.map((i) => i.id),
|
|
458
494
|
});
|
|
459
495
|
}
|
|
496
|
+
// D67: a domain group — work that serves the domain's standing goal with no OKR, said plainly
|
|
497
|
+
for (const d of spine.domains || []) {
|
|
498
|
+
groups.push({
|
|
499
|
+
gid: d.id, idc: C.fg, gname: 'domain — no OKR' + (d.goal ? ' · ' + d.goal : ''),
|
|
500
|
+
gct: String(d.initiatives.length), hasKr: false, slugs: d.initiatives.map((i) => i.id),
|
|
501
|
+
});
|
|
502
|
+
}
|
|
460
503
|
// D59: rejected work is FILED, never hidden. It is the last group and it says what it is.
|
|
461
504
|
groups.push({
|
|
462
505
|
gid: 'unanchored', dyn: true, idc: C.wrn,
|
|
@@ -526,7 +569,7 @@ function foldWorkspaces(rows) {
|
|
|
526
569
|
const key = w.repo_key || w.root;
|
|
527
570
|
if (!byRepo.has(key)) {
|
|
528
571
|
byRepo.set(key, {
|
|
529
|
-
name: w.repo_name || w.name, ws: w.id, root: w.root, trees: [],
|
|
572
|
+
name: w.label || w.repo_name || w.name, ws: w.id, root: w.root, trees: [],
|
|
530
573
|
// The server derived this from the tree's `origin` remote (git-info.originUrl). It is read
|
|
531
574
|
// here and never re-derived: a second opinion about what a repository IS would be a second
|
|
532
575
|
// thing to keep true, and this one is the one that did the read.
|
|
@@ -539,6 +582,8 @@ function foldWorkspaces(rows) {
|
|
|
539
582
|
// remote, since a linked worktree of the same repo answers with the same origin anyway.
|
|
540
583
|
if (w.root === key) {
|
|
541
584
|
p.ws = w.id;
|
|
585
|
+
// the PM's own name for the repository (overhaul-r1 R3.1) lives on the main tree's row
|
|
586
|
+
if (w.label) p.name = w.label;
|
|
542
587
|
p.kind = w.kind === 'github' ? 'github' : 'local';
|
|
543
588
|
p.remote = w.remote || null;
|
|
544
589
|
}
|
|
@@ -585,6 +630,7 @@ async function loadBoard(prod) {
|
|
|
585
630
|
for (const r of o.direct || []) note(r, { canonical: o.key });
|
|
586
631
|
}
|
|
587
632
|
for (const b of spine.budgets || []) for (const r of b.initiatives) note(r, { canonical: b.id });
|
|
633
|
+
for (const d of spine.domains || []) for (const r of d.initiatives) note(r, { canonical: d.id });
|
|
588
634
|
for (const r of spine.unanchored || []) note(r, null);
|
|
589
635
|
|
|
590
636
|
// The library can see initiatives the SPINE has no record for: artifacts that group into a unit
|
|
@@ -830,7 +876,7 @@ let sessionDigest = null;
|
|
|
830
876
|
function sessionProjection(rows) {
|
|
831
877
|
return (rows || []).map((s) => [
|
|
832
878
|
s.id, s.ws, s.name, s.agent, s.state, s.shown, s.note || '', s.cwd || '',
|
|
833
|
-
s.profile_id || '', s.profile_line || '',
|
|
879
|
+
s.profile_id || '', s.profile_line || '', s.model_line || '',
|
|
834
880
|
// the label's own resolution, not the millisecond behind it
|
|
835
881
|
s.shown === 'quiet' && Number.isFinite(s.quietMs) ? Math.max(1, Math.round(s.quietMs / 60000)) : '',
|
|
836
882
|
// Joined on U+0000, fields and rows both. A separator that cannot occur in any of these
|
|
@@ -939,6 +985,66 @@ export async function loadRepos() {
|
|
|
939
985
|
});
|
|
940
986
|
}
|
|
941
987
|
|
|
988
|
+
/**
|
|
989
|
+
* panel-repos.js — `productTrees(name)`, synchronous. The LINES OF WORK of one repository
|
|
990
|
+
* (overhaul-r1 R2.1): every registered tree sharing its `repo_key`, main tree first. `initiative`
|
|
991
|
+
* is read from an `initiative/<id>` branch and says so (`by: 'branch'`) — it is a reading of the
|
|
992
|
+
* branch name, not a recorded attribution, and the row must not present it as one.
|
|
993
|
+
*/
|
|
994
|
+
export function productTrees(name) {
|
|
995
|
+
const p = products.get(name);
|
|
996
|
+
if (!p || !Array.isArray(p.trees)) return [];
|
|
997
|
+
const key = p.root;
|
|
998
|
+
return p.trees.map((t) => {
|
|
999
|
+
const m = /^initiative\/(.+)$/.exec(t.branch || '');
|
|
1000
|
+
return { ws: t.id, branch: t.branch || null, main: t.root === key, sessions: t.sessions || 0,
|
|
1001
|
+
initiative: m ? m[1] : null, by: m ? 'branch' : null };
|
|
1002
|
+
}).sort((a, b) => (b.main - a.main));
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
/** The branch of a registered tree, or null — for tab labels and the status bar (R2.2/R2.3). */
|
|
1006
|
+
export function branchOfWs(ws) {
|
|
1007
|
+
return ws ? (wsBranch.get(ws) || null) : null;
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
/** True when `ws` is a linked worktree rather than its repository's main tree. */
|
|
1011
|
+
export function isLinkedTree(ws) {
|
|
1012
|
+
if (!ws) return false;
|
|
1013
|
+
for (const p of products.values()) {
|
|
1014
|
+
if (!Array.isArray(p.trees)) continue;
|
|
1015
|
+
const t = p.trees.find((x) => x.id === ws);
|
|
1016
|
+
if (t) return t.root !== p.root;
|
|
1017
|
+
}
|
|
1018
|
+
return false;
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
/**
|
|
1022
|
+
* panel-repos.js — `removeRepository(name)`. Forgets the repository AND its worktree rows; never
|
|
1023
|
+
* deletes a folder (R3.2). Resolves `{ok}` or `{ok:false, reason, error}` with the server's own
|
|
1024
|
+
* words, which the row shows as-is (R3.3).
|
|
1025
|
+
*/
|
|
1026
|
+
export async function removeRepository(name) {
|
|
1027
|
+
if (FIXTURE) return { ok: false, error: 'fixture mode — nothing is registered' };
|
|
1028
|
+
const p = products.get(name);
|
|
1029
|
+
if (!p) return { ok: false, reason: 'unknown', error: 'unknown repository' };
|
|
1030
|
+
const res = await postJson('/api/workspaces/forget', { id: p.ws, repository: true });
|
|
1031
|
+
if (res && res.ok) {
|
|
1032
|
+
products.delete(name);
|
|
1033
|
+
productOrder = productOrder.filter((n) => n !== name);
|
|
1034
|
+
publishBoards();
|
|
1035
|
+
if (state.product === name) setState({ product: productOrder[0] || null, sel: null, wsId: null });
|
|
1036
|
+
}
|
|
1037
|
+
return res || { ok: false, error: 'the workspace server did not answer' };
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
/** panel-repos.js — `renameRepository(name, label)`. A display name only (R3.1). */
|
|
1041
|
+
export async function renameRepository(name, label) {
|
|
1042
|
+
if (FIXTURE) return { ok: false, error: 'fixture mode — nothing is registered' };
|
|
1043
|
+
const p = products.get(name);
|
|
1044
|
+
if (!p) return { ok: false, reason: 'unknown', error: 'unknown repository' };
|
|
1045
|
+
return (await postJson('/api/workspaces/rename', { id: p.ws, name: label })) || { ok: false, error: 'the workspace server did not answer' };
|
|
1046
|
+
}
|
|
1047
|
+
|
|
942
1048
|
/**
|
|
943
1049
|
* panel-repos.js — `firstSlugOf(id)`, synchronous. The initiative selected when a repository is
|
|
944
1050
|
* picked. A repository whose board is loaded and genuinely empty returns null, and panel 3 says so.
|
|
@@ -1184,10 +1290,12 @@ export async function saveScratchpad(key, text) {
|
|
|
1184
1290
|
}
|
|
1185
1291
|
|
|
1186
1292
|
/** tab-doc.js — `readDoc(path)`. The raw document body, or a rejected promise carrying the reason. */
|
|
1187
|
-
export async function readDoc(path) {
|
|
1293
|
+
export async function readDoc(path, ws) {
|
|
1188
1294
|
if (FIXTURE) return '';
|
|
1189
1295
|
const p = products.get(currentProductName());
|
|
1190
|
-
|
|
1296
|
+
// `ws` is the tree a search hit came from (overhaul-r1 R1.3) — a branch-only file must be read
|
|
1297
|
+
// from its own worktree, not from the product's main tree.
|
|
1298
|
+
const url = '/api/doc?' + q({ ws: ws || state.wsId || (p ? p.ws : ''), path });
|
|
1191
1299
|
const res = await fetch(url);
|
|
1192
1300
|
if (!res.ok) throw new Error(res.status === 404 ? 'not a readable text document in this workspace' : 'HTTP ' + res.status);
|
|
1193
1301
|
return res.text();
|
|
@@ -1203,10 +1311,12 @@ export async function readDoc(path) {
|
|
|
1203
1311
|
* that a fixture render is provably not a real read wearing a costume, and `no-network.test.js`
|
|
1204
1312
|
* checks that claim at source level.
|
|
1205
1313
|
*/
|
|
1206
|
-
export async function searchFiles(query, limit) {
|
|
1314
|
+
export async function searchFiles(query, limit, all) {
|
|
1207
1315
|
const q = String(query == null ? '' : query).trim();
|
|
1208
1316
|
if (FIXTURE || !q) return { ok: true, q, hits: [], truncated: false, scanned: 0 };
|
|
1209
1317
|
const p = products.get(currentProductName());
|
|
1318
|
+
// overhaul-r4 B1 — `all`: every registered repository, each hit tagged with its repository
|
|
1319
|
+
if (all) return getJson('/api/search/files', { scope: 'all', q, limit });
|
|
1210
1320
|
return getJson('/api/search/files', { ws: state.wsId || (p ? p.ws : ''), q, limit });
|
|
1211
1321
|
}
|
|
1212
1322
|
|
|
@@ -1330,3 +1440,43 @@ if (!FIXTURE) {
|
|
|
1330
1440
|
}
|
|
1331
1441
|
});
|
|
1332
1442
|
}
|
|
1443
|
+
|
|
1444
|
+
/* ── D70 — stopped initiatives and press-only sync ───────────────────────────────────────────
|
|
1445
|
+
* Each of these is reached only by a PM press. None runs on load, on a timer or as a side effect. */
|
|
1446
|
+
export function wsOf(name) { const p = products.get(name); return p ? p.ws : null; }
|
|
1447
|
+
|
|
1448
|
+
/** The lifecycle of every initiative in a repository, from the same surface the board uses. */
|
|
1449
|
+
export async function lifecycleMap(name) {
|
|
1450
|
+
const ws = wsOf(name);
|
|
1451
|
+
if (FIXTURE || !ws) return {};
|
|
1452
|
+
const res = await getJson('/api/surface', { name: 'initiatives', ws });
|
|
1453
|
+
const out = {};
|
|
1454
|
+
const items = (res && res.surface && res.surface.initiatives) || (res && res.initiatives) || [];
|
|
1455
|
+
for (const i of items) if (i && i.lifecycle) out[i.id] = i.lifecycle;
|
|
1456
|
+
return out;
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
export async function setLifecycle(name, initiativeId, newState, reason) {
|
|
1460
|
+
if (FIXTURE) return { ok: false, error: 'fixture mode — nothing is recorded' };
|
|
1461
|
+
const ws = wsOf(name);
|
|
1462
|
+
if (!ws) return { ok: false, error: 'unknown repository' };
|
|
1463
|
+
const res = await postJson('/api/lifecycle', { ws, initiative_id: initiativeId, state: newState, reason });
|
|
1464
|
+
if (res && res.ok) { const p = products.get(name); if (p) { p.board = null; await loadBoard(p); publishBoards(); } }
|
|
1465
|
+
return res || { ok: false, error: 'the workspace server did not answer' };
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
export async function syncStatusOf(name) {
|
|
1469
|
+
const ws = wsOf(name);
|
|
1470
|
+
if (FIXTURE || !ws) return null;
|
|
1471
|
+
return getJson('/api/sync/status', { ws });
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
export async function syncRepository(name) {
|
|
1475
|
+
if (FIXTURE) return { ok: false, message: 'fixture mode — nothing to sync' };
|
|
1476
|
+
const ws = wsOf(name);
|
|
1477
|
+
if (!ws) return { ok: false, message: 'unknown repository' };
|
|
1478
|
+
const res = await postJson('/api/sync', { ws });
|
|
1479
|
+
const p = products.get(name);
|
|
1480
|
+
if (res && res.ok && p) { p.board = null; await loadBoard(p); publishBoards(); }
|
|
1481
|
+
return res || { ok: false, message: 'the workspace server did not answer' };
|
|
1482
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lifecycle.js — D70. The stopped-state control on an initiative, and the Sync control on a repository.
|
|
3
|
+
*
|
|
4
|
+
* Both are PM presses and nothing else: this module starts no timer and loads nothing on its own
|
|
5
|
+
* beyond the local read of the current state. The state is a ledger line (lifecycle.jsonl); closing,
|
|
6
|
+
* holding or cancelling an initiative takes it off the board and stops it gating, and reopening
|
|
7
|
+
* puts it back.
|
|
8
|
+
*/
|
|
9
|
+
import { el } from './dom.js';
|
|
10
|
+
import { state, setState, FIXTURE } from './state.js';
|
|
11
|
+
|
|
12
|
+
let dataMod = null;
|
|
13
|
+
const dataReady = import('./data.js').then((m) => { dataMod = m; }).catch(() => { dataMod = null; });
|
|
14
|
+
|
|
15
|
+
const known = {}; // product -> { map, at } — the lifecycle of every initiative, read once per press
|
|
16
|
+
const loading = new Set();
|
|
17
|
+
|
|
18
|
+
const BTN = 'font-family:var(--mono);font-size:10px;padding:2px 7px;background:var(--secondary);border:1px solid var(--border);color:var(--foreground);cursor:pointer';
|
|
19
|
+
const STATES = [['closed', 'close'], ['on-hold', 'hold'], ['cancelled', 'cancel']];
|
|
20
|
+
|
|
21
|
+
function refresh(product) {
|
|
22
|
+
if (FIXTURE || !product || loading.has(product)) return;
|
|
23
|
+
loading.add(product);
|
|
24
|
+
dataReady.then(() => (dataMod ? dataMod.lifecycleMap(product) : {}))
|
|
25
|
+
.then((map) => { known[product] = { map: map || {} }; })
|
|
26
|
+
.catch(() => { known[product] = { map: {} }; })
|
|
27
|
+
.finally(() => { loading.delete(product); setState((s) => ({ lcTick: (s.lcTick || 0) + 1 })); });
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function apply(product, slug, to) {
|
|
31
|
+
const word = to === 'active' ? 'Reopen' : to === 'closed' ? 'Close' : to === 'on-hold' ? 'Put on hold' : 'Cancel';
|
|
32
|
+
const reason = to === 'active' ? '' : (typeof window !== 'undefined' && window.prompt ? window.prompt(word + ' “' + slug + '” — why? (one line, kept in the record)', '') : '');
|
|
33
|
+
if (reason === null) return; // the PM backed out
|
|
34
|
+
const out = await dataMod.setLifecycle(product, slug, to, String(reason).trim());
|
|
35
|
+
if (!out || out.ok === false) { setState({ lcNotice: { slug, text: (out && out.error) || 'not recorded' } }); return; }
|
|
36
|
+
setState({ lcNotice: null });
|
|
37
|
+
refresh(product);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The control row for one initiative. Live mode only; the fixture is the prototype's. */
|
|
41
|
+
export function lifecycleRow(product, slug) {
|
|
42
|
+
if (FIXTURE || !product || !slug) return null;
|
|
43
|
+
if (!known[product]) refresh(product);
|
|
44
|
+
const cur = known[product] && known[product].map[slug];
|
|
45
|
+
const n = state.lcNotice && state.lcNotice.slug === slug ? state.lcNotice : null;
|
|
46
|
+
const kids = [];
|
|
47
|
+
if (cur) {
|
|
48
|
+
kids.push(el('span', { 'data-r6': 'lifecycle-state', style: 'font-family:var(--mono);font-size:10px;padding:2px 6px;border:1px solid var(--border);color:var(--muted-foreground)',
|
|
49
|
+
title: 'off the board and not gating' + (cur.reason ? ' — ' + cur.reason : '') }, String(cur.state) + (cur.at ? ' · ' + String(cur.at).slice(0, 10) : '')));
|
|
50
|
+
kids.push(el('button', { onClick: () => apply(product, slug, 'active'), style: BTN, 'data-r6': 'reopen', title: 'put this back on the board' }, 'reopen'));
|
|
51
|
+
} else {
|
|
52
|
+
for (const [st, label] of STATES) {
|
|
53
|
+
kids.push(el('button', { onClick: () => apply(product, slug, st), style: BTN, 'data-r6': label,
|
|
54
|
+
title: label + ' this initiative — it leaves the board and stops gating; it can be reopened' }, label));
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
if (n) kids.push(el('span', { style: 'font-size:11px;color:var(--destructive)' }, n.text));
|
|
58
|
+
return el('div', { 'data-r6': 'lifecycle', style: 'display:flex;gap:5px;flex-wrap:wrap;align-items:center;padding:6px 14px;border-bottom:1px solid var(--border)' }, kids);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/* ── Sync ─────────────────────────────────────────────────────────────────────────────────── */
|
|
62
|
+
const syncInfo = {};
|
|
63
|
+
const syncing = new Set();
|
|
64
|
+
function readSync(product) {
|
|
65
|
+
if (syncInfo[product] && syncInfo[product].loading) return;
|
|
66
|
+
syncInfo[product] = { ...(syncInfo[product] || {}), loading: true };
|
|
67
|
+
dataReady.then(() => (dataMod ? dataMod.syncStatusOf(product) : null))
|
|
68
|
+
.then((s) => { syncInfo[product] = { status: s, last: (syncInfo[product] || {}).last }; })
|
|
69
|
+
.catch(() => { syncInfo[product] = { status: null }; })
|
|
70
|
+
.finally(() => setState((s) => ({ lcTick: (s.lcTick || 0) + 1 })));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function glance(st) {
|
|
74
|
+
if (!st || st.ok === false) return '';
|
|
75
|
+
const bits = [];
|
|
76
|
+
if (st.behind) bits.push('↓' + st.behind);
|
|
77
|
+
if (st.ahead) bits.push('↑' + st.ahead);
|
|
78
|
+
if (st.dirty_records) bits.push('●' + st.dirty_records);
|
|
79
|
+
return bits.join(' ');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The Sync button for a repository. Shows behind / ahead / uncommitted from LOCAL git, no network. */
|
|
83
|
+
export function syncButton(product) {
|
|
84
|
+
if (FIXTURE || !product) return null;
|
|
85
|
+
if (!syncInfo[product]) readSync(product);
|
|
86
|
+
const info = syncInfo[product] || {};
|
|
87
|
+
const busy = syncing.has(product);
|
|
88
|
+
const g = glance(info.status);
|
|
89
|
+
const tip = info.status && info.status.ok !== false
|
|
90
|
+
? 'Sync — fetch, bring in the other machine’s work, commit your records, push. ' + (info.status.state || '') + (g ? ' (' + g + ')' : '')
|
|
91
|
+
: 'Sync — ' + ((info.status && (info.status.error || info.status.message)) || 'checking…');
|
|
92
|
+
return el('button', {
|
|
93
|
+
'data-r6': 'sync', disabled: busy ? 'disabled' : null, title: tip,
|
|
94
|
+
style: 'font-family:var(--mono);font-size:11px;background:none;border:0;color:var(--muted-foreground);cursor:pointer;padding:0 4px',
|
|
95
|
+
onClick: async () => {
|
|
96
|
+
syncing.add(product); setState((s) => ({ lcTick: (s.lcTick || 0) + 1 }));
|
|
97
|
+
const out = await dataMod.syncRepository(product);
|
|
98
|
+
syncing.delete(product);
|
|
99
|
+
const text = out.ok ? 'synced — ' + (out.steps || []).map((x) => x.step + ': ' + x.note).join(' · ')
|
|
100
|
+
: 'sync stopped at ' + (out.stopped_at || 'start') + ' — ' + (out.message || out.error || 'unknown');
|
|
101
|
+
syncInfo[product] = { status: null, last: text }; readSync(product);
|
|
102
|
+
setState({ repoNotice: { name: product, tone: out.ok ? 'ok' : 'bad', text } });
|
|
103
|
+
},
|
|
104
|
+
}, busy ? '…' : '⇅' + (g ? ' ' + g : ''));
|
|
105
|
+
}
|
|
@@ -176,9 +176,18 @@ function mdLink(txt, url, ctx) {
|
|
|
176
176
|
const u = String(url || '').trim();
|
|
177
177
|
if (!u) return null;
|
|
178
178
|
if (REMOTE.test(u) || MAILTO.test(u)) {
|
|
179
|
-
|
|
179
|
+
const a = el('a', {
|
|
180
180
|
href: u, target: '_blank', rel: 'noopener noreferrer', style: ST.a,
|
|
181
181
|
}, inline(txt, ctx));
|
|
182
|
+
// overhaul-r5 (D69): beside an http(s) link, ⧉ opens it as a web tab — only when the PM clicks it.
|
|
183
|
+
// Offered only where the caller supplies `onWeb` (the live doc tab); every other render is unchanged.
|
|
184
|
+
if (ctx && typeof ctx.onWeb === 'function' && REMOTE.test(u)) {
|
|
185
|
+
return el('span', null, a, el('button', {
|
|
186
|
+
onClick: (e) => { e.preventDefault(); ctx.onWeb(u); }, title: 'open this link as a web tab', 'data-r5': 'open-tab',
|
|
187
|
+
style: 'margin-left:3px;font-size:10px;padding:0 4px;border:1px solid var(--border);color:var(--muted-foreground);vertical-align:1px',
|
|
188
|
+
}, '⧉'));
|
|
189
|
+
}
|
|
190
|
+
return a;
|
|
182
191
|
}
|
|
183
192
|
if (u.startsWith('#')) return null; // an in-page anchor has nowhere to go here
|
|
184
193
|
if (!OPENABLE.test(u)) return null;
|