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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-pmos",
3
- "version": "0.65.2",
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": {
@@ -1,3 +1,3 @@
1
- kernel-source: pmos@cc06cf0
1
+ kernel-source: pmos@c6198fe
2
2
  assembled: 2026-10-07
3
3
  mode: vendor
@@ -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:20:37Z"}
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:20:37Z"}
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:20:37Z"}
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
- ### Re-scan
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, use **Re-scan the repository**. It is in the **⋯** menu of an initiative sheet, and in the command menu (⌘K). A toast shows **Re-scanning…**, and another shows the time when the read ends. Then the view updates. Re-scan writes nothing. It works the same on PMOS itself and on a tenant repository.
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
- const files = filesOf(root, rec, fm, opts);
282
- const derived = derivedBreakdown(root, rec, lay);
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
- for (const k of ['prd_path', 'doc_path', 'brief_path']) {
401
- const rel = typeof rfm[k] === 'string' ? rfm[k].trim() : '';
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 });
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.65.2",
3
+ "version": "0.66.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.65.2",
3
+ "version": "0.66.0",
4
4
  "private": true,
5
5
  "description": "PMOS Terminal Workspace — local companion hosting real PTYs beside the library and gates (D62)",
6
6
  "license": "MIT",
@@ -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 the library's
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 pmGet('/api/initiatives?ws=' + enc(ws) + '&view=roadmap');
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: 'Re-scan the repository',
32
- rescanning: 'Re-scanning…',
33
- rescanHint: 'Read stage, files and breakdown again now',
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: a.slow ? COPY.rescanSlow : COPY.rescanDone(t), timeout: 6000 });
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
  /**
@@ -0,0 +1,5 @@
1
+ {
2
+ "_comment": "scan-documents: a document repository's initiatives read their stage from the documents and Jira keys the repository holds.",
3
+ "modules": ["scan-documents.test.js"],
4
+ "seconds": { "scan-documents.test.js": 5 }
5
+ }