create-pmos 0.496.0 → 0.498.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/package.json +51 -33
- package/payload/AGENTS.md +1 -0
- package/payload/KIT-VERSION +1 -1
- package/payload/planning/evals/eval-calibration.md +1 -1
- package/payload/planning/okrs/health-budgets.md +1 -1
- package/payload/scripts/workflow-state.js +35 -19
- package/payload/state/discoveries.md +1 -1
- package/workspace/briefing-premium-test.js +2 -2
- package/workspace/changelog.json +50 -0
- package/workspace/guide/briefing-premium.md +4 -2
- package/workspace/guide/data-freshness.md +5 -1
- package/workspace/guide/home.md +3 -3
- package/workspace/guide/launcher.md +7 -1
- package/workspace/lib/briefing-premium.js +38 -12
- package/workspace/lib/engine/workflow-state.js +35 -19
- package/workspace/lib/git-identity-cli.js +67 -0
- package/workspace/lib/git-identity.js +93 -0
- package/workspace/lib/pmos-verbs.js +3 -0
- package/workspace/lib/routes-boards.js +8 -3
- package/workspace/lib/vendor/scripts/workflow-state.js +35 -19
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/public/pm/bd-ini-panel.js +1 -1
- package/workspace/public/pm/chat-turn-end.js +19 -0
- package/workspace/public/pm/home-app.js +13 -3
- package/workspace/public/pm/home-chat.js +7 -2
- package/workspace/public/pm/home-ini.js +8 -1
- package/workspace/public/pm/home-need.js +1 -1
- package/workspace/public/pm/ini-edit.js +82 -19
- package/workspace/public/pm/ini-tasks.js +6 -1
- package/workspace/public/pm/ini-view-model.js +3 -1
- package/workspace/public/pm/ini-view.css +31 -7
- package/workspace/public/pm/premium-board.js +2 -2
- package/workspace/stage-card-tasks-test.js +4 -3
- package/workspace/test-lists/chat-turn-end.json +9 -0
- package/workspace/test-lists/git-identity.json +5 -0
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* git-identity-cli.js — `pmos git-identity [set …]` (feedback batch 25 item 1, lib/git-identity.js).
|
|
3
|
+
*
|
|
4
|
+
* pmos git-identity this repository's identity and push login, or what is missing and
|
|
5
|
+
* the ones the PM uses elsewhere
|
|
6
|
+
* pmos git-identity set --name <n> --email <e> [--login <gh>] save the PM's choice in this repository's own git config (never --global)
|
|
7
|
+
* pmos git-identity set --login <gh> save only the GitHub login that pushes this repository
|
|
8
|
+
*
|
|
9
|
+
* An agent runs the first when a commit fails with "Author identity unknown" or a push fails with "Repository not found", asks the
|
|
10
|
+
* PM once which identity and GitHub login this product uses, and runs `set` with the answer. Nothing here picks one by itself. The
|
|
11
|
+
* other repositories are the ones the workspace hosts (workspaces.json in the state folder). Exit codes: 0 done (or all is set),
|
|
12
|
+
* 1 something is missing or refused, 2 usage. */
|
|
13
|
+
const fs = require('fs');
|
|
14
|
+
const path = require('path');
|
|
15
|
+
const G = require('./git-identity.js');
|
|
16
|
+
const stateRoot = require('./state-root');
|
|
17
|
+
|
|
18
|
+
const COMMANDS = { 'git-identity': ['set'] };
|
|
19
|
+
const USAGE = [
|
|
20
|
+
'usage: pmos git-identity [set [--name <name> --email <email>] [--login <github-login>]]',
|
|
21
|
+
' pmos git-identity the identity and GitHub login this repository uses, or the ones you use elsewhere',
|
|
22
|
+
' pmos git-identity set --name <n> --email <e> --login <gh> save your choice for this repository only',
|
|
23
|
+
' pmos git-identity set --login <gh> save only the GitHub login that pushes this repository',
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
function hostedRoots(env) {
|
|
27
|
+
let reg = [];
|
|
28
|
+
try { reg = JSON.parse(fs.readFileSync(path.join(stateRoot.defaultRoot({ env }), 'workspaces.json'), 'utf8')); } catch { reg = []; }
|
|
29
|
+
return Array.isArray(reg) ? reg.filter((x) => x && typeof x.root === 'string').map((x) => x.root) : [];
|
|
30
|
+
}
|
|
31
|
+
function flag(argv, name) { const i = argv.indexOf(name); return i >= 0 && i + 1 < argv.length ? argv[i + 1] : null; }
|
|
32
|
+
|
|
33
|
+
const ghLine = (login) => `GH_TOKEN="$(gh auth token --user ${login})" gh …`;
|
|
34
|
+
|
|
35
|
+
function run(argv, { say = console.log, cwd = process.cwd(), env = process.env, git = 'git' } = {}) {
|
|
36
|
+
const sub = argv[1];
|
|
37
|
+
if (sub === 'help' || sub === '--help') { USAGE.forEach((l) => say(l)); return 0; }
|
|
38
|
+
if (!G.isRepo(cwd, git)) { say('This folder is not a git repository.'); return 1; }
|
|
39
|
+
if (sub === 'set') {
|
|
40
|
+
const name = flag(argv, '--name'); const email = flag(argv, '--email'); const login = flag(argv, '--login');
|
|
41
|
+
if ((name == null) !== (email == null) || (name == null && login == null)) { USAGE.forEach((l) => say(l)); return 2; }
|
|
42
|
+
const r = G.set(cwd, { name: name == null ? undefined : name, email: email == null ? undefined : email, login: login == null ? undefined : login }, { git });
|
|
43
|
+
if (!r.ok) { say(`Not saved: ${r.error}.`); return 1; }
|
|
44
|
+
if (name != null) say(`This repository now commits as ${r.name} <${r.email}>.`);
|
|
45
|
+
if (login != null) { say(`git push here now uses the GitHub login ${r.login}, whichever gh login is active.`); say(`Run gh here as: ${ghLine(r.login)}`); }
|
|
46
|
+
return 0;
|
|
47
|
+
}
|
|
48
|
+
if (sub !== undefined) { USAGE.forEach((l) => say(l)); return 2; }
|
|
49
|
+
const have = G.identity(cwd, { git });
|
|
50
|
+
const login = G.pushLogin(cwd, { git });
|
|
51
|
+
const okId = !!(have.name && have.email);
|
|
52
|
+
const needLogin = !login && G.githubHttps(cwd, { git }); // an ssh or non-GitHub origin does not use the login
|
|
53
|
+
if (okId) say(`This repository commits as ${have.name} <${have.email}>.`);
|
|
54
|
+
if (login) { say(`It pushes with the GitHub login ${login}. Run gh here as: ${ghLine(login)}`); }
|
|
55
|
+
if (okId && !needLogin) return 0;
|
|
56
|
+
say(okId ? 'It has no saved GitHub login, so git push uses whichever gh login is active and can fail with "Repository not found".'
|
|
57
|
+
: 'This repository has no git identity, so commits fail.');
|
|
58
|
+
say('Ask the PM once which ' + (okId ? 'GitHub login' : needLogin ? 'identity and GitHub login' : 'identity') + ' this product uses, then run:');
|
|
59
|
+
say(' pmos git-identity set' + (okId ? '' : ' --name "<name>" --email "<email>"') + (needLogin ? ' --login <github-login>' : ''));
|
|
60
|
+
const list = G.choices(cwd, hostedRoots(env), { git });
|
|
61
|
+
if (!list.length) { say('No other repository here has one to offer.'); return 1; }
|
|
62
|
+
say('Used in the other repositories:');
|
|
63
|
+
list.forEach((c, i) => say(` ${i + 1}. ${c.name} <${c.email}>${c.login ? ', login ' + c.login : ''} — ${c.repos.map((r) => path.basename(r)).join(', ')}`));
|
|
64
|
+
return 1;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
module.exports = { run, COMMANDS, USAGE };
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* git-identity.js — which git author identity a product repository commits as (feedback batch 25 item 1).
|
|
3
|
+
*
|
|
4
|
+
* identity(root, { git }) → { name, email } as git resolves it in `root` (local, then global), either may be null
|
|
5
|
+
* localIdentity(root, { git }) → { name, email } set in `root`'s own .git/config only, either may be null
|
|
6
|
+
* pushLogin(root, { git }) → the GitHub login this repository pushes with (`pmos.pushLogin` in its own config), or null
|
|
7
|
+
* choices(root, roots, { git }) → [{ name, email, login, repos: [root…] }] the distinct identities (and push logins) of the other repositories
|
|
8
|
+
* set(root, { name, email, login }, { git }) → { ok, name, email, login } | { ok: false, error } writes `root`'s own config, never --global
|
|
9
|
+
*
|
|
10
|
+
* Why: a Mac with no global identity keeps one per repository, and the PM uses different ones for different products (a personal
|
|
11
|
+
* one, a work one). A product repository seeded from another had none, so the agent could not commit and left the PM's files staged.
|
|
12
|
+
* PMOS never inherits or guesses an identity: committing as the wrong person cannot be taken back once pushed. The agent asks the PM
|
|
13
|
+
* once which identity this product uses, offering choices() (`pmos git-identity`), and saves the answer with set()
|
|
14
|
+
* (`pmos git-identity set`). The kit's AGENTS.md says so.
|
|
15
|
+
*
|
|
16
|
+
* The push login (batch 25, after #1176): the PM's Mac has more than one gh login (wawancsui, wsetiawan-pm), and a private repository
|
|
17
|
+
* of one account is "not found" to the other. set() with a login saves it as `pmos.pushLogin` and gives the repository its own
|
|
18
|
+
* credential helper for https://github.com: the inherited helpers are reset, and the one left asks gh for that login's token
|
|
19
|
+
* (`gh auth token --user <login>`), so `git push` there uses that login whichever gh login is active. `gh` commands run in the
|
|
20
|
+
* repository take GH_TOKEN from the same call (lib/git-identity-cli.js prints the line). The login is asked once, with the identity. */
|
|
21
|
+
const cp = require('child_process');
|
|
22
|
+
const path = require('path');
|
|
23
|
+
|
|
24
|
+
function cfg(git, root, args) {
|
|
25
|
+
try {
|
|
26
|
+
const out = cp.execFileSync(git, ['config', ...args], { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 5000, windowsHide: true,
|
|
27
|
+
env: { ...process.env, GIT_OPTIONAL_LOCKS: '0' } });
|
|
28
|
+
return String(out).trim() || null;
|
|
29
|
+
} catch { return null; }
|
|
30
|
+
}
|
|
31
|
+
function isRepo(root, git = 'git') {
|
|
32
|
+
try { cp.execFileSync(git, ['rev-parse', '--git-dir'], { cwd: root, stdio: 'ignore', timeout: 5000, windowsHide: true }); return true; } catch { return false; }
|
|
33
|
+
}
|
|
34
|
+
function identity(root, { git = 'git' } = {}) { return { name: cfg(git, root, ['user.name']), email: cfg(git, root, ['user.email']) }; }
|
|
35
|
+
function localIdentity(root, { git = 'git' } = {}) { return { name: cfg(git, root, ['--local', 'user.name']), email: cfg(git, root, ['--local', 'user.email']) }; }
|
|
36
|
+
|
|
37
|
+
/** The identities the PM set in the other repositories, each once, with the repositories that use it, in the order first seen. */
|
|
38
|
+
function choices(root, roots, { git = 'git' } = {}) {
|
|
39
|
+
const self = root ? path.resolve(root) : null;
|
|
40
|
+
const seen = new Map();
|
|
41
|
+
for (const r of roots || []) {
|
|
42
|
+
if (!r || path.resolve(r) === self) continue;
|
|
43
|
+
const id = localIdentity(r, { git });
|
|
44
|
+
if (!id.name || !id.email) continue;
|
|
45
|
+
const login = pushLogin(r, { git });
|
|
46
|
+
const k = id.name + '\n' + id.email + '\n' + (login || '');
|
|
47
|
+
if (!seen.has(k)) seen.set(k, { name: id.name, email: id.email, login, repos: [] });
|
|
48
|
+
const e = seen.get(k); if (!e.repos.includes(path.resolve(r))) e.repos.push(path.resolve(r));
|
|
49
|
+
}
|
|
50
|
+
return [...seen.values()];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** True when origin is an https GitHub URL: the only case the saved login's credential helper serves (an ssh origin uses ssh keys). */
|
|
54
|
+
function githubHttps(root, { git = 'git' } = {}) { return /^https:\/\/([^@/]+@)?github\.com\//i.test(cfg(git, root, ['--get', 'remote.origin.url']) || ''); }
|
|
55
|
+
function pushLogin(root, { git = 'git' } = {}) { return cfg(git, root, ['--local', 'pmos.pushLogin']); }
|
|
56
|
+
|
|
57
|
+
const LOGIN = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/; // a GitHub login: letters, digits and hyphens, so it is safe inside the helper's shell line
|
|
58
|
+
const HELPER_KEY = 'credential.https://github.com.helper';
|
|
59
|
+
const helperFor = (login) => `!f() { test "$1" = get || exit 0; echo username=${login}; echo "password=$(gh auth token --user ${login} --hostname github.com)"; }; f`;
|
|
60
|
+
|
|
61
|
+
const ONE_LINE = (v, max) => typeof v === 'string' && v.trim().length >= 1 && v.trim().length <= max && !/[\r\n\x00-\x1f\x7f]/.test(v);
|
|
62
|
+
const EMAIL = /^[^\s@<>]+@[^\s@<>]+$/;
|
|
63
|
+
|
|
64
|
+
/** Save the PM's choice in this repository only: the identity (name and email together), the push login, or both. Refuses a folder
|
|
65
|
+
* that is not a repository, a malformed name, email or login, and a call with nothing to save. */
|
|
66
|
+
function set(root, { name, email, login } = {}, { git = 'git' } = {}) {
|
|
67
|
+
if (!root || !isRepo(root, git)) return { ok: false, error: 'this folder is not a git repository' };
|
|
68
|
+
const ident = name != null || email != null;
|
|
69
|
+
if (!ident && login == null) return { ok: false, error: 'give --name and --email, --login, or all three' };
|
|
70
|
+
if (ident) {
|
|
71
|
+
if (!ONE_LINE(name, 120) || /[<>]/.test(name)) return { ok: false, error: 'give a name of 1 to 120 characters on one line' };
|
|
72
|
+
if (!ONE_LINE(email, 254) || !EMAIL.test(email.trim())) return { ok: false, error: 'give an email address, for example you@example.com' };
|
|
73
|
+
}
|
|
74
|
+
if (login != null && !LOGIN.test(String(login).trim())) return { ok: false, error: 'give a GitHub login: letters, digits and hyphens, at most 39' };
|
|
75
|
+
if (ident) {
|
|
76
|
+
cfg(git, root, ['--local', 'user.name', name.trim()]);
|
|
77
|
+
cfg(git, root, ['--local', 'user.email', email.trim()]);
|
|
78
|
+
}
|
|
79
|
+
if (login != null) {
|
|
80
|
+
const l = String(login).trim();
|
|
81
|
+
cfg(git, root, ['--local', 'pmos.pushLogin', l]);
|
|
82
|
+
cfg(git, root, ['--local', '--unset-all', HELPER_KEY]);
|
|
83
|
+
cfg(git, root, ['--local', '--add', HELPER_KEY, '']); // an empty value drops the helpers inherited from the global config
|
|
84
|
+
cfg(git, root, ['--local', '--add', HELPER_KEY, helperFor(l)]);
|
|
85
|
+
}
|
|
86
|
+
const now = localIdentity(root, { git });
|
|
87
|
+
if (ident && (now.name !== name.trim() || now.email !== email.trim())) return { ok: false, error: 'git did not save the identity' };
|
|
88
|
+
const l = pushLogin(root, { git });
|
|
89
|
+
if (login != null && l !== String(login).trim()) return { ok: false, error: 'git did not save the push login' };
|
|
90
|
+
return { ok: true, name: now.name, email: now.email, login: l };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
module.exports = { identity, localIdentity, pushLogin, githubHttps, choices, set, isRepo, helperFor };
|
|
@@ -98,6 +98,9 @@ const VERBS = Object.freeze({
|
|
|
98
98
|
// Not a pm-os-absorb verb (run: null): the Grill and Forecast build (F-A) added it through the same table.
|
|
99
99
|
forecast: Object.freeze({ module: 'forecast-cli.js', run: null, guide: 'forecast.md',
|
|
100
100
|
help: 'pmos forecast baseline|baseline-off|turned … — a goal forecast\'s kept baseline, and the forecasts that turned (pmos forecast help)' }),
|
|
101
|
+
// Not a pm-os-absorb verb (run: null): feedback batch 25 item 1 added it through the same table.
|
|
102
|
+
'git-identity': Object.freeze({ module: 'git-identity-cli.js', run: null, guide: 'launcher.md',
|
|
103
|
+
help: 'pmos git-identity [set --name <n> --email <e> --login <gh>] — which git identity and GitHub login this repository uses; save your choice for it' }),
|
|
101
104
|
launcher: Object.freeze({ module: 'launcher-cli.js', run: null, guide: 'launcher.md',
|
|
102
105
|
help: 'pmos launcher on|off|status — a PMOS app and a login item, so the workspace starts without a terminal' }),
|
|
103
106
|
});
|
|
@@ -26,7 +26,8 @@
|
|
|
26
26
|
* POST /api/boards/settings { ws, board, buildDeliver?, jira?, offer?, defTpl? } → { ok, board } jira: one key or a list
|
|
27
27
|
* POST /api/boards/ini-workflow { ws, board, id, template?, stages, moveTo? } → { ok, row, moved }
|
|
28
28
|
* POST /api/boards/ini-epics { ws, board, id, epics: [Jira key] } → { ok, row } the Jira epics of an initiative ([] unlinks)
|
|
29
|
-
* POST /api/boards/tasks/add { ws, board, initiative, title, type?, epic?, start?, due?, owner?, notes?, section? } → { ok, item } also `pmos task add`
|
|
29
|
+
* POST /api/boards/tasks/add { ws, board, initiative, title, type?, epic?, start?, due?, owner?, notes?, section?, milestone? } → { ok, item } also `pmos task add`
|
|
30
|
+
* (milestone: true adds it as a milestone, ◇ with one date: the due day, no start; batch 25 item 5)
|
|
30
31
|
* (section: the stage it goes under, as tasks/edit's section: the + on a stage of the Tasks list, batch 18 item 16)
|
|
31
32
|
* POST /api/boards/tasks/order { ws, board, initiative, section, tasks: [id] } → { ok, from } the PM's order inside one stage (a drag in the
|
|
32
33
|
* Tasks list, batch 18 item 16): a task-order line; tasks [] takes the order out; Undo sends `from` back
|
|
@@ -1284,6 +1285,7 @@ const WRITES = {
|
|
|
1284
1285
|
if (body.epic != null && body.epic !== '' && !ONE_LINE(body.epic, 80)) return fail(ctx, res, 400, 'epic is one line of at most 80 characters');
|
|
1285
1286
|
if (body.notes != null && (typeof body.notes !== 'string' || body.notes.length > 4000)) return fail(ctx, res, 400, 'notes are text of at most 4000 characters');
|
|
1286
1287
|
if (body.start && body.due && body.start > body.due) return fail(ctx, res, 400, 'the start is after the due day');
|
|
1288
|
+
if (body.milestone != null && typeof body.milestone !== 'boolean') return fail(ctx, res, 400, 'milestone is true or false');
|
|
1287
1289
|
if (!p.file) return fail(ctx, res, 409, NO_STATE);
|
|
1288
1290
|
const base = needRows(ctx, res, p.w);
|
|
1289
1291
|
if (!base) return;
|
|
@@ -1296,11 +1298,14 @@ const WRITES = {
|
|
|
1296
1298
|
if (body.section && !st.some((x) => x.key === body.section)) return fail(ctx, res, 400, `"${body.section}" is not a stage of this initiative's workflow: one of ${st.map((x) => x.key).join(', ')}, or ''`);
|
|
1297
1299
|
}
|
|
1298
1300
|
const id = `task-${crypto.randomBytes(5).toString('hex')}`;
|
|
1299
|
-
|
|
1300
|
-
const
|
|
1301
|
+
// a milestone has one date, the due day, and no start (the ◇ of the Viewer's add field, batch 25 item 5)
|
|
1302
|
+
const ms = body.milestone === true;
|
|
1303
|
+
const start = ms ? '' : body.start || (body.due && body.due < B.shiftDay(today(), 1) ? body.due : B.shiftDay(today(), 1));
|
|
1304
|
+
const due = body.due || (ms ? body.start || B.shiftDay(today(), 8) : B.shiftDay(start, 7));
|
|
1301
1305
|
append(p.file, g.actor, { kind: 'task', board: p.board.id, task: id, type: body.type || 'Task', initiative: body.initiative, title: body.title.trim(), epic: body.epic ? String(body.epic).trim() : null,
|
|
1302
1306
|
owner: body.owner ? String(body.owner).trim() : actorLib.pageName(g.actor.by), state: 'To do', start, due, ...(body.notes && body.notes.trim() ? { notes: body.notes.replace(/\s+$/, '') } : {}) });
|
|
1303
1307
|
if (typeof body.section === 'string') append(p.file, g.actor, { kind: 'task-section', board: p.board.id, task: id, section: body.section, why: body.section ? 'placed under ' + body.section : 'placed under no stage' });
|
|
1308
|
+
if (ms) append(p.file, g.actor, { kind: 'task-milestone', board: p.board.id, task: id, milestone: true, why: 'added as a milestone' });
|
|
1304
1309
|
const t = B.taskOf(B.fold(readLedger(ctx, p.w)), id);
|
|
1305
1310
|
ctx.json(res, { ok: true, item: B.rollupItems([B.taskItem(t)])[0] });
|
|
1306
1311
|
},
|
|
@@ -556,6 +556,29 @@ function profileOf(root) {
|
|
|
556
556
|
} catch { return 'product-code'; }
|
|
557
557
|
}
|
|
558
558
|
|
|
559
|
+
/*
|
|
560
|
+
* workflowOf(root, id) — the workflow an initiative runs, when its repository runs initiatives on workflows (Boards, Settings ›
|
|
561
|
+
* Workflows). The workspace gives every repository it keeps state for a default board, so every initiative there runs a workflow:
|
|
562
|
+
* an `ini-wf` line in the boards ledger (<state dir>/boards.jsonl, the dirs workspace/server.js stateDirFor reads) names its
|
|
563
|
+
* own, else it runs its board's (Product feature by default). A repository runs on workflows when it holds that ledger or a
|
|
564
|
+
* pmos/state/ dir. PMOS itself holds neither (its state is the workspace's own), so it still routes by lane and R0 still binds it.
|
|
565
|
+
* → the workflow's id, '' (its board's), or null (no workflows). The ledger is read again when its size or mtime changes.
|
|
566
|
+
*/
|
|
567
|
+
const wfMemo = new Map();
|
|
568
|
+
function workflowOf(root, id) {
|
|
569
|
+
const f = [['pmos', 'state'], ['state'], ['.pmos']].map((rel) => path.join(root, ...rel, 'boards.jsonl')).find((x) => fs.existsSync(x));
|
|
570
|
+
const on = !!f || fs.existsSync(path.join(root, 'pmos', 'state'));
|
|
571
|
+
if (!on) return null;
|
|
572
|
+
let key = '';
|
|
573
|
+
try { const st = f ? fs.statSync(f) : null; key = st ? st.size + ':' + st.mtimeMs : '-'; } catch { key = '?'; }
|
|
574
|
+
let m = wfMemo.get(root);
|
|
575
|
+
if (!m || m.key !== key) { // a held module sees a new line on the next read (the in-process freshness rule)
|
|
576
|
+
m = { key, wf: new Map() };
|
|
577
|
+
for (const l of f ? jsonl.readJsonl(f).rows : []) if (l && l.kind === 'ini-wf' && typeof l.id === 'string' && typeof l.wf === 'string' && l.wf) m.wf.set(l.id, l.wf);
|
|
578
|
+
wfMemo.set(root, m);
|
|
579
|
+
}
|
|
580
|
+
return m.wf.get(id) || '';
|
|
581
|
+
}
|
|
559
582
|
/** The lanes a repository's initiatives may declare — what the lane picker offers and the lane write accepts. */
|
|
560
583
|
function laneNames(profile) { return laneDefs.laneNames(profile === 'pm-artifacts' ? 'pm-artifacts' : 'product-code'); }
|
|
561
584
|
function lanesFor(root) {
|
|
@@ -735,6 +758,7 @@ function computeWorkflowDerived(id, opts) {
|
|
|
735
758
|
// ── route ─ the lane is chosen and the two intake questions are answered ─────────────────────
|
|
736
759
|
const sRoute = specimenRoute(fm.specimen_test);
|
|
737
760
|
const fRoute = surfaceRoute(fm.surface_check);
|
|
761
|
+
const onWf = workflowOf(root, id);
|
|
738
762
|
{
|
|
739
763
|
const missing = [];
|
|
740
764
|
if (sRoute === 'undeclared' || sRoute === 'malformed') missing.push('specimen_test');
|
|
@@ -742,6 +766,10 @@ function computeWorkflowDerived(id, opts) {
|
|
|
742
766
|
if (!missing.length) {
|
|
743
767
|
steps.push(step('route', 'route', 'done',
|
|
744
768
|
`${lane} lane · specimen test ${sRoute} · surface ${fRoute}`));
|
|
769
|
+
} else if (onWf !== null) {
|
|
770
|
+
// Batch 25 item 6: an initiative on a workflow is routed. The workflow's stages say what comes first, and PMOS proposes a
|
|
771
|
+
// switch when the BRD says another workflow fits, so the two intake questions are not asked of it again.
|
|
772
|
+
steps.push(step('route', 'route', 'done', `${onWf ? `runs the ${onWf} workflow` : 'runs its board\'s workflow'} — the workflow routes it`));
|
|
745
773
|
} else if (effLane !== 'feature') {
|
|
746
774
|
// The routing questions are asked of feature work only.
|
|
747
775
|
steps.push(step('route', 'route', 'done', `${lane} lane — routing questions are for features only (${CUT_NOTE})`));
|
|
@@ -1438,11 +1466,11 @@ function documentDuplicates(root, opts) {
|
|
|
1438
1466
|
return { ok: true, profile, archive, groups };
|
|
1439
1467
|
}
|
|
1440
1468
|
/**
|
|
1441
|
-
* Jira keys
|
|
1442
|
-
* record's
|
|
1443
|
-
*
|
|
1469
|
+
* Jira keys a handoff record holds for an initiative (S4): the Jira tab's draft (`- jira: KEY` lines) and the
|
|
1470
|
+
* record's `jira:` / `jira_key:` / `jira_keys:` / `jira_epic:` / `jira_issue_key:` frontmatter. Body text and found documents are not
|
|
1471
|
+
* read: a key a PM mentions in prose ("see Jira LOY-41", an epic link in a spec) is a reference, not a handoff.
|
|
1444
1472
|
*/
|
|
1445
|
-
function jiraKeysOf(
|
|
1473
|
+
function jiraKeysOf(L, id, fm) {
|
|
1446
1474
|
const keys = [];
|
|
1447
1475
|
const draft = path.join(path.dirname(L.initiatives), 'tracker', 'jira', `${id}-tickets.md`);
|
|
1448
1476
|
try {
|
|
@@ -1451,21 +1479,9 @@ function jiraKeysOf(root, L, id, fm, found) {
|
|
|
1451
1479
|
if (m && JIRA_KEY.test(m[1].toUpperCase())) keys.push(m[1].toUpperCase());
|
|
1452
1480
|
}
|
|
1453
1481
|
} catch { /* no draft */ }
|
|
1454
|
-
for (const k of ['jira', 'jira_key', 'jira_keys', 'jira_epic']) {
|
|
1482
|
+
for (const k of ['jira', 'jira_key', 'jira_keys', 'jira_epic', 'jira_issue_key']) {
|
|
1455
1483
|
for (const m of String(fm[k] || '').toUpperCase().matchAll(/\b([A-Z][A-Z0-9_]+-\d+)\b/g)) keys.push(m[1]);
|
|
1456
1484
|
}
|
|
1457
|
-
const fromText = (text, lines) => {
|
|
1458
|
-
for (const m of String(text).matchAll(/\/browse\/([A-Z][A-Z0-9_]+-\d+)\b/g)) keys.push(m[1]);
|
|
1459
|
-
if (lines) for (const line of String(text).split('\n')) {
|
|
1460
|
-
if (!/jira/i.test(line)) continue;
|
|
1461
|
-
for (const m of line.matchAll(/\b([A-Z][A-Z0-9_]+-\d+)\b/g)) keys.push(m[1]);
|
|
1462
|
-
}
|
|
1463
|
-
};
|
|
1464
|
-
try { fromText(fs.readFileSync(path.join(L.initiatives, `${id}.md`), 'utf8'), true); } catch { /* no record text */ }
|
|
1465
|
-
for (const rel of found ? [].concat(found.brief, found.draft, found.review, found.handoff) : []) {
|
|
1466
|
-
if (!SCAN_TEXT.test(rel)) continue;
|
|
1467
|
-
try { fromText(fs.readFileSync(path.join(root, rel), 'utf8').slice(0, 200000), false); } catch { /* unreadable */ }
|
|
1468
|
-
}
|
|
1469
1485
|
return [...new Set(keys)];
|
|
1470
1486
|
}
|
|
1471
1487
|
|
|
@@ -1483,7 +1499,7 @@ function jiraKeysOf(root, L, id, fm, found) {
|
|
|
1483
1499
|
* handoff — `handed_off:` names where it went; n/a for decision records, reports and chores
|
|
1484
1500
|
* When the record names no file for brief, draft or review, or names one that does not exist, a
|
|
1485
1501
|
* document the repository scan found for the id completes the step (scan-documents S3); Jira keys
|
|
1486
|
-
* in the
|
|
1502
|
+
* in the Jira tab's draft or the record's `jira:` frontmatter complete the handoff (S4), never a key in body text. Found evidence never blocks and never fails CI.
|
|
1487
1503
|
* Track: `light` (guidance — only route and anchor can fail CI) or `governed` (brief and draft
|
|
1488
1504
|
* existence and their order fail CI for a non-legacy record). Review, approval and handoff are never
|
|
1489
1505
|
* CI-blocking in either track: none can be true before the PR exists.
|
|
@@ -1564,7 +1580,7 @@ function pmLadder({ id, fm, lane, effLane, knownLane, legacy, steps, root, L, re
|
|
|
1564
1580
|
if (!flows('handoff')) steps.push(step('handoff', 'handed off', 'na', `${effLane} — nothing to hand off`));
|
|
1565
1581
|
else {
|
|
1566
1582
|
const to = String(fm.handed_off || '').trim();
|
|
1567
|
-
const keys = to ? [] : jiraKeysOf(
|
|
1583
|
+
const keys = to ? [] : jiraKeysOf(L, id, fm);
|
|
1568
1584
|
steps.push(to ? step('handoff', 'handed off', 'done', `handed off — ${to}`)
|
|
1569
1585
|
: keys.length ? step('handoff', 'handed off', 'done', `handed off to Jira — ${keys.slice(0, 5).join(', ')}${keys.length > 5 ? ` and ${keys.length - 5} more` : ''}`)
|
|
1570
1586
|
: foundOf('handoff') ? step('handoff', 'handed off', 'done', `${foundOf('handoff')} — handoff found by the repository scan`)
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pmos-workspace",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.498.0",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "pmos-workspace",
|
|
9
|
-
"version": "0.
|
|
9
|
+
"version": "0.498.0",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@anthropic-ai/sdk": "^1.3.0",
|
package/workspace/package.json
CHANGED
|
@@ -205,7 +205,7 @@ function wire(body, key) {
|
|
|
205
205
|
hold: (i, b) => { const r = O.rowOf(key); if (r && O.hold) O.hold(r, i, b); },
|
|
206
206
|
addCancel: () => { adding = null; addTo = ''; addDates = null; redraw(); },
|
|
207
207
|
// opt (batch 18 item 16): the + on a stage, with the dates its field picked
|
|
208
|
-
addTask: async (title, inp, opt) => { const r = O.rowOf(key); if (!r || !O.addTask) return; inp.disabled = true; const ok = await O.addTask(r, title, opt ? opt.section : addTo, opt ? (opt.start || opt.due ? opt : null) : addDates, opt ? opt.name : ''); inp.disabled = false; if (ok && !opt) { inp.value = ''; inp.focus(); } },
|
|
208
|
+
addTask: async (title, inp, opt) => { const r = O.rowOf(key); if (!r || !O.addTask) return; inp.disabled = true; const ok = await O.addTask(r, title, opt ? opt.section : addTo, opt ? (opt.start || opt.due || opt.milestone || opt.owner ? opt : null) : addDates, opt ? opt.name : ''); inp.disabled = false; if (ok && !opt) { inp.value = ''; inp.focus(); } },
|
|
209
209
|
primary: (act, b) => {
|
|
210
210
|
const r = O.rowOf(key); if (!r) return;
|
|
211
211
|
if (act === 'grill') O.grillOpen(r, b);
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/* pm/chat-turn-end.js — tells when a chat turn ended, so the page beside the chat reads its records again (batch 25 item 3: "When chat
|
|
2
|
+
* said it has made changes to the doc or content shown on the viewer, the content on the viewer should automatically update without
|
|
3
|
+
* the user needing to refresh"). home-chat.js takes a snapshot on each read of the conversation; the host re-reads what the Viewer shows
|
|
4
|
+
* when a turn ends.
|
|
5
|
+
*
|
|
6
|
+
* snapOf(key, busy, messages) { key, busy, agents }: the conversation's place, whether Claude is still at work, and how many of its
|
|
7
|
+
* messages are not the PM's.
|
|
8
|
+
* turnEnded(prev, cur) true once per finished turn: the same conversation, not at work now, and either at work on the last read
|
|
9
|
+
* or with more of Claude's messages than then (a turn that started and finished between two reads).
|
|
10
|
+
* The first read of a conversation is never a finished turn: opening a chat does not re-read the page. */
|
|
11
|
+
export function snapOf(key, busy, messages) {
|
|
12
|
+
const agents = (Array.isArray(messages) ? messages : []).filter((m) => m && m.role !== 'user').length;
|
|
13
|
+
return { key: String(key || ''), busy: !!busy, agents };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function turnEnded(prev, cur) {
|
|
17
|
+
if (!prev || !cur || !cur.key || prev.key !== cur.key || cur.busy) return false;
|
|
18
|
+
return prev.busy || cur.agents > prev.agents;
|
|
19
|
+
}
|
|
@@ -47,7 +47,7 @@ import * as YD from './home-day.js';
|
|
|
47
47
|
import * as AD from './cozy-adler.js'; // Adler, Claude's face (batch 16 item 2)
|
|
48
48
|
import * as OKL from './okr-list.js'; // the product page's Goals line (batch 16 item 6, GsProduct)
|
|
49
49
|
import { canDictate, dictate } from './home-dictate.js';
|
|
50
|
-
import { mountIni, iniList, iniOverview, iniPage, iniAddTask } from './home-ini.js'; // the initiative's page, its stage move, Tasks and Core documents (premium-uncovered-features, HbC)
|
|
50
|
+
import { mountIni, iniList, iniOverview, iniPage, iniAddTask, iniFresh } from './home-ini.js'; // the initiative's page, its stage move, Tasks and Core documents (premium-uncovered-features, HbC)
|
|
51
51
|
import * as MEN from './home-mentions.js';
|
|
52
52
|
import * as LS from './load-shapes.js'; // the last view, the grey shapes and the held Viewer column while Home reads (page-open-speed) // @Mentions, the permanent row at the top of Home and its Viewer (Viewer kit, XvANew)
|
|
53
53
|
import { mountRecRow, clockOf as recClock } from './home-rec-row.js';
|
|
@@ -509,7 +509,7 @@ function iniViewOf(id) {
|
|
|
509
509
|
const need = H.needsOf(S.D).find((r) => r.ini === id);
|
|
510
510
|
const p = programOf(S.D.programs || [], row.ws || (B.board || {}).ws || '', id);
|
|
511
511
|
return iniView(rowFor({ ...row, trail: t || row.trail || null }), { steps: B.steps || STEPS, today: B.today, usual: WFM.usualOf(S.D.trail, row),
|
|
512
|
-
ask: need ? { title: need.name, n: H.needsOf(S.D).filter((r) => r.ini === id).length } : null, program: p ? { id: p.id, title: p.title } : null, jiraReadAt: ((B.jira || {}).readAt) || '',
|
|
512
|
+
ask: need ? { title: need.name, n: H.needsOf(S.D).filter((r) => r.ini === id).length, propose: (need.row && need.row.propose) || null } : null, program: p ? { id: p.id, title: p.title } : null, jiraReadAt: ((B.jira || {}).readAt) || '',
|
|
513
513
|
// the card (batch 18 item 1): Claude's line while a session works on it, the stakeholders
|
|
514
514
|
claude: (() => { const w = S.D.sessions.find((s) => H.iniOfSession(s) === id && s.shown === 'working'); return w ? 'I’m working on “' + H.nameOf(w) + '”. I’ll say here when it is ready.' : ''; })(),
|
|
515
515
|
people: (((S.D.desk && S.D.desk.stakeholders) || {})[id] || []) });
|
|
@@ -756,7 +756,15 @@ const chat = mountChat($('#hmChat'), {
|
|
|
756
756
|
desk: () => !isPhone(),
|
|
757
757
|
reopenViewer: () => reopenViewer(),
|
|
758
758
|
head: (t) => (isPhone() ? '' : TD.chatHeadHtml(chatHead(t))), // the phone names it in its top bar (#hmTop)
|
|
759
|
+
// a chat turn finished: read the records again, so the list and the Viewer show what Claude changed with no refresh (batch 25 item 3).
|
|
760
|
+
// load() keeps the open folds (ini-view.js FOLD) and the Viewer's scroll (home-ini.js redrawView); the initiative's layout is read again too
|
|
761
|
+
turnEnded: () => turnEnded(),
|
|
759
762
|
});
|
|
763
|
+
let turnT = null;
|
|
764
|
+
function turnEnded() {
|
|
765
|
+
clearTimeout(turnT);
|
|
766
|
+
turnT = setTimeout(async () => { await load(); iniFresh(); }, 400); // two chats that finish together read once
|
|
767
|
+
}
|
|
760
768
|
/* claude-onboarding-build: set up a project as a conversation in this chat's place (cozy-onboard.js). It opens on ?onboard=1, shows the
|
|
761
769
|
* finished project's next steps on ?onboarded=1, and opens by itself on a first run: setup not finished and no registered folder holds an
|
|
762
770
|
* initiative. A named workspace does not stop it: the installer opens its own empty ~/PMOS folder by name (bug 2026-11-02, Windows). */
|
|
@@ -859,7 +867,9 @@ function alsoOf(ini) {
|
|
|
859
867
|
// in an initiative's chat only that initiative's own next step shows; another initiative's is not "also" this conversation's
|
|
860
868
|
const other = H.needsOf(S.D).filter((r) => !(S.sel && S.sel === r.id));
|
|
861
869
|
const n = ini ? other.find((r) => r.ini === ini) : other[0];
|
|
862
|
-
|
|
870
|
+
// a blocked step reaches the PM as Claude's ✦ proposal (batch 25 item 6): the line names what Claude will do, the press reviews it
|
|
871
|
+
const pr = n && n.row && n.row.propose;
|
|
872
|
+
return n ? { text: pr ? '✦ ' + pr.label : n.name, go: () => select(n.id), verb: pr ? 'Review' : 'Answer' } : null;
|
|
863
873
|
}
|
|
864
874
|
/* An ask's conversation (batch 18 item 2-A, OvA10Desk): the Viewer holds the summary and the one verb, so the chat asks a question instead
|
|
865
875
|
* of saying it again. A decision's choices are pills that open the Viewer with that choice picked; it is recorded there. */
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* A loose session the host can place (host.suggestLink(target, firstText) → { id, title }) shows one chip: Link it (host.linkTo) or × (host.dismissLink).
|
|
17
17
|
* Every write is the PM's press. mountChat(el, host) returns { open(target), refresh(), focus(), send(text) }.
|
|
18
18
|
* target: { key?, sid?, title, scope: { text, icon, loose }, role: { name, initial }, intro?: { role, text, at, mood? (Adler as the avatar while the log is empty), pills: [{ label, primary, go }] },
|
|
19
|
-
* ini?, rich, also?: { text, go } }
|
|
19
|
+
* ini?, rich, also?: { text, go, verb? ('Answer' when absent; 'Review' for Claude's ✦ proposal) } }
|
|
20
20
|
* host: { esc, toast, pmGet, pmPost, roles(), seen(key), markSeen(key), onScope(btn), onLink(btn), onTo(btn), workItem(text, target),
|
|
21
21
|
* viewerClosed(), reopenViewer(), head(target), touch(sid) (the PM opened, typed in or answered that session: the Live pill) } */
|
|
22
22
|
import { mdHtml, userHtml, toolLine, webKind } from './chat-md.js';
|
|
@@ -33,6 +33,7 @@ import { splitFiles, filesHtml, sizeText, MAX_BYTES, MAX_FILES, setAttachSink }
|
|
|
33
33
|
import * as VWC from './cozy-viewer.js';
|
|
34
34
|
import { canDictate, dictate } from './home-dictate.js';
|
|
35
35
|
import { adler } from './cozy-adler.js';
|
|
36
|
+
import { snapOf, turnEnded } from './chat-turn-end.js'; // a finished turn re-reads what the Viewer shows (batch 25 item 3)
|
|
36
37
|
/* The views a chat opens go in the Viewer: on a page with no dialog the old viewer's views draw in the cozy card (premium-viewer.js). */
|
|
37
38
|
const views = {
|
|
38
39
|
ctx: () => import('./premium-vw-ctx.js'), file: () => import('./premium-vw-file.js'), attach: () => import('./premium-attach.js'),
|
|
@@ -92,7 +93,11 @@ export function mountChat(el, host) {
|
|
|
92
93
|
C.th = th && th.ok ? th : null; C.conv = conv && conv.ok ? conv : null;
|
|
93
94
|
if (host.touch && sid() && C.touched !== sid()) { C.touched = sid(); host.touch(C.touched); } // the Live pill: the PM opened it now
|
|
94
95
|
C.sess = host.session ? host.session(t.sid || (C.th && C.th.session)) : null;
|
|
96
|
+
// a turn that finished: Claude may have changed what the Viewer shows (tasks, documents), so the host reads it again (batch 25 item 3)
|
|
97
|
+
const snap = snapOf(t.key || t.sid, running() || !!(C.th && C.th.working), C.conv && C.conv.messages);
|
|
98
|
+
const ended = turnEnded(C.turn, snap); C.turn = snap;
|
|
95
99
|
draw();
|
|
100
|
+
if (ended && host.turnEnded) host.turnEnded(t);
|
|
96
101
|
clearTimeout(C.timer);
|
|
97
102
|
const live = (C.th && (C.th.session || C.th.working)) || (t.sid && C.conv && C.conv.state !== 'ended');
|
|
98
103
|
C.timer = setTimeout(() => { if (!document.hidden) read(); else C.timer = setTimeout(read, IDLE_POLL); }, live ? POLL : IDLE_POLL);
|
|
@@ -395,7 +400,7 @@ export function mountChat(el, host) {
|
|
|
395
400
|
const chipsOf = (items) => (chipsHtmlFn ? chipsHtmlFn(items) : '');
|
|
396
401
|
function drawFoot(R) {
|
|
397
402
|
const t = C.t, goal = C.th && C.th.goal;
|
|
398
|
-
const also = t.also && !(C.conv && C.conv.ask) ? `<button type="button" class="hm-also" data-hm-also><i aria-hidden="true"></i><span><b>Also waiting on you:</b> ${esc(t.also.text)}</span><em
|
|
403
|
+
const also = t.also && !(C.conv && C.conv.ask) ? `<button type="button" class="hm-also" data-hm-also><i aria-hidden="true"></i><span><b>Also waiting on you:</b> ${esc(t.also.text)}</span><em>${esc(t.also.verb || 'Answer')}</em></button>` : '';
|
|
399
404
|
// while it works the PM talks as usual (Claude reads it after its tool call); a goal that stopped says how to go on (principle 8)
|
|
400
405
|
const ph = C.conv && C.conv.ask ? 'Or tell ' + R.role.name + ' what to do instead' : goal && goal.state === 'stopped' && !running() ? 'Reply to go on, or /goal for a new goal' : t.placeholder || 'Message Claude';
|
|
401
406
|
const sc = t.scope || { text: 'All work', icon: 'M5 21V4M5 4h12l-2 4 2 4H5' };
|
|
@@ -201,6 +201,13 @@ function ask(anchor, label, value, go, o = {}) { // one line of words, inline
|
|
|
201
201
|
i.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); const v = i.value.trim(); if (v) { closePop(); go(v); } } });
|
|
202
202
|
}
|
|
203
203
|
|
|
204
|
+
/** The open initiative's records changed elsewhere (a chat turn finished, batch 25 item 3): read its layout again, as a press does.
|
|
205
|
+
* The page redraws in place; the folds and the scroll stay. */
|
|
206
|
+
export function iniFresh() {
|
|
207
|
+
if (!X.ctx) return;
|
|
208
|
+
const id = X.ctx.S.ini, M = curM();
|
|
209
|
+
if (M) readL(id, M, true);
|
|
210
|
+
}
|
|
204
211
|
export function iniList(sc) {
|
|
205
212
|
if (!X.ctx || !sc) return;
|
|
206
213
|
sc.querySelectorAll('[data-hi-slot]').forEach((n) => n.remove());
|
|
@@ -287,7 +294,7 @@ function wirePage(b, id) {
|
|
|
287
294
|
const M = curM(); if (!M) return;
|
|
288
295
|
inp.disabled = true;
|
|
289
296
|
const D = opt ? opt : ADD_DATES || {};
|
|
290
|
-
const r = await C.pmPost('/api/boards/tasks/add', { ...bw(M), initiative: id, title, ...(D.start ? { start: D.start } : {}), ...(D.due ? { due: D.due } : {}), ...(opt && opt.section != null ? { section: opt.section } : {}) });
|
|
297
|
+
const r = await C.pmPost('/api/boards/tasks/add', { ...bw(M), initiative: id, title, ...(D.start ? { start: D.start } : {}), ...(D.due ? { due: D.due } : {}), ...(opt && opt.section != null ? { section: opt.section } : {}), ...(opt && opt.milestone ? { milestone: true } : {}), ...(opt && opt.owner ? { owner: opt.owner } : {}) });
|
|
291
298
|
inp.disabled = false;
|
|
292
299
|
if (!r || r.ok === false) { say((r && r.error) || 'The task was not added.'); return; }
|
|
293
300
|
const uid = r.item && r.item.uid;
|
|
@@ -74,7 +74,7 @@ function whyBlocked(step, rule, lane) {
|
|
|
74
74
|
if (step === 'prototype') return 'This idea needs a prototype people can look at before a PRD says what to build.';
|
|
75
75
|
if (step === 'contract') return 'The run needs its done-criteria agreed before it starts, so there is something to check it against.';
|
|
76
76
|
if (step === 'anchor') return 'Every initiative names the goal it moves. Work with no goal is turned away.';
|
|
77
|
-
if (step === 'route') return 'The initiative needs
|
|
77
|
+
if (step === 'route') return 'The initiative needs a workflow, so its stages can run.';
|
|
78
78
|
return 'The ' + step + ' step is not done yet.';
|
|
79
79
|
}
|
|
80
80
|
const lineOf = (step, rule) => (/^O[12]$/.test(rule || '') ? 'Blocked: ' + (STEP_WORD[step] || 'the ' + step) + ' came after the build'
|