create-pmos 0.47.0 → 0.48.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/AGENTS-HUMAN.md +1 -1
- package/payload/AGENTS.md +1 -1
- package/payload/KIT-VERSION +1 -1
- package/payload/mods/pmos-cockpit/.claude-plugin/plugin.json +1 -1
- package/payload/mods/pmos-cockpit/README.md +4 -2
- package/payload/mods/pmos-cockpit/hooks/lib/commands.mjs +34 -6
- package/payload/mods/pmos-cockpit/hooks/lib/grants.mjs +22 -3
- package/payload/mods/pmos-cockpit/hooks/lib/guard.mjs +16 -3
- package/payload/mods/pmos-cockpit/hooks/register.tsx +37 -18
- package/payload/planning/evals/eval-calibration.md +1 -1
- package/payload/planning/okrs/health-budgets.md +1 -1
- package/payload/scripts/pmos-log.sh +18 -8
- package/payload/scripts/pmos-model.js +233 -2
- package/payload/scripts/status-facts.js +204 -0
- package/payload/scripts/workflow-state.js +52 -0
- package/payload/state/discoveries.md +1 -1
- package/workspace/README.md +2 -2
- package/workspace/bin/live-notes.js +133 -0
- package/workspace/design-parity.js +328 -21
- package/workspace/doc-category-bench.js +165 -0
- package/workspace/fixtures/doc-category-labelled.json +167 -0
- package/workspace/guide/initiatives-and-next-steps.md +15 -2
- package/workspace/guide/knowledge-graph.md +83 -1
- package/workspace/guide/layout.md +29 -2
- package/workspace/guide/proactive-engine.md +6 -2
- package/workspace/guide/validate-with-typesafe.md +16 -1
- package/workspace/lib/claims.js +51 -2
- package/workspace/lib/doc-category-corrections.js +113 -0
- package/workspace/lib/doc-category-writeback.js +110 -0
- package/workspace/lib/doc-category.js +225 -0
- package/workspace/lib/engine/pmos-model.js +233 -2
- package/workspace/lib/engine/status-facts.js +204 -0
- package/workspace/lib/engine/workflow-state.js +52 -0
- package/workspace/lib/jev-categorize.js +399 -0
- package/workspace/lib/jev-claims.js +59 -15
- package/workspace/lib/jev-route.js +129 -0
- package/workspace/lib/kg-cli.js +3 -1
- package/workspace/lib/kg.js +6 -1
- package/workspace/lib/learn.js +5 -3
- package/workspace/lib/live-notes.js +890 -0
- package/workspace/lib/typesafe-settings.js +10 -2
- package/workspace/package-lock.json +1 -1
- package/workspace/package.json +5 -4
- package/workspace/public/pm/app.js +148 -79
- package/workspace/public/pm/data.js +58 -0
- package/workspace/public/pm/fixture.js +40 -2
- package/workspace/public/pm/modes-live.js +216 -18
- package/workspace/public/pm/panel-cockpit.js +636 -0
- package/workspace/public/pm/panel-detail.js +210 -8
- package/workspace/public/pm/panel-initiatives.js +9 -9
- package/workspace/public/pm/panel-newsess.js +145 -86
- package/workspace/public/pm/panel-repos.js +34 -17
- package/workspace/public/pm/panel-tabs.js +145 -27
- package/workspace/public/pm/state.js +45 -4
- package/workspace/public/pm/tab-doc.js +244 -16
- package/workspace/public/pm/tab-term.js +11 -1
- package/workspace/public/pm/tokens.css +4 -3
- package/workspace/public/pm/view-addrepo.js +207 -0
- package/workspace/public/pm/view-files.js +197 -0
- package/workspace/public/pm/view-guide.js +141 -0
- package/workspace/public/pm/view-knowledge.js +225 -0
- package/workspace/public/pm/view-memory.js +122 -0
- package/workspace/public/pm/view-portfolio.js +153 -0
- package/workspace/public/pm/view-reports.js +190 -0
- package/workspace/public/pm/view-scheduled.js +161 -0
- package/workspace/public/pm/view-skills.js +216 -0
- package/workspace/public/pm/view-today.js +168 -0
- package/workspace/public/pm/view-validate.js +171 -0
- package/workspace/server.js +290 -7
- package/workspace/skills/live-notes.skill +58 -0
package/workspace/lib/claims.js
CHANGED
|
@@ -281,8 +281,11 @@ const PAIR_QUESTIONS = {
|
|
|
281
281
|
delivers: 'The story section does not deliver what the requirement it cites asks for.',
|
|
282
282
|
supports: 'The decision does not support what the document says it decides.',
|
|
283
283
|
sections: 'These two sections of the same document contradict each other.',
|
|
284
|
+
// status-from-artifacts Run 2 (D83) — asked only when the repository's "read documents for status" switch is on.
|
|
285
|
+
stub: 'The document is not a complete document for its initiative: it is a stub, an outline or an unfinished draft.',
|
|
286
|
+
contradicts: 'The note states something that contradicts one of the requirements.',
|
|
284
287
|
};
|
|
285
|
-
const PAIR_CAP = { rubric: 2500, req: 1200, story: 1500, reqs: 1500, sentence: 600, decision: 700, section: 1800 };
|
|
288
|
+
const PAIR_CAP = { rubric: 2500, req: 1200, story: 1500, reqs: 1500, sentence: 600, decision: 700, section: 1800, doc: 2500, note: 1800, intent: 600 };
|
|
286
289
|
// A link keeps its words, not its path.
|
|
287
290
|
const clip = (t, n) => { const s = String(t).replace(/\[([^\]]*)\]\([^)]*\)/g, '$1').replace(/[ \t]+/g, ' ').trim(); return s.length > n ? s.slice(0, n - 1) + '…' : s; };
|
|
288
291
|
|
|
@@ -367,7 +370,7 @@ function decisionText(root, files, n) {
|
|
|
367
370
|
* @param {Set<string>} [a.only] limit to pairs whose own document is in this set (a changed file and its dependents)
|
|
368
371
|
* @returns {{pairs: object[], reads: number}}
|
|
369
372
|
*/
|
|
370
|
-
function pairsFor({ root, cacheDir, only = null }) {
|
|
373
|
+
function pairsFor({ root, cacheDir, only = null, statusKinds = false }) {
|
|
371
374
|
const index = readJson(path.join(cacheDir, 'index.json'), null);
|
|
372
375
|
if (!index) return { pairs: [], reads: 0 };
|
|
373
376
|
const files = Object.keys(index).filter((r) => index[r] && index[r].cls);
|
|
@@ -441,9 +444,55 @@ function pairsFor({ root, cacheDir, only = null }) {
|
|
|
441
444
|
}
|
|
442
445
|
}
|
|
443
446
|
}
|
|
447
|
+
if (statusKinds) statusPairs({ files, index, initiatives, read, typeOf, add, only });
|
|
444
448
|
return { pairs, reads };
|
|
445
449
|
}
|
|
446
450
|
|
|
451
|
+
/*
|
|
452
|
+
* status-from-artifacts Run 2 (D83): two questions about the documents TIED to an initiative, so the initiative
|
|
453
|
+
* detail can say what the documents suggest. Ties are concrete only — the paths the record names, and documents whose
|
|
454
|
+
* own frontmatter or identity line names the initiative — never "feels related".
|
|
455
|
+
* stub each spec document the record names (PRD, rubric, brief) against the initiative's title and intent
|
|
456
|
+
* contradicts each other document that names the initiative (a meeting note, a report) against the PRD's requirements
|
|
457
|
+
* The texts are clipped like every other pair. The answer is a suggestion: it changes no step, stage or count.
|
|
458
|
+
*/
|
|
459
|
+
const bodyOf = (t) => String(t || '').replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, '');
|
|
460
|
+
function statusPairs({ files, index, initiatives, read, typeOf, add, only }) {
|
|
461
|
+
const SPEC_KEYS = ['prd_path', 'rubric_path', 'brief_path'];
|
|
462
|
+
const SKIP_TYPES = new Set(['Initiative', 'PRD', 'Rubric', 'Story', 'Decision', 'ADR']);
|
|
463
|
+
const ids = Array.from(initiatives.keys()).sort();
|
|
464
|
+
for (const iid of ids) {
|
|
465
|
+
const { rel: recRel, fm } = initiatives.get(iid);
|
|
466
|
+
const recText = read(recRel);
|
|
467
|
+
const intent = clip([fm.title || iid, bodyOf(recText || '')].join('\n'), PAIR_CAP.intent);
|
|
468
|
+
const own = new Set([recRel]);
|
|
469
|
+
for (const k of SPEC_KEYS) {
|
|
470
|
+
const p = fm[k] && !isPlaceholder(fm[k]) ? norm(fm[k]) : null;
|
|
471
|
+
if (!p || !index[p] || !index[p].cls) continue;
|
|
472
|
+
own.add(p);
|
|
473
|
+
if (only && !only.has(p) && !only.has(recRel)) continue;
|
|
474
|
+
const t = read(p); if (t == null) continue;
|
|
475
|
+
add({ kind: 'stub', doc: p, doc_type: typeOf(p), subject: 'stub:' + iid, a: clip(bodyOf(t), PAIR_CAP.doc), b: intent, a_label: p, partner: recRel, initiative: iid });
|
|
476
|
+
}
|
|
477
|
+
const prdRel = fm.prd_path && !isPlaceholder(fm.prd_path) ? norm(fm.prd_path) : null;
|
|
478
|
+
const prdText = prdRel && typeOf(prdRel) === 'PRD' ? read(prdRel) : null;
|
|
479
|
+
if (prdText == null) continue;
|
|
480
|
+
const reqs = Array.from(requirementTexts(prdText).values());
|
|
481
|
+
if (!reqs.length) continue;
|
|
482
|
+
const reqText = clip(reqs.join('\n'), PAIR_CAP.reqs);
|
|
483
|
+
for (const rel of files) {
|
|
484
|
+
if (own.has(rel)) continue;
|
|
485
|
+
const e = index[rel]; const t0 = e.cls && e.cls.type;
|
|
486
|
+
if (!e.cls.classified || SKIP_TYPES.has(t0)) continue;
|
|
487
|
+
const named = idOf((e.fm || {}).initiative || (e.fm || {}).initiative_id || (e.body || {}).initiative_id);
|
|
488
|
+
if (named !== iid) continue;
|
|
489
|
+
if (only && !only.has(rel) && !only.has(prdRel)) continue;
|
|
490
|
+
const t = read(rel); if (t == null) continue;
|
|
491
|
+
add({ kind: 'contradicts', doc: rel, doc_type: t0, subject: 'contradicts:' + iid, a: clip(bodyOf(t), PAIR_CAP.note), b: reqText, a_label: rel, partner: prdRel, initiative: iid });
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
447
496
|
/* ── verification ──────────────────────────────────────────────────────────────────────────────── */
|
|
448
497
|
const date = (secs) => new Date(secs * 1000).toISOString().slice(0, 16).replace('T', ' ');
|
|
449
498
|
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* doc-category-corrections.js — the PM's own category for a document, kept per user (jev-doc-categorizer Run 3, C7).
|
|
3
|
+
*
|
|
4
|
+
* A correction is the PM saying "this file is a <category>". It is stored OUTSIDE the repository, in the per-user runtime
|
|
5
|
+
* directory that lib/state-root.js resolves:
|
|
6
|
+
*
|
|
7
|
+
* <stateDir>/doccat/<repoKey>/corrections.json repoKey = sha1(repository root).slice(0, 12), as the claims cache uses
|
|
8
|
+
*
|
|
9
|
+
* The file is a map: repo-relative path → { category, ticket_kind, at }. A correction never edits the document. Writing the
|
|
10
|
+
* category into the document's frontmatter is a separate press (lib/doc-category-writeback.js).
|
|
11
|
+
*
|
|
12
|
+
* The file is written with mode 0600 through a temporary file and a rename, and a symlink at the target is refused, as
|
|
13
|
+
* lib/typesafe-settings.js does. Pure Node: no network, and nothing here requires the TypeSafe module.
|
|
14
|
+
*/
|
|
15
|
+
const fs = require('fs');
|
|
16
|
+
const path = require('path');
|
|
17
|
+
const crypto = require('crypto');
|
|
18
|
+
const { validCorrection, CATEGORIES, TICKET_KINDS } = require('./doc-category');
|
|
19
|
+
|
|
20
|
+
const FILE = 'corrections.json';
|
|
21
|
+
const MAX_ENTRIES = 20000;
|
|
22
|
+
const MAX_REL = 1024;
|
|
23
|
+
|
|
24
|
+
/** The per-repository key: the first 12 hex characters of the sha1 of the repository root. */
|
|
25
|
+
function repoKey(root) { return crypto.createHash('sha1').update(String(root)).digest('hex').slice(0, 12); }
|
|
26
|
+
/** The per-repository directory under the runtime state directory. */
|
|
27
|
+
function repoDir(stateDir, root) { return path.join(stateDir, 'doccat', repoKey(root)); }
|
|
28
|
+
function file(dir) { return path.join(dir, FILE); }
|
|
29
|
+
|
|
30
|
+
/** A repo-relative Markdown path with forward slashes, or an `{error}` sentence. */
|
|
31
|
+
function checkRel(rel) {
|
|
32
|
+
if (typeof rel !== 'string' || !rel.trim()) return { error: 'a document path is required' };
|
|
33
|
+
if (rel.length > MAX_REL || rel.includes('\0')) return { error: 'that document path is not valid' };
|
|
34
|
+
const r = rel.replace(/\\/g, '/');
|
|
35
|
+
if (r.startsWith('/') || /^[a-zA-Z]:/.test(r) || path.isAbsolute(rel)) return { error: 'the path must be relative to the repository, not absolute' };
|
|
36
|
+
if (r.split('/').some((seg) => seg === '..')) return { error: 'the path must not contain ".."' };
|
|
37
|
+
const n = r.replace(/^(\.\/)+/, '');
|
|
38
|
+
// one spelling per file: the library lists `a/b.md`, so `a/./b.md` or `a//b.md` would be saved and never apply
|
|
39
|
+
if (n.split('/').some((seg) => seg === '' || seg === '.')) return { error: 'write the path as the library lists it, with no "./" or "//" inside it' };
|
|
40
|
+
if (n.split('/').some((seg) => seg.toLowerCase() === '.git')) return { error: 'a file inside .git is not a document' };
|
|
41
|
+
if (path.posix.extname(n).toLowerCase() !== '.md') return { error: 'only a Markdown (.md) document has a category' };
|
|
42
|
+
return { rel: n };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function readRaw(dir) {
|
|
46
|
+
try {
|
|
47
|
+
const st = fs.lstatSync(file(dir));
|
|
48
|
+
if (!st.isFile()) return {};
|
|
49
|
+
const o = JSON.parse(fs.readFileSync(file(dir), 'utf8'));
|
|
50
|
+
return o && typeof o === 'object' && !Array.isArray(o) ? o : {};
|
|
51
|
+
} catch { return {}; }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Every valid correction for the repository whose directory is `dir`: rel → { category, ticket_kind, at }. */
|
|
55
|
+
function get(dir) {
|
|
56
|
+
const raw = readRaw(dir);
|
|
57
|
+
const out = {};
|
|
58
|
+
for (const [rel, v] of Object.entries(raw)) {
|
|
59
|
+
if (checkRel(rel).error) continue;
|
|
60
|
+
const c = validCorrection(v);
|
|
61
|
+
if (!c) continue;
|
|
62
|
+
out[rel] = { category: c.category, ticket_kind: c.ticket_kind, at: v && typeof v.at === 'string' ? v.at : null };
|
|
63
|
+
}
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function write(dir, data) {
|
|
68
|
+
const target = file(dir);
|
|
69
|
+
try { if (fs.lstatSync(target).isSymbolicLink()) return { ok: false, error: 'the corrections file is a symlink and was not written' }; }
|
|
70
|
+
catch { /* not there yet */ }
|
|
71
|
+
const tmp = target + '.' + process.pid + '.tmp';
|
|
72
|
+
try {
|
|
73
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
74
|
+
fs.writeFileSync(tmp, JSON.stringify(data, null, 1), { mode: 0o600 });
|
|
75
|
+
fs.chmodSync(tmp, 0o600);
|
|
76
|
+
fs.renameSync(tmp, target);
|
|
77
|
+
return { ok: true };
|
|
78
|
+
} catch (e) {
|
|
79
|
+
try { fs.unlinkSync(tmp); } catch { /* nothing to clean */ }
|
|
80
|
+
return { ok: false, error: 'the correction could not be saved (' + String((e && e.code) || 'write failed') + ')' };
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Set the PM's category for one document. Returns { ok, rel, correction } or { ok: false, error }. */
|
|
85
|
+
function set(dir, rel, { category, ticket_kind } = {}, now = new Date()) {
|
|
86
|
+
const p = checkRel(rel);
|
|
87
|
+
if (p.error) return { ok: false, error: p.error };
|
|
88
|
+
const c = validCorrection({ category, ticket_kind: ticket_kind === undefined ? null : ticket_kind });
|
|
89
|
+
if (!c) {
|
|
90
|
+
const cat = String(category || '').trim().toLowerCase();
|
|
91
|
+
if (!CATEGORIES.includes(cat)) return { ok: false, error: 'the category must be one of: ' + CATEGORIES.join(', ') };
|
|
92
|
+
if (cat === 'ticket') return { ok: false, error: 'a ticket needs a ticket kind: ' + TICKET_KINDS.join(', ') };
|
|
93
|
+
return { ok: false, error: 'only a ticket has a ticket kind' };
|
|
94
|
+
}
|
|
95
|
+
const cur = get(dir);
|
|
96
|
+
if (!cur[p.rel] && Object.keys(cur).length >= MAX_ENTRIES) return { ok: false, error: 'there are too many corrections for this repository (' + MAX_ENTRIES + ')' };
|
|
97
|
+
const correction = { category: c.category, ticket_kind: c.ticket_kind, at: now.toISOString() };
|
|
98
|
+
const w = write(dir, { ...cur, [p.rel]: correction });
|
|
99
|
+
return w.ok ? { ok: true, rel: p.rel, correction } : w;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Remove the PM's category for one document. Returns { ok, rel, cleared } or { ok: false, error }. */
|
|
103
|
+
function clear(dir, rel) {
|
|
104
|
+
const p = checkRel(rel);
|
|
105
|
+
if (p.error) return { ok: false, error: p.error };
|
|
106
|
+
const cur = get(dir);
|
|
107
|
+
if (!cur[p.rel]) return { ok: true, rel: p.rel, cleared: false };
|
|
108
|
+
delete cur[p.rel];
|
|
109
|
+
const w = write(dir, cur);
|
|
110
|
+
return w.ok ? { ok: true, rel: p.rel, cleared: true } : w;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
module.exports = { get, set, clear, checkRel, repoKey, repoDir, FILE, MAX_ENTRIES };
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* doc-category-writeback.js — write the PM's category into a document's own frontmatter (jev-doc-categorizer Run 3, C7).
|
|
3
|
+
*
|
|
4
|
+
* A correction lives outside the repository and never edits a file. When the PM wants the category IN the file, as an author
|
|
5
|
+
* `category:` key, that is this separate press, and it has two steps:
|
|
6
|
+
*
|
|
7
|
+
* 1. plan(text, correction) returns the diff, the sha1 of the text the PM saw and the sha1 of the text it would write.
|
|
8
|
+
* Nothing is written.
|
|
9
|
+
* 2. apply(current, correction, expectSha, expectNextSha) refuses when the file changed after the PM saw the diff, or
|
|
10
|
+
* when the change is not the one the PM saw; otherwise returns the same plan, and the caller writes `next`.
|
|
11
|
+
*
|
|
12
|
+
* The change is as small as it can be: ONE `category:` line (and ONE `ticket_kind:` line for a ticket) is set or replaced in
|
|
13
|
+
* the LEADING frontmatter block. No other byte of the file changes, line endings and a byte-order mark included. A file with
|
|
14
|
+
* no leading frontmatter block is refused: this module does not add a block. Pure: it reads and writes no file.
|
|
15
|
+
*/
|
|
16
|
+
const crypto = require('crypto');
|
|
17
|
+
const { validCorrection, CATEGORIES, TICKET_KINDS } = require('./doc-category');
|
|
18
|
+
|
|
19
|
+
const sha1 = (text) => crypto.createHash('sha1').update(String(text), 'utf8').digest('hex');
|
|
20
|
+
const lines = (text) => String(text).match(/[^\n]*\n|[^\n]+$/g) || [];
|
|
21
|
+
const bare = (line) => line.replace(/\r?\n$/, '');
|
|
22
|
+
const eolOf = (line) => (/\r\n$/.test(line) ? '\r\n' : (/\n$/.test(line) ? '\n' : ''));
|
|
23
|
+
|
|
24
|
+
/* The leading frontmatter block: the first line is `---` (after an optional byte-order mark) and a later line is `---`.
|
|
25
|
+
* Returns the index of the closing line, or an error sentence. */
|
|
26
|
+
function block(ls) {
|
|
27
|
+
if (!ls.length || bare(ls[0]).replace(/^/, '').trimEnd() !== '---') {
|
|
28
|
+
return { error: 'This file has no frontmatter block at the top, so the category was not written. Add a frontmatter block first, or keep the correction only.' };
|
|
29
|
+
}
|
|
30
|
+
for (let i = 1; i < ls.length; i++) if (bare(ls[i]).trimEnd() === '---') return { close: i };
|
|
31
|
+
return { error: 'The frontmatter block at the top of this file has no closing --- line, so the category was not written.' };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/* Set `key: value` on one top-level line inside the block. More than one such line is refused, because the writer cannot
|
|
35
|
+
* know which one the reader uses. */
|
|
36
|
+
function setKey(ls, close, key, value, eol, changes) {
|
|
37
|
+
const re = new RegExp('^' + key + '\\s*:');
|
|
38
|
+
const at = [];
|
|
39
|
+
for (let i = 1; i < close; i++) if (re.test(ls[i])) at.push(i);
|
|
40
|
+
if (at.length > 1) return { error: 'The frontmatter has more than one ' + key + ': line, so the category was not written. Remove the extra line first.' };
|
|
41
|
+
const want = key + ': ' + value;
|
|
42
|
+
if (at.length === 1) {
|
|
43
|
+
const i = at[0];
|
|
44
|
+
// a value that goes on below the key (a block scalar, a list, a map) is more than one line; replacing the key line alone
|
|
45
|
+
// would leave broken YAML, so it is refused
|
|
46
|
+
if (/^[^:]*:\s*[|>][-+0-9]*\s*(#.*)?$/.test(bare(ls[i])) || (i + 1 < close && /^[ \t]/.test(ls[i + 1]))) {
|
|
47
|
+
return { error: 'The ' + key + ': value in the frontmatter goes on below its line, so the category was not written. Make it one line first.' };
|
|
48
|
+
}
|
|
49
|
+
if (bare(ls[i]) === want) return { close };
|
|
50
|
+
changes.push({ line: i + 1, before: bare(ls[i]), after: want });
|
|
51
|
+
ls[i] = want + eolOf(ls[i]);
|
|
52
|
+
return { close };
|
|
53
|
+
}
|
|
54
|
+
changes.push({ line: close + 1, before: null, after: want });
|
|
55
|
+
ls.splice(close, 0, want + eol);
|
|
56
|
+
return { close: close + 1 };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function diffOf(changes) {
|
|
60
|
+
return changes.map((c) => c.before === null
|
|
61
|
+
? '@@ line ' + c.line + ' (added) @@\n+' + c.after
|
|
62
|
+
: '@@ line ' + c.line + ' @@\n-' + c.before + '\n+' + c.after).join('\n');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Plan the write. Pure.
|
|
67
|
+
* @returns {{ok:true, next:string, diff:string, changes:Array, sha:string, next_sha:string, unchanged:boolean} | {ok:false, error:string, sha?:string}}
|
|
68
|
+
*/
|
|
69
|
+
function plan(text, { category, ticket_kind } = {}) {
|
|
70
|
+
const t = String(text == null ? '' : text);
|
|
71
|
+
const sha = sha1(t);
|
|
72
|
+
const c = validCorrection({ category, ticket_kind: ticket_kind === undefined ? null : ticket_kind });
|
|
73
|
+
if (!c) {
|
|
74
|
+
const cat = String(category || '').trim().toLowerCase();
|
|
75
|
+
if (!CATEGORIES.includes(cat)) return { ok: false, sha, error: 'The category must be one of: ' + CATEGORIES.join(', ') + '.' };
|
|
76
|
+
if (cat === 'ticket') return { ok: false, sha, error: 'A ticket needs a ticket kind: ' + TICKET_KINDS.join(', ') + '.' };
|
|
77
|
+
return { ok: false, sha, error: 'Only a ticket has a ticket kind.' };
|
|
78
|
+
}
|
|
79
|
+
const ls = lines(t);
|
|
80
|
+
const b = block(ls);
|
|
81
|
+
if (b.error) return { ok: false, sha, error: b.error };
|
|
82
|
+
const eol = eolOf(ls[0]) || '\n';
|
|
83
|
+
const changes = [];
|
|
84
|
+
let r = setKey(ls, b.close, 'category', c.category, eol, changes);
|
|
85
|
+
if (r.error) return { ok: false, sha, error: r.error };
|
|
86
|
+
if (c.category === 'ticket') {
|
|
87
|
+
r = setKey(ls, r.close, 'ticket_kind', c.ticket_kind, eol, changes);
|
|
88
|
+
if (r.error) return { ok: false, sha, error: r.error };
|
|
89
|
+
}
|
|
90
|
+
const next = ls.join('');
|
|
91
|
+
return { ok: true, next, diff: diffOf(changes), changes, sha, next_sha: sha1(next), unchanged: next === t };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The confirmed step: refuse when the file is no longer the text the PM saw, or when the change is not the one the PM saw
|
|
96
|
+
* (`expectNextSha` is the plan's `next_sha`); otherwise the plan, whose `next` the caller writes.
|
|
97
|
+
*/
|
|
98
|
+
function apply(current, correction, expectSha, expectNextSha) {
|
|
99
|
+
const sha = sha1(current);
|
|
100
|
+
if (typeof expectSha !== 'string' || expectSha !== sha) {
|
|
101
|
+
return { ok: false, stale: true, sha, error: 'The file changed after you saw the diff, so nothing was written. Look at the new diff and press again.' };
|
|
102
|
+
}
|
|
103
|
+
const p = plan(current, correction);
|
|
104
|
+
if (p.ok && (typeof expectNextSha !== 'string' || expectNextSha !== p.next_sha)) {
|
|
105
|
+
return { ok: false, stale: true, sha, error: 'This is not the change you were shown, so nothing was written. Look at the diff again and press apply.' };
|
|
106
|
+
}
|
|
107
|
+
return p;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
module.exports = { plan, apply, sha1 };
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* doc-category.js — what KIND of document is this, in the PM's words? (jev-doc-categorizer Run 1)
|
|
3
|
+
*
|
|
4
|
+
* `doc-profiles.js` answers a narrower question for the claims ledger: which documents is this one compared with.
|
|
5
|
+
* This module answers the PM's: is it a PRD, meeting notes, a report, a ticket, knowledge or something else. The category is
|
|
6
|
+
* DERIVED data kept beside the frontmatter `type`. It never replaces `type`, adds nothing to the registry, and is never
|
|
7
|
+
* written into a document.
|
|
8
|
+
*
|
|
9
|
+
* Run 1 has NO network and NO model. It is pure: it reads no file and requires nothing outside Node. Evidence, strongest first,
|
|
10
|
+
* and the FIRST basis that decides wins:
|
|
11
|
+
* 1. author key a `category:` line the author wrote (and `ticket_kind:` for a ticket)
|
|
12
|
+
* 2. frontmatter type a `type` that maps to a category (TYPE_TO_CATEGORY). `Reference` and `Research` map to nothing,
|
|
13
|
+
* because each is a bucket, and fall through to the next basis.
|
|
14
|
+
* 3. named by initiative an initiative record that names the file as its prd_path or rubric_path
|
|
15
|
+
* 4. file name a file-name suffix or a folder name
|
|
16
|
+
* 5. headings a heading signature
|
|
17
|
+
* A correction the PM set (Run 3, C7) is stronger than all five. The caller reads it from the per-user store
|
|
18
|
+
* (lib/doc-category-corrections.js) and passes it in as `correction`, so this module still reads no file. A valid correction
|
|
19
|
+
* decides with basis `pm correction`; an invalid one is ignored and the five bases decide as before.
|
|
20
|
+
* A file no basis decides is `undetermined` with `decided: false`. It is not rounded to `other`: `other` is an answer
|
|
21
|
+
* ("this is none of the kinds"), `undetermined` is the absence of one. Later bases that disagree with the winner are listed
|
|
22
|
+
* in `disagrees`, so the PM can see the conflict and the winner is never silently chosen.
|
|
23
|
+
*/
|
|
24
|
+
const path = require('path');
|
|
25
|
+
|
|
26
|
+
const CATEGORIES = ['prd', 'meeting-notes', 'report', 'ticket', 'knowledge', 'spec-artifact', 'other'];
|
|
27
|
+
const TICKET_KINDS = ['design', 'engineering', 'other'];
|
|
28
|
+
const UNDETERMINED = 'undetermined';
|
|
29
|
+
/* Every basis name a result can carry. `pm correction` is the strongest. The next five are the local bases in their order.
|
|
30
|
+
* `jev` is reserved for the measured Jev pass of a later run, which sets its place in the order. */
|
|
31
|
+
const BASES = ['pm correction', 'author key', 'frontmatter type', 'named by initiative', 'file name', 'headings', 'jev'];
|
|
32
|
+
|
|
33
|
+
/** A correction is valid when its category is in the vocabulary, and its ticket kind is a known kind for a ticket and null
|
|
34
|
+
* (or absent) for every other category. Returns the normalized correction or null. */
|
|
35
|
+
function validCorrection(c) {
|
|
36
|
+
if (!c || typeof c !== 'object') return null;
|
|
37
|
+
const category = typeof c.category === 'string' ? c.category.trim().toLowerCase() : '';
|
|
38
|
+
if (!CATEGORIES.includes(category)) return null;
|
|
39
|
+
const k = c.ticket_kind === null || c.ticket_kind === undefined ? null : String(c.ticket_kind).trim().toLowerCase();
|
|
40
|
+
if (category === 'ticket') return TICKET_KINDS.includes(k) ? { category, ticket_kind: k } : null;
|
|
41
|
+
return k === null || k === '' ? { category, ticket_kind: null } : null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/* Every type in scripts/frontmatter_registry.txt has a row here (a test enforces it). `null` means the type alone does not
|
|
45
|
+
* decide: it is a bucket, and a sharper basis may. Rows are the vocabulary's one editable decision; change one with a reason. */
|
|
46
|
+
const TYPE_TO_CATEGORY = {
|
|
47
|
+
Concept: 'knowledge', ADR: 'spec-artifact', Template: 'other', Playbook: 'knowledge', Reference: null,
|
|
48
|
+
Initiative: 'spec-artifact', PRD: 'prd', Story: 'spec-artifact', Rubric: 'spec-artifact', OKR: 'spec-artifact',
|
|
49
|
+
Radar: 'report', Checklist: 'other', Calibration: 'report', Research: null, Design: 'spec-artifact',
|
|
50
|
+
Prototype: 'other', Handover: 'report',
|
|
51
|
+
Decision: 'spec-artifact', Principle: 'knowledge', Debt: 'report', Discovery: 'report', Guide: 'knowledge',
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/* A tenant repository is not bound to PMOS's registry, so a few spellings a PM would write for the same kinds are read too. */
|
|
55
|
+
const norm = (s) => String(s || '').toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
56
|
+
const TYPE_ALIASES = {
|
|
57
|
+
prd: ['prd', 'productrequirements', 'productrequirementsdocument'],
|
|
58
|
+
'meeting-notes': ['meetingnotes', 'meetingnote', 'meeting', 'minutes', 'meetingminutes'],
|
|
59
|
+
report: ['report', 'statusreport', 'postmortem'],
|
|
60
|
+
ticket: ['ticket', 'issue', 'bug', 'task'],
|
|
61
|
+
knowledge: ['knowledge', 'article', 'kb', 'howto'],
|
|
62
|
+
'spec-artifact': [],
|
|
63
|
+
other: ['other'],
|
|
64
|
+
};
|
|
65
|
+
const ALIAS = new Map();
|
|
66
|
+
for (const [cat, list] of Object.entries(TYPE_ALIASES)) for (const a of list) ALIAS.set(a, cat);
|
|
67
|
+
ALIAS.set('designticket', ['ticket', 'design']);
|
|
68
|
+
ALIAS.set('engineeringticket', ['ticket', 'engineering']);
|
|
69
|
+
ALIAS.set('engticket', ['ticket', 'engineering']);
|
|
70
|
+
|
|
71
|
+
const ROLE_CATEGORY = { prd_path: 'prd', rubric_path: 'spec-artifact' };
|
|
72
|
+
|
|
73
|
+
const RESERVED = /(^|\/)(index|log)\.md$/i;
|
|
74
|
+
const TEMPLATE = /(^|\/)templates?\//i;
|
|
75
|
+
|
|
76
|
+
function typeDecision(fmType) {
|
|
77
|
+
const t = String(fmType || '').trim();
|
|
78
|
+
if (!t) return null;
|
|
79
|
+
if (Object.prototype.hasOwnProperty.call(TYPE_TO_CATEGORY, t)) {
|
|
80
|
+
const c = TYPE_TO_CATEGORY[t];
|
|
81
|
+
return c ? { category: c } : null;
|
|
82
|
+
}
|
|
83
|
+
const a = ALIAS.get(norm(t));
|
|
84
|
+
if (!a) return null;
|
|
85
|
+
return Array.isArray(a) ? { category: a[0], ticket_kind: a[1] } : { category: a };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function ticketKindFrom(text) {
|
|
89
|
+
const s = String(text || '').toLowerCase();
|
|
90
|
+
if (/(^|[^a-z])(design|ux|ui|figma|mockup|wireframe)([^a-z]|$)/.test(s)) return 'design';
|
|
91
|
+
if (/(^|[^a-z])(eng|engineering|bug|backend|frontend|api|steps to reproduce|regression)([^a-z]|$)/.test(s)) return 'engineering';
|
|
92
|
+
return 'other';
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/* File-name and folder evidence. Suffixes the repository already uses come first because they are the strongest. */
|
|
96
|
+
const NAME_RULES = [
|
|
97
|
+
[/-eval-rubric\.md$/i, 'spec-artifact'],
|
|
98
|
+
[/-prd\.md$/i, 'prd'],
|
|
99
|
+
[/-user-stor(?:y|ies)\.md$/i, 'spec-artifact'],
|
|
100
|
+
[/-surface-explainer\.md$/i, 'knowledge'],
|
|
101
|
+
[/(^|\/)d\d{1,3}-[^/]+\.md$/i, 'spec-artifact'],
|
|
102
|
+
[/(^|\/)(meetings?|minutes|standups?)\//i, 'meeting-notes'],
|
|
103
|
+
[/(^|[-_/ ])(meeting|minutes|standup|1[-_ ]?on[-_ ]?1)([-_. ]|notes|\.md$)/i, 'meeting-notes'],
|
|
104
|
+
[/(^|\/)(initiatives|adr)\//i, 'spec-artifact'],
|
|
105
|
+
[/(^|\/)(explainers|guide|guides|playbooks|concepts)\//i, 'knowledge'],
|
|
106
|
+
[/(^|\/)readme\.md$/i, 'knowledge'],
|
|
107
|
+
[/(^|\/)(tickets?|issues?|bugs?)\//i, 'ticket'],
|
|
108
|
+
// a tracker key such as PROJ-123, but not a date: `sync-2026-09-12.md` is a dated note, not ticket SYNC-2026
|
|
109
|
+
[/(^|\/)[a-z][a-z0-9]{1,9}-\d{1,6}(?!-\d)([-_.][^/]*)?\.md$/i, 'ticket'],
|
|
110
|
+
[/(^|[-_/ ])ticket([-_. ]|\.md$)/i, 'ticket'],
|
|
111
|
+
[/(^|\/)reports?\//i, 'report'],
|
|
112
|
+
[/(^|[-_/ ])(report|postmortem|post-mortem|status-update)([-_. ]|\.md$)/i, 'report'],
|
|
113
|
+
];
|
|
114
|
+
|
|
115
|
+
function headings(text) {
|
|
116
|
+
const out = [];
|
|
117
|
+
for (const m of String(text).matchAll(/^#{1,6}\s+(.+?)\s*$/gm)) out.push(m[1].toLowerCase());
|
|
118
|
+
return out;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function fileNameDecision(rel) {
|
|
122
|
+
if (TEMPLATE.test(rel) || /-template\.md$/i.test(rel)) return { category: 'other', via: 'a template path' };
|
|
123
|
+
for (const [re, category] of NAME_RULES) {
|
|
124
|
+
if (re.test(rel)) return { category, via: 'the name or folder matches ' + String(re) };
|
|
125
|
+
}
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function headingsDecision(text) {
|
|
130
|
+
const h = headings(text);
|
|
131
|
+
const has = (s) => h.some((x) => x.includes(s));
|
|
132
|
+
if (has('requirements') && has('identity')) return { category: 'prd', via: 'headings: requirements and identity' };
|
|
133
|
+
if (has('dimensions') && has('sprint contract')) return { category: 'spec-artifact', via: 'headings: dimensions and sprint contract' };
|
|
134
|
+
if ((has('attendees') || has('agenda')) && (has('action items') || has('decisions') || has('notes') || has('discussion'))) {
|
|
135
|
+
return { category: 'meeting-notes', via: 'headings: attendees or agenda with notes, decisions or action items' };
|
|
136
|
+
}
|
|
137
|
+
if (has('acceptance criteria') && (has('steps to reproduce') || has('expected') || has('description') || has('actual'))) {
|
|
138
|
+
return { category: 'ticket', via: 'headings: acceptance criteria with a description or reproduction steps' };
|
|
139
|
+
}
|
|
140
|
+
if ((has('executive summary') || has('summary')) && (has('findings') || has('results') || has('recommendations') || has('metrics'))) {
|
|
141
|
+
return { category: 'report', via: 'headings: a summary with findings, results, recommendations or metrics' };
|
|
142
|
+
}
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Categorize one file. Pure: no file is read, no network is used, nothing is written.
|
|
148
|
+
* @param {object} a
|
|
149
|
+
* @param {string} a.rel repo-relative path with forward slashes
|
|
150
|
+
* @param {object} [a.fm] parsed frontmatter ({} when none)
|
|
151
|
+
* @param {string} [a.text] file text
|
|
152
|
+
* @param {Map} [a.named] path → [{role, id, lane}] from initiative records
|
|
153
|
+
* @param {object} [a.correction] the PM's stored correction {category, ticket_kind}; wins over every basis when valid
|
|
154
|
+
* @returns {{category:string, ticket_kind:string|null, basis:string|null, via:string, decided:boolean, disagrees:Array}}
|
|
155
|
+
*/
|
|
156
|
+
function categorize({ rel, fm = {}, text = '', named = new Map(), correction = null }) {
|
|
157
|
+
const r = String(rel || '').replace(/\\/g, '/');
|
|
158
|
+
if (path.posix.extname(r).toLowerCase() !== '.md') {
|
|
159
|
+
return { category: UNDETERMINED, ticket_kind: null, basis: null, via: 'not a Markdown document', decided: false, disagrees: [] };
|
|
160
|
+
}
|
|
161
|
+
const found = []; // every basis that has something to say, in order
|
|
162
|
+
const push = (basis, via, d) => { if (d && d.category) found.push({ basis, via, category: d.category, kind: d.ticket_kind || null, }); };
|
|
163
|
+
|
|
164
|
+
const corr = validCorrection(correction);
|
|
165
|
+
if (corr) push('pm correction', 'a correction the PM set', corr);
|
|
166
|
+
// index.md and log.md are reserved by OKF: they are directory listings and histories, not documents of a kind.
|
|
167
|
+
if (RESERVED.test(r)) push('file name', 'a reserved index or log file', { category: 'other' });
|
|
168
|
+
else collect(r, fm, text, named, push);
|
|
169
|
+
return decide(found, r, fm, text);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function collect(r, fm, text, named, push) {
|
|
173
|
+
const authorCat = String(fm.category || '').trim().toLowerCase();
|
|
174
|
+
if (CATEGORIES.includes(authorCat)) {
|
|
175
|
+
const k = String(fm.ticket_kind || '').trim().toLowerCase();
|
|
176
|
+
push('author key', 'a category line the author wrote', { category: authorCat, ticket_kind: TICKET_KINDS.includes(k) ? k : null });
|
|
177
|
+
}
|
|
178
|
+
const t = typeDecision(fm.type);
|
|
179
|
+
if (t) push('frontmatter type', 'type: ' + String(fm.type).trim(), t);
|
|
180
|
+
const roles = named.get(r);
|
|
181
|
+
if (roles && roles.length) {
|
|
182
|
+
// In every lane but `feature` the prd_path holds that lane's brief (a spike brief, an intent), not a PRD.
|
|
183
|
+
const lane = roles[0].lane;
|
|
184
|
+
const c = roles[0].role === 'prd_path' && lane && lane !== 'feature' ? 'spec-artifact' : ROLE_CATEGORY[roles[0].role];
|
|
185
|
+
if (c) push('named by initiative', 'named by initiative ' + roles[0].id, { category: c });
|
|
186
|
+
}
|
|
187
|
+
const n = fileNameDecision(r);
|
|
188
|
+
if (n) push('file name', n.via, n);
|
|
189
|
+
const h = headingsDecision(text);
|
|
190
|
+
if (h) push('headings', h.via, h);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function decide(found, r, fm, text) {
|
|
194
|
+
if (!found.length) {
|
|
195
|
+
return { category: UNDETERMINED, ticket_kind: null, basis: null, via: 'no evidence', decided: false, disagrees: [] };
|
|
196
|
+
}
|
|
197
|
+
const win = found[0];
|
|
198
|
+
let kind = null;
|
|
199
|
+
if (win.category === 'ticket') kind = win.kind || ticketKindFrom(String(fm.title || '') + ' ' + r + ' ' + headings(text).join(' '));
|
|
200
|
+
return {
|
|
201
|
+
category: win.category, ticket_kind: kind, basis: win.basis, via: win.via, decided: true,
|
|
202
|
+
disagrees: found.slice(1).filter((f) => f.category !== win.category).map((f) => ({ basis: f.basis, category: f.category })),
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Build the `named` map from initiative records: path → [{role, id, lane}]. Pure; the caller supplies each record's path and
|
|
208
|
+
* parsed frontmatter, so no file is read here.
|
|
209
|
+
* @param {Array<{rel:string, fm:object}>} records
|
|
210
|
+
*/
|
|
211
|
+
function namedFromRecords(records) {
|
|
212
|
+
const named = new Map();
|
|
213
|
+
for (const { rel, fm } of records || []) {
|
|
214
|
+
if (!/(^|\/)initiatives\//.test(String(rel || '').replace(/\\/g, '/')) || !fm) continue;
|
|
215
|
+
for (const role of Object.keys(ROLE_CATEGORY)) {
|
|
216
|
+
if (!fm[role]) continue;
|
|
217
|
+
const k = String(fm[role]).replace(/^\//, '');
|
|
218
|
+
if (!named.has(k)) named.set(k, []);
|
|
219
|
+
named.get(k).push({ role, id: fm.id || path.posix.basename(String(rel), '.md'), lane: fm.lane || null });
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return named;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
module.exports = { namedFromRecords, categorize, validCorrection, CATEGORIES, TICKET_KINDS, UNDETERMINED, BASES, TYPE_TO_CATEGORY, ROLE_CATEGORY };
|