create-pmos 0.47.0 → 0.49.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 +2 -2
- package/payload/mods/pmos-cockpit/.claude-plugin/plugin.json +1 -1
- package/payload/mods/pmos-cockpit/README.md +14 -3
- 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/lib/route.mjs +95 -0
- package/payload/mods/pmos-cockpit/hooks/register.tsx +104 -19
- package/payload/mods/pmos-cockpit/types/index.d.ts +1 -1
- 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 +214 -0
- package/payload/scripts/workflow-state.js +59 -2
- package/payload/state/discoveries.md +1 -1
- package/payload/state/log.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/index.md +2 -1
- package/workspace/guide/initiatives-and-next-steps.md +17 -2
- package/workspace/guide/knowledge-graph.md +94 -1
- package/workspace/guide/layout.md +31 -2
- package/workspace/guide/live-notes.md +78 -0
- package/workspace/guide/proactive-engine.md +6 -2
- package/workspace/guide/validate-with-typesafe.md +31 -2
- 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 +214 -0
- package/workspace/lib/engine/workflow-state.js +59 -2
- package/workspace/lib/jev-categorize.js +433 -0
- package/workspace/lib/jev-claims.js +59 -15
- package/workspace/lib/jev-live.js +37 -0
- package/workspace/lib/jev-route.js +139 -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 +1035 -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 +78 -0
- package/workspace/public/pm/fixture.js +40 -2
- package/workspace/public/pm/modes-live.js +279 -18
- package/workspace/public/pm/panel-cockpit.js +638 -0
- package/workspace/public/pm/panel-detail.js +212 -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 +249 -29
- package/workspace/public/pm/state.js +48 -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-livenotes.js +397 -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 +349 -7
- package/workspace/skills/live-notes.skill +58 -0
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
type: Guide
|
|
3
3
|
title: "Guide: Today and the proactive engine"
|
|
4
4
|
description: "What the Today item shows, how the morning brief is made, and how the workspace works out what is waiting for you."
|
|
5
|
-
timestamp: 2026-10-
|
|
5
|
+
timestamp: 2026-10-04
|
|
6
6
|
---
|
|
7
7
|
|
|
8
8
|
# Guide: Today and the proactive engine
|
|
@@ -46,7 +46,7 @@ Today learns from you, and only from you. The evidence is your own records on th
|
|
|
46
46
|
|
|
47
47
|
- **Weights.** Three adjustments are added to an item's place in the order: one for its trigger (at most 8 points), one for its lane (at most 4) and one for its trigger at this time of day (at most 3). Time of day is morning 05-12, afternoon 12-17, evening 17-22 and night, in your local time. A cell moves only after 5 events, and moves less than the raw average. The total is at most 15, so learning reorders items that are close and cannot turn the stated order upside down. An item shows its adjustment and the counts behind it.
|
|
48
48
|
- **What it never does.** It never lowers or hides what blocks on you: the needs and returned triggers get no negative adjustment and are never narrowed. Items of the morning brief teach no weight, because their order is fixed. Pause learning makes every weight zero and stops the weekly review, the proposals and the narrowing; feedback is still recorded.
|
|
49
|
-
- **The weekly review.** About every 7 days, only while the workspace server runs, the workspace takes one snapshot. It does not catch up after a gap: it takes one snapshot of the last 7 days. **run the weekly review now** does the same on demand and is refused while learning is paused. Quiet hours hold scans, not this review.
|
|
49
|
+
- **The weekly review.** About every 7 days, only while the workspace server runs, the workspace takes one snapshot. It does not catch up after a gap: it takes one snapshot of the last 7 days. **run the weekly review now** does the same on demand and is refused while learning is paused. Quiet hours hold scans, not this review. The review runs from the proactive tick, so while the engine is switched off there is no weekly review either; **run the weekly review now** still works.
|
|
50
50
|
- **The rollup.** For the last 7 days: the hit rate (useful and acted, over useful, acted, not useful, wrong priority and dismissed), the used-as-is rate (accepted over accepted and sent back), corrections per prepared run (sends-back over every session started from a step, reviewed or not), and the items you reported as missed. Every rate shows its count. With fewer than 5 rated items, or 3 runs, it says there is not enough evidence and shows no rate.
|
|
51
51
|
- **The watermelon check.** It looks only when the hit rate is 0.6 or more. It then names any of these: 2 or more items reported as missed, a used-as-is rate under 0.5 over 3 or more reviewed runs, or 0.5 or more corrections per prepared run over 3 or more runs. A good hit rate with one of these under it is not a good week.
|
|
52
52
|
- **Lessons.** The review proposes lessons from four templates: a trigger that was useful under 40% or at least 80% of the time (5 or more ratings), items you reported as missed (2 or more), and a trigger that did badly in one part of the day only. A lesson is a proposal. It is approved, rejected or retired only when you press the button. A rejected lesson is not proposed again for 28 days. At most 10 lessons are approved at once.
|
|
@@ -93,6 +93,10 @@ A verdict never changes a status, a lifecycle record or an acceptance. It is rec
|
|
|
93
93
|
| quiet hours | A start and an end time. No automatic scan runs between them. |
|
|
94
94
|
| pause learning | Stops all learning: weights are zero, and there is no weekly review, no proposal and no narrowing. Feedback is still recorded. |
|
|
95
95
|
|
|
96
|
+
When you save a setting, Today shows the saved value immediately. A read that started before the save does not replace it.
|
|
97
|
+
|
|
98
|
+
Text that you type in a setting (the name, the stalled days, the quiet hours) or in the "something was missed" box stays in the box until you save it. A refresh of Today does not clear it, and the box keeps the focus.
|
|
99
|
+
|
|
96
100
|
## The commands
|
|
97
101
|
|
|
98
102
|
- `pmos proactive today` lists the items.
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
type: Guide
|
|
3
3
|
title: "Guide: validate a repository"
|
|
4
4
|
description: "How the validate item checks that a repository agrees with itself, and the optional second check with TypeSafe."
|
|
5
|
-
timestamp: 2026-10-
|
|
5
|
+
timestamp: 2026-10-04
|
|
6
6
|
---
|
|
7
7
|
|
|
8
8
|
# Guide: validate a repository
|
|
@@ -61,13 +61,42 @@ Jev's answers are **suspected**, not proven. They appear in their own group, **s
|
|
|
61
61
|
|
|
62
62
|
Two rejected keys, or three failed requests in a row, pause automatic checking for that repository. The page says why and shows **resume**. The workspace appends suspected pairs to `claims.jsonl`. Those lines hold a short snippet of the claim. A pair that Jev found fine is not written.
|
|
63
63
|
|
|
64
|
+
## Read documents for status
|
|
65
|
+
|
|
66
|
+
A second switch, **read documents for status**, is next to the first one. It is off until you turn it on. It sends texts only when the four conditions above also hold.
|
|
67
|
+
|
|
68
|
+
When it is on, the workspace asks Jev two more questions about the documents that belong to an initiative:
|
|
69
|
+
|
|
70
|
+
- **Is it a stub?** Each PRD, rubric and brief that the initiative record names, against the initiative's title and intent.
|
|
71
|
+
- **Does a note contradict a requirement?** Each other document whose frontmatter names the initiative, for example a meeting note, against the requirements of the initiative's PRD.
|
|
72
|
+
|
|
73
|
+
A document belongs to an initiative only if the record names its path or its own frontmatter names the initiative. Similar words do not make a link.
|
|
74
|
+
|
|
75
|
+
The first check after you turn this switch on records the documents that exist and sends nothing for them. After that, the workspace sends a document only when it changes. If you turn the switch off, the workspace forgets that starting point. When you turn it on again, it starts again from the documents that exist then. Documents that changed while the switch was off are not sent.
|
|
76
|
+
|
|
77
|
+
The answers appear in the initiative detail under **What the documents say**, as lines that start with **Documents suggest**. Each line says that it is a suggestion and names the model. A suggestion never changes a phase, a status or a count, and the workspace writes nothing to the document.
|
|
78
|
+
|
|
79
|
+
## Route prompts with Jev
|
|
80
|
+
|
|
81
|
+
A third switch, **route prompts with Jev**, is below the consent switch. It is off until you turn it on. When it is off, no prompt is sent.
|
|
82
|
+
|
|
83
|
+
When the switch is on, a TypeSafe key is saved, and validation is on for the repository, the workspace asks Jev about each prompt that you type in a workspace terminal. Jev answers with a tier: fast, balanced or deep. If the answer names a model that is different from the model of the session, the PMOS cockpit mod changes the model with `/model` and then sends your prompt. A line in the terminal names the new model and the tier.
|
|
84
|
+
|
|
85
|
+
These prompts are not sent: a slash command, a prompt with an attachment, a prompt that names a file with `@`, and a prompt shorter than 40 characters. A prompt that matches a credential pattern, or that names an excluded path, is not sent. In these cases the model does not change.
|
|
86
|
+
|
|
87
|
+
If one of the three conditions is missing, the model does not change. The terminal tells you once which condition is missing. A guess from keywords never changes the model.
|
|
88
|
+
|
|
89
|
+
To stop routing in one terminal, type `/pmos-route off`. To start it again, type `/pmos-route on`.
|
|
90
|
+
|
|
91
|
+
A change of model starts a new prompt cache, so the next answer can cost more. The workspace does not yet measure if routing saves money.
|
|
92
|
+
|
|
64
93
|
## Check with TypeSafe
|
|
65
94
|
|
|
66
95
|
## What leaves your machine
|
|
67
96
|
|
|
68
97
|
Validation is off until you turn it on. Until then, the workspace sends nothing.
|
|
69
98
|
|
|
70
|
-
When you turn it on, the workspace sends document text to `api.typesafe.ai`. It sends nothing else. It does not send binary files. It does not send a document that you excluded. It does not send a document that matches a credential pattern, such as a private key or a token. It lists that document as withheld.
|
|
99
|
+
When you turn it on, the workspace sends document text to `api.typesafe.ai`. If **route prompts with Jev** is also on, it sends the text of the prompts that you type in a workspace terminal. It sends nothing else. It does not send binary files. It does not send a document that you excluded. It does not send a document that matches a credential pattern, such as a private key or a token. It lists that document as withheld.
|
|
71
100
|
|
|
72
101
|
The workspace reads the TypeSafe key only on this machine. After you save the key, the screen does not show it again. The workspace does not give the key to a terminal or to an agent.
|
|
73
102
|
|
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 };
|