create-pmos 0.70.1 → 0.71.1

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.1",
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@5a5029e
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:40:31Z"}
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:40:31Z"}
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:40:31Z"}
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
 
@@ -17,7 +17,7 @@ The top bar has these parts, from left to right:
17
17
  - **Branches and sessions** is in ⌘K. It opens a sheet with every branch of the repository (main first) and how many sessions run in each. Press a branch to see its sessions, open one, end one, or start a session there. **+ branch** makes a branch, and **+ session** starts one with a branch, an agent and a profile. With All repositories, the sheet lists every repository.
18
18
  - **Agenda, Initiatives, Board, Reports, Docs and People** are the six places. Press one to show it. The Agenda count shows how many items need you. Docs shows whether each document is the same as its Google Docs and Confluence copy. People shows the people you work with in the repository ([People](people.md)).
19
19
  - **Search or capture ⌘K** opens the command palette. Type to find an initiative, a repository, an open tab or a command.
20
- - **Sync** shows only when the current repository is not in sync with its remote. Press it to fetch, bring in the work of the other machine, record your changes and push. If the repository has not fetched in the last 10 minutes, Sync also shows, because a push from another machine is not visible until a fetch. When the repository fetched recently and is in sync, Sync is not shown. When two machines add lines to the same ledger (a `.jsonl` file under `pmos/` or `planning/`), Sync keeps the lines from both machines. If Sync stops, the message names the step that stopped. If both machines changed the same lines of another file, the message names the file. Keep the version you want in that file on one machine, sync it, then sync the other machine.
20
+ - **Sync** shows only when the current repository is not in sync with its remote. Press it to fetch, bring in the work of the other machine, record your changes and push. If the repository has not fetched in the last 10 minutes, Sync also shows, because a push from another machine is not visible until a fetch. When the repository fetched recently and is in sync, Sync is not shown. When two machines add lines to the same ledger (a `.jsonl` file under `pmos/` or `planning/`), Sync keeps the lines from both machines. If Sync stops, the message names the step that stopped. If both machines changed the same lines of another file, Sync changes nothing and names the files. Then choose **Push this machine’s version** or **Pull the other machine’s version**. Sync keeps that version of those files, keeps all other changes from both machines, and finishes. It never overwrites the remote history.
21
21
  - **Sync on its own.** If you forget to press Sync, the workspace syncs for you. It syncs each repository once when the workspace starts. It also syncs a repository that has records not yet pushed when no record changed for 5 minutes. In a document repository it syncs every changed file on any branch. In a code repository it syncs only on the default branch (for example `main`), and only when every commit not yet pushed changes records and no code. If you close the tab while records are not yet pushed, the browser asks you first. To turn automatic sync off, start the workspace with `PMOS_AUTO_SYNC=0`.
22
22
  - **Settings** opens the settings of the workspace.
23
23
 
@@ -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;
@@ -118,7 +118,36 @@ async function status(root) {
118
118
  }
119
119
 
120
120
  // The press. Returns { ok, steps:[{step, ok, note}], stopped_at?, message }.
