create-pmos 0.70.1 → 0.71.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.70.1",
3
+ "version": "0.71.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@9413354
1
+ kernel-source: pmos@c87956b
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-07T04:11:36Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-07T04:24:34Z"}
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-07T04:11:36Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-07T04:24:34Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -1313,51 +1313,102 @@ function roleOfWords(words, extra) {
1313
1313
  }
1314
1314
  return null;
1315
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) {
1316
+ /** A name as a slug: lower case, every run of other characters one dash. "Checkout Redesign PRD.docx" → "checkout-redesign-prd-docx". */
1317
+ const scanSlug = (s) => String(s || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
1318
+ /**
1319
+ * Does the slug `name` carry the key? Equal, or the key leads it ("checkout-prd"). A key of two words or more, or of
1320
+ * six characters or more, may also stand anywhere between dashes ("prd-checkout-redesign-v2"): a short one-word key
1321
+ * such as "app" would otherwise claim every file with that word in it.
1322
+ */
1323
+ function slugHasKey(name, key) {
1324
+ if (!name || !key) return false;
1325
+ if (name === key || name.startsWith(key + '-')) return true;
1326
+ if (!key.includes('-') && key.length < 6) return false;
1327
+ return name.endsWith('-' + key) || name.includes('-' + key + '-');
1328
+ }
1329
+ /**
1330
+ * The initiatives a document belongs to (S1): its frontmatter names them; else the record whose key — its id, or its
1331
+ * title as a slug — is a folder in the path, leads or stands in the file name, or stands in the document's own title.
1332
+ * The longest key wins, so "checkout-redesign" takes a file that "checkout" would also match.
1333
+ */
1334
+ function documentOwners(rel, fm, idSet, keys) {
1335
+ const named = [fm.initiative, fm.initiative_id, fm.initiatives].filter(Boolean)
1336
+ .flatMap((v) => String(v).replace(/^\[|\]$/g, '').split(',')).map((s) => s.trim().replace(/^["']|["']$/g, ''))
1337
+ .filter((s) => idSet.has(s));
1338
+ if (named.length) return { ids: [...new Set(named)], key: null };
1339
+ const parts = rel.split('/');
1340
+ const base = scanSlug(parts[parts.length - 1].replace(/\.[^.]+$/, ''));
1341
+ const dirs = parts.slice(0, -1).map(scanSlug);
1342
+ const title = scanSlug(fm.title);
1343
+ for (const { id, key } of keys) {
1344
+ if (dirs.includes(key) || slugHasKey(base, key) || slugHasKey(title, key)) return { ids: [id], key };
1345
+ }
1346
+ return { ids: [], key: null };
1347
+ }
1348
+ /** The role of a found document (S2): its file name, then its frontmatter `type:`, then its folders. */
1349
+ function documentRole(rel, type, key) {
1318
1350
  const parts = rel.split('/');
1319
1351
  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
1352
  // A rubric or a template says what good looks like; it is not evidence that the work was judged or done.
1323
1353
  if (scanWords(base).some((x) => SCAN_NOT_EVIDENCE.includes(x))) return null;
1354
+ // The initiative's own name is not a role word: "problem-reporting-prd" is the PRD of the initiative problem-reporting.
1355
+ const strip = (s) => { const slug = scanSlug(s); return key ? slug.split(key).join('-') : slug; };
1324
1356
  return roleOfWords(scanWords(strip(base))) || roleOfWords(scanWords(type))
1325
1357
  || roleOfWords(scanWords(strip(parts.slice(0, -1).join('/'))));
1326
1358
  }
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];
1359
+ /** Every record: its id and the keys a document may carry — the id, and the title as a slug when it is not too short. */
1360
+ function recordKeys(L) {
1361
+ let files = [];
1362
+ try { files = fs.readdirSync(L.initiatives).filter((f) => f.endsWith('.md') && !/^(README|index|log)\.md$/i.test(f)); }
1363
+ catch { return []; }
1364
+ const out = [];
1365
+ for (const f of files) {
1366
+ const id = f.slice(0, -3);
1367
+ const fm = readFrontmatter(path.join(L.initiatives, f)) || {};
1368
+ const keys = new Set([scanSlug(id)]);
1369
+ const t = scanSlug(fm.title);
1370
+ if (t && (t.includes('-') || t.length >= 6)) keys.add(t);
1371
+ for (const key of keys) if (key.length >= 3) out.push({ id, key });
1341
1372
  }
1342
- return [];
1373
+ return out.sort((a, b) => b.key.length - a.key.length);
1343
1374
  }
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 []; }
1375
+ /** The repository's files: git's list (tracked, and untracked but not ignored); a walk of the folder when it is not a git repository. */
1376
+ function scanFiles(root) {
1377
+ const out = git(root, ['ls-files', '-z', '--cached', '--others', '--exclude-standard']);
1378
+ if (out != null) return [...new Set(out.split('\0').filter(Boolean))].slice(0, SCAN_MAX_FILES);
1379
+ const files = [];
1380
+ const walk = (dir, rel) => {
1381
+ let ents = [];
1382
+ try { ents = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
1383
+ for (const e of ents) {
1384
+ if (files.length >= SCAN_MAX_FILES) return;
1385
+ if (e.name.startsWith('.') || e.name === 'node_modules') continue;
1386
+ const r = rel ? `${rel}/${e.name}` : e.name;
1387
+ if (e.isDirectory()) walk(path.join(dir, e.name), r);
1388
+ else if (e.isFile()) files.push(r);
1389
+ }
1390
+ };
1391
+ walk(root, '');
1392
+ return files;
1393
+ }
1394
+ /** Where the tidy (S10) moves a duplicate, repository-relative: `<state>/archive/duplicates/`. The scan does not read it. */
1395
+ function duplicatesArchive(root, L) {
1396
+ return path.relative(root, path.join(path.dirname(L.initiatives), 'archive', 'duplicates')).split(path.sep).join('/') + '/';
1348
1397
  }
1349
- function scanRepository(root, ids, L) {
1398
+ function scanRepository(root, L) { return scanDetail(root, L).index; }
1399
+ /** The scan's index, and how many initiatives each found document belongs to (`owners`, for the tidy). */
1400
+ function scanDetail(root, L) {
1350
1401
  const key = path.resolve(root);
1351
1402
  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;
1403
+ if (hit && Date.now() - hit.at < SCAN_TTL_MS) return hit;
1353
1404
  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);
1405
+ const owners = new Map();
1406
+ const keys = recordKeys(L);
1407
+ const idSet = new Set(keys.map((k) => k.id));
1358
1408
  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;
1409
+ const archDir = duplicatesArchive(root, L);
1410
+ for (const rel of scanFiles(root)) {
1411
+ if (!SCAN_EXT.test(rel) || rel.startsWith(recDir) || rel.startsWith(archDir) || /(^|\/)(node_modules|\.git)\//.test(rel)) continue;
1361
1412
  let fm = {};
1362
1413
  if (SCAN_TEXT.test(rel)) {
1363
1414
  try {
@@ -1368,21 +1419,67 @@ function scanRepository(root, ids, L) {
1368
1419
  fm = parseLeadingFrontmatter(buf.slice(0, n).toString('utf8')) || {};
1369
1420
  } catch { continue; }
1370
1421
  }
1371
- const owners = documentOwners(rel, fm, idSet, byLength);
1372
- if (!owners.length) continue;
1373
- const role = documentRole(rel, fm.type, owners);
1422
+ const owner = documentOwners(rel, fm, idSet, keys);
1423
+ if (!owner.ids.length) continue;
1424
+ const role = documentRole(rel, fm.type, owner.key);
1374
1425
  if (!role) continue;
1375
- for (const id of owners) {
1426
+ // git's index still lists a tracked file that was moved or deleted and not yet staged
1427
+ if (!SCAN_TEXT.test(rel) && !fs.existsSync(path.join(root, rel))) continue;
1428
+ owners.set(rel, owner.ids.length);
1429
+ for (const id of owner.ids) {
1376
1430
  if (!index.has(id)) index.set(id, { brief: [], draft: [], review: [], handoff: [] });
1377
1431
  index.get(id)[role].push(rel);
1378
1432
  }
1379
1433
  }
1380
1434
  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;
1435
+ const out = { at: Date.now(), index, owners };
1436
+ _scanCache.set(key, out);
1437
+ return out;
1438
+ }
1439
+ /** When a document last changed, in seconds: its last commit, else the file's own time. */
1440
+ function documentChangedAt(root, rel) {
1441
+ const t = Number(String(git(root, ['log', '-1', '--format=%ct', '--', rel]) || '').trim());
1442
+ if (t > 0) return t;
1443
+ try { return Math.floor(fs.statSync(path.join(root, rel)).mtimeMs / 1000); } catch { return 0; }
1444
+ }
1445
+ /**
1446
+ * The duplicates the tidy offers (S10): for each initiative, each role with more than one document. The group keeps the
1447
+ * file the record names, else the most recently changed, else the shortest path. A document that belongs to more than one
1448
+ * initiative is listed as `shared` and is never one to move. Only a pm-artifacts repository has groups. Nothing is written.
1449
+ * { ok, profile, archive, groups: [{ id, title, role, keep, files: [{ path, changed }], shared: [path] }] }
1450
+ */
1451
+ function documentDuplicates(root, opts) {
1452
+ const L = layout(root);
1453
+ const profile = profileOf(root);
1454
+ const archive = duplicatesArchive(root, L);
1455
+ if (profile !== 'pm-artifacts') return { ok: true, profile, archive, groups: [] };
1456
+ if (opts && opts.fresh) forgetDocumentScan(root);
1457
+ const { index, owners } = scanDetail(root, L);
1458
+ const norm = (p) => { const v = String(p || '').trim(); return v ? path.relative(root, path.resolve(root, v)).split(path.sep).join('/') : ''; };
1459
+ const groups = [];
1460
+ for (const [id, roles] of [...index].sort((a, b) => a[0].localeCompare(b[0]))) {
1461
+ const fm = readFrontmatter(path.join(L.initiatives, `${id}.md`)) || {};
1462
+ const named = { brief: norm(fm.brief_path || fm.prd_path), draft: norm(fm.doc_path) };
1463
+ for (const role of ['brief', 'draft', 'review', 'handoff']) {
1464
+ const own = roles[role].filter((f) => (owners.get(f) || 0) <= 1);
1465
+ const shared = roles[role].filter((f) => (owners.get(f) || 0) > 1);
1466
+ const pin = named[role] && fs.existsSync(path.join(root, named[role])) ? named[role] : '';
1467
+ if (pin && !own.includes(pin) && !shared.includes(pin)) own.push(pin);
1468
+ if (own.length < 2) continue;
1469
+ const files = own.map((f) => ({ path: f, changed: documentChangedAt(root, f) }));
1470
+ files.sort((a, b) => (b.path === pin) - (a.path === pin) || b.changed - a.changed
1471
+ || a.path.length - b.path.length || a.path.localeCompare(b.path));
1472
+ groups.push({ id, title: fm.title || id, role, keep: files[0].path, files, shared, named: !!pin && files[0].path === pin });
1473
+ }
1474
+ }
1475
+ return { ok: true, profile, archive, groups };
1383
1476
  }
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) {
1477
+ /**
1478
+ * Jira keys the repository holds for an initiative (S4): the Jira tab's draft; the record's `jira:` keys; in the
1479
+ * record's text, a Jira link (`…/browse/KEY`) or a key on a line that says Jira; and a Jira link in its found
1480
+ * documents. A bare key elsewhere is not read: "UTF-8" and "ISO-8601" have the same shape.
1481
+ */
1482
+ function jiraKeysOf(root, L, id, fm, found) {
1386
1483
  const keys = [];
1387
1484
  const draft = path.join(path.dirname(L.initiatives), 'tracker', 'jira', `${id}-tickets.md`);
1388
1485
  try {
@@ -1394,6 +1491,18 @@ function jiraKeysOf(root, L, id, fm) {
1394
1491
  for (const k of ['jira', 'jira_key', 'jira_keys', 'jira_epic']) {
1395
1492
  for (const m of String(fm[k] || '').toUpperCase().matchAll(/\b([A-Z][A-Z0-9_]+-\d+)\b/g)) keys.push(m[1]);
1396
1493
  }
1494
+ const fromText = (text, lines) => {
1495
+ for (const m of String(text).matchAll(/\/browse\/([A-Z][A-Z0-9_]+-\d+)\b/g)) keys.push(m[1]);
1496
+ if (lines) for (const line of String(text).split('\n')) {
1497
+ if (!/jira/i.test(line)) continue;
1498
+ for (const m of line.matchAll(/\b([A-Z][A-Z0-9_]+-\d+)\b/g)) keys.push(m[1]);
1499
+ }
1500
+ };
1501
+ try { fromText(fs.readFileSync(path.join(L.initiatives, `${id}.md`), 'utf8'), true); } catch { /* no record text */ }
1502
+ for (const rel of found ? [].concat(found.brief, found.draft, found.review, found.handoff) : []) {
1503
+ if (!SCAN_TEXT.test(rel)) continue;
1504
+ try { fromText(fs.readFileSync(path.join(root, rel), 'utf8').slice(0, 200000), false); } catch { /* unreadable */ }
1505
+ }
1397
1506
  return [...new Set(keys)];
1398
1507
  }
1399
1508
 
@@ -1433,7 +1542,7 @@ function pmLadder({ id, fm, lane, effLane, knownLane, legacy, steps, root, L, re
1433
1542
  : step('route', 'route', 'done', `unknown lane '${lane}' — treated as a governed prd, the profile's strictest (D68)`,
1434
1543
  `set lane: to one of ${laneNames('pm-artifacts').join(', ')}`);
1435
1544
 
1436
- const found = (opts && opts.scan === false) ? null : (scanRepository(root, recordIds(L).concat(id), L).get(id) || null);
1545
+ const found = (opts && opts.scan === false) ? null : (scanRepository(root, L).get(id) || null);
1437
1546
  const foundOf = (role) => (found && found[role].length ? found[role][0] : null);
1438
1547
  const briefPath = String(fm.brief_path || fm.prd_path || '').trim();
1439
1548
  const docPath = String(fm.doc_path || '').trim();
@@ -1492,7 +1601,7 @@ function pmLadder({ id, fm, lane, effLane, knownLane, legacy, steps, root, L, re
1492
1601
  if (!flows('handoff')) steps.push(step('handoff', 'handed off', 'na', `${effLane} — nothing to hand off`));
1493
1602
  else {
1494
1603
  const to = String(fm.handed_off || '').trim();
1495
- const keys = to ? [] : jiraKeysOf(root, L, id, fm);
1604
+ const keys = to ? [] : jiraKeysOf(root, L, id, fm, found);
1496
1605
  steps.push(to ? step('handoff', 'handed off', 'done', `handed off — ${to}`)
1497
1606
  : 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
1607
  : foundOf('handoff') ? step('handoff', 'handed off', 'done', `${foundOf('handoff')} — handoff found by the repository scan`)
@@ -1811,7 +1920,7 @@ module.exports = {
1811
1920
  historyRange,
1812
1921
  ENFORCED_FROM,
1813
1922
  lanesFor, laneNames,
1814
- forgetDocumentScan,
1923
+ forgetDocumentScan, documentDuplicates,
1815
1924
  };
1816
1925
 
1817
1926
  /*
@@ -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-07T04:11:36Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-07T04:24:34Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -217,5 +217,10 @@
217
217
  }
218
218
  ]
219
219
  }
220
- ]
220
+ ],
221
+ "scan": {
222
+ "initiatives": 6,
223
+ "with_documents": 0,
224
+ "profile": "product-code"
225
+ }
221
226
  }
@@ -15,15 +15,15 @@ Press **Roadmap** or **List** at the top to change the view. The workspace keeps
15
15
 
16
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, 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.
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. Documents or Jira keys found for 30 of them." When it found nothing for any initiative, the toast says so. Then the view updates. The scan writes nothing, except the tidy you confirm (below). It works the same on PMOS itself and on a tenant repository.
19
19
 
20
20
  ### What the scan finds in a document repository
21
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:
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 folder that is not a git repository is read the same way. Each initiative has two names the scan looks for: its id (the record's file name) and its title. Spaces, capitals, underscores and punctuation do not matter, so the title "Guest Checkout" matches `guest-checkout`, `Guest Checkout` and `guest_checkout`. A document belongs to an initiative when:
23
23
 
24
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.
25
+ - a folder in its path has the initiative's name, for example `Product/Guest Checkout/PRD.md`, or
26
+ - its file name or its own `title:` carries the initiative's name, for example `PRD - Guest Checkout v2.md`. A one-word name shorter than six letters must start the file name. When two initiatives match, the longer name wins.
27
27
 
28
28
  The file name, then the `type:` in the frontmatter, then the folder names give the document its role:
29
29
 
@@ -34,7 +34,19 @@ The file name, then the `type:` in the frontmatter, then the folder names give t
34
34
  | Brief | `brief`, `problem`, `idea`, `intake`, `one-pager`, `bug-report` | the brief, for example Problem brief |
35
35
  | Draft | `prd`, `spec`, `specification`, `requirements`, `findings`, `plan`, `strategy`, `report`, `decision` | the draft, for example PRD |
36
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.
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, a `jira:`, `jira_key:` or `jira_epic:` key in the record, a Jira link or a line that says Jira in the record, and a Jira link in one of its documents. 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.
38
+
39
+ ### Tidy duplicate documents
40
+
41
+ An initiative has one document of each kind: one brief, one PRD, one evaluation and one handoff. When a document repository holds more than one of a kind for an initiative, for example `checkout-prd.md` and `checkout-prd-v2.md`, **Scan repository** first opens the **Tidy duplicate documents** sheet. It lists each group and selects the document to keep:
42
+
43
+ 1. the file the record names (`doc_path:` or `brief_path:`), else
44
+ 2. the most recently changed file (its last git commit, else the file's own time), else
45
+ 3. the shortest path.
46
+
47
+ Select another file to keep a different one. Clear a group's check box to leave that group as it is. Then press **Archive N and scan**. Every other document of each checked group moves to `pmos/state/archive/duplicates/`, under its original path. Nothing is deleted. A file that git tracks moves with `git mv`, so it shows as a rename when you next commit. Each move is one line in `moves.jsonl` in the same folder, so you can move a file back by hand. A document that belongs to more than one initiative never moves. The scan does not read the archive. The scan then runs, and its toast starts with how many documents were archived.
48
+
49
+ Press **Scan without tidying** to scan with every document in place. Press **Cancel** or Escape to do neither. The tidy writes no initiative record.
38
50
 
39
51
  ## The Roadmap
40
52
 
@@ -1313,51 +1313,102 @@ function roleOfWords(words, extra) {
1313
1313
  }
1314
1314
  return null;
1315
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) {
1316
+ /** A name as a slug: lower case, every run of other characters one dash. "Checkout Redesign PRD.docx" → "checkout-redesign-prd-docx". */
1317
+ const scanSlug = (s) => String(s || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
1318
+ /**
1319
+ * Does the slug `name` carry the key? Equal, or the key leads it ("checkout-prd"). A key of two words or more, or of
1320
+ * six characters or more, may also stand anywhere between dashes ("prd-checkout-redesign-v2"): a short one-word key
1321
+ * such as "app" would otherwise claim every file with that word in it.
1322
+ */
1323
+ function slugHasKey(name, key) {
1324
+ if (!name || !key) return false;
1325
+ if (name === key || name.startsWith(key + '-')) return true;
1326
+ if (!key.includes('-') && key.length < 6) return false;
1327
+ return name.endsWith('-' + key) || name.includes('-' + key + '-');
1328
+ }
1329
+ /**
1330
+ * The initiatives a document belongs to (S1): its frontmatter names them; else the record whose key — its id, or its
1331
+ * title as a slug — is a folder in the path, leads or stands in the file name, or stands in the document's own title.
1332
+ * The longest key wins, so "checkout-redesign" takes a file that "checkout" would also match.
1333
+ */
1334
+ function documentOwners(rel, fm, idSet, keys) {
1335
+ const named = [fm.initiative, fm.initiative_id, fm.initiatives].filter(Boolean)
1336
+ .flatMap((v) => String(v).replace(/^\[|\]$/g, '').split(',')).map((s) => s.trim().replace(/^["']|["']$/g, ''))
1337
+ .filter((s) => idSet.has(s));
1338
+ if (named.length) return { ids: [...new Set(named)], key: null };
1339
+ const parts = rel.split('/');
1340
+ const base = scanSlug(parts[parts.length - 1].replace(/\.[^.]+$/, ''));
1341
+ const dirs = parts.slice(0, -1).map(scanSlug);
1342
+ const title = scanSlug(fm.title);
1343
+ for (const { id, key } of keys) {
1344
+ if (dirs.includes(key) || slugHasKey(base, key) || slugHasKey(title, key)) return { ids: [id], key };
1345
+ }
1346
+ return { ids: [], key: null };
1347
+ }
1348
+ /** The role of a found document (S2): its file name, then its frontmatter `type:`, then its folders. */
1349
+ function documentRole(rel, type, key) {
1318
1350
  const parts = rel.split('/');
1319
1351
  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
1352
  // A rubric or a template says what good looks like; it is not evidence that the work was judged or done.
1323
1353
  if (scanWords(base).some((x) => SCAN_NOT_EVIDENCE.includes(x))) return null;
1354
+ // The initiative's own name is not a role word: "problem-reporting-prd" is the PRD of the initiative problem-reporting.
1355
+ const strip = (s) => { const slug = scanSlug(s); return key ? slug.split(key).join('-') : slug; };
1324
1356
  return roleOfWords(scanWords(strip(base))) || roleOfWords(scanWords(type))
1325
1357
  || roleOfWords(scanWords(strip(parts.slice(0, -1).join('/'))));
1326
1358
  }
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];
1359
+ /** Every record: its id and the keys a document may carry — the id, and the title as a slug when it is not too short. */
1360
+ function recordKeys(L) {
1361
+ let files = [];
1362
+ try { files = fs.readdirSync(L.initiatives).filter((f) => f.endsWith('.md') && !/^(README|index|log)\.md$/i.test(f)); }
1363
+ catch { return []; }
1364
+ const out = [];
1365
+ for (const f of files) {
1366
+ const id = f.slice(0, -3);
1367
+ const fm = readFrontmatter(path.join(L.initiatives, f)) || {};
1368
+ const keys = new Set([scanSlug(id)]);
1369
+ const t = scanSlug(fm.title);
1370
+ if (t && (t.includes('-') || t.length >= 6)) keys.add(t);
1371
+ for (const key of keys) if (key.length >= 3) out.push({ id, key });
1341
1372
  }
1342
- return [];
1373
+ return out.sort((a, b) => b.key.length - a.key.length);
1343
1374
  }
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 []; }
1375
+ /** The repository's files: git's list (tracked, and untracked but not ignored); a walk of the folder when it is not a git repository. */
1376
+ function scanFiles(root) {
1377
+ const out = git(root, ['ls-files', '-z', '--cached', '--others', '--exclude-standard']);
1378
+ if (out != null) return [...new Set(out.split('\0').filter(Boolean))].slice(0, SCAN_MAX_FILES);
1379
+ const files = [];
1380
+ const walk = (dir, rel) => {
1381
+ let ents = [];
1382
+ try { ents = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
1383
+ for (const e of ents) {
1384
+ if (files.length >= SCAN_MAX_FILES) return;
1385
+ if (e.name.startsWith('.') || e.name === 'node_modules') continue;
1386
+ const r = rel ? `${rel}/${e.name}` : e.name;
1387
+ if (e.isDirectory()) walk(path.join(dir, e.name), r);
1388
+ else if (e.isFile()) files.push(r);
1389
+ }
1390
+ };
1391
+ walk(root, '');
1392
+ return files;
1393
+ }
1394
+ /** Where the tidy (S10) moves a duplicate, repository-relative: `<state>/archive/duplicates/`. The scan does not read it. */
1395
+ function duplicatesArchive(root, L) {
1396
+ return path.relative(root, path.join(path.dirname(L.initiatives), 'archive', 'duplicates')).split(path.sep).join('/') + '/';
1348
1397
  }
1349
- function scanRepository(root, ids, L) {
1398
+ function scanRepository(root, L) { return scanDetail(root, L).index; }
1399
+ /** The scan's index, and how many initiatives each found document belongs to (`owners`, for the tidy). */
1400
+ function scanDetail(root, L) {
1350
1401
  const key = path.resolve(root);
1351
1402
  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;
1403
+ if (hit && Date.now() - hit.at < SCAN_TTL_MS) return hit;
1353
1404
  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);
1405
+ const owners = new Map();
1406
+ const keys = recordKeys(L);
1407
+ const idSet = new Set(keys.map((k) => k.id));
1358
1408
  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;
1409
+ const archDir = duplicatesArchive(root, L);
1410
+ for (const rel of scanFiles(root)) {
1411
+ if (!SCAN_EXT.test(rel) || rel.startsWith(recDir) || rel.startsWith(archDir) || /(^|\/)(node_modules|\.git)\//.test(rel)) continue;
1361
1412
  let fm = {};
1362
1413
  if (SCAN_TEXT.test(rel)) {
1363
1414
  try {
@@ -1368,21 +1419,67 @@ function scanRepository(root, ids, L) {
1368
1419
  fm = parseLeadingFrontmatter(buf.slice(0, n).toString('utf8')) || {};
1369
1420
  } catch { continue; }
1370
1421
  }
1371
- const owners = documentOwners(rel, fm, idSet, byLength);
1372
- if (!owners.length) continue;
1373
- const role = documentRole(rel, fm.type, owners);
1422
+ const owner = documentOwners(rel, fm, idSet, keys);
1423
+ if (!owner.ids.length) continue;
1424
+ const role = documentRole(rel, fm.type, owner.key);
1374
1425
  if (!role) continue;
1375
- for (const id of owners) {
1426
+ // git's index still lists a tracked file that was moved or deleted and not yet staged
1427
+ if (!SCAN_TEXT.test(rel) && !fs.existsSync(path.join(root, rel))) continue;
1428
+ owners.set(rel, owner.ids.length);
1429
+ for (const id of owner.ids) {
1376
1430
  if (!index.has(id)) index.set(id, { brief: [], draft: [], review: [], handoff: [] });
1377
1431
  index.get(id)[role].push(rel);
1378
1432
  }
1379
1433
  }
1380
1434
  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;
1435
+ const out = { at: Date.now(), index, owners };
1436
+ _scanCache.set(key, out);
1437
+ return out;
1438
+ }
1439
+ /** When a document last changed, in seconds: its last commit, else the file's own time. */
1440
+ function documentChangedAt(root, rel) {
1441
+ const t = Number(String(git(root, ['log', '-1', '--format=%ct', '--', rel]) || '').trim());
1442
+ if (t > 0) return t;
1443
+ try { return Math.floor(fs.statSync(path.join(root, rel)).mtimeMs / 1000); } catch { return 0; }
1444
+ }
1445
+ /**
1446
+ * The duplicates the tidy offers (S10): for each initiative, each role with more than one document. The group keeps the
1447
+ * file the record names, else the most recently changed, else the shortest path. A document that belongs to more than one
1448
+ * initiative is listed as `shared` and is never one to move. Only a pm-artifacts repository has groups. Nothing is written.
1449
+ * { ok, profile, archive, groups: [{ id, title, role, keep, files: [{ path, changed }], shared: [path] }] }
1450
+ */
1451
+ function documentDuplicates(root, opts) {
1452
+ const L = layout(root);
1453
+ const profile = profileOf(root);
1454
+ const archive = duplicatesArchive(root, L);
1455
+ if (profile !== 'pm-artifacts') return { ok: true, profile, archive, groups: [] };
1456
+ if (opts && opts.fresh) forgetDocumentScan(root);
1457
+ const { index, owners } = scanDetail(root, L);
1458
+ const norm = (p) => { const v = String(p || '').trim(); return v ? path.relative(root, path.resolve(root, v)).split(path.sep).join('/') : ''; };
1459
+ const groups = [];
1460
+ for (const [id, roles] of [...index].sort((a, b) => a[0].localeCompare(b[0]))) {
1461
+ const fm = readFrontmatter(path.join(L.initiatives, `${id}.md`)) || {};
1462
+ const named = { brief: norm(fm.brief_path || fm.prd_path), draft: norm(fm.doc_path) };
1463
+ for (const role of ['brief', 'draft', 'review', 'handoff']) {
1464
+ const own = roles[role].filter((f) => (owners.get(f) || 0) <= 1);
1465
+ const shared = roles[role].filter((f) => (owners.get(f) || 0) > 1);
1466
+ const pin = named[role] && fs.existsSync(path.join(root, named[role])) ? named[role] : '';
1467
+ if (pin && !own.includes(pin) && !shared.includes(pin)) own.push(pin);
1468
+ if (own.length < 2) continue;
1469
+ const files = own.map((f) => ({ path: f, changed: documentChangedAt(root, f) }));
1470
+ files.sort((a, b) => (b.path === pin) - (a.path === pin) || b.changed - a.changed
1471
+ || a.path.length - b.path.length || a.path.localeCompare(b.path));
1472
+ groups.push({ id, title: fm.title || id, role, keep: files[0].path, files, shared, named: !!pin && files[0].path === pin });
1473
+ }
1474
+ }
1475
+ return { ok: true, profile, archive, groups };
1383
1476
  }
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) {
1477
+ /**
1478
+ * Jira keys the repository holds for an initiative (S4): the Jira tab's draft; the record's `jira:` keys; in the
1479
+ * record's text, a Jira link (`…/browse/KEY`) or a key on a line that says Jira; and a Jira link in its found
1480
+ * documents. A bare key elsewhere is not read: "UTF-8" and "ISO-8601" have the same shape.
1481
+ */
1482
+ function jiraKeysOf(root, L, id, fm, found) {
1386
1483
  const keys = [];
1387
1484
  const draft = path.join(path.dirname(L.initiatives), 'tracker', 'jira', `${id}-tickets.md`);
1388
1485
  try {
@@ -1394,6 +1491,18 @@ function jiraKeysOf(root, L, id, fm) {
1394
1491
  for (const k of ['jira', 'jira_key', 'jira_keys', 'jira_epic']) {
1395
1492
  for (const m of String(fm[k] || '').toUpperCase().matchAll(/\b([A-Z][A-Z0-9_]+-\d+)\b/g)) keys.push(m[1]);
1396
1493
  }
1494
+ const fromText = (text, lines) => {
1495
+ for (const m of String(text).matchAll(/\/browse\/([A-Z][A-Z0-9_]+-\d+)\b/g)) keys.push(m[1]);
1496
+ if (lines) for (const line of String(text).split('\n')) {
1497
+ if (!/jira/i.test(line)) continue;
1498
+ for (const m of line.matchAll(/\b([A-Z][A-Z0-9_]+-\d+)\b/g)) keys.push(m[1]);
1499
+ }
1500
+ };
1501
+ try { fromText(fs.readFileSync(path.join(L.initiatives, `${id}.md`), 'utf8'), true); } catch { /* no record text */ }
1502
+ for (const rel of found ? [].concat(found.brief, found.draft, found.review, found.handoff) : []) {
1503
+ if (!SCAN_TEXT.test(rel)) continue;
1504
+ try { fromText(fs.readFileSync(path.join(root, rel), 'utf8').slice(0, 200000), false); } catch { /* unreadable */ }
1505
+ }
1397
1506
  return [...new Set(keys)];
1398
1507
  }
1399
1508
 
@@ -1433,7 +1542,7 @@ function pmLadder({ id, fm, lane, effLane, knownLane, legacy, steps, root, L, re
1433
1542
  : step('route', 'route', 'done', `unknown lane '${lane}' — treated as a governed prd, the profile's strictest (D68)`,
1434
1543
  `set lane: to one of ${laneNames('pm-artifacts').join(', ')}`);
1435
1544
 
1436
- const found = (opts && opts.scan === false) ? null : (scanRepository(root, recordIds(L).concat(id), L).get(id) || null);
1545
+ const found = (opts && opts.scan === false) ? null : (scanRepository(root, L).get(id) || null);
1437
1546
  const foundOf = (role) => (found && found[role].length ? found[role][0] : null);
1438
1547
  const briefPath = String(fm.brief_path || fm.prd_path || '').trim();
1439
1548
  const docPath = String(fm.doc_path || '').trim();
@@ -1492,7 +1601,7 @@ function pmLadder({ id, fm, lane, effLane, knownLane, legacy, steps, root, L, re
1492
1601
  if (!flows('handoff')) steps.push(step('handoff', 'handed off', 'na', `${effLane} — nothing to hand off`));
1493
1602
  else {
1494
1603
  const to = String(fm.handed_off || '').trim();
1495
- const keys = to ? [] : jiraKeysOf(root, L, id, fm);
1604
+ const keys = to ? [] : jiraKeysOf(root, L, id, fm, found);
1496
1605
  steps.push(to ? step('handoff', 'handed off', 'done', `handed off — ${to}`)
1497
1606
  : 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
1607
  : foundOf('handoff') ? step('handoff', 'handed off', 'done', `${foundOf('handoff')} — handoff found by the repository scan`)
@@ -1811,7 +1920,7 @@ module.exports = {
1811
1920
  historyRange,
1812
1921
  ENFORCED_FROM,
1813
1922
  lanesFor, laneNames,
1814
- forgetDocumentScan,
1923
+ forgetDocumentScan, documentDuplicates,
1815
1924
  };
1816
1925
 
1817
1926
  /*
@@ -9,6 +9,10 @@
9
9
  * → { ok, id, path, written, sig } New initiative (requirePagePress)
10
10
  * POST /api/initiatives/rescan { ws } → { ok, started, running } read the repository again now (requirePagePress);
11
11
  * nothing is written: the cached rows go stale and one derivation starts
12
+ * POST /api/initiatives/duplicates { ws } → { ok, profile, archive, groups } documents that share an initiative and a
13
+ * role (scan-documents S10; requirePagePress; nothing is written)
14
+ * POST /api/initiatives/tidy { ws, keep: [{ id, role, keep }] } → { ok, archive, moved, skipped } move every other
15
+ * document of each named group to the archive (lib/tidy-documents.js)
12
16
  * POST /api/placements { ws, events: [placed | created | adopted | dropped] }
13
17
  * → { ok, written, sig } fixtures/initiatives-api/post-ok.json
14
18
  * POST /api/measurements { ws, events: [measured] } → { ok, written, sig }
@@ -46,6 +50,7 @@ const breakdownExport = require('./breakdown-export.js');
46
50
  const consoleState = require('./console-state');
47
51
  const initiativeFiles = require('./initiative-files');
48
52
  const jiraTickets = require('./jira-tickets.js');
53
+ const tidy = require('./tidy-documents.js');
49
54
 
50
55
  const HORIZON_RANK = { now: 0, next: 1, later: 2 };
51
56
  const FILE_KEYS = [['brief_path', 'brief', 'Brief'], ['prd_path', 'prd', 'PRD'], ['rubric_path', 'rubric', 'Rubric'], ['doc_path', 'doc', 'Document']];
@@ -190,9 +195,23 @@ function roadmapOf(w, F) {
190
195
  dormant,
191
196
  proposals: F.placed.proposals.map((p) => ({ eid: p.eid, initiative: p.init, title: title(p.init), horizon: p.horizon, after: p.after, by: actorLib.pageName(p.by), at: p.at })),
192
197
  flags: flagsOf(F),
198
+ scan: scanSummary(F),
193
199
  };
194
200
  }
195
201
 
202
+ /**
203
+ * What the document scan found across the repository (scan-documents S7): how many initiatives it read, how many it
204
+ * found a document or a Jira key for, and the repository's profile — the scan reads documents in a `pm-artifacts`
205
+ * repository only, and the Scan repository toast says so instead of a bare "None moved".
206
+ */
207
+ function scanSummary(F) {
208
+ const recs = [...F.byId.values()];
209
+ const hasAny = (f) => !!f && ['brief', 'draft', 'review', 'handoff'].some((k) => Array.isArray(f[k]) && f[k].length);
210
+ const handedToJira = (r) => Array.isArray(r.steps) && r.steps.some((s) => s.key === 'handoff' && s.status === 'done');
211
+ const profile = recs.find((r) => r.profile) ? recs.find((r) => r.profile).profile : null;
212
+ return { initiatives: recs.length, with_documents: recs.filter((r) => hasAny(r.found) || handedToJira(r)).length, profile };
213
+ }
214
+
196
215
  /**
197
216
  * The units the library groups as an initiative (a PRD, a rubric, a prototype folder) with no record behind them. The
198
217
  * panel lists them under "no record"; without this the Roadmap and the List hid them, so a PRD-only initiative was
@@ -540,6 +559,20 @@ async function handle(req, res, url, ctx) {
540
559
  ctx.json(res, { ok: true, ws: w.id, started: r.started, running: r.running });
541
560
  return true;
542
561
  }
562
+ // Tidy duplicates before a scan (scan-documents S10). The list is a POST so its path cannot be read as an initiative id.
563
+ if (p === '/api/initiatives/duplicates' || p === '/api/initiatives/tidy') {
564
+ const g = guard.requirePagePress(req, body);
565
+ if (!g.ok) return answer(ctx, res, g), true;
566
+ const loaded = consoleState.loadEngine();
567
+ if (!loaded.ok) { ctx.json(res, { ok: false, status: 500, error: 'the workflow engine could not be loaded' }, 500); return true; }
568
+ const r = p === '/api/initiatives/duplicates'
569
+ ? tidy.preview(loaded.engine, w.root)
570
+ : tidy.apply(loaded.engine, w.root, body.keep, { actor: actorLib.fromUi() });
571
+ if (!r.ok) { ctx.json(res, { ok: false, status: r.status || 500, error: r.error }, r.status || 500); return true; }
572
+ // the page scans next (POST /rescan), which drops the cached rows; dropping them here would lose the answer before the tidy
573
+ ctx.json(res, { ws: w.id, ...r });
574
+ return true;
575
+ }
543
576
  if (p === '/api/initiatives') {
544
577
  const g = guard.requirePagePress(req, body);
545
578
  if (!g.ok) return answer(ctx, res, g), true;
@@ -0,0 +1,86 @@
1
+ 'use strict';
2
+ /* tidy-documents.js — move duplicate documents out of the way before a scan (scan-documents S10).
3
+ *
4
+ * preview(engine, root) → { ok, profile, archive, groups } the engine's documentDuplicates, fresh
5
+ * apply(engine, root, keep, { actor, now }) → { ok, moved: [{ from, to, id, role }], skipped: [{ id, role, why }] }
6
+ * | { ok: false, status, error }
7
+ *
8
+ * `keep` is [{ id, role, keep }]: one entry for each group the PM tidies, naming the document to keep. A group that is
9
+ * not named stays as it is. The server reads the duplicates again and moves only files its own fresh list holds, so a
10
+ * page cannot name a file to move. Every other document of a named group moves to `<state>/archive/duplicates/` under
11
+ * its original path, with `git mv` when git tracks it (the move shows as a rename) and a plain rename otherwise.
12
+ * Nothing is deleted and no record is written. Each move is one line in `<archive>/moves.jsonl`.
13
+ */
14
+ const fs = require('fs');
15
+ const path = require('path');
16
+ const { execFileSync } = require('child_process');
17
+
18
+ const ROLES = ['brief', 'draft', 'review', 'handoff'];
19
+ const MAX_GROUPS = 500;
20
+
21
+ function preview(engine, root) {
22
+ if (!engine || typeof engine.documentDuplicates !== 'function') return { ok: false, status: 501, error: 'this workspace engine cannot list duplicates; update the kit' };
23
+ return engine.documentDuplicates(root, { fresh: true });
24
+ }
25
+
26
+ /** A free path under the archive for `rel`: the same path, or `name.2.ext`, `name.3.ext`, … when one is already there. */
27
+ function freeTarget(root, archive, rel) {
28
+ const base = archive + rel;
29
+ if (!fs.existsSync(path.join(root, base))) return base;
30
+ const ext = path.posix.extname(base);
31
+ const stem = base.slice(0, base.length - ext.length);
32
+ for (let n = 2; n < 1000; n++) {
33
+ const t = `${stem}.${n}${ext}`;
34
+ if (!fs.existsSync(path.join(root, t))) return t;
35
+ }
36
+ return null;
37
+ }
38
+
39
+ function tracked(root, rel) {
40
+ try { execFileSync('git', ['-C', root, 'ls-files', '--error-unmatch', '--', rel], { stdio: 'ignore' }); return true; } catch { return false; }
41
+ }
42
+
43
+ function move(root, from, to) {
44
+ fs.mkdirSync(path.dirname(path.join(root, to)), { recursive: true });
45
+ if (tracked(root, from)) {
46
+ try { execFileSync('git', ['-C', root, 'mv', '--', from, to], { stdio: 'ignore' }); return; } catch { /* fall back to a rename */ }
47
+ }
48
+ fs.renameSync(path.join(root, from), path.join(root, to));
49
+ }
50
+
51
+ function apply(engine, root, keep, { actor, now } = {}) {
52
+ if (!Array.isArray(keep) || !keep.length) return { ok: false, status: 400, error: 'send { keep: [{ id, role, keep }] } for at least one group' };
53
+ if (keep.length > MAX_GROUPS) return { ok: false, status: 400, error: `at most ${MAX_GROUPS} groups at once` };
54
+ for (const k of keep) {
55
+ if (!k || typeof k.id !== 'string' || !ROLES.includes(k.role) || typeof k.keep !== 'string') {
56
+ return { ok: false, status: 400, error: 'each entry is { id, role, keep } with role one of ' + ROLES.join(', ') };
57
+ }
58
+ }
59
+ const p = preview(engine, root);
60
+ if (!p.ok) return p;
61
+ const at = (now || new Date()).toISOString();
62
+ const by = (actor && actor.by) || 'pm:unknown';
63
+ const moved = [];
64
+ const skipped = [];
65
+ for (const k of keep) {
66
+ const g = p.groups.find((x) => x.id === k.id && x.role === k.role);
67
+ if (!g) { skipped.push({ id: k.id, role: k.role, why: 'no longer has duplicates' }); continue; }
68
+ if (!g.files.some((f) => f.path === k.keep)) { skipped.push({ id: k.id, role: k.role, why: `${k.keep} is not one of its documents` }); continue; }
69
+ for (const f of g.files) {
70
+ if (f.path === k.keep) continue;
71
+ const to = freeTarget(root, p.archive, f.path);
72
+ if (!to) { skipped.push({ id: k.id, role: k.role, why: `no free place in the archive for ${f.path}` }); continue; }
73
+ try { move(root, f.path, to); } catch (e) { skipped.push({ id: k.id, role: k.role, why: `${f.path} could not be moved: ${String((e && e.message) || e).slice(0, 120)}` }); continue; }
74
+ moved.push({ from: f.path, to, id: k.id, role: k.role });
75
+ }
76
+ }
77
+ if (moved.length) {
78
+ const log = path.join(root, p.archive, 'moves.jsonl');
79
+ fs.mkdirSync(path.dirname(log), { recursive: true });
80
+ fs.appendFileSync(log, moved.map((m) => JSON.stringify({ ev: 'archived', from: m.from, to: m.to, initiative: m.id, role: m.role, at, by })).join('\n') + '\n');
81
+ if (engine.forgetDocumentScan) engine.forgetDocumentScan(root);
82
+ }
83
+ return { ok: true, archive: p.archive, moved, skipped };
84
+ }
85
+
86
+ module.exports = { preview, apply, ROLES };
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.70.1",
3
+ "version": "0.71.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.70.1",
3
+ "version": "0.71.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",
@@ -1570,6 +1570,65 @@
1570
1570
  color: var(--destructive);
1571
1571
  }
1572
1572
 
1573
+ /* the tidy sheet (scan-documents S10): one group per initiative and kind of document */
1574
+ .in-tidy {
1575
+ display: flex;
1576
+ flex-direction: column;
1577
+ gap: 12px;
1578
+ }
1579
+ .in-tidy__group {
1580
+ margin: 0;
1581
+ padding: 8px 12px;
1582
+ border: 1px solid var(--border);
1583
+ min-width: 0;
1584
+ }
1585
+ .in-tidy__head {
1586
+ display: flex;
1587
+ align-items: center;
1588
+ gap: 8px;
1589
+ font-size: 14px;
1590
+ line-height: 20px;
1591
+ font-weight: 600;
1592
+ color: var(--foreground);
1593
+ }
1594
+ .in-tidy__file {
1595
+ display: flex;
1596
+ align-items: flex-start;
1597
+ gap: 8px;
1598
+ padding: 4px 0 4px 24px;
1599
+ font-size: 12px;
1600
+ line-height: 16px;
1601
+ color: var(--foreground);
1602
+ overflow-wrap: anywhere;
1603
+ }
1604
+ .in-tidy__file input {
1605
+ margin-top: 2px;
1606
+ flex: none;
1607
+ }
1608
+ .in-tidy__file input:focus-visible,
1609
+ .in-tidy__head input:focus-visible {
1610
+ outline: 2px solid color-mix(in oklab, var(--ring) 50%, var(--foreground));
1611
+ outline-offset: 2px;
1612
+ }
1613
+ @media (pointer: coarse) {
1614
+ .in-tidy__file input,
1615
+ .in-tidy__head input {
1616
+ min-height: 44px;
1617
+ min-width: 44px;
1618
+ margin-top: 0;
1619
+ }
1620
+ .in-tidy__file {
1621
+ min-height: 48px;
1622
+ align-items: center;
1623
+ }
1624
+ }
1625
+ .in-tidy__path {
1626
+ font-family: var(--mono);
1627
+ }
1628
+ .in-tidy__group[data-off] .in-tidy__file {
1629
+ color: var(--muted-foreground);
1630
+ }
1631
+
1573
1632
  /* the Roadmap */
1574
1633
  .in-roadmap {
1575
1634
  display: flex;
@@ -11,6 +11,8 @@
11
11
  * POST /api/placements { ws, events: [placed…] } every move, reorder and check-by
12
12
  * POST /api/initiatives { ws, title, lane, anchor, intent?, from? } the New-initiative form and a triage card (one body, no events)
13
13
  * POST /api/initiatives/rescan { ws } the Re-scan press: read the repository again now (nothing is written)
14
+ * POST /api/initiatives/duplicates { ws } documents that share an initiative and a role (S10; nothing is written)
15
+ * POST /api/initiatives/tidy { ws, keep } move every other document of each kept group to the archive (S10)
14
16
  *
15
17
  * Every GET of /api/initiatives answers 202 { ok: false, pending: true } until the repository's first derivation lands (2C,
16
18
  * about 260 ms a record); a read waits and asks again, so a caller sees the answer or a real error, never the 202.
@@ -50,21 +52,31 @@ export const drop = (ws, proposal) => pmPost('/api/placements', { ws, events: [{
50
52
  /** A new initiative: from the form, or from a triaged capture (`from` is the capture's id). It lands in Later. */
51
53
  /** Read the repository again now. Answers when the new derivation has landed (the records part of the sig stops reading
52
54
  * `~stale`), or { ok: true, slow: true } when it is still running after about three minutes. */
53
- export async function rescan(ws) {
55
+ export async function rescan(ws, opts) {
54
56
  const roadmap = () => pmGet('/api/initiatives?ws=' + enc(ws) + '&view=roadmap');
55
57
  // the answer before the scan, so the caller can say what moved (scan-documents S7)
56
58
  const before = await roadmap();
59
+ // a step between that answer and the scan: the tidy (S10), so its moves count as the scan's
60
+ let tidied = null;
61
+ if (opts && typeof opts.first === 'function') {
62
+ tidied = await opts.first();
63
+ if (!tidied || !tidied.ok) return tidied || { ok: false, error: 'no answer' };
64
+ }
57
65
  const a = await pmPost('/api/initiatives/rescan', { ws });
58
66
  if (!a || !a.ok) return a || { ok: false, error: 'no answer' };
59
67
  for (let k = 0; k < 120; k++) {
60
68
  await sleep(1500);
61
69
  const r = await roadmap();
62
70
  const rec = r && r.sig ? String(r.sig.records || '') : '';
63
- if (r && r.ok && rec && !rec.endsWith('~stale')) return { ok: true, before: before && before.ok ? before : null, after: r };
71
+ if (r && r.ok && rec && !rec.endsWith('~stale')) return { ok: true, before: before && before.ok ? before : null, after: r, tidied };
64
72
  }
65
- return { ok: true, slow: true };
73
+ return { ok: true, slow: true, tidied };
66
74
  }
67
75
 
76
+ /** The duplicates a tidy offers (S10), and the tidy itself: `keep` is [{ id, role, keep }] for each group to tidy. */
77
+ export const duplicates = (ws) => pmPost('/api/initiatives/duplicates', { ws });
78
+ export const tidy = (ws, keep) => pmPost('/api/initiatives/tidy', { ws, keep });
79
+
68
80
  export const createInitiative = (ws, fields) => pmPost('/api/initiatives', { ws, ...fields });
69
81
 
70
82
  export const setLifecycle = (ws, id, state, reason) => pmPost('/api/lifecycle', { ws, initiative_id: id, state, reason: reason || '' });
@@ -32,12 +32,30 @@ export const COPY = {
32
32
  rescanning: 'Scanning the repository…',
33
33
  rescanHint: 'Check every initiative against the repository and move it to its real stage',
34
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) => {
35
+ // `scan` is the roadmap's summary ({ initiatives, with_documents, profile }): it says why nothing moved.
36
+ rescanResult: (checked, moves, scan) => {
36
37
  const n = moves.reduce((a, m) => a + m.count, 0);
37
38
  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(', ')}.`;
39
+ const body = n ? `${n} moved: ${moves.map((m) => `${m.count} to ${m.label}`).join(', ')}.` : 'None moved.';
40
+ if (!scan) return `${head} ${body}`;
41
+ if (scan.profile && scan.profile !== 'pm-artifacts') return `${head} ${body} This repository is set up for code, so the scan reads builds, not documents.`;
42
+ const w = scan.with_documents || 0;
43
+ if (!w) return `${head} ${body} No document or Jira key matched an initiative. The guide says how files are matched.`;
44
+ return `${head} ${body} Documents or Jira keys found for ${w} of them.`;
40
45
  },
46
+ // the tidy before a scan (scan-documents S10)
47
+ tidyTitle: 'Tidy duplicate documents',
48
+ tidyBody: (groups, inits, archive) => `${inits} ${inits === 1 ? 'initiative has' : 'initiatives have'} more than one document of one kind (${groups} ${groups === 1 ? 'group' : 'groups'}). Pick the document to keep in each group. The others move to ${archive}. Nothing is deleted, and the move is logged there.`,
49
+ tidyRole: { brief: 'Brief', draft: 'PRD or draft', review: 'Evaluation', handoff: 'Handoff' },
50
+ tidyGroupToggle: 'Tidy this group',
51
+ tidyNamed: 'named in the record',
52
+ tidyShared: (p) => `${p} also belongs to another initiative and stays`,
53
+ tidyChanged: (d) => `changed ${d}`,
54
+ tidyConfirm: (n) => `Archive ${n} and scan`,
55
+ tidySkip: 'Scan without tidying',
56
+ tidyNone: 'Nothing to archive: every group is off.',
57
+ tidyArchived: (n) => `Archived ${n} duplicate ${n === 1 ? 'document' : 'documents'}.`,
58
+ tidyFailed: (why) => `Could not tidy: ${why}`,
41
59
  rescanDone: (t) => `Read again at ${t}`,
42
60
  rescanFailed: (why) => `Could not re-scan: ${why}`,
43
61
  rescanSlow: 'The re-scan is still running. The view updates when it ends.',
@@ -6,14 +6,16 @@
6
6
  * confirmSheet({…}) a sheet that asks for one press (a PM-only act): an optional choice, a reason, Cancel, Confirm
7
7
  * checkBySheet({…}) a sheet with a date and Save or Clear
8
8
  * say(out, ok) a toast for the answer of a write: the success text, or the server's refusal
9
- * rescanRepo(ws) read the repository again now, with a toast at the start and at the end (scan-consistency R6)
9
+ * rescanRepo(ws) read the repository again now, with a toast at the start and at the end (scan-consistency R6);
10
+ * the tidy sheet first when a document repository has duplicates (scan-documents S10)
11
+ * tidySheet(duplicates) the groups of duplicate documents, the one to keep in each, and Cancel, Skip or Archive
10
12
  * No native dialog is used anywhere here. Every control is a primitive (primitives.js).
11
13
  */
12
14
 
13
15
  import { el } from './dom.js';
14
16
  import { button, field, signal, sheet, tabs, toast } from './primitives.js';
15
17
  import { COPY } from './initiatives-copy.js';
16
- import { rescan } from './initiatives-api.js';
18
+ import { rescan, duplicates, tidy } from './initiatives-api.js';
17
19
 
18
20
  export const horizonLabel = (h) => COPY.horizons[h] || h;
19
21
  export const progressText = (row) => (row && row.progress && row.progress.label) || '';
@@ -48,16 +50,77 @@ export function scanMoves(before, after) {
48
50
  return { checked: rows.length, moves };
49
51
  }
50
52
 
51
- /** Re-scan: read the repository again now. A toast says it started and another says how it ended. Answers the rescan
52
- * answer; the caller reloads what it shows when `ok`. */
53
+ /**
54
+ * The tidy sheet (scan-documents S10): each group of documents that share an initiative and a role, with the one to keep
55
+ * chosen, a switch to leave the group as it is, and three answers. Resolves { keep: [{ id, role, keep }] } on the confirm,
56
+ * 'skip' on Scan without tidying, and null on Cancel, Escape or the backdrop.
57
+ */
58
+ export function tidySheet(d) {
59
+ return new Promise((resolve) => {
60
+ let settled = false;
61
+ const finish = (v) => { if (settled) return; settled = true; resolve(v); };
62
+ const state = d.groups.map((g) => ({ g, on: true, keep: g.keep }));
63
+ const count = () => state.reduce((n, x) => n + (x.on ? x.g.files.length - 1 : 0), 0);
64
+ const day = (t) => (t ? new Date(t * 1000).toISOString().slice(0, 10) : '');
65
+ const err = el('p', { class: 'in-error', role: 'alert', 'data-in-error': '' });
66
+ err.hidden = true;
67
+ const groups = state.map((x, gi) => {
68
+ const box = el('fieldset', { class: 'in-tidy__group', 'data-in-tidy-group': x.g.id + ':' + x.g.role });
69
+ const toggle = el('input', { type: 'checkbox', checked: true, 'aria-label': COPY.tidyGroupToggle, 'data-in-tidy-on': '' });
70
+ toggle.addEventListener('change', () => { x.on = toggle.checked; if (x.on) box.removeAttribute('data-off'); else box.setAttribute('data-off', ''); go.textContent = COPY.tidyConfirm(count()); });
71
+ const head = el('legend', { class: 'in-tidy__head' }, toggle, `${x.g.title} · ${COPY.tidyRole[x.g.role] || x.g.role}`);
72
+ const files = x.g.files.map((f) => {
73
+ const radio = el('input', { type: 'radio', name: 'in-tidy-' + gi, value: f.path, checked: f.path === x.keep, 'data-in-tidy-keep': f.path });
74
+ radio.addEventListener('change', () => { if (radio.checked) x.keep = f.path; });
75
+ const meta = [f.changed ? COPY.tidyChanged(day(f.changed)) : '', x.g.named && f.path === x.g.keep ? COPY.tidyNamed : ''].filter(Boolean).join(' · ');
76
+ return el('label', { class: 'in-tidy__file' }, radio, el('span', null, el('span', { class: 'in-tidy__path' }, f.path), meta ? el('span', { class: 'in-note' }, ' · ' + meta) : null));
77
+ });
78
+ const shared = (x.g.shared || []).map((p) => el('p', { class: 'in-note in-tidy__file' }, COPY.tidyShared(p)));
79
+ box.replaceChildren(head, ...files, ...shared);
80
+ return box;
81
+ });
82
+ const inits = new Set(d.groups.map((g) => g.id)).size;
83
+ const go = button({
84
+ label: COPY.tidyConfirm(count()), variant: 'primary', attrs: { 'data-in-tidy-confirm': '' },
85
+ onClick: () => {
86
+ const keep = state.filter((x) => x.on).map((x) => ({ id: x.g.id, role: x.g.role, keep: x.keep }));
87
+ if (!keep.length) { err.textContent = COPY.tidyNone; err.hidden = false; return; }
88
+ finish({ keep }); s.close();
89
+ },
90
+ });
91
+ const s = sheet({
92
+ mode: 'auto', title: COPY.tidyTitle,
93
+ body: [el('p', { class: 'in-note' }, COPY.tidyBody(d.groups.length, inits, d.archive)), el('div', { class: 'in-tidy' }, groups), err],
94
+ attrs: { 'data-in-sheet': 'tidy' },
95
+ onClose: () => finish(null),
96
+ actions: [
97
+ button({ label: COPY.cancel, variant: 'outline', onClick: () => s.close(), attrs: { 'data-in-cancel': '' } }),
98
+ button({ label: COPY.tidySkip, variant: 'outline', onClick: () => { finish('skip'); s.close(); }, attrs: { 'data-in-tidy-skip': '' } }),
99
+ go,
100
+ ],
101
+ });
102
+ });
103
+ }
104
+
105
+ /** Re-scan: read the repository again now. In a document repository with duplicates, the tidy sheet comes first (S10).
106
+ * A toast says the scan started and another says how it ended. Answers the rescan answer; the caller reloads what it
107
+ * shows when `ok`. */
53
108
  export async function rescanRepo(ws) {
109
+ let first = null;
110
+ const d = await duplicates(ws);
111
+ if (d && d.ok && Array.isArray(d.groups) && d.groups.length) {
112
+ const pick = await tidySheet(d);
113
+ if (!pick) return { ok: false, cancelled: true };
114
+ if (pick !== 'skip') first = () => tidy(ws, pick.keep);
115
+ }
54
116
  toast({ text: COPY.rescanning, timeout: 4000 });
55
- const a = await rescan(ws);
117
+ const a = await rescan(ws, first ? { first } : null);
56
118
  const t = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
57
119
  const moved = a && a.ok && a.after ? scanMoves(a.before, a.after) : null;
58
- if (!a || !a.ok) toast({ text: COPY.rescanFailed((a && a.error) || 'no answer'), tone: 'error', timeout: 8000 });
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 });
120
+ const archived = a && a.tidied && Array.isArray(a.tidied.moved) && a.tidied.moved.length ? COPY.tidyArchived(a.tidied.moved.length) + ' ' : '';
121
+ if (!a || !a.ok) toast({ text: first && a && !a.after && !a.slow ? COPY.tidyFailed((a && a.error) || 'no answer') : COPY.rescanFailed((a && a.error) || 'no answer'), tone: 'error', timeout: 8000 });
122
+ else if (a.slow) toast({ text: archived + COPY.rescanSlow, timeout: 6000 });
123
+ else toast({ text: archived + (moved ? COPY.rescanResult(moved.checked, moved.moves, (a.after && a.after.scan) || null) : COPY.rescanDone(t)), timeout: 10000 });
61
124
  try { if (a && a.ok) window.dispatchEvent(new CustomEvent('pmos:initiatives-changed')); } catch { /* no window */ }
62
125
  return a || { ok: false };
63
126
  }