create-pmos 0.65.2 → 0.66.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 +1 -1
- 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 +139 -0
- package/payload/state/discoveries.md +1 -1
- package/workspace/guide/initiatives-screen.md +22 -3
- package/workspace/guide/layout.md +1 -1
- package/workspace/lib/engine/workflow-state.js +139 -0
- package/workspace/lib/initiative-rows.js +2 -0
- package/workspace/lib/routes-initiatives.js +13 -4
- package/workspace/package-lock.json +1 -1
- package/workspace/package.json +1 -1
- package/workspace/public/pm/dest-initiatives.js +2 -0
- package/workspace/public/pm/initiative-sheet.js +3 -2
- package/workspace/public/pm/initiatives-api.js +5 -2
- package/workspace/public/pm/initiatives-copy.js +11 -4
- package/workspace/public/pm/initiatives-ui.js +25 -1
- package/workspace/public/pm/tab-term.js +69 -0
- package/workspace/test-lists/scan-documents.json +5 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-pmos",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.66.0",
|
|
4
4
|
"description": "Scaffold the PMOS portable kit — run the PMOS product-management workflow (initiative → lane-fit spec → contract → build → three gates, with work-type lanes for features, research, maintenance, incidents, and content) entirely inside your own repo, with all state as git-versioned files. Includes `workflow-sweep`, which audits the state every board derives from and separates real defects from work you simply have not done. `create-pmos workspace` also installs + launches the local Terminal Workspace GUI. No database, no network, no telemetry.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"bin": {
|
package/payload/KIT-VERSION
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
type: Reference
|
|
3
3
|
title: "Eval calibration log"
|
|
4
4
|
description: "Append-only record of every PM override of an evaluator verdict."
|
|
5
|
-
generated: {by: process:create-pmos, at: "2026-10-07T01:
|
|
5
|
+
generated: {by: process:create-pmos, at: "2026-10-07T01:43:50Z"}
|
|
6
6
|
status: stable
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
type: Reference
|
|
3
3
|
title: "Health budgets"
|
|
4
4
|
description: "Closed registry of health-budget anchors (D59), edited only by the PM."
|
|
5
|
-
generated: {by: process:create-pmos, at: "2026-10-07T01:
|
|
5
|
+
generated: {by: process:create-pmos, at: "2026-10-07T01:43:50Z"}
|
|
6
6
|
status: stable
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -1274,6 +1274,129 @@ function computeWorkflowDerived(id, opts) {
|
|
|
1274
1274
|
};
|
|
1275
1275
|
}
|
|
1276
1276
|
|
|
1277
|
+
// ── The document scan (scan-documents S1–S4) — what the repository holds for each initiative ─────
|
|
1278
|
+
// A pm-artifacts record often names none of its documents: the PRD, the evaluation and the Jira
|
|
1279
|
+
// tickets sit in the repository and the record was never told. The ladder used to read the record
|
|
1280
|
+
// only, so that work read as not started. This reads the repository once per root (tracked and
|
|
1281
|
+
// untracked, not ignored), joins each document to an initiative by its frontmatter or by the id in
|
|
1282
|
+
// its path, and gives it a role from its name. It is evidence a named path outranks, never a
|
|
1283
|
+
// blocker, and nothing is written back. The cache holds SCAN_TTL_MS; `forgetDocumentScan` (the
|
|
1284
|
+
// workspace's Scan repository) drops it at once.
|
|
1285
|
+
const SCAN_TTL_MS = 30000;
|
|
1286
|
+
const SCAN_MAX_FILES = 20000;
|
|
1287
|
+
const SCAN_EXT = /\.(md|markdown|mdx|txt|docx|pdf|html?)$/i;
|
|
1288
|
+
const SCAN_TEXT = /\.(md|markdown|mdx|txt)$/i;
|
|
1289
|
+
const SCAN_ROLES = [ // first match wins, in this order (S2)
|
|
1290
|
+
['review', ['eval', 'evals', 'evaluation', 'evaluations', 'review', 'reviews', 'verdict', 'scorecard']],
|
|
1291
|
+
['handoff', ['handoff', 'handover', 'hand-off']],
|
|
1292
|
+
['brief', ['brief', 'briefs', 'problem', 'idea', 'ideas', 'intake', 'one-pager', 'onepager', 'bug-report']],
|
|
1293
|
+
['draft', ['prd', 'prds', 'spec', 'specs', 'specification', 'requirements', 'findings', 'plan', 'strategy', 'report', 'decision']],
|
|
1294
|
+
];
|
|
1295
|
+
const SCAN_NOT_EVIDENCE = ['rubric', 'rubrics', 'template', 'templates'];
|
|
1296
|
+
const JIRA_KEY = /^[A-Z][A-Z0-9_]+-\d+$/;
|
|
1297
|
+
const _scanCache = new Map(); // root -> { at, index: Map(id -> { brief:[], draft:[], review:[], handoff:[] }) }
|
|
1298
|
+
function forgetDocumentScan(root) { if (root) _scanCache.delete(path.resolve(root)); else _scanCache.clear(); }
|
|
1299
|
+
|
|
1300
|
+
function scanWords(s) {
|
|
1301
|
+
const lower = String(s || '').toLowerCase();
|
|
1302
|
+
const words = lower.split(/[^a-z0-9]+/).filter(Boolean);
|
|
1303
|
+
// The hyphenated role words are matched as joined pairs too: "hand-off", "one-pager", "bug-report".
|
|
1304
|
+
const n = words.length;
|
|
1305
|
+
for (let i = 0; i + 1 < n; i++) words.push(`${words[i]}-${words[i + 1]}`);
|
|
1306
|
+
return words;
|
|
1307
|
+
}
|
|
1308
|
+
function roleOfWords(words, extra) {
|
|
1309
|
+
const set = new Set(words);
|
|
1310
|
+
for (const [role, list] of SCAN_ROLES) {
|
|
1311
|
+
if (list.some((w) => set.has(w))) return role;
|
|
1312
|
+
if (role === 'draft' && extra && extra.some((w) => set.has(w))) return role;
|
|
1313
|
+
}
|
|
1314
|
+
return null;
|
|
1315
|
+
}
|
|
1316
|
+
/** The role of a found document: its file name, then its frontmatter `type:`, then its folders (S2). */
|
|
1317
|
+
function documentRole(rel, type, ids) {
|
|
1318
|
+
const parts = rel.split('/');
|
|
1319
|
+
const base = parts[parts.length - 1].replace(/\.[^.]+$/, '');
|
|
1320
|
+
// The id is not a role word: "problem-reporting-prd" is a PRD of the initiative problem-reporting.
|
|
1321
|
+
const strip = (s) => { let out = s.toLowerCase(); for (const id of ids || []) out = out.split(id.toLowerCase()).join(' '); return out; };
|
|
1322
|
+
// A rubric or a template says what good looks like; it is not evidence that the work was judged or done.
|
|
1323
|
+
if (scanWords(base).some((x) => SCAN_NOT_EVIDENCE.includes(x))) return null;
|
|
1324
|
+
return roleOfWords(scanWords(strip(base))) || roleOfWords(scanWords(type))
|
|
1325
|
+
|| roleOfWords(scanWords(strip(parts.slice(0, -1).join('/'))));
|
|
1326
|
+
}
|
|
1327
|
+
/** The initiatives a document belongs to (S1): its frontmatter names them, else the longest id that is a folder or leads the file name. */
|
|
1328
|
+
function documentOwners(rel, fm, idSet, byLength) {
|
|
1329
|
+
const named = [fm.initiative, fm.initiative_id, fm.initiatives].filter(Boolean)
|
|
1330
|
+
.flatMap((v) => String(v).replace(/^\[|\]$/g, '').split(',')).map((s) => s.trim().replace(/^["']|["']$/g, ''))
|
|
1331
|
+
.filter((s) => idSet.has(s));
|
|
1332
|
+
if (named.length) return [...new Set(named)];
|
|
1333
|
+
const parts = rel.toLowerCase().split('/');
|
|
1334
|
+
const base = parts[parts.length - 1].replace(/\.[^.]+$/, '');
|
|
1335
|
+
const dirs = parts.slice(0, -1);
|
|
1336
|
+
for (const id of byLength) {
|
|
1337
|
+
if (id.length < 3) break;
|
|
1338
|
+
const low = id.toLowerCase();
|
|
1339
|
+
if (dirs.includes(low)) return [id];
|
|
1340
|
+
if (base === low || base.startsWith(low + '-') || base.startsWith(low + '_') || base.startsWith(low + '.')) return [id];
|
|
1341
|
+
}
|
|
1342
|
+
return [];
|
|
1343
|
+
}
|
|
1344
|
+
/** Every record id, read from the records folder (the scan needs them all to pick the longest owner). */
|
|
1345
|
+
function recordIds(L) {
|
|
1346
|
+
try { return fs.readdirSync(L.initiatives).filter((f) => f.endsWith('.md') && !/^(README|index|log)\.md$/i.test(f)).map((f) => f.slice(0, -3)); }
|
|
1347
|
+
catch { return []; }
|
|
1348
|
+
}
|
|
1349
|
+
function scanRepository(root, ids, L) {
|
|
1350
|
+
const key = path.resolve(root);
|
|
1351
|
+
const hit = _scanCache.get(key);
|
|
1352
|
+
if (hit && Date.now() - hit.at < SCAN_TTL_MS && ids.every((id) => hit.ids.has(id))) return hit.index;
|
|
1353
|
+
const index = new Map();
|
|
1354
|
+
const idSet = new Set(ids);
|
|
1355
|
+
const byLength = ids.slice().sort((a, b) => b.length - a.length);
|
|
1356
|
+
const out = git(root, ['ls-files', '-z', '--cached', '--others', '--exclude-standard']);
|
|
1357
|
+
const files = out == null ? [] : [...new Set(out.split('\0').filter(Boolean))].slice(0, SCAN_MAX_FILES);
|
|
1358
|
+
const recDir = path.relative(root, L.initiatives).split(path.sep).join('/') + '/';
|
|
1359
|
+
for (const rel of files) {
|
|
1360
|
+
if (!SCAN_EXT.test(rel) || rel.startsWith(recDir) || /(^|\/)(node_modules|\.git)\//.test(rel)) continue;
|
|
1361
|
+
let fm = {};
|
|
1362
|
+
if (SCAN_TEXT.test(rel)) {
|
|
1363
|
+
try {
|
|
1364
|
+
const fd = fs.openSync(path.join(root, rel), 'r');
|
|
1365
|
+
const buf = Buffer.alloc(4096);
|
|
1366
|
+
const n = fs.readSync(fd, buf, 0, 4096, 0);
|
|
1367
|
+
fs.closeSync(fd);
|
|
1368
|
+
fm = parseLeadingFrontmatter(buf.slice(0, n).toString('utf8')) || {};
|
|
1369
|
+
} catch { continue; }
|
|
1370
|
+
}
|
|
1371
|
+
const owners = documentOwners(rel, fm, idSet, byLength);
|
|
1372
|
+
if (!owners.length) continue;
|
|
1373
|
+
const role = documentRole(rel, fm.type, owners);
|
|
1374
|
+
if (!role) continue;
|
|
1375
|
+
for (const id of owners) {
|
|
1376
|
+
if (!index.has(id)) index.set(id, { brief: [], draft: [], review: [], handoff: [] });
|
|
1377
|
+
index.get(id)[role].push(rel);
|
|
1378
|
+
}
|
|
1379
|
+
}
|
|
1380
|
+
for (const v of index.values()) for (const k of Object.keys(v)) v[k].sort();
|
|
1381
|
+
_scanCache.set(key, { at: Date.now(), ids: idSet, index });
|
|
1382
|
+
return index;
|
|
1383
|
+
}
|
|
1384
|
+
/** Jira keys the repository holds for an initiative (S4): the Jira tab's draft, then the record's own keys. */
|
|
1385
|
+
function jiraKeysOf(root, L, id, fm) {
|
|
1386
|
+
const keys = [];
|
|
1387
|
+
const draft = path.join(path.dirname(L.initiatives), 'tracker', 'jira', `${id}-tickets.md`);
|
|
1388
|
+
try {
|
|
1389
|
+
for (const line of fs.readFileSync(draft, 'utf8').split('\n')) {
|
|
1390
|
+
const m = /^- jira:\s*(\S+)\s*$/.exec(line);
|
|
1391
|
+
if (m && JIRA_KEY.test(m[1].toUpperCase())) keys.push(m[1].toUpperCase());
|
|
1392
|
+
}
|
|
1393
|
+
} catch { /* no draft */ }
|
|
1394
|
+
for (const k of ['jira', 'jira_key', 'jira_keys', 'jira_epic']) {
|
|
1395
|
+
for (const m of String(fm[k] || '').toUpperCase().matchAll(/\b([A-Z][A-Z0-9_]+-\d+)\b/g)) keys.push(m[1]);
|
|
1396
|
+
}
|
|
1397
|
+
return [...new Set(keys)];
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1277
1400
|
/**
|
|
1278
1401
|
* D68 — the pm-artifacts ladder: route → anchor → brief → draft → review → approved → handed off.
|
|
1279
1402
|
*
|
|
@@ -1286,6 +1409,9 @@ function computeWorkflowDerived(id, opts) {
|
|
|
1286
1409
|
* review — an evals.jsonl verdict for this id, or a named `reviewed_by:` (a rubric, an agent, a human)
|
|
1287
1410
|
* approved — the PM's acceptance in acceptances.jsonl (D12, never automatic)
|
|
1288
1411
|
* handoff — `handed_off:` names where it went; n/a for decision records, reports and chores
|
|
1412
|
+
* When the record names no file for brief, draft or review, or names one that does not exist, a
|
|
1413
|
+
* document the repository scan found for the id completes the step (scan-documents S3); Jira keys
|
|
1414
|
+
* in the repository complete the handoff (S4). Found evidence never blocks and never fails CI.
|
|
1289
1415
|
* Track: `light` (guidance — only route and anchor can fail CI) or `governed` (brief and draft
|
|
1290
1416
|
* existence and their order fail CI for a non-legacy record). Review, approval and handoff are never
|
|
1291
1417
|
* CI-blocking in either track: none can be true before the PR exists.
|
|
@@ -1307,12 +1433,17 @@ function pmLadder({ id, fm, lane, effLane, knownLane, legacy, steps, root, L, re
|
|
|
1307
1433
|
: step('route', 'route', 'done', `unknown lane '${lane}' — treated as a governed prd, the profile's strictest (D68)`,
|
|
1308
1434
|
`set lane: to one of ${laneNames('pm-artifacts').join(', ')}`);
|
|
1309
1435
|
|
|
1436
|
+
const found = (opts && opts.scan === false) ? null : (scanRepository(root, recordIds(L).concat(id), L).get(id) || null);
|
|
1437
|
+
const foundOf = (role) => (found && found[role].length ? found[role][0] : null);
|
|
1310
1438
|
const briefPath = String(fm.brief_path || fm.prd_path || '').trim();
|
|
1311
1439
|
const docPath = String(fm.doc_path || '').trim();
|
|
1440
|
+
const foundBrief = exists(briefPath) ? null : foundOf('brief');
|
|
1441
|
+
const foundDraft = exists(docPath) ? null : foundOf('draft');
|
|
1312
1442
|
const briefIdx = briefPath ? firstAppearance(root, range, briefPath) : null;
|
|
1313
1443
|
const docIdx = docPath ? firstAppearance(root, range, docPath) : null;
|
|
1314
1444
|
|
|
1315
1445
|
if (!flows('brief')) steps.push(skipped('brief', def.brief_label));
|
|
1446
|
+
else if (foundBrief) steps.push(step('brief', def.brief_label, 'done', `${foundBrief} — found by the repository scan`));
|
|
1316
1447
|
else if (exists(briefPath)) steps.push(step('brief', def.brief_label, 'done', `${briefPath} present`));
|
|
1317
1448
|
else steps.push(step('brief', def.brief_label, enforce ? 'blocked' : 'pending',
|
|
1318
1449
|
briefPath ? `brief_path '${briefPath}' does not exist` : `no ${def.brief_label} yet`,
|
|
@@ -1320,6 +1451,7 @@ function pmLadder({ id, fm, lane, effLane, knownLane, legacy, steps, root, L, re
|
|
|
1320
1451
|
{ blocking: enforce, rule: 'P1' }));
|
|
1321
1452
|
|
|
1322
1453
|
if (!flows('draft')) steps.push(skipped('draft', def.draft_label));
|
|
1454
|
+
else if (foundDraft) steps.push(step('draft', def.draft_label, 'done', `${foundDraft} — found by the repository scan`));
|
|
1323
1455
|
else if (exists(docPath)) {
|
|
1324
1456
|
const inOrder = briefIdx == null || docIdx == null ? null : docIdx >= briefIdx;
|
|
1325
1457
|
if (enforce && inOrder === false) {
|
|
@@ -1335,6 +1467,7 @@ function pmLadder({ id, fm, lane, effLane, knownLane, legacy, steps, root, L, re
|
|
|
1335
1467
|
if (!flows('review')) steps.push(skipped('review', 'review'));
|
|
1336
1468
|
else if (evals.length) steps.push(step('review', 'review', 'done', `${evals[evals.length - 1].verdict || 'verdict'} recorded in evals.jsonl`));
|
|
1337
1469
|
else if (reviewer) steps.push(step('review', 'review', 'done', `reviewed by ${reviewer}`));
|
|
1470
|
+
else if (foundOf('review')) steps.push(step('review', 'review', 'done', `${foundOf('review')} — evaluation found by the repository scan`));
|
|
1338
1471
|
else steps.push(step('review', 'review', 'pending', 'not reviewed yet',
|
|
1339
1472
|
'name the reviewer in reviewed_by: (a person, an agent, or a rubric verdict in evals.jsonl)'));
|
|
1340
1473
|
|
|
@@ -1359,7 +1492,10 @@ function pmLadder({ id, fm, lane, effLane, knownLane, legacy, steps, root, L, re
|
|
|
1359
1492
|
if (!flows('handoff')) steps.push(step('handoff', 'handed off', 'na', `${effLane} — nothing to hand off`));
|
|
1360
1493
|
else {
|
|
1361
1494
|
const to = String(fm.handed_off || '').trim();
|
|
1495
|
+
const keys = to ? [] : jiraKeysOf(root, L, id, fm);
|
|
1362
1496
|
steps.push(to ? step('handoff', 'handed off', 'done', `handed off — ${to}`)
|
|
1497
|
+
: keys.length ? step('handoff', 'handed off', 'done', `handed off to Jira — ${keys.slice(0, 5).join(', ')}${keys.length > 5 ? ` and ${keys.length - 5} more` : ''}`)
|
|
1498
|
+
: foundOf('handoff') ? step('handoff', 'handed off', 'done', `${foundOf('handoff')} — handoff found by the repository scan`)
|
|
1363
1499
|
: step('handoff', 'handed off', 'pending', 'not handed off yet',
|
|
1364
1500
|
'record where it went in handed_off: (a tracker link, the team, or "accepted as the deliverable")'));
|
|
1365
1501
|
}
|
|
@@ -1370,6 +1506,7 @@ function pmLadder({ id, fm, lane, effLane, knownLane, legacy, steps, root, L, re
|
|
|
1370
1506
|
if (by.brief.status === 'done') stage = 'brief';
|
|
1371
1507
|
if (by.draft.status === 'done') stage = 'draft';
|
|
1372
1508
|
if (by.review.status === 'done') stage = 'review';
|
|
1509
|
+
if (by.handoff && by.handoff.status === 'done') stage = 'handoff';
|
|
1373
1510
|
if (accepted.length) stage = 'accepted';
|
|
1374
1511
|
const blockers = steps.filter((x) => x.status === 'blocked');
|
|
1375
1512
|
const gateFailures = blockers.filter((x) => x.blocking);
|
|
@@ -1382,6 +1519,7 @@ function pmLadder({ id, fm, lane, effLane, knownLane, legacy, steps, root, L, re
|
|
|
1382
1519
|
ok: true, id, lane, laneKnown: knownLane, legacy, in_change: inChange,
|
|
1383
1520
|
last_activity: lastActivity, accepted: accepted.length > 0, stage, record: recRel,
|
|
1384
1521
|
title: fm.title || id, anchor: fm.parent_kr || null, profile: 'pm-artifacts', track,
|
|
1522
|
+
found: found ? { brief: found.brief.slice(), draft: found.draft.slice(), review: found.review.slice(), handoff: found.handoff.slice() } : null,
|
|
1385
1523
|
...facts,
|
|
1386
1524
|
steps, blockers: blockers.map((x) => x.key), gate_failures: gateFailures.map((x) => x.rule || x.key),
|
|
1387
1525
|
next: next ? { step: next.key, why: next.evidence, action: next.next || next.evidence } : null,
|
|
@@ -1673,6 +1811,7 @@ module.exports = {
|
|
|
1673
1811
|
historyRange,
|
|
1674
1812
|
ENFORCED_FROM,
|
|
1675
1813
|
lanesFor, laneNames,
|
|
1814
|
+
forgetDocumentScan,
|
|
1676
1815
|
};
|
|
1677
1816
|
|
|
1678
1817
|
/*
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
type: Reference
|
|
3
3
|
title: "Discoveries and friction log"
|
|
4
4
|
description: "Append-only record of what runs discovered and where the process rubbed."
|
|
5
|
-
generated: {by: process:create-pmos, at: "2026-10-07T01:
|
|
5
|
+
generated: {by: process:create-pmos, at: "2026-10-07T01:43:50Z"}
|
|
6
6
|
status: stable
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -11,11 +11,30 @@ The Initiatives place shows every initiative of the selected repository. Each in
|
|
|
11
11
|
|
|
12
12
|
Press **Roadmap** or **List** at the top to change the view. The workspace keeps your choice in this browser.
|
|
13
13
|
|
|
14
|
-
###
|
|
14
|
+
### Scan repository
|
|
15
15
|
|
|
16
|
-
The workspace reads the stage, the files and the breakdown of each initiative from the repository. It reads them again when an initiative record changes, and at the latest after ten minutes. A change that does not touch a record, for example a pull request merged on the trunk, can show late.
|
|
16
|
+
The workspace reads the stage, the files and the breakdown of each initiative from the repository. It reads them again when an initiative record changes, and at the latest after ten minutes. A change that does not touch a record, for example a new PRD or a pull request merged on the trunk, can show late.
|
|
17
17
|
|
|
18
|
-
To read the repository again now,
|
|
18
|
+
To read the repository again now, press **Scan repository** at the top of the screen. The same action is in the **⋯** menu of an initiative sheet, and in the command menu (⌘K). A toast shows **Scanning the repository…**. When the scan ends, a toast tells you how many initiatives it checked and how many moved, by stage, for example "Checked 42 initiatives. 9 moved: 4 to PRD, 3 to Evaluate, 2 to Handoff." Then the view updates. The scan writes nothing. It works the same on PMOS itself and on a tenant repository.
|
|
19
|
+
|
|
20
|
+
### What the scan finds in a document repository
|
|
21
|
+
|
|
22
|
+
In a document repository (`.pmos-profile` is `pm-artifacts`), the scan reads every document in the repository: Markdown, text, Word, PDF and HTML files that git does not ignore, whether git tracks them yet or not. A document belongs to an initiative when:
|
|
23
|
+
|
|
24
|
+
- its frontmatter names the initiative in `initiative:`, `initiative_id:` or `initiatives:`, or
|
|
25
|
+
- a folder in its path is the initiative's id, for example `docs/checkout/problem-brief.md`, or
|
|
26
|
+
- its file name starts with the initiative's id, for example `checkout-prd.md`. When two ids match, the longer id wins.
|
|
27
|
+
|
|
28
|
+
The file name, then the `type:` in the frontmatter, then the folder names give the document its role:
|
|
29
|
+
|
|
30
|
+
| Role | Words | Step it completes |
|
|
31
|
+
|---|---|---|
|
|
32
|
+
| Evaluation | `eval`, `evaluation`, `review`, `verdict`, `scorecard` | review |
|
|
33
|
+
| Handoff | `handoff`, `hand-off`, `handover` | handed off |
|
|
34
|
+
| Brief | `brief`, `problem`, `idea`, `intake`, `one-pager`, `bug-report` | the brief, for example Problem brief |
|
|
35
|
+
| Draft | `prd`, `spec`, `specification`, `requirements`, `findings`, `plan`, `strategy`, `report`, `decision` | the draft, for example PRD |
|
|
36
|
+
|
|
37
|
+
A rubric or a template is not evidence. Jira keys also complete the handoff: the keys the Jira tab recorded when it pushed the tickets, and a `jira:`, `jira_key:` or `jira_epic:` key in the record. A path that the record names always comes before a found document. A found document never fails a gate. Acceptance is always yours: the scan never records it.
|
|
19
38
|
|
|
20
39
|
## The Roadmap
|
|
21
40
|
|
|
@@ -119,7 +119,7 @@ Every row has a visible ⋯ button. Every control is at least 44 pixels high. No
|
|
|
119
119
|
|
|
120
120
|
You can have the same session open on your phone and your computer. A session has one size, so the screen you type in or tap last sets it. The other screen keeps showing the session and takes the size back as soon as you type in it.
|
|
121
121
|
|
|
122
|
-
On an iPhone or an iPad, the terminal gets smaller when the keyboard opens, so the agent's prompt stays above the keyboard. Tapping the terminal does not zoom the page, and dragging at its top or bottom does not pull or reload the page. A swipe from the left edge of the screen is Safari's Back gesture and leaves the workspace. To avoid that, add the workspace to your Home Screen and open it from there.
|
|
122
|
+
On an iPhone or an iPad, the terminal gets smaller when the keyboard opens, so the agent's prompt stays above the keyboard. Tapping the terminal does not zoom the page, and dragging at its top or bottom does not pull or reload the page. Swipe up or down on the terminal to scroll it, also inside Claude Code or any other program that uses the mouse. A swipe from the left edge of the screen is Safari's Back gesture and leaves the workspace. To avoid that, add the workspace to your Home Screen and open it from there.
|
|
123
123
|
|
|
124
124
|
## The older layout
|
|
125
125
|
|
|
@@ -1274,6 +1274,129 @@ function computeWorkflowDerived(id, opts) {
|
|
|
1274
1274
|
};
|
|
1275
1275
|
}
|
|
1276
1276
|
|
|
1277
|
+
// ── The document scan (scan-documents S1–S4) — what the repository holds for each initiative ─────
|
|
1278
|
+
// A pm-artifacts record often names none of its documents: the PRD, the evaluation and the Jira
|
|
1279
|
+
// tickets sit in the repository and the record was never told. The ladder used to read the record
|
|
1280
|
+
// only, so that work read as not started. This reads the repository once per root (tracked and
|
|
1281
|
+
// untracked, not ignored), joins each document to an initiative by its frontmatter or by the id in
|
|
1282
|
+
// its path, and gives it a role from its name. It is evidence a named path outranks, never a
|
|
1283
|
+
// blocker, and nothing is written back. The cache holds SCAN_TTL_MS; `forgetDocumentScan` (the
|
|
1284
|
+
// workspace's Scan repository) drops it at once.
|
|
1285
|
+
const SCAN_TTL_MS = 30000;
|
|
1286
|
+
const SCAN_MAX_FILES = 20000;
|
|
1287
|
+
const SCAN_EXT = /\.(md|markdown|mdx|txt|docx|pdf|html?)$/i;
|
|
1288
|
+
const SCAN_TEXT = /\.(md|markdown|mdx|txt)$/i;
|
|
1289
|
+
const SCAN_ROLES = [ // first match wins, in this order (S2)
|
|
1290
|
+
['review', ['eval', 'evals', 'evaluation', 'evaluations', 'review', 'reviews', 'verdict', 'scorecard']],
|
|
1291
|
+
['handoff', ['handoff', 'handover', 'hand-off']],
|
|
1292
|
+
['brief', ['brief', 'briefs', 'problem', 'idea', 'ideas', 'intake', 'one-pager', 'onepager', 'bug-report']],
|
|
1293
|
+
['draft', ['prd', 'prds', 'spec', 'specs', 'specification', 'requirements', 'findings', 'plan', 'strategy', 'report', 'decision']],
|
|
1294
|
+
];
|
|
1295
|
+
const SCAN_NOT_EVIDENCE = ['rubric', 'rubrics', 'template', 'templates'];
|
|
1296
|
+
const JIRA_KEY = /^[A-Z][A-Z0-9_]+-\d+$/;
|
|
1297
|
+
const _scanCache = new Map(); // root -> { at, index: Map(id -> { brief:[], draft:[], review:[], handoff:[] }) }
|
|
1298
|
+
function forgetDocumentScan(root) { if (root) _scanCache.delete(path.resolve(root)); else _scanCache.clear(); }
|
|
1299
|
+
|
|
1300
|
+
function scanWords(s) {
|
|
1301
|
+
const lower = String(s || '').toLowerCase();
|
|
1302
|
+
const words = lower.split(/[^a-z0-9]+/).filter(Boolean);
|
|
1303
|
+
// The hyphenated role words are matched as joined pairs too: "hand-off", "one-pager", "bug-report".
|
|
1304
|
+
const n = words.length;
|
|
1305
|
+
for (let i = 0; i + 1 < n; i++) words.push(`${words[i]}-${words[i + 1]}`);
|
|
1306
|
+
return words;
|
|
1307
|
+
}
|
|
1308
|
+
function roleOfWords(words, extra) {
|
|
1309
|
+
const set = new Set(words);
|
|
1310
|
+
for (const [role, list] of SCAN_ROLES) {
|
|
1311
|
+
if (list.some((w) => set.has(w))) return role;
|
|
1312
|
+
if (role === 'draft' && extra && extra.some((w) => set.has(w))) return role;
|
|
1313
|
+
}
|
|
1314
|
+
return null;
|
|
1315
|
+
}
|
|
1316
|
+
/** The role of a found document: its file name, then its frontmatter `type:`, then its folders (S2). */
|
|
1317
|
+
function documentRole(rel, type, ids) {
|
|
1318
|
+
const parts = rel.split('/');
|
|
1319
|
+
const base = parts[parts.length - 1].replace(/\.[^.]+$/, '');
|
|
1320
|
+
// The id is not a role word: "problem-reporting-prd" is a PRD of the initiative problem-reporting.
|
|
1321
|
+
const strip = (s) => { let out = s.toLowerCase(); for (const id of ids || []) out = out.split(id.toLowerCase()).join(' '); return out; };
|
|
1322
|
+
// A rubric or a template says what good looks like; it is not evidence that the work was judged or done.
|
|
1323
|
+
if (scanWords(base).some((x) => SCAN_NOT_EVIDENCE.includes(x))) return null;
|
|
1324
|
+
return roleOfWords(scanWords(strip(base))) || roleOfWords(scanWords(type))
|
|
1325
|
+
|| roleOfWords(scanWords(strip(parts.slice(0, -1).join('/'))));
|
|
1326
|
+
}
|
|
1327
|
+
/** The initiatives a document belongs to (S1): its frontmatter names them, else the longest id that is a folder or leads the file name. */
|
|
1328
|
+
function documentOwners(rel, fm, idSet, byLength) {
|
|
1329
|
+
const named = [fm.initiative, fm.initiative_id, fm.initiatives].filter(Boolean)
|
|
1330
|
+
.flatMap((v) => String(v).replace(/^\[|\]$/g, '').split(',')).map((s) => s.trim().replace(/^["']|["']$/g, ''))
|
|
1331
|
+
.filter((s) => idSet.has(s));
|
|
1332
|
+
if (named.length) return [...new Set(named)];
|
|
1333
|
+
const parts = rel.toLowerCase().split('/');
|
|
1334
|
+
const base = parts[parts.length - 1].replace(/\.[^.]+$/, '');
|
|
1335
|
+
const dirs = parts.slice(0, -1);
|
|
1336
|
+
for (const id of byLength) {
|
|
1337
|
+
if (id.length < 3) break;
|
|
1338
|
+
const low = id.toLowerCase();
|
|
1339
|
+
if (dirs.includes(low)) return [id];
|
|
1340
|
+
if (base === low || base.startsWith(low + '-') || base.startsWith(low + '_') || base.startsWith(low + '.')) return [id];
|
|
1341
|
+
}
|
|
1342
|
+
return [];
|
|
1343
|
+
}
|
|
1344
|
+
/** Every record id, read from the records folder (the scan needs them all to pick the longest owner). */
|
|
1345
|
+
function recordIds(L) {
|
|
1346
|
+
try { return fs.readdirSync(L.initiatives).filter((f) => f.endsWith('.md') && !/^(README|index|log)\.md$/i.test(f)).map((f) => f.slice(0, -3)); }
|
|
1347
|
+
catch { return []; }
|
|
1348
|
+
}
|
|
1349
|
+
function scanRepository(root, ids, L) {
|
|
1350
|
+
const key = path.resolve(root);
|
|
1351
|
+
const hit = _scanCache.get(key);
|
|
1352
|
+
if (hit && Date.now() - hit.at < SCAN_TTL_MS && ids.every((id) => hit.ids.has(id))) return hit.index;
|
|
1353
|
+
const index = new Map();
|
|
1354
|
+
const idSet = new Set(ids);
|
|
1355
|
+
const byLength = ids.slice().sort((a, b) => b.length - a.length);
|
|
1356
|
+
const out = git(root, ['ls-files', '-z', '--cached', '--others', '--exclude-standard']);
|
|
1357
|
+
const files = out == null ? [] : [...new Set(out.split('\0').filter(Boolean))].slice(0, SCAN_MAX_FILES);
|
|
1358
|
+
const recDir = path.relative(root, L.initiatives).split(path.sep).join('/') + '/';
|
|
1359
|
+
for (const rel of files) {
|
|
1360
|
+
if (!SCAN_EXT.test(rel) || rel.startsWith(recDir) || /(^|\/)(node_modules|\.git)\//.test(rel)) continue;
|
|
1361
|
+
let fm = {};
|
|
1362
|
+
if (SCAN_TEXT.test(rel)) {
|
|
1363
|
+
try {
|
|
1364
|
+
const fd = fs.openSync(path.join(root, rel), 'r');
|
|
1365
|
+
const buf = Buffer.alloc(4096);
|
|
1366
|
+
const n = fs.readSync(fd, buf, 0, 4096, 0);
|
|
1367
|
+
fs.closeSync(fd);
|
|
1368
|
+
fm = parseLeadingFrontmatter(buf.slice(0, n).toString('utf8')) || {};
|
|
1369
|
+
} catch { continue; }
|
|
1370
|
+
}
|
|
1371
|
+
const owners = documentOwners(rel, fm, idSet, byLength);
|
|
1372
|
+
if (!owners.length) continue;
|
|
1373
|
+
const role = documentRole(rel, fm.type, owners);
|
|
1374
|
+
if (!role) continue;
|
|
1375
|
+
for (const id of owners) {
|
|
1376
|
+
if (!index.has(id)) index.set(id, { brief: [], draft: [], review: [], handoff: [] });
|
|
1377
|
+
index.get(id)[role].push(rel);
|
|
1378
|
+
}
|
|
1379
|
+
}
|
|
1380
|
+
for (const v of index.values()) for (const k of Object.keys(v)) v[k].sort();
|
|
1381
|
+
_scanCache.set(key, { at: Date.now(), ids: idSet, index });
|
|
1382
|
+
return index;
|
|
1383
|
+
}
|
|
1384
|
+
/** Jira keys the repository holds for an initiative (S4): the Jira tab's draft, then the record's own keys. */
|
|
1385
|
+
function jiraKeysOf(root, L, id, fm) {
|
|
1386
|
+
const keys = [];
|
|
1387
|
+
const draft = path.join(path.dirname(L.initiatives), 'tracker', 'jira', `${id}-tickets.md`);
|
|
1388
|
+
try {
|
|
1389
|
+
for (const line of fs.readFileSync(draft, 'utf8').split('\n')) {
|
|
1390
|
+
const m = /^- jira:\s*(\S+)\s*$/.exec(line);
|
|
1391
|
+
if (m && JIRA_KEY.test(m[1].toUpperCase())) keys.push(m[1].toUpperCase());
|
|
1392
|
+
}
|
|
1393
|
+
} catch { /* no draft */ }
|
|
1394
|
+
for (const k of ['jira', 'jira_key', 'jira_keys', 'jira_epic']) {
|
|
1395
|
+
for (const m of String(fm[k] || '').toUpperCase().matchAll(/\b([A-Z][A-Z0-9_]+-\d+)\b/g)) keys.push(m[1]);
|
|
1396
|
+
}
|
|
1397
|
+
return [...new Set(keys)];
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1277
1400
|
/**
|
|
1278
1401
|
* D68 — the pm-artifacts ladder: route → anchor → brief → draft → review → approved → handed off.
|
|
1279
1402
|
*
|
|
@@ -1286,6 +1409,9 @@ function computeWorkflowDerived(id, opts) {
|
|
|
1286
1409
|
* review — an evals.jsonl verdict for this id, or a named `reviewed_by:` (a rubric, an agent, a human)
|
|
1287
1410
|
* approved — the PM's acceptance in acceptances.jsonl (D12, never automatic)
|
|
1288
1411
|
* handoff — `handed_off:` names where it went; n/a for decision records, reports and chores
|
|
1412
|
+
* When the record names no file for brief, draft or review, or names one that does not exist, a
|
|
1413
|
+
* document the repository scan found for the id completes the step (scan-documents S3); Jira keys
|
|
1414
|
+
* in the repository complete the handoff (S4). Found evidence never blocks and never fails CI.
|
|
1289
1415
|
* Track: `light` (guidance — only route and anchor can fail CI) or `governed` (brief and draft
|
|
1290
1416
|
* existence and their order fail CI for a non-legacy record). Review, approval and handoff are never
|
|
1291
1417
|
* CI-blocking in either track: none can be true before the PR exists.
|
|
@@ -1307,12 +1433,17 @@ function pmLadder({ id, fm, lane, effLane, knownLane, legacy, steps, root, L, re
|
|
|
1307
1433
|
: step('route', 'route', 'done', `unknown lane '${lane}' — treated as a governed prd, the profile's strictest (D68)`,
|
|
1308
1434
|
`set lane: to one of ${laneNames('pm-artifacts').join(', ')}`);
|
|
1309
1435
|
|
|
1436
|
+
const found = (opts && opts.scan === false) ? null : (scanRepository(root, recordIds(L).concat(id), L).get(id) || null);
|
|
1437
|
+
const foundOf = (role) => (found && found[role].length ? found[role][0] : null);
|
|
1310
1438
|
const briefPath = String(fm.brief_path || fm.prd_path || '').trim();
|
|
1311
1439
|
const docPath = String(fm.doc_path || '').trim();
|
|
1440
|
+
const foundBrief = exists(briefPath) ? null : foundOf('brief');
|
|
1441
|
+
const foundDraft = exists(docPath) ? null : foundOf('draft');
|
|
1312
1442
|
const briefIdx = briefPath ? firstAppearance(root, range, briefPath) : null;
|
|
1313
1443
|
const docIdx = docPath ? firstAppearance(root, range, docPath) : null;
|
|
1314
1444
|
|
|
1315
1445
|
if (!flows('brief')) steps.push(skipped('brief', def.brief_label));
|
|
1446
|
+
else if (foundBrief) steps.push(step('brief', def.brief_label, 'done', `${foundBrief} — found by the repository scan`));
|
|
1316
1447
|
else if (exists(briefPath)) steps.push(step('brief', def.brief_label, 'done', `${briefPath} present`));
|
|
1317
1448
|
else steps.push(step('brief', def.brief_label, enforce ? 'blocked' : 'pending',
|
|
1318
1449
|
briefPath ? `brief_path '${briefPath}' does not exist` : `no ${def.brief_label} yet`,
|
|
@@ -1320,6 +1451,7 @@ function pmLadder({ id, fm, lane, effLane, knownLane, legacy, steps, root, L, re
|
|
|
1320
1451
|
{ blocking: enforce, rule: 'P1' }));
|
|
1321
1452
|
|
|
1322
1453
|
if (!flows('draft')) steps.push(skipped('draft', def.draft_label));
|
|
1454
|
+
else if (foundDraft) steps.push(step('draft', def.draft_label, 'done', `${foundDraft} — found by the repository scan`));
|
|
1323
1455
|
else if (exists(docPath)) {
|
|
1324
1456
|
const inOrder = briefIdx == null || docIdx == null ? null : docIdx >= briefIdx;
|
|
1325
1457
|
if (enforce && inOrder === false) {
|
|
@@ -1335,6 +1467,7 @@ function pmLadder({ id, fm, lane, effLane, knownLane, legacy, steps, root, L, re
|
|
|
1335
1467
|
if (!flows('review')) steps.push(skipped('review', 'review'));
|
|
1336
1468
|
else if (evals.length) steps.push(step('review', 'review', 'done', `${evals[evals.length - 1].verdict || 'verdict'} recorded in evals.jsonl`));
|
|
1337
1469
|
else if (reviewer) steps.push(step('review', 'review', 'done', `reviewed by ${reviewer}`));
|
|
1470
|
+
else if (foundOf('review')) steps.push(step('review', 'review', 'done', `${foundOf('review')} — evaluation found by the repository scan`));
|
|
1338
1471
|
else steps.push(step('review', 'review', 'pending', 'not reviewed yet',
|
|
1339
1472
|
'name the reviewer in reviewed_by: (a person, an agent, or a rubric verdict in evals.jsonl)'));
|
|
1340
1473
|
|
|
@@ -1359,7 +1492,10 @@ function pmLadder({ id, fm, lane, effLane, knownLane, legacy, steps, root, L, re
|
|
|
1359
1492
|
if (!flows('handoff')) steps.push(step('handoff', 'handed off', 'na', `${effLane} — nothing to hand off`));
|
|
1360
1493
|
else {
|
|
1361
1494
|
const to = String(fm.handed_off || '').trim();
|
|
1495
|
+
const keys = to ? [] : jiraKeysOf(root, L, id, fm);
|
|
1362
1496
|
steps.push(to ? step('handoff', 'handed off', 'done', `handed off — ${to}`)
|
|
1497
|
+
: keys.length ? step('handoff', 'handed off', 'done', `handed off to Jira — ${keys.slice(0, 5).join(', ')}${keys.length > 5 ? ` and ${keys.length - 5} more` : ''}`)
|
|
1498
|
+
: foundOf('handoff') ? step('handoff', 'handed off', 'done', `${foundOf('handoff')} — handoff found by the repository scan`)
|
|
1363
1499
|
: step('handoff', 'handed off', 'pending', 'not handed off yet',
|
|
1364
1500
|
'record where it went in handed_off: (a tracker link, the team, or "accepted as the deliverable")'));
|
|
1365
1501
|
}
|
|
@@ -1370,6 +1506,7 @@ function pmLadder({ id, fm, lane, effLane, knownLane, legacy, steps, root, L, re
|
|
|
1370
1506
|
if (by.brief.status === 'done') stage = 'brief';
|
|
1371
1507
|
if (by.draft.status === 'done') stage = 'draft';
|
|
1372
1508
|
if (by.review.status === 'done') stage = 'review';
|
|
1509
|
+
if (by.handoff && by.handoff.status === 'done') stage = 'handoff';
|
|
1373
1510
|
if (accepted.length) stage = 'accepted';
|
|
1374
1511
|
const blockers = steps.filter((x) => x.status === 'blocked');
|
|
1375
1512
|
const gateFailures = blockers.filter((x) => x.blocking);
|
|
@@ -1382,6 +1519,7 @@ function pmLadder({ id, fm, lane, effLane, knownLane, legacy, steps, root, L, re
|
|
|
1382
1519
|
ok: true, id, lane, laneKnown: knownLane, legacy, in_change: inChange,
|
|
1383
1520
|
last_activity: lastActivity, accepted: accepted.length > 0, stage, record: recRel,
|
|
1384
1521
|
title: fm.title || id, anchor: fm.parent_kr || null, profile: 'pm-artifacts', track,
|
|
1522
|
+
found: found ? { brief: found.brief.slice(), draft: found.draft.slice(), review: found.review.slice(), handoff: found.handoff.slice() } : null,
|
|
1385
1523
|
...facts,
|
|
1386
1524
|
steps, blockers: blockers.map((x) => x.key), gate_failures: gateFailures.map((x) => x.rule || x.key),
|
|
1387
1525
|
next: next ? { step: next.key, why: next.evidence, action: next.next || next.evidence } : null,
|
|
@@ -1673,6 +1811,7 @@ module.exports = {
|
|
|
1673
1811
|
historyRange,
|
|
1674
1812
|
ENFORCED_FROM,
|
|
1675
1813
|
lanesFor, laneNames,
|
|
1814
|
+
forgetDocumentScan,
|
|
1676
1815
|
};
|
|
1677
1816
|
|
|
1678
1817
|
/*
|
|
@@ -73,6 +73,8 @@ function narrow(root, raw) {
|
|
|
73
73
|
last_activity: iso(r.last_activity), profile: r.profile || null, record: r.record || null, next: r.next || null,
|
|
74
74
|
// key and status of each ladder step, so a row's stage is read by the same rule as the sheet's stepper
|
|
75
75
|
steps: Array.isArray(r.steps) ? r.steps.filter(Boolean).map((x) => ({ key: x.key, status: x.status })) : null,
|
|
76
|
+
// the documents the repository scan found for it, by role (scan-documents S1–S3); null in a code repository
|
|
77
|
+
found: r.found && typeof r.found === 'object' ? r.found : null,
|
|
76
78
|
} : { ok: false, id: r && r.id, error: (r && r.error) || 'the row could not be derived' }));
|
|
77
79
|
}
|
|
78
80
|
|
|
@@ -278,8 +278,10 @@ function sheetOf(w, F, rec, opts = {}) {
|
|
|
278
278
|
const file = planTree.recordPath(root, rec.id);
|
|
279
279
|
if (loaded.ok && file) fm = loaded.engine.parseLeadingFrontmatter(fs.readFileSync(file, 'utf8')) || {};
|
|
280
280
|
} catch { fm = {}; }
|
|
281
|
-
|
|
282
|
-
const
|
|
281
|
+
// the sheet's own ladder is fresher than the row cache: its scan finds a document written since the rows were read
|
|
282
|
+
const recF = lad && lad.ok && lad.found ? { ...rec, found: lad.found } : rec;
|
|
283
|
+
const files = filesOf(root, recF, fm, opts);
|
|
284
|
+
const derived = derivedBreakdown(root, recF, lay);
|
|
283
285
|
const verb = { now: 'Moved to Now', next: 'Moved to Next', later: 'Moved to Later' };
|
|
284
286
|
const history = F.placed.moves.filter((m) => m.init === rec.id)
|
|
285
287
|
.map((m) => ({ at: m.at, by: actorLib.pageName(m.by), text: m.from === null && m.to === 'later' ? 'Added to Later' : verb[m.to] }))
|
|
@@ -295,6 +297,7 @@ function sheetOf(w, F, rec, opts = {}) {
|
|
|
295
297
|
* git the files the commits for it changed (lib/initiative-files.js: its branch, and trunk commits naming it)
|
|
296
298
|
* A path is listed once, under its first source. A git read that fails is a `{ kind: 'note' }` row, never an empty list.
|
|
297
299
|
*/
|
|
300
|
+
const FOUND_ROLES = [['brief', 'Brief'], ['draft', 'Draft'], ['review', 'Evaluation'], ['handoff', 'Handoff']];
|
|
298
301
|
function filesOf(root, rec, fm, opts) {
|
|
299
302
|
const out = [];
|
|
300
303
|
const seen = new Set();
|
|
@@ -316,6 +319,8 @@ function filesOf(root, rec, fm, opts) {
|
|
|
316
319
|
push({ kind, label: cap(kind.replace(/_/g, ' ')), path: v.trim(), source: 'record' });
|
|
317
320
|
}
|
|
318
321
|
if (rec.record) push({ kind: 'record', label: 'Record', path: String(rec.record), source: 'record' });
|
|
322
|
+
// The documents the repository scan tied to it (scan-documents S1–S3), when the record names none of them.
|
|
323
|
+
for (const [role, label] of FOUND_ROLES) for (const rel of (rec.found && rec.found[role]) || []) push({ kind: role, label, path: String(rel), source: 'scan' });
|
|
319
324
|
let groups = [];
|
|
320
325
|
try { groups = opts && opts.initiativeIndex ? (opts.initiativeIndex(root).initiatives || []) : []; } catch { groups = []; }
|
|
321
326
|
const g = groups.find((x) => x && x.id === rec.id);
|
|
@@ -397,8 +402,11 @@ function derivedBreakdown(root, rec, lay) {
|
|
|
397
402
|
if (st && st.ok && st.items.length) { items = st.items.map((x) => ({ ref: x.id, title: firstSentence(x.title) })); source = st.sources.join(', '); }
|
|
398
403
|
} catch { items = []; }
|
|
399
404
|
if (!items.length) {
|
|
400
|
-
|
|
401
|
-
|
|
405
|
+
// the paths the record names, then the documents the repository scan found (scan-documents S6)
|
|
406
|
+
const found = rec.found || {};
|
|
407
|
+
const cands = ['prd_path', 'doc_path', 'brief_path'].map((k) => rfm[k]).concat(found.draft || [], found.brief || []);
|
|
408
|
+
for (const c of cands) {
|
|
409
|
+
const rel = typeof c === 'string' ? c.trim() : '';
|
|
402
410
|
if (!rel) continue;
|
|
403
411
|
let text = null;
|
|
404
412
|
try { text = fs.readFileSync(path.join(root, rel), 'utf8'); } catch { text = null; }
|
|
@@ -526,6 +534,7 @@ async function handle(req, res, url, ctx) {
|
|
|
526
534
|
const g = guard.requirePagePress(req, body);
|
|
527
535
|
if (!g.ok) return answer(ctx, res, g), true;
|
|
528
536
|
initiativeFiles.forget(w.root);
|
|
537
|
+
try { const loaded = consoleState.loadEngine(); if (loaded.ok && loaded.engine.forgetDocumentScan) loaded.engine.forgetDocumentScan(w.root); } catch { /* an older engine has no scan cache */ }
|
|
529
538
|
const r = rowsLib.rescan(w.root);
|
|
530
539
|
if (ctx.invalidateNeedsAgg) ctx.invalidateNeedsAgg(w.id);
|
|
531
540
|
ctx.json(res, { ok: true, ws: w.id, started: r.started, running: r.running });
|
package/workspace/package.json
CHANGED
|
@@ -108,6 +108,8 @@ function draw(host, r, ctx, repo, ws) {
|
|
|
108
108
|
const head = el('div', { class: 'in-head' },
|
|
109
109
|
el('h2', { class: 'in-head__title' }, COPY.title),
|
|
110
110
|
tabs({ variant: 'segmented', role: 'tablist', ariaLabel: COPY.views, items: views, value: r.view, attrs: { 'data-in-views': '' }, onChange: (id) => { r.view = id; saveView(id); r.draw(r.ctx); } }),
|
|
111
|
+
// Scan repository (scan-documents S7): the visible form of the sheet menu's and ⌘K's re-scan. It needs a workspace.
|
|
112
|
+
ws ? button({ label: COPY.rescan, icon: 'refresh-cw', attrs: { 'data-in-scan': '', title: COPY.rescanHint }, onClick: () => rescanRepo(ws) }) : null,
|
|
111
113
|
button({ label: COPY.newInitiative, icon: 'plus', variant: 'primary', attrs: { 'data-in-new': '' }, onClick: () => r.openNew() }));
|
|
112
114
|
r.body = el('div', { class: 'in-body', 'data-in-body': r.view });
|
|
113
115
|
r.root.replaceChildren(head, r.body);
|
|
@@ -272,9 +272,10 @@ export function openSheet(repo, slug) {
|
|
|
272
272
|
if (tab === 'people') { panel.replaceChildren(initiativePeople(ws, slug, { onOpen: () => s.close() })); return; }
|
|
273
273
|
if (tab === 'files') {
|
|
274
274
|
const files = data.files || [];
|
|
275
|
-
// Grouped by where the tie came from (scan-consistency R3): the record's own paths, the documents
|
|
275
|
+
// Grouped by where the tie came from (scan-consistency R3, scan-documents S3): the record's own paths, the documents
|
|
276
|
+
// the repository scan found, the documents the library's
|
|
276
277
|
// index links to it, and the files git changed for it. A `note` row is a fact about the list, not a file.
|
|
277
|
-
const order = ['record', 'knowledge-graph', 'git'];
|
|
278
|
+
const order = ['record', 'scan', 'knowledge-graph', 'git'];
|
|
278
279
|
const groups = order.map((src) => [src, files.filter((f) => (f.source || 'record') === src)]).filter(([, list]) => list.length);
|
|
279
280
|
const fileRow = (f) => (f.kind === 'note' ? el('p', { class: 'in-note', 'data-in-file-note': '' }, f.label) : row({
|
|
280
281
|
title: f.label, meta: [f.path, f.commits ? COPY.sheet.commits(f.commits) : null, f.shared ? COPY.sheet.shared : null], openLabel: COPY.aria.open(f.label),
|
|
@@ -51,13 +51,16 @@ export const drop = (ws, proposal) => pmPost('/api/placements', { ws, events: [{
|
|
|
51
51
|
/** Read the repository again now. Answers when the new derivation has landed (the records part of the sig stops reading
|
|
52
52
|
* `~stale`), or { ok: true, slow: true } when it is still running after about three minutes. */
|
|
53
53
|
export async function rescan(ws) {
|
|
54
|
+
const roadmap = () => pmGet('/api/initiatives?ws=' + enc(ws) + '&view=roadmap');
|
|
55
|
+
// the answer before the scan, so the caller can say what moved (scan-documents S7)
|
|
56
|
+
const before = await roadmap();
|
|
54
57
|
const a = await pmPost('/api/initiatives/rescan', { ws });
|
|
55
58
|
if (!a || !a.ok) return a || { ok: false, error: 'no answer' };
|
|
56
59
|
for (let k = 0; k < 120; k++) {
|
|
57
60
|
await sleep(1500);
|
|
58
|
-
const r = await
|
|
61
|
+
const r = await roadmap();
|
|
59
62
|
const rec = r && r.sig ? String(r.sig.records || '') : '';
|
|
60
|
-
if (r && r.ok && rec && !rec.endsWith('~stale')) return { ok: true };
|
|
63
|
+
if (r && r.ok && rec && !rec.endsWith('~stale')) return { ok: true, before: before && before.ok ? before : null, after: r };
|
|
61
64
|
}
|
|
62
65
|
return { ok: true, slow: true };
|
|
63
66
|
}
|
|
@@ -28,9 +28,16 @@ export const COPY = {
|
|
|
28
28
|
roadmap: 'Roadmap',
|
|
29
29
|
list: 'List',
|
|
30
30
|
newInitiative: 'New initiative',
|
|
31
|
-
rescan: '
|
|
32
|
-
rescanning: '
|
|
33
|
-
rescanHint: '
|
|
31
|
+
rescan: 'Scan repository',
|
|
32
|
+
rescanning: 'Scanning the repository…',
|
|
33
|
+
rescanHint: 'Check every initiative against the repository and move it to its real stage',
|
|
34
|
+
// scan-documents S7: what the scan found, in one sentence — "Checked 42 initiatives. 9 moved: 4 to PRD, 3 to Evaluate."
|
|
35
|
+
rescanResult: (checked, moves) => {
|
|
36
|
+
const n = moves.reduce((a, m) => a + m.count, 0);
|
|
37
|
+
const head = `Checked ${checked} initiative${checked === 1 ? '' : 's'}.`;
|
|
38
|
+
if (!n) return `${head} None moved.`;
|
|
39
|
+
return `${head} ${n} moved: ${moves.map((m) => `${m.count} to ${m.label}`).join(', ')}.`;
|
|
40
|
+
},
|
|
34
41
|
rescanDone: (t) => `Read again at ${t}`,
|
|
35
42
|
rescanFailed: (why) => `Could not re-scan: ${why}`,
|
|
36
43
|
rescanSlow: 'The re-scan is still running. The view updates when it ends.',
|
|
@@ -142,7 +149,7 @@ export const COPY = {
|
|
|
142
149
|
graphReading: 'Reading the links…',
|
|
143
150
|
graphError: 'The links could not be read. The list is below.',
|
|
144
151
|
graphPhone: 'On a small screen the files show as a list.',
|
|
145
|
-
fileSource: { record: 'Named by the record', 'knowledge-graph': 'Linked documents', git: 'Changed for it in git' },
|
|
152
|
+
fileSource: { record: 'Named by the record', scan: 'Found by the repository scan', 'knowledge-graph': 'Linked documents', git: 'Changed for it in git' },
|
|
146
153
|
commits: (n) => (n === 1 ? '1 commit' : `${n} commits`),
|
|
147
154
|
shared: 'shared',
|
|
148
155
|
skippedStep: (noun) => `Skipped: ${noun} is still open`,
|
|
@@ -26,14 +26,38 @@ export function healthSignal(health) {
|
|
|
26
26
|
return signal({ text: top.text || COPY.healthUnknown, tone, title: top.source || null, attrs: { 'data-health': health.level } });
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
/** The rows of a roadmap answer: every horizon, and the dormant ones. */
|
|
30
|
+
const rowsOf = (answer) => (answer && answer.horizons
|
|
31
|
+
? [...(answer.horizons.now || []), ...(answer.horizons.next || []), ...(answer.horizons.later || []), ...(answer.dormant || [])] : []);
|
|
32
|
+
/**
|
|
33
|
+
* What a scan changed (scan-documents S7): how many initiatives the answer after the scan holds, and how many now read a
|
|
34
|
+
* different stage than before, grouped by the stage they moved to, largest group first. null when there is no answer
|
|
35
|
+
* from before to compare with.
|
|
36
|
+
*/
|
|
37
|
+
export function scanMoves(before, after) {
|
|
38
|
+
if (!before) return null;
|
|
39
|
+
const was = new Map(rowsOf(before).filter((r) => r && r.ok !== false).map((r) => [r.id, (r.stage && r.stage.label) || null]));
|
|
40
|
+
const rows = rowsOf(after).filter((r) => r && r.ok !== false);
|
|
41
|
+
const by = new Map();
|
|
42
|
+
for (const r of rows) {
|
|
43
|
+
const now = (r.stage && r.stage.label) || null;
|
|
44
|
+
if (!now || !was.has(r.id) || was.get(r.id) === now) continue;
|
|
45
|
+
by.set(now, (by.get(now) || 0) + 1);
|
|
46
|
+
}
|
|
47
|
+
const moves = [...by].map(([label, count]) => ({ label, count })).sort((x, y) => y.count - x.count || x.label.localeCompare(y.label));
|
|
48
|
+
return { checked: rows.length, moves };
|
|
49
|
+
}
|
|
50
|
+
|
|
29
51
|
/** Re-scan: read the repository again now. A toast says it started and another says how it ended. Answers the rescan
|
|
30
52
|
* answer; the caller reloads what it shows when `ok`. */
|
|
31
53
|
export async function rescanRepo(ws) {
|
|
32
54
|
toast({ text: COPY.rescanning, timeout: 4000 });
|
|
33
55
|
const a = await rescan(ws);
|
|
34
56
|
const t = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
|
57
|
+
const moved = a && a.ok && a.after ? scanMoves(a.before, a.after) : null;
|
|
35
58
|
if (!a || !a.ok) toast({ text: COPY.rescanFailed((a && a.error) || 'no answer'), tone: 'error', timeout: 8000 });
|
|
36
|
-
else toast({ text:
|
|
59
|
+
else if (a.slow) toast({ text: COPY.rescanSlow, timeout: 6000 });
|
|
60
|
+
else toast({ text: moved ? COPY.rescanResult(moved.checked, moved.moves) : COPY.rescanDone(t), timeout: 10000 });
|
|
37
61
|
try { if (a && a.ok) window.dispatchEvent(new CustomEvent('pmos:initiatives-changed')); } catch { /* no window */ }
|
|
38
62
|
return a || { ok: false };
|
|
39
63
|
}
|
|
@@ -915,9 +915,78 @@ function buildReal(tab) {
|
|
|
915
915
|
rec.ro = new ResizeObserver(() => refitVisible());
|
|
916
916
|
try { rec.ro.observe(host); } catch { /* ignore */ }
|
|
917
917
|
}
|
|
918
|
+
touchScroll(rec);
|
|
918
919
|
return rec;
|
|
919
920
|
}
|
|
920
921
|
|
|
922
|
+
/* ── a swipe scrolls ───────────────────────────────────────────────────────────────────────────
|
|
923
|
+
* xterm scrolls on a touch drag only while no program has asked for the mouse. A program that has
|
|
924
|
+
* (Claude Code, tmux, an editor) gets nothing from a swipe, because xterm ignores touch then and a
|
|
925
|
+
* touch is not a wheel. On an iPhone or iPad a swipe was therefore dead in exactly the sessions
|
|
926
|
+
* that matter. A vertical drag on the pane is turned into wheel events instead, and xterm does with
|
|
927
|
+
* them what it does with a mouse wheel: it scrolls its own scrollback, or it reports the wheel to
|
|
928
|
+
* the program that asked for the mouse, or it sends arrow keys in a full-screen program that did
|
|
929
|
+
* not. The finger's speed carries on for a moment after it lifts, as a native scroll does. A tap
|
|
930
|
+
* is left alone, so it still focuses the terminal and raises the keyboard. */
|
|
931
|
+
const TOUCH_SLOP = 6; // px a finger moves before a touch is a swipe rather than a tap
|
|
932
|
+
const MOMENTUM_DECAY = 0.95; // the carried speed kept per frame after the finger lifts
|
|
933
|
+
|
|
934
|
+
function touchScroll(rec) {
|
|
935
|
+
const { host, term } = rec;
|
|
936
|
+
let t = null; // the swipe in progress: { x, y, lastY, lastT, v, swiping }
|
|
937
|
+
let glide = 0; // the momentum frame, if one is queued
|
|
938
|
+
|
|
939
|
+
const wheel = (dy, x, y) => {
|
|
940
|
+
const target = (term.element && term.element.querySelector('.xterm-screen')) || term.element;
|
|
941
|
+
if (!target || !dy) return;
|
|
942
|
+
target.dispatchEvent(new WheelEvent('wheel', {
|
|
943
|
+
deltaY: dy, deltaMode: 0, clientX: x, clientY: y, bubbles: true, cancelable: true,
|
|
944
|
+
}));
|
|
945
|
+
};
|
|
946
|
+
const stopGlide = () => { if (glide) { cancelAnimationFrame(glide); glide = 0; } };
|
|
947
|
+
|
|
948
|
+
host.addEventListener('touchstart', (e) => {
|
|
949
|
+
stopGlide();
|
|
950
|
+
if (e.touches.length !== 1) { t = null; return; }
|
|
951
|
+
const p = e.touches[0];
|
|
952
|
+
t = { x: p.clientX, y: p.clientY, lastY: p.clientY, lastT: e.timeStamp, v: 0, swiping: false };
|
|
953
|
+
}, { capture: true, passive: true });
|
|
954
|
+
|
|
955
|
+
host.addEventListener('touchmove', (e) => {
|
|
956
|
+
if (!t || e.touches.length !== 1) return;
|
|
957
|
+
const p = e.touches[0];
|
|
958
|
+
if (!t.swiping) {
|
|
959
|
+
const dx = p.clientX - t.x, dy = p.clientY - t.y;
|
|
960
|
+
if (Math.abs(dy) < TOUCH_SLOP || Math.abs(dy) < Math.abs(dx)) return;
|
|
961
|
+
t.swiping = true;
|
|
962
|
+
}
|
|
963
|
+
// Ours from here: xterm's own touch handling and the page's scroll do not also see it.
|
|
964
|
+
e.preventDefault();
|
|
965
|
+
e.stopPropagation();
|
|
966
|
+
const dy = t.lastY - p.clientY; // finger up = content up = wheel down
|
|
967
|
+
const dt = Math.max(1, e.timeStamp - t.lastT);
|
|
968
|
+
t.v = 0.8 * (dy / dt) + 0.2 * t.v;
|
|
969
|
+
t.lastY = p.clientY; t.lastT = e.timeStamp;
|
|
970
|
+
wheel(dy, p.clientX, p.clientY);
|
|
971
|
+
}, { capture: true, passive: false });
|
|
972
|
+
|
|
973
|
+
const end = () => {
|
|
974
|
+
if (!t || !t.swiping) { t = null; return; }
|
|
975
|
+
let v = t.v * 16; // px per frame
|
|
976
|
+
const x = t.x, y = t.lastY;
|
|
977
|
+
t = null;
|
|
978
|
+
const step = () => {
|
|
979
|
+
v *= MOMENTUM_DECAY;
|
|
980
|
+
if (Math.abs(v) < 0.5) { glide = 0; return; }
|
|
981
|
+
wheel(v, x, y);
|
|
982
|
+
glide = requestAnimationFrame(step);
|
|
983
|
+
};
|
|
984
|
+
if (Math.abs(v) >= 0.5) glide = requestAnimationFrame(step);
|
|
985
|
+
};
|
|
986
|
+
host.addEventListener('touchend', end, { capture: true, passive: true });
|
|
987
|
+
host.addEventListener('touchcancel', end, { capture: true, passive: true });
|
|
988
|
+
}
|
|
989
|
+
|
|
921
990
|
/* ── the exported surface ──────────────────────────────────────────────────────────────────── */
|
|
922
991
|
|
|
923
992
|
/**
|