create-pmos 0.74.0 → 0.76.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.76.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@26e93ea
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-07T08:15:37Z"}
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-07T08:15:37Z"}
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-07T08:15:37Z"}
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.
@@ -1,19 +1,20 @@
1
1
  ---
2
2
  type: Guide
3
3
  title: "Guide: notes"
4
- description: "How to take notes in a meeting: listen to your microphone and the meeting on this machine, see decisions, actions and questions caught as they are said, check them against the repository, correct and tag lines, see what changed in your notes, replay the meeting, link a note to its meeting and its initiative, and get a summary."
4
+ description: "How to take notes in a meeting: one note top to bottom, typed notes as timed lines, key points written each minute and labelled by Jev, delete a note; listen to your microphone and the meeting on this machine, see decisions, actions and questions caught as they are said, check them against the repository, correct and tag lines, see what changed in your notes, replay the meeting, link a note to its meeting and its initiative, and get a summary."
5
5
  timestamp: 2026-10-07
6
6
  ---
7
7
 
8
8
  # Guide: notes
9
9
 
10
- The Notes view is one screen for one meeting. Your own notes are on the left, with the decisions, actions and questions that the workspace catches while people talk. The transcript is on the right. Notes are kept on this machine, outside every repository.
10
+ The Notes view shows one meeting, top to bottom. A bar at the top shows whether notes are being taken, with **Stop** or **Listen** and the **⋯** menu. Under it are the title, the day, the meeting, the initiative and the people. Then come the overview, the key points, the decisions, the next steps, the open questions and your own notes, each item with the meeting time it was said or written. The transcript folds away at the end. Notes are kept on this machine, outside every repository.
11
11
 
12
12
  ## Start a meeting
13
13
 
14
14
  1. Press ⌘K, type **notes** and press Enter. The view opens as a tab. It is the same in every repository.
15
- 2. Press **New meeting**. Type a title. Type your own notes in the box below it; they steer the summary. The note saves itself when you stop typing.
16
- 3. Press **All notes** to open an earlier note. Press the settings button for local transcription, Jev, private and delete.
15
+ 2. Press **New meeting**. Type a title.
16
+ 3. Under **Your notes**, type a note and press Enter. Each note is its own line, with the meeting time you added it at. To remove a line, press the **x** beside it. Each line also shows in the transcript as **You, typed** and is labelled like a spoken line, so a decision or a next step you type goes into its section. Your notes steer the summary, and they save at once.
17
+ 4. Press **All notes** to open an earlier note. The **⋯** menu has Copy as Markdown, New meeting, the meeting and the initiative, Summary by agent, Mark private, Settings and **Delete note**.
17
18
 
18
19
  ## Listen
19
20
 
@@ -22,13 +23,17 @@ The Notes view is one screen for one meeting. Your own notes are on the left, wi
22
23
  - **The meeting**: the other people in an online meeting. Select the meeting's tab or window next, and tick **Share tab audio**.
23
24
  2. Press **Start**. The header shows a red dot, the time, and the sources. Tell people that you are taking notes.
24
25
  3. Each line shows in the transcript a few seconds after it is said, with who said it, the time and its label. Your lines are **You**. The meeting's lines are **Meeting**: click the name to change it. The transcript cannot tell the people in the meeting apart.
25
- 4. Press **Stop** to end. The summary shows at the top of your notes.
26
+ 4. Press **Stop** to end. The summary shows at the top of your notes. To carry on, press **Listen again**: the clock carries on from where it stopped, and the time between does not count. Line times and the kept audio use the same clock.
26
27
 
27
28
  The meeting can only be heard when local transcription is set up. Until then, **Listen** hears your microphone only, through the browser's own recognition on this device.
28
29
 
29
30
  ## What is caught
30
31
 
31
- Each line gets a label: decision, action, question, fact, topic, statement or small talk. Decisions, actions and questions go into their sections as they are said. After **Stop**, facts and statements with a number go under **Facts mentioned**.
32
+ Every minute, your agent session reads that minute of the transcript, with the minutes before it, and writes it as **Key points**: short, complete sentences. Jev then labels each point as a decision, a next step, an open question or a point, and the repository check runs on the points that qualify. A minute almost always ends in the middle of a conversation. A point that is still going on shows **… continuing** and gets no label yet; the next minute finishes it. At **Stop**, every point is finished and labelled.
33
+
34
+ When the agent session does not write a minute's points within two minutes, that minute's lines are labelled as below. When no agent session can start, or the note is private, the lines are labelled on this machine, and Jev labels the clear ones once a minute. A line that ends in the middle of a sentence waits for the rest of it.
35
+
36
+ After **Stop**, your agent session writes the **Overview** at the top. Until it is written, and when no agent session can start, the overview is picked from what was said, on this machine. Facts and statements with a number go under **Facts mentioned**.
32
37
 
