create-pmos 0.168.0 → 0.170.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/c3-scan-fixture.js +78 -0
- package/workspace/c3-scan-test.js +170 -0
- package/workspace/guide/horizon-scan.md +73 -0
- package/workspace/guide/index.md +1 -0
- package/workspace/guide/initiative-driver.md +12 -0
- package/workspace/lib/engine/boards.js +8 -2
- package/workspace/lib/headless.js +21 -3
- package/workspace/lib/pmos-verbs.js +3 -0
- package/workspace/lib/route-modules.js +1 -0
- package/workspace/lib/routes-boards.js +30 -1
- package/workspace/lib/routes-driver.js +38 -6
- package/workspace/lib/routes-scan.js +130 -0
- package/workspace/lib/scan-cli.js +226 -0
- package/workspace/lib/scan-run.js +186 -0
- package/workspace/lib/scan.js +396 -0
- 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-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-ib-triage.js +6 -2
- 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 +21 -0
- package/workspace/public/pm/c3-scan.css +25 -0
- package/workspace/public/pm/c3-scan.js +263 -0
- package/workspace/public/pm/c3-shell.js +3 -2
- package/workspace/public/pm/pm-work-view.js +12 -2
- package/workspace/server.js +40 -4
- package/workspace/skills/horizon-scan-repo.skill +56 -0
- package/workspace/skills/horizon-scan-web.skill +43 -0
- package/workspace/test-files.json +8 -0
package/workspace/guide/index.md
CHANGED
|
@@ -62,6 +62,7 @@ On a desktop the workspace is one frame. [Layout](layout.md) explains each part.
|
|
|
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
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`.
|
|
65
|
+
- [The Horizon scan: the agent looks outside your initiatives](horizon-scan.md): how often it runs, the watch list you keep, what a scan does, where findings land, what it cannot do, and `pmos scan`.
|
|
65
66
|
- [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.
|
|
66
67
|
- [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.
|
|
67
68
|
- [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.
|
|
@@ -50,6 +50,14 @@ The agent reads, and it files proposals with `pmos driver propose`. It has no Wr
|
|
|
50
50
|
|
|
51
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
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
|
+
|
|
53
61
|
## Keep, send back, drop
|
|
54
62
|
|
|
55
63
|
Open proposals appear in Needs you as one row for each initiative or task: "Agent has 3 drafts on the initiative".
|
|
@@ -77,3 +85,7 @@ The agent uses `pmos driver propose` to file a proposal and `pmos driver status`
|
|
|
77
85
|
- `pmos driver mode <off|shadow|approve> [--board <id>]` sets the board mode. Without `--board` it sets every board.
|
|
78
86
|
|
|
79
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.
|
|
88
|
+
|
|
89
|
+
## Related
|
|
90
|
+
|
|
91
|
+
The [Horizon scan](horizon-scan.md) is the part of the agent that looks outside the initiatives: it files new opportunities as asks in Triage.
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
* workflow { stages: [{ name, cat, hint }], renamed: { old: new }, moved: [{ id, from, to }], sprints? }
|
|
28
28
|
* move { id, from, to, why, warnings }
|
|
29
29
|
* assign { id, to } (`board` is the board it left, or null)
|
|
30
|
-
* ask { ask, line, source, draft }
|
|
30
|
+
* ask { ask, line, source, draft, found? } found: what a Horizon scan filed with the ask { why, sources: [{ label, ref }], match, scan } (source 'scan')
|
|
31
31
|
* ask-accept { ask, initiative, stage }
|
|
32
32
|
* ask-decline { ask, why }
|
|
33
33
|
* ask-more { ask, note } the asker is prompted for more detail; the ask stays open
|
|
@@ -146,6 +146,12 @@ const TOOLS = Object.freeze({
|
|
|
146
146
|
|
|
147
147
|
const STATUSES = Object.freeze(['on track', 'at risk', 'late', 'done']);
|
|
148
148
|
const VIEWS = Object.freeze(['now', 'mine', 'risk', 'noowner', 'all']);
|
|
149
|
+
/** What a Horizon scan filed with an ask (initiative horizon-scan), cut to its shape; null for any other ask. */
|
|
150
|
+
function foundOf(f) {
|
|
151
|
+
if (!f || typeof f !== 'object' || Array.isArray(f)) return null;
|
|
152
|
+
const sources = Array.isArray(f.sources) ? f.sources.filter((x) => x && typeof x.ref === 'string').slice(0, 8).map((x) => ({ label: String(x.label || ''), ref: x.ref })) : [];
|
|
153
|
+
return { why: typeof f.why === 'string' ? f.why : '', sources, match: typeof f.match === 'string' && f.match ? f.match : null, scan: typeof f.scan === 'string' ? f.scan : null, run: typeof f.run === 'string' ? f.run : null };
|
|
154
|
+
}
|
|
149
155
|
const KINDS = new Set(['board', 'workflow', 'move', 'assign', 'ask', 'ask-accept', 'ask-decline', 'ask-more', 'ini-workflow', 'task', 'task-update', 'ini-epics']);
|
|
150
156
|
const TASK_STATES = Object.freeze(['To do', 'In progress', 'In review', 'Done']);
|
|
151
157
|
|
|
@@ -206,7 +212,7 @@ function fold(events) {
|
|
|
206
212
|
} else if (e.kind === 'assign' && typeof e.id === 'string' && typeof e.to === 'string') {
|
|
207
213
|
assigns.set(e.id, e.to);
|
|
208
214
|
} else if (e.kind === 'ask' && b && typeof e.ask === 'string' && !asks.has(e.ask)) {
|
|
209
|
-
asks.set(e.ask, { id: e.ask, board: b, line: String(e.line || ''), source: e.source || null, draft: e.draft || null, at: e.at || null, by: e.by || null, state: 'open', initiative: null, stage: null, why: null, closedAt: null });
|
|
215
|
+
asks.set(e.ask, { id: e.ask, board: b, line: String(e.line || ''), source: e.source || null, draft: e.draft || null, ...(foundOf(e.found) ? { found: foundOf(e.found) } : {}), at: e.at || null, by: e.by || null, state: 'open', initiative: null, stage: null, why: null, closedAt: null });
|
|
210
216
|
} else if (e.kind === 'ask-accept' && typeof e.ask === 'string' && asks.has(e.ask)) {
|
|
211
217
|
const a = asks.get(e.ask);
|
|
212
218
|
if (a.state !== 'open') continue;
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* (initiative workspace-headless-jobs; PM decision 2026-10-11: keep Jev, no direct Claude API, background
|
|
4
4
|
* jobs that need no human run headless).
|
|
5
5
|
*
|
|
6
|
-
* kindFor({ agent, origin, skill, env }) → 'notes' | 'skill' | 'ask' | 'drive' | null (null: open a pane, as before)
|
|
6
|
+
* kindFor({ agent, origin, skill, env }) → 'notes' | 'skill' | 'ask' | 'drive' | 'scan-web' | 'scan-repo' | null (null: open a pane, as before)
|
|
7
7
|
* argsFor(kind, { modelArgs, addDirs }) → the argv after `claude`
|
|
8
8
|
* parseResult(stdout) → { ok, text, isError, turns, durationMs, costUsd, denied[] }
|
|
9
9
|
* logLines(result, exitCode) → the lines the read-only log pane shows at the end
|
|
@@ -20,6 +20,11 @@
|
|
|
20
20
|
* connector tool is named in its list, so a connector read runs only where the PM's own Claude Code settings already allow it,
|
|
21
21
|
* and a connector write is denied and reported like any other tool outside the list. The PM-only `pmos driver` verbs refuse an
|
|
22
22
|
* agent session by themselves. The driver never runs in a pane (server.js refuses to start it when PMOS_HEADLESS=off).
|
|
23
|
+
*
|
|
24
|
+
* The `scan-web` and `scan-repo` kinds are the Horizon scan (initiative horizon-scan). `scan-web` has WebSearch, WebFetch and
|
|
25
|
+
* `pmos scan file`, and nothing else: it reads untrusted pages, so it holds no private data (no Read, Glob or Grep, no repository
|
|
26
|
+
* path in its prompt, no --add-dir). `scan-repo` reads the repository and the findings the web run filed, so it has no web tool and
|
|
27
|
+
* nothing that sends. Neither kind ever runs in a pane.
|
|
23
28
|
*/
|
|
24
29
|
|
|
25
30
|
// The skills whose every start runs headless: they read, file a result for PMOS, and send nothing out.
|
|
@@ -33,6 +38,11 @@ const HEADLESS_ORIGINS = Object.freeze(['notes-bullets:', 'notes-summary:']);
|
|
|
33
38
|
const ASK_ORIGINS = Object.freeze(['doc-ask:', 'focus-companion:']); // focus-companion: the Meeting focus page's quiet companion and its private questions (read-only, nothing sent)
|
|
34
39
|
// initiative-driver: one short run for one initiative or one Work task. Its origin is `drive:<kind>:<id>`.
|
|
35
40
|
const DRIVE_ORIGINS = Object.freeze(['drive:']);
|
|
41
|
+
// horizon-scan: a scan is two runs. The web run (origin `scan-web:<board>:<scan>`) reads the public web for the kept watch list and sees nothing of
|
|
42
|
+
// the repository. The repository run (origin `scan-repo:<board>:<scan>`, and `scan-suggest:<board>` for the one that suggests a watch list) reads
|
|
43
|
+
// the repository and fetches nothing. Neither has both untrusted input and a way out (PRD: trust boundary).
|
|
44
|
+
const SCAN_WEB_ORIGINS = Object.freeze(['scan-web:']);
|
|
45
|
+
const SCAN_REPO_ORIGINS = Object.freeze(['scan-repo:', 'scan-suggest:']);
|
|
36
46
|
|
|
37
47
|
// What each kind may use. Read-only search is always allowed. The notes passes write their JSON (and rename the .tmp
|
|
38
48
|
// file) in their notes folder, which --add-dir opens to them; the skills also run `pmos` verbs and repository scripts.
|
|
@@ -41,6 +51,11 @@ const TOOLS = Object.freeze({
|
|
|
41
51
|
ask: Object.freeze(['Read', 'Glob', 'Grep']),
|
|
42
52
|
drive: Object.freeze(['Read', 'Glob', 'Grep', 'Bash(pmos driver *)', 'Bash(pmos initiative list *)', 'Bash(pmos initiative export *)',
|
|
43
53
|
'Bash(git log *)', 'Bash(ls *)', 'Bash(date *)']),
|
|
54
|
+
// The web run: WebSearch, WebFetch and the one verb that files a finding. No Read, no Glob, no Grep, no repository path in its prompt or its add-dirs.
|
|
55
|
+
'scan-web': Object.freeze(['WebSearch', 'WebFetch', 'Bash(pmos scan file *)']),
|
|
56
|
+
// The repository run: read and search the repository, `pmos scan` (its PM-only verbs refuse an agent session), the initiative list and git log.
|
|
57
|
+
// No WebSearch, no WebFetch, no curl, no git push: it reads the web run's findings, which are untrusted text, so it has nothing that sends or fetches.
|
|
58
|
+
'scan-repo': Object.freeze(['Read', 'Glob', 'Grep', 'Bash(pmos scan *)', 'Bash(pmos initiative list *)', 'Bash(git log *)']),
|
|
44
59
|
skill: Object.freeze(['Read', 'Write', 'Edit', 'Glob', 'Grep', 'Bash(pmos *)', 'Bash(node *)',
|
|
45
60
|
'Bash(git log *)', 'Bash(git status *)', 'Bash(git diff *)', 'Bash(ls *)', 'Bash(date *)']),
|
|
46
61
|
});
|
|
@@ -57,6 +72,8 @@ function kindFor({ agent, origin, skill, env } = {}) {
|
|
|
57
72
|
if (HEADLESS_ORIGINS.some((p) => o.startsWith(p))) return 'notes';
|
|
58
73
|
if (ASK_ORIGINS.some((p) => o.startsWith(p))) return 'ask';
|
|
59
74
|
if (DRIVE_ORIGINS.some((p) => o.startsWith(p))) return 'drive';
|
|
75
|
+
if (SCAN_WEB_ORIGINS.some((p) => o.startsWith(p))) return 'scan-web';
|
|
76
|
+
if (SCAN_REPO_ORIGINS.some((p) => o.startsWith(p))) return 'scan-repo';
|
|
60
77
|
if (skill && HEADLESS_SKILLS.includes(String(skill))) return 'skill';
|
|
61
78
|
return null;
|
|
62
79
|
}
|
|
@@ -65,7 +82,8 @@ function argsFor(kind, { modelArgs = [], addDirs = [] } = {}) {
|
|
|
65
82
|
const tools = TOOLS[kind];
|
|
66
83
|
if (!tools) throw new Error('unknown headless kind: ' + kind);
|
|
67
84
|
const argv = ['-p', '--output-format', 'json', '--no-session-persistence', ...modelArgs, '--allowedTools', tools.join(',')];
|
|
68
|
-
|
|
85
|
+
// The web run gets no directory at all, whatever the caller passes: it must not be able to read the repository.
|
|
86
|
+
if (kind !== 'scan-web') for (const d of addDirs) if (d) argv.push('--add-dir', String(d));
|
|
69
87
|
return argv;
|
|
70
88
|
}
|
|
71
89
|
|
|
@@ -107,4 +125,4 @@ function logLines(r, exitCode) {
|
|
|
107
125
|
return out;
|
|
108
126
|
}
|
|
109
127
|
|
|
110
|
-
module.exports = { HEADLESS_SKILLS, HEADLESS_ORIGINS, ASK_ORIGINS, DRIVE_ORIGINS, TOOLS, DEFAULT_TIMEOUT_MS, kindFor, argsFor, timeoutMs, parseResult, logLines, switchedOff };
|
|
128
|
+
module.exports = { HEADLESS_SKILLS, HEADLESS_ORIGINS, ASK_ORIGINS, DRIVE_ORIGINS, SCAN_WEB_ORIGINS, SCAN_REPO_ORIGINS, TOOLS, DEFAULT_TIMEOUT_MS, kindFor, argsFor, timeoutMs, parseResult, logLines, switchedOff };
|
|
@@ -59,6 +59,9 @@ const VERBS = Object.freeze({
|
|
|
59
59
|
// Not a pm-os-absorb verb (run: null): initiative initiative-driver added it through the same table.
|
|
60
60
|
driver: Object.freeze({ module: 'driver-cli.js', run: null, guide: 'initiative-driver.md',
|
|
61
61
|
help: 'pmos driver propose|status|keep|return|drop|pause|resume|mode … — the Initiative Driver: drafts for you to keep, send back or drop (pmos driver help)' }),
|
|
62
|
+
// Not a pm-os-absorb verb (run: null): initiative horizon-scan added it through the same table.
|
|
63
|
+
scan: Object.freeze({ module: 'scan-cli.js', run: null, guide: 'horizon-scan.md',
|
|
64
|
+
help: 'pmos scan file|list|match|suggest|status|cadence|watch … — the Horizon scan: what the agent found outside the initiatives, and the watch list (pmos scan help)' }),
|
|
62
65
|
launcher: Object.freeze({ module: 'launcher-cli.js', run: null, guide: 'launcher.md',
|
|
63
66
|
help: 'pmos launcher on|off|status — a PMOS app and a login item, so the workspace starts without a terminal' }),
|
|
64
67
|
});
|
|
@@ -69,6 +69,7 @@ const MODULES = Object.freeze([
|
|
|
69
69
|
'routes-meeting-focus', // console3 Meeting focus: the meeting, its note's talk, the companion's notes (GET); topic corrections and Ask PMOS quietly (page press)
|
|
70
70
|
'routes-stage-tools', // stage-tools-records: the records the PM edits per stage, what PMOS holds per stage, the sprint burndown
|
|
71
71
|
'routes-driver', // initiative-driver: the Agent strip's data, Keep, Send back, Drop, Pause and the board mode (driver.jsonl)
|
|
72
|
+
'routes-scan', // horizon-scan: a board's watch list, cadence and last scan, the day's scan for the Briefing, the PM's watch and cadence presses (scan.jsonl)
|
|
72
73
|
]);
|
|
73
74
|
const PM_DIR = path.join(__dirname, '..', 'public', 'pm');
|
|
74
75
|
const MODULE_NAME = /^[a-z0-9][a-z0-9-]*\.js$/;
|
|
@@ -94,6 +94,33 @@ function append(file, actor, ev) {
|
|
|
94
94
|
return line;
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
+
/** A Horizon scan ask that the PM declines counts as a correction (initiative horizon-scan, PRD section 5): one line in corrections.jsonl, as the driver writes. */
|
|
98
|
+
function scanDeclineSignal(ctx, a) {
|
|
99
|
+
if (!ctx.runtimeDir) return;
|
|
100
|
+
try {
|
|
101
|
+
const f = path.join(ctx.runtimeDir, 'corrections.jsonl');
|
|
102
|
+
const reason = 'scan:decline';
|
|
103
|
+
fs.mkdirSync(path.dirname(f), { recursive: true });
|
|
104
|
+
fs.appendFileSync(f, JSON.stringify({ type: 'correction', kind: 'reject', reason, run: (a.found && (a.found.run || a.found.scan)) || null, counted: true, at: new Date().toISOString(),
|
|
105
|
+
record: { kind: 'correction', run: (a.found && (a.found.run || a.found.scan)) || null, reason, by: 'pm' } }) + '\n');
|
|
106
|
+
} catch { /* the declined ask in the boards ledger is still the record */ }
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** The Triage ask of one Horizon scan finding (initiative horizon-scan, PRD section 4). The same ledger line as POST /api/boards/asks/add, with
|
|
110
|
+
* source 'scan', `found` and the agent as `by`. The ask id comes from the finding id, so writing it twice makes one ask. A scan never creates an
|
|
111
|
+
* initiative: the PM accepts or declines. → { ok, ask, existing } | { ok: false, error } */
|
|
112
|
+
function appendScanAsk(ctx, w, boardId, f, by) {
|
|
113
|
+
const file = ledgerFile(ctx, w);
|
|
114
|
+
if (!file) return { ok: false, error: NO_STATE };
|
|
115
|
+
const id = 'ask-' + String(f.id).replace(/^f-/, '');
|
|
116
|
+
if (B.fold(readLedger(ctx, w)).asks.has(id)) return { ok: true, existing: true, id };
|
|
117
|
+
let draft = null;
|
|
118
|
+
try { draft = intake.intakeDraft({ line: f.title, root: w.root }); } catch { draft = null; }
|
|
119
|
+
append(file, { by }, { kind: 'ask', board: boardId, ask: id, line: f.title, source: 'scan', draft,
|
|
120
|
+
found: { why: f.why, sources: f.sources, match: f.match || null, scan: f.scan, run: f.run || null } });
|
|
121
|
+
return { ok: true, existing: false, id };
|
|
122
|
+
}
|
|
123
|
+
|
|
97
124
|
// ── boards of a workspace ─────────────────────────────────────────────────────────────────────────
|
|
98
125
|
const isSelf = (ctx, w) => !!(ctx.selfRoot && w && path.resolve(w.root) === path.resolve(ctx.selfRoot));
|
|
99
126
|
|
|
@@ -750,6 +777,8 @@ const WRITES = {
|
|
|
750
777
|
if (!ONE_LINE(body.why, 500)) return fail(ctx, res, 400, 'a decline needs a reason (why) of 1 to 500 characters');
|
|
751
778
|
if (!p.file) return fail(ctx, res, 409, NO_STATE);
|
|
752
779
|
append(p.file, g.actor, { kind: 'ask-decline', board: p.board.id, ask: a.id, why: body.why.trim() });
|
|
780
|
+
// initiative horizon-scan: a declined scan finding is a correction signal, as a dropped driver proposal is.
|
|
781
|
+
if (a.source === 'scan') scanDeclineSignal(ctx, a);
|
|
753
782
|
ctx.json(res, { ok: true });
|
|
754
783
|
},
|
|
755
784
|
};
|
|
@@ -846,4 +875,4 @@ function meetingBoards(ctx) {
|
|
|
846
875
|
return { list, detail };
|
|
847
876
|
}
|
|
848
877
|
|
|
849
|
-
module.exports = { prefixes: ['/api/boards'], handle, move, boardIndex, frameFor, boardIdOf, meetingBoards, boardsOf, baseRows, boardRow, portfolio, SELF_REFUSAL, NO_STATE };
|
|
878
|
+
module.exports = { prefixes: ['/api/boards'], handle, move, boardIndex, appendScanAsk, frameFor, boardIdOf, meetingBoards, boardsOf, baseRows, boardRow, portfolio, SELF_REFUSAL, NO_STATE };
|
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
/* routes-driver.js — the Initiative Driver's routes (initiative initiative-driver, PRD sections 4 and 5).
|
|
3
3
|
*
|
|
4
|
-
* GET /api/driver/strip?ws&target_kind&id
|
|
4
|
+
* GET /api/driver/strip?ws&target_kind&id[&board] → { ok, ws, target, mode, board, paused, budget, today: [runs of today], running, last, blocker, proposals }
|
|
5
5
|
* POST /api/driver/keep { ws, proposal } → { ok, proposal, wrote, warnings }
|
|
6
6
|
* POST /api/driver/return { ws, proposal, note } → { ok, proposal } the note is required; it wakes a run
|
|
7
7
|
* POST /api/driver/drop { ws, proposal, note? } → { ok, proposal }
|
|
8
8
|
* POST /api/driver/pause { ws, target_kind, id } → { ok, paused: true, target }
|
|
9
9
|
* POST /api/driver/resume { ws, target_kind, id } → { ok, paused: false, target }
|
|
10
10
|
* POST /api/driver/mode { ws, board?, mode } → { ok, board, mode } board omitted: every board
|
|
11
|
+
* GET /api/driver/mode?ws&board → { ok, ws, board, mode } the mode a board runs in (Approve when none is set)
|
|
12
|
+
* GET /api/driver/open?ws&target_kind → { ok, ws, kind, mode, targets: { <id>: [proposal] } } every open proposal of
|
|
13
|
+
* one kind, by target: the Work list draws one agent line per task from this
|
|
11
14
|
*
|
|
12
15
|
* The strip is the data of the Agent strip on an initiative's Overview and of the one-line version on a Work task. Its
|
|
13
16
|
* `proposals` are the open ones; `by` is the page name (`agent:sess-<n>`), never the session token. `running` is a run that has
|
|
@@ -28,6 +31,8 @@ const stageTools = require('./stage-tools.js');
|
|
|
28
31
|
const text = (v) => (typeof v === 'string' ? v : '');
|
|
29
32
|
const bad = (status, error) => ({ status, body: { ok: false, error } });
|
|
30
33
|
const POSTS = ['keep', 'return', 'drop', 'pause', 'resume', 'mode'];
|
|
34
|
+
const GETS = ['strip', 'mode', 'open'];
|
|
35
|
+
const proposalOut = (p) => ({ id: p.id, what: p.what, data: p.data, why: p.why, sources: p.sources, run: p.run, by: actorLib.pageName(p.by), at: p.at });
|
|
31
36
|
|
|
32
37
|
function wsOf(ctx, id) {
|
|
33
38
|
const w = ctx.getWs(id || '');
|
|
@@ -46,22 +51,23 @@ function boardOfTarget(state, target) {
|
|
|
46
51
|
return runs.length ? runs[runs.length - 1].board : null;
|
|
47
52
|
}
|
|
48
53
|
|
|
49
|
-
function stripOf(state, target, { now = Date.now() } = {}) {
|
|
54
|
+
function stripOf(state, target, { now = Date.now(), board: asked = null } = {}) {
|
|
50
55
|
const key = D.targetKey(target);
|
|
51
56
|
const runs = [...state.runs.values()].filter((r) => D.targetKey(r.target) === key);
|
|
52
57
|
const running = runs.filter((r) => !r.end).pop() || null;
|
|
53
58
|
const ended = runs.filter((r) => r.end).pop() || null;
|
|
54
|
-
const board = boardOfTarget(state, target);
|
|
59
|
+
const board = boardOfTarget(state, target) || asked || null;
|
|
55
60
|
const day = D.localDay(now);
|
|
56
61
|
const blocker = state.blockers.filter((b) => b.target && D.targetKey(b.target) === key && b.day === day).pop() || null;
|
|
57
62
|
const open = D.openProposals(state, target);
|
|
58
63
|
return {
|
|
59
64
|
target, board, mode: D.modeOf(state, board), paused: D.isPaused(state, target),
|
|
60
65
|
budget: { used: D.runsOnDay(state, target, day), limit: target.kind === 'work' ? D.BUDGET.workPerDay : D.BUDGET.initiativePerDay, concurrent: D.BUDGET.concurrent },
|
|
66
|
+
today: runs.filter((r) => r.start && D.localDay(Date.parse(r.start)) === day).map((r) => ({ run: r.run, start: r.start, end: r.end, ok: r.ok, reason: r.reason, proposals: [...state.proposals.values()].filter((p) => p.run === r.run).length })),
|
|
61
67
|
running: running ? { run: running.run, since: running.start } : null,
|
|
62
68
|
last: ended ? { run: ended.run, at: ended.end, ok: ended.ok, reason: ended.reason, proposals: [...state.proposals.values()].filter((p) => p.run === ended.run).length } : null,
|
|
63
69
|
blocker: blocker ? { code: blocker.code, text: blocker.text, owner: blocker.owner, day: blocker.day } : null,
|
|
64
|
-
proposals: open.map(
|
|
70
|
+
proposals: open.map(proposalOut),
|
|
65
71
|
};
|
|
66
72
|
}
|
|
67
73
|
|
|
@@ -69,7 +75,33 @@ async function handle(req, res, url, ctx) {
|
|
|
69
75
|
const p = url.pathname;
|
|
70
76
|
const send = (r) => { ctx.json(res, r.body, r.status); return true; };
|
|
71
77
|
const sub = p.replace(/^\/api\/driver\/?/, '');
|
|
72
|
-
if (sub
|
|
78
|
+
if (!GETS.includes(sub) && !POSTS.includes(sub)) return send(bad(404, 'no such driver route'));
|
|
79
|
+
|
|
80
|
+
if (sub === 'mode' && req.method === 'GET') {
|
|
81
|
+
const w = wsOf(ctx, url.searchParams.get('ws'));
|
|
82
|
+
if (!w) return send(bad(404, 'no such workspace'));
|
|
83
|
+
const board = text(url.searchParams.get('board'));
|
|
84
|
+
if (!board || !D.ID_RE.test(board)) return send(bad(400, 'board must be a board id'));
|
|
85
|
+
const file = ledgerOf(ctx, w);
|
|
86
|
+
const state = D.fold(file ? D.readLines(file) : []);
|
|
87
|
+
return send({ status: 200, body: { ok: true, ws: w.id, ledger: !!file, board, mode: D.modeOf(state, board) } });
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (sub === 'open') {
|
|
91
|
+
if (req.method !== 'GET') return send(bad(405, 'GET only'));
|
|
92
|
+
const w = wsOf(ctx, url.searchParams.get('ws'));
|
|
93
|
+
if (!w) return send(bad(404, 'no such workspace'));
|
|
94
|
+
const kind = text(url.searchParams.get('target_kind'));
|
|
95
|
+
if (!D.KINDS.includes(kind)) return send(bad(400, `target_kind must be one of ${D.KINDS.join(', ')}`));
|
|
96
|
+
const file = ledgerOf(ctx, w);
|
|
97
|
+
const state = D.fold(file ? D.readLines(file) : []);
|
|
98
|
+
const targets = {};
|
|
99
|
+
for (const pr of D.openProposals(state)) {
|
|
100
|
+
if (pr.target.kind !== kind || pr.target.ws !== w.id) continue;
|
|
101
|
+
(targets[pr.target.id] = targets[pr.target.id] || []).push(proposalOut(pr));
|
|
102
|
+
}
|
|
103
|
+
return send({ status: 200, body: { ok: true, ws: w.id, ledger: !!file, kind, mode: D.modeOf(state, kind === 'work' ? 'work' : null), targets } });
|
|
104
|
+
}
|
|
73
105
|
|
|
74
106
|
if (sub === 'strip') {
|
|
75
107
|
if (req.method !== 'GET') return send(bad(405, 'GET only'));
|
|
@@ -79,7 +111,7 @@ async function handle(req, res, url, ctx) {
|
|
|
79
111
|
if (!t.ok) return send(bad(400, t.error));
|
|
80
112
|
const file = ledgerOf(ctx, w);
|
|
81
113
|
const state = D.fold(file ? D.readLines(file) : []);
|
|
82
|
-
return send({ status: 200, body: { ok: true, ws: w.id, ledger: !!file, ...stripOf(state, t.target) } });
|
|
114
|
+
return send({ status: 200, body: { ok: true, ws: w.id, ledger: !!file, ...stripOf(state, t.target, { board: text(url.searchParams.get('board')) || null }) } });
|
|
83
115
|
}
|
|
84
116
|
|
|
85
117
|
if (req.method !== 'POST') return send(bad(405, 'POST only'));
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* routes-scan.js — the Horizon scan's routes (initiative horizon-scan, PRD section 6).
|
|
3
|
+
*
|
|
4
|
+
* GET /api/scan/board?ws&board → { ok, ws, board, ledger, cadence, schedule, items: [item], suggested: [item], kept: [item], suggesting, suggest_failed, running, last, next, switched_off }
|
|
5
|
+
* GET /api/scan/today?ws → { ok, ws, ledger, scan: null | { day, at, boards: [{ id, name }], found, matches, fresh, pages, skipped, findings: [finding], failed: [{ board, reason }] } }
|
|
6
|
+
* POST /api/scan/watch { ws, board, op: keep | edit | remove | add, id?, name?, kind?, why?, restore? } → { ok, id, item? }
|
|
7
|
+
* POST /api/scan/cadence { ws, board, cadence: daily | weekly | off } → { ok, board, cadence }
|
|
8
|
+
*
|
|
9
|
+
* An item is { id, kind, name, why, ini, added, state: suggested | kept }; `ini` is the initiative a suggestion came from (null for one the PM added). A finding is
|
|
10
|
+
* { id, ask, board, title, match, origin, state } where `state` is the Triage ask's state (open, accepted or declined). `next` is an ISO time or null.
|
|
11
|
+
* `scan` of today is the day's scans across the boards of the workspace read as one: the time of the latest, the boards scanned, the counts and the findings.
|
|
12
|
+
*
|
|
13
|
+
* WHO WRITES. Both POSTs pass pm-guard requirePagePress, and a request that carries an agent session header is refused first (403), as the driver routes
|
|
14
|
+
* do. `by` of every line is the guard's actor; the page sends none. The agent writes through `pmos scan` (lib/scan-cli.js) and the scan run, never here.
|
|
15
|
+
* State: <state folder>/scan.jsonl, one per repository (lib/scan.js). Nothing here sends anything outward.
|
|
16
|
+
*/
|
|
17
|
+
const guard = require('./pm-guard.js');
|
|
18
|
+
const D = require('./driver.js');
|
|
19
|
+
const S = require('./scan.js');
|
|
20
|
+
const scanRun = require('./scan-run.js');
|
|
21
|
+
|
|
22
|
+
const text = (v) => (typeof v === 'string' ? v : '');
|
|
23
|
+
const bad = (status, error) => ({ status, body: { ok: false, error } });
|
|
24
|
+
const GETS = ['board', 'today'];
|
|
25
|
+
const POSTS = ['watch', 'cadence'];
|
|
26
|
+
const itemOut = (i) => ({ id: i.id, kind: i.kind, name: i.name, why: i.why, ini: i.ini, added: !!i.added, state: i.state });
|
|
27
|
+
|
|
28
|
+
function wsOf(ctx, id) {
|
|
29
|
+
const w = ctx.getWs(id || '');
|
|
30
|
+
return w && (!id || w.id === id) ? w : null;
|
|
31
|
+
}
|
|
32
|
+
function ledgerOf(ctx, w) {
|
|
33
|
+
const d = ctx.stateDirFor ? ctx.stateDirFor(w) : null;
|
|
34
|
+
return S.ledgerFile(d);
|
|
35
|
+
}
|
|
36
|
+
const boardsOf = (ctx, w) => { try { return require('./routes-boards.js').frameFor(ctx, w); } catch { return null; } };
|
|
37
|
+
const settingsOf = (ctx) => { try { return ctx.proSettings ? ctx.proSettings() : null; } catch { return null; } };
|
|
38
|
+
|
|
39
|
+
function boardAnswer(ctx, w, file, board, now) {
|
|
40
|
+
const state = S.fold(file ? S.readLines(file) : []);
|
|
41
|
+
const wl = S.watchOf(state, board);
|
|
42
|
+
const last = S.lastScan(state, board);
|
|
43
|
+
const cadence = S.cadenceOf(state, board);
|
|
44
|
+
const dc = (settingsOf(ctx) || {}).dayCheck || {};
|
|
45
|
+
const next = file ? S.nextScan(state, board, now, { dayEnd: dc.end || '18:00' }) : null;
|
|
46
|
+
const sug = S.scansOf(state, board, 'suggest');
|
|
47
|
+
const openSug = sug.find((x) => !x.end);
|
|
48
|
+
const lastSug = sug.filter((x) => x.end).pop() || null;
|
|
49
|
+
return {
|
|
50
|
+
cadence, schedule: S.scheduleText(cadence), items: S.itemsOf(state, board).map(itemOut), suggested: wl.suggested.map(itemOut), kept: wl.kept.map(itemOut),
|
|
51
|
+
suggesting: !!openSug, suggest_failed: !!(lastSug && lastSug.ok === false && !wl.suggested.length && !wl.kept.length),
|
|
52
|
+
running: S.openScans(state).some((x) => x.board === board && x.mode === 'scan'),
|
|
53
|
+
last: last ? { scan: last.scan, at: last.start, end: last.end, ok: last.ok, reason: last.reason, found: last.found, skipped: last.skipped, pages: last.pages } : null,
|
|
54
|
+
next: next == null ? null : new Date(next).toISOString(), switched_off: !!scanRun.off(process.env),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function todayAnswer(ctx, w, file, now) {
|
|
59
|
+
const state = S.fold(file ? S.readLines(file) : []);
|
|
60
|
+
const day = D.localDay(now);
|
|
61
|
+
const done = state.scanOrder.map((id) => state.scans.get(id)).filter((x) => x.mode === 'scan' && x.end && x.start && D.localDay(Date.parse(x.start)) === day);
|
|
62
|
+
if (!done.length) return null;
|
|
63
|
+
const fr = boardsOf(ctx, w);
|
|
64
|
+
const names = new Map(((fr && fr.boards) || []).map((b) => [b.id, b.name]));
|
|
65
|
+
let asks = new Map();
|
|
66
|
+
try { asks = fr ? fr.folded.asks : new Map(); } catch { asks = new Map(); }
|
|
67
|
+
const boards = []; const findings = []; const failed = [];
|
|
68
|
+
let pages = 0; let skipped = 0; let latest = done[0];
|
|
69
|
+
for (const sc of done) {
|
|
70
|
+
if (Date.parse(sc.start) >= Date.parse(latest.start)) latest = sc;
|
|
71
|
+
if (!boards.some((b) => b.id === sc.board)) boards.push({ id: sc.board, name: names.get(sc.board) || sc.board });
|
|
72
|
+
pages += sc.pages || 0; skipped += sc.skipped || 0;
|
|
73
|
+
if (sc.ok === false && sc.reason) failed.push({ board: sc.board, reason: sc.reason });
|
|
74
|
+
for (const fid of sc.findings) {
|
|
75
|
+
const f = state.findings.get(fid);
|
|
76
|
+
const ask = asks.get('ask-' + fid.replace(/^f-/, ''));
|
|
77
|
+
findings.push({ id: f.id, ask: ask ? ask.id : null, board: f.board, title: f.title, match: f.match, origin: f.origin, state: ask ? ask.state : null });
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
const matches = findings.filter((f) => f.match).length;
|
|
81
|
+
return { day, at: latest.start, boards, found: findings.length, matches, fresh: findings.length - matches, pages, skipped, findings, failed };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function handle(req, res, url, ctx) {
|
|
85
|
+
const p = url.pathname;
|
|
86
|
+
const send = (r) => { ctx.json(res, r.body, r.status); return true; };
|
|
87
|
+
const sub = p.replace(/^\/api\/scan\/?/, '');
|
|
88
|
+
if (!GETS.includes(sub) && !POSTS.includes(sub)) return send(bad(404, 'no such scan route'));
|
|
89
|
+
|
|
90
|
+
if (GETS.includes(sub)) {
|
|
91
|
+
if (req.method !== 'GET') return send(bad(405, 'GET only'));
|
|
92
|
+
const w = wsOf(ctx, url.searchParams.get('ws'));
|
|
93
|
+
if (!w) return send(bad(404, 'no such workspace'));
|
|
94
|
+
const file = ledgerOf(ctx, w);
|
|
95
|
+
const now = Date.now();
|
|
96
|
+
if (sub === 'today') return send({ status: 200, body: { ok: true, ws: w.id, ledger: !!file, scan: todayAnswer(ctx, w, file, now) } });
|
|
97
|
+
const board = text(url.searchParams.get('board')) || w.id;
|
|
98
|
+
if (!S.ID_RE.test(board)) return send(bad(400, 'board must be a board id'));
|
|
99
|
+
const fr = boardsOf(ctx, w);
|
|
100
|
+
if (fr && !fr.boards.some((b) => b.id === board)) return send(bad(404, `no board ${board.slice(0, 40)} in this workspace`));
|
|
101
|
+
return send({ status: 200, body: { ok: true, ws: w.id, board, ledger: !!file, ...boardAnswer(ctx, w, file, board, now) } });
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (req.method !== 'POST') return send(bad(405, 'POST only'));
|
|
105
|
+
const read = await ctx.readJson(req);
|
|
106
|
+
if (!read.ok) return send(bad(400, 'the body is not JSON'));
|
|
107
|
+
const body = read.body;
|
|
108
|
+
if (req.headers && req.headers['x-pmos-session']) return send(bad(403, 'an agent session cannot change the watch list or the scan cadence'));
|
|
109
|
+
const g = guard.requirePagePress(req, body);
|
|
110
|
+
if (!g.ok) return send(bad(g.status, g.error));
|
|
111
|
+
const w = wsOf(ctx, text(body.ws));
|
|
112
|
+
if (!w) return send(bad(404, 'no such workspace'));
|
|
113
|
+
const file = ledgerOf(ctx, w);
|
|
114
|
+
if (!file) return send(bad(409, 'this repository has no state folder (pmos/state, state or .pmos), so the scan has nowhere to keep its ledger'));
|
|
115
|
+
const board = text(body.board);
|
|
116
|
+
if (!board || !S.ID_RE.test(board)) return send(bad(400, 'board must be a board id'));
|
|
117
|
+
const fr = boardsOf(ctx, w);
|
|
118
|
+
if (fr && !fr.boards.some((b) => b.id === board)) return send(bad(404, `no board ${board.slice(0, 40)} in this workspace`));
|
|
119
|
+
|
|
120
|
+
if (sub === 'cadence') {
|
|
121
|
+
const r = S.setCadence(file, board, text(body.cadence), g.actor);
|
|
122
|
+
if (!r.ok) return send(bad(r.status, r.error));
|
|
123
|
+
return send({ status: 200, body: { ok: true, board: r.board, cadence: r.cadence, schedule: S.scheduleText(r.cadence) } });
|
|
124
|
+
}
|
|
125
|
+
const r = S.watchOp(file, text(body.op), board, { id: body.id, name: body.name, kind: body.kind, why: body.why, restore: body.restore === true }, g.actor);
|
|
126
|
+
if (!r.ok) return send(bad(r.status, r.error));
|
|
127
|
+
return send({ status: 200, body: { ok: true, id: r.id, ...(r.item ? { item: r.item } : {}) } });
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
module.exports = { prefixes: ['/api/scan'], handle, boardAnswer, todayAnswer };
|