create-pmos 0.52.0 → 0.53.1
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 +2 -2
- package/payload/mods/pmos-cockpit/README.md +13 -0
- package/payload/mods/pmos-cockpit/hooks/lib/commands.mjs +2 -1
- package/payload/mods/pmos-cockpit/hooks/lib/grants.mjs +41 -11
- package/payload/mods/pmos-cockpit/hooks/lib/guard.mjs +228 -8
- package/payload/mods/pmos-cockpit/hooks/register.tsx +11 -3
- package/payload/planning/evals/eval-calibration.md +1 -1
- package/payload/planning/okrs/health-budgets.md +1 -1
- package/payload/scripts/plan-items-stage.js +271 -0
- package/payload/scripts/pmos-log.sh +18 -7
- package/payload/scripts/workflow-state.js +28 -2
- package/payload/state/discoveries.md +1 -1
- package/payload/state/log.md +1 -1
- package/workspace/README.md +2 -0
- package/workspace/REMOTE.md +24 -63
- package/workspace/bin/pmos.js +19 -3
- package/workspace/cut-stub.js +40 -0
- package/workspace/first-paint-audit.js +145 -0
- package/workspace/fixtures/capture/requirements/capture-2026-10-04-1.md +10 -0
- package/workspace/fixtures/initiatives-api/list.json +176 -0
- package/workspace/fixtures/initiatives-api/post-403.json +5 -0
- package/workspace/fixtures/initiatives-api/post-ok.json +10 -0
- package/workspace/fixtures/initiatives-api/roadmap.json +208 -0
- package/workspace/fixtures/initiatives-api/sheet.json +112 -0
- package/workspace/fixtures/report-api/draft.json +177 -0
- package/workspace/fixtures/reports-page/draft-biweekly-2026-10-16.md +97 -0
- package/workspace/fixtures/today-api/provider-rows.json +204 -0
- package/workspace/fixtures/today-api/seam-probe-kinds.json +5 -0
- package/workspace/guide/board.md +81 -0
- package/workspace/guide/breakdown.md +154 -0
- package/workspace/guide/command-palette.md +75 -0
- package/workspace/guide/index.md +28 -17
- package/workspace/guide/initiatives-roadmap.md +130 -0
- package/workspace/guide/initiatives-screen.md +89 -0
- package/workspace/guide/initiatives-timeline.md +57 -0
- package/workspace/guide/integrated-work.md +94 -0
- package/workspace/guide/knowledge-graph.md +3 -68
- package/workspace/guide/launcher.md +51 -0
- package/workspace/guide/layout.md +102 -32
- package/workspace/guide/live-notes.md +3 -3
- package/workspace/guide/pm-only-acts.md +89 -0
- package/workspace/guide/pm-only-lines.md +61 -0
- package/workspace/guide/proactive-engine.md +21 -5
- package/workspace/guide/reports-page.md +80 -0
- package/workspace/guide/reports.md +73 -0
- package/workspace/guide/timeline.md +70 -0
- package/workspace/guide/today.md +85 -0
- package/workspace/guide/validate-with-typesafe.md +1 -1
- package/workspace/lib/actor.js +91 -0
- package/workspace/lib/board-cache.js +32 -2
- package/workspace/lib/board-cli.js +108 -0
- package/workspace/lib/board-fold.js +232 -0
- package/workspace/lib/breakdown-export.js +75 -0
- package/workspace/lib/brief.js +20 -2
- package/workspace/lib/capture.js +98 -0
- package/workspace/lib/engine/plan-items-stage.js +271 -0
- package/workspace/lib/engine/workflow-state.js +28 -2
- package/workspace/lib/factory-join.js +158 -0
- package/workspace/lib/goals.js +165 -0
- package/workspace/lib/grant-mint.js +113 -0
- package/workspace/lib/health-rules-factory.js +92 -0
- package/workspace/lib/health-rules-plan.js +164 -0
- package/workspace/lib/health.js +79 -0
- package/workspace/lib/initiative-cli.js +222 -0
- package/workspace/lib/initiative-display.js +183 -0
- package/workspace/lib/initiative-needs.js +127 -0
- package/workspace/lib/initiative-rows.js +210 -0
- package/workspace/lib/initiative-writer.js +141 -0
- package/workspace/lib/issues.js +206 -0
- package/workspace/lib/item-cli.js +195 -0
- package/workspace/lib/item-spans.js +137 -0
- package/workspace/lib/launcher-cli.js +207 -0
- package/workspace/lib/measure-cli.js +100 -0
- package/workspace/lib/measurements.js +189 -0
- package/workspace/lib/mobile-nav.js +57 -53
- package/workspace/lib/needs-providers.js +120 -0
- package/workspace/lib/placements.js +440 -0
- package/workspace/lib/plan-items.js +922 -0
- package/workspace/lib/plan-ledgers.js +85 -0
- package/workspace/lib/plan-rules.js +72 -0
- package/workspace/lib/plan-tree.js +380 -0
- package/workspace/lib/pm-guard.js +174 -0
- package/workspace/lib/pm-lines-needs.js +331 -0
- package/workspace/lib/pmos-verbs.js +69 -0
- package/workspace/lib/proactive-cli.js +58 -2
- package/workspace/lib/proactive.js +9 -6
- package/workspace/lib/qr.js +293 -0
- package/workspace/lib/remote-access.js +93 -1
- package/workspace/lib/report-cli.js +152 -0
- package/workspace/lib/report-draft.js +578 -0
- package/workspace/lib/report-needs.js +213 -0
- package/workspace/lib/route-modules.js +128 -0
- package/workspace/lib/routes-board.js +91 -0
- package/workspace/lib/routes-capture.js +52 -0
- package/workspace/lib/routes-initiatives.js +371 -0
- package/workspace/lib/routes-reports.js +163 -0
- package/workspace/lib/routes-timeline.js +197 -0
- package/workspace/lib/routes-today.js +32 -0
- package/workspace/lib/today-cli.js +99 -0
- package/workspace/lib/today.js +333 -0
- package/workspace/lib/triage.js +163 -0
- package/workspace/lib/verify-work-cli.js +81 -0
- package/workspace/lib/work-check-needs.js +99 -0
- package/workspace/package-lock.json +1 -1
- package/workspace/package.json +4 -5
- package/workspace/public/icons/apple-touch-icon.png +0 -0
- package/workspace/public/icons/icon-192.png +0 -0
- package/workspace/public/icons/icon-512.png +0 -0
- package/workspace/public/index.html +40 -19
- package/workspace/public/pm/app.js +330 -30
- package/workspace/public/pm/bd-board.js +113 -0
- package/workspace/public/pm/bd-drag.js +59 -0
- package/workspace/public/pm/bd-model.js +152 -0
- package/workspace/public/pm/bd-timeline.js +233 -0
- package/workspace/public/pm/bd-tree.js +244 -0
- package/workspace/public/pm/board-copy.js +57 -0
- package/workspace/public/pm/breakdown-api.js +58 -0
- package/workspace/public/pm/breakdown-copy.js +138 -0
- package/workspace/public/pm/components.css +3135 -0
- package/workspace/public/pm/cycle-review.js +125 -0
- package/workspace/public/pm/data.js +40 -187
- package/workspace/public/pm/dest-board.js +438 -0
- package/workspace/public/pm/dest-initiatives.js +134 -0
- package/workspace/public/pm/dest-reports.js +270 -0
- package/workspace/public/pm/dest-settings.js +217 -0
- package/workspace/public/pm/dest-today.js +182 -0
- package/workspace/public/pm/dialogs.js +88 -0
- package/workspace/public/pm/display-word.js +56 -0
- package/workspace/public/pm/dock.js +175 -0
- package/workspace/public/pm/fixture.js +8 -227
- package/workspace/public/pm/frame-copy.js +99 -0
- package/workspace/public/pm/handoff-box.js +82 -0
- package/workspace/public/pm/http.js +88 -0
- package/workspace/public/pm/icons.js +53 -0
- package/workspace/public/pm/initiative-list.js +218 -0
- package/workspace/public/pm/initiative-sheet.js +261 -0
- package/workspace/public/pm/initiatives-api.js +59 -0
- package/workspace/public/pm/initiatives-copy.js +215 -0
- package/workspace/public/pm/initiatives-timeline-copy.js +59 -0
- package/workspace/public/pm/initiatives-timeline.js +410 -0
- package/workspace/public/pm/initiatives-ui.js +123 -0
- package/workspace/public/pm/item-sheet.js +251 -0
- package/workspace/public/pm/keep-card.js +204 -0
- package/workspace/public/pm/lifecycle.js +7 -1
- package/workspace/public/pm/mobile.css +49 -20
- package/workspace/public/pm/mobile.js +157 -29
- package/workspace/public/pm/modes-live.js +7 -883
- package/workspace/public/pm/modes.js +32 -1024
- package/workspace/public/pm/new-initiative-sheet.js +74 -0
- package/workspace/public/pm/next-step-ui.js +3 -2
- package/workspace/public/pm/palette-copy.js +51 -0
- package/workspace/public/pm/palette.js +312 -0
- package/workspace/public/pm/panel-breakdown.js +702 -0
- package/workspace/public/pm/panel-cockpit.js +63 -295
- package/workspace/public/pm/panel-detail.js +25 -172
- package/workspace/public/pm/panel-initiatives.js +1 -19
- package/workspace/public/pm/panel-repos.js +31 -81
- package/workspace/public/pm/panel-tabs.js +200 -111
- package/workspace/public/pm/panel-trees.js +5 -2
- package/workspace/public/pm/portfolio-report.js +61 -0
- package/workspace/public/pm/primitives.js +656 -0
- package/workspace/public/pm/print.css +33 -0
- package/workspace/public/pm/provider-cards.js +136 -0
- package/workspace/public/pm/registry.js +194 -0
- package/workspace/public/pm/release-card.js +110 -0
- package/workspace/public/pm/report-view.js +309 -0
- package/workspace/public/pm/reports-copy.js +114 -0
- package/workspace/public/pm/roadmap.js +271 -0
- package/workspace/public/pm/sig-watch.js +41 -0
- package/workspace/public/pm/state.js +8 -4
- package/workspace/public/pm/status-banner.js +13 -5
- package/workspace/public/pm/tab-doc.js +2 -4
- package/workspace/public/pm/tab-term.js +12 -2
- package/workspace/public/pm/tab-web.js +3 -2
- package/workspace/public/pm/timeline-copy.js +48 -0
- package/workspace/public/pm/today-cards.js +244 -0
- package/workspace/public/pm/today-copy.js +126 -0
- package/workspace/public/pm/today-goals.js +172 -0
- package/workspace/public/pm/tokens.css +16 -18
- package/workspace/public/pm/topbar.js +102 -0
- package/workspace/public/pm/triage-card.js +60 -0
- package/workspace/public/pm/undo-hold.js +103 -0
- package/workspace/public/pm/view-addrepo.js +2 -1
- package/workspace/public/pm/view-livenotes.js +3 -1
- package/workspace/public/pm/view-today.js +7 -162
- package/workspace/public/vendor/Sortable.min.js +2 -0
- package/workspace/public/vendor/lucide-sprite.js +79 -0
- package/workspace/replaced-tests.json +2877 -0
- package/workspace/run-tests.js +56 -5
- package/workspace/server.js +446 -40
- package/workspace/sheet-answer.js +61 -0
- package/workspace/skills/measure.skill +41 -0
- package/workspace/skills/morning-brief.skill +15 -9
- package/workspace/skills/report-biweekly.skill +23 -25
- package/workspace/skills/tracker-refresh.skill +50 -0
- package/workspace/test-files.json +21 -3
- package/workspace/test-lists/base.json +6 -0
- package/workspace/test-lists/run2-a.json +6 -0
- package/workspace/test-lists/run2-b.json +13 -0
- package/workspace/test-lists/run2-c.json +19 -0
- package/workspace/test-lists/run2-d.json +22 -0
- package/workspace/test-lists/run3-0.json +5 -0
- package/workspace/test-lists/run3-a.json +5 -0
- package/workspace/test-lists/run3-b.json +6 -0
- package/workspace/test-lists/run3-c.json +15 -0
- package/workspace/test-lists/run3-d.json +6 -0
- package/workspace/test-lists/run4-a.json +8 -0
- package/workspace/test-lists/run4-b.json +6 -0
- package/workspace/test-lists/run4-c.json +13 -0
- package/workspace/test-lists/run4-d.json +9 -0
- package/workspace/test-lists/run5-a.json +6 -0
- package/workspace/test-lists/run5-b.json +6 -0
- package/workspace/test-lists/run5-c.json +15 -0
- package/workspace/test-lists/workspace-launcher.json +5 -0
- package/workspace/test-lists/workspace-phone-app.json +5 -0
- package/workspace/test-run.js +27 -1
- package/workspace/third-party/README.md +44 -0
- package/workspace/third-party/lucide-static-1.52.0.LICENSE +43 -0
- package/workspace/third-party/sortablejs-1.15.7.LICENSE +21 -0
- package/workspace/design-parity.js +0 -673
- package/workspace/public/pm/tab-task.js +0 -86
- package/workspace/public/pm/view-guide.js +0 -141
- package/workspace/public/pm/view-knowledge.js +0 -225
- package/workspace/public/pm/view-memory.js +0 -122
- package/workspace/public/pm/view-portfolio.js +0 -153
- package/workspace/public/pm/view-reports.js +0 -190
- package/workspace/public/pm/view-scheduled.js +0 -161
- package/workspace/public/pm/view-skills.js +0 -216
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* grant-mint.js — a PM press in the workspace mints ONE D81 grant through the repository's own grant writer
|
|
3
|
+
* (pm-os-absorb, D86 point 2; PRD R25; map-spec X5, X6, X25).
|
|
4
|
+
*
|
|
5
|
+
* PRESS_SCOPES ['release', 'lifecycle-bulk']
|
|
6
|
+
* mintFromPress(root, { scope, target, ids, state, instruction, initiative }, { actor })
|
|
7
|
+
* → { ok: true, grant_id, scope, target } | { ok: false, status, error }
|
|
8
|
+
* bulkTarget({ target, ids, state }) → '<state>:<id>,<id>,…' | null
|
|
9
|
+
*
|
|
10
|
+
* Two presses mint a grant, and only after pm-guard.js requirePmPress has passed, so `actor` is the guard's actor:
|
|
11
|
+
* - Release on a batch of factory items (Run 4, slice 4D: POST /api/board/release). Scope 'release', target
|
|
12
|
+
* '<initiative>:<batch-id>'. One grant for each batch.
|
|
13
|
+
* - A bulk lifecycle change (Run 2, slice 2A: the bulk body of POST /api/lifecycle). Scope 'lifecycle-bulk', target the
|
|
14
|
+
* explicit ids (20 at most) and one state. Each lifecycle line that the press writes cites grant_id (X6).
|
|
15
|
+
* The writers spell the target '<state>:<id>,<id>,…'. This file builds that spelling, so a caller cannot get it
|
|
16
|
+
* wrong: pass { ids, state } (preferred), or a target in either order ('<state>:<ids>' or '<ids>:<state>'). Any
|
|
17
|
+
* other target goes to the writer unchanged, and the writer refuses it with 400.
|
|
18
|
+
*
|
|
19
|
+
* The grant is written by the repository's OWN writer, run as a child process with an argv and no shell, resolved the
|
|
20
|
+
* way the cockpit mod resolves it (kit/mods/pmos-cockpit/hooks/lib/commands.mjs writerOf): PMOS-self runs
|
|
21
|
+
* `node scripts/record-grant.js grant …`, and a kit install runs `bash pmos/scripts/pmos-log.sh grant …`. The workspace
|
|
22
|
+
* never requires scripts/ (the engine is vendored, X29) and never writes grants.jsonl itself. So each scope has one
|
|
23
|
+
* rule, kept in the writers (grants.mjs, record-grant.js, pmos-log.sh) and compared by scripts/tests/record-grant.test.js.
|
|
24
|
+
*
|
|
25
|
+
* Refusals that hold before and after the fill: a scope a press cannot mint is 400; an actor that is not the PM is 403
|
|
26
|
+
* (a grant is the person's permission, D81).
|
|
27
|
+
*
|
|
28
|
+
* Filled by Run 2 (slice 2B). The repository is the MAIN working tree of `root` (lib/plan-ledgers.js), so the grant lands
|
|
29
|
+
* in the same runs directory as the lifecycle lines that cite it. The child gets a minimal environment: no
|
|
30
|
+
* PMOS_STATE_DIR or KIT_ROOT from this process can point the writer at another folder, and nothing of the server's own
|
|
31
|
+
* environment reaches it. A refusal by the writer (a target the scope rule does not allow) is 400 with the writer's
|
|
32
|
+
* reason; a writer that is missing or fails in another way is 500. Nothing is retried, and nothing is written here.
|
|
33
|
+
*/
|
|
34
|
+
const fs = require('fs');
|
|
35
|
+
const path = require('path');
|
|
36
|
+
const { spawnSync } = require('child_process');
|
|
37
|
+
const actor = require('./actor.js');
|
|
38
|
+
const planTree = require('./plan-tree.js');
|
|
39
|
+
|
|
40
|
+
const PRESS_SCOPES = Object.freeze(['release', 'lifecycle-bulk']);
|
|
41
|
+
const TTL_MINUTES = '120';
|
|
42
|
+
const isDir = (p) => { try { return fs.statSync(p).isDirectory(); } catch { return false; } };
|
|
43
|
+
const isFile = (p) => { try { return fs.statSync(p).isFile(); } catch { return false; } };
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The grants writer of the repository at `repo`, as commands.mjs writerOf resolves it: PMOS-self (planning/initiatives)
|
|
47
|
+
* runs scripts/record-grant.js; a kit (pmos/state/initiatives and no planning/initiatives) runs pmos/scripts/pmos-log.sh.
|
|
48
|
+
* Returns { argv } or null.
|
|
49
|
+
*/
|
|
50
|
+
function writerOf(repo) {
|
|
51
|
+
const self = isDir(path.join(repo, 'planning', 'initiatives'));
|
|
52
|
+
const kit = !self && isDir(path.join(repo, 'pmos', 'state', 'initiatives'));
|
|
53
|
+
if (self && isFile(path.join(repo, 'scripts', 'record-grant.js'))) return { argv: [process.execPath, path.join(repo, 'scripts', 'record-grant.js')] };
|
|
54
|
+
if (kit && isFile(path.join(repo, 'pmos', 'scripts', 'pmos-log.sh'))) return { argv: ['bash', path.join(repo, 'pmos', 'scripts', 'pmos-log.sh')] };
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The row a writer printed after it appended: the last line of its output that holds a JSON object. */
|
|
59
|
+
function rowFrom(stdout) {
|
|
60
|
+
const lines = String(stdout || '').split('\n').filter((l) => l.includes('{'));
|
|
61
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
62
|
+
try { const r = JSON.parse(lines[i].slice(lines[i].indexOf('{'))); if (r && r.type === 'grant' && typeof r.id === 'string') return r; } catch { /* the next one */ }
|
|
63
|
+
}
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const BULK_STATES = Object.freeze(['active', 'closed', 'on-hold', 'cancelled']);
|
|
68
|
+
/**
|
|
69
|
+
* The one spelling of a lifecycle-bulk target: '<state>:<id>,<id>,…'. From { ids, state }, or from a target in either
|
|
70
|
+
* order. Null when neither gives one state and a list of ids; the writer then judges the target as it was given.
|
|
71
|
+
*/
|
|
72
|
+
function bulkTarget({ target, ids, state } = {}) {
|
|
73
|
+
if (Array.isArray(ids) && ids.length && typeof state === 'string' && BULK_STATES.includes(state)) return state + ':' + ids.map(String).join(',');
|
|
74
|
+
if (typeof target !== 'string') return null;
|
|
75
|
+
const t = target.trim();
|
|
76
|
+
const head = t.slice(0, t.indexOf(':'));
|
|
77
|
+
const tail = t.slice(t.lastIndexOf(':') + 1);
|
|
78
|
+
if (BULK_STATES.includes(head)) return t;
|
|
79
|
+
if (t.includes(':') && BULK_STATES.includes(tail)) return tail + ':' + t.slice(0, t.lastIndexOf(':'));
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function mintFromPress(root, { scope, target, ids, state, instruction, initiative } = {}, { actor: who } = {}) {
|
|
84
|
+
if (!PRESS_SCOPES.includes(scope)) return { ok: false, status: 400, error: 'a press cannot mint a grant of scope ' + JSON.stringify(scope) };
|
|
85
|
+
if (!actor.isPm(who)) return { ok: false, status: 403, error: 'only the PM mints a grant' };
|
|
86
|
+
if (scope === 'lifecycle-bulk') target = bulkTarget({ target, ids, state }) ?? target;
|
|
87
|
+
if (typeof target !== 'string' || !target.trim()) return { ok: false, status: 400, error: 'a ' + scope + ' grant names its target' };
|
|
88
|
+
const repo = planTree.mainTree(path.resolve(String(root)));
|
|
89
|
+
const w = writerOf(repo);
|
|
90
|
+
if (!w) return { ok: false, status: 500, error: 'this repository has no grants writer (scripts/record-grant.js or pmos/scripts/pmos-log.sh)' };
|
|
91
|
+
const words = typeof instruction === 'string' && instruction.trim() ? instruction.trim() : 'the PM pressed ' + scope + ' in the workspace';
|
|
92
|
+
const argv = [...w.argv, 'grant', scope + ':' + target, who.by.slice('pm:'.length), words, TTL_MINUTES];
|
|
93
|
+
if (typeof initiative === 'string' && initiative) argv.push(initiative);
|
|
94
|
+
const env = { PATH: process.env.PATH || '/usr/bin:/bin', HOME: process.env.HOME || repo, LANG: 'C.UTF-8' };
|
|
95
|
+
const r = spawnSync(argv[0], argv.slice(1), { cwd: repo, env, encoding: 'utf8', timeout: 20000, shell: false });
|
|
96
|
+
if (r.error) return { ok: false, status: 500, error: 'the grants writer did not run: ' + String(r.error.message || r.error).slice(0, 160) };
|
|
97
|
+
const said = String(r.stderr || '').trim().replace(/^(record-grant|pmos-log): ERROR — /, '').slice(0, 400);
|
|
98
|
+
if (r.status !== 0) {
|
|
99
|
+
const refused = /invalid grant|is not a single|unknown scope|has no record|not a plain lowercase slug|must not be empty|ttl_minutes/.test(said);
|
|
100
|
+
return { ok: false, status: refused ? 400 : 500, error: said || 'the grants writer exited ' + r.status };
|
|
101
|
+
}
|
|
102
|
+
const row = rowFrom(r.stdout);
|
|
103
|
+
if (!row) return { ok: false, status: 500, error: 'the grants writer wrote no row it could name' };
|
|
104
|
+
return { ok: true, grant_id: row.id, scope: row.scope, target: row.target };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The scopes a press can mint NOW: PRESS_SCOPES, since 2B filled mintFromPress. The page reads it as /api/pm-modules
|
|
109
|
+
* `grantScopes`, so 2D shows the bulk lifecycle act enabled.
|
|
110
|
+
*/
|
|
111
|
+
function mintableScopes() { return PRESS_SCOPES.slice(); }
|
|
112
|
+
|
|
113
|
+
module.exports = { PRESS_SCOPES, mintFromPress, mintableScopes, bulkTarget };
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* health-rules-factory.js — the factory's health rules (pm-os-absorb Run 4, PRD R8). lib/health.js loads this file from
|
|
3
|
+
* its RULE_FILES slot; each rule answers null (nothing to say) or { level, code, text, source }.
|
|
4
|
+
*
|
|
5
|
+
* factory-failed OFF TRACK — a factory item failed its check and nobody resolved it. "Resolved" is the fold's
|
|
6
|
+
* word: a later `verified` revokes the failure (D65(d)), and a `withdrawn` ends the item. So an
|
|
7
|
+
* item whose folded state is `failed` is unresolved. UNKNOWN (code factory-unreadable) when the
|
|
8
|
+
* factory ledger exists but cannot be read or folded (the join's `unreadable`, R8): a failure
|
|
9
|
+
* the rule cannot see must not read as on track.
|
|
10
|
+
* factory-outside-now AT RISK — building outside Now: a factory item is claimed (a live claim) while the initiative
|
|
11
|
+
* is in Next or Later, or the trunk holds commits that scripts/delivery-index.js attributes to
|
|
12
|
+
* it while it is in Next or Later and it is not accepted. An initiative with no placement is in
|
|
13
|
+
* Later (lib/placements.js horizonOf).
|
|
14
|
+
*
|
|
15
|
+
* What a rule reads (lib/health.js ctx): `factory` = lib/factory-join.js factoryOf(joinFactory(root), id), `placement`
|
|
16
|
+
* = lib/placements.js byInit[id], `row` = the workflow row (scripts/workflow-state.js). A rule whose input is missing
|
|
17
|
+
* says nothing: a missing factory join is "no factory work", never a failure. An UNREADABLE join is not missing.
|
|
18
|
+
*
|
|
19
|
+
* THE COMMIT SIGNAL is the engine's own. The workflow row's Build step says why it is done; when the reason is
|
|
20
|
+
* delivery-index's attribution sentence (a merged initiative/<id> branch, or a trunk commit that names the initiative
|
|
21
|
+
* and changes build files), the commits are attributed. The sentences come from delivery-index.attributionEvidence,
|
|
22
|
+
* not from a copy here, so a change of wording there cannot silence this rule (factory-join.test.js holds it).
|
|
23
|
+
*
|
|
24
|
+
* Pure. No clock, no file reads after the first load of delivery-index.
|
|
25
|
+
*/
|
|
26
|
+
const fs = require('fs');
|
|
27
|
+
const path = require('path');
|
|
28
|
+
|
|
29
|
+
const SOURCE = 'planning/work-items.jsonl';
|
|
30
|
+
const HORIZON_WORD = { next: 'Next', later: 'Later' };
|
|
31
|
+
const DELIVERY_PATHS = [
|
|
32
|
+
path.join(__dirname, '..', '..', 'scripts', 'delivery-index.js'),
|
|
33
|
+
path.join(__dirname, 'engine', 'delivery-index.js'),
|
|
34
|
+
];
|
|
35
|
+
let delivery;
|
|
36
|
+
function attributionSentences(id) {
|
|
37
|
+
if (delivery === undefined) {
|
|
38
|
+
delivery = null;
|
|
39
|
+
for (const p of DELIVERY_PATHS) { if (fs.existsSync(p)) { try { delivery = require(p); } catch { delivery = null; } break; } }
|
|
40
|
+
}
|
|
41
|
+
if (!delivery || typeof delivery.attributionEvidence !== 'function') return [];
|
|
42
|
+
return ['branch', 'mention'].map((s) => delivery.attributionEvidence(s, id));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const horizonOf = (ctx) => (ctx && ctx.placement && ctx.placement.horizon) || 'later';
|
|
46
|
+
const list = (ids) => (ids.length > 3 ? `${ids.slice(0, 3).join(', ')} and ${ids.length - 3} more` : ids.join(', '));
|
|
47
|
+
|
|
48
|
+
module.exports = [
|
|
49
|
+
{
|
|
50
|
+
id: 'factory-failed',
|
|
51
|
+
check(ctx) {
|
|
52
|
+
if (ctx && ctx.factory && typeof ctx.factory.unreadable === 'string') {
|
|
53
|
+
return {
|
|
54
|
+
level: 'unknown', code: 'factory-unreadable', source: SOURCE,
|
|
55
|
+
text: `The factory ledger cannot be read, so the state of factory work is not known. ${ctx.factory.unreadable}`,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
const items = (ctx && ctx.factory && Array.isArray(ctx.factory.items)) ? ctx.factory.items : [];
|
|
59
|
+
const failed = items.filter((it) => it && it.state === 'failed').map((it) => it.id);
|
|
60
|
+
if (!failed.length) return null;
|
|
61
|
+
return {
|
|
62
|
+
level: 'off', code: 'factory-failed', source: SOURCE,
|
|
63
|
+
text: `${failed.length === 1 ? 'Factory item' : 'Factory items'} ${list(failed)} failed ${failed.length === 1 ? 'its check' : 'their checks'}. Fix or withdraw ${failed.length === 1 ? 'it' : 'them'}.`,
|
|
64
|
+
};
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
id: 'factory-outside-now',
|
|
69
|
+
check(ctx) {
|
|
70
|
+
const horizon = horizonOf(ctx);
|
|
71
|
+
if (horizon === 'now' || !HORIZON_WORD[horizon]) return null;
|
|
72
|
+
const where = HORIZON_WORD[horizon];
|
|
73
|
+
const items = (ctx && ctx.factory && Array.isArray(ctx.factory.items)) ? ctx.factory.items : [];
|
|
74
|
+
const claimed = items.filter((it) => it && it.state === 'claimed').map((it) => it.id);
|
|
75
|
+
if (claimed.length) {
|
|
76
|
+
return {
|
|
77
|
+
level: 'at-risk', code: 'factory-outside-now', source: SOURCE,
|
|
78
|
+
text: `Factory work is claimed (${list(claimed)}), but the initiative is in ${where}. Factory work starts only from Now.`,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
const row = ctx && ctx.row;
|
|
82
|
+
if (!row || row.accepted || !Array.isArray(row.steps)) return null;
|
|
83
|
+
const build = row.steps.find((s) => s && s.key === 'build');
|
|
84
|
+
const said = build && build.status === 'done' ? String(build.evidence || '') : '';
|
|
85
|
+
if (!said || !attributionSentences(ctx.id || row.id).some((s) => said.includes(s))) return null;
|
|
86
|
+
return {
|
|
87
|
+
level: 'at-risk', code: 'factory-outside-now', source: 'git (scripts/delivery-index.js)',
|
|
88
|
+
text: `Commits on the trunk are attributed to this initiative, but it is in ${where}. Building starts only from Now.`,
|
|
89
|
+
};
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
];
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* health-rules-plan.js — the health rules of the plan: items, placements and the ladder (pm-os-absorb, PRD R8, slice 2D).
|
|
3
|
+
*
|
|
4
|
+
* lib/health.js loads this file (RULE_FILES) and folds each rule's answer: the worst level wins and on track shows
|
|
5
|
+
* nothing. Each rule is `{ id, check(ctx) }` and answers null or `{ level, code, text, source }`, where `text` is the
|
|
6
|
+
* reason a person reads next to the dot. ctx is `{ id, row, fold, placement, measurement, factory, rules, now }`
|
|
7
|
+
* (seams §5.4). A field a rule needs and ctx does not carry makes that rule say nothing.
|
|
8
|
+
*
|
|
9
|
+
* off track an overdue milestone; a predecessor from another initiative that was withdrawn
|
|
10
|
+
* at risk a Now initiative idle for stall_days; a PM step owed longer than that; a passed check-by date; an
|
|
11
|
+
* overdue item; an item that starts before an open predecessor ends
|
|
12
|
+
* unknown a source could not be read (the workflow row, or lines of plan-items.jsonl)
|
|
13
|
+
*
|
|
14
|
+
* Not here: an unresolved failed factory item and building outside Now (Run 4, health-rules-factory.js). A finished
|
|
15
|
+
* initiative (Accepted, Handed off) has no health: every rule here says nothing for it. A PM step's age is the age of the
|
|
16
|
+
* initiative's last evidence, because the ladder does not record when a step became owed. A stopped initiative (On hold, Closed,
|
|
17
|
+
* Cancelled) has none either: it is off the board. A check-by date belongs to Now, so one that a card keeps in Next or Later is
|
|
18
|
+
* not read.
|
|
19
|
+
*
|
|
20
|
+
* Pure: no I/O, no clock beyond ctx.now, no cache.
|
|
21
|
+
*/
|
|
22
|
+
const display = require('./initiative-display.js');
|
|
23
|
+
|
|
24
|
+
const DAY = 86400000;
|
|
25
|
+
const MON = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
26
|
+
const ITEMS = 'planning/runs/plan-items.jsonl';
|
|
27
|
+
const PLACEMENTS = 'planning/runs/placements.jsonl';
|
|
28
|
+
|
|
29
|
+
const nowOf = (ctx) => (typeof ctx.now === 'number' ? ctx.now : Date.now());
|
|
30
|
+
const todayOf = (ctx) => new Date(nowOf(ctx)).toISOString().slice(0, 10);
|
|
31
|
+
/** '2026-10-01' -> '1 Oct' (a calendar day, never shifted by the time zone). */
|
|
32
|
+
function shortDate(ymd) {
|
|
33
|
+
const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(String(ymd || ''));
|
|
34
|
+
return m ? `${Number(m[3])} ${MON[Number(m[2]) - 1]}` : '';
|
|
35
|
+
}
|
|
36
|
+
const plural = (n, one, many) => `${n} ${n === 1 ? one : many}`;
|
|
37
|
+
const recordOf = (ctx) => (ctx.row && ctx.row.record) || (ctx.id ? `planning/initiatives/${ctx.id}.md` : 'planning/initiatives');
|
|
38
|
+
|
|
39
|
+
/** The live items of this initiative in ctx.fold (an object map or a list), proposals and withdrawn items left out. */
|
|
40
|
+
function itemsOf(ctx) {
|
|
41
|
+
const raw = ctx.fold && ctx.fold.items;
|
|
42
|
+
const all = Array.isArray(raw) ? raw : raw && typeof raw === 'object' ? Object.values(raw) : [];
|
|
43
|
+
return all.filter((it) => it && (!ctx.id || !it.initiative || it.initiative === ctx.id) && it.state !== 'withdrawn' && !it.proposed);
|
|
44
|
+
}
|
|
45
|
+
const open = (it) => it.state !== 'done';
|
|
46
|
+
|
|
47
|
+
const displayCtx = (ctx) => ({ fold: ctx.fold, placement: ctx.placement, rules: ctx.rules, now: nowOf(ctx) });
|
|
48
|
+
/** The state of a stopped initiative (on-hold, closed or cancelled): ctx.lifecycle when the caller passes one, else the workflow row's `stopped`. */
|
|
49
|
+
function stoppedState(ctx) {
|
|
50
|
+
const s = (ctx.lifecycle && ctx.lifecycle.state) || (ctx.row && ctx.row.stopped && ctx.row.stopped.state);
|
|
51
|
+
return typeof s === 'string' && s && s !== 'active' ? s : null;
|
|
52
|
+
}
|
|
53
|
+
/** A finished initiative (Accepted, Handed off) and a stopped one (On hold, Closed, Cancelled: off the board, D70) have no health. */
|
|
54
|
+
function finishedWord(ctx) {
|
|
55
|
+
if (stoppedState(ctx)) return true;
|
|
56
|
+
if (!ctx.row) return false;
|
|
57
|
+
const w = display.displayOf(ctx.row, displayCtx(ctx)).word;
|
|
58
|
+
return w === 'Accepted' || w === 'Handed off';
|
|
59
|
+
}
|
|
60
|
+
const stallDays = (ctx) => (ctx.rules && Number.isInteger(ctx.rules.stall_days) && ctx.rules.stall_days > 0 ? ctx.rules.stall_days : 5);
|
|
61
|
+
|
|
62
|
+
const RULES = [
|
|
63
|
+
{
|
|
64
|
+
id: 'milestone-overdue',
|
|
65
|
+
check(ctx) {
|
|
66
|
+
if (!ctx.fold || finishedWord(ctx)) return null;
|
|
67
|
+
const today = todayOf(ctx);
|
|
68
|
+
const late = itemsOf(ctx).filter((it) => it.milestone && it.due && it.due < today && open(it)).sort((a, b) => (a.due < b.due ? -1 : 1));
|
|
69
|
+
if (!late.length) return null;
|
|
70
|
+
return {
|
|
71
|
+
level: 'off', code: 'milestone-overdue', source: ITEMS,
|
|
72
|
+
text: late.length === 1 ? `Milestone "${late[0].title}" was due ${shortDate(late[0].due)}` : `${late.length} milestones are overdue, the first due ${shortDate(late[0].due)}`,
|
|
73
|
+
};
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
id: 'withdrawn-predecessor',
|
|
78
|
+
check(ctx) {
|
|
79
|
+
if (!ctx.fold || !ctx.id || finishedWord(ctx)) return null;
|
|
80
|
+
const gone = new Set(Array.isArray(ctx.fold.withdrawnIds) ? ctx.fold.withdrawnIds : []);
|
|
81
|
+
if (!gone.size) return null;
|
|
82
|
+
const foreign = (id) => { const k = String(id).lastIndexOf('.'); return k > 0 && String(id).slice(0, k) !== ctx.id; };
|
|
83
|
+
const hit = itemsOf(ctx).find((it) => open(it) && (it.after || []).some((p) => gone.has(p) && foreign(p)));
|
|
84
|
+
if (!hit) return null;
|
|
85
|
+
return { level: 'off', code: 'withdrawn-predecessor', source: ITEMS, text: `"${hit.title}" waits on an item of another initiative that was withdrawn` };
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
id: 'idle-in-now',
|
|
90
|
+
check(ctx) {
|
|
91
|
+
if (!ctx.row || !ctx.placement || ctx.placement.horizon !== 'now' || finishedWord(ctx)) return null;
|
|
92
|
+
const label = display.idleLabel(ctx.row, displayCtx(ctx));
|
|
93
|
+
if (!label) return null;
|
|
94
|
+
return { level: 'at-risk', code: 'idle', text: label, source: recordOf(ctx) };
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
id: 'pm-step-owed',
|
|
99
|
+
check(ctx) {
|
|
100
|
+
if (!ctx.row || finishedWord(ctx)) return null;
|
|
101
|
+
const d = display.displayOf(ctx.row, displayCtx(ctx));
|
|
102
|
+
if (d.waitingOn !== 'pm') return null;
|
|
103
|
+
const ev = display.evidenceOf(ctx.row, displayCtx(ctx));
|
|
104
|
+
if (ev.moving || ev.at === null) return null;
|
|
105
|
+
const days = Math.floor((nowOf(ctx) - ev.at) / DAY);
|
|
106
|
+
if (days < stallDays(ctx)) return null;
|
|
107
|
+
return { level: 'at-risk', code: 'pm-step-owed', text: `Your ${d.act} is owed. The last evidence is ${days} d old`, source: recordOf(ctx) };
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
id: 'check-by-passed',
|
|
112
|
+
check(ctx) {
|
|
113
|
+
const by = ctx.placement && ctx.placement.horizon === 'now' && ctx.placement.check_by;
|
|
114
|
+
if (!by || by >= todayOf(ctx) || finishedWord(ctx)) return null;
|
|
115
|
+
return { level: 'at-risk', code: 'check-by-passed', text: `check-by ${shortDate(by)} passed`, source: PLACEMENTS };
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
id: 'item-overdue',
|
|
120
|
+
check(ctx) {
|
|
121
|
+
if (!ctx.fold || finishedWord(ctx)) return null;
|
|
122
|
+
const today = todayOf(ctx);
|
|
123
|
+
const late = itemsOf(ctx).filter((it) => !it.milestone && it.due && it.due < today && open(it)).sort((a, b) => (a.due < b.due ? -1 : 1));
|
|
124
|
+
if (!late.length) return null;
|
|
125
|
+
return {
|
|
126
|
+
level: 'at-risk', code: 'item-overdue', source: ITEMS,
|
|
127
|
+
text: late.length === 1 ? `"${late[0].title}" was due ${shortDate(late[0].due)}` : `${plural(late.length, 'item is', 'items are')} overdue, the first due ${shortDate(late[0].due)}`,
|
|
128
|
+
};
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
id: 'starts-before-predecessor',
|
|
133
|
+
check(ctx) {
|
|
134
|
+
if (!ctx.fold || finishedWord(ctx)) return null;
|
|
135
|
+
const mine = itemsOf(ctx);
|
|
136
|
+
const byId = new Map(mine.map((it) => [it.id, it]));
|
|
137
|
+
const all = ctx.fold.items;
|
|
138
|
+
const lookup = (id) => byId.get(id) || (all && !Array.isArray(all) ? all[id] : null);
|
|
139
|
+
for (const it of mine) {
|
|
140
|
+
if (!it.start || !open(it)) continue;
|
|
141
|
+
for (const pid of it.after || []) {
|
|
142
|
+
const p = lookup(pid);
|
|
143
|
+
if (p && p.due && p.due > it.start && p.state !== 'withdrawn' && open(p)) {
|
|
144
|
+
return { level: 'at-risk', code: 'starts-before-predecessor', source: ITEMS, text: `"${it.title}" starts ${shortDate(it.start)}, before "${p.title}" ends ${shortDate(p.due)}` };
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return null;
|
|
149
|
+
},
|
|
150
|
+
},
|
|
151
|
+
{
|
|
152
|
+
id: 'source-unreadable',
|
|
153
|
+
check(ctx) {
|
|
154
|
+
if (ctx.row && ctx.row.ok === false) {
|
|
155
|
+
return { level: 'unknown', code: 'workflow-unreadable', text: 'The workflow could not be read for this initiative', source: recordOf(ctx) };
|
|
156
|
+
}
|
|
157
|
+
const bad = ((ctx.fold && ctx.fold.flags) || []).filter((f) => f && f.kind === 'unparseable');
|
|
158
|
+
if (!bad.length) return null;
|
|
159
|
+
return { level: 'unknown', code: 'items-unreadable', text: `plan-items.jsonl has ${plural(bad.length, 'line', 'lines')} that could not be read`, source: ITEMS };
|
|
160
|
+
},
|
|
161
|
+
},
|
|
162
|
+
];
|
|
163
|
+
|
|
164
|
+
module.exports = RULES;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* health.js — ONE definition of an initiative's health: on track, at risk, off track or unknown, each with its reason
|
|
3
|
+
* (pm-os-absorb, PRD R8).
|
|
4
|
+
*
|
|
5
|
+
* healthOf(ctx) → { level: 'on'|'at-risk'|'off'|'unknown', reasons: [{ level, code, text, source }] }
|
|
6
|
+
* LEVELS · RULE_FILES · rules() · loadErrors()
|
|
7
|
+
*
|
|
8
|
+
* `ctx` is what a rule may read, assembled by the caller; any field may be missing and a rule that needs it says
|
|
9
|
+
* nothing:
|
|
10
|
+
* { id, row, fold, placement, measurement, factory, rules, now }
|
|
11
|
+
* row the workflow row of the initiative (scripts/workflow-state.js computeWorkflow)
|
|
12
|
+
* fold lib/plan-items.js readFold of the repository, or null
|
|
13
|
+
* placement lib/placements.js byInit[id], or null
|
|
14
|
+
* measurement lib/measurements.js latest(…, anchor), or null
|
|
15
|
+
* factory the factory join (Run 4), or null
|
|
16
|
+
* rules lib/plan-rules.js readRules(root)
|
|
17
|
+
* now ms since the epoch
|
|
18
|
+
*
|
|
19
|
+
* RULES LIVE IN THEIR OWN FILES, not here, so the runs that add them never edit this one. Each file in RULE_FILES
|
|
20
|
+
* exports an array of `{ id, check(ctx) }`; `check` returns null (nothing to say) or `{ level, code, text, source }`.
|
|
21
|
+
* A file that is not on disk yet is skipped. A file that fails to load, or a rule that throws, gives an `unknown`
|
|
22
|
+
* reason that names it: a broken rule must not read as on track.
|
|
23
|
+
*
|
|
24
|
+
* The worst level wins: off, then at-risk, then unknown, then on. On track shows no reason. Pure; nothing is cached
|
|
25
|
+
* except the loaded rule list (D33 applies to data, and rules are code).
|
|
26
|
+
*/
|
|
27
|
+
const path = require('path');
|
|
28
|
+
|
|
29
|
+
const LEVELS = Object.freeze(['on', 'unknown', 'at-risk', 'off']); // ascending severity
|
|
30
|
+
const RULE_FILES = Object.freeze([
|
|
31
|
+
'health-rules-plan.js', // Run 2 (slice 2D): items, placements, the ladder
|
|
32
|
+
'health-rules-factory.js', // Run 4 (slice 4A): factory items, building outside Now
|
|
33
|
+
]);
|
|
34
|
+
|
|
35
|
+
let loaded = null;
|
|
36
|
+
function load() {
|
|
37
|
+
if (loaded) return loaded;
|
|
38
|
+
const list = [];
|
|
39
|
+
const errors = [];
|
|
40
|
+
for (const f of RULE_FILES) {
|
|
41
|
+
const full = path.join(__dirname, f);
|
|
42
|
+
let mod;
|
|
43
|
+
try { mod = require(full); }
|
|
44
|
+
catch (e) {
|
|
45
|
+
if (e && e.code === 'MODULE_NOT_FOUND' && String(e.message).includes(full)) continue; // not built yet
|
|
46
|
+
errors.push({ file: f, error: String((e && e.message) || e).slice(0, 200) });
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if (!Array.isArray(mod)) { errors.push({ file: f, error: 'does not export an array of rules' }); continue; }
|
|
50
|
+
for (const r of mod) {
|
|
51
|
+
if (r && typeof r.id === 'string' && typeof r.check === 'function') list.push({ ...r, file: f });
|
|
52
|
+
else errors.push({ file: f, error: 'a rule needs an id and a check function' });
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
loaded = { list, errors };
|
|
56
|
+
return loaded;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const rules = () => load().list.map((r) => ({ id: r.id, file: r.file }));
|
|
60
|
+
const loadErrors = () => load().errors.slice();
|
|
61
|
+
const rank = (level) => LEVELS.indexOf(level);
|
|
62
|
+
|
|
63
|
+
function healthOf(ctx = {}) {
|
|
64
|
+
const { list, errors } = load();
|
|
65
|
+
const reasons = errors.map((e) => ({ level: 'unknown', code: 'rule-file', text: `The health rules in ${e.file} could not be read: ${e.error}`, source: `workspace/lib/${e.file}` }));
|
|
66
|
+
for (const r of list) {
|
|
67
|
+
let out;
|
|
68
|
+
try { out = r.check(ctx); }
|
|
69
|
+
catch (e) { out = { level: 'unknown', code: 'rule-error', text: `The health rule ${r.id} failed: ${String((e && e.message) || e).slice(0, 160)}`, source: `workspace/lib/${r.file}` }; }
|
|
70
|
+
if (!out) continue;
|
|
71
|
+
const level = LEVELS.includes(out.level) && out.level !== 'on' ? out.level : 'unknown';
|
|
72
|
+
reasons.push({ level, code: String(out.code || r.id), text: String(out.text || r.id), source: String(out.source || `workspace/lib/${r.file}`) });
|
|
73
|
+
}
|
|
74
|
+
reasons.sort((a, b) => rank(b.level) - rank(a.level));
|
|
75
|
+
const level = reasons.length ? reasons[0].level : 'on';
|
|
76
|
+
return { level, reasons };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
module.exports = { LEVELS, RULE_FILES, healthOf, rules, loadErrors };
|