create-pmos 0.132.0 → 0.133.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/state/discoveries.md +1 -1
- package/workspace/bin/pmos.js +18 -2
- package/workspace/guide/index.md +1 -0
- package/workspace/guide/welcome.md +38 -0
- package/workspace/lib/import-overlay.js +32 -0
- package/workspace/lib/route-modules.js +1 -0
- package/workspace/lib/routes-onboarding.js +215 -0
- package/workspace/lib/vendor/scripts/anchor-resolve.js +351 -0
- package/workspace/lib/vendor/scripts/delivery-index.js +356 -0
- package/workspace/lib/vendor/scripts/intake.js +78 -0
- package/workspace/lib/vendor/scripts/lane-defs.js +226 -0
- package/workspace/lib/vendor/scripts/migrate-layout.js +775 -0
- package/workspace/lib/vendor/scripts/nine-stages.js +81 -0
- package/workspace/lib/vendor/scripts/onboarding/apply.js +110 -0
- package/workspace/lib/vendor/scripts/onboarding/brief.js +154 -0
- package/workspace/lib/vendor/scripts/onboarding/first-briefing.js +41 -0
- package/workspace/lib/vendor/scripts/onboarding/first-look.js +84 -0
- package/workspace/lib/vendor/scripts/onboarding/infer-brief.js +70 -0
- package/workspace/lib/vendor/scripts/onboarding/proposals.js +98 -0
- package/workspace/lib/vendor/scripts/onboarding/scan-worker.js +27 -0
- package/workspace/lib/vendor/scripts/onboarding/scan.js +859 -0
- package/workspace/lib/vendor/scripts/onboarding/structure.js +217 -0
- package/workspace/lib/vendor/scripts/plan-items-stage.js +271 -0
- package/workspace/lib/vendor/scripts/read-jsonl.js +53 -0
- package/workspace/lib/vendor/scripts/signals/layout-paths.js +43 -0
- package/workspace/lib/vendor/scripts/status-facts.js +214 -0
- package/workspace/lib/vendor/scripts/trust/import-guard.js +36 -0
- package/workspace/lib/vendor/scripts/trust/ledger.js +53 -0
- package/workspace/lib/vendor/scripts/workflow-state.js +1888 -0
- package/workspace/lib/vendor/workspace/lib/actor.js +91 -0
- package/workspace/lib/vendor/workspace/lib/board-cache.js +156 -0
- package/workspace/lib/vendor/workspace/lib/console-state.js +295 -0
- package/workspace/lib/vendor/workspace/lib/doc-category.js +227 -0
- package/workspace/lib/vendor/workspace/lib/factory-join.js +158 -0
- package/workspace/lib/vendor/workspace/lib/git-info.js +306 -0
- package/workspace/lib/vendor/workspace/lib/plan-items.js +922 -0
- package/workspace/lib/vendor/workspace/lib/plan-tree.js +419 -0
- package/workspace/lib/vendor/workspace/lib/review-gate.js +39 -0
- package/workspace/lib/vendor/workspace/lib/sink.js +321 -0
- package/workspace/lib/vendor/workspace/lib/state-root.js +587 -0
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/public/pm/components.css +280 -0
- package/workspace/public/pm/welcome.js +283 -0
- package/workspace/public/welcome.html +20 -0
- package/workspace/server.js +2 -2
- package/workspace/test-lists/onboarding-firstrun.json +15 -0
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/*
|
|
3
|
+
* The nine stage names as a label layer over the workflow engine's derived stage (run 3).
|
|
4
|
+
*
|
|
5
|
+
* The engine derives one of six rungs (intake, spec, contract, build, eval, accepted) from the steps
|
|
6
|
+
* it found. This file names the nine stages the PM sees and says which of them each lane uses. It
|
|
7
|
+
* never decides a stage itself: a stage is `done` only when a derived signal shows it, and a stage
|
|
8
|
+
* no record can yet show is `no-signal`, not guessed.
|
|
9
|
+
*
|
|
10
|
+
* The lane sets below are a proposal for the PM to correct (see the run 3 contract in the PRD).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const STAGES = ['discover', 'ideate', 'strategize', 'prioritize', 'plan', 'build', 'deliver', 'monitor', 'evaluate'];
|
|
14
|
+
|
|
15
|
+
const LABELS = {
|
|
16
|
+
discover: 'Discover', ideate: 'Ideate', strategize: 'Strategize', prioritize: 'Prioritize', plan: 'Plan',
|
|
17
|
+
build: 'Build', deliver: 'Deliver', monitor: 'Monitor', evaluate: 'Evaluate',
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
// Which stages each product-code lane uses. Stages not listed read `skipped`.
|
|
21
|
+
const LANE_STAGES = {
|
|
22
|
+
feature: STAGES,
|
|
23
|
+
discovery: ['discover', 'ideate', 'strategize', 'prioritize', 'evaluate'],
|
|
24
|
+
chore: ['plan', 'build', 'evaluate'],
|
|
25
|
+
incident: ['build', 'deliver', 'monitor', 'evaluate'],
|
|
26
|
+
content: ['ideate', 'plan', 'build', 'deliver'],
|
|
27
|
+
prototype: ['discover', 'ideate', 'evaluate'],
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
// Stages that have a derived signal today. Every other stage reads `no-signal` until its record exists.
|
|
31
|
+
const SIGNALLED = new Set(['discover', 'plan', 'build', 'evaluate']);
|
|
32
|
+
|
|
33
|
+
const RANK = { intake: 0, spec: 1, contract: 2, build: 3, eval: 4, accepted: 5 };
|
|
34
|
+
|
|
35
|
+
// Stages that gain a signal only when the caller passes the records (scripts/records readAll plus optional `releases`).
|
|
36
|
+
// With no records the output is exactly the older output, so the lifecycle-lens mirror stays equal.
|
|
37
|
+
const RECORD_SIGNALLED = new Set(['ideate', 'strategize', 'prioritize', 'monitor', 'deliver']);
|
|
38
|
+
|
|
39
|
+
// records: { initiative?, anchor?, ideas, hypotheses, decisions, bets, 'kr-metrics', releases } (every list optional).
|
|
40
|
+
// The initiative id and anchor come from `derived` (id, anchor) or from records.initiative / records.anchor.
|
|
41
|
+
function recordSignals(derived, records, buildDone) {
|
|
42
|
+
const id = (derived && derived.id) || records.initiative || null;
|
|
43
|
+
const anchor = (derived && derived.anchor) || records.anchor || null;
|
|
44
|
+
const mine = (list) => (list || []).filter((r) => id && r.initiative === id);
|
|
45
|
+
const kr = (records['kr-metrics'] || []).filter((m) => anchor && (m.rid === anchor || m.parent_kr === anchor || 'kr:' + m.rid === anchor));
|
|
46
|
+
return {
|
|
47
|
+
ideate: mine(records.ideas).length + mine(records.hypotheses).length > 0,
|
|
48
|
+
strategize: mine(records.decisions).length + mine(records.bets).length > 0,
|
|
49
|
+
prioritize: mine(records.bets).some((b) => ['now', 'next', 'later'].includes(b.horizon) && Number.isInteger(b.rank)),
|
|
50
|
+
monitor: !!buildDone && kr.some((m) => (m.readings || []).length > 0),
|
|
51
|
+
deliver: mine(records.releases).length > 0,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function nineStages(lane, derived, records) {
|
|
56
|
+
const laneSet = new Set(LANE_STAGES[lane] || STAGES);
|
|
57
|
+
const rank = RANK[derived && derived.stage] ?? 0;
|
|
58
|
+
const step = (key) => (derived && derived.steps || []).find((s) => s.key === key);
|
|
59
|
+
const done = {
|
|
60
|
+
discover: rank >= 1,
|
|
61
|
+
plan: rank >= 2,
|
|
62
|
+
build: rank >= 3 || (step('build') && step('build').status === 'done'),
|
|
63
|
+
evaluate: rank >= 4,
|
|
64
|
+
};
|
|
65
|
+
const accepted = rank >= 5;
|
|
66
|
+
const fromRecords = records && typeof records === 'object' ? recordSignals(derived, records, done.build) : null;
|
|
67
|
+
const out = [];
|
|
68
|
+
let currentSet = false;
|
|
69
|
+
for (const key of STAGES) {
|
|
70
|
+
let status;
|
|
71
|
+
if (!laneSet.has(key)) status = 'skipped';
|
|
72
|
+
else if (!SIGNALLED.has(key) && !(fromRecords && RECORD_SIGNALLED.has(key) && (key !== 'deliver' || fromRecords.deliver))) status = 'no-signal';
|
|
73
|
+
else if (done[key] || (fromRecords && fromRecords[key])) status = 'done';
|
|
74
|
+
else status = 'pending';
|
|
75
|
+
if (status === 'pending' && !currentSet) { status = 'current'; currentSet = true; }
|
|
76
|
+
out.push({ key, label: LABELS[key], status, accepted: key === 'evaluate' ? accepted : undefined });
|
|
77
|
+
}
|
|
78
|
+
return out;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
module.exports = { STAGES, LABELS, LANE_STAGES, nineStages };
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* apply.js: take the proposed structure into the per-user overlay, with a reverse manifest (slice 2). Nothing is written into the scanned folder.
|
|
3
|
+
* apply(dir, structure, { include, revisions, answers, actor, now, root }) -> { import_id, boards, initiatives, skipped, manifest }
|
|
4
|
+
* undo(dir, import_id, { actor, now }) -> { retracted, import_id } one `retract` event; nothing is deleted
|
|
5
|
+
* importedFor(dir) -> { boards, initiatives, imports } the fold of <dir>/imports.jsonl
|
|
6
|
+
* <dir>/imports.jsonl holds, through scripts/trust/ledger.js (ULIDs in order, actor from the call path):
|
|
7
|
+
* import { import_id, scan_id, include, fiscal? } the press
|
|
8
|
+
* board { import_id, board:{key,name,domain,template} }
|
|
9
|
+
* initiative { import_id, board, proposal_id, initiative_id (the row id; `id` is the event's own ULID), title, lane, anchor, anchor_source, stage, open, docs, docs_total }
|
|
10
|
+
* assign { import_id, initiative, board }
|
|
11
|
+
* retract { import_id, events:[ids] }
|
|
12
|
+
* apply also records an approve (or a revise, for a renamed or moved row) for each included proposal in <dir>/proposals.jsonl.
|
|
13
|
+
* A human actor is required; a dir inside the scanned root is refused. */
|
|
14
|
+
const fs = require('fs');
|
|
15
|
+
const path = require('path');
|
|
16
|
+
const ledger = require('../trust/ledger.js');
|
|
17
|
+
const P = require('./proposals.js');
|
|
18
|
+
|
|
19
|
+
const LANES = ['feature', 'discovery', 'chore', 'incident', 'content', 'prototype'];
|
|
20
|
+
const TEMPLATES = ['product', 'project', 'simple'];
|
|
21
|
+
const inside = (child, parent) => { const r = path.relative(path.resolve(parent), path.resolve(child)); return r === '' || (!r.startsWith('..') && !path.isAbsolute(r)); };
|
|
22
|
+
const slug = (s) => String(s || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 60) || 'initiative';
|
|
23
|
+
const file = (dir) => path.join(dir, 'imports.jsonl');
|
|
24
|
+
|
|
25
|
+
function apply(dir, structure, opts = {}) {
|
|
26
|
+
const actor = P.humanActor(opts.actor);
|
|
27
|
+
if (opts.root && inside(dir, opts.root)) throw new Error('refusing to write the import inside the scanned folder: ' + dir);
|
|
28
|
+
const at = opts.now || new Date().toISOString();
|
|
29
|
+
const revisions = opts.revisions || {}; const answers = opts.answers || {};
|
|
30
|
+
const allRows = structure.boards.flatMap((b) => b.initiatives.map((r) => ({ r, b })));
|
|
31
|
+
const include = new Set(Array.isArray(opts.include) ? opts.include : allRows.filter((x) => x.r.checked).map((x) => x.r.proposal_id));
|
|
32
|
+
const boardMeta = new Map(structure.boards.map((b) => [b.key, { key: b.key, name: b.name, domain: b.domain, template: b.template }]));
|
|
33
|
+
for (const [k, t] of Object.entries(answers.template || {})) { if (!TEMPLATES.includes(t)) throw new Error('template must be one of ' + TEMPLATES.join(', ')); if (boardMeta.has(k)) boardMeta.get(k).template = t; }
|
|
34
|
+
const picked = [];
|
|
35
|
+
for (const { r, b } of allRows) {
|
|
36
|
+
if (!include.has(r.proposal_id)) continue;
|
|
37
|
+
const rev = revisions[r.proposal_id] || {};
|
|
38
|
+
const row = Object.assign({}, r, { board: b.key, anchor_source: r.anchor_source });
|
|
39
|
+
if (rev.title) row.title = String(rev.title);
|
|
40
|
+
if (rev.board) { if (!boardMeta.has(rev.board)) throw new Error('unknown board ' + rev.board); row.board = rev.board; }
|
|
41
|
+
if (rev.lane) { if (!LANES.includes(rev.lane)) throw new Error('lane must be one of ' + LANES.join(', ')); row.lane = rev.lane; }
|
|
42
|
+
if (rev.anchor) { row.anchor = String(rev.anchor); row.anchor_source = 'confirmed'; }
|
|
43
|
+
else if (answers.anchor && row.anchor_source === 'proposed') {
|
|
44
|
+
// 'per-board' (or the one proposed anchor itself) is the PM accepting each board's domain anchor; anything else replaces it
|
|
45
|
+
if (answers.anchor !== 'per-board') row.anchor = String(answers.anchor);
|
|
46
|
+
row.anchor_source = 'confirmed';
|
|
47
|
+
}
|
|
48
|
+
row._rev = Object.keys(rev).length ? rev : null;
|
|
49
|
+
picked.push(row);
|
|
50
|
+
}
|
|
51
|
+
if (!picked.length) throw new Error('nothing to import: no row is included');
|
|
52
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
53
|
+
const importId = 'imp_' + ledger.newId();
|
|
54
|
+
const used = new Set(); const ids = [];
|
|
55
|
+
const uniqueId = (row) => { const base = row.id ? String(row.id) : slug(row.title); let id = base; let n = 2; while (used.has(id)) id = base + '-' + n++; used.add(id); return id; };
|
|
56
|
+
const f = file(dir);
|
|
57
|
+
const put = (ev) => { const l = ledger.append(f, Object.assign({ id: ledger.newId(), at }, ev), actor); ids.push(l.id); return l; };
|
|
58
|
+
const used2 = picked.map((row) => Object.assign(row, { _id: uniqueId(row) }));
|
|
59
|
+
const boardKeys = [...new Set(used2.map((r) => r.board))].sort();
|
|
60
|
+
put({ kind: 'import', import_id: importId, scan_id: structure.scan_id, include: used2.length, fiscal: answers.fiscal || undefined, why: 'first-run import: ' + used2.length + ' initiatives into ' + boardKeys.length + ' boards' });
|
|
61
|
+
for (const k of boardKeys) put({ kind: 'board', import_id: importId, board: boardMeta.get(k), why: 'board for import ' + importId });
|
|
62
|
+
for (const r of used2.sort((a, b) => (a.board < b.board ? -1 : a.board > b.board ? 1 : a._id < b._id ? -1 : 1))) {
|
|
63
|
+
put({ kind: 'initiative', import_id: importId, board: r.board, proposal_id: r.proposal_id, initiative_id: r._id, title: r.title, lane: r.lane, anchor: r.anchor, anchor_source: r.anchor_source, stage: r.stage, open: r.open, docs: (r.docs || []).map((d) => d.file), docs_total: r.docs_total || 0, why: 'imported at first run' });
|
|
64
|
+
put({ kind: 'assign', import_id: importId, initiative: r._id, board: r.board, why: 'imported at first run' });
|
|
65
|
+
}
|
|
66
|
+
const known = new Set(P.load(path.join(dir, 'proposals.jsonl')).map((p) => p.proposal_id));
|
|
67
|
+
const pf = path.join(dir, 'proposals.jsonl'); const decisions = []; let skipped = 0;
|
|
68
|
+
for (const r of used2) {
|
|
69
|
+
if (!known.has(r.proposal_id)) { skipped++; continue; }
|
|
70
|
+
const rev = r._rev;
|
|
71
|
+
const d = rev ? P.decide(pf, { proposal_id: r.proposal_id, decision: 'revise', revision: rev, why: 'revised at first-run import' }, actor, { now: at })
|
|
72
|
+
: P.decide(pf, { proposal_id: r.proposal_id, decision: 'approve' }, actor, { now: at });
|
|
73
|
+
decisions.push(d.id);
|
|
74
|
+
}
|
|
75
|
+
const manifest = { import_id: importId, scan_id: structure.scan_id, at, by: actor.by, events: ids.slice(), decisions, skipped_decisions: skipped };
|
|
76
|
+
fs.mkdirSync(path.join(dir, 'imports'), { recursive: true });
|
|
77
|
+
fs.writeFileSync(path.join(dir, 'imports', importId + '.manifest.json'), JSON.stringify(manifest, null, 1) + '\n');
|
|
78
|
+
return { import_id: importId, boards: boardKeys.length, initiatives: used2.length, skipped, manifest };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function undo(dir, importId, opts = {}) {
|
|
82
|
+
const actor = P.humanActor(opts.actor);
|
|
83
|
+
const lines = ledger.read(file(dir));
|
|
84
|
+
const imp = lines.find((l) => l.kind === 'import' && l.import_id === importId);
|
|
85
|
+
if (!imp) throw new Error('unknown import ' + importId);
|
|
86
|
+
if (lines.some((l) => l.kind === 'retract' && l.import_id === importId)) throw new Error('import ' + importId + ' is already retracted');
|
|
87
|
+
const events = lines.filter((l) => l.import_id === importId && l.kind !== 'retract').map((l) => l.id);
|
|
88
|
+
const l = ledger.append(file(dir), { id: ledger.newId(), kind: 'retract', import_id: importId, events, at: opts.now || new Date().toISOString(), why: 'undo of import ' + importId }, actor);
|
|
89
|
+
return { retracted: events.length, import_id: importId, event: l.id };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function importedFor(dir) {
|
|
93
|
+
const lines = ledger.read(file(dir));
|
|
94
|
+
const retracted = new Set(lines.filter((l) => l.kind === 'retract').map((l) => l.import_id));
|
|
95
|
+
const imports = []; const boards = new Map(); const inits = new Map();
|
|
96
|
+
for (const l of lines) {
|
|
97
|
+
if (l.kind === 'import') imports.push({ import_id: l.import_id, at: l.at, boards: 0, initiatives: 0, retracted: retracted.has(l.import_id) });
|
|
98
|
+
const im = imports.find((x) => x.import_id === l.import_id);
|
|
99
|
+
if (!im) continue;
|
|
100
|
+
if (l.kind === 'board') { im.boards++; if (!im.retracted) boards.set(l.board.key, Object.assign({}, l.board, { import_id: l.import_id, source: 'import' })); }
|
|
101
|
+
else if (l.kind === 'initiative') {
|
|
102
|
+
im.initiatives++;
|
|
103
|
+
if (!im.retracted) inits.set(l.initiative_id, { proposal_id: l.proposal_id, id: l.initiative_id, title: l.title, board: l.board, lane: l.lane, anchor: l.anchor, anchor_source: l.anchor_source, stage: l.stage, open: l.open, docs: l.docs, docs_total: l.docs_total, import_id: l.import_id, source: 'import' });
|
|
104
|
+
} else if (l.kind === 'assign' && !im.retracted && inits.has(l.initiative)) inits.get(l.initiative).board = l.board;
|
|
105
|
+
}
|
|
106
|
+
const cmp = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
|
|
107
|
+
return { boards: [...boards.values()].sort((a, b) => cmp(a.key, b.key)), initiatives: [...inits.values()].sort((a, b) => cmp(a.board, b.board) || cmp(a.id, b.id)), imports };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
module.exports = { apply, undo, importedFor };
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* brief.js - the ONBOARDING BRIEF: what the user tells PMOS before the scan runs.
|
|
3
|
+
* Pure module. No file access, no model. A caller stores the text at <repo>/onboarding/brief.md.
|
|
4
|
+
* The file is OKF Markdown: frontmatter (keys on one line), sentences for a person, and one JSON block that holds the exact fields. */
|
|
5
|
+
|
|
6
|
+
const KINDS = ['folder', 'repo', 'jira', 'confluence', 'drive', 'calendar', 'slack', 'mail', 'notion', 'figma', 'other'];
|
|
7
|
+
const LOCAL_KINDS = ['folder', 'repo'];
|
|
8
|
+
const SENSITIVITY = ['public', 'internal', 'confidential', 'personal'];
|
|
9
|
+
const ACCESS = { LOCAL: 'local path', IMPORT: 'imported through a session with my own connection' };
|
|
10
|
+
const BRIEF_TYPE = 'Reference'; // a registered frontmatter type (scripts/frontmatter_registry.txt)
|
|
11
|
+
const MARK = 'pmos-onboarding-brief';
|
|
12
|
+
|
|
13
|
+
const clone = (x) => JSON.parse(JSON.stringify(x));
|
|
14
|
+
const now = () => new Date().toISOString();
|
|
15
|
+
|
|
16
|
+
function newSource(o = {}) {
|
|
17
|
+
return { kind: 'folder', ref: '', scope: '', include: [], exclude: [], sensitivity: null, access: ACCESS.LOCAL, ...o };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function newBrief(o = {}) {
|
|
21
|
+
const b = {
|
|
22
|
+
type: BRIEF_TYPE,
|
|
23
|
+
title: 'Onboarding brief',
|
|
24
|
+
timestamp: now(),
|
|
25
|
+
sources: [],
|
|
26
|
+
product: '',
|
|
27
|
+
knownInitiatives: [],
|
|
28
|
+
vocabulary: { initiative: [], goal: [], fiscalYearStartMonth: null, quarterNames: [] },
|
|
29
|
+
horizonDays: null,
|
|
30
|
+
people: [],
|
|
31
|
+
goals: [],
|
|
32
|
+
doNotRead: [],
|
|
33
|
+
decisionsPending: [],
|
|
34
|
+
constraints: '',
|
|
35
|
+
history: [],
|
|
36
|
+
answered: [],
|
|
37
|
+
override: null,
|
|
38
|
+
};
|
|
39
|
+
const v = { ...b.vocabulary, ...(o.vocabulary || {}) };
|
|
40
|
+
const out = { ...b, ...clone(o), vocabulary: clone(v) };
|
|
41
|
+
out.sources = (o.sources || []).map((s) => newSource(clone(s)));
|
|
42
|
+
return out;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function validate(b) {
|
|
46
|
+
const e = [];
|
|
47
|
+
if (!b || typeof b !== 'object') return { valid: false, errors: ['the brief is not an object'] };
|
|
48
|
+
const arr = (k) => { if (!Array.isArray(b[k])) e.push(`${k} must be a list`); };
|
|
49
|
+
['sources', 'knownInitiatives', 'people', 'goals', 'doNotRead', 'decisionsPending', 'history', 'answered'].forEach(arr);
|
|
50
|
+
if (b.type !== BRIEF_TYPE) e.push(`type must be ${BRIEF_TYPE}`);
|
|
51
|
+
if (typeof b.title !== 'string' || !b.title) e.push('title is required');
|
|
52
|
+
if (!b.timestamp || Number.isNaN(Date.parse(b.timestamp))) e.push('timestamp must be an ISO date');
|
|
53
|
+
(b.sources || []).forEach((s, i) => {
|
|
54
|
+
const p = `sources[${i}]`;
|
|
55
|
+
if (!KINDS.includes(s.kind)) e.push(`${p}.kind must be one of ${KINDS.join(', ')}`);
|
|
56
|
+
if (typeof s.ref !== 'string') e.push(`${p}.ref must be text`);
|
|
57
|
+
if (s.sensitivity !== null && !SENSITIVITY.includes(s.sensitivity)) e.push(`${p}.sensitivity must be one of ${SENSITIVITY.join(', ')}`);
|
|
58
|
+
if (![ACCESS.LOCAL, ACCESS.IMPORT].includes(s.access)) e.push(`${p}.access must be "${ACCESS.LOCAL}" or "${ACCESS.IMPORT}"`);
|
|
59
|
+
if (!Array.isArray(s.include) || !Array.isArray(s.exclude)) e.push(`${p}.include and ${p}.exclude must be lists`);
|
|
60
|
+
});
|
|
61
|
+
(b.knownInitiatives || []).forEach((k, i) => {
|
|
62
|
+
if (!k || typeof k.name !== 'string' || !Array.isArray(k.aliases)) e.push(`knownInitiatives[${i}] needs name and aliases[]`);
|
|
63
|
+
});
|
|
64
|
+
const v = b.vocabulary;
|
|
65
|
+
if (!v || !Array.isArray(v.initiative) || !Array.isArray(v.goal) || !Array.isArray(v.quarterNames)) e.push('vocabulary needs initiative[], goal[], quarterNames[]');
|
|
66
|
+
else if (v.fiscalYearStartMonth !== null && !(Number.isInteger(v.fiscalYearStartMonth) && v.fiscalYearStartMonth >= 1 && v.fiscalYearStartMonth <= 12)) e.push('vocabulary.fiscalYearStartMonth must be 1 to 12 or null');
|
|
67
|
+
if (b.horizonDays !== null && !(Number.isFinite(b.horizonDays) && b.horizonDays > 0)) e.push('horizonDays must be a positive number or null');
|
|
68
|
+
(b.people || []).forEach((p, i) => { if (!p || typeof p.name !== 'string') e.push(`people[${i}] needs a name`); });
|
|
69
|
+
if (typeof b.constraints !== 'string') e.push('constraints must be text');
|
|
70
|
+
if (b.override !== null && !(b.override && b.override.reason && b.override.by)) e.push('override needs reason and by');
|
|
71
|
+
return { valid: e.length === 0, errors: e };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/* ---- glob matching (shared by the assess and probe code) ---- */
|
|
75
|
+
function globToRegExp(g) {
|
|
76
|
+
let s = '';
|
|
77
|
+
for (let i = 0; i < g.length; i++) {
|
|
78
|
+
const c = g[i];
|
|
79
|
+
if (c === '*') {
|
|
80
|
+
if (g[i + 1] === '*') { i++; if (g[i + 1] === '/') { i++; s += '(?:.*/)?'; } else s += '.*'; } else s += '[^/]*';
|
|
81
|
+
} else if (c === '?') s += '[^/]';
|
|
82
|
+
else s += c.replace(/[.+^${}()|[\]\\]/g, '\\$&');
|
|
83
|
+
}
|
|
84
|
+
return new RegExp(`^${s}$`, 'i');
|
|
85
|
+
}
|
|
86
|
+
/* A pattern with no slash matches any file or folder name at any depth. A trailing slash means "everything inside". */
|
|
87
|
+
function matchGlob(pattern, relPath) {
|
|
88
|
+
let p = String(pattern || '').trim();
|
|
89
|
+
if (!p) return false;
|
|
90
|
+
const rel = relPath.replace(/\\/g, '/').replace(/^\.?\//, '');
|
|
91
|
+
if (p.endsWith('/')) p += '**';
|
|
92
|
+
p = p.replace(/^\//, '');
|
|
93
|
+
if (!p.includes('/')) return rel.split('/').some((seg) => globToRegExp(p).test(seg));
|
|
94
|
+
return globToRegExp(p).test(rel) || globToRegExp(p.replace(/\/\*\*$/, '')).test(rel);
|
|
95
|
+
}
|
|
96
|
+
const matchesAny = (patterns, rel) => (patterns || []).some((p) => matchGlob(p, rel));
|
|
97
|
+
|
|
98
|
+
/* ---- text rendering and parsing ---- */
|
|
99
|
+
const oneLine = (s) => String(s).replace(/\s+/g, ' ').replace(/"/g, "'").trim();
|
|
100
|
+
const list = (a) => (a && a.length ? a.join(', ') : 'none');
|
|
101
|
+
|
|
102
|
+
function renderBody(b) {
|
|
103
|
+
const L = [];
|
|
104
|
+
L.push(`# ${b.title}`, '', 'This brief tells the scan what to read and how to read it.', '');
|
|
105
|
+
L.push('## Sources', '');
|
|
106
|
+
if (!b.sources.length) L.push('No source is named yet.');
|
|
107
|
+
b.sources.forEach((s, i) => {
|
|
108
|
+
L.push(`${i + 1}. ${s.kind}: ${s.ref || '(no reference)'}.`);
|
|
109
|
+
L.push(` - What counts: ${s.scope || 'not said'}.`);
|
|
110
|
+
L.push(` - Include: ${list(s.include)}. Exclude: ${list(s.exclude)}.`);
|
|
111
|
+
L.push(` - Sensitivity: ${s.sensitivity || 'not said'}. Access: ${s.access}.`);
|
|
112
|
+
});
|
|
113
|
+
L.push('', '## Product and initiatives', '');
|
|
114
|
+
L.push(`The product is ${b.product || 'not named'}.`);
|
|
115
|
+
if (b.knownInitiatives.length) b.knownInitiatives.forEach((k) => L.push(`- ${k.name}${k.aliases.length ? ` (also: ${k.aliases.join(', ')})` : ''}`));
|
|
116
|
+
else L.push('No initiative is named yet.');
|
|
117
|
+
L.push('', '## Words you use', '');
|
|
118
|
+
L.push(`One piece of work is called: ${list(b.vocabulary.initiative)}.`);
|
|
119
|
+
L.push(`A goal is called: ${list(b.vocabulary.goal)}.`);
|
|
120
|
+
L.push(`The fiscal year starts in month: ${b.vocabulary.fiscalYearStartMonth || 'not said'}.`);
|
|
121
|
+
L.push(`Quarter names: ${list(b.vocabulary.quarterNames)}.`);
|
|
122
|
+
L.push('', '## Horizon, people and goals', '');
|
|
123
|
+
L.push(`Look ahead: ${b.horizonDays ? `${b.horizonDays} days` : 'not said'}.`);
|
|
124
|
+
b.people.forEach((p) => L.push(`- ${p.name}${p.role ? `, ${p.role}` : ''}${p.audience ? `. Audience: ${p.audience}` : ''}`));
|
|
125
|
+
if (!b.people.length) L.push('No person is named yet.');
|
|
126
|
+
L.push('', 'What you want from the first look:');
|
|
127
|
+
if (b.goals.length) b.goals.forEach((g) => L.push(`- ${oneLine(g)}`)); else L.push('- not said');
|
|
128
|
+
L.push('', '## Boundaries', '');
|
|
129
|
+
L.push(`Do not read: ${list(b.doNotRead)}.`);
|
|
130
|
+
L.push(`Decisions still open: ${list(b.decisionsPending)}.`);
|
|
131
|
+
L.push(`Constraints: ${b.constraints ? oneLine(b.constraints) : 'none'}.`);
|
|
132
|
+
if (b.override) L.push('', `The scan was started with open blockers. Reason: ${oneLine(b.override.reason)} (by ${b.override.by}).`);
|
|
133
|
+
L.push('', '## Questions and answers', '');
|
|
134
|
+
if (!b.history.length) L.push('No question has been answered yet.');
|
|
135
|
+
b.history.forEach((h) => L.push(`- ${h.at || ''} ${h.by}: ${oneLine(h.question || h.kind)} Answer: ${oneLine(JSON.stringify(h.answer === undefined ? h.reason || '' : h.answer))}`));
|
|
136
|
+
return L.join('\n');
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function serialize(b) {
|
|
140
|
+
const fm = ['---', `type: ${b.type}`, `title: "${oneLine(b.title)}"`, `timestamp: ${b.timestamp}`, '---'].join('\n');
|
|
141
|
+
const data = JSON.stringify(b, null, 2);
|
|
142
|
+
return `${fm}\n\n${renderBody(b)}\n\n## Exact fields\n\nThe scan reads this block. Edit the brief with the interview, not by hand.\n\n\`\`\`json ${MARK}\n${data}\n\`\`\`\n`;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function parse(text) {
|
|
146
|
+
const m = String(text).match(new RegExp('```json ' + MARK + '\\n([\\s\\S]*?)\\n```'));
|
|
147
|
+
if (!m) throw new Error('no exact-fields block found: this is not an onboarding brief');
|
|
148
|
+
const b = JSON.parse(m[1]);
|
|
149
|
+
const v = validate(b);
|
|
150
|
+
if (!v.valid) throw new Error('invalid brief: ' + v.errors.join('; '));
|
|
151
|
+
return b;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
module.exports = { KINDS, LOCAL_KINDS, SENSITIVITY, ACCESS, BRIEF_TYPE, newBrief, newSource, validate, serialize, parse, globToRegExp, matchGlob, matchesAny, clone };
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* first-briefing.js: the first Briefing after an import (slice 2). firstBriefing(scan, imported, { today }) -> { items }
|
|
3
|
+
* At most five items, from the first-look findings, each mapped onto an imported initiative when the finding's evidence file is one of that
|
|
4
|
+
* initiative's documents, the proposal id matches, or the finding's candidate names the initiative id. Items with an initiative come first;
|
|
5
|
+
* room left over goes to open imported initiatives whose anchor is still only proposed. No model, no write. */
|
|
6
|
+
const { findings } = require('./first-look.js');
|
|
7
|
+
|
|
8
|
+
const MAX = 5;
|
|
9
|
+
|
|
10
|
+
function firstBriefing(scan, imported, opts = {}) {
|
|
11
|
+
void opts;
|
|
12
|
+
const inits = (imported && imported.initiatives) || [];
|
|
13
|
+
const byDoc = new Map(); const byId = new Map(); const byProposal = new Map();
|
|
14
|
+
for (const i of inits) { byId.set(i.id, i); if (i.proposal_id) byProposal.set(i.proposal_id, i); for (const f of i.docs || []) if (!byDoc.has(f)) byDoc.set(f, i); }
|
|
15
|
+
const cand = new Map((scan.candidates || []).map((c) => [c.id, c]));
|
|
16
|
+
const ref = (i) => (i ? { id: i.id, title: i.title, board: i.board } : null);
|
|
17
|
+
const mapped = []; const rest = []; const asked = new Set();
|
|
18
|
+
for (const f of findings(scan)) {
|
|
19
|
+
const c = cand.get(f.proposal_id); const d = (c && c.detail) || {};
|
|
20
|
+
let hit = byProposal.get(f.proposal_id) || null;
|
|
21
|
+
for (const e of f.evidence) if (!hit && byDoc.has(e.file)) hit = byDoc.get(e.file);
|
|
22
|
+
for (const k of [d.id, d.initiative, ...(d.cited_by || [])]) if (!hit && k && byId.has(k)) hit = byId.get(k);
|
|
23
|
+
const evidence = f.evidence.slice(0, 2).map((e) => ({ file: e.file, line: e.line, quote: e.quote }));
|
|
24
|
+
if (f.kind === 'MISSING_ANCHOR' && hit) {
|
|
25
|
+
// the import gave it an anchor: a confirmed one settles the finding; a proposed one is still the PM's to confirm
|
|
26
|
+
if (hit.anchor_source !== 'proposed') continue;
|
|
27
|
+
asked.add(hit.id);
|
|
28
|
+
mapped.push({ title: `${hit.title} needs an anchor you confirm`, why: `It had no anchor. PMOS proposed ${hit.anchor}; PMOS rejects unanchored work and never invents a KR.`, initiative: ref(hit), evidence });
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
(hit ? mapped : rest).push({ title: f.title, why: f.why, initiative: ref(hit), evidence });
|
|
32
|
+
}
|
|
33
|
+
const items = [...mapped, ...rest].slice(0, MAX);
|
|
34
|
+
for (const i of inits) {
|
|
35
|
+
if (items.length >= MAX) break;
|
|
36
|
+
if (i.open && i.anchor_source === 'proposed' && !asked.has(i.id)) items.push({ title: `${i.title} needs an anchor you confirm`, why: `PMOS proposed ${i.anchor} for it; it rejects unanchored work and never invents a KR.`, initiative: ref(i), evidence: [] });
|
|
37
|
+
}
|
|
38
|
+
return { items };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
module.exports = { firstBriefing };
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* first-look.js: the AHA report. firstLook(scan, today) -> { numbers, sentence, findings, markdown }.
|
|
3
|
+
* Every number comes from scan.totals (uncapped counts); every finding is a scan candidate with its file:line evidence and the
|
|
4
|
+
* proposal id it leads to. Findings are ranked by consequence, not by count. Text taken from the folder is untrusted data: it is
|
|
5
|
+
* shown datamarked (a leading mark) inside a code span, never as markdown a reader or an agent could take for an instruction. */
|
|
6
|
+
const MARK = '▒';
|
|
7
|
+
|
|
8
|
+
const code = (s) => '`' + MARK + ' ' + String(s).replace(/`/g, "'").replace(/\n/g, ' ') + '`';
|
|
9
|
+
const ref = (e) => (e.line ? `${e.file}:${e.line}` : e.file);
|
|
10
|
+
|
|
11
|
+
function goalBoost(goals) {
|
|
12
|
+
const g = (goals || []).join(' ').toLowerCase();
|
|
13
|
+
return { late: /late|overdue|slip|deadline|behind/.test(g) ? 15 : 0, owner: /owner|accountab|who/.test(g) ? 15 : 0, stale: /stale|old|outdated|fresh/.test(g) ? 15 : 0, anchor: /goal|okr|anchor|kr\b/.test(g) ? 15 : 0 };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function findings(scan) {
|
|
17
|
+
const out = []; const cs = scan.candidates; const boost = goalBoost(scan.brief && scan.brief.goals);
|
|
18
|
+
const win = (scan.windows) || { near: 30, far: 90 };
|
|
19
|
+
const push = (c, score, title, why) => out.push({ score, title, why, evidence: c.evidence, proposal_id: c.id, kind: c.kind, confidence: c.confidence, label: c.label });
|
|
20
|
+
const recById = new Map(cs.filter((c) => c.kind === 'INITIATIVE' && c.detail.id).map((c) => [c.detail.id, c]));
|
|
21
|
+
for (const c of cs) {
|
|
22
|
+
const d = c.detail;
|
|
23
|
+
if (c.kind === 'DEADLINE' && d.bucket === 'in_30' && c.confidence !== 'low') {
|
|
24
|
+
if (!d.owner) push(c, 100 - d.days + boost.owner, `Deadline ${d.iso} in ${d.days} days with no owner`, `A date within ${win.near} days is written in ${c.evidence[0].file}, and the document names no owner or author.`);
|
|
25
|
+
else push(c, 70 - d.days + boost.late, `Deadline ${d.iso} in ${d.days} days`, `Owner on the document: ${d.owner}.`);
|
|
26
|
+
} else if (c.kind === 'DEADLINE' && d.bucket === 'past' && d.days >= -60 && c.confidence === 'high' && !d.owner) push(c, 62 + boost.late, `Date ${d.iso} passed ${-d.days} days ago, no owner`, 'A strong deadline word sits next to a date that has passed, and the document names no owner.');
|
|
27
|
+
else if (c.kind === 'DEADLINE' && d.bucket === 'in_90' && c.confidence !== 'low') push(c, 45 - Math.floor(d.days / 10) + boost.late, `Deadline ${d.iso} in ${d.days} days`, `Inside the ${win.far}-day window.`);
|
|
28
|
+
else if (c.kind === 'MISSING_ANCHOR') push(c, (d.open ? 80 : 30) + boost.anchor, `${d.id} has no anchor`, 'PMOS rejects unanchored work: the PM names a KR or a health budget.');
|
|
29
|
+
else if (c.kind === 'STALE' && d.cited_by && d.cited_by.length) push(c, 72 + boost.stale, `Stale document cited by an open initiative`, `${c.evidence[0].file} was last committed ${d.age_days} days ago and ${d.cited_by.join(', ')} cites it.`);
|
|
30
|
+
else if (c.kind === 'STALE') push(c, 20 + boost.stale, `Untouched for ${d.age_days} days`, c.title);
|
|
31
|
+
else if (c.kind === 'DECISION' && c.detail.level === 'doc' && (c.detail.followups || 0) === 0) push(c, 50, `Decision with no follow-up: ${c.title}`, 'No other document links to it and it names no initiative.');
|
|
32
|
+
else if (c.kind === 'RISK' && d.blocked && !/^Blocked by:\s*\[[^\]]+\]\([^)]+\)\s*$/i.test(c.title)) push(c, 36, `Blocked: ${c.title}`, 'A blocker is written under a Blocked heading or prefix.');
|
|
33
|
+
else if (c.kind === 'KR' && c.confidence === 'low' && d.in_okr_doc) push(c, 28 + boost.anchor, `${d.id} has no complete metric`, 'The KR is written with neither a baseline nor a target; the metric is the first thing a plan needs.');
|
|
34
|
+
else if (c.kind === 'DUPLICATE' && c.confidence === 'high') push(c, 22, `Duplicate: ${c.evidence.length} places`, c.proposed.summary);
|
|
35
|
+
}
|
|
36
|
+
void recById;
|
|
37
|
+
// one finding per proposal and per file:line, and at most 3 of one kind (4 deadlines), so ten findings are ten different things
|
|
38
|
+
const seen = new Set(); const per = {};
|
|
39
|
+
const ranked = out.sort((a, b) => b.score - a.score || (a.proposal_id < b.proposal_id ? -1 : 1)).filter((f) => {
|
|
40
|
+
const k = f.evidence[0].file + ':' + f.evidence[0].line + (f.kind === 'KR' ? '' : '');
|
|
41
|
+
if (seen.has(f.proposal_id) || seen.has(k)) return false;
|
|
42
|
+
const cap = f.kind === 'DEADLINE' ? 4 : 3; per[f.kind] = (per[f.kind] || 0) + 1; if (per[f.kind] > cap) return false;
|
|
43
|
+
seen.add(f.proposal_id); seen.add(k); return true;
|
|
44
|
+
});
|
|
45
|
+
return ranked.slice(0, 10).map((f, i) => Object.assign({ rank: i + 1 }, f));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function firstLook(scan, today) {
|
|
49
|
+
const t = scan.totals; const day = today || scan.today; const win = scan.windows || { near: 30, far: 90 };
|
|
50
|
+
const dl = t.deadlines || { in_30: 0, in_90: 0, in_next_90: 0 };
|
|
51
|
+
const numbers = {
|
|
52
|
+
initiatives: t.initiatives, initiatives_anchored: t.initiatives_anchored, possible_initiatives: t.initiative_clusters || 0,
|
|
53
|
+
documents: t.documents, documents_with_frontmatter: t.documents_with_frontmatter,
|
|
54
|
+
deadlines_near: dl.in_30, deadlines_far: dl.in_next_90, near_days: win.near, far_days: win.far, deadlines_past: dl.past,
|
|
55
|
+
stale: t.stale, duplicates: t.duplicates, people: t.people, risks_or_questions: (t.risks || 0) + (t.open_questions || 0), git_history: !!t.git_history,
|
|
56
|
+
};
|
|
57
|
+
const n = numbers;
|
|
58
|
+
const sentence = `We found ${n.initiatives} initiatives (${n.initiatives_anchored} anchored), ${n.documents} documents (${n.documents_with_frontmatter} with frontmatter), ${n.deadlines_near} deadlines in the next ${n.near_days} days and ${n.deadlines_far} in the next ${n.far_days}, ${n.stale} documents untouched for 90 days, ${n.duplicates} duplicates, ${n.people} people, ${n.risks_or_questions} risks or open questions.`;
|
|
59
|
+
const top = findings(scan);
|
|
60
|
+
const L = [];
|
|
61
|
+
L.push('---', 'type: Reference', `title: "First look: ${String(scan.tenant_name).replace(/"/g, "'")} (${day})"`, `timestamp: ${day}`, '---', '');
|
|
62
|
+
L.push(`# First look: ${scan.tenant_name}`, '');
|
|
63
|
+
L.push(`Scan ${scan.scan_id} of ${scan.tenant_name}, read-only, today is ${day}. Every number below is counted from the scan; every finding cites a file and line. Nothing has been changed, and every finding is a proposal for you to approve, reject or revise.`, '');
|
|
64
|
+
L.push(`**${sentence}**`, '');
|
|
65
|
+
if (n.possible_initiatives) L.push(`Beside those records, ${n.possible_initiatives} possible initiatives were grouped from folders, briefs, Jira epic keys and documents that declare an initiative id. They are low or medium confidence: an agent grouping pass is the next slice, and you decide.`, '');
|
|
66
|
+
if (!n.git_history) L.push('This folder has no git history, so "untouched for 90 days" could not be counted. It is not zero; it is unknown.', '');
|
|
67
|
+
if (scan.brief) L.push(`The scan followed your onboarding brief: ${scan.brief.skipped_files} files were not opened (${Object.entries(scan.brief.skipped_by_reason).map(([k, v]) => `${v} by ${k}`).join('; ') || 'none'}).`, '');
|
|
68
|
+
L.push('## The ten findings that matter most', '');
|
|
69
|
+
if (!top.length) L.push('No finding reached the ranking rules in this scan.', '');
|
|
70
|
+
for (const f of top) {
|
|
71
|
+
L.push(`${f.rank}. **${f.title}** (${f.confidence} confidence, ${f.label})`);
|
|
72
|
+
L.push(` ${f.why}`);
|
|
73
|
+
for (const e of f.evidence.slice(0, 2)) L.push(` - ${ref(e)}: ${code(e.quote)}`);
|
|
74
|
+
L.push(` - leads to proposal \`${f.proposal_id}\` (${f.kind})`);
|
|
75
|
+
}
|
|
76
|
+
L.push('', '## What the numbers are', '');
|
|
77
|
+
L.push(`- Initiatives and anchors: counted from initiative records (${t.initiatives_archived || 0} archived records are not counted). Anchored means the record names a parent_kr.`);
|
|
78
|
+
L.push(`- Deadlines: a date next to a trigger word (due, deadline, launch, release, go-live, by, ETA ...) in a document or a deadline-type frontmatter key. ${dl.past} more are in the past. A quarter with no year assumes the current year; a quarter under a fiscal start month is labelled an assumption.`);
|
|
79
|
+
L.push(`- Windows: next ${win.near} days and next ${win.far} days (the second includes the first).`);
|
|
80
|
+
L.push('- People: only from frontmatter owner, author, attendees and the like, and from Roles tables. No name is guessed from prose.', '');
|
|
81
|
+
return { numbers, sentence, findings: top, markdown: L.join('\n') + '\n' };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
module.exports = { firstLook, findings };
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* infer-brief.js: a valid onboarding brief made from the folder alone (slice 2, scan first and ask later).
|
|
3
|
+
* inferBrief(root, { today }) -> brief (brief.js shape, `inferred: true`)
|
|
4
|
+
* Reads only file and folder NAMES and the first lines of a few markdown files, to learn which word the folder uses for a piece of work
|
|
5
|
+
* (initiative, epic, project, bet). No model, no network, no write. The same folder and the same today give the same brief. */
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
const B = require('./brief.js');
|
|
9
|
+
|
|
10
|
+
const DO_NOT_READ = ['.git/**', 'node_modules/**', '**/.env*', '**/*secret*', '**/*.pem', '**/*.key'];
|
|
11
|
+
const WORDS = ['initiative', 'epic', 'project', 'bet'];
|
|
12
|
+
const SKIP_DIR = /^(\.git|node_modules|\.next|dist|build|coverage|\.venv)$/;
|
|
13
|
+
const MAX_FILES = 600; const MAX_DEPTH = 5; const MAX_HEAD = 300;
|
|
14
|
+
|
|
15
|
+
/** Count the words the folder uses: folder names that are the word, and markdown lines that start `Word:` or `# Word:` in the first 40 lines. */
|
|
16
|
+
function vocabulary(root) {
|
|
17
|
+
const count = Object.fromEntries(WORDS.map((w) => [w, 0]));
|
|
18
|
+
let seen = 0; let heads = 0;
|
|
19
|
+
const lineRe = new RegExp('^\\s*(?:#+\\s*)?(' + WORDS.join('|') + ')\\s*[:\\u2014\\u2013-]', 'i');
|
|
20
|
+
const dirRe = new RegExp('^(' + WORDS.join('|') + ')s?$', 'i');
|
|
21
|
+
(function walk(dir, depth) {
|
|
22
|
+
let ents; try { ents = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
23
|
+
ents.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
24
|
+
for (const e of ents) {
|
|
25
|
+
if (seen >= MAX_FILES) return;
|
|
26
|
+
const f = path.join(dir, e.name);
|
|
27
|
+
if (e.isDirectory()) {
|
|
28
|
+
if (SKIP_DIR.test(e.name) || depth >= MAX_DEPTH) continue;
|
|
29
|
+
const m = dirRe.exec(e.name); if (m) count[m[1].toLowerCase()]++;
|
|
30
|
+
walk(f, depth + 1);
|
|
31
|
+
} else if (e.isFile() && /\.(md|markdown)$/i.test(e.name) && !/secret|\.env/i.test(e.name)) {
|
|
32
|
+
seen++; if (heads >= MAX_HEAD) continue; heads++;
|
|
33
|
+
let text; try { const fd = fs.openSync(f, 'r'); const buf = Buffer.alloc(4096); const n = fs.readSync(fd, buf, 0, 4096, 0); fs.closeSync(fd); text = buf.slice(0, n).toString('utf8'); } catch { continue; }
|
|
34
|
+
for (const l of text.split('\n').slice(0, 40)) { const m = lineRe.exec(l); if (m) count[m[1].toLowerCase()]++; }
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
})(root, 0);
|
|
38
|
+
const used = WORDS.filter((w) => count[w] > 0).sort((a, b) => count[b] - count[a] || WORDS.indexOf(a) - WORDS.indexOf(b));
|
|
39
|
+
return used.length ? used : ['initiative'];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function inferBrief(root, opts = {}) {
|
|
43
|
+
const abs = path.resolve(root);
|
|
44
|
+
const today = opts.today || new Date().toISOString().slice(0, 10);
|
|
45
|
+
const b = B.newBrief({
|
|
46
|
+
title: 'Onboarding brief (inferred)',
|
|
47
|
+
timestamp: today + 'T00:00:00.000Z',
|
|
48
|
+
sources: [{ kind: 'folder', ref: abs, scope: 'everything in the folder', sensitivity: null, access: B.ACCESS.LOCAL }],
|
|
49
|
+
product: path.basename(abs),
|
|
50
|
+
vocabulary: { initiative: vocabulary(abs), goal: [], fiscalYearStartMonth: null, quarterNames: [] },
|
|
51
|
+
horizonDays: 30,
|
|
52
|
+
doNotRead: DO_NOT_READ.slice(),
|
|
53
|
+
constraints: 'Inferred from the folder alone. Nothing here was asked of the user.',
|
|
54
|
+
});
|
|
55
|
+
b.inferred = true;
|
|
56
|
+
const v = B.validate(b); if (!v.valid) throw new Error('inferred brief is invalid: ' + v.errors.join('; '));
|
|
57
|
+
return b;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The first-run scan: the inferred brief, then the read-only scan with every initiative kept. The default cap (80) suits a proposal list
|
|
61
|
+
* the PM reads; a portfolio must hold every initiative, or the boards silently miss some. */
|
|
62
|
+
const FIRST_RUN_CAPS = { INITIATIVE: 5000 };
|
|
63
|
+
function firstRunScan(root, opts = {}) {
|
|
64
|
+
const today = opts.today || new Date().toISOString().slice(0, 10);
|
|
65
|
+
const brief = opts.brief || inferBrief(root, { today });
|
|
66
|
+
const scan = require('./scan.js').scan(root, { domain: opts.domain, today, brief, caps: FIRST_RUN_CAPS });
|
|
67
|
+
return { brief, scan };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
module.exports = { inferBrief, firstRunScan, DO_NOT_READ, FIRST_RUN_CAPS };
|