create-pmos 0.65.3 → 0.67.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/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/run-tests.js +39 -2
- package/workspace/test-lists/scan-documents.json +5 -0
- package/workspace/test-timings.json +172 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-pmos",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.67.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-
|
|
5
|
+
generated: {by: process:create-pmos, at: "2026-10-07T02:01:39Z"}
|
|
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-
|
|
5
|
+
generated: {by: process:create-pmos, at: "2026-10-07T02:01:39Z"}
|
|
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-
|
|
5
|
+
generated: {by: process:create-pmos, at: "2026-10-07T02:01:39Z"}
|
|
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
|
|
|
@@ -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
|
}
|
package/workspace/run-tests.js
CHANGED
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
'use strict';
|
|
3
3
|
/* run-tests.js — run the workspace suites side by side.
|
|
4
4
|
*
|
|
5
|
-
* node run-tests.js <group> [--jobs=N] [--shard=I/N] [--changed[=REF]] [--list] [--verbose]
|
|
5
|
+
* node run-tests.js <group> [--jobs=N] [--shard=I/N] [--changed[=REF]] [--timings-out=FILE] [--list] [--verbose]
|
|
6
6
|
* node run-tests.js --check
|
|
7
|
+
* node run-tests.js --merge-timings=DIR
|
|
7
8
|
*
|
|
8
9
|
* Groups come from test-files.json: unit (test.js + modules), modules, panels, ui, browser
|
|
9
10
|
* (panels + ui), all. The npm scripts call this file, so `npm test` and CI run the same lists.
|
|
@@ -31,6 +32,14 @@
|
|
|
31
32
|
* test-files.json, plus `helpers` (a -test.js file that is a helper, not a suite, with the reason, as
|
|
32
33
|
* in ELSEWHERE). This file merges every fragment into the lists before it does anything else, so
|
|
33
34
|
* --check, --shard and every group see one list. A fragment that is not valid fails every command.
|
|
35
|
+
*
|
|
36
|
+
* Timings. --shard balances the shards by each suite's time, and the shards only finish together when
|
|
37
|
+
* those times are the CI runner's. test-timings.json holds times measured on CI; the `seconds` keys in
|
|
38
|
+
* test-files.json and the fragments are the fallback for a suite it does not name yet, and a suite in
|
|
39
|
+
* neither counts as 10. The `seconds` numbers were measured on a 4-CPU sandbox and were off by up to
|
|
40
|
+
* 4x on the 2-CPU runners, which left one shard running 280s while two others finished in 130s.
|
|
41
|
+
* Every CI job writes its times with --timings-out and uploads them; --merge-timings=DIR folds a
|
|
42
|
+
* downloaded set into test-timings.json. A stale time never skips a suite: it only moves it.
|
|
34
43
|
*/
|
|
35
44
|
const fs = require('fs');
|
|
36
45
|
const os = require('os');
|
|
@@ -98,7 +107,10 @@ const args = process.argv.slice(2);
|
|
|
98
107
|
const flag = (name) => { const a = args.find((x) => x === `--${name}` || x.startsWith(`--${name}=`)); return a === undefined ? undefined : (a.includes('=') ? a.slice(a.indexOf('=') + 1) : true); };
|
|
99
108
|
const group = args.find((a) => !a.startsWith('--'));
|
|
100
109
|
|
|
101
|
-
const
|
|
110
|
+
const TIMINGS_FILE = path.join(DIR, 'test-timings.json');
|
|
111
|
+
let TIMINGS = {};
|
|
112
|
+
try { TIMINGS = JSON.parse(fs.readFileSync(TIMINGS_FILE, 'utf8')).seconds || {}; } catch { /* no CI timings yet: the seconds keys decide */ }
|
|
113
|
+
const secs = (f) => Number(TIMINGS[f]) || Number((FILES.seconds || {})[f]) || 10;
|
|
102
114
|
// Slowest first, so a long suite never starts last and holds the run open on its own.
|
|
103
115
|
const bySlowest = (list) => list.map((f, k) => ({ f, k })).sort((a, b) => secs(b.f) - secs(a.f) || a.k - b.k).map((x) => x.f);
|
|
104
116
|
|
|
@@ -155,6 +167,7 @@ function changedSuites(list, ref) {
|
|
|
155
167
|
for (const f of files) {
|
|
156
168
|
if (source.has(f)) { picked.add(f); continue; }
|
|
157
169
|
if (/^(guide|docs)\/|\.md$/.test(f)) continue; // prose: no suite asserts on it except guide.test.js, a unit suite
|
|
170
|
+
if (f === 'test-timings.json') continue; // run order only: no suite reads it
|
|
158
171
|
const stem = path.basename(f).replace(/\.[^.]+$/, '');
|
|
159
172
|
const key = stem.replace(/^(panel|view|tab)-/, '');
|
|
160
173
|
const hits = list.filter((s) => source.get(s).includes(stem) || s.includes(`-${key}-`) || s.includes(`-${key}.`));
|
|
@@ -207,8 +220,30 @@ function runOne(file, verbose) {
|
|
|
207
220
|
});
|
|
208
221
|
}
|
|
209
222
|
|
|
223
|
+
// Fold the --timings-out files a CI run uploaded (one per job, in DIR) into test-timings.json. A suite
|
|
224
|
+
// the run did not reach keeps its old time; a time under one second is recorded as one.
|
|
225
|
+
function mergeTimings(dir) {
|
|
226
|
+
const old = fs.existsSync(TIMINGS_FILE) ? JSON.parse(fs.readFileSync(TIMINGS_FILE, 'utf8')) : {};
|
|
227
|
+
const seconds = { ...(old.seconds || {}) };
|
|
228
|
+
let n = 0;
|
|
229
|
+
for (const name of fs.readdirSync(dir, { recursive: true }).filter((x) => String(x).endsWith('.json'))) {
|
|
230
|
+
for (const [f, v] of Object.entries(JSON.parse(fs.readFileSync(path.join(dir, String(name)), 'utf8')))) {
|
|
231
|
+
if (typeof v === 'number' && GROUPS.all.includes(f)) { seconds[f] = Math.max(1, Math.round(v)); n++; }
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
const sorted = Object.fromEntries(Object.keys(seconds).filter((f) => GROUPS.all.includes(f)).sort().map((f) => [f, seconds[f]]));
|
|
235
|
+
const out = {
|
|
236
|
+
_comment: old._comment || 'Wall time of each suite on the Workspace CI runners, in seconds. run-tests.js --shard balances the shards with these numbers. Refresh: download the timings-* artifacts of a green Workspace CI run into one directory, then run `node run-tests.js --merge-timings=DIR`.',
|
|
237
|
+
measured: new Date().toISOString().slice(0, 10),
|
|
238
|
+
seconds: sorted,
|
|
239
|
+
};
|
|
240
|
+
fs.writeFileSync(TIMINGS_FILE, `${JSON.stringify(out, null, 2)}\n`);
|
|
241
|
+
console.log(`run-tests: ${n} times merged into test-timings.json (${Object.keys(sorted).length} suites).`);
|
|
242
|
+
}
|
|
243
|
+
|
|
210
244
|
async function main() {
|
|
211
245
|
if (flag('check')) return check();
|
|
246
|
+
if (flag('merge-timings')) return mergeTimings(flag('merge-timings'));
|
|
212
247
|
if (fragProblems.length) { for (const p of fragProblems) console.error(`✗ ${p}`); process.exitCode = 2; return; }
|
|
213
248
|
if (!group || !GROUPS[group]) {
|
|
214
249
|
console.error(`usage: node run-tests.js <${Object.keys(GROUPS).join('|')}> [--jobs=N] [--shard=I/N] [--list] [--verbose]\n node run-tests.js --check`);
|
|
@@ -250,6 +285,8 @@ async function main() {
|
|
|
250
285
|
|
|
251
286
|
const failed = results.filter((r) => !r.ok);
|
|
252
287
|
const slowest = [...results].sort((a, b) => b.secs - a.secs).slice(0, 5).map((r) => `${r.file} ${r.secs}s`).join(', ');
|
|
288
|
+
const timingsOut = flag('timings-out');
|
|
289
|
+
if (timingsOut && timingsOut !== true) fs.writeFileSync(timingsOut, JSON.stringify(Object.fromEntries(results.map((r) => [r.file, r.secs])), null, 2));
|
|
253
290
|
console.log(`\nrun-tests: ${results.length - failed.length}/${results.length} suites passed in ${((Date.now() - t0) / 1000).toFixed(1)}s wall. Slowest: ${slowest}.`);
|
|
254
291
|
// exitCode, never process.exit(): stdout to a pipe is asynchronous, and exiting here cut the CI log
|
|
255
292
|
// off mid-suite, before the summary that names what failed.
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
{
|
|
2
|
+
"_comment": "Wall time of each suite on the Workspace CI runners, in seconds. run-tests.js --shard balances the shards with these numbers. Refresh: download the timings-* artifacts of a green Workspace CI run into one directory, then run `node run-tests.js --merge-timings=DIR`.",
|
|
3
|
+
"measured": "2026-10-07",
|
|
4
|
+
"seconds": {
|
|
5
|
+
"agenda.test.js": 1,
|
|
6
|
+
"auto-sync.test.js": 2,
|
|
7
|
+
"board-cli.test.js": 3,
|
|
8
|
+
"capture.test.js": 1,
|
|
9
|
+
"claims.test.js": 2,
|
|
10
|
+
"cuts.test.js": 1,
|
|
11
|
+
"d86-seams.test.js": 1,
|
|
12
|
+
"day-run.test.js": 21,
|
|
13
|
+
"doc-category-corrections.test.js": 1,
|
|
14
|
+
"doc-category.test.js": 1,
|
|
15
|
+
"doc-library.test.js": 1,
|
|
16
|
+
"doc-sync.test.js": 1,
|
|
17
|
+
"docs-cli.test.js": 5,
|
|
18
|
+
"external-linking.test.js": 1,
|
|
19
|
+
"factory-join.test.js": 1,
|
|
20
|
+
"first-paint-board.test.js": 8,
|
|
21
|
+
"first-paint-count.test.js": 23,
|
|
22
|
+
"first-paint-initiatives.test.js": 12,
|
|
23
|
+
"first-paint-reports.test.js": 8,
|
|
24
|
+
"first-paint-today.test.js": 26,
|
|
25
|
+
"fixture-parity.test.mjs": 1,
|
|
26
|
+
"focus.test.js": 1,
|
|
27
|
+
"glossary.test.js": 1,
|
|
28
|
+
"goals.test.js": 1,
|
|
29
|
+
"grant-mint.test.js": 1,
|
|
30
|
+
"guide.test.js": 1,
|
|
31
|
+
"handoff.test.js": 1,
|
|
32
|
+
"health.test.js": 1,
|
|
33
|
+
"initiative-cli.test.js": 8,
|
|
34
|
+
"initiative-needs.test.js": 2,
|
|
35
|
+
"initiative-surface.test.js": 3,
|
|
36
|
+
"initiative-writer.test.js": 1,
|
|
37
|
+
"issues.test.js": 1,
|
|
38
|
+
"item-cli.test.js": 7,
|
|
39
|
+
"item-spans.test.js": 1,
|
|
40
|
+
"jev-categorize.test.js": 1,
|
|
41
|
+
"jev-claims.test.js": 1,
|
|
42
|
+
"jev-route.test.js": 1,
|
|
43
|
+
"jev-status.test.js": 1,
|
|
44
|
+
"jira-wbs.test.js": 1,
|
|
45
|
+
"kg.test.js": 2,
|
|
46
|
+
"kit-update.test.js": 1,
|
|
47
|
+
"launcher.test.js": 1,
|
|
48
|
+
"learn.test.js": 4,
|
|
49
|
+
"live-notes.test.js": 5,
|
|
50
|
+
"markdown.test.mjs": 1,
|
|
51
|
+
"measurements.test.js": 1,
|
|
52
|
+
"next-step-actions.test.js": 11,
|
|
53
|
+
"no-network.test.js": 1,
|
|
54
|
+
"notes-listen.test.js": 2,
|
|
55
|
+
"notes.test.js": 5,
|
|
56
|
+
"online-mode.test.js": 1,
|
|
57
|
+
"people.test.js": 2,
|
|
58
|
+
"phone-access.test.js": 1,
|
|
59
|
+
"phone-command.test.js": 4,
|
|
60
|
+
"placements.test.js": 1,
|
|
61
|
+
"plan-items-stage-parity.test.js": 1,
|
|
62
|
+
"plan-items.test.js": 1,
|
|
63
|
+
"plan-routes.test.js": 11,
|
|
64
|
+
"plan-tree.test.js": 6,
|
|
65
|
+
"pm-board-dest-phone-test.js": 14,
|
|
66
|
+
"pm-board-dest-test.js": 27,
|
|
67
|
+
"pm-breakdown-phone-test.js": 33,
|
|
68
|
+
"pm-breakdown-test.js": 68,
|
|
69
|
+
"pm-breakdown-timeline-test.js": 25,
|
|
70
|
+
"pm-cuts-test.js": 16,
|
|
71
|
+
"pm-detail-test.js": 18,
|
|
72
|
+
"pm-doc-category-test.js": 6,
|
|
73
|
+
"pm-docs-test.js": 37,
|
|
74
|
+
"pm-frame-phone-test.js": 14,
|
|
75
|
+
"pm-frame-test.js": 21,
|
|
76
|
+
"pm-initiatives-dest-phone-test.js": 9,
|
|
77
|
+
"pm-initiatives-dest-test.js": 28,
|
|
78
|
+
"pm-initiatives-test.js": 19,
|
|
79
|
+
"pm-initiatives-timeline-test.js": 39,
|
|
80
|
+
"pm-jira-test.js": 6,
|
|
81
|
+
"pm-lines-needs.test.js": 1,
|
|
82
|
+
"pm-live-notes-test.js": 25,
|
|
83
|
+
"pm-modes-test.js": 11,
|
|
84
|
+
"pm-nonce-test.js": 14,
|
|
85
|
+
"pm-notes-test.js": 40,
|
|
86
|
+
"pm-palette-test.js": 29,
|
|
87
|
+
"pm-people-test.js": 12,
|
|
88
|
+
"pm-r1-test.js": 10,
|
|
89
|
+
"pm-r10-test.js": 1,
|
|
90
|
+
"pm-r11-test.js": 2,
|
|
91
|
+
"pm-r12-test.js": 17,
|
|
92
|
+
"pm-r13-test.js": 12,
|
|
93
|
+
"pm-r14-test.js": 7,
|
|
94
|
+
"pm-r15-test.js": 7,
|
|
95
|
+
"pm-r16-test.js": 16,
|
|
96
|
+
"pm-r17-test.js": 7,
|
|
97
|
+
"pm-r18-test.js": 11,
|
|
98
|
+
"pm-r19-test.js": 20,
|
|
99
|
+
"pm-r2-test.js": 7,
|
|
100
|
+
"pm-r20-test.js": 19,
|
|
101
|
+
"pm-r21-test.js": 29,
|
|
102
|
+
"pm-r22-test.js": 2,
|
|
103
|
+
"pm-r23-test.js": 17,
|
|
104
|
+
"pm-r24-test.js": 15,
|
|
105
|
+
"pm-r25-test.js": 1,
|
|
106
|
+
"pm-r26-test.js": 1,
|
|
107
|
+
"pm-r27-test.js": 26,
|
|
108
|
+
"pm-r28-test.js": 1,
|
|
109
|
+
"pm-r29-filespad-test.js": 1,
|
|
110
|
+
"pm-r29-knowledge-test.js": 1,
|
|
111
|
+
"pm-r29-memory-test.js": 1,
|
|
112
|
+
"pm-r29-newsession-alignment-test.js": 1,
|
|
113
|
+
"pm-r29-portfolio-test.js": 1,
|
|
114
|
+
"pm-r29-reports-addrepo-test.js": 1,
|
|
115
|
+
"pm-r29-skills-scheduled-test.js": 1,
|
|
116
|
+
"pm-r29-today-test.js": 12,
|
|
117
|
+
"pm-r29-validate-guide-test.js": 1,
|
|
118
|
+
"pm-r3-test.js": 11,
|
|
119
|
+
"pm-r30-test.js": 13,
|
|
120
|
+
"pm-r4-test.js": 4,
|
|
121
|
+
"pm-r5-test.js": 6,
|
|
122
|
+
"pm-r6-test.js": 5,
|
|
123
|
+
"pm-r7-test.js": 1,
|
|
124
|
+
"pm-r8-test.js": 1,
|
|
125
|
+
"pm-r9-test.js": 10,
|
|
126
|
+
"pm-report-slides-test.js": 6,
|
|
127
|
+
"pm-reports-phone-test.js": 9,
|
|
128
|
+
"pm-reports-test.js": 20,
|
|
129
|
+
"pm-repos-test.js": 10,
|
|
130
|
+
"pm-seams-test.js": 16,
|
|
131
|
+
"pm-shell-test.js": 6,
|
|
132
|
+
"pm-tabs-test.js": 55,
|
|
133
|
+
"pm-term-test.js": 38,
|
|
134
|
+
"pm-today-goals-test.js": 30,
|
|
135
|
+
"pm-today-phone-test.js": 27,
|
|
136
|
+
"pm-today-test.js": 60,
|
|
137
|
+
"pm-updates-test.js": 16,
|
|
138
|
+
"proactive-prepare.test.js": 12,
|
|
139
|
+
"proactive.test.js": 17,
|
|
140
|
+
"profiles.test.js": 1,
|
|
141
|
+
"public-host.test.js": 1,
|
|
142
|
+
"r25-guards.test.js": 9,
|
|
143
|
+
"r3-display.test.js": 4,
|
|
144
|
+
"replaced-tests.test.js": 17,
|
|
145
|
+
"repo-scan.test.js": 1,
|
|
146
|
+
"repo-watch.test.js": 1,
|
|
147
|
+
"report-cli.test.js": 2,
|
|
148
|
+
"report-draft.test.js": 4,
|
|
149
|
+
"report-needs.test.js": 1,
|
|
150
|
+
"report-slides-data.test.js": 1,
|
|
151
|
+
"routes-board.test.js": 1,
|
|
152
|
+
"routes-capture.test.js": 1,
|
|
153
|
+
"routes-initiatives.test.js": 3,
|
|
154
|
+
"routes-reports.test.js": 1,
|
|
155
|
+
"routes-timeline.test.js": 1,
|
|
156
|
+
"routes-today.test.js": 6,
|
|
157
|
+
"scan-consistency.test.js": 1,
|
|
158
|
+
"schedule-cli.test.js": 3,
|
|
159
|
+
"session-flag.test.js": 1,
|
|
160
|
+
"start-policy.test.js": 1,
|
|
161
|
+
"state-isolation.test.js": 37,
|
|
162
|
+
"status-facts-panel.test.js": 1,
|
|
163
|
+
"story-artifacts.test.js": 1,
|
|
164
|
+
"sync.test.js": 4,
|
|
165
|
+
"test.js": 65,
|
|
166
|
+
"today.test.js": 5,
|
|
167
|
+
"tracker-refresh.test.js": 1,
|
|
168
|
+
"typesafe-validation.test.js": 1,
|
|
169
|
+
"ui-test.js": 43,
|
|
170
|
+
"work-check-needs.test.js": 1
|
|
171
|
+
}
|
|
172
|
+
}
|