121
- async function sync(root, { label } = {}) {
121
+ // While a replay stops on a conflict, the PM's choice settles each conflicted file whole. In a rebase, stage 2 (`--ours`)
122
+ // is the remote's side being built on, and stage 3 (`--theirs`) is this machine's commit being replayed. A side that
123
+ // deleted the file is honoured by removing it. A commit left with nothing of its own is skipped. Bounded: each round
124
+ // replays one commit, so the loop ends after at most one round per unpushed commit plus one.
125
+ async function settleReplay(root, prefer, rounds) {
126
+ for (let i = 0; i <= rounds; i++) {
127
+ const rp = await run(root, ['rev-parse', '--git-path', 'rebase-merge']);
128
+ if (!rp.ok || !fs.existsSync(path.resolve(root, rp.out))) return { ok: true };
129
+ const u = await run(root, ['diff', '--name-only', '--diff-filter=U']);
130
+ const files = u.ok && u.out ? u.out.split('\n').filter(Boolean) : [];
131
+ if (!files.length) return { ok: false, err: 'the replay stopped with no conflicted file' };
132
+ const stage = prefer === 'mine' ? '3' : '2';
133
+ for (const f of files) {
134
+ const has = await run(root, ['cat-file', '-e', `:${stage}:${f}`]);
135
+ const r = has.ok ? await run(root, ['checkout', prefer === 'mine' ? '--theirs' : '--ours', '--', f]) : await run(root, ['rm', '-q', '--', f]);
136
+ if (!r.ok) return { ok: false, err: r.err };
137
+ if (has.ok) { const a = await run(root, ['add', '--', f]); if (!a.ok) return { ok: false, err: a.err }; }
138
+ }
139
+ const empty = (await run(root, ['diff', '--cached', '--quiet'])).ok;
140
+ const next = await run(root, ['-c', 'core.editor=true', 'rebase', empty ? '--skip' : '--continue']);
141
+ if (next.ok) return { ok: true };
142
+ }
143
+ return { ok: false, err: 'the replay did not finish' };
144
+ }
145
+
146
+ // `prefer` is the PM's answer after a stop on a conflict, and only the press passes it (never auto-sync): 'mine' keeps
147
+ // this machine's version of each file both machines changed, 'theirs' keeps the other machine's. Every other change
148
+ // from both machines is kept either way. Still a rebase of this machine's own commits: no force, no merge commit.
149
+ async function sync(root, { label, prefer } = {}) {
150
+ if (prefer !== undefined && prefer !== 'mine' && prefer !== 'theirs') prefer = undefined;
122
151
  const steps = [];
123
152
  const stop = (step, message) => { steps.push({ step, ok: false, note: message }); return { ok: false, steps, stopped_at: step, message }; };
124
153
  const done = (step, note) => steps.push({ step, ok: true, note });
@@ -157,20 +186,26 @@ async function sync(root, { label } = {}) {
157
186
  if (s2.behind) {
158
187
  // Fast-forward when we have nothing of our own; otherwise replay OUR unpushed commits on top.
159
188
  const attrs = s2.ahead ? unionAttributesFor(root) : null;
160
- const verb = s2.ahead ? [...(attrs ? ['-c', `core.attributesFile=${attrs}`] : []), 'rebase', '--autostash', s2.upstream]
189
+ // With the PM's choice, a conflicted hunk takes that side (-X); in a rebase `theirs` is this machine's commit.
190
+ const side = prefer && s2.ahead ? ['-X', prefer === 'mine' ? 'theirs' : 'ours'] : [];
191
+ const verb = s2.ahead ? [...(attrs ? ['-c', `core.attributesFile=${attrs}`] : []), 'rebase', '--autostash', ...side, s2.upstream]
161
192
  : ['merge', '--ff-only', s2.upstream];
162
- const r = await run(root, verb);
193
+ let r = await run(root, verb);
194
+ if (!r.ok && prefer && s2.ahead) r = await settleReplay(root, prefer, s2.ahead);
163
195
  if (!r.ok) {
164
196
  if (!s2.ahead) return stop('integrate', `cannot fast-forward — ${r.err.split('\n')[0] || 'see git'}`);
165
197
  // Name the files before the abort clears them: the PM has to open these, and "the same lines" alone says not where.
166
198
  const u = await run(root, ['diff', '--name-only', '--diff-filter=U']);
167
199
  const files = u.ok && u.out ? u.out.split('\n').filter(Boolean) : [];
168
200
  await run(root, ['rebase', '--abort']);
169
- return stop('integrate', files.length
170
- ? `both machines changed ${files.join(', ')} — rebase aborted, nothing changed; keep the version you want in each file on one machine, sync it, then sync the other`
171
- : `the two machines changed the same lines — rebase aborted, nothing changed — ${r.err.split('\n')[0] || 'see git'}`);
201
+ const out = stop('integrate', files.length
202
+ ? `both machines changed ${files.join(', ')} — rebase aborted, nothing changed; choose which machine's version to keep`
203
+ : `the two machines changed the same lines — rebase aborted, nothing changed — ${(r.err || '').split('\n')[0] || 'see git'}`);
204
+ // The surface offers the choice only for a conflict it can settle, and never again once one was made.
205
+ if (!prefer) out.conflict = { files };
206
+ return out;
172
207
  }
173
- done('integrate', s2.ahead ? `replayed ${s2.ahead} local commit(s) on the remote` : `fast-forwarded ${s2.behind} commit(s)`);
208
+ done('integrate', s2.ahead ? `replayed ${s2.ahead} local commit(s) on the remote${prefer ? `, keeping ${prefer === 'mine' ? 'this' : 'the other'} machine's version where both changed a file` : ''}` : `fast-forwarded ${s2.behind} commit(s)`);
174
209
  } else done('integrate', 'nothing to bring in');
175
210
 
176
211
  const s3 = await status(root);
@@ -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.1",
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.1",
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",
@@ -39,7 +39,7 @@ import { isMobile, mountMobile, applyMobile } from './mobile.js';
39
39
  /* The frame's parts (Run 3, slice 3A). Small modules with no effect at import: a fixture page loads them and calls none. */
40
40
  import { createTopbar, unsyncedOf } from './topbar.js';
41
41
  import { createDockRail } from './dock.js';
42
- import { SEL, ensureComponentsCss, iconButton, menu, toast } from './primitives.js';
42
+ import { SEL, ensureComponentsCss, iconButton, menu, toast, sheet, button } from './primitives.js';
43
43
  import { ensureSprite } from './icons.js';
44
44
  import { COPY as FC } from './frame-copy.js';
45
45
  import { registerCommand } from './registry.js';
@@ -512,17 +512,34 @@ function syncReading(repo) {
512
512
  }
513
513
  return syncSeen.get(repo).st;
514
514
  }
515
- async function runSync() {
515
+ async function runSync(prefer) {
516
516
  const repo = fr.scope === 'all' ? null : activeRepo();
517
517
  if (!repo || fr.syncing || !mods.data || typeof mods.data.syncRepository !== 'function') return;
518
518
  fr.syncing = true;
519
519
  setState({});
520
520
  let res;
521
- try { res = await mods.data.syncRepository(repo); } catch (e) { res = { ok: false, message: String((e && e.message) || e) }; }
521
+ const opts = prefer === 'mine' || prefer === 'theirs' ? { prefer } : undefined;
522
+ try { res = await mods.data.syncRepository(repo, opts); } catch (e) { res = { ok: false, message: String((e && e.message) || e) }; }
522
523
  fr.syncing = false;
523
524
  syncSeen.delete(repo);
524
- toast({ text: res && res.ok ? FC.sync.done : FC.sync.stopped((res && (res.stopped_at || res.step)) || FC.sync.label, (res && (res.message || res.error)) || '') });
525
525
  setState({});
526
+ // Both machines changed the same lines and nothing changed: the PM chooses which version to keep (D70 amended
527
+ // 2026-10-07). The server offers the choice only once per press, so a choice that still stops ends in the toast.
528
+ if (res && !res.ok && res.conflict) return chooseSyncSide(res.conflict.files || []);
529
+ toast({ text: res && res.ok ? FC.sync.done : FC.sync.stopped((res && (res.stopped_at || res.step)) || FC.sync.label, (res && (res.message || res.error)) || '') });
530
+ }
531
+ function chooseSyncSide(files) {
532
+ const C = FC.sync.conflict;
533
+ let s = null;
534
+ const pick = (side) => () => { s.close(); runSync(side); };
535
+ s = sheet({
536
+ title: C.title, sub: C.sub(files), body: [el('p', {}, C.body)], attrs: { 'data-sync-conflict': '' },
537
+ actions: [
538
+ button({ label: C.push, variant: 'primary', onClick: pick('mine'), attrs: { 'data-sync-prefer': 'mine' } }),
539
+ button({ label: C.pull, onClick: pick('theirs'), attrs: { 'data-sync-prefer': 'theirs' } }),
540
+ button({ label: C.cancel, variant: 'ghost', onClick: () => s.close() }),
541
+ ],
542
+ });
526
543
  }
527
544
 
528
545
  /* Closing the tab with work that is not yet pushed asks first (D70 as amended 2026-10-06): idle auto-sync waits for a
@@ -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;
@@ -1559,11 +1559,12 @@ export async function syncStatusOf(name) {
1559
1559
  return getJson('/api/sync/status', { ws });
1560
1560
  }
1561
1561
 
1562
- export async function syncRepository(name) {
1562
+ // prefer: 'mine' | 'theirs' — the PM's answer after Sync stopped on a conflict (D70 as amended 2026-10-07).
1563
+ export async function syncRepository(name, { prefer } = {}) {
1563
1564
  if (FIXTURE) return { ok: false, message: 'fixture mode — nothing to sync' };
1564
1565
  const ws = wsOf(name);
1565
1566
  if (!ws) return { ok: false, message: 'unknown repository' };
1566
- const res = await postJson('/api/sync', { ws });
1567
+ const res = await postJson('/api/sync', prefer ? { ws, prefer } : { ws });
1567
1568
  const p = products.get(name);
1568
1569
  if (res && res.ok && p) { p.board = null; await loadBoard(p); publishBoards(); }
1569
1570
  return res || { ok: false, message: 'the workspace server did not answer' };
@@ -41,6 +41,15 @@ export const COPY = {
41
41
  title: 'Sync this repository: fetch, bring in the work of the other machine, record your changes, push.',
42
42
  done: 'The repository is in sync.',
43
43
  stopped: (step, why) => 'Sync stopped at ' + step + ': ' + why,
44
+ /** Sync stopped because both machines changed the same lines. The PM chooses which version to keep. */
45
+ conflict: {
46
+ title: 'Both machines changed the same files',
47
+ sub: (files) => (files.length ? files.join(', ') : 'Git did not name the files.'),
48
+ body: 'Nothing changed. Choose which version to keep in these files. All other changes from both machines are kept.',
49
+ push: 'Push this machine’s version',
50
+ pull: 'Pull the other machine’s version',
51
+ cancel: 'Not now',
52
+ },
44
53
  },
45
54
  settings: 'Settings',
46
55
  dock: {
@@ -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
  }
@@ -7095,7 +7095,9 @@ async function handleRequest(req, res) {
7095
7095
  if (syncing.has(w.root)) return json(res, { ok: false, error: 'a sync is already running for this repository' }, 409);
7096
7096
  syncing.add(w.root);
7097
7097
  try {
7098
- const out = await syncLib.sync(w.root, { label: os.hostname() });
7098
+ // After a stop on a conflict the PM may answer which machine's version to keep; only this press passes it.
7099
+ const prefer = body.prefer === 'mine' || body.prefer === 'theirs' ? body.prefer : undefined;
7100
+ const out = await syncLib.sync(w.root, { label: os.hostname(), prefer });
7099
7101
  boardCache.invalidate(w.root);
7100
7102
  return json(res, { ws: w.id, name: w.name, ...out });
7101
7103
  } finally { syncing.delete(w.root); }