create-pmos 0.52.0 → 0.53.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 +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/bin/pmos.js +15 -1
- 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 +27 -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/layout.md +98 -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/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 +80 -0
- 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/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 +206 -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/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 +367 -0
- package/workspace/lib/pm-guard.js +174 -0
- package/workspace/lib/pm-lines-needs.js +331 -0
- package/workspace/lib/pmos-verbs.js +66 -0
- package/workspace/lib/proactive.js +7 -4
- package/workspace/lib/report-cli.js +152 -0
- package/workspace/lib/report-draft.js +579 -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/index.html +29 -19
- package/workspace/public/pm/app.js +299 -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 +3054 -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 +176 -0
- package/workspace/public/pm/dialogs.js +88 -0
- package/workspace/public/pm/display-word.js +56 -0
- package/workspace/public/pm/dock.js +105 -0
- package/workspace/public/pm/fixture.js +8 -227
- package/workspace/public/pm/frame-copy.js +94 -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 +197 -110
- 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 +97 -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 +2864 -0
- package/workspace/run-tests.js +56 -5
- package/workspace/server.js +312 -36
- package/workspace/sheet-answer.js +61 -0
- package/workspace/skills/measure.skill +41 -0
- package/workspace/skills/report-biweekly.skill +23 -25
- package/workspace/skills/tracker-refresh.skill +50 -0
- package/workspace/test-files.json +19 -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-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,333 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* today.js — what needs the PM, ranked as Today shows it (pm-os-absorb Run 3, slice 3B, PRD R12, D86 point 5).
|
|
3
|
+
*
|
|
4
|
+
* todayFor(w, { stateDir, sessions }) → { ok, ws, count, cards, banner, next, stall_days, sig }
|
|
5
|
+
* rank(cards) → the cards in Today's order
|
|
6
|
+
* rowKind(row) → the R12 kind (1 to 8) of one needs row, or null for a row that is no card
|
|
7
|
+
* gather(w, { stateDir, sessions }) → { rows, banner } every source's rows, before they are ranked
|
|
8
|
+
* nextDraft(root) → { cadence, slot, label } | null what the empty state names
|
|
9
|
+
* KINDS · KIND_LABEL
|
|
10
|
+
*
|
|
11
|
+
* ONE LIST. Today, GET /api/today and `pmos today` all show this list: the page draws the cards of each source and puts
|
|
12
|
+
* them in this order (public/pm/today-cards.js rankNodes), and lib/today-cli.js prints it. A card has one of the eight
|
|
13
|
+
* kinds of R12 and no other:
|
|
14
|
+
* 1 a gate is owed: an acceptance, a due outcome check, a report to issue 5 an agent breakdown to keep
|
|
15
|
+
* 2 a session is waiting 6 a triaged capture
|
|
16
|
+
* 3 a step was returned to you 7 cycle review
|
|
17
|
+
* 4 work to check: an item in Review, an item with no evidence, a Release 8 needs repair
|
|
18
|
+
* The order is the kind, then the card id. A card is never a status: nothing here changes a record.
|
|
19
|
+
*
|
|
20
|
+
* WHAT IS NOT A CARD. A stalled initiative is a health reason (lib/health.js, `stall_days` from lib/plan-rules.js), so
|
|
21
|
+
* the proactive engine's `stalled` candidates never reach this list. A source that could not be read is a `banner`
|
|
22
|
+
* entry, never an empty list and never a card (R10: a broken source does not read as "nothing needs you").
|
|
23
|
+
*
|
|
24
|
+
* WHERE THE ROWS COME FROM. The needs rows of a repository are made in server.js deriveNeeds, and a route module does
|
|
25
|
+
* not get that function (seams §5.1 ctx). So gather() reads the same sources with the same rules and tests/routes-today
|
|
26
|
+
* compares the two answers row by row. The rows are: lib/plan-items.js needsFor (Keep, no evidence, a line with no
|
|
27
|
+
* effect), every needs provider (lib/needs-providers.js), an open Acceptance Gate, a due outcome check, the queue file
|
|
28
|
+
* the harness writes, an acceptance returned and not accepted since, and the live sessions that wait for the PM.
|
|
29
|
+
*/
|
|
30
|
+
const fs = require('fs');
|
|
31
|
+
const path = require('path');
|
|
32
|
+
const crypto = require('crypto');
|
|
33
|
+
const planItems = require('./plan-items.js');
|
|
34
|
+
const needsProviders = require('./needs-providers.js');
|
|
35
|
+
const fmParse = require('./file-mode-parse.js');
|
|
36
|
+
const planRules = require('./plan-rules.js');
|
|
37
|
+
const issues = require('./issues.js');
|
|
38
|
+
|
|
39
|
+
const KINDS = Object.freeze([1, 2, 3, 4, 5, 6, 7, 8]);
|
|
40
|
+
const KIND_LABEL = Object.freeze({
|
|
41
|
+
1: 'gate', 2: 'session', 3: 'returned', 4: 'check', 5: 'keep', 6: 'capture', 7: 'cycle', 8: 'repair',
|
|
42
|
+
});
|
|
43
|
+
// The row kinds this module owns (the source says `today`): kinds 1 to 3 of R12.
|
|
44
|
+
const OWN = Object.freeze({ accept: 1, outcome: 1, question: 2, session: 2, returned: 3 });
|
|
45
|
+
// Run 1's breakdown rows (lib/plan-items.js needsFor).
|
|
46
|
+
const PLAN = Object.freeze({ keep: 5, evidence: 4, 'plan-flag': 8 });
|
|
47
|
+
const STOPPED = ['closed', 'on-hold', 'cancelled'];
|
|
48
|
+
|
|
49
|
+
const WEEKDAY = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
|
50
|
+
const MONTH = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
51
|
+
const clip = (s, n) => String(s == null ? '' : s).replace(/\s+/g, ' ').trim().slice(0, n);
|
|
52
|
+
|
|
53
|
+
/** The R12 kind of a needs row (the `today.kind` a provider set, else the kind of the row), or null when it is no card. */
|
|
54
|
+
function rowKind(row) {
|
|
55
|
+
if (!row || typeof row !== 'object') return null;
|
|
56
|
+
if (row.kind === 'provider-error') return null;
|
|
57
|
+
if (row.run_id === 'plan-items:unreadable') return null;
|
|
58
|
+
const t = row.today && typeof row.today === 'object' ? row.today : null;
|
|
59
|
+
if (t && Number.isInteger(t.kind) && t.kind >= 1 && t.kind <= 8) return t.kind;
|
|
60
|
+
if (Object.prototype.hasOwnProperty.call(OWN, row.kind)) return OWN[row.kind];
|
|
61
|
+
if (Object.prototype.hasOwnProperty.call(PLAN, row.kind)) return PLAN[row.kind];
|
|
62
|
+
return 8; // a row that names no kind needs repair: it is shown, not dropped
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/* ── the cards ──────────────────────────────────────────────────────────────────────────────────────────────────── */
|
|
66
|
+
|
|
67
|
+
const CARD_KEYS = ['id', 'run_id', 'ws', 'kind', 'source', 'row_kind', 'initiative_id', 'cause', 'evidence', 'act'];
|
|
68
|
+
|
|
69
|
+
function make(ws, kind, source, row, p) {
|
|
70
|
+
return {
|
|
71
|
+
id: `${ws}|${row.run_id}`, run_id: row.run_id, ws, kind, source, row_kind: row.kind,
|
|
72
|
+
initiative_id: row.initiative_id || null, cause: clip(p.cause, 200), evidence: clip(p.evidence, 240),
|
|
73
|
+
act: { label: clip(p.act && p.act.label, 24) || 'Open', ...(p.act && p.act.cli ? { cli: clip(p.act.cli, 200) } : {}) },
|
|
74
|
+
...(p.detail ? { detail: p.detail } : {}),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** One needs row as a card, or null when it is no card. `row.provider` names the source of a provider's row. */
|
|
79
|
+
function cardOf(ws, row) {
|
|
80
|
+
const kind = rowKind(row);
|
|
81
|
+
if (kind === null) return null;
|
|
82
|
+
const t = row.today && typeof row.today === 'object' ? row.today : {};
|
|
83
|
+
const source = typeof row.provider === 'string' ? row.provider : (Object.prototype.hasOwnProperty.call(PLAN, row.kind) ? 'plan-items' : 'today');
|
|
84
|
+
if (typeof row.provider === 'string' || (row.today && typeof row.today === 'object')) {
|
|
85
|
+
return make(ws, kind, t.source || source, row, {
|
|
86
|
+
cause: t.cause || row.question || row.kind, evidence: t.evidence || row.doc || '', act: t.act || { label: 'Open' } });
|
|
87
|
+
}
|
|
88
|
+
if (row.kind === 'accept') {
|
|
89
|
+
const d = row.detail || {};
|
|
90
|
+
return make(ws, 1, 'today', row, {
|
|
91
|
+
cause: d.verdict ? `Acceptance is owed. The evaluator says ${d.verdict}${d.score != null ? ` (${d.score})` : ''}.` : (row.question || 'Acceptance is owed.'),
|
|
92
|
+
evidence: row.doc || `run ${row.run_id}`, act: { label: 'Accept' }, detail: d.verdict || d.score != null ? { verdict: d.verdict || null, score: d.score != null ? d.score : null } : undefined });
|
|
93
|
+
}
|
|
94
|
+
if (row.kind === 'outcome') return make(ws, 1, 'today', row, { cause: row.question, evidence: row.doc || '', act: { label: 'Check' } });
|
|
95
|
+
if (row.kind === 'returned') return make(ws, 3, 'today', row, { cause: row.question, evidence: row.doc || '', act: { label: 'Open' } });
|
|
96
|
+
if (row.kind === 'session') return make(ws, 2, 'today', row, { cause: row.question, evidence: row.doc || '', act: { label: 'Open' } });
|
|
97
|
+
if (row.kind === 'question') return make(ws, 2, 'today', row, { cause: row.question, evidence: row.doc || 'the needs queue', act: { label: 'Open' } });
|
|
98
|
+
if (row.kind === 'keep') {
|
|
99
|
+
const b = row.batch || {};
|
|
100
|
+
return make(ws, 5, 'plan-items', row, {
|
|
101
|
+
cause: `${row.question} proposed by ${b.byLabel || b.by || 'an agent'}`, evidence: row.initiative_id || '', act: { label: 'Keep', cli: null } });
|
|
102
|
+
}
|
|
103
|
+
if (row.kind === 'evidence') return make(ws, 4, 'plan-items', row, { cause: row.question, evidence: row.initiative_id || '', act: { label: 'Open' } });
|
|
104
|
+
if (row.kind === 'plan-flag') return make(ws, 8, 'plan-items', row, { cause: row.question, evidence: row.initiative_id || 'plan-items.jsonl', act: { label: 'Open' } });
|
|
105
|
+
return make(ws, kind, source, row, { cause: row.question || row.kind, evidence: row.doc || '', act: { label: 'Open' } });
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** The cards of every row, ranked: the R12 kind first, then the card id (so two lists of the same rows always agree). */
|
|
109
|
+
function rank(cards) {
|
|
110
|
+
return (cards || []).slice().sort((a, b) => a.kind - b.kind || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/* ── gathering the rows ─────────────────────────────────────────────────────────────────────────────────────────── */
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The PMOS checkout this library lives in, as server.js knows it (EMBEDDED_SELF, SELF_ROOT, LEGACY_STATE). Only there is the
|
|
117
|
+
* checkout's own workspace/state/ the file-mode mirror. `SELF` is a plain object so a suite can point it at a scratch tree.
|
|
118
|
+
*/
|
|
119
|
+
const SELF = (() => {
|
|
120
|
+
const appDir = path.resolve(__dirname, '..');
|
|
121
|
+
const root = path.resolve(appDir, '..');
|
|
122
|
+
const installed = /[\\/]node_modules[\\/]/.test(__dirname);
|
|
123
|
+
const named = process.env.PMOS_SELF_ROOT ? path.resolve(process.env.PMOS_SELF_ROOT) === root : true;
|
|
124
|
+
const embedded = !installed && named && (fs.existsSync(path.join(root, 'DECISIONS.md')) || fs.existsSync(path.join(root, 'AGENTS.md')));
|
|
125
|
+
return { appDir, root, embedded };
|
|
126
|
+
})();
|
|
127
|
+
|
|
128
|
+
function isDir(d) { try { return fs.statSync(d).isDirectory(); } catch { return false; } }
|
|
129
|
+
|
|
130
|
+
/** The state directory of a tree, as server.js stateDirFor finds it (the route passes the server's own answer). */
|
|
131
|
+
function stateDirOf(root) {
|
|
132
|
+
for (const rel of [['pmos', 'state'], ['state'], ['.pmos']]) {
|
|
133
|
+
const d = path.join(root, ...rel);
|
|
134
|
+
if (isDir(d)) return d;
|
|
135
|
+
}
|
|
136
|
+
// PMOS-self with no pmos/state/: the checkout's own workspace/state/ (server.js returns LEGACY_STATE for the same tree)
|
|
137
|
+
if (SELF.embedded && path.resolve(root) === SELF.root) return path.join(SELF.appDir, 'state');
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** A tree that carries PMOS records (a PMOS repository), so that a missing state directory is a fault and not an empty list. */
|
|
142
|
+
function carriesPmos(root) {
|
|
143
|
+
return fs.existsSync(path.join(root, 'DECISIONS.md')) || fs.existsSync(path.join(root, 'AGENTS.md'));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// A ledger lives in the state directory and in the runs directory of the tree: both are read, as server.js readLedger does.
|
|
147
|
+
function readLedger(w, stateDir, name) {
|
|
148
|
+
const out = [];
|
|
149
|
+
const files = [];
|
|
150
|
+
if (stateDir) files.push(path.join(stateDir, name));
|
|
151
|
+
if (w && w.root) files.push(path.join(w.root, 'planning', 'runs', name));
|
|
152
|
+
for (const f of files) for (const r of fmParse.readJsonlRows(f)) out.push(r);
|
|
153
|
+
return out;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// workspaces.json names a tree by this id (server.js wsId); the route asks the server whether a tree is registered by it.
|
|
157
|
+
function wsIdOf(root) { return crypto.createHash('sha1').update(path.resolve(root)).digest('hex').slice(0, 10); }
|
|
158
|
+
|
|
159
|
+
// The linked worktrees of a repository, from its main tree's own `.git/worktrees/<name>/gitdir` files (no git process).
|
|
160
|
+
function linkedTrees(main) {
|
|
161
|
+
const dir = path.join(main, '.git', 'worktrees');
|
|
162
|
+
let names = [];
|
|
163
|
+
try { names = fs.readdirSync(dir).sort(); } catch { return []; }
|
|
164
|
+
const out = [];
|
|
165
|
+
for (const n of names) {
|
|
166
|
+
try { out.push(path.dirname(fs.readFileSync(path.join(dir, n, 'gitdir'), 'utf8').trim())); } catch { /* a stale entry */ }
|
|
167
|
+
}
|
|
168
|
+
return out;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* The tree that speaks for its repository's ledgers: a second tree does not, or every row would show twice. server.js
|
|
173
|
+
* isPlanNeedsTree picks it among the REGISTERED trees: the main tree, else the first registered linked worktree.
|
|
174
|
+
* `registered(root)` says whether the server holds a workspace at that root; a route module cannot list the server's
|
|
175
|
+
* workspaces (seams §5.1 ctx), so the linked trees come from the repository itself and the first by path stands in for
|
|
176
|
+
* the first registered (runs/issues/run3-b.md). Without `registered` the tree is read alone, so it speaks.
|
|
177
|
+
*/
|
|
178
|
+
function isLead(root, registered) {
|
|
179
|
+
if (typeof registered !== 'function') return true;
|
|
180
|
+
const me = path.resolve(root);
|
|
181
|
+
let main;
|
|
182
|
+
try { main = planItems.ledgerRoot(me); } catch { return true; }
|
|
183
|
+
if (main === me) return true;
|
|
184
|
+
if (registered(main)) return false;
|
|
185
|
+
const first = linkedTrees(main).map((t) => path.resolve(t)).filter((t) => registered(t)).sort()[0];
|
|
186
|
+
return !first || first === me;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function acceptancesOf(w, stateDir) {
|
|
190
|
+
const acc = new Map(); // initiative_id -> { action, at, run_id }
|
|
191
|
+
for (const r of readLedger(w, stateDir, 'acceptances.jsonl')) {
|
|
192
|
+
if (!r || !r.initiative_id) continue;
|
|
193
|
+
const label = String(r.action || '').toLowerCase();
|
|
194
|
+
const action = r.accepted === true ? 'accept' : r.accepted === false ? 'return' : label === 'return' ? 'return'
|
|
195
|
+
: ['accept', 'approve', 'merge', 'publish'].includes(label) ? 'accept' : null;
|
|
196
|
+
const at = r.at || r.date || null;
|
|
197
|
+
const prev = acc.get(r.initiative_id);
|
|
198
|
+
if (prev && prev.at && (!at || at < prev.at)) continue;
|
|
199
|
+
acc.set(r.initiative_id, { action, at, run_id: r.run_id || null, note: r.note || '' });
|
|
200
|
+
}
|
|
201
|
+
return acc;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function stoppedOf(w, stateDir) {
|
|
205
|
+
const latest = new Map();
|
|
206
|
+
for (const r of readLedger(w, stateDir, 'lifecycle.jsonl')) {
|
|
207
|
+
if (!r || typeof r.initiative_id !== 'string') continue;
|
|
208
|
+
if (r.state !== 'active' && !STOPPED.includes(r.state)) continue;
|
|
209
|
+
latest.set(r.initiative_id, r.state);
|
|
210
|
+
}
|
|
211
|
+
return new Set([...latest].filter(([, s]) => STOPPED.includes(s)).map(([id]) => id));
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function queueRows(w, stateDir, banner) {
|
|
215
|
+
let f = path.join(w.root, '.pmos', 'needs.json');
|
|
216
|
+
if (!fs.existsSync(f)) f = stateDir ? path.join(stateDir, 'needs.json') : null;
|
|
217
|
+
if (!f || !fs.existsSync(f)) return [];
|
|
218
|
+
let parsed;
|
|
219
|
+
try { parsed = JSON.parse(fs.readFileSync(f, 'utf8')); }
|
|
220
|
+
catch { banner.push({ source: 'needs queue', error: 'the needs-you queue file is unreadable or not valid JSON' }); return []; }
|
|
221
|
+
if (!Array.isArray(parsed)) { banner.push({ source: 'needs queue', error: 'the needs-you queue file is not a JSON array' }); return []; }
|
|
222
|
+
return parsed.filter((n) => n && typeof n === 'object').map((n) => ({
|
|
223
|
+
run_id: n.run_id || null, kind: n.kind || 'question', initiative_id: n.initiative_id || null, derived: !!n.derived,
|
|
224
|
+
question: n.question || n.t || '(unstated)', doc: n.doc || null, ...(n.today && typeof n.today === 'object' ? { today: n.today } : {}),
|
|
225
|
+
})).filter((n) => n.run_id);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** A 4D `item-no-evidence` row for an item Run 1's `evidence` row already names (R12 kind 4, one fact, one card). */
|
|
229
|
+
function isDuplicateNoEvidence(row, evidenceIds) {
|
|
230
|
+
if (!row || row.kind !== 'item-no-evidence') return false;
|
|
231
|
+
const m = /^work-check-needs:no-evidence:(.+)$/.exec(String(row.run_id || ''));
|
|
232
|
+
return !!m && evidenceIds.has(m[1]);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Every source's rows for workspace `w`, and the sources that could not be read.
|
|
237
|
+
* w { id, root }, a registered workspace
|
|
238
|
+
* stateDir the workspace's state directory (the route passes ctx.stateDirFor(w)); null reads none
|
|
239
|
+
* sessions the live sessions (ctx.sessionList()); a session whose `shown` is "needs" is a waiting session
|
|
240
|
+
* registered (root) → boolean whether the server holds a workspace at that root; decides which tree of a repository leads
|
|
241
|
+
*/
|
|
242
|
+
function gather(w, { stateDir = stateDirOf(w.root), sessions = [], registered } = {}) {
|
|
243
|
+
const banner = [];
|
|
244
|
+
const rows = [];
|
|
245
|
+
const lead = isLead(w.root, registered);
|
|
246
|
+
if (!stateDir && carriesPmos(w.root)) banner.push({ source: 'state directory', error: 'this repository has PMOS records but no state directory was found, so acceptances and checks were not read' });
|
|
247
|
+
if (lead) {
|
|
248
|
+
try { for (const r of planItems.needsFor(w.root)) rows.push({ ...r, ws: w.id }); }
|
|
249
|
+
catch (e) { banner.push({ source: 'plan-items', error: `the plan-items ledger could not be read: ${clip((e && e.message) || 'error', 160)}` }); }
|
|
250
|
+
}
|
|
251
|
+
// Run 1's `evidence:<item>` row and 4D's `work-check-needs:no-evidence:<item>` row say the same thing about the same item:
|
|
252
|
+
// the item is counted once, by Run 1's row (provider-cards.js drops the same rows on the page)
|
|
253
|
+
const evidenceOf = new Set(rows.filter((r) => r.kind === 'evidence').map((r) => String(r.run_id).replace(/^evidence:/, '')));
|
|
254
|
+
for (const r of needsProviders.rowsFor(w, { lead })) {
|
|
255
|
+
if (r.kind === 'provider-error') banner.push({ source: r.provider || 'a needs source', error: clip(r.question, 240) });
|
|
256
|
+
else if (isDuplicateNoEvidence(r, evidenceOf)) continue;
|
|
257
|
+
else rows.push(r);
|
|
258
|
+
}
|
|
259
|
+
for (const b of needsProviders.bannersFor(w, { lead })) banner.push({ source: b.source, error: clip(b.error, 240) });
|
|
260
|
+
const stopped = stoppedOf(w, stateDir);
|
|
261
|
+
const queue = queueRows(w, stateDir, banner);
|
|
262
|
+
const seen = new Set(queue.map((n) => n.run_id));
|
|
263
|
+
for (const n of queue) rows.push(n);
|
|
264
|
+
if (stateDir) {
|
|
265
|
+
const settled = new Set();
|
|
266
|
+
for (const a of readLedger(w, stateDir, 'acceptances.jsonl')) if (a && a.run_id) settled.add(a.run_id);
|
|
267
|
+
for (const e of readLedger(w, stateDir, 'evals.jsonl')) {
|
|
268
|
+
if (!e || !e.run_id || settled.has(e.run_id) || seen.has(e.run_id)) continue;
|
|
269
|
+
rows.push({ run_id: e.run_id, initiative_id: e.initiative_id || null, kind: 'accept', derived: true,
|
|
270
|
+
question: 'Acceptance Gate open', doc: e.rubric_path || e.rubric || null,
|
|
271
|
+
detail: { verdict: e.verdict ? String(e.verdict) : null, score: e.score != null ? e.score : null } });
|
|
272
|
+
}
|
|
273
|
+
const disposed = new Set();
|
|
274
|
+
for (const c of readLedger(w, stateDir, 'outcome_checks.jsonl')) if (c && c.initiative_id) disposed.add(c.initiative_id);
|
|
275
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
276
|
+
let files = [];
|
|
277
|
+
try { files = fs.readdirSync(path.join(stateDir, 'initiatives')).filter((f) => f.endsWith('.md')).sort(); } catch { /* none */ }
|
|
278
|
+
for (const f of files) {
|
|
279
|
+
const fm = fmParse.leadingFrontmatter(path.join(stateDir, 'initiatives', f));
|
|
280
|
+
if (!fm.id || !fm.outcome_metric || disposed.has(fm.id) || !fmParse.outcomeDue(fm, today)) continue;
|
|
281
|
+
const run_id = `outcome:${fm.id}`;
|
|
282
|
+
if (seen.has(run_id)) continue;
|
|
283
|
+
rows.push({ run_id, initiative_id: fm.id, kind: 'outcome', derived: true,
|
|
284
|
+
question: `An outcome check is due: "${fm.outcome_metric}" (horizon ${fm.outcome_horizon || 'none declared'})`, doc: null });
|
|
285
|
+
}
|
|
286
|
+
// an initiative stopped by a lifecycle line is not waiting for a returned step, as the proactive engine also holds
|
|
287
|
+
for (const [id, a] of acceptancesOf(w, stateDir)) {
|
|
288
|
+
if (a.action !== 'return' || stopped.has(id)) continue;
|
|
289
|
+
rows.push({ run_id: `returned:${id}`, initiative_id: id, kind: 'returned', derived: true,
|
|
290
|
+
question: 'You returned this at the Acceptance Gate and it has not been accepted since.', doc: a.note || null });
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
for (const s of sessions || []) {
|
|
294
|
+
if (!s || s.ws !== w.id || s.shown !== 'needs') continue;
|
|
295
|
+
rows.push({ run_id: `session:${s.id}`, initiative_id: s.initiative || planItems.initiativeOf(s.plan_item) || null, kind: 'session', derived: true,
|
|
296
|
+
question: 'A session is waiting for you.', doc: [s.name, s.label].filter(Boolean).join(' · ') || null });
|
|
297
|
+
}
|
|
298
|
+
return { rows, banner };
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/* ── the empty state ────────────────────────────────────────────────────────────────────────────────────────────── */
|
|
302
|
+
|
|
303
|
+
/** The next report draft: the last issue of the biweekly plus `report_cycle_days`, or null when nothing was issued yet. */
|
|
304
|
+
function nextDraft(root) {
|
|
305
|
+
let last = null;
|
|
306
|
+
try { last = issues.lastIssue(issues.readIssues(root), 'biweekly'); } catch { return null; }
|
|
307
|
+
if (!last || !/^\d{4}-\d{2}-\d{2}$/.test(String(last.slot))) return null;
|
|
308
|
+
const days = planRules.readRules(root).report_cycle_days;
|
|
309
|
+
const [y, m, d] = last.slot.split('-').map(Number);
|
|
310
|
+
const due = new Date(Date.UTC(y, m - 1, d + days));
|
|
311
|
+
const slot = due.toISOString().slice(0, 10);
|
|
312
|
+
return { cadence: 'biweekly', slot, label: `biweekly draft ${WEEKDAY[due.getUTCDay()]} ${due.getUTCDate()} ${MONTH[due.getUTCMonth()]}` };
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/* ── the answer ─────────────────────────────────────────────────────────────────────────────────────────────────── */
|
|
316
|
+
|
|
317
|
+
/** The ranked list for one workspace: the answer of GET /api/today?ws=, and what `pmos today` prints. */
|
|
318
|
+
function todayFor(w, { stateDir, sessions, registered } = {}) {
|
|
319
|
+
if (!w || !w.root) return { ok: false, error: 'no workspace resolved for Today' };
|
|
320
|
+
let g;
|
|
321
|
+
try { g = gather(w, { stateDir: stateDir === undefined ? stateDirOf(w.root) : stateDir, sessions, registered }); }
|
|
322
|
+
catch (e) { return { ok: false, error: `Today could not be read: ${clip((e && e.message) || 'error', 160)}` }; }
|
|
323
|
+
const cards = rank(g.rows.map((r) => cardOf(w.id, r)).filter(Boolean));
|
|
324
|
+
const next = cards.length ? null : nextDraft(w.root);
|
|
325
|
+
// what the page shows: each card's id, kind, words and act, and the notice. A change to any of them moves the sig.
|
|
326
|
+
const sig = crypto.createHash('sha1').update(JSON.stringify([cards.map((c) => [c.id, c.kind, c.cause, c.evidence, c.act.label]), g.banner, next && next.label])).digest('hex').slice(0, 16);
|
|
327
|
+
return {
|
|
328
|
+
ok: true, ws: w.id, count: cards.length, cards, banner: g.banner,
|
|
329
|
+
next, stall_days: planRules.readRules(w.root).stall_days, sig,
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
module.exports = { isDuplicateNoEvidence, KINDS, KIND_LABEL, CARD_KEYS, SELF, rowKind, cardOf, rank, gather, stateDirOf, wsIdOf, isLead, nextDraft, todayFor };
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* triage.js — what a captured idea would become as an initiative: "Proposed: <title> · <lane> · <anchor>", and
|
|
3
|
+
* whether it matches an earlier rejection (pm-os-absorb Run 2, PRD R16, R12 kind 6; map-spec X16).
|
|
4
|
+
*
|
|
5
|
+
* captures(root) → [{ id, path, title, timestamp, by, text }] every capture note, oldest first
|
|
6
|
+
* triage(root, { since } = {}) → { ok, proposals: [proposal], flags: [{ kind, path, why }] }
|
|
7
|
+
* proposal = { capture, path, title, lane, laneLabel, anchor, anchorTitle, rejection: { path, title } | null, by }
|
|
8
|
+
*
|
|
9
|
+
* WHERE A CAPTURE IS. ⌘K (Run 3, slice 3C) writes each capture as a note `<state>/requirements/capture-YYYY-MM-DD-<n>.md`
|
|
10
|
+
* (`<state>` is `planning/` for PMOS-self, `pmos/state/` in a kit repository: the folder that holds the initiatives),
|
|
11
|
+
* frontmatter `type: Reference`, `title`, `timestamp`, `source: capture`, `by`; the body is the captured text. No new
|
|
12
|
+
* ledger (X16).
|
|
13
|
+
*
|
|
14
|
+
* WHICH CAPTURES ARE PROPOSED. A note is triaged until an initiative is created from it: the initiative writer's
|
|
15
|
+
* `created` line in placements.jsonl names it in `from` (lib/placements.js createdFrom). A Dismiss is the needs queue's
|
|
16
|
+
* own dismissal of the row (its run_id), so triage keeps no state of its own. `since` (an ISO time) narrows the run to
|
|
17
|
+
* the notes newer than a caller's last run, compared with the note's `timestamp`.
|
|
18
|
+
*
|
|
19
|
+
* WHAT IS PROPOSED, and how — deterministic, no model call. The title is the first sentence of the text (at most 60
|
|
20
|
+
* characters). The lane is the profile's first work lane that builds something new (`feature`, or `new-feature` in a
|
|
21
|
+
* document repository). The anchor is the KR or health budget whose name shares the most words with the text (at least
|
|
22
|
+
* two), or none: the Add then asks for one, because the writer refuses an initiative with no anchor. This is a first
|
|
23
|
+
* guess for the PM to correct, not a judgement; the pmos-router skill is the judgement.
|
|
24
|
+
*
|
|
25
|
+
* THE EARLIER-REJECTION CHECK (the router's prior-rejection step, R12 kind 6). Each file under `planning/out-of-scope/`
|
|
26
|
+
* (except README.md) is read for its title. A capture matches when it holds at least two of the title's words and at
|
|
27
|
+
* least half of them (words of 3 or more letters that are not common words). A match names the file; it does not
|
|
28
|
+
* stop the Add: the concept returns only if the PM overrides the recorded reasoning.
|
|
29
|
+
*/
|
|
30
|
+
const fs = require('fs');
|
|
31
|
+
const path = require('path');
|
|
32
|
+
const consoleState = require('./console-state');
|
|
33
|
+
const placements = require('./placements.js');
|
|
34
|
+
const rowsLib = require('./initiative-rows.js');
|
|
35
|
+
|
|
36
|
+
const NOTE_RE = /^capture-(\d{4}-\d{2}-\d{2})-([1-9]\d{0,3})\.md$/;
|
|
37
|
+
const STOP = new Set(['the', 'and', 'for', 'with', 'from', 'that', 'this', 'want', 'wants', 'before', 'after', 'into', 'each',
|
|
38
|
+
'what', 'when', 'have', 'has', 'are', 'was', 'were', 'not', 'but', 'our', 'your', 'their', 'they', 'them', 'can', 'should',
|
|
39
|
+
'would', 'could', 'will', 'all', 'any', 'out', 'one', 'two', 'its', 'than', 'then', 'there', 'here', 'also', 'via', 'per']);
|
|
40
|
+
|
|
41
|
+
function engine() {
|
|
42
|
+
const loaded = consoleState.loadEngine();
|
|
43
|
+
if (!loaded.ok) throw new Error(loaded.error);
|
|
44
|
+
return loaded.engine;
|
|
45
|
+
}
|
|
46
|
+
const words = (s) => [...new Set(String(s || '').toLowerCase().replace(/[`*_#>[\]()]/g, ' ').split(/[^a-z0-9]+/).filter((w) => w.length >= 3 && !STOP.has(w)))];
|
|
47
|
+
|
|
48
|
+
/** The folder that holds the initiatives' folder: `planning/` or `pmos/state/`. */
|
|
49
|
+
function stateDir(root) { return path.dirname(engine().layout(root).initiatives); }
|
|
50
|
+
|
|
51
|
+
function readNote(file) {
|
|
52
|
+
const text = fs.readFileSync(file, 'utf8');
|
|
53
|
+
const fm = engine().parseLeadingFrontmatter(text) || {};
|
|
54
|
+
const m = /^---\r?\n[\s\S]*?\r?\n---\r?\n?/.exec(text);
|
|
55
|
+
return { fm, body: (m ? text.slice(m[0].length) : text).trim() };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function captures(root) {
|
|
59
|
+
const dir = path.join(stateDir(root), 'requirements');
|
|
60
|
+
let names = [];
|
|
61
|
+
try { names = fs.readdirSync(dir).filter((n) => NOTE_RE.test(n)); } catch { return []; }
|
|
62
|
+
const out = [];
|
|
63
|
+
for (const n of names) {
|
|
64
|
+
const file = path.join(dir, n);
|
|
65
|
+
let note;
|
|
66
|
+
try { if (!fs.lstatSync(file).isFile()) continue; note = readNote(file); } catch { continue; }
|
|
67
|
+
if (String(note.fm.source || '').trim() !== 'capture') continue;
|
|
68
|
+
out.push({ id: n.replace(/\.md$/, ''), path: path.relative(root, file).split(path.sep).join('/'), title: String(note.fm.title || '').trim(),
|
|
69
|
+
timestamp: String(note.fm.timestamp || '').trim(), by: String(note.fm.by || '').trim() || null, text: note.body });
|
|
70
|
+
}
|
|
71
|
+
const key = (c) => { const m = NOTE_RE.exec(c.id + '.md'); return `${m[1]}-${m[2].padStart(4, '0')}`; };
|
|
72
|
+
return out.sort((a, b) => (key(a) < key(b) ? -1 : 1));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function rejections(root) {
|
|
76
|
+
const dir = path.join(root, 'planning', 'out-of-scope');
|
|
77
|
+
let names = [];
|
|
78
|
+
try { names = fs.readdirSync(dir).filter((n) => n.endsWith('.md') && n !== 'README.md' && n !== 'index.md').sort(); } catch { return []; }
|
|
79
|
+
const out = [];
|
|
80
|
+
for (const n of names) {
|
|
81
|
+
try {
|
|
82
|
+
const { fm, body } = readNote(path.join(dir, n));
|
|
83
|
+
const heading = /^#\s+(.+)$/m.exec(body);
|
|
84
|
+
const title = String(fm.title || (heading && heading[1]) || n.replace(/\.md$/, '').replace(/-/g, ' ')).trim();
|
|
85
|
+
out.push({ path: `planning/out-of-scope/${n}`, title, words: words(title) });
|
|
86
|
+
} catch { /* an unreadable file is skipped */ }
|
|
87
|
+
}
|
|
88
|
+
return out;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function firstSentence(text) {
|
|
92
|
+
const one = String(text || '').replace(/\s+/g, ' ').trim();
|
|
93
|
+
const m = /^(.+?[.!?])(\s|$)/.exec(one);
|
|
94
|
+
let s = (m ? m[1] : one).replace(/[.!?]+$/, '').trim();
|
|
95
|
+
if (s.length > 60) s = s.slice(0, 60).replace(/\s+\S*$/, '').trim();
|
|
96
|
+
return s ? s[0].toUpperCase() + s.slice(1) : '';
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** The anchor whose name shares the most words with the text (two at least), from the OKR files and the budgets. */
|
|
100
|
+
function guessAnchor(root, text) {
|
|
101
|
+
const want = new Set(words(text));
|
|
102
|
+
const cands = [];
|
|
103
|
+
try {
|
|
104
|
+
const loaded = consoleState.loadEngine();
|
|
105
|
+
const A = require(path.join(path.dirname(loaded.from), 'anchor-resolve.js'));
|
|
106
|
+
const index = A.parseOkrIndex(root);
|
|
107
|
+
if (index.ok) for (const o of index.objectives) for (const k of o.krs) cands.push({ token: k.token, title: k.name || k.token, words: words(k.name) });
|
|
108
|
+
const L = loaded.engine.layout(root);
|
|
109
|
+
const budgets = fs.readFileSync(L.budgets, 'utf8');
|
|
110
|
+
for (const line of budgets.split('\n')) {
|
|
111
|
+
const m = /^\|\s*`?(health:[a-z0-9-]+)`?\s*\|\s*([^|]*)\|/.exec(line);
|
|
112
|
+
if (m) cands.push({ token: m[1], title: m[1], words: words(m[1].slice(7) + ' ' + m[2]) });
|
|
113
|
+
}
|
|
114
|
+
} catch { /* no resolver or no budgets: no guess */ }
|
|
115
|
+
let best = null;
|
|
116
|
+
for (const c of cands) {
|
|
117
|
+
const score = c.words.filter((w) => want.has(w)).length;
|
|
118
|
+
if (score >= 2 && (!best || score > best.score)) best = { ...c, score };
|
|
119
|
+
}
|
|
120
|
+
return best;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function triage(root, { since } = {}) {
|
|
124
|
+
const flags = [];
|
|
125
|
+
let all;
|
|
126
|
+
try { all = captures(root); } catch (e) { return { ok: false, proposals: [], flags: [{ kind: 'unreadable', path: null, why: String((e && e.message) || e).slice(0, 160) }] }; }
|
|
127
|
+
let done = {};
|
|
128
|
+
try { done = placements.createdFrom(root); } catch { done = {}; }
|
|
129
|
+
const sinceMs = since ? Date.parse(since) : NaN;
|
|
130
|
+
let groups = {};
|
|
131
|
+
try { groups = rowsLib.workGroupOf(root); } catch { groups = {}; }
|
|
132
|
+
const lane = ['feature', 'new-feature'].find((l) => groups[l] === 'work') || Object.keys(groups).find((l) => groups[l] === 'work') || 'feature';
|
|
133
|
+
const rej = rejections(root);
|
|
134
|
+
const proposals = [];
|
|
135
|
+
for (const c of all) {
|
|
136
|
+
if (done[c.id]) continue;
|
|
137
|
+
if (Number.isFinite(sinceMs) && !(Date.parse(c.timestamp) > sinceMs)) continue;
|
|
138
|
+
if (!c.text) { flags.push({ kind: 'empty', path: c.path, why: `${c.path} holds no text` }); continue; }
|
|
139
|
+
const title = firstSentence(c.text) || c.title;
|
|
140
|
+
const anchor = guessAnchor(root, c.text);
|
|
141
|
+
const have = new Set(words(c.text));
|
|
142
|
+
const hit = rej.find((r) => { const shared = r.words.filter((w) => have.has(w)).length; return shared >= 2 && shared * 2 >= r.words.length; });
|
|
143
|
+
proposals.push({ capture: c.id, path: c.path, title, lane, laneLabel: lane.replace(/-/g, ' '), anchor: anchor ? anchor.token : null,
|
|
144
|
+
anchorTitle: anchor ? anchor.title : null, rejection: hit ? { path: hit.path, title: hit.title } : null, by: c.by });
|
|
145
|
+
}
|
|
146
|
+
return { ok: true, proposals, flags };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** The proposal of one capture, whatever its state (`pmos initiative new --from`). `done: true` only when an
|
|
150
|
+
* initiative was created from it; `empty: true` when the note holds no text (triage flags it and proposes nothing). */
|
|
151
|
+
function proposalFor(root, captureId) {
|
|
152
|
+
const p = triage(root).proposals.find((x) => x.capture === captureId);
|
|
153
|
+
if (p) return p;
|
|
154
|
+
const c = captures(root).find((x) => x.id === captureId);
|
|
155
|
+
if (!c) return null;
|
|
156
|
+
let made = {};
|
|
157
|
+
try { made = placements.createdFrom(root); } catch { made = {}; }
|
|
158
|
+
const base = { capture: c.id, path: c.path, title: firstSentence(c.text) || c.title, lane: null, anchor: null, rejection: null };
|
|
159
|
+
if (made[c.id]) return { ...base, done: true, initiative: made[c.id] };
|
|
160
|
+
return { ...base, empty: !c.text };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
module.exports = { captures, triage, proposalFor, rejections, words, firstSentence, stateDir, NOTE_RE };
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* verify-work-cli.js — `pmos verify-work`: mark work items integrated from the default branch (pm-os-absorb Run 4,
|
|
3
|
+
* PRD R7 and R24, map-spec X26, D70). Guide page: guide/integrated-work.md.
|
|
4
|
+
*
|
|
5
|
+
* pmos verify-work [--branch <ref>] [--dry-run] [--json] [--root <dir>]
|
|
6
|
+
*
|
|
7
|
+
* The verb runs the repository's OWN scripts/verify-work.js in a child process, in that repository. The factory
|
|
8
|
+
* ledger (planning/work-items.jsonl) has one writer, scripts/work-item.js, and that writer appends only to the ledger
|
|
9
|
+
* of the repository it sits in. So the copy that runs must be the repository's: a copy shipped with the workspace
|
|
10
|
+
* would write nowhere. A repository with no factory writer is told so, and nothing runs.
|
|
11
|
+
*
|
|
12
|
+
* Local refs only: the script runs `git rev-parse`, `git log` and `git merge-base`, never `git fetch` (D70). A
|
|
13
|
+
* `--branch` ref must be on the default branch, or the script refuses it. Pull first (Sync) to see newer commits.
|
|
14
|
+
* Not a PM-only act: `integrated` is a factory event, and the writer decides what may be appended.
|
|
15
|
+
* Exit codes: 0 done, 1 refused (or no writer), 2 usage.
|
|
16
|
+
*/
|
|
17
|
+
const fs = require('fs');
|
|
18
|
+
const path = require('path');
|
|
19
|
+
const { execFileSync, spawnSync } = require('child_process');
|
|
20
|
+
|
|
21
|
+
const COMMANDS = { 'verify-work': [] };
|
|
22
|
+
const USAGE = [
|
|
23
|
+
'usage: pmos verify-work [--branch <ref>] [--dry-run] [--json] [--root <dir>]',
|
|
24
|
+
'Marks a work item integrated when a commit on the default branch has the trailer "Work-Item: <id>".',
|
|
25
|
+
'It reads local refs only and never fetches. Pull first to see newer commits.',
|
|
26
|
+
'Run it again at any time: an item that is already integrated is not written twice.',
|
|
27
|
+
];
|
|
28
|
+
const BOOL = new Set(['json', 'dry-run', 'help']);
|
|
29
|
+
const VALUE = new Set(['branch', 'root']);
|
|
30
|
+
|
|
31
|
+
function repoRoot(cwd) {
|
|
32
|
+
try { return execFileSync('git', ['rev-parse', '--show-toplevel'], { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim() || cwd; }
|
|
33
|
+
catch { return cwd; }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function parse(args) {
|
|
37
|
+
const flags = {}; const pos = [];
|
|
38
|
+
for (let i = 0; i < args.length; i++) {
|
|
39
|
+
const a = args[i];
|
|
40
|
+
if (a === '-h') { flags.help = true; continue; }
|
|
41
|
+
if (!a.startsWith('--')) { pos.push(a); continue; }
|
|
42
|
+
const k = a.slice(2);
|
|
43
|
+
if (BOOL.has(k)) { flags[k] = true; continue; }
|
|
44
|
+
if (VALUE.has(k)) {
|
|
45
|
+
const v = args[i + 1];
|
|
46
|
+
if (v === undefined || v.startsWith('--')) return { error: `--${k} needs a value` };
|
|
47
|
+
flags[k] = v; i++; continue;
|
|
48
|
+
}
|
|
49
|
+
return { error: `unknown option --${k}` };
|
|
50
|
+
}
|
|
51
|
+
return { flags, pos };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function run(argv, { say = (s) => console.log(s), cwd = process.cwd() } = {}) {
|
|
55
|
+
const args = argv[0] === 'verify-work' ? argv.slice(1) : argv.slice();
|
|
56
|
+
if (args[0] === 'help') { for (const l of USAGE) say(l); return 0; }
|
|
57
|
+
const p = parse(args);
|
|
58
|
+
if (p.error) { say(`✗ ${p.error}`); for (const l of USAGE) say(l); return 2; }
|
|
59
|
+
if (p.flags.help) { for (const l of USAGE) say(l); return 0; }
|
|
60
|
+
if (p.pos.length) { say(`✗ pmos verify-work takes no argument "${p.pos[0]}"`); for (const l of USAGE) say(l); return 2; }
|
|
61
|
+
|
|
62
|
+
// The main working tree: its ledger is the one the views read, also when this runs in a linked worktree.
|
|
63
|
+
const root = require('./plan-tree.js').mainTree(p.flags.root ? path.resolve(cwd, p.flags.root) : repoRoot(cwd));
|
|
64
|
+
const script = path.join(root, 'scripts', 'verify-work.js');
|
|
65
|
+
if (!fs.existsSync(script)) {
|
|
66
|
+
const why = `${root} has no factory writer (scripts/verify-work.js), so there is no work item to mark`;
|
|
67
|
+
say(p.flags.json ? JSON.stringify({ ok: false, error: why }) : `✗ ${why}.`);
|
|
68
|
+
return 1;
|
|
69
|
+
}
|
|
70
|
+
const pass = [];
|
|
71
|
+
if (p.flags.branch) pass.push('--branch', p.flags.branch);
|
|
72
|
+
if (p.flags['dry-run']) pass.push('--dry-run');
|
|
73
|
+
if (p.flags.json) pass.push('--json');
|
|
74
|
+
const r = spawnSync(process.execPath, [script, ...pass], { cwd: root, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
|
|
75
|
+
for (const line of String(r.stdout || '').split('\n')) if (line) say(line);
|
|
76
|
+
if (r.status === null) { say(`✗ scripts/verify-work.js stopped: ${(r.error && r.error.message) || r.signal}`); return 1; }
|
|
77
|
+
if (r.status !== 0 && r.stderr && r.stderr.trim()) say(r.stderr.trim().split('\n').slice(-3).join('\n'));
|
|
78
|
+
return r.status === 0 ? 0 : r.status === 2 ? 2 : 1;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
module.exports = { run, COMMANDS, USAGE };
|