33
38
  - To remove an item that does not belong, press the **x** beside it. To take a line out of the summary, press the **x** beside it in the summary.
34
39
  - When a caught line is about something in the repository, the closest file shows beside it: **see** the file, a check mark when Jev finds that the file says the same, or **≠** the file when Jev finds that the line says something different. Point at the mark to see the file and the line.
@@ -95,12 +100,12 @@ Under the title, a note shows two lines: **Meeting** and **Initiative**.
95
100
 
96
101
  When a note has no initiative, the summary suggests one: the open initiative that shares 3 or more words with what was said, when no other initiative shares as many. A suggestion never replaces an initiative you chose.
97
102
 
98
- To take notes for a calendar event, open the event's detail on the Agenda and press **Take notes**. PMOS starts to listen at once, to your microphone and to the meeting when local transcription is set up. Pick the meeting's tab or window when the browser asks. Let people know that you are taking notes. The note is linked to the event, and to the event's initiative when it has one. The note stays small at the bottom right of the workspace, with a red dot, its title and its time, on every page. Press it to open the note as a pop-up: type your notes, read the transcript, and press **Stop** when the meeting ends. Press **Minimize** or Esc to make it small again, and **Open in Notes** to see it on the full screen. One note is taken at a time. If you press **Take notes** on another event while a note listens, PMOS asks first: **Keep it**, or **Stop it and start this one**. The note stays at the bottom right after a reload. When it no longer listens, press the x beside it to put it away. An event that already has a note shows its summary and **Open notes**, which opens the note in the pop-up. See [Today](today.md) and [the initiative sheet](initiatives-screen.md).
103
+ To take notes for a calendar event, open the event's detail on the Agenda and press **Take notes**. PMOS starts to listen at once, to your microphone and to the meeting when local transcription is set up. Pick the meeting's tab or window when the browser asks. Let people know that you are taking notes. The note is linked to the event, and to the event's initiative when it has one. The note stays small at the bottom right of the workspace, with a red dot, its title and its time, on every page. Press it to open the note as a pop-up: add your notes, read the key points, and press **Stop** when the meeting ends. Press **Minimize** or Esc to make it small again. **Open in Notes** in the **⋯** menu shows it on the full screen. One note is taken at a time. If you press **Take notes** on another event while a note listens, PMOS asks first: **Keep it**, or **Stop it and start this one**. The note stays at the bottom right after a reload. When it no longer listens, press the x beside it to put it away. An event that already has a note shows its summary and **Open notes**, which opens the note in the pop-up. See [Today](today.md) and [the initiative sheet](initiatives-screen.md).
99
104
 
100
105
  ## Dictate into your notes
101
106
 
102
- 1. Put the cursor where the text must go, then press **Dictate** and speak.
103
- 2. Say **new line** or **new paragraph** to start one. Say **scratch that** to remove the phrase you just said.
107
+ 1. Press **Dictate** and speak. The words go into the note field, at the cursor.
108
+ 2. Say **new line** to add what you said as a note line and start the next one. Say **scratch that** to remove the phrase you just said.
104
109
  3. Press **Stop dictating** when you are done.
105
110
 
106
111
  ## Copy, private and delete
@@ -108,7 +113,7 @@ To take notes for a calendar event, open the event's detail on the Agenda and pr
108
113
  - **Copy as Markdown** puts the title, the summary, your notes, the caught items and the transcript on the clipboard.
109
114
  - In the settings, **Mark private** stops listening at once and deletes the note's audio. A private note is not recorded again, and nothing from it goes to Jev or to an agent. **Mark not private** undoes it.
110
115
  - **Summary by agent** starts an agent session in the selected repository that writes a summary into the note. Lines that look like a secret are removed first.
111
- - **Delete** removes the note, its transcript, its summaries and its audio from this machine after you confirm. You cannot undo it.
116
+ - **Delete note** in the **⋯** menu, or **Delete** on a row of **All notes**, removes the note, its transcript, its summaries and its audio from this machine after you confirm. The note's summary goes from the calendar and the initiative. You cannot undo it. Deleting a note that is not being taken does not stop the one that is.
112
117
 
113
118
  ## What stays on your machine
114
119
 
@@ -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 };