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.
- package/package.json +1 -1
- package/payload/KIT-VERSION +1 -1
- package/payload/planning/evals/eval-calibration.md +1 -1
- package/payload/planning/okrs/health-budgets.md +1 -1
- package/payload/scripts/workflow-state.js +45 -4
- package/payload/state/discoveries.md +1 -1
- package/workspace/guide/initiatives-screen.md +10 -0
- package/workspace/guide/today.md +22 -1
- package/workspace/lib/brief.js +88 -2
- package/workspace/lib/day-marks.js +163 -0
- package/workspace/lib/day-run.js +38 -4
- package/workspace/lib/engine/workflow-state.js +45 -4
- package/workspace/lib/jev-documents.js +224 -0
- package/workspace/lib/routes-initiatives.js +76 -3
- package/workspace/package-lock.json +1 -1
- package/workspace/package.json +1 -1
- package/workspace/public/pm/components.css +34 -0
- package/workspace/public/pm/initiatives-api.js +8 -2
- package/workspace/public/pm/initiatives-copy.js +19 -0
- package/workspace/public/pm/initiatives-ui.js +88 -21
- package/workspace/public/pm/today-copy.js +82 -4
- package/workspace/public/pm/today-day.js +349 -19
- package/workspace/server.js +81 -3
- package/workspace/skills/day-task.skill +1 -0
- package/workspace/skills/morning-brief.skill +13 -2
- package/workspace/test-lists/today-act-prepare.json +5 -0
|
@@ -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,
|
|
15
|
-
*
|
|
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,
|
|
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 });
|
package/workspace/package.json
CHANGED
|
@@ -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 }
|
|
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.',
|