create-pmos 0.73.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.
@@ -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.73.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.73.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",
@@ -3346,6 +3346,40 @@
3346
3346
  .td-day__draftbox { display: grid; gap: 4px; }
3347
3347
  .td-day__draft { margin: 0; max-height: 220px; overflow: auto; padding: 8px 10px; white-space: pre-wrap; font: 12px/1.5 var(--mono); background: var(--muted); color: var(--foreground); }
3348
3348
  .td-day__revise { display: grid; gap: 6px; }
3349
+ /* act and prepare (initiative today-act-prepare): a verb first, one sentence, the steps, one button that fits, the marks */
3350
+ .td-day__verb { display: inline-block; min-width: 58px; padding: 2px 5px; border: 1px solid currentColor; font: 600 10px/1.3 var(--mono); letter-spacing: 0.05em; text-transform: uppercase; text-align: center; color: var(--foreground); white-space: nowrap; }
3351
+ .td-day__verb[data-verb="decide"], .td-day__verb[data-verb="prepare"] { color: var(--warning); }
3352
+ .td-day__acth { margin: 0; font-size: 16px; font-weight: 600; line-height: 1.3; text-wrap: balance; }
3353
+ .td-day__todo { margin: 0; padding: 7px 10px; border-left: 3px solid var(--foreground); background: var(--muted); font-size: 14px; line-height: 1.45; color: var(--foreground); }
3354
+ .td-day__steps2 { list-style: none; margin: 0; padding: 0; display: grid; gap: 4px; }
3355
+ .td-day__step2 { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 5px 8px; border: 1px solid var(--border); background: var(--background); }
3356
+ .td-day__step2[data-done="true"] .td-day__steprow { text-decoration: line-through; color: var(--muted-foreground); }
3357
+ .td-day__marks { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; padding-top: 8px; border-top: 1px solid var(--border); }
3358
+ .td-day__grow { flex: 1 1 0; }
3359
+ .td-day__mark { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 10px; padding: 6px 10px; border: 1px solid var(--border); background: var(--muted); font-size: 14px; }
3360
+ .td-day__mark[data-day-mark-state="done"] { border-color: var(--foreground); }
3361
+ .td-day__ready { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 12px; font-size: 12px; }
3362
+ .td-day__prep { display: grid; gap: 8px; padding: 10px 12px; border: 1px solid var(--border); background: var(--background); }
3363
+ .td-day__fold { border-top: 1px solid var(--border); padding-top: 6px; }
3364
+ .td-day__fold > summary { cursor: pointer; font-size: 12px; color: var(--muted-foreground); }
3365
+ .td-day__fold[open] > summary { margin-bottom: 6px; }
3366
+ .td-day__fold > summary:focus-visible, .td-day__fold[open] > summary:focus-visible, .td-day__ref > summary:focus-visible { outline: 2px solid color-mix(in oklab, var(--ring) 50%, var(--foreground)); outline-offset: 2px; }
3367
+ @media (pointer: coarse) { .td-day__fold > summary, .td-day__fold[open] > summary, .td-day__ref > summary { min-height: 44px; } }
3368
+ .td-day__fold-head { justify-self: start; }
3369
+ .td-day__steprow { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
3370
+ .td-day__step2:has(.td-day__ref), .td-day__step2:has(.td-day__notebox) { flex-direction: column; align-items: stretch; }
3371
+ .td-day__ref { display: grid; gap: 4px; padding: 6px 8px; background: var(--muted); font-size: 14px; }
3372
+ .td-day__ref > summary { display: flex; justify-content: space-between; gap: 8px; cursor: pointer; font-weight: 600; color: var(--foreground); }
3373
+ .td-day__reflines { margin: 0; padding-left: 18px; display: grid; gap: 2px; color: var(--foreground); line-height: 1.45; }
3374
+ .td-day__thread { display: grid; gap: 6px; }
3375
+ .td-day__msg { display: grid; gap: 2px; padding: 6px 10px; border-left: 2px solid var(--border); }
3376
+ .td-day__msg p { margin: 0; font-size: 14px; line-height: 1.45; color: var(--foreground); }
3377
+ .td-day__msg--last { border-left-color: var(--foreground); }
3378
+ .td-day__compose { display: grid; gap: 6px; padding: 8px 10px; border: 1px solid var(--border); background: var(--background); }
3379
+ .td-day__chipsrow { display: flex; flex-wrap: wrap; align-items: center; gap: 4px; }
3380
+ .td-day__notebox { display: grid; gap: 4px; }
3381
+ .td-day__a--primary { background: var(--primary); color: var(--primary-foreground); border-color: var(--primary); }
3382
+ @media (hover: hover) { .td-day__a--primary:hover { color: var(--primary-foreground); opacity: 0.9; } }
3349
3383
  .td-day__log { font-size: 12px; color: var(--muted-foreground); }
3350
3384
  .td-day__ch { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 8px; font-size: 14px; color: var(--foreground); }
3351
3385
  .td-day__slist { display: grid; }
@@ -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.',