create-pmos 0.167.0 → 0.169.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/c3-driver-fixture.js +61 -0
- package/workspace/c3-driver-test.js +164 -0
- package/workspace/extension/README.md +33 -0
- package/workspace/extension/address.js +20 -0
- package/workspace/extension/content.js +76 -0
- package/workspace/extension/icons/icon-128.png +0 -0
- package/workspace/extension/icons/icon-16.png +0 -0
- package/workspace/extension/icons/icon-32.png +0 -0
- package/workspace/extension/icons/icon-48.png +0 -0
- package/workspace/extension/manifest.json +15 -0
- package/workspace/extension/options.css +33 -0
- package/workspace/extension/options.html +26 -0
- package/workspace/extension/options.js +24 -0
- package/workspace/extension/panel.css +21 -0
- package/workspace/extension/panel.html +14 -0
- package/workspace/extension/panel.js +67 -0
- package/workspace/guide/console-calendar.md +14 -0
- package/workspace/guide/index.md +1 -0
- package/workspace/guide/initiative-driver.md +87 -0
- package/workspace/lib/driver-cli.js +207 -0
- package/workspace/lib/driver-decide.js +118 -0
- package/workspace/lib/driver-facts.js +113 -0
- package/workspace/lib/driver-needs.js +107 -0
- package/workspace/lib/driver-run.js +187 -0
- package/workspace/lib/driver.js +408 -0
- package/workspace/lib/extension-cli.js +39 -0
- package/workspace/lib/headless.js +13 -2
- package/workspace/lib/meeting-focus.js +48 -1
- package/workspace/lib/needs-providers.js +1 -0
- package/workspace/lib/pmos-verbs.js +6 -0
- package/workspace/lib/route-modules.js +1 -0
- package/workspace/lib/routes-boards.js +11 -1
- package/workspace/lib/routes-driver.js +167 -0
- package/workspace/lib/routes-meeting-focus.js +17 -1
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/public/pm/c3-brief-needs.js +19 -3
- package/workspace/public/pm/c3-demo-focus.js +12 -2
- package/workspace/public/pm/c3-demo.js +1 -1
- package/workspace/public/pm/c3-driver-work.js +110 -0
- package/workspace/public/pm/c3-driver.css +18 -0
- package/workspace/public/pm/c3-driver.js +298 -0
- package/workspace/public/pm/c3-focus.css +31 -0
- package/workspace/public/pm/c3-focus.js +122 -21
- package/workspace/public/pm/c3-ii-tabs.js +3 -2
- package/workspace/public/pm/c3-ini-ui.js +4 -2
- package/workspace/public/pm/c3-initiatives.js +14 -0
- package/workspace/public/pm/c3-shell.js +2 -2
- package/workspace/public/pm/pm-work-view.js +12 -2
- package/workspace/server.js +21 -1
- package/workspace/skills/initiative-drive.skill +50 -0
- package/workspace/skills/work-unblock.skill +42 -0
- package/workspace/test-files.json +6 -0
- package/workspace/test-lists/c3-meeting-focus.json +13 -4
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/* panel.js — the page the content script frames over a Meet call (panel.html).
|
|
2
|
+
* It reads the PMOS address from chrome.storage.sync (pmosAddress, default http://localhost:7433) and asks whether anything answers there.
|
|
3
|
+
* When PMOS answers, the page becomes one frame: ${address}/c3.html?focus=now&pop=1&meet=…&mtitle=… (PMOS's compact meeting focus). When it does not,
|
|
4
|
+
* the page says where it looked and links to the options. Nothing else is fetched. The frame's { pmosFocus } message (fold or open, no data) is passed
|
|
5
|
+
* up to the content script. */
|
|
6
|
+
(function () {
|
|
7
|
+
'use strict';
|
|
8
|
+
var A = window.PMOS_ADDRESS;
|
|
9
|
+
var CHECK_MS = 4000; // how long to wait for PMOS to answer
|
|
10
|
+
var RETRY_MS = 5000; // looked again while it does not answer, so a PMOS started later fills the panel
|
|
11
|
+
var MEET_ORIGIN = 'https://meet.google.com';
|
|
12
|
+
var params = new URLSearchParams(location.search);
|
|
13
|
+
var meet = params.get('meet') || '';
|
|
14
|
+
var title = params.get('title') || '';
|
|
15
|
+
var msg = document.getElementById('msg');
|
|
16
|
+
var frame = null;
|
|
17
|
+
var frameOrigin = '';
|
|
18
|
+
var timer = null;
|
|
19
|
+
|
|
20
|
+
function readAddress(done) {
|
|
21
|
+
var fallback = A.DEFAULT_ADDRESS;
|
|
22
|
+
try {
|
|
23
|
+
var o = {}; o[A.KEY] = fallback;
|
|
24
|
+
chrome.storage.sync.get(o, function (v) { done(A.normalizeAddress(v && v[A.KEY]) || fallback); });
|
|
25
|
+
} catch (e) { done(fallback); }
|
|
26
|
+
}
|
|
27
|
+
/** Resolves when something answers at the address (an opaque no-cors reply is enough), rejects when nothing listens. */
|
|
28
|
+
function answers(addr) {
|
|
29
|
+
var ctl = typeof AbortController === 'function' ? new AbortController() : null;
|
|
30
|
+
var t = ctl ? setTimeout(function () { ctl.abort(); }, CHECK_MS) : null;
|
|
31
|
+
return fetch(addr + '/c3.html', { mode: 'no-cors', cache: 'no-store', signal: ctl ? ctl.signal : undefined })
|
|
32
|
+
.then(function (r) { if (t) clearTimeout(t); return r; }, function (e) { if (t) clearTimeout(t); throw e; });
|
|
33
|
+
}
|
|
34
|
+
function showMissing(addr) {
|
|
35
|
+
msg.textContent = '';
|
|
36
|
+
msg.className = 'msg';
|
|
37
|
+
msg.appendChild(document.createTextNode('PMOS is not running at ' + addr + '. '));
|
|
38
|
+
var a = document.createElement('a');
|
|
39
|
+
a.href = '#';
|
|
40
|
+
a.textContent = 'Set the address';
|
|
41
|
+
a.addEventListener('click', function (e) { e.preventDefault(); try { chrome.runtime.openOptionsPage(); } catch (x) { /* no options page */ } });
|
|
42
|
+
msg.appendChild(a);
|
|
43
|
+
msg.appendChild(document.createTextNode('.'));
|
|
44
|
+
}
|
|
45
|
+
function showFrame(addr) {
|
|
46
|
+
var q = new URLSearchParams({ focus: 'now', pop: '1', meet: meet, mtitle: title });
|
|
47
|
+
frame = document.createElement('iframe');
|
|
48
|
+
frame.title = 'PMOS meeting helper';
|
|
49
|
+
frame.src = addr + '/c3.html?' + q.toString();
|
|
50
|
+
frameOrigin = new URL(addr).origin;
|
|
51
|
+
msg.remove();
|
|
52
|
+
document.body.appendChild(frame);
|
|
53
|
+
}
|
|
54
|
+
function start() {
|
|
55
|
+
readAddress(function (addr) {
|
|
56
|
+
answers(addr).then(function () { showFrame(addr); }, function () { showMissing(addr); timer = setTimeout(start, RETRY_MS); });
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
window.addEventListener('message', function (e) {
|
|
61
|
+
if (!frame || e.source !== frame.contentWindow || e.origin !== frameOrigin) return;
|
|
62
|
+
var m = e.data && e.data.pmosFocus;
|
|
63
|
+
if (m !== 'fold' && m !== 'open') return;
|
|
64
|
+
if (window.parent !== window) window.parent.postMessage({ pmosFocus: m }, MEET_ORIGIN);
|
|
65
|
+
});
|
|
66
|
+
start();
|
|
67
|
+
})();
|
|
@@ -137,6 +137,20 @@ To fix the topic, press **Topics**. Press **Not this topic** and pick the topic
|
|
|
137
137
|
|
|
138
138
|
The companion starts only while the meeting is recorded. It reads the new lines of the transcript at most every 40 seconds. It writes the sentence, the chips, the topic and the notes. If the answer is not usable, PMOS drops it and shows nothing. To stop the companion, set `PMOS_FOCUS_AGENT` to `off`. The page then shows the transcript and a quiet line, and it adds no notes. In the last 5 minutes, and after the meeting, the page shows the captured items under the material. You confirm them in the note after the meeting. Nothing goes to other people.
|
|
139
139
|
|
|
140
|
+
### Meeting focus over Google Meet
|
|
141
|
+
|
|
142
|
+
The PMOS Chrome extension shows a small panel over a Google Meet call, so you do not need to switch to the PMOS tab. The panel is the Meeting focus page in a compact form. It has a head with the topic the room is on (or "Starts in" before the start) and the time left, the **What's being said** line, the conversation with PMOS, and **Ask PMOS quietly**. Only you see it. It is not part of the screen you share. The extension reads the address and the title of the Meet tab only. It does not read the call's audio or captions, and it talks only to the PMOS on your computer.
|
|
143
|
+
|
|
144
|
+
To add the extension:
|
|
145
|
+
|
|
146
|
+
1. Run `pmos extension`. It prints the folder of the extension and these steps.
|
|
147
|
+
2. In Chrome, open `chrome://extensions` and turn on **Developer mode**.
|
|
148
|
+
3. Press **Load unpacked** and pick the folder that `pmos extension` printed.
|
|
149
|
+
|
|
150
|
+
PMOS must run while you use the panel. The extension looks for PMOS at `http://localhost:7433`. If `pmos` started on another port, open the extension's **Options**, type that address and press **Save**, then reload the Meet tab.
|
|
151
|
+
|
|
152
|
+
To use it, open a Meet call. The panel shows at the bottom right of the call, and not on the Meet home page. PMOS finds the meeting on your calendar by the Meet link in the invite. If no invite has that link, it uses the title of the tab, and then the meeting that is under way now (from 10 minutes before the start to 10 minutes after the end). If none fits, the panel says "No meeting right now" and lists today's meetings. Press the one this call is. **Fold** shrinks the panel to a pill that shows the head only, and the same button opens it again. **Open focus in PMOS** opens the full Meeting focus page in a new tab, with the material, the topics and the captured items. If PMOS does not answer, the panel says "PMOS is not running at" and the address, with a link to the Options.
|
|
153
|
+
|
|
140
154
|
## Free time
|
|
141
155
|
|
|
142
156
|
Press **Free time** to show the free blocks. A free block is a gap inside your working hours that is long enough to use. On Day and Week, each block is a dashed purple window on the timeline, with its length, for example "1.5 h focus". The side column lists the blocks, up to 8, with the suggestion for each. On Month, Quarter and Year the list says that free time shows in the Day and Week views, and the chip still shows the number of blocks.
|
package/workspace/guide/index.md
CHANGED
|
@@ -61,6 +61,7 @@ On a desktop the workspace is one frame. [Layout](layout.md) explains each part.
|
|
|
61
61
|
- [Find knowledge without reading whole files](knowledge-graph.md): what `pmos kg` finds, its commands, how to read its numbers, and its limits.
|
|
62
62
|
- [Initiatives: the Roadmap, the List and the initiative sheet](initiatives-screen.md): how to read and order the initiatives of a repository, open the sheet, make a new initiative, and review the cycle.
|
|
63
63
|
- [Initiatives and the Roadmap: Now, Next and Later](initiatives-roadmap.md): what day one shows, how an agent proposes a move, and `pmos initiative` and `pmos measure` from a terminal.
|
|
64
|
+
- [The Initiative Driver: drafts the agent files for you](initiative-driver.md): when the driver runs, the unblock sweep, the limits, Keep, Send back and Drop, the board modes, and `pmos driver`.
|
|
64
65
|
- [PM-only acts: what only you can do](pm-only-acts.md): which acts are yours, how the workspace checks that a press comes from your page or your terminal, and what to do when one is refused.
|
|
65
66
|
- [PM-only lines: how the workspace checks your acts](pm-only-lines.md): how each act is recorded, what the Today card "A PM-only line is not attested" means, and the grants for a bulk lifecycle change and a Release.
|
|
66
67
|
- [Breakdown › Timeline](timeline.md): an initiative's items on a time axis, split by stage, with milestones, today, and the at-risk flag when an item starts before its predecessor ends.
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
---
|
|
2
|
+
type: Guide
|
|
3
|
+
title: "The Initiative Driver: drafts the agent files for you"
|
|
4
|
+
description: "What the Initiative Driver does when an initiative is created or moved, what stops it, how it files proposals you keep, send back or drop, the board modes, and the pmos driver commands."
|
|
5
|
+
timestamp: 2026-10-12
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# The Initiative Driver: drafts the agent files for you
|
|
9
|
+
|
|
10
|
+
The Initiative Driver is a short background agent run for one initiative or one Work task. It reads the record, decides the next useful step, files the result as a **proposal**, and ends. It never decides for you. You keep a proposal, send it back with a note, or drop it.
|
|
11
|
+
|
|
12
|
+
## When a run starts
|
|
13
|
+
|
|
14
|
+
A run starts for one of four reasons:
|
|
15
|
+
|
|
16
|
+
- **Created.** An initiative is created from the page, from Triage (accept), or with `pmos initiative new`. The run starts within one tick of the server (about one minute).
|
|
17
|
+
- **Moved.** You move the initiative to another stage on the board.
|
|
18
|
+
- **Answered.** You send a proposal back with a note.
|
|
19
|
+
- **Stuck.** The unblock sweep finds a blocker the agent can work on.
|
|
20
|
+
|
|
21
|
+
An initiative that existed before you first ran this version does not start a run. Only events after the first scan count.
|
|
22
|
+
|
|
23
|
+
## The unblock sweep
|
|
24
|
+
|
|
25
|
+
The sweep reads every active initiative and every open Work task. It uses no model. It runs every 30 minutes, inside the working hours of the daytime check (09:00 to 18:00 by default) and outside your quiet hours.
|
|
26
|
+
|
|
27
|
+
| Where | What it finds |
|
|
28
|
+
| --- | --- |
|
|
29
|
+
| You are the blocker | An open proposal is more than one day old. No run starts. The row comes back in Needs you once a day. |
|
|
30
|
+
| Discover | No kept problem statement, or no kept evidence. |
|
|
31
|
+
| Plan | No PRD, no done-criteria, or no anchor. |
|
|
32
|
+
| Do | No Jira ticket moved for the stall threshold, or a ticket is flagged blocked. |
|
|
33
|
+
| Learn | The check-by date passed with no outcome check, or a kill condition tripped. |
|
|
34
|
+
| Any stage | Nothing changed for the stall threshold (5 days by default). |
|
|
35
|
+
| Work task | Overdue, waiting on someone for 3 days or more, untouched for 7 days, or no next step. |
|
|
36
|
+
|
|
37
|
+
The sweep starts a run for a blocker the agent can work on, at most once per blocker per day. A dormant initiative starts no run.
|
|
38
|
+
|
|
39
|
+
## The limits
|
|
40
|
+
|
|
41
|
+
- At most 3 runs per initiative per day, and 1 per Work task per day.
|
|
42
|
+
- At most 3 runs at the same time. The others wait.
|
|
43
|
+
- A run that fails or times out counts against the day. Its reason is in the ledger.
|
|
44
|
+
- A paused initiative or Work task starts no run.
|
|
45
|
+
- `PMOS_DRIVER=off` in the environment of the server starts none.
|
|
46
|
+
|
|
47
|
+
## What the agent can and cannot do
|
|
48
|
+
|
|
49
|
+
The agent reads, and it files proposals with `pmos driver propose`. It has no Write or Edit tool, no `git push`, and no tool that sends anything. Every claim in a proposal carries a pointer: a Jira key, a link or a path. A follow-up to a person is draft text only. You send it from your own session.
|
|
50
|
+
|
|
51
|
+
In Discover, the agent writes the problem statement, the evidence, the opportunity sizing, the riskiest assumption and the stakeholders. When you have kept the first three, it proposes the move to Plan. In the other stages, it names the blocker and proposes what would clear it.
|
|
52
|
+
|
|
53
|
+
## Where you see the agent
|
|
54
|
+
|
|
55
|
+
- **The Agent strip.** Open an initiative and stay on Overview. The Agent section is at the top. It shows the state of the agent (working, idle, waiting on you, paused or off), the last thing it did, and what stops it ("Stopped by: ..."). Each open proposal is one row with Keep, Send back and Drop. Press a row to read its text and its sources. A source that is a link opens in a new tab. Pause and Resume are in the header. The text "1 of 3 runs used today" opens the list of today's runs.
|
|
56
|
+
- **Keep, Send back and Drop on a row.** A kept row stays in the strip, marked "Kept by you", until you leave the page. Send back asks for a one-line note. A kept stage move changes the stage on the page at once. These three acts cannot be undone: each adds one line to the ledger.
|
|
57
|
+
- **One line in Work.** A Work task with an open proposal shows one line under its title, for example "Agent drafted a follow-up to Dana. Nothing is sent." Press Review to read the draft. Keep copies the text so you can send it yourself, and records your decision. If the browser does not allow copying, the text is selected and you press Ctrl+C. A suggested next step shows Keep and Drop in the line. Shadow and Off show no line.
|
|
58
|
+
- **Needs you.** In Briefing, the agent has two kinds of row: "Agent has 3 drafts on ..." and "... is waiting on you". Press a row to open the initiative.
|
|
59
|
+
- **The Agent setting.** Open the board, press Board settings, and find Agent. Choose Off, Shadow or Approve. The setting is saved when you press it.
|
|
60
|
+
|
|
61
|
+
## Keep, send back, drop
|
|
62
|
+
|
|
63
|
+
Open proposals appear in Needs you as one row for each initiative or task: "Agent has 3 drafts on the initiative".
|
|
64
|
+
|
|
65
|
+
- **Keep** writes the record as you. A problem statement goes into the problem record of the initiative. A move goes through the board move, with its warnings. A Work next step goes into the steps of the task. Other kinds are kept as your decision to rely on them.
|
|
66
|
+
- **Send back** needs a note. The note starts a new run. It counts as a correction.
|
|
67
|
+
- **Drop** closes the proposal. It counts as a correction.
|
|
68
|
+
|
|
69
|
+
The agent cannot keep, send back, drop, pause or change the mode. Those acts need your page or your terminal.
|
|
70
|
+
|
|
71
|
+
## Board modes
|
|
72
|
+
|
|
73
|
+
- **Approve** is the default. The agent runs and drafts, and Needs you shows the rows.
|
|
74
|
+
- **Shadow.** The agent runs and drafts, and raises no Needs you rows.
|
|
75
|
+
- **Off.** No run starts.
|
|
76
|
+
|
|
77
|
+
## The commands
|
|
78
|
+
|
|
79
|
+
The agent uses `pmos driver propose` to file a proposal and `pmos driver status` to read what exists. The other commands are yours, at an interactive terminal:
|
|
80
|
+
|
|
81
|
+
- `pmos driver keep <proposal>` asks you to type the proposal id. A kept move is pressed on the page.
|
|
82
|
+
- `pmos driver return <proposal> --note "<what to change>"` sends it back.
|
|
83
|
+
- `pmos driver drop <proposal>` drops it.
|
|
84
|
+
- `pmos driver pause <initiative|work> <id>` and `pmos driver resume <initiative|work> <id>` stop and restart the runs for one target.
|
|
85
|
+
- `pmos driver mode <off|shadow|approve> [--board <id>]` sets the board mode. Without `--board` it sets every board.
|
|
86
|
+
|
|
87
|
+
The ledger is `driver.jsonl` in the state folder of the repository (`pmos/state`, `state` or `.pmos`). Each action adds one line. Nothing is changed or removed.
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* driver-cli.js — `pmos driver …`: the Initiative Driver from a terminal (initiative initiative-driver, PRD sections 4 and 5).
|
|
3
|
+
*
|
|
4
|
+
* pmos driver propose <initiative|work> <id> --what <what> (--file <json|md> | --data "<text>") --why "<one line>"
|
|
5
|
+
* [--source label=ref …] [--ws <id>] an agent session files a proposal (anyone may; the PM seldom does)
|
|
6
|
+
* pmos driver status <initiative|work> <id> proposals, pause, mode and the day's runs (read)
|
|
7
|
+
* pmos driver keep <proposal> PM only: type the proposal id to confirm
|
|
8
|
+
* pmos driver return <proposal> --note "<what to change>" PM only: send back; the note wakes a run
|
|
9
|
+
* pmos driver drop <proposal> [--note "<why>"] PM only
|
|
10
|
+
* pmos driver pause|resume <initiative|work> <id> PM only
|
|
11
|
+
* pmos driver mode <off|shadow|approve> [--board <id>] PM only; with no --board it sets every board
|
|
12
|
+
* every command: [--json] [--root <dir>]
|
|
13
|
+
*
|
|
14
|
+
* Who wrote a line is the call path (lib/actor.js), never an argument. `keep` goes through lib/pm-guard.js requirePmCli, which
|
|
15
|
+
* refuses an agent session without asking; `return`, `drop`, `pause`, `resume` and `mode` refuse any actor that is not the PM
|
|
16
|
+
* at an interactive terminal. The agent-facing verbs are `propose` and `status` only. Keep writes through the existing writers
|
|
17
|
+
* (lib/driver-decide.js); a kept move is pressed on the workspace page, where the board move route runs.
|
|
18
|
+
* Exit codes: 0 done, 1 refused, 2 usage.
|
|
19
|
+
*/
|
|
20
|
+
const fs = require('fs');
|
|
21
|
+
const path = require('path');
|
|
22
|
+
const { execFileSync } = require('child_process');
|
|
23
|
+
const guard = require('./pm-guard.js');
|
|
24
|
+
const actorLib = require('./actor.js');
|
|
25
|
+
const D = require('./driver.js');
|
|
26
|
+
const decideLib = require('./driver-decide.js');
|
|
27
|
+
const stageTools = require('./stage-tools.js');
|
|
28
|
+
const stateRoot = require('./state-root.js');
|
|
29
|
+
|
|
30
|
+
const COMMANDS = { driver: ['propose', 'status', 'keep', 'return', 'drop', 'pause', 'resume', 'mode'] };
|
|
31
|
+
const USAGE = [
|
|
32
|
+
'usage: pmos driver propose|status|keep|return|drop|pause|resume|mode — the Initiative Driver',
|
|
33
|
+
' pmos driver propose <initiative|work> <id> --what <what> (--file <json|md> | --data "<text>") --why "<one line>" [--source label=ref …]',
|
|
34
|
+
' what: problem, evidence, sizing, assumption, stakeholders, move, note (initiative) · next-step, follow-up, note (Work task)',
|
|
35
|
+
' pmos driver status <initiative|work> <id>',
|
|
36
|
+
' pmos driver keep <proposal> PM only',
|
|
37
|
+
' pmos driver return <proposal> --note "<what to change>" PM only',
|
|
38
|
+
' pmos driver drop <proposal> [--note "<why>"] PM only',
|
|
39
|
+
' pmos driver pause|resume <initiative|work> <id> PM only',
|
|
40
|
+
' pmos driver mode <off|shadow|approve> [--board <id>] PM only',
|
|
41
|
+
' every command: [--json] [--root <dir>]',
|
|
42
|
+
'Every claim in a proposal carries a pointer (--source label=ref). The driver sends nothing and edits no record: Keep is the PM\'s.',
|
|
43
|
+
];
|
|
44
|
+
const BOOL = new Set(['json', 'help']);
|
|
45
|
+
const VALUE = new Set(['what', 'file', 'data', 'why', 'source', 'ws', 'note', 'board', 'root']);
|
|
46
|
+
|
|
47
|
+
function parse(argv) {
|
|
48
|
+
const pos = []; const flags = {}; const sources = [];
|
|
49
|
+
for (let i = 0; i < argv.length; i++) {
|
|
50
|
+
const a = argv[i];
|
|
51
|
+
if (a === '-h') { flags.help = true; continue; }
|
|
52
|
+
if (!a.startsWith('--')) { pos.push(a); continue; }
|
|
53
|
+
const eq = a.indexOf('=');
|
|
54
|
+
const key = eq > 0 ? a.slice(2, eq) : a.slice(2);
|
|
55
|
+
if (BOOL.has(key)) { flags[key] = true; continue; }
|
|
56
|
+
let val;
|
|
57
|
+
if (eq > 0) val = a.slice(eq + 1);
|
|
58
|
+
else { const next = argv[i + 1]; if (next === undefined || (next.startsWith('--') && next.length > 2)) val = ''; else { val = next; i++; } }
|
|
59
|
+
if (key === 'source') sources.push(val); else flags[key] = val;
|
|
60
|
+
}
|
|
61
|
+
return { pos, flags, sources };
|
|
62
|
+
}
|
|
63
|
+
function repoRoot(cwd) {
|
|
64
|
+
try { return execFileSync('git', ['rev-parse', '--show-toplevel'], { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim() || cwd; }
|
|
65
|
+
catch { return cwd; }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The data of a proposal from --file (JSON by extension or by content, else the text) or --data. */
|
|
69
|
+
function dataFrom(flags, cwd) {
|
|
70
|
+
let raw;
|
|
71
|
+
if (flags.file) {
|
|
72
|
+
try { raw = fs.readFileSync(path.resolve(cwd, flags.file), 'utf8'); } catch (e) { return { error: `cannot read ${flags.file}: ${String(e.code || e.message)}` }; }
|
|
73
|
+
} else raw = flags.data;
|
|
74
|
+
if (typeof raw !== 'string' || !raw.trim()) return { error: 'give the proposal body with --file <json|md> or --data "<text>"' };
|
|
75
|
+
const t = raw.trim();
|
|
76
|
+
if (t.startsWith('{') || (flags.file && /\.json$/i.test(flags.file))) {
|
|
77
|
+
try { const j = JSON.parse(t); if (j && typeof j === 'object' && !Array.isArray(j)) return { data: j }; } catch { if (/\.json$/i.test(flags.file || '')) return { error: `${flags.file} is not valid JSON` }; }
|
|
78
|
+
}
|
|
79
|
+
return { data: { text: t } };
|
|
80
|
+
}
|
|
81
|
+
function sourcesFrom(list) {
|
|
82
|
+
const out = [];
|
|
83
|
+
for (const s of list) {
|
|
84
|
+
const eq = s.indexOf('=');
|
|
85
|
+
if (eq < 1 || eq === s.length - 1) return { error: `--source needs label=ref, not "${s.slice(0, 60)}"` };
|
|
86
|
+
out.push({ label: s.slice(0, eq).trim(), ref: s.slice(eq + 1).trim() });
|
|
87
|
+
}
|
|
88
|
+
return { sources: out };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function run(argv, { say = (s) => console.log(s), cwd = process.cwd(), isTTY, env, now, ask } = {}) {
|
|
92
|
+
const args = argv[0] === 'driver' ? argv.slice(1) : argv.slice();
|
|
93
|
+
const verb = args[0];
|
|
94
|
+
const { pos, flags, sources } = parse(args.slice(1));
|
|
95
|
+
const json = !!flags.json;
|
|
96
|
+
if (!verb || verb === 'help' || verb === '--help' || verb === '-h' || flags.help) { for (const l of USAGE) say(l); return verb ? 0 : 1; }
|
|
97
|
+
if (!COMMANDS.driver.includes(verb)) { say(`unknown driver command "${verb}" — one of ${COMMANDS.driver.join(', ')}`); return 2; }
|
|
98
|
+
if ('by' in flags || 'via' in flags) { say(`attribution is set from the call path; --${'by' in flags ? 'by' : 'via'} is not accepted`); return 2; }
|
|
99
|
+
for (const k of Object.keys(flags)) {
|
|
100
|
+
if (!BOOL.has(k) && !VALUE.has(k)) { say(`unknown option --${k}`); return 2; }
|
|
101
|
+
if (VALUE.has(k) && flags[k] === '') { say(`--${k} needs a value`); return 2; }
|
|
102
|
+
}
|
|
103
|
+
const root = flags.root ? path.resolve(cwd, flags.root) : repoRoot(cwd);
|
|
104
|
+
const who = { isTTY: isTTY === undefined ? !!(process.stdin.isTTY && process.stdout.isTTY) : isTTY, env: env === undefined ? process.env : env };
|
|
105
|
+
const fail = (error, code = 1) => { if (json) say(JSON.stringify({ ok: false, error })); else say(`✗ ${error}`); return code; };
|
|
106
|
+
const done = (obj, line) => { if (json) say(JSON.stringify({ ok: true, ...obj })); else say(line); return 0; };
|
|
107
|
+
const stateDir = D.stateDirOf(root);
|
|
108
|
+
const file = D.ledgerFile(stateDir);
|
|
109
|
+
const noState = () => fail('this repository has no PMOS state folder (pmos/state, state or .pmos), so the driver has nowhere to keep its ledger');
|
|
110
|
+
const ws = flags.ws || D.wsIdOf(root);
|
|
111
|
+
const target = () => ({ kind: pos[0], ws, id: pos[1] });
|
|
112
|
+
const needTarget = () => {
|
|
113
|
+
if (!pos[0] || !pos[1]) { say(`usage: pmos driver ${verb} <initiative|work> <id>`); return false; }
|
|
114
|
+
return true;
|
|
115
|
+
};
|
|
116
|
+
const pmOnly = (act) => {
|
|
117
|
+
const a = actorLib.fromCli(who);
|
|
118
|
+
if (!actorLib.isPm(a)) return { ok: false, error: `only the PM can ${act}, at an interactive terminal${actorLib.isAgent(a) && a.by !== 'agent:unknown' ? '; an agent session cannot' : ''}` };
|
|
119
|
+
return { ok: true, actor: a };
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
if (verb === 'propose') {
|
|
123
|
+
if (!needTarget()) return 2;
|
|
124
|
+
if (!file) return noState();
|
|
125
|
+
if (!flags.what) { say('usage: pmos driver propose <initiative|work> <id> --what <what> (--file <json|md> | --data "<text>") --why "<one line>"'); return 2; }
|
|
126
|
+
const d = dataFrom(flags, cwd);
|
|
127
|
+
if (d.error) return fail(d.error, 2);
|
|
128
|
+
const s = sourcesFrom(sources);
|
|
129
|
+
if (s.error) return fail(s.error, 2);
|
|
130
|
+
const c = D.checkProposal({ ...target(), what: flags.what, data: d.data, why: flags.why, sources: s.sources });
|
|
131
|
+
if (!c.ok) return fail(c.error);
|
|
132
|
+
const actor = actorLib.fromCli(who);
|
|
133
|
+
const state = D.fold(D.readLines(file));
|
|
134
|
+
const same = JSON.stringify(c.proposal.data);
|
|
135
|
+
const dup = [...state.proposals.values()].find((p) => D.targetKey(p.target) === D.targetKey(c.proposal.target) && p.what === c.proposal.what && JSON.stringify(p.data) === same);
|
|
136
|
+
if (dup) return done({ filed: false, duplicate: dup.id, state: dup.state }, `${dup.id} holds this already (${dup.state}); nothing new was filed`);
|
|
137
|
+
const token = actorLib.isAgent(actor) && actor.by !== 'agent:unknown' ? actor.by.slice('agent:'.length) : null;
|
|
138
|
+
const id = D.newId('p');
|
|
139
|
+
const line = D.append(file, { ev: 'proposal', id, ...c.proposal, run: D.runOfSession(state, token), by: actor.by }, { now });
|
|
140
|
+
return done({ filed: true, id, proposal: { ...line, by: actorLib.pageName(line.by) } }, `✓ filed ${id}: ${c.proposal.what} for ${c.proposal.target.kind} ${c.proposal.target.id}. The PM keeps, sends back or drops it.`);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (verb === 'status') {
|
|
144
|
+
if (!needTarget()) return 2;
|
|
145
|
+
if (!file) return noState();
|
|
146
|
+
const t = D.checkTarget(target());
|
|
147
|
+
if (!t.ok) return fail(t.error);
|
|
148
|
+
const state = D.fold(D.readLines(file));
|
|
149
|
+
const mine = [...state.proposals.values()].filter((p) => D.targetKey(p.target) === D.targetKey(t.target));
|
|
150
|
+
const day = D.localDay(now == null ? Date.now() : now);
|
|
151
|
+
const used = D.runsOnDay(state, t.target, day);
|
|
152
|
+
const limit = t.target.kind === 'work' ? D.BUDGET.workPerDay : D.BUDGET.initiativePerDay;
|
|
153
|
+
const out = { target: t.target, paused: D.isPaused(state, t.target), mode: D.modeOf(state, t.target.kind === 'work' ? 'work' : null), runs_today: used, runs_limit: limit,
|
|
154
|
+
proposals: mine.map((p) => ({ id: p.id, what: p.what, state: p.state, why: p.why, note: p.note, at: p.at })) };
|
|
155
|
+
if (json) { say(JSON.stringify({ ok: true, ...out })); return 0; }
|
|
156
|
+
say(`${t.target.kind} ${t.target.id} · mode ${out.mode}${out.paused ? ' · paused' : ''} · ${used} of ${limit} runs today`);
|
|
157
|
+
for (const p of out.proposals) say(` ${p.id} · ${p.what} · ${p.state}${p.note ? ` · "${p.note}"` : ''} — ${p.why}`);
|
|
158
|
+
if (!out.proposals.length) say(' no proposals');
|
|
159
|
+
return 0;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ── the PM's verbs ────────────────────────────────────────────────────────────────────────────────
|
|
163
|
+
if (verb === 'keep' || verb === 'return' || verb === 'drop') {
|
|
164
|
+
const pid = pos[0];
|
|
165
|
+
if (!pid) { say(`usage: pmos driver ${verb} <proposal>${verb === 'return' ? ' --note "<what to change>"' : ''}`); return 2; }
|
|
166
|
+
let actor;
|
|
167
|
+
if (verb === 'keep') {
|
|
168
|
+
const g = await guard.requirePmCli({ act: 'keep', expect: pid, isTTY: who.isTTY, env: who.env, ask: ask || guard.askAtTerminal });
|
|
169
|
+
if (!g.ok) return fail(g.error);
|
|
170
|
+
actor = g.actor;
|
|
171
|
+
} else {
|
|
172
|
+
const g = pmOnly(verb === 'return' ? 'send a proposal back' : 'drop a proposal');
|
|
173
|
+
if (!g.ok) return fail(g.error);
|
|
174
|
+
actor = g.actor;
|
|
175
|
+
}
|
|
176
|
+
if (!file) return noState();
|
|
177
|
+
const p = D.fold(D.readLines(file)).proposals.get(pid);
|
|
178
|
+
if (p && verb === 'keep' && p.what === 'move') return fail('keep a move on the workspace page: the board move route checks the stage and its warnings');
|
|
179
|
+
const ctx = { stageFile: path.join(stateDir, stageTools.LEDGER), itemsDir: path.join(stateRoot.defaultRoot(), 'pm-items'),
|
|
180
|
+
correctionsFile: path.join(stateRoot.defaultRoot(), 'corrections.jsonl') };
|
|
181
|
+
const r = await decideLib.decide(verb, { file, proposal: pid, note: flags.note, actor, ctx, now });
|
|
182
|
+
if (!r.ok) return fail(r.error);
|
|
183
|
+
return done({ proposal: pid, state: r.proposal.state, wrote: r.wrote }, `✓ ${pid} ${r.proposal.state}${r.wrote ? ` (written to the ${r.wrote} record as you)` : ''}`);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (verb === 'pause' || verb === 'resume') {
|
|
187
|
+
if (!needTarget()) return 2;
|
|
188
|
+
const g = pmOnly(`${verb} the driver`);
|
|
189
|
+
if (!g.ok) return fail(g.error);
|
|
190
|
+
if (!file) return noState();
|
|
191
|
+
const r = decideLib.setPause(file, verb, target(), g.actor, { now });
|
|
192
|
+
if (!r.ok) return fail(r.error);
|
|
193
|
+
return done({ target: r.target, paused: r.paused }, `✓ the driver is ${r.paused ? 'paused' : 'resumed'} for ${r.target.kind} ${r.target.id}`);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// mode
|
|
197
|
+
const mode = pos[0];
|
|
198
|
+
if (!mode) { say('usage: pmos driver mode <off|shadow|approve> [--board <id>]'); return 2; }
|
|
199
|
+
const g = pmOnly('set the driver mode');
|
|
200
|
+
if (!g.ok) return fail(g.error);
|
|
201
|
+
if (!file) return noState();
|
|
202
|
+
const r = decideLib.setMode(file, flags.board || '*', mode, g.actor, { now });
|
|
203
|
+
if (!r.ok) return fail(r.error);
|
|
204
|
+
return done({ board: r.board, mode: r.mode }, `✓ driver mode ${r.mode} for ${r.board === '*' ? 'every board' : 'board ' + r.board}`);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
module.exports = { run, COMMANDS, USAGE };
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* driver-decide.js — what the PM does with a driver proposal, and with the driver itself (initiative initiative-driver,
|
|
3
|
+
* PRD sections 4 and 5). The CLI (lib/driver-cli.js) and the routes (lib/routes-driver.js) both call it, so a terminal and
|
|
4
|
+
* a page press cannot differ.
|
|
5
|
+
*
|
|
6
|
+
* decide(verb, { file, proposal, note, actor, ctx, now }) → Promise of { ok, proposal, wrote } | { ok: false, status, error }
|
|
7
|
+
* verb: keep | return | drop. ctx: { stageFile, itemsDir, move(proposal) → { status, body }, correctionsFile? }
|
|
8
|
+
* setPause(file, verb, target, actor, { now }) verb: pause | resume
|
|
9
|
+
* setMode(file, board, mode, actor, { now }) mode: off | shadow | approve; board '*' is every board
|
|
10
|
+
*
|
|
11
|
+
* The caller has already proved the actor is the PM (pm-guard requirePagePress on a route, lib/actor.js on the CLI). These
|
|
12
|
+
* functions still refuse a non-PM actor: a run cannot keep its own proposals.
|
|
13
|
+
*
|
|
14
|
+
* KEEP WRITES THE PM'S RECORD THROUGH THE EXISTING WRITERS, so the record says the PM made it (PRD section 4):
|
|
15
|
+
* problem → lib/stage-tools.js appendTool, tool `problem`, `by` the PM
|
|
16
|
+
* move → the board move route's own function (lib/routes-boards.js move): its validation and warnings apply
|
|
17
|
+
* next-step → lib/pm-items.js appendEvent: a step (lib/item-steps.js) and, when the proposal names a date, an `edited` due
|
|
18
|
+
* Every other kind (evidence, sizing, assumption, stakeholders, follow-up, note) is kept as a decision only: the PM's choice to
|
|
19
|
+
* rely on it. A follow-up is never sent: the PM sends it from their own session. The driver never moves a stage by itself (D3).
|
|
20
|
+
* RETURN needs a note and DROP may carry one; each logs one correction (the north star counts it) and a return wakes a run.
|
|
21
|
+
*/
|
|
22
|
+
const fs = require('fs');
|
|
23
|
+
const path = require('path');
|
|
24
|
+
const actorLib = require('./actor.js');
|
|
25
|
+
const D = require('./driver.js');
|
|
26
|
+
const stageTools = require('./stage-tools.js');
|
|
27
|
+
const pmItems = require('./pm-items.js');
|
|
28
|
+
|
|
29
|
+
const refuse = (status, error) => ({ ok: false, status, error });
|
|
30
|
+
const isPm = (a) => !!a && actorLib.isPm(a);
|
|
31
|
+
|
|
32
|
+
function itemsPaths(dir) { return { items: path.join(dir, 'items.jsonl'), steps: path.join(dir, 'steps.jsonl') }; }
|
|
33
|
+
|
|
34
|
+
/** The record write of a Keep. → { ok, wrote } | { ok: false, status, error }. */
|
|
35
|
+
async function writeRecord(p, actor, ctx, now) {
|
|
36
|
+
if (p.what === 'problem') {
|
|
37
|
+
if (!ctx.stageFile) return refuse(409, 'this repository has no state folder to keep the problem statement in');
|
|
38
|
+
const c = stageTools.checkData('problem', { text: p.data && p.data.text });
|
|
39
|
+
if (!c.ok) return refuse(400, c.error);
|
|
40
|
+
stageTools.appendTool(ctx.stageFile, { initiative: p.target.id, tool: 'problem', data: c.data }, actor, { now });
|
|
41
|
+
return { ok: true, wrote: 'problem' };
|
|
42
|
+
}
|
|
43
|
+
if (p.what === 'move') {
|
|
44
|
+
if (typeof ctx.move !== 'function') return refuse(409, 'a move is kept from the workspace page');
|
|
45
|
+
const r = await ctx.move(p);
|
|
46
|
+
if (!r || !r.body || r.body.ok !== true) return refuse((r && r.status) || 500, (r && r.body && r.body.error) || 'the move was refused');
|
|
47
|
+
return { ok: true, wrote: 'move', warnings: r.body.warnings || [] };
|
|
48
|
+
}
|
|
49
|
+
if (p.what === 'next-step') {
|
|
50
|
+
if (!ctx.itemsDir) return refuse(409, 'no folder for the Work items');
|
|
51
|
+
const paths = itemsPaths(ctx.itemsDir);
|
|
52
|
+
const lines = pmItems.readLines(paths.items);
|
|
53
|
+
const item = pmItems.fold(lines).find((i) => i.id === p.target.id);
|
|
54
|
+
if (!item) return refuse(404, `no Work task ${p.target.id}`);
|
|
55
|
+
const stepsLib = require('./item-steps.js');
|
|
56
|
+
const id = `step-${require('crypto').randomBytes(6).toString('hex')}`;
|
|
57
|
+
const r = stepsLib.addStep(p.target.id, p.data.text, 'you', id);
|
|
58
|
+
if (!r.ok) return refuse(400, r.error);
|
|
59
|
+
pmItems.appendEvent(paths.steps, r.event, new Date(now));
|
|
60
|
+
if (p.data.due) pmItems.appendEvent(paths.items, { event: 'edited', id: p.target.id, fields: { due: p.data.due } }, new Date(now));
|
|
61
|
+
return { ok: true, wrote: 'next-step' };
|
|
62
|
+
}
|
|
63
|
+
return { ok: true, wrote: null };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function decide(verb, { file, proposal, note, actor, ctx = {}, now } = {}) {
|
|
67
|
+
if (!['keep', 'return', 'drop'].includes(verb)) return refuse(500, `driver: ${JSON.stringify(verb)} is not a decision`);
|
|
68
|
+
if (!isPm(actor)) return refuse(403, 'only the PM can keep, send back or drop a driver proposal');
|
|
69
|
+
if (!file) return refuse(409, 'this repository has no state folder for the driver');
|
|
70
|
+
const t = now == null ? Date.now() : now;
|
|
71
|
+
const state = D.fold(D.readLines(file));
|
|
72
|
+
const p = state.proposals.get(proposal);
|
|
73
|
+
if (!p) return refuse(404, `no driver proposal ${String(proposal).slice(0, 40)}`);
|
|
74
|
+
if (p.state !== 'open') return refuse(409, `the proposal is ${p.state} already`);
|
|
75
|
+
const text = typeof note === 'string' ? note.trim() : '';
|
|
76
|
+
if (text.length > 500 || /[\r\n]/.test(text)) return refuse(400, 'the note is one line of at most 500 characters');
|
|
77
|
+
if (verb === 'return' && !text) return refuse(400, 'say what to change: sending a proposal back needs a note');
|
|
78
|
+
let wrote = null; let warnings = [];
|
|
79
|
+
if (verb === 'keep') {
|
|
80
|
+
const w = await writeRecord(p, actor, ctx, t);
|
|
81
|
+
if (!w.ok) return w;
|
|
82
|
+
wrote = w.wrote; warnings = w.warnings || [];
|
|
83
|
+
}
|
|
84
|
+
D.append(file, { ev: verb, proposal: p.id, ...(text ? { note: text } : {}), by: actor.by }, { now: t });
|
|
85
|
+
if (verb !== 'keep') {
|
|
86
|
+
D.append(file, { ev: 'correction', proposal: p.id, run: p.run, kind: verb, by: actor.by }, { now: t });
|
|
87
|
+
if (ctx.correctionsFile) {
|
|
88
|
+
try {
|
|
89
|
+
const reason = `driver:${verb}`;
|
|
90
|
+
fs.mkdirSync(path.dirname(ctx.correctionsFile), { recursive: true });
|
|
91
|
+
fs.appendFileSync(ctx.correctionsFile, JSON.stringify({ type: 'correction', kind: 'reject', reason, run: p.run, counted: true, at: new Date(t).toISOString(),
|
|
92
|
+
record: { kind: 'correction', run: p.run, reason, by: 'pm' } }) + '\n');
|
|
93
|
+
} catch { /* the driver ledger already holds the correction */ }
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return { ok: true, proposal: D.fold(D.readLines(file)).proposals.get(p.id), wrote, warnings };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function setPause(file, verb, target, actor, { now } = {}) {
|
|
100
|
+
if (!['pause', 'resume'].includes(verb)) return refuse(500, `driver: ${JSON.stringify(verb)} is not pause or resume`);
|
|
101
|
+
if (!isPm(actor)) return refuse(403, 'only the PM can pause or resume the driver');
|
|
102
|
+
if (!file) return refuse(409, 'this repository has no state folder for the driver');
|
|
103
|
+
const c = D.checkTarget(target);
|
|
104
|
+
if (!c.ok) return refuse(400, c.error);
|
|
105
|
+
D.append(file, { ev: verb, target: c.target, by: actor.by }, { now });
|
|
106
|
+
return { ok: true, paused: verb === 'pause', target: c.target };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function setMode(file, board, mode, actor, { now } = {}) {
|
|
110
|
+
if (!isPm(actor)) return refuse(403, 'only the PM can set the driver mode');
|
|
111
|
+
if (!file) return refuse(409, 'this repository has no state folder for the driver');
|
|
112
|
+
if (!D.MODES.includes(mode)) return refuse(400, `mode must be one of ${D.MODES.join(', ')}`);
|
|
113
|
+
if (typeof board !== 'string' || !/^[A-Za-z0-9*][A-Za-z0-9._*-]{0,79}$/.test(board)) return refuse(400, 'board must be a board id (or * for every board)');
|
|
114
|
+
D.append(file, { ev: 'mode', board, mode, by: actor.by }, { now });
|
|
115
|
+
return { ok: true, board, mode };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
module.exports = { decide, setPause, setMode, writeRecord };
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* driver-facts.js — what the unblock sweep reads (initiative initiative-driver, PRD section 3).
|
|
3
|
+
*
|
|
4
|
+
* gather(w, ctx, { now, includeWork, runtimeDir, driverState }) → { ok: true, facts } | { pending: true } | { ok: false, error }
|
|
5
|
+
*
|
|
6
|
+
* `facts` is the shape lib/driver.js detect() documents. This file only READS: the cached workflow rows and the boards ledger
|
|
7
|
+
* (lib/routes-boards.js), the stage-tool records (lib/stage-tools.js), the cached Jira readings (lib/jira-status.js), the
|
|
8
|
+
* placements ledger and the Work items. It starts no derivation of its own: while the rows are still being read it answers
|
|
9
|
+
* pending and the sweep tries again at the next tick. `ctx` is what route modules get (stateDirFor, selfRoot, getWs …).
|
|
10
|
+
*
|
|
11
|
+
* WHAT IS ACTIVE. An initiative that is not done, not stopped by a lifecycle line and not dormant. A dormant initiative (Later,
|
|
12
|
+
* untouched for dormant_days) is folded away on the Roadmap and starts no driver run.
|
|
13
|
+
* LIMITS, NAMED. The Jira readings keep no status-change time, so `last_ticket_move` is the latest resolved date of the
|
|
14
|
+
* initiative's tickets (null when none: the Do rule then stays quiet instead of guessing). `has_evidence` counts kept driver
|
|
15
|
+
* evidence only. `has_done_criteria` is a PRD that names done-criteria, acceptance criteria or a rubric, or a rubric file.
|
|
16
|
+
*/
|
|
17
|
+
const fs = require('fs');
|
|
18
|
+
const path = require('path');
|
|
19
|
+
const D = require('./driver.js');
|
|
20
|
+
const stageTools = require('./stage-tools.js');
|
|
21
|
+
const pmItems = require('./pm-items.js');
|
|
22
|
+
const itemSteps = require('./item-steps.js');
|
|
23
|
+
|
|
24
|
+
const ms = (iso) => { const t = Date.parse(iso); return Number.isFinite(t) ? t : null; };
|
|
25
|
+
|
|
26
|
+
function fmOf(root, rel) {
|
|
27
|
+
if (!rel) return {};
|
|
28
|
+
let text = '';
|
|
29
|
+
try { text = fs.readFileSync(path.isAbsolute(rel) ? rel : path.join(root, rel), 'utf8'); } catch { return {}; }
|
|
30
|
+
const m = /^---\r?\n([\s\S]*?)\r?\n---/.exec(text);
|
|
31
|
+
const out = {};
|
|
32
|
+
if (m) for (const line of m[1].split(/\r?\n/)) { const kv = /^([A-Za-z_][A-Za-z0-9_]*):\s*(.*?)\s*$/.exec(line); if (kv) out[kv[1]] = kv[2].replace(/^["']|["']$/g, ''); }
|
|
33
|
+
return out;
|
|
34
|
+
}
|
|
35
|
+
function doneCriteriaOf(root, fm) {
|
|
36
|
+
if (fm.rubric_path && fs.existsSync(path.join(root, fm.rubric_path))) return true;
|
|
37
|
+
if (!fm.prd_path) return false;
|
|
38
|
+
try { return /done[- ]criteria|acceptance criteria|rubric/i.test(fs.readFileSync(path.join(root, fm.prd_path), 'utf8')); } catch { return false; }
|
|
39
|
+
}
|
|
40
|
+
function outcomeChecked(stateDir, id) {
|
|
41
|
+
try {
|
|
42
|
+
return fs.readFileSync(path.join(stateDir, 'outcome_checks.jsonl'), 'utf8').split('\n').some((l) => { try { const c = JSON.parse(l); return c && c.initiative_id === id; } catch { return false; } });
|
|
43
|
+
} catch { return false; }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function initiativeFacts(w, ctx, { driverState, stateDir }) {
|
|
47
|
+
const boards = require('./routes-boards.js');
|
|
48
|
+
const got = boards.baseRows(w);
|
|
49
|
+
if (got.pending) return { pending: true };
|
|
50
|
+
if (!got.ok) return { ok: false, error: got.error };
|
|
51
|
+
const fr = boards.frameFor(ctx, w);
|
|
52
|
+
const tools = stageTools.readLedger(path.join(stateDir, stageTools.LEDGER));
|
|
53
|
+
let placed = null;
|
|
54
|
+
try { placed = require('./placements.js').readPlacements(w.root); } catch { placed = null; }
|
|
55
|
+
let jiraStatus = null;
|
|
56
|
+
try { jiraStatus = require('./jira-status.js'); } catch { jiraStatus = null; }
|
|
57
|
+
const keptEvidence = new Set();
|
|
58
|
+
for (const p of driverState.proposals.values()) if (p.state === 'kept' && p.what === 'evidence' && p.target.kind === 'initiative') keptEvidence.add(p.target.id);
|
|
59
|
+
const out = [];
|
|
60
|
+
for (const base of got.rows) {
|
|
61
|
+
if (base.status === 'done' || base.lifecycle || base.dormant) continue;
|
|
62
|
+
const boardId = boards.boardIdOf(base, fr.boards, fr.folded, w);
|
|
63
|
+
const board = fr.boards.find((b) => b.id === boardId) || fr.boards[0];
|
|
64
|
+
const row = boards.boardRow(board, base, fr.folded);
|
|
65
|
+
const fm = fmOf(w.root, base.record);
|
|
66
|
+
const rec = stageTools.recordsOf(tools, base.id);
|
|
67
|
+
let snap = null;
|
|
68
|
+
try { snap = jiraStatus ? jiraStatus.readSnapshot(w.root, base.id) : null; } catch { snap = null; }
|
|
69
|
+
const tickets = snap ? [...snap.tickets.values()] : [];
|
|
70
|
+
const resolved = tickets.map((t) => ms(t.resolved)).filter((x) => x != null);
|
|
71
|
+
out.push({
|
|
72
|
+
ws: w.id, board: board.id, id: base.id, title: base.title || base.id, cat: row.cat, active: true,
|
|
73
|
+
last_activity: ms(base.updated),
|
|
74
|
+
has_problem: !!rec.problem, has_evidence: keptEvidence.has(base.id),
|
|
75
|
+
has_prd: !!base.hasSpec, has_done_criteria: doneCriteriaOf(w.root, fm), has_anchor: !!(base.anchor && String(base.anchor).trim()),
|
|
76
|
+
has_tickets: tickets.length > 0, last_ticket_move: resolved.length ? Math.max(...resolved) : null,
|
|
77
|
+
blocked_tickets: tickets.filter((t) => t.flagged).map((t) => t.key),
|
|
78
|
+
check_by: placed && placed.ok && placed.byInit[base.id] ? placed.byInit[base.id].check_by || null : null,
|
|
79
|
+
outcome_checked: outcomeChecked(stateDir, base.id),
|
|
80
|
+
kill_tripped: !!(rec.kill && rec.kill.data && (rec.kill.data.items || []).some((i) => i.status === 'tripped')),
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
return { ok: true, list: out };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function workFacts(w, { runtimeDir, now }) {
|
|
87
|
+
const dir = path.join(runtimeDir, 'pm-items');
|
|
88
|
+
const items = pmItems.fold(pmItems.readLines(path.join(dir, 'items.jsonl')));
|
|
89
|
+
const steps = itemSteps.foldSteps(pmItems.readLines(path.join(dir, 'steps.jsonl')));
|
|
90
|
+
const today = new Date(Date.UTC(new Date(now).getFullYear(), new Date(now).getMonth(), new Date(now).getDate()));
|
|
91
|
+
const stale = new Set(pmItems.reviewQueue(items, new Date(now)).map((i) => i.id));
|
|
92
|
+
return items.filter((i) => !i.done).map((i) => ({
|
|
93
|
+
ws: w.id, id: i.id, title: i.title || i.id, done: false, placement: pmItems.workPlacement(i, today), due: i.due || null,
|
|
94
|
+
waiting_on: i.waiting_on || null, waiting_since: i.waiting_since || null, touched_at: i.touched_at || null,
|
|
95
|
+
untouched: stale.has(i.id), steps: (steps.byItem[i.id] || []).length,
|
|
96
|
+
}));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function gather(w, ctx, { now = Date.now(), includeWork = false, runtimeDir, driverState } = {}) {
|
|
100
|
+
const stateDir = ctx.stateDirFor ? ctx.stateDirFor(w) : null;
|
|
101
|
+
if (!stateDir) return { ok: false, error: 'no state folder' };
|
|
102
|
+
const facts = { day: D.localDay(now), now, initiatives: [], work: [] };
|
|
103
|
+
const ini = initiativeFacts(w, ctx, { driverState: driverState || D.fold([]), stateDir });
|
|
104
|
+
if (ini.pending) return { pending: true };
|
|
105
|
+
if (ini.ok === false) return ini;
|
|
106
|
+
facts.initiatives = ini.list;
|
|
107
|
+
if (includeWork) {
|
|
108
|
+
try { facts.work = workFacts(w, { runtimeDir, now }); } catch (e) { return { ok: false, error: `the Work items could not be read: ${String((e && e.message) || e).slice(0, 120)}` }; }
|
|
109
|
+
}
|
|
110
|
+
return { ok: true, facts };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
module.exports = { gather, initiativeFacts, workFacts };
|