create-pmos 0.74.0 → 0.75.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.74.0",
3
+ "version": "0.75.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@fb515e5
1
+ kernel-source: pmos@a8b70bb
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-07T07:25:37Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-07T07:33:59Z"}
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-07T07:25:37Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-07T07:33:59Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -1396,6 +1396,21 @@ function duplicatesArchive(root, L) {
1396
1396
  return path.relative(root, path.join(path.dirname(L.initiatives), 'archive', 'duplicates')).split(path.sep).join('/') + '/';
1397
1397
  }
1398
1398
  function scanRepository(root, L) { return scanDetail(root, L).index; }
1399
+ /** The kinds the PM confirmed (S11): `<state>/document-kinds.jsonl`, the latest line for a path. A kind is a role, or `none`. */
1400
+ const DOCUMENT_KINDS = ['brief', 'draft', 'review', 'handoff', 'none'];
1401
+ function documentKinds(L) {
1402
+ const out = new Map();
1403
+ let text = '';
1404
+ try { text = fs.readFileSync(path.join(path.dirname(L.initiatives), 'document-kinds.jsonl'), 'utf8'); } catch { return out; }
1405
+ for (const line of text.split('\n')) {
1406
+ if (!line.trim()) continue;
1407
+ try {
1408
+ const o = JSON.parse(line);
1409
+ if (o && typeof o.path === 'string' && DOCUMENT_KINDS.includes(o.kind)) out.set(o.path, o.kind);
1410
+ } catch { /* a torn line is skipped */ }
1411
+ }
1412
+ return out;
1413
+ }
1399
1414
  /** The scan's index, and how many initiatives each found document belongs to (`owners`, for the tidy). */
1400
1415
  function scanDetail(root, L) {
1401
1416
  const key = path.resolve(root);
@@ -1403,6 +1418,8 @@ function scanDetail(root, L) {
1403
1418
  if (hit && Date.now() - hit.at < SCAN_TTL_MS) return hit;
1404
1419
  const index = new Map();
1405
1420
  const owners = new Map();
1421
+ const owned = []; // every document tied to an initiative, with or without a role (the Jev check reads them, S11)
1422
+ const kinds = documentKinds(L);
1406
1423
  const keys = recordKeys(L);
1407
1424
  const idSet = new Set(keys.map((k) => k.id));
1408
1425
  const recDir = path.relative(root, L.initiatives).split(path.sep).join('/') + '/';
@@ -1421,10 +1438,13 @@ function scanDetail(root, L) {
1421
1438
  }
1422
1439
  const owner = documentOwners(rel, fm, idSet, keys);
1423
1440
  if (!owner.ids.length) continue;
1424
- const role = documentRole(rel, fm.type, owner.key);
1425
- if (!role) continue;
1426
1441
  // git's index still lists a tracked file that was moved or deleted and not yet staged
1427
1442
  if (!SCAN_TEXT.test(rel) && !fs.existsSync(path.join(root, rel))) continue;
1443
+ const local = documentRole(rel, fm.type, owner.key);
1444
+ const kind = kinds.get(rel) || null; // a kind the PM confirmed outranks the name (S11)
1445
+ const role = kind ? (kind === 'none' ? null : kind) : local;
1446
+ owned.push({ path: rel, ids: owner.ids, role, local, kind, title: fm.title ? String(fm.title) : null });
1447
+ if (!role) continue;
1428
1448
  owners.set(rel, owner.ids.length);
1429
1449
  for (const id of owner.ids) {
1430
1450
  if (!index.has(id)) index.set(id, { brief: [], draft: [], review: [], handoff: [] });
@@ -1432,7 +1452,7 @@ function scanDetail(root, L) {
1432
1452
  }
1433
1453
  }
1434
1454
  for (const v of index.values()) for (const k of Object.keys(v)) v[k].sort();
1435
- const out = { at: Date.now(), index, owners };
1455
+ const out = { at: Date.now(), index, owners, owned };
1436
1456
  _scanCache.set(key, out);
1437
1457
  return out;
1438
1458
  }
@@ -1442,6 +1462,27 @@ function documentChangedAt(root, rel) {
1442
1462
  if (t > 0) return t;
1443
1463
  try { return Math.floor(fs.statSync(path.join(root, rel)).mtimeMs / 1000); } catch { return 0; }
1444
1464
  }
1465
+ /**
1466
+ * Every document the scan ties to an initiative, for the Jev check (S11): { ok, profile, kinds, documents: [{ path, ids,
1467
+ * role, local, kind, title, text }] }. `local` is the role the name gives, `kind` the one the PM confirmed, `role` the one
1468
+ * the scan uses, and `text` says whether the file is Markdown or text (only those can be read by Jev). Nothing is written.
1469
+ */
1470
+ function documentCandidates(root, opts) {
1471
+ const L = layout(root);
1472
+ const profile = profileOf(root);
1473
+ const kinds = path.relative(root, path.join(path.dirname(L.initiatives), 'document-kinds.jsonl')).split(path.sep).join('/');
1474
+ if (profile !== 'pm-artifacts') return { ok: true, profile, kinds, documents: [] };
1475
+ if (opts && opts.fresh) forgetDocumentScan(root);
1476
+ const { owned } = scanDetail(root, L);
1477
+ const titles = {};
1478
+ try {
1479
+ for (const f of fs.readdirSync(L.initiatives)) {
1480
+ if (!f.endsWith('.md') || /^(README|index|log)\.md$/i.test(f)) continue;
1481
+ titles[f.slice(0, -3)] = String((readFrontmatter(path.join(L.initiatives, f)) || {}).title || f.slice(0, -3));
1482
+ }
1483
+ } catch { /* no records */ }
1484
+ return { ok: true, profile, kinds, titles, documents: owned.map((d) => ({ ...d, text: SCAN_TEXT.test(d.path), changed: documentChangedAt(root, d.path) })) };
1485
+ }
1445
1486
  /**
1446
1487
  * The duplicates the tidy offers (S10): for each initiative, each role with more than one document. The group keeps the
1447
1488
  * file the record names, else the most recently changed, else the shortest path. A document that belongs to more than one
@@ -1920,7 +1961,7 @@ module.exports = {
1920
1961
  historyRange,
1921
1962
  ENFORCED_FROM,
1922
1963
  lanesFor, laneNames,
1923
- forgetDocumentScan, documentDuplicates,
1964
+ forgetDocumentScan, documentDuplicates, documentCandidates, DOCUMENT_KINDS,
1924
1965
  };
1925
1966
 
1926
1967
  /*
@@ -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-07T07:25:37Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-07T07:33:59Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -48,6 +48,16 @@ Select another file to keep a different one. Clear a group's check box to leave
48
48
 
49
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.
50
50
 
51
+ ### Check documents with Jev
52
+
53
+ A file name, a folder or a `type:` line can give a document the wrong kind. When Jev is set up for the repository, **Scan repository** first asks Jev to read each document that is tied to an initiative and say what it is: a problem brief, a PRD, an evaluation, a handoff, or none of these. Jev is set up when three things are true: a TypeSafe key is saved, validation is on for the repository, and the switch that lets Jev categorize documents is on.
54
+
55
+ 1. The **Check documents with Jev** sheet says how many documents Jev reads and how many requests that sends. Nothing is sent before you press **Check with Jev**. Jev gets each document's title, headings and opening only. It never gets the path or the whole file. A document in an excluded folder, or one that may contain a secret, is not sent. A document Jev read before, and that did not change, is not sent again.
56
+ 2. The next sheet lists each document that Jev reads as a different kind, with how sure Jev is. A kind that Jev is unsure of is not listed. Clear a check box to keep the kind the scan gives now. Then the sheet lists the duplicates by Jev's kinds, and in each group it selects the version Jev picks as the most complete and current. The file the record names is still kept first.
57
+ 3. Press the confirm button. Each changed kind is one line in `pmos/state/document-kinds.jsonl`, and the scan uses that kind from now on. A document set to "none of these" leaves the scan. Then the duplicates move to the archive as in [Tidy duplicate documents](#tidy-duplicate-documents), and the scan runs. Its toast says how many kinds changed and how many documents moved.
58
+
59
+ Press **Scan without Jev** to use the file names only. When Jev is not set up, the scan toast says which part is missing, and the tidy sheet uses the file names.
60
+
51
61
  ## The Roadmap
52
62
 
53
63
  The Roadmap has three columns: Now, Next and Later. Each column is a ranked list of cards. The line at the top reads, for example, "Now · 5 KR · 3 health". It counts the initiatives in Now by what they serve: a key result (KR) or a health budget. When the rules set an advisory cap for Now, the line shows it. The cap only advises. It never stops a move.
@@ -1396,6 +1396,21 @@ function duplicatesArchive(root, L) {
1396
1396
  return path.relative(root, path.join(path.dirname(L.initiatives), 'archive', 'duplicates')).split(path.sep).join('/') + '/';
1397
1397
  }
1398
1398
  function scanRepository(root, L) { return scanDetail(root, L).index; }
1399
+ /** The kinds the PM confirmed (S11): `<state>/document-kinds.jsonl`, the latest line for a path. A kind is a role, or `none`. */
1400
+ const DOCUMENT_KINDS = ['brief', 'draft', 'review', 'handoff', 'none'];
1401
+ function documentKinds(L) {
1402
+ const out = new Map();
1403
+ let text = '';
1404
+ try { text = fs.readFileSync(path.join(path.dirname(L.initiatives), 'document-kinds.jsonl'), 'utf8'); } catch { return out; }
1405
+ for (const line of text.split('\n')) {
1406
+ if (!line.trim()) continue;
1407
+ try {
1408
+ const o = JSON.parse(line);
1409
+ if (o && typeof o.path === 'string' && DOCUMENT_KINDS.includes(o.kind)) out.set(o.path, o.kind);
1410
+ } catch { /* a torn line is skipped */ }
1411
+ }
1412
+ return out;
1413
+ }
1399
1414
  /** The scan's index, and how many initiatives each found document belongs to (`owners`, for the tidy). */
1400
1415
  function scanDetail(root, L) {
1401
1416
  const key = path.resolve(root);
@@ -1403,6 +1418,8 @@ function scanDetail(root, L) {
1403
1418
  if (hit && Date.now() - hit.at < SCAN_TTL_MS) return hit;
1404
1419
  const index = new Map();
1405
1420
  const owners = new Map();
1421
+ const owned = []; // every document tied to an initiative, with or without a role (the Jev check reads them, S11)
1422
+ const kinds = documentKinds(L);
1406
1423
  const keys = recordKeys(L);
1407
1424
  const idSet = new Set(keys.map((k) => k.id));
1408
1425
  const recDir = path.relative(root, L.initiatives).split(path.sep).join('/') + '/';
@@ -1421,10 +1438,13 @@ function scanDetail(root, L) {
1421
1438
  }
1422
1439
  const owner = documentOwners(rel, fm, idSet, keys);
1423
1440
  if (!owner.ids.length) continue;
1424
- const role = documentRole(rel, fm.type, owner.key);
1425
- if (!role) continue;
1426
1441
  // git's index still lists a tracked file that was moved or deleted and not yet staged
1427
1442
  if (!SCAN_TEXT.test(rel) && !fs.existsSync(path.join(root, rel))) continue;
1443
+ const local = documentRole(rel, fm.type, owner.key);
1444
+ const kind = kinds.get(rel) || null; // a kind the PM confirmed outranks the name (S11)
1445
+ const role = kind ? (kind === 'none' ? null : kind) : local;
1446
+ owned.push({ path: rel, ids: owner.ids, role, local, kind, title: fm.title ? String(fm.title) : null });
1447
+ if (!role) continue;
1428
1448
  owners.set(rel, owner.ids.length);
1429
1449
  for (const id of owner.ids) {
1430
1450
  if (!index.has(id)) index.set(id, { brief: [], draft: [], review: [], handoff: [] });
@@ -1432,7 +1452,7 @@ function scanDetail(root, L) {
1432
1452
  }
1433
1453
  }
1434
1454
  for (const v of index.values()) for (const k of Object.keys(v)) v[k].sort();
1435
- const out = { at: Date.now(), index, owners };
1455
+ const out = { at: Date.now(), index, owners, owned };
1436
1456
  _scanCache.set(key, out);
1437
1457
  return out;
1438
1458
  }
@@ -1442,6 +1462,27 @@ function documentChangedAt(root, rel) {
1442
1462
  if (t > 0) return t;
1443
1463
  try { return Math.floor(fs.statSync(path.join(root, rel)).mtimeMs / 1000); } catch { return 0; }
1444
1464
  }
1465
+ /**
1466
+ * Every document the scan ties to an initiative, for the Jev check (S11): { ok, profile, kinds, documents: [{ path, ids,
1467
+ * role, local, kind, title, text }] }. `local` is the role the name gives, `kind` the one the PM confirmed, `role` the one
1468
+ * the scan uses, and `text` says whether the file is Markdown or text (only those can be read by Jev). Nothing is written.
1469
+ */
1470
+ function documentCandidates(root, opts) {
1471
+ const L = layout(root);
1472
+ const profile = profileOf(root);
1473
+ const kinds = path.relative(root, path.join(path.dirname(L.initiatives), 'document-kinds.jsonl')).split(path.sep).join('/');
1474
+ if (profile !== 'pm-artifacts') return { ok: true, profile, kinds, documents: [] };
1475
+ if (opts && opts.fresh) forgetDocumentScan(root);
1476
+ const { owned } = scanDetail(root, L);
1477
+ const titles = {};
1478
+ try {
1479
+ for (const f of fs.readdirSync(L.initiatives)) {
1480
+ if (!f.endsWith('.md') || /^(README|index|log)\.md$/i.test(f)) continue;
1481
+ titles[f.slice(0, -3)] = String((readFrontmatter(path.join(L.initiatives, f)) || {}).title || f.slice(0, -3));
1482
+ }
1483
+ } catch { /* no records */ }
1484
+ return { ok: true, profile, kinds, titles, documents: owned.map((d) => ({ ...d, text: SCAN_TEXT.test(d.path), changed: documentChangedAt(root, d.path) })) };
1485
+ }
1445
1486
  /**
1446
1487
  * The duplicates the tidy offers (S10): for each initiative, each role with more than one document. The group keeps the
1447
1488
  * file the record names, else the most recently changed, else the shortest path. A document that belongs to more than one
@@ -1920,7 +1961,7 @@ module.exports = {
1920
1961
  historyRange,
1921
1962
  ENFORCED_FROM,
1922
1963
  lanesFor, laneNames,
1923
- forgetDocumentScan, documentDuplicates,
1964
+ forgetDocumentScan, documentDuplicates, documentCandidates, DOCUMENT_KINDS,
1924
1965
  };
1925
1966
 
1926
1967
  /*
@@ -0,0 +1,224 @@
1
+ 'use strict';
2
+ /* jev-documents.js — Jev checks the kind of each initiative document and picks the duplicate to keep (scan-documents S11).
3
+ *
4
+ * The bridge between the repository scan (scripts/workflow-state.js documentCandidates) and the one TypeSafe module. Like
5
+ * lib/jev-categorize.js it opens no client of its own, and no-network.test.js asserts it.
6
+ *
7
+ * plan({ docs, cacheDir, exclude }) → { documents, requests, fingerprint, cached, withheld_count, excluded, host }
8
+ * check({ docs, cacheDir, key, flags, exclude, expectRequests, expectFingerprint, ask, askOpts, now })
9
+ * → { ok, sent, failed, kinds: [...], corrections: [...], groups: [...] }
10
+ * record(root, kindsFile, corrections, { actor, now }) → { ok, written } the PM's confirmed corrections, appended
11
+ *
12
+ * TWO PASSES. (1) KIND: one Choice question per Markdown or text document the scan ties to an initiative — problem brief,
13
+ * PRD, evaluation, handoff, or none of these. (2) KEEP: then, by those kinds, one Choice question per group of more than
14
+ * one document of one kind for one initiative — which one is the most complete and current version. The PM confirms the
15
+ * number of kind questions before the first pass sends anything; the keep questions follow from the answers.
16
+ *
17
+ * WHAT LEAVES THE MACHINE. The D82 snippet of each document (jev-categorize.snippetFor: title, headings and the opening,
18
+ * at most 3000 characters). Never the path, never the frontmatter, never the whole file. A document under an excluded path,
19
+ * or whose snippet the secret scan matches, is withheld, and so is every keep group it would be in.
20
+ *
21
+ * WHAT A RESULT IS. Advisory (D40). A top option under 0.6, or within 0.1 of the next, is unsure and proposes nothing. A
22
+ * sure kind that differs from the kind the scan uses now is a CORRECTION; nothing is written until the PM confirms it, and
23
+ * then only one line per document in `<state>/document-kinds.jsonl`. A keep pick is shown on the tidy sheet; the tidy itself
24
+ * (lib/tidy-documents.js) moves files. Answers are cached by snippet in the per-user cache directory, so an unchanged
25
+ * document is never asked twice.
26
+ */
27
+ const fs = require('fs');
28
+ const path = require('path');
29
+ const crypto = require('crypto');
30
+ const typesafe = require('./typesafe');
31
+ const scanner = require('./secret-scan');
32
+ const settings = require('./typesafe-settings');
33
+ const JC = require('./jev-categorize');
34
+
35
+ const UNSURE_BELOW = 0.6;
36
+ const UNSURE_GAP = 0.1;
37
+ const MAX_GROUP = 8; // a keep question compares at most this many documents
38
+ const MAX_DOCS = 2000;
39
+ const sha = (s) => crypto.createHash('sha1').update(s).digest('hex');
40
+
41
+ /* The kind question. The option ids are the scan's roles; `none` takes the document out of the scan. */
42
+ const KIND_OPTIONS = {
43
+ brief: 'A problem brief or idea: the problem, who has it and why it matters, before any solution is specified.',
44
+ draft: 'A product requirements document (PRD) or specification: the requirements, the scope and how success is judged.',
45
+ review: 'An evaluation or review of a PRD or of delivered work: scores, a verdict, findings against criteria.',
46
+ handoff: 'A handoff to delivery: the tickets, epics or work items sent to engineering, or a handover note.',
47
+ none: 'None of these: meeting notes, a report, research, knowledge, a template or anything else.',
48
+ };
49
+ const KIND_INSTRUCTIONS = 'Which kind of product document is this? Judge from its title, its headings and its opening, given below.';
50
+ const KEEP_INSTRUCTIONS = 'These documents are versions of one kind of document for one initiative. Which one is the most complete and current version, the one to keep?';
51
+ const QUESTION_HASH = sha(JSON.stringify([KIND_INSTRUCTIONS, KIND_OPTIONS, KEEP_INSTRUCTIONS]));
52
+ const LETTERS = 'ABCDEFGH';
53
+
54
+ const wordsFor = (p) => (p >= 0.85 ? 'very likely' : 'likely');
55
+
56
+ /** The probabilities of a Choice answer over `ids`, or null when it cannot be read. Pure. */
57
+ function readChoice(ans, ids) {
58
+ if (!ans || typeof ans !== 'object') return null;
59
+ const probs = {};
60
+ const put = (k, v) => { const n = Number(v); if (ids.includes(k) && Number.isFinite(n) && n >= 0 && n <= 1) probs[k] = n; };
61
+ const raw = ans.probabilities || ans.distribution;
62
+ if (Array.isArray(raw)) { for (const x of raw) if (x && typeof x === 'object') put(x.option || x.label || x.choice, x.probability != null ? x.probability : x.p); }
63
+ else if (raw && typeof raw === 'object') { for (const [k, v] of Object.entries(raw)) put(k, v); }
64
+ if (!Object.keys(probs).length && typeof ans.choice === 'string') put(ans.choice, ans.confidence);
65
+ if (!Object.keys(probs).length) return null;
66
+ if (Object.values(probs).reduce((a, b) => a + b, 0) > 1.05) return null;
67
+ return probs;
68
+ }
69
+ /** The decided option, or unsure with the two leading ones. Pure. */
70
+ function decide(probs) {
71
+ const ranked = Object.entries(probs || {}).sort((a, b) => b[1] - a[1]);
72
+ if (!ranked.length) return null;
73
+ const [top, second] = [ranked[0], ranked[1] || [null, 0]];
74
+ const leading = [top, second].filter((x) => x[0]).map(([id, p]) => ({ id, p: Math.round(p * 1000) / 1000 }));
75
+ if (top[1] < UNSURE_BELOW || top[1] - second[1] < UNSURE_GAP) return { decided: false, id: null, p: null, leading };
76
+ return { decided: true, id: top[0], p: Math.round(top[1] * 1000) / 1000, leading };
77
+ }
78
+
79
+ /* ── per-user cache ─────────────────────────────────────────────────────────────────────────────── */
80
+ const cacheFile = (cacheDir) => path.join(cacheDir, 'jev-documents-cache.json');
81
+ function readCache(cacheDir) {
82
+ try { const c = JSON.parse(fs.readFileSync(cacheFile(cacheDir), 'utf8')); return c && typeof c === 'object' && !Array.isArray(c) ? c : {}; } catch { return {}; }
83
+ }
84
+ function writeCache(cacheDir, c) {
85
+ const keys = Object.keys(c);
86
+ if (keys.length > 5000) for (const k of keys.slice(0, keys.length - 5000)) delete c[k];
87
+ try { fs.mkdirSync(cacheDir, { recursive: true }); const tmp = cacheFile(cacheDir) + '.' + process.pid + '.tmp'; fs.writeFileSync(tmp, JSON.stringify(c), { mode: 0o600 }); fs.renameSync(tmp, cacheFile(cacheDir)); } catch { /* the cache is a saving, not a record */ }
88
+ }
89
+ const kindKey = (snip) => sha(['kind', sha(snip), QUESTION_HASH, typesafe.DEFAULT_MODEL].join('\0'));
90
+ const keepKey = (snips) => sha(['keep', ...snips.map(sha), QUESTION_HASH, typesafe.DEFAULT_MODEL].join('\0'));
91
+
92
+ /* ── which documents ───────────────────────────────────────────────────────────────────────────── */
93
+ /**
94
+ * `docs` are the scan's documents ({ path, ids, role, text }) with the file text the caller read ({ body, fm }). Only
95
+ * Markdown and text can be read; each gets its snippet, or is excluded or withheld. Pure.
96
+ */
97
+ function triage(docs, { exclude = [], cache = {} } = {}) {
98
+ const out = { items: [], excluded: 0, withheld: [], skipped: 0 };
99
+ for (const d of (docs || []).slice(0, MAX_DOCS)) {
100
+ if (!d.text || typeof d.body !== 'string') { out.skipped++; continue; }
101
+ if (settings.isExcluded(exclude, d.path)) { out.excluded++; continue; }
102
+ const snip = JC.snippetFor({ fm: d.fm || {}, text: d.body });
103
+ if (scanner.scan(snip).length) { out.withheld.push(d.path); continue; }
104
+ const ck = kindKey(snip);
105
+ out.items.push({ ...d, snip, ck, cached: !!cache[ck] });
106
+ }
107
+ return out;
108
+ }
109
+ const fingerprintOf = (items) => sha(Array.from(new Set(items.filter((x) => !x.cached).map((x) => x.ck))).sort().join('\n'));
110
+
111
+ /** What a check would send for the kind pass. Sends nothing. */
112
+ function plan({ docs, cacheDir, exclude = [] }) {
113
+ const t = triage(docs, { exclude, cache: readCache(cacheDir) });
114
+ const ask = new Set(t.items.filter((x) => !x.cached).map((x) => x.ck));
115
+ return { documents: t.items.length, requests: ask.size, fingerprint: fingerprintOf(t.items), cached: t.items.filter((x) => x.cached).length,
116
+ withheld_count: t.withheld.length, excluded: t.excluded, skipped: t.skipped, host: typesafe.HOST };
117
+ }
118
+
119
+ /* ── asking ────────────────────────────────────────────────────────────────────────────────────── */
120
+ async function askOne({ ask, key, askOpts, state, ids, questions, onFail }) {
121
+ const res = await ask({ key, state, questions, ...askOpts });
122
+ if (!res.ok) { onFail(res); return null; }
123
+ return { probs: readChoice(res.answers && res.answers.q, ids), model: res.model || null };
124
+ }
125
+
126
+ /**
127
+ * The PM's confirmed check. Refuses unless the gates hold (key, consent, categorize — the D82 switch) and the kind pass has
128
+ * the number of requests, and the same files, the PM confirmed. Then asks the kind of every document, groups them by kind
129
+ * and initiative, and asks which one to keep in each group of more than one.
130
+ */
131
+ async function check({ docs, cacheDir, key, flags, exclude = [], expectRequests, expectFingerprint = null, ask = typesafe.ask, askOpts = {}, now = Date.now() }) {
132
+ const g = JC.gates({ key: typesafe.validKey(key), consent: !!(flags && flags.consent), categorize: !!(flags && flags.categorize) });
133
+ if (!g.open) return { ok: false, error: JC.gateSentence(g.missing), missing: g.missing };
134
+ const cache = readCache(cacheDir);
135
+ const t = triage(docs, { exclude, cache });
136
+ const fresh = new Set(t.items.filter((x) => !x.cached).map((x) => x.ck));
137
+ if (!Number.isInteger(expectRequests) || expectRequests !== fresh.size || (expectFingerprint !== null && expectFingerprint !== fingerprintOf(t.items))) {
138
+ return { ok: false, conflict: true, error: `the plan changed — it now has ${fresh.size} request(s), not ${expectRequests}. Review it again.` };
139
+ }
140
+ let sent = 0, failed = 0, rejected = 0, lastError = null;
141
+ const at = new Date(now).toISOString();
142
+ const onFail = (res) => { failed++; lastError = res.reason || 'the request failed'; if (res.status === 401) rejected++; };
143
+ const stop = () => rejected >= 2 || (failed >= 3 && sent === 0);
144
+
145
+ // 1 — the kind of each document
146
+ const kindIds = Object.keys(KIND_OPTIONS);
147
+ for (const it of t.items) {
148
+ if (cache[it.ck] || stop()) continue;
149
+ const a = await askOne({ ask, key, askOpts, ids: kindIds, state: it.snip, questions: { q: { type: 'choice', instructions: KIND_INSTRUCTIONS, criteria: KIND_OPTIONS } }, onFail });
150
+ if (!a) continue;
151
+ sent++;
152
+ cache[it.ck] = { probs: a.probs, model: a.model, at };
153
+ }
154
+ const kinds = [];
155
+ const corrections = [];
156
+ const jevRole = new Map(); // path → the role Jev is sure of, or the current one
157
+ for (const it of t.items) {
158
+ const hit = cache[it.ck];
159
+ const d = hit && hit.probs ? decide(hit.probs) : null;
160
+ const now_ = it.role || 'none';
161
+ const row = { path: it.path, ids: it.ids, current: now_, kind: d && d.decided ? d.id : null, p: d && d.decided ? d.p : null,
162
+ unsure: !!(d && !d.decided), leading: d ? d.leading.map((x) => x.id) : [], answered: !!d,
163
+ words: d && d.decided ? `Jev rates it ${wordsFor(d.p)} that this is ${KIND_WORDS[d.id]}.` : d ? `Jev was unsure: ${d.leading.map((x) => KIND_WORDS[x.id]).join(' or ')}.` : null };
164
+ kinds.push(row);
165
+ jevRole.set(it.path, row.kind || now_);
166
+ // a kind the PM already confirmed is the PM's: Jev proposes no change to it
167
+ if (row.kind && row.kind !== now_ && !it.kind) corrections.push({ path: it.path, ids: it.ids, was: now_, kind: row.kind, p: row.p, words: row.words });
168
+ }
169
+
170
+ // 2 — which one to keep, per initiative and kind, by Jev's kinds
171
+ const byGroup = new Map();
172
+ for (const it of t.items) {
173
+ const role = jevRole.get(it.path);
174
+ if (!role || role === 'none' || it.ids.length !== 1) continue; // a document shared by two initiatives never moves (S10)
175
+ const k = it.ids[0] + '\0' + role;
176
+ if (!byGroup.has(k)) byGroup.set(k, { id: it.ids[0], role, docs: [] });
177
+ byGroup.get(k).docs.push(it);
178
+ }
179
+ const groups = [];
180
+ for (const grp of [...byGroup.values()].sort((a, b) => (a.id + a.role).localeCompare(b.id + b.role))) {
181
+ if (grp.docs.length < 2) continue;
182
+ const docs_ = grp.docs.slice(0, MAX_GROUP).sort((a, b) => a.path.localeCompare(b.path));
183
+ const ids = docs_.map((_, i) => LETTERS[i]);
184
+ const kk = keepKey(docs_.map((x) => x.snip));
185
+ if (!cache[kk] && !stop()) {
186
+ const state = docs_.map((x, i) => `=== DOCUMENT ${LETTERS[i]} ===\n${x.snip}`).join('\n\n').slice(0, typesafe.MAX_STATE_CHARS);
187
+ const criteria = Object.fromEntries(ids.map((l) => [l, `Document ${l} is the most complete and current version.`]));
188
+ const a = await askOne({ ask, key, askOpts, ids, state, questions: { q: { type: 'choice', instructions: KEEP_INSTRUCTIONS, criteria } }, onFail });
189
+ if (a) { sent++; cache[kk] = { probs: a.probs, model: a.model, at }; }
190
+ }
191
+ const hit = cache[kk];
192
+ const d = hit && hit.probs ? decide(hit.probs) : null;
193
+ const pick = d && d.decided ? docs_[ids.indexOf(d.id)] : null;
194
+ groups.push({ id: grp.id, role: grp.role, files: docs_.map((x) => x.path), keep: pick ? pick.path : null, p: d && d.decided ? d.p : null,
195
+ words: pick ? `Jev rates it ${wordsFor(d.p)} that ${pick.path} is the version to keep.` : d ? 'Jev was unsure which version to keep.' : 'Jev did not answer for this group.' });
196
+ }
197
+ writeCache(cacheDir, cache);
198
+ const paused = rejected >= 2 ? 'The service rejected the key twice, so the check stopped.' : (failed >= 3 && sent === 0 ? 'The service could not be reached, so the check stopped.' : null);
199
+ return { ok: true, sent, failed, error: lastError, paused, kinds, corrections, groups, withheld_count: t.withheld.length, excluded: t.excluded, skipped: t.skipped };
200
+ }
201
+ const KIND_WORDS = { brief: 'a problem brief', draft: 'a PRD', review: 'an evaluation', handoff: 'a handoff', none: 'none of these kinds' };
202
+
203
+ /**
204
+ * Append the PM's confirmed corrections to `<state>/document-kinds.jsonl`, one line each. `corrections` is
205
+ * [{ path, kind, was?, p? }]. A path must be repository-relative and a kind one of KIND_OPTIONS.
206
+ */
207
+ function record(root, kindsFile, corrections, { actor, now } = {}) {
208
+ if (!Array.isArray(corrections) || !corrections.length) return { ok: true, written: 0 };
209
+ if (corrections.length > MAX_DOCS) return { ok: false, status: 400, error: `at most ${MAX_DOCS} corrections at once` };
210
+ const lines = [];
211
+ for (const c of corrections) {
212
+ const p = c && typeof c.path === 'string' ? c.path.replace(/\\/g, '/') : '';
213
+ if (!p || p.startsWith('/') || p.split('/').includes('..') || p.length > 1024) return { ok: false, status: 400, error: 'each correction names a repository-relative path' };
214
+ if (!Object.prototype.hasOwnProperty.call(KIND_OPTIONS, c.kind)) return { ok: false, status: 400, error: 'each correction names a kind: ' + Object.keys(KIND_OPTIONS).join(', ') };
215
+ lines.push(JSON.stringify({ ev: 'kind', path: p, kind: c.kind, was: typeof c.was === 'string' ? c.was : null, p: Number.isFinite(c.p) ? c.p : null,
216
+ by: (actor && actor.by) || 'pm:unknown', via: 'jev', at: (now || new Date()).toISOString() }));
217
+ }
218
+ const file = path.join(root, kindsFile);
219
+ fs.mkdirSync(path.dirname(file), { recursive: true });
220
+ fs.appendFileSync(file, lines.join('\n') + '\n');
221
+ return { ok: true, written: lines.length };
222
+ }
223
+
224
+ module.exports = { plan, check, record, triage, readChoice, decide, KIND_OPTIONS, KIND_WORDS, MAX_GROUP, UNSURE_BELOW, UNSURE_GAP };
@@ -11,8 +11,11 @@
11
11
  * nothing is written: the cached rows go stale and one derivation starts
12
12
  * POST /api/initiatives/duplicates { ws } → { ok, profile, archive, groups } documents that share an initiative and a
13
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)
14
+ * POST /api/initiatives/tidy { ws, keep: [{ id, role, keep }], kinds?: [{ path, kind, was, p }] } → { ok, archive, moved,
15
+ * skipped, kinds_written } record the PM's confirmed Jev kinds (S11), then
16
+ * move every other document of each named group to the archive
17
+ * POST /api/initiatives/jev-check { ws, confirm?, expect_requests?, expect_fingerprint? } → { ok, gates, plan } without
18
+ * confirm; { ok, corrections, groups, sent, … } with it (lib/jev-documents.js)
16
19
  * POST /api/placements { ws, events: [placed | created | adopted | dropped] }
17
20
  * → { ok, written, sig } fixtures/initiatives-api/post-ok.json
18
21
  * POST /api/measurements { ws, events: [measured] } → { ok, written, sig }
@@ -51,6 +54,9 @@ const consoleState = require('./console-state');
51
54
  const initiativeFiles = require('./initiative-files');
52
55
  const jiraTickets = require('./jira-tickets.js');
53
56
  const tidy = require('./tidy-documents.js');
57
+ const jevDocs = require('./jev-documents.js');
58
+ const tsSettings = require('./typesafe-settings');
59
+ const docCatCorrections = require('./doc-category-corrections');
54
60
 
55
61
  const HORIZON_RANK = { now: 0, next: 1, later: 2 };
56
62
  const FILE_KEYS = [['brief_path', 'brief', 'Brief'], ['prd_path', 'prd', 'PRD'], ['rubric_path', 'rubric', 'Rubric'], ['doc_path', 'doc', 'Document']];
@@ -445,6 +451,51 @@ function derivedBreakdown(root, rec, lay) {
445
451
  return { source, basis, state, items: list, progress: { done: state === 'done' ? n : 0, total: n, proposed: 0, label: `${state === 'done' ? n : 0} of ${n} · from ${source.split(',')[0].split('/').pop()}` } };
446
452
  }
447
453
 
454
+ // ── the Jev check (scan-documents S11) ───────────────────────────────────────────────────────────
455
+ const JEV_BODY_MAX = 200000;
456
+ /** The scan's documents with the text Jev's snippet is made from (Markdown and text only). */
457
+ function jevDocsOf(engine, root) {
458
+ const c = engine.documentCandidates(root, { fresh: true });
459
+ const docs = (c.documents || []).map((d) => {
460
+ if (!d.text) return d;
461
+ let text = '';
462
+ try { text = fs.readFileSync(path.join(root, d.path), 'utf8').slice(0, JEV_BODY_MAX); } catch { return { ...d, text: false }; }
463
+ const fm = (engine.parseLeadingFrontmatter && engine.parseLeadingFrontmatter(text)) || {};
464
+ return { ...d, fm, body: text };
465
+ });
466
+ return { c, docs };
467
+ }
468
+ async function jevCheck(ctx, w, engine, body) {
469
+ const flags = tsSettings.getRepo(ctx.runtimeDir, w.root);
470
+ const present = tsSettings.status(ctx.runtimeDir).present;
471
+ const gates = { open: !!present && flags.consent && flags.categorize, missing: [] };
472
+ if (!present) gates.missing.push('key');
473
+ if (!flags.consent) gates.missing.push('consent');
474
+ if (!flags.categorize) gates.missing.push('categorize');
475
+ gates.sentence = gates.open ? null : require('./jev-categorize').gateSentence(gates.missing);
476
+ const { c, docs } = jevDocsOf(engine, w.root);
477
+ if (c.profile !== 'pm-artifacts') return { ok: true, profile: c.profile, gates, plan: null };
478
+ const cacheDir = docCatCorrections.repoDir(ctx.runtimeDir, w.root);
479
+ if (body.confirm !== true || !gates.open) return { ok: true, profile: c.profile, gates, plan: gates.open ? jevDocs.plan({ docs, cacheDir, exclude: flags.exclude }) : null };
480
+ const out = await jevDocs.check({ docs, cacheDir, key: tsSettings.getKey(ctx.runtimeDir), flags, exclude: flags.exclude,
481
+ expectRequests: body.expect_requests, expectFingerprint: typeof body.expect_fingerprint === 'string' ? body.expect_fingerprint : null, askOpts: ctx.jevModelOpts ? ctx.jevModelOpts(w.root) : {} });
482
+ if (!out.ok) return { ...out, status: out.conflict ? 409 : 403, gates };
483
+ // the groups in the tidy sheet's shape: the record's own file is kept whatever Jev says (S11), then Jev's pick, then the newest
484
+ const named = new Map();
485
+ const dup = engine.documentDuplicates ? engine.documentDuplicates(w.root) : { groups: [] };
486
+ for (const g of dup.groups || []) if (g.named) named.set(g.id + '\0' + g.role, g.keep);
487
+ const byPath = new Map(docs.map((d) => [d.path, d]));
488
+ const groups = out.groups.map((g) => {
489
+ const files = g.files.map((f) => ({ path: f, changed: (byPath.get(f) || {}).changed || 0 })).sort((a, b) => b.changed - a.changed || a.path.localeCompare(b.path));
490
+ const pin = named.get(g.id + '\0' + g.role);
491
+ const keep = pin && g.files.includes(pin) ? pin : (g.keep || files[0].path);
492
+ return { id: g.id, title: (c.titles || {})[g.id] || g.id, role: g.role, keep, files, shared: [], named: keep === pin, jev: { keep: g.keep, words: g.words } };
493
+ });
494
+ const corrections = out.corrections.map((x) => ({ ...x, title: (c.titles || {})[x.ids[0]] || x.ids[0] }));
495
+ return { ok: true, profile: c.profile, gates, sent: out.sent, failed: out.failed, error: out.error, paused: out.paused, checked: out.kinds.length,
496
+ unsure: out.kinds.filter((k) => k.unsure).length, corrections, groups, archive: dup.archive || null, withheld_count: out.withheld_count, excluded: out.excluded };
497
+ }
498
+
448
499
  // ── HTTP ─────────────────────────────────────────────────────────────────────────────────────────
449
500
  function wsOf(ctx, url) {
450
501
  const wsParam = url.searchParams.get('ws');
@@ -559,15 +610,37 @@ async function handle(req, res, url, ctx) {
559
610
  ctx.json(res, { ok: true, ws: w.id, started: r.started, running: r.running });
560
611
  return true;
561
612
  }
613
+ // Jev checks the kind of each document and picks the duplicate to keep (scan-documents S11). Without `confirm` it
614
+ // answers the gates and the plan and sends nothing; with it, the PM-confirmed check.
615
+ if (p === '/api/initiatives/jev-check') {
616
+ const g = guard.requirePagePress(req, body);
617
+ if (!g.ok) return answer(ctx, res, g), true;
618
+ const loaded = consoleState.loadEngine();
619
+ if (!loaded.ok || typeof loaded.engine.documentCandidates !== 'function') { ctx.json(res, { ok: false, status: 501, error: 'this workspace engine cannot list documents; update the kit' }, 501); return true; }
620
+ const r = await jevCheck(ctx, w, loaded.engine, body);
621
+ ctx.json(res, { ws: w.id, ...r }, r.ok ? 200 : (r.status || 500));
622
+ return true;
623
+ }
562
624
  // Tidy duplicates before a scan (scan-documents S10). The list is a POST so its path cannot be read as an initiative id.
563
625
  if (p === '/api/initiatives/duplicates' || p === '/api/initiatives/tidy') {
564
626
  const g = guard.requirePagePress(req, body);
565
627
  if (!g.ok) return answer(ctx, res, g), true;
566
628
  const loaded = consoleState.loadEngine();
567
629
  if (!loaded.ok) { ctx.json(res, { ok: false, status: 500, error: 'the workflow engine could not be loaded' }, 500); return true; }
630
+ let kinds = null;
631
+ if (p === '/api/initiatives/tidy' && Array.isArray(body.kinds) && body.kinds.length) {
632
+ // the PM's confirmed Jev corrections first (S11), so the duplicates below are grouped by the corrected kinds
633
+ const c = typeof loaded.engine.documentCandidates === 'function' ? loaded.engine.documentCandidates(w.root) : null;
634
+ if (!c || !c.kinds) { ctx.json(res, { ok: false, status: 501, error: 'this workspace engine cannot record document kinds; update the kit' }, 501); return true; }
635
+ kinds = jevDocs.record(w.root, c.kinds, body.kinds, { actor: actorLib.fromUi() });
636
+ if (!kinds.ok) { ctx.json(res, { ok: false, status: kinds.status || 400, error: kinds.error }, kinds.status || 400); return true; }
637
+ if (loaded.engine.forgetDocumentScan) loaded.engine.forgetDocumentScan(w.root);
638
+ }
639
+ const keep = Array.isArray(body.keep) ? body.keep : [];
568
640
  const r = p === '/api/initiatives/duplicates'
569
641
  ? tidy.preview(loaded.engine, w.root)
570
- : tidy.apply(loaded.engine, w.root, body.keep, { actor: actorLib.fromUi() });
642
+ : (kinds && !keep.length ? { ok: true, archive: null, moved: [], skipped: [] } : tidy.apply(loaded.engine, w.root, keep, { actor: actorLib.fromUi() }));
643
+ if (r.ok && kinds) r.kinds_written = kinds.written;
571
644
  if (!r.ok) { ctx.json(res, { ok: false, status: r.status || 500, error: r.error }, r.status || 500); return true; }
572
645
  // the page scans next (POST /rescan), which drops the cached rows; dropping them here would lose the answer before the tidy
573
646
  ctx.json(res, { ws: w.id, ...r });
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.74.0",
3
+ "version": "0.75.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.74.0",
3
+ "version": "0.75.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",
@@ -12,7 +12,10 @@
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
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)
15
+ * POST /api/initiatives/tidy { ws, keep, kinds? } record the kinds the PM confirmed (S11), then move every other
16
+ * document of each kept group to the archive (S10)
17
+ * POST /api/initiatives/jev-check { ws, confirm?, … } Jev's plan (nothing sent), or the PM-confirmed check: each
18
+ * document's kind and the duplicate to keep (S11)
16
19
  *
17
20
  * Every GET of /api/initiatives answers 202 { ok: false, pending: true } until the repository's first derivation lands (2C,
18
21
  * about 260 ms a record); a read waits and asks again, so a caller sees the answer or a real error, never the 202.
@@ -75,7 +78,10 @@ export async function rescan(ws, opts) {
75
78
 
76
79
  /** The duplicates a tidy offers (S10), and the tidy itself: `keep` is [{ id, role, keep }] for each group to tidy. */
77
80
  export const duplicates = (ws) => pmPost('/api/initiatives/duplicates', { ws });
78
- export const tidy = (ws, keep) => pmPost('/api/initiatives/tidy', { ws, keep });
81
+ export const tidy = (ws, keep, kinds) => pmPost('/api/initiatives/tidy', kinds && kinds.length ? { ws, keep, kinds } : { ws, keep });
82
+ /** Jev's check (S11). With no `plan` it answers the gates and the plan and sends nothing; with the plan the PM confirmed it asks Jev. */
83
+ export const jevCheck = (ws, plan) => pmPost('/api/initiatives/jev-check', plan
84
+ ? { ws, confirm: true, expect_requests: plan.requests, expect_fingerprint: plan.fingerprint } : { ws });
79
85
 
80
86
  export const createInitiative = (ws, fields) => pmPost('/api/initiatives', { ws, ...fields });
81
87
 
@@ -22,6 +22,8 @@ export const GLOSSARY = {
22
22
  guide: 'initiatives-screen.md',
23
23
  };
24
24
 
25
+ const KIND = { brief: 'problem brief', draft: 'PRD', review: 'evaluation', handoff: 'handoff', none: 'none of these' };
26
+
25
27
  export const COPY = {
26
28
  title: 'Initiatives',
27
29
  views: 'Initiatives view',
@@ -56,6 +58,23 @@ export const COPY = {
56
58
  tidyNone: 'Nothing to archive: every group is off.',
57
59
  tidyArchived: (n) => `Archived ${n} duplicate ${n === 1 ? 'document' : 'documents'}.`,
58
60
  tidyFailed: (why) => `Could not tidy: ${why}`,
61
+ // Jev checks each document's kind and picks the duplicate to keep, before the tidy (scan-documents S11)
62
+ jevTitle: 'Check documents with Jev',
63
+ jevPlan: (p) => `Jev reads ${p.documents} ${p.documents === 1 ? 'document' : 'documents'} tied to initiatives and says what kind each one is: problem brief, PRD, evaluation, handoff or none of these. Then it picks the version to keep where an initiative has more than one of a kind. This sends ${p.requests} ${p.requests === 1 ? 'request' : 'requests'} to ${p.host}${p.cached ? `; ${p.cached} ${p.cached === 1 ? 'document was' : 'documents were'} checked before and ${p.cached === 1 ? 'is' : 'are'} not sent again` : ''}. Only each document's title, headings and opening are sent, never its path or the whole file.`,
64
+ jevWithheld: (n) => `${n} ${n === 1 ? 'document is' : 'documents are'} not sent: ${n === 1 ? 'it is' : 'they are'} in an excluded folder or may contain a secret.`,
65
+ jevGo: (n) => (n ? `Check with Jev (${n} ${n === 1 ? 'request' : 'requests'})` : 'Check with Jev'),
66
+ jevSkip: 'Scan without Jev',
67
+ jevReading: 'Jev is reading the documents…',
68
+ jevFailed: (why) => `Jev could not check the documents: ${why}`,
69
+ jevShut: (why) => `Jev did not check the documents: ${why}`,
70
+ jevPaused: (why) => `${why} The scan uses the answers it has.`,
71
+ jevClean: (n) => `Jev checked ${n} ${n === 1 ? 'document' : 'documents'}: each kind is right and there are no duplicates.`,
72
+ jevKindsHead: (n) => `Jev reads ${n} ${n === 1 ? 'document' : 'documents'} as a different kind`,
73
+ jevKindsBody: 'A changed kind is written to the kinds file in the repository. The scan uses it from now on.',
74
+ jevKindLine: (c) => `${c.title} · ${c.path}: ${KIND[c.was] || c.was} → ${KIND[c.kind] || c.kind}`,
75
+ jevKindToggle: 'Use this kind',
76
+ jevConfirm: (k, n) => [k ? `Change ${k} ${k === 1 ? 'kind' : 'kinds'}` : '', n ? `archive ${n}` : ''].filter(Boolean).join(', ') + ' and scan',
77
+ jevRecorded: (n) => `Changed the kind of ${n} ${n === 1 ? 'document' : 'documents'}.`,
59
78
  rescanDone: (t) => `Read again at ${t}`,
60
79
  rescanFailed: (why) => `Could not re-scan: ${why}`,
61
80
  rescanSlow: 'The re-scan is still running. The view updates when it ends.',
@@ -7,15 +7,18 @@
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
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
+ * in a document repository, Jev's check first when its gates are open (scan-documents S11),
11
+ * then the tidy sheet when there are kinds to change or duplicates (S10)
12
+ * jevPlanSheet(plan) what Jev would read and how many requests it sends, and Cancel, Scan without Jev or Check
13
+ * tidySheet(duplicates) the kinds Jev would change, the groups of duplicate documents, the one to keep in each,
14
+ * and Cancel, Skip or Archive
12
15
  * No native dialog is used anywhere here. Every control is a primitive (primitives.js).
13
16
  */
14
17
 
15
18
  import { el } from './dom.js';
16
19
  import { button, field, signal, sheet, tabs, toast } from './primitives.js';
17
20
  import { COPY } from './initiatives-copy.js';
18
- import { rescan, duplicates, tidy } from './initiatives-api.js';
21
+ import { rescan, duplicates, tidy, jevCheck } from './initiatives-api.js';
19
22
 
20
23
  export const horizonLabel = (h) => COPY.horizons[h] || h;
21
24
  export const progressText = (row) => (row && row.progress && row.progress.label) || '';
@@ -52,22 +55,25 @@ export function scanMoves(before, after) {
52
55
 
53
56
  /**
54
57
  * 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.
58
+ * chosen, a switch to leave the group as it is, and three answers. Resolves { keep: [{ id, role, keep }], kinds } on the
59
+ * confirm, 'skip' on Scan without tidying, and null on Cancel, Escape or the backdrop. After Jev's check (S11) it also lists
60
+ * the kinds Jev would change, each with a switch, and each group says which version Jev picks; `kinds` is the switched-on ones.
57
61
  */
58
62
  export function tidySheet(d) {
59
63
  return new Promise((resolve) => {
60
64
  let settled = false;
61
65
  const finish = (v) => { if (settled) return; settled = true; resolve(v); };
62
66
  const state = d.groups.map((g) => ({ g, on: true, keep: g.keep }));
67
+ const fixes = (d.corrections || []).map((c) => ({ c, on: true }));
63
68
  const count = () => state.reduce((n, x) => n + (x.on ? x.g.files.length - 1 : 0), 0);
69
+ const label = () => (fixes.length ? COPY.jevConfirm(fixes.filter((x) => x.on).length, count()) : COPY.tidyConfirm(count()));
64
70
  const day = (t) => (t ? new Date(t * 1000).toISOString().slice(0, 10) : '');
65
71
  const err = el('p', { class: 'in-error', role: 'alert', 'data-in-error': '' });
66
72
  err.hidden = true;
67
73
  const groups = state.map((x, gi) => {
68
74
  const box = el('fieldset', { class: 'in-tidy__group', 'data-in-tidy-group': x.g.id + ':' + x.g.role });
69
75
  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()); });
76
+ toggle.addEventListener('change', () => { x.on = toggle.checked; if (x.on) box.removeAttribute('data-off'); else box.setAttribute('data-off', ''); go.textContent = label(); });
71
77
  const head = el('legend', { class: 'in-tidy__head' }, toggle, `${x.g.title} · ${COPY.tidyRole[x.g.role] || x.g.role}`);
72
78
  const files = x.g.files.map((f) => {
73
79
  const radio = el('input', { type: 'radio', name: 'in-tidy-' + gi, value: f.path, checked: f.path === x.keep, 'data-in-tidy-keep': f.path });
@@ -76,21 +82,34 @@ export function tidySheet(d) {
76
82
  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
83
  });
78
84
  const shared = (x.g.shared || []).map((p) => el('p', { class: 'in-note in-tidy__file' }, COPY.tidyShared(p)));
79
- box.replaceChildren(head, ...files, ...shared);
85
+ const jev = x.g.jev && x.g.jev.words ? [el('p', { class: 'in-note in-tidy__file', 'data-in-tidy-jev': '' }, x.g.jev.words)] : [];
86
+ box.replaceChildren(head, ...jev, ...files, ...shared);
80
87
  return box;
81
88
  });
89
+ const kindsBox = fixes.length ? (() => {
90
+ const box = el('fieldset', { class: 'in-tidy__group', 'data-in-tidy-kinds': '' });
91
+ const rows = fixes.map((x) => {
92
+ const t = el('input', { type: 'checkbox', checked: true, 'aria-label': COPY.jevKindToggle, 'data-in-tidy-kind': x.c.path });
93
+ t.addEventListener('change', () => { x.on = t.checked; go.textContent = label(); });
94
+ return el('label', { class: 'in-tidy__file' }, t, el('span', null, el('span', { class: 'in-tidy__path' }, COPY.jevKindLine(x.c)), x.c.words ? el('span', { class: 'in-note' }, ' · ' + x.c.words) : null));
95
+ });
96
+ box.replaceChildren(el('legend', { class: 'in-tidy__head' }, COPY.jevKindsHead(fixes.length)), el('p', { class: 'in-note in-tidy__file' }, COPY.jevKindsBody), ...rows);
97
+ return box;
98
+ })() : null;
82
99
  const inits = new Set(d.groups.map((g) => g.id)).size;
83
100
  const go = button({
84
- label: COPY.tidyConfirm(count()), variant: 'primary', attrs: { 'data-in-tidy-confirm': '' },
101
+ label: label(), variant: 'primary', attrs: { 'data-in-tidy-confirm': '' },
85
102
  onClick: () => {
86
103
  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();
104
+ const kinds = fixes.filter((x) => x.on).map((x) => ({ path: x.c.path, kind: x.c.kind, was: x.c.was, p: x.c.p }));
105
+ if (!keep.length && !kinds.length) { err.textContent = COPY.tidyNone; err.hidden = false; return; }
106
+ finish(kinds.length ? { keep, kinds } : { keep }); s.close();
89
107
  },
90
108
  });
91
109
  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],
110
+ mode: 'auto', title: fixes.length ? COPY.jevTitle : COPY.tidyTitle,
111
+ body: [d.groups.length ? el('p', { class: 'in-note' }, COPY.tidyBody(d.groups.length, inits, d.archive)) : null,
112
+ el('div', { class: 'in-tidy' }, ...(kindsBox ? [kindsBox] : []), ...groups), err].filter(Boolean),
94
113
  attrs: { 'data-in-sheet': 'tidy' },
95
114
  onClose: () => finish(null),
96
115
  actions: [
@@ -102,25 +121,73 @@ export function tidySheet(d) {
102
121
  });
103
122
  }
104
123
 
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`. */
124
+ /**
125
+ * Jev's plan (scan-documents S11): how many documents Jev would read and how many requests that sends, before anything
126
+ * is sent. Resolves 'jev' on the check, 'skip' on Scan without Jev, and null on Cancel, Escape or the backdrop.
127
+ */
128
+ export function jevPlanSheet(plan) {
129
+ return new Promise((resolve) => {
130
+ let settled = false;
131
+ const finish = (v) => { if (settled) return; settled = true; resolve(v); };
132
+ const s = sheet({
133
+ mode: 'auto', title: COPY.jevTitle,
134
+ body: [el('p', { class: 'in-note' }, COPY.jevPlan(plan)), plan.withheld_count || plan.excluded ? el('p', { class: 'in-note' }, COPY.jevWithheld((plan.withheld_count || 0) + (plan.excluded || 0))) : null].filter(Boolean),
135
+ attrs: { 'data-in-sheet': 'jev-plan' },
136
+ onClose: () => finish(null),
137
+ actions: [
138
+ button({ label: COPY.cancel, variant: 'outline', onClick: () => s.close(), attrs: { 'data-in-cancel': '' } }),
139
+ button({ label: COPY.jevSkip, variant: 'outline', onClick: () => { finish('skip'); s.close(); }, attrs: { 'data-in-jev-skip': '' } }),
140
+ button({ label: COPY.jevGo(plan.requests), variant: 'primary', onClick: () => { finish('jev'); s.close(); }, attrs: { 'data-in-jev-go': '' } }),
141
+ ],
142
+ });
143
+ });
144
+ }
145
+
146
+ /** Jev's part of a scan (S11): the plan, the PM's confirm, then the check. Answers { cancelled } on Cancel, { review } with
147
+ * the kinds to change and the groups when Jev answered, or { note } with what to say when Jev did not check. */
148
+ async function jevStep(ws) {
149
+ const j = await jevCheck(ws);
150
+ if (!j || !j.ok || j.profile !== 'pm-artifacts') return {};
151
+ if (!j.gates || !j.gates.open) return { note: COPY.jevShut(j.gates && j.gates.sentence ? j.gates.sentence : 'it is switched off.') };
152
+ if (!j.plan || !j.plan.documents) return {};
153
+ const go = await jevPlanSheet(j.plan);
154
+ if (!go) return { cancelled: true };
155
+ if (go === 'skip') return {};
156
+ toast({ text: COPY.jevReading, timeout: 6000 });
157
+ const r = await jevCheck(ws, j.plan);
158
+ if (!r || !r.ok) return { failed: COPY.jevFailed((r && r.error) || 'no answer') };
159
+ const pre = r.paused ? COPY.jevPaused(r.paused) + ' ' : '';
160
+ if (!(r.corrections || []).length && !(r.groups || []).length) return { note: pre + COPY.jevClean(r.checked || 0) };
161
+ return { review: { corrections: r.corrections || [], groups: r.groups || [], archive: r.archive }, note: pre || null };
162
+ }
163
+
164
+ /** Re-scan: read the repository again now. In a document repository, Jev's check comes first when its gates are open
165
+ * (S11), then the tidy sheet: the kinds Jev would change and the duplicates by Jev's kinds and picks. With Jev's gates
166
+ * shut, or on Scan without Jev, the tidy sheet lists the duplicates the file names show (S10). A toast says the scan
167
+ * started and another says how it ended. Answers the rescan answer; the caller reloads what it shows when `ok`. */
108
168
  export async function rescanRepo(ws) {
109
169
  let first = null;
110
- const d = await duplicates(ws);
111
- if (d && d.ok && Array.isArray(d.groups) && d.groups.length) {
170
+ const jv = await jevStep(ws);
171
+ if (jv.cancelled) return { ok: false, cancelled: true };
172
+ if (jv.failed) toast({ text: jv.failed, tone: 'error', timeout: 8000 });
173
+ let d = jv.review || null;
174
+ if (!d) { const dup = await duplicates(ws); if (dup && dup.ok && Array.isArray(dup.groups) && dup.groups.length) d = dup; }
175
+ if (d) {
112
176
  const pick = await tidySheet(d);
113
177
  if (!pick) return { ok: false, cancelled: true };
114
- if (pick !== 'skip') first = () => tidy(ws, pick.keep);
178
+ if (pick !== 'skip') first = () => tidy(ws, pick.keep, pick.kinds);
115
179
  }
116
180
  toast({ text: COPY.rescanning, timeout: 4000 });
117
181
  const a = await rescan(ws, first ? { first } : null);
118
182
  const t = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
119
183
  const moved = a && a.ok && a.after ? scanMoves(a.before, a.after) : null;
120
- const archived = a && a.tidied && Array.isArray(a.tidied.moved) && a.tidied.moved.length ? COPY.tidyArchived(a.tidied.moved.length) + ' ' : '';
184
+ const td = a && a.tidied;
185
+ const archived = (td && td.kinds_written ? COPY.jevRecorded(td.kinds_written) + ' ' : '')
186
+ + (td && Array.isArray(td.moved) && td.moved.length ? COPY.tidyArchived(td.moved.length) + ' ' : '');
187
+ const note = jv.note ? ' ' + jv.note : '';
121
188
  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 });
189
+ else if (a.slow) toast({ text: archived + COPY.rescanSlow + note, timeout: 6000 });
190
+ else toast({ text: archived + (moved ? COPY.rescanResult(moved.checked, moved.moves, (a.after && a.after.scan) || null) : COPY.rescanDone(t)) + note, timeout: 10000 });
124
191
  try { if (a && a.ok) window.dispatchEvent(new CustomEvent('pmos:initiatives-changed')); } catch { /* no window */ }
125
192
  return a || { ok: false };
126
193
  }