create-pmos 0.41.0 → 0.43.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/planning/evals/eval-calibration.md +1 -1
- package/payload/planning/okrs/health-budgets.md +1 -1
- package/payload/state/discoveries.md +1 -1
- package/payload/state/log.md +1 -1
- package/workspace/README.md +8 -0
- package/workspace/bin/pmos.js +6 -1
- package/workspace/guide/index.md +1 -0
- package/workspace/guide/knowledge-graph.md +63 -0
- package/workspace/guide/proactive-engine.md +21 -2
- package/workspace/lib/kg-cli.js +146 -0
- package/workspace/lib/kg-dangling-rules.json +6 -0
- package/workspace/lib/kg.js +266 -0
- package/workspace/lib/prepare-queue.js +76 -0
- package/workspace/lib/proactive-cli.js +57 -1
- package/workspace/lib/proactive.js +3 -2
- package/workspace/package-lock.json +1 -1
- package/workspace/package.json +3 -3
- package/workspace/public/pm/modes-live.js +71 -2
- package/workspace/server.js +101 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-pmos",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.43.0",
|
|
4
4
|
"description": "Scaffold the PMOS portable kit — run the PMOS product-management workflow (initiative → lane-fit spec → contract → build → three gates, with work-type lanes for features, research, maintenance, incidents, and content) entirely inside your own repo, with all state as git-versioned files. Includes `workflow-sweep`, which audits the state every board derives from and separates real defects from work you simply have not done. `create-pmos workspace` also installs + launches the local Terminal Workspace GUI. No database, no network, no telemetry.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"bin": {
|
package/payload/KIT-VERSION
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
kernel-source: pmos@
|
|
2
|
-
assembled: 2026-10-
|
|
1
|
+
kernel-source: pmos@e4f29fe
|
|
2
|
+
assembled: 2026-10-04
|
|
3
3
|
mode: vendor
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
type: Reference
|
|
3
3
|
title: "Eval calibration log"
|
|
4
4
|
description: "Append-only record of every PM override of an evaluator verdict."
|
|
5
|
-
generated: {by: process:create-pmos, at: "2026-10-
|
|
5
|
+
generated: {by: process:create-pmos, at: "2026-10-04T02:41:17Z"}
|
|
6
6
|
status: stable
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
type: Reference
|
|
3
3
|
title: "Health budgets"
|
|
4
4
|
description: "Closed registry of health-budget anchors (D59), edited only by the PM."
|
|
5
|
-
generated: {by: process:create-pmos, at: "2026-10-
|
|
5
|
+
generated: {by: process:create-pmos, at: "2026-10-04T02:41:17Z"}
|
|
6
6
|
status: stable
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
type: Reference
|
|
3
3
|
title: "Discoveries and friction log"
|
|
4
4
|
description: "Append-only record of what runs discovered and where the process rubbed."
|
|
5
|
-
generated: {by: process:create-pmos, at: "2026-10-
|
|
5
|
+
generated: {by: process:create-pmos, at: "2026-10-04T02:41:17Z"}
|
|
6
6
|
status: stable
|
|
7
7
|
---
|
|
8
8
|
|
package/payload/state/log.md
CHANGED
package/workspace/README.md
CHANGED
|
@@ -82,6 +82,14 @@ After the session ends, `GET /api/next-step/summary` returns the files the branc
|
|
|
82
82
|
|
|
83
83
|
The top of Today is a brief: a greeting, the day's calendar, ranked priorities and a worth-asking list. The server does not build it. The built-in skill `skills/morning-brief.skill` runs in an agent session, reads the sources the settings allow (calendar, wispr, mail, jira, confluence; mail is off by default) and the derived list (`pmos proactive today`), and submits one brief with `pmos proactive brief submit --file`. `POST /api/proactive/brief` checks it with `lib/brief.js` and stores `briefs/<date>.json` in the runtime state folder, replacing any earlier brief for the same day; `GET` returns the newest one with a `stale` flag when its date is not today. The check refuses, with the reason and without storing, a wrong shape, an unknown source, a confidence outside 0 to 1, more than 12 items or 20 events, an over-long field, an item that rests on a source not reported as read, a source reported as read that the settings switched off, and a claim of typed scoring when that setting is off. The bucket is derived: under 0.5 confidence is "worth asking". The order is fixed: confidence, then PM-owned, then title. `POST /api/schedules/seed` with `set: "brief"` (one press in Today, or `pmos proactive brief schedule`) makes the daily 06:45 schedule once; as with every schedule, one made after its time of day runs once within a minute. The routes make no model or network call and write only the brief file. Typed scoring is a standing opt-in setting that is off by default. Tests: `proactive.test.js` G, H, I and `pm-r22-test.js` B1-B11. The guide page is `guide/proactive-engine.md`.
|
|
84
84
|
|
|
85
|
+
## Find knowledge without reading whole files (`knowledge-graph`, slice K1)
|
|
86
|
+
|
|
87
|
+
`pmos kg find|card|neighbors|section|why|stats` builds a map of the repository's tracked Markdown files from relations they already write down (links, frontmatter paths, `D<nn>` citations, `initiative_id`, `parent_kr`) and answers from it, so an agent reads one card or one section and not a 130 KB file. `lib/kg.js` is pure; `lib/kg-cli.js` reads the files, keeps the map in the runtime state directory (rebuilt when HEAD or an uncommitted `.md` changes) and writes nothing in the repository. A link to a missing file is listed as dangling and is never an edge; `lib/kg-dangling-rules.json` says which dangling links are history by design. It does not guess, use embeddings, or call a model or the network. `node scripts/kg-bench.js` replays two written strategies over the fixed questions in `planning/evals/knowledge-graph-questions.md`; it is a simulation, and says "NOT REPORTABLE" until three of the questions are the PM's. Guide page: `guide/knowledge-graph.md`. Tests: `kg.test.js`.
|
|
88
|
+
|
|
89
|
+
## Background prepare (`proactive-engine`, slice D3)
|
|
90
|
+
|
|
91
|
+
From a Today item that names an initiative the PM can approve **one plan hash**: the step an agent may do now, with the exact first input. The server queues that approval (`prepare-queue.jsonl` in the runtime state directory) and a 60-second tick starts it through the same `startWork` path as the C1 "go" button — origin `next-step`, the same propose-only rules, the same session budget — only when the switch is on (default off), it is not quiet hours, the budget allows, and the plan hash has not changed. An approval expires after 24 hours, at most 5 can be queued, and a changed plan is skipped, never run. `lib/prepare-queue.js` holds the pure rules (`fold`, `checkRequest`, `canQueue`, `decide`); the routes are `/api/proactive/prepare/{plan,queue,cancel,run}` and `GET /api/proactive/prepare`; `pmos proactive prepare plan|queue|status|cancel|run` is the terminal face. "Run now" ignores quiet hours only. The C1 run record carries `via: 'background'`. Guide page: `guide/proactive-engine.md`. Tests: `proactive-prepare.test.js` (server, CLI, boundary) and `pm-r25-test.js` (browser). Not verified here: a real `claude` session doing the work; the suites use a stand-in agent that records what it was typed.
|
|
92
|
+
|
|
85
93
|
## Scheduled tasks from the terminal (`schedule-cli`)
|
|
86
94
|
|
|
87
95
|
`pmos schedule` and `pmos skill` talk to the running workspace server, so the terminal and the workspace share one schedule store and one validator. Every change is saved the moment the command succeeds. A refused value names what is allowed and changes nothing. With no server running the command says so and exits 1.
|
package/workspace/bin/pmos.js
CHANGED
|
@@ -402,6 +402,10 @@ function doctor() {
|
|
|
402
402
|
code = await require('../lib/proactive-cli').run(process.argv.slice(2), {
|
|
403
403
|
say, call: async (m, p, b) => (port ? api(port, m, p, b) : null) });
|
|
404
404
|
}
|
|
405
|
+
else if (cmd === 'kg') {
|
|
406
|
+
// Reads tracked .md files straight from disk (lib/kg-cli.js); needs no running server.
|
|
407
|
+
code = require('../lib/kg-cli').run(process.argv.slice(2), { say });
|
|
408
|
+
}
|
|
405
409
|
else if (cmd === 'upgrade') {
|
|
406
410
|
// Escape hatch: pull the latest bundled app and force-reinstall over this global copy. Delegates
|
|
407
411
|
// to the scaffolder (which owns the install path) rather than reinstalling itself in place.
|
|
@@ -413,12 +417,13 @@ function doctor() {
|
|
|
413
417
|
if (r.error) { say('✗ could not run ' + npx + ' — ' + r.error.message); code = 1; }
|
|
414
418
|
else code = r.status === null ? 1 : r.status;
|
|
415
419
|
} else if (cmd === 'help' || cmd === '--help' || cmd === '-h') {
|
|
416
|
-
say('usage: pmos [<path>|status|ls|stop|doctor|upgrade|schedule|skill|proactive]');
|
|
420
|
+
say('usage: pmos [<path>|status|ls|stop|doctor|upgrade|schedule|skill|proactive|kg]');
|
|
417
421
|
say(' pmos open the current repo as a workspace (starts the server if needed)');
|
|
418
422
|
say(' pmos <path> open another repo as a workspace');
|
|
419
423
|
say(' pmos schedule … create, edit and run scheduled tasks (pmos schedule --help)');
|
|
420
424
|
say(' pmos skill … list, read, create and edit skills');
|
|
421
425
|
say(' pmos proactive … what is waiting for you, feedback, settings (pmos proactive --help)');
|
|
426
|
+
say(' pmos kg … find a card, a section or a decision\'s citers without reading whole files (pmos kg --help)');
|
|
422
427
|
say(' pmos upgrade reinstall the workspace app from the latest published create-pmos');
|
|
423
428
|
code = 0;
|
|
424
429
|
} else if (cmd === 'up' || cmd === 'start' || cmd === 'open') {
|
package/workspace/guide/index.md
CHANGED
|
@@ -38,3 +38,4 @@ The workspace has three columns. [Layout](layout.md) explains where things open.
|
|
|
38
38
|
- [Scheduled tasks and the command line](scheduled-tasks-and-cli.md): how to make, edit and run scheduled tasks.
|
|
39
39
|
- [Validate a repository](validate-with-typesafe.md): what each kind of document is compared with, how to read the verdict, and what leaves your machine.
|
|
40
40
|
- [Today and the proactive engine](proactive-engine.md): what Today finds, what you can do with an item, the settings, the commands and the limits.
|
|
41
|
+
- [Find knowledge without reading whole files](knowledge-graph.md): what `pmos kg` finds, its commands, how to read its numbers, and its limits.
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
---
|
|
2
|
+
type: Guide
|
|
3
|
+
title: "Guide: find knowledge without reading whole files"
|
|
4
|
+
timestamp: 2026-10-03
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Guide: find knowledge without reading whole files
|
|
8
|
+
|
|
9
|
+
`pmos kg` reads the Markdown files of a repository and builds a map of how they point at each other. You use the map to find one card, one section or one decision's readers. You do not need to open a long file.
|
|
10
|
+
|
|
11
|
+
## What it is, and what it is not
|
|
12
|
+
|
|
13
|
+
A **card** is a short summary of one file: its title, its type, its `description` (or its first paragraph) and its size in tokens. A **section** is the lines under one heading.
|
|
14
|
+
|
|
15
|
+
The map has an **edge** only where a file states a relation:
|
|
16
|
+
|
|
17
|
+
| Edge | What states it |
|
|
18
|
+
|---|---|
|
|
19
|
+
| link | A Markdown link to a tracked file. |
|
|
20
|
+
| frontmatter | A path in `prd_path`, `rubric_path`, `explainer_path` or `sources`. |
|
|
21
|
+
| supersedes, amended_by, cites | A document that chooses to carry one of these keys. |
|
|
22
|
+
| initiative | An `initiative_id` that names an initiative record. |
|
|
23
|
+
| anchor | A `parent_kr` of `health:<id>` or a KR id. |
|
|
24
|
+
| cites (mention) | A `D<nn>` in the text, such as `D65`, with two or more digits. |
|
|
25
|
+
|
|
26
|
+
A link that points at a missing file is **dangling**. It is listed and never becomes an edge. A link, a `D<nn>` or a heading inside a code block or a code span is not read.
|
|
27
|
+
|
|
28
|
+
It does not guess. A file that talks about a topic without a link, a path or a `D<nn>` is not connected to it. It does not use embeddings or a model, and it does not use the network.
|
|
29
|
+
|
|
30
|
+
## Commands
|
|
31
|
+
|
|
32
|
+
| Command | What it does |
|
|
33
|
+
|---|---|
|
|
34
|
+
| `pmos kg find <term>` | Lists cards whose id, title, headings or card contain the term, with the token size of each file. |
|
|
35
|
+
| `pmos kg card <id>` | Prints one card and the headings of the file with their line ranges. |
|
|
36
|
+
| `pmos kg neighbors <id> [--type T] [--depth 1\|2]` | Lists the files that link to this file or that it links to, with the type and the basis of each edge. |
|
|
37
|
+
| `pmos kg section <path>#<heading>` | Prints only that section. `pmos kg section <path> <from>-<to>` prints a line range. |
|
|
38
|
+
| `pmos kg why <D-id>` | Prints the decision's ADR view, its line in the amendment log of `DECISIONS.md`, and the files that cite it, grouped by kind. |
|
|
39
|
+
| `pmos kg stats` | Prints counts: files, edges by type, written and derived cards, files with no incoming link, and the dangling links. |
|
|
40
|
+
|
|
41
|
+
Add `--max-tokens N` to stop printing at a result boundary. The command then says how many results it did not show.
|
|
42
|
+
|
|
43
|
+
An unknown file, heading or decision stops with a message that says what was not found. The exit code is 1.
|
|
44
|
+
|
|
45
|
+
## What the numbers mean
|
|
46
|
+
|
|
47
|
+
- A token count is the number of characters divided by four. It is an estimate.
|
|
48
|
+
- `stats` splits dangling links into **history by design** and **needs a decision**. The rules are in `workspace/lib/kg-dangling-rules.json`. Read the file and change it if you disagree. A link in `okf/log.md` or in a decision about a removed document is history.
|
|
49
|
+
- A file with no incoming link is not an error. It means no other file links to it.
|
|
50
|
+
- A **derived** card comes from the first paragraph of a file that has no `description`.
|
|
51
|
+
|
|
52
|
+
## Where the map is kept
|
|
53
|
+
|
|
54
|
+
The command reads the tracked `.md` files of the Git repository you run it in. It keeps the map in the runtime state directory of the workspace, never in the repository. It builds the map again when the Git HEAD changes or when you edit a tracked `.md` file that is not yet committed. It does not use the cache for any other change.
|
|
55
|
+
|
|
56
|
+
The command does not edit a file in the repository.
|
|
57
|
+
|
|
58
|
+
## Limits
|
|
59
|
+
|
|
60
|
+
- It finds only relations that are written in the files.
|
|
61
|
+
- It reads Markdown files. It does not read the ledgers in `pmos/state/`, a connector, or a session.
|
|
62
|
+
- `find` matches the words you type. It does not know that two different words mean the same thing.
|
|
63
|
+
- The benchmark in `scripts/kg-bench.js` replays two written strategies over fixed questions. It is a simulation. It does not measure the tokens of a real agent session.
|
|
@@ -22,6 +22,23 @@ The top of Today is the morning brief: a greeting, the day's calendar, the prior
|
|
|
22
22
|
- A brief is refused, with the reason and with nothing stored, when it has a wrong shape, an unknown source, a confidence outside 0 to 1, more than 12 items or 20 events, or a text field that is too long.
|
|
23
23
|
- The brief is stored in the workspace state folder on this computer. It is not committed.
|
|
24
24
|
|
|
25
|
+
## Background preparation
|
|
26
|
+
|
|
27
|
+
From a Today item that names an initiative, you can ask for the plan and approve it. The workspace then starts that session itself, later. It uses the same path as the next-step go button, so the plan, the prompt, the propose-only rules and the session budget are the same.
|
|
28
|
+
|
|
29
|
+
- Press **prepare in the background** on an item. Today shows the step an agent session may work on now, where the work would happen, the budget and the exact first input. Press **queue it** to approve that plan.
|
|
30
|
+
- Background prepare is off by default. Turn it on in the settings. When it is off, nothing can be queued and nothing queued is started. A queued entry waits while it is off. It is not dropped.
|
|
31
|
+
- The approval is for one plan. If the plan has changed when the workspace is ready to start, the entry is skipped and the reason is shown. Queue it again to approve the new plan.
|
|
32
|
+
- An approval expires after 24 hours. An expired entry is never started.
|
|
33
|
+
- The workspace starts an entry only when the switch is on, it is not quiet hours, the session budget allows it, and the plan is the one you approved. If the budget is full, the entry waits. Entries start oldest first. At most 5 entries wait at once.
|
|
34
|
+
- **run the queue now** looks at the queue immediately. It ignores quiet hours and nothing else.
|
|
35
|
+
- A queued entry can be cancelled. A cancelled entry is never started.
|
|
36
|
+
- The session starts with no profile, so it names no outside source, and with typed scoring off. It commits only to a local initiative branch or writes drafts. It does not push, open a pull request, send, post, accept, approve a contract or merge. The contract, acceptance and open items are your steps and cannot be queued.
|
|
37
|
+
- A started session counts in the same session budget as a next-step go. Switch to it from Today, and review it from the initiative as for any prepared step.
|
|
38
|
+
- It runs only while the workspace server runs. The queue is stored in the workspace state folder on this computer.
|
|
39
|
+
|
|
40
|
+
The states of an entry are queued, started, skipped, expired and cancelled.
|
|
41
|
+
|
|
25
42
|
## What it shows
|
|
26
43
|
|
|
27
44
|
Today scans for five conditions. Each one becomes one item with the reason and the evidence.
|
|
@@ -52,6 +69,7 @@ A verdict never changes a status, a lifecycle record or an acceptance. In this r
|
|
|
52
69
|
| engine on | Off stops automatic scans. The scan now button still works. |
|
|
53
70
|
| watch: needs, returned, review, schedule, stalled | Turn one trigger off. |
|
|
54
71
|
| brief sources | One switch for each of calendar, wispr, mail, jira and confluence. |
|
|
72
|
+
| background prepare | Off by default. On lets the workspace start a session for a plan you approved. |
|
|
55
73
|
| typed scoring | Off by default. On lets the skill send titles and metadata to the Typesafe service. |
|
|
56
74
|
| name | Used in the greeting. |
|
|
57
75
|
| stalled after N days | The stalled threshold, from 1 to 90 days. |
|
|
@@ -62,14 +80,15 @@ A verdict never changes a status, a lifecycle record or an acceptance. In this r
|
|
|
62
80
|
|
|
63
81
|
- `pmos proactive today` lists the items.
|
|
64
82
|
- `pmos proactive scan` scans now.
|
|
65
|
-
- `pmos proactive settings` shows the settings. Add `--enabled`, `--trigger NAME=on|off`, `--stalled-days`, `--quiet HH:MM-HH:MM|none`, `--pause-learning`, `--brief-source NAME=on|off`, `--brief-scoring on|off` or `--name` to change one.
|
|
83
|
+
- `pmos proactive settings` shows the settings. Add `--enabled`, `--trigger NAME=on|off`, `--stalled-days`, `--quiet HH:MM-HH:MM|none`, `--pause-learning`, `--background-prepare on|off`, `--brief-source NAME=on|off`, `--brief-scoring on|off` or `--name` to change one.
|
|
66
84
|
- `pmos proactive brief` shows today's brief. `pmos proactive brief submit --file PATH` submits one. `pmos proactive brief schedule` makes the 06:45 schedule for the current repository.
|
|
85
|
+
- `pmos proactive prepare` shows the queue. `pmos proactive prepare plan --initiative ID` shows a plan and its hash. `pmos proactive prepare queue --initiative ID --item ITEM --sha HASH` approves it. `pmos proactive prepare cancel ID` cancels an entry. `pmos proactive prepare run` looks at the queue now.
|
|
67
86
|
- `pmos proactive feedback <id> useful|not-useful|wrong-priority|opened|dismissed` records feedback. Use `--missed "text"` for a missed item.
|
|
68
87
|
|
|
69
88
|
## Limits
|
|
70
89
|
|
|
71
90
|
- It runs only while the workspace server runs. It does not catch up for time the server was off. A sleeping computer gets no scan.
|
|
72
91
|
- The items from the records come from this computer. The workspace itself does not read your mail or your calendar. Only the morning-brief session does, and only the sources you switched on.
|
|
73
|
-
- It does not start a session, push, send, post, accept or merge. It only shows items and records your feedback.
|
|
92
|
+
- It does not start a session, push, send, post, accept or merge. It only shows items and records your feedback. The one exception is background preparation, which starts a session only for a plan you approved, as described above.
|
|
74
93
|
- An unreadable repository is listed as not read. An empty list is not proof that nothing waits there.
|
|
75
94
|
- The records stay in the workspace state folder on this computer. They are not committed.
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* kg-cli.js — `pmos kg …`: query the repository's knowledge graph from the terminal (knowledge-graph K1).
|
|
3
|
+
*
|
|
4
|
+
* pmos kg find <term> cards whose id, title, headings or card match, with their token cost
|
|
5
|
+
* pmos kg card <id> one card
|
|
6
|
+
* pmos kg neighbors <id> [--type T] [--depth 1|2]
|
|
7
|
+
* pmos kg section <path>#<heading> the lines of one section
|
|
8
|
+
* pmos kg section <path> <from>-<to> a line range
|
|
9
|
+
* pmos kg why <D-id> the decision's ADR view, its amendment-log line, and the files citing it
|
|
10
|
+
* pmos kg stats counts, not claims
|
|
11
|
+
* --max-tokens N stop at a card boundary and say how many results were not shown
|
|
12
|
+
*
|
|
13
|
+
* Reads the repository's tracked .md files straight from disk — no server, no model, no network. The graph is cached in
|
|
14
|
+
* the runtime state directory (never in the repository), keyed by git HEAD plus the modification times of uncommitted
|
|
15
|
+
* .md files, so a stale graph is rebuilt and a pull request never carries a graph diff. The only write is that cache.
|
|
16
|
+
*/
|
|
17
|
+
const fs = require('fs');
|
|
18
|
+
const os = require('os');
|
|
19
|
+
const path = require('path');
|
|
20
|
+
const crypto = require('crypto');
|
|
21
|
+
const { execFileSync } = require('child_process');
|
|
22
|
+
const Kg = require('./kg');
|
|
23
|
+
const { defaultRoot } = require('./state-root');
|
|
24
|
+
const { parseArgs } = require('./schedule-cli');
|
|
25
|
+
|
|
26
|
+
const COMMANDS = ['find', 'card', 'neighbors', 'section', 'why', 'stats'];
|
|
27
|
+
const RULES_FILE = path.join(__dirname, 'kg-dangling-rules.json');
|
|
28
|
+
const git = (cwd, args) => execFileSync('git', args, { cwd, encoding: 'utf8', maxBuffer: 256 * 1024 * 1024, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
29
|
+
|
|
30
|
+
function repoRoot(cwd) { return git(cwd, ['rev-parse', '--show-toplevel']).trim(); }
|
|
31
|
+
|
|
32
|
+
function loadFiles(root) {
|
|
33
|
+
const out = []; const unreadable = [];
|
|
34
|
+
for (const rel of git(root, ['ls-files', '-z', '*.md']).split('\0').filter(Boolean)) {
|
|
35
|
+
try { out.push({ path: rel, text: fs.readFileSync(path.join(root, rel), 'utf8') }); }
|
|
36
|
+
catch (e) { unreadable.push({ path: rel, error: e.code || e.message }); }
|
|
37
|
+
}
|
|
38
|
+
return { files: out, unreadable };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function cacheKey(root) {
|
|
42
|
+
const head = git(root, ['rev-parse', 'HEAD']).trim();
|
|
43
|
+
const dirty = git(root, ['status', '--porcelain', '-z', '--untracked-files=no']).split('\0').filter(Boolean)
|
|
44
|
+
.map((l) => l.slice(3)).filter((p) => /\.md$/.test(p)).sort()
|
|
45
|
+
.map((p) => { try { return p + ':' + fs.statSync(path.join(root, p)).mtimeMs; } catch { return p + ':gone'; } });
|
|
46
|
+
return crypto.createHash('sha1').update(head + '\n' + dirty.join('\n')).digest('hex');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function graphFor(root, { stateDir = defaultRoot() } = {}) {
|
|
50
|
+
const dir = path.join(stateDir, 'kg');
|
|
51
|
+
const file = path.join(dir, crypto.createHash('sha1').update(root).digest('hex').slice(0, 12) + '.json');
|
|
52
|
+
const key = cacheKey(root);
|
|
53
|
+
try { const c = JSON.parse(fs.readFileSync(file, 'utf8')); if (c.key === key) return { graph: c.graph, unreadable: c.unreadable, cached: true }; } catch { /* rebuild */ }
|
|
54
|
+
const { files, unreadable } = loadFiles(root);
|
|
55
|
+
const graph = Kg.build(files);
|
|
56
|
+
try { fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(file, JSON.stringify({ key, graph, unreadable })); } catch { /* the cache is an optimisation, never required */ }
|
|
57
|
+
return { graph, unreadable, cached: false };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function run(argv, { say, cwd = process.cwd(), stateDir } = {}) {
|
|
61
|
+
const [group, verb, ...rest] = argv;
|
|
62
|
+
const { pos, flags } = parseArgs(rest);
|
|
63
|
+
const fail = (m) => { say('✗ ' + m); return 1; };
|
|
64
|
+
if (group !== 'kg' || !verb || flags.help || verb === 'help') { say(`usage: pmos kg <${COMMANDS.join('|')}> … [--max-tokens N]`); return verb ? 0 : 1; }
|
|
65
|
+
if (!COMMANDS.includes(verb)) return fail(`unknown command "${verb}" — use ${COMMANDS.join(', ')}`);
|
|
66
|
+
let root;
|
|
67
|
+
try { root = repoRoot(cwd); } catch { return fail('this is not a git repository, so there are no tracked files to read'); }
|
|
68
|
+
const { graph, unreadable, cached } = graphFor(root, { stateDir });
|
|
69
|
+
const budget = flags['max-tokens'] === undefined ? Infinity : Number(flags['max-tokens']);
|
|
70
|
+
if (!(budget > 0)) return fail('--max-tokens takes a positive number');
|
|
71
|
+
for (const u of unreadable || []) say(`not read — ${u.path}: ${u.error}`);
|
|
72
|
+
|
|
73
|
+
/* print blocks until the budget is spent, stopping at a block boundary; say how many were not shown */
|
|
74
|
+
const emit = (blocks) => {
|
|
75
|
+
let used = 0; let shown = 0;
|
|
76
|
+
for (const b of blocks) {
|
|
77
|
+
const cost = Kg.estTokens(b);
|
|
78
|
+
if (shown && used + cost > budget) break;
|
|
79
|
+
say(b); used += cost; shown++;
|
|
80
|
+
}
|
|
81
|
+
if (shown < blocks.length) say(`… ${blocks.length - shown} more result(s) not shown (--max-tokens ${budget}). ~${used} tokens printed.`);
|
|
82
|
+
else if (budget !== Infinity) say(`~${used} tokens printed.`);
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
if (verb === 'find') {
|
|
86
|
+
if (!pos[0]) return fail('find needs a term');
|
|
87
|
+
const hits = Kg.find(graph, pos.join(' '), { limit: 50 });
|
|
88
|
+
if (!hits.length) { say(`nothing matches "${pos.join(' ')}"`); return 0; }
|
|
89
|
+
emit(hits.map((h) => `${Kg.cardText(h.node)}\n matched: ${h.where.join(', ')} · linked from ${h.indegree} file(s)`));
|
|
90
|
+
return 0;
|
|
91
|
+
}
|
|
92
|
+
if (verb === 'card') {
|
|
93
|
+
const n = Kg.nodeById(graph, pos[0]);
|
|
94
|
+
if (!n) return fail(`no file "${pos[0] || ''}" in the graph`);
|
|
95
|
+
say(Kg.cardText(n));
|
|
96
|
+
for (const h of n.headings) say(`${' '.repeat(h.level - 1)}${h.text} (lines ${h.start}-${h.end})`);
|
|
97
|
+
return 0;
|
|
98
|
+
}
|
|
99
|
+
if (verb === 'neighbors') {
|
|
100
|
+
const depth = flags.depth === undefined ? 1 : Number(flags.depth);
|
|
101
|
+
if (![1, 2].includes(depth)) return fail('--depth takes 1 or 2');
|
|
102
|
+
const rows = Kg.neighbors(graph, pos[0], { type: flags.type || null, depth });
|
|
103
|
+
if (!rows) return fail(`no file "${pos[0] || ''}" in the graph`);
|
|
104
|
+
if (!rows.length) { say('no neighbours' + (flags.type ? ` of type ${flags.type}` : '')); return 0; }
|
|
105
|
+
emit(rows.map((r) => `${r.direction === 'out' ? '→' : '←'} ${r.via} (${r.basis}${r.depth > 1 ? ', depth ' + r.depth : ''}) ${Kg.cardText(Kg.nodeById(graph, r.id))}`));
|
|
106
|
+
return 0;
|
|
107
|
+
}
|
|
108
|
+
if (verb === 'section') {
|
|
109
|
+
let id = pos[0] || ''; let spec = pos[1] || '';
|
|
110
|
+
const hash = id.indexOf('#');
|
|
111
|
+
if (hash > 0) { spec = id.slice(hash); id = id.slice(0, hash); }
|
|
112
|
+
const n = Kg.nodeById(graph, id);
|
|
113
|
+
if (!n) return fail(`no file "${id}" in the graph`);
|
|
114
|
+
if (!spec) return fail('section needs #heading or from-to');
|
|
115
|
+
const s = Kg.section(n, fs.readFileSync(path.join(root, id), 'utf8'), spec);
|
|
116
|
+
if (!s) return fail(`no section "${spec}" in ${id} — run: pmos kg card ${id}`);
|
|
117
|
+
say(`${id} lines ${s.from}-${s.to} (~${Kg.estTokens(s.text)} tokens of ${n.tokens_est})`);
|
|
118
|
+
say(s.text);
|
|
119
|
+
return 0;
|
|
120
|
+
}
|
|
121
|
+
if (verb === 'why') {
|
|
122
|
+
const w = Kg.why(graph, pos[0]);
|
|
123
|
+
if (!w) return fail(`no decision "${pos[0] || ''}" — the ADR views are okf/products/pmos/adr/dNN-*.md`);
|
|
124
|
+
say(Kg.cardText(w.adr));
|
|
125
|
+
say(w.amendmentLog ? `amendment log: ${w.amendmentLog.path} line ${w.amendmentLog.line}` : 'amendment log: no "Added **D…**" line found');
|
|
126
|
+
const blocks = [];
|
|
127
|
+
for (const [kind, rows] of Object.entries(w.citedBy).sort()) blocks.push(`${kind} (${rows.length}):\n` + rows.map((r) => ` ${r.id} [${r.via.join('+')}, ${r.basis}]`).join('\n'));
|
|
128
|
+
emit(blocks.length ? blocks : ['no file cites this decision']);
|
|
129
|
+
return 0;
|
|
130
|
+
}
|
|
131
|
+
if (verb === 'stats') {
|
|
132
|
+
const rules = JSON.parse(fs.readFileSync(RULES_FILE, 'utf8'));
|
|
133
|
+
const s = Kg.stats(graph, rules);
|
|
134
|
+
say(`${s.nodes} files, ${s.edges} edges (${s.byBasis.stated} stated, ${s.byBasis.mention} mention) — graph ${cached ? 'from cache' : 'rebuilt'}`);
|
|
135
|
+
say('edges by type: ' + Object.entries(s.byType).sort().map(([k, v]) => `${k} ${v}`).join(', '));
|
|
136
|
+
say(`cards: ${s.cardsWritten} written (description), ${s.cardsDerived} derived from the first paragraph, ${s.noCard} none`);
|
|
137
|
+
say(`${s.noType} file(s) have no type · ${s.noIncoming} have no incoming link (index and log files excluded)`);
|
|
138
|
+
say(`dangling links: ${s.dangling} — ${s.danglingHistory} history by design (${path.relative(root, RULES_FILE) || RULES_FILE}), ${s.danglingNeedsDecision} need a decision`);
|
|
139
|
+
for (const d of s.needsDecision) say(` ${d.from} → ${d.to}`);
|
|
140
|
+
say('Only relations written in the files are found. Token counts are characters ÷ 4, an estimate.');
|
|
141
|
+
return 0;
|
|
142
|
+
}
|
|
143
|
+
return 0;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
module.exports = { run, graphFor, loadFiles, cacheKey, COMMANDS };
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
[
|
|
2
|
+
{ "from": "^okf/log\\.md$", "why": "the OKF log records references to documents that were removed on purpose" },
|
|
3
|
+
{ "from": "^okf/products/pmos/", "to": "(mcp-tools|data-model|references/metrics/v_)", "why": "D66 deleted the hosted-backend reference documents; the text that cites them is history" },
|
|
4
|
+
{ "from": "/blind-plants/", "why": "a blind-plant specimen is planted with broken references on purpose" },
|
|
5
|
+
{ "to": "^/planning/prd/x\\.md$", "why": "an example path in an explainer, not a reference" }
|
|
6
|
+
]
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* kg.js — the repository's knowledge graph, derived from what its files already state (knowledge-graph K1).
|
|
3
|
+
*
|
|
4
|
+
* Pure: takes [{path, text}] for the tracked Markdown files and returns nodes, edges and the dangling links.
|
|
5
|
+
* No filesystem, no git, no network, no model. Same input → byte-identical output.
|
|
6
|
+
*
|
|
7
|
+
* WHAT IS A NODE. One tracked .md file. It carries a CARD (the frontmatter `description`, else the first paragraph,
|
|
8
|
+
* cut at 240 characters), a token estimate (characters ÷ 4 — an estimate, not a measurement), and its headings with
|
|
9
|
+
* line ranges so a caller can read ONE section and not the file.
|
|
10
|
+
*
|
|
11
|
+
* WHAT IS AN EDGE. Only a relation a file writes down:
|
|
12
|
+
* link a Markdown link to a tracked file basis stated
|
|
13
|
+
* frontmatter a path in prd_path / rubric_path / explainer_path / sources basis stated
|
|
14
|
+
* supersedes · amended_by · cites (typed keys a document opts into) basis stated
|
|
15
|
+
* initiative initiative_id (frontmatter or an identity bullet) → its record basis stated
|
|
16
|
+
* anchor parent_kr / parent_KR `health:<id>` or a KR id → the file naming it basis stated
|
|
17
|
+
* cites a `D<nn>` mention in prose → that decision's ADR view basis mention
|
|
18
|
+
* A link to a file that does not exist is a DANGLING item, never an edge. A link-like string inside a code fence or
|
|
19
|
+
* an inline code span is not a relation and makes no edge. Nothing is inferred by similarity; workspace/lib/
|
|
20
|
+
* related-docs.js took the same position for alignment, for the same reason.
|
|
21
|
+
*
|
|
22
|
+
* `D<nn>` needs two or more digits on purpose: slice labels such as "D1" and "D3" in the proactive-engine documents
|
|
23
|
+
* would otherwise cite decisions D01 and D03 that they never mention.
|
|
24
|
+
*/
|
|
25
|
+
(function (root, factory) {
|
|
26
|
+
const api = factory();
|
|
27
|
+
if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
|
28
|
+
else root.Kg = api;
|
|
29
|
+
})(typeof self !== 'undefined' ? self : this, function () {
|
|
30
|
+
|
|
31
|
+
const CARD_MAX = 240;
|
|
32
|
+
const ADR_DIR = 'okf/products/pmos/adr/';
|
|
33
|
+
const PATH_KEYS = ['prd_path', 'rubric_path', 'explainer_path', 'sources'];
|
|
34
|
+
const TYPED_KEYS = ['supersedes', 'amended_by', 'cites'];
|
|
35
|
+
const estTokens = (s) => Math.ceil(String(s || '').length / 4);
|
|
36
|
+
|
|
37
|
+
/* Replace fenced code blocks and inline code spans with spaces of the same length, keeping every newline, so line
|
|
38
|
+
* numbers stay true and nothing inside code is read as a link, a citation or a heading. */
|
|
39
|
+
function stripCode(text) {
|
|
40
|
+
const blank = (s) => s.replace(/[^\n]/g, ' ');
|
|
41
|
+
let out = '';
|
|
42
|
+
let fence = null;
|
|
43
|
+
for (const line of String(text || '').split('\n')) {
|
|
44
|
+
const m = /^\s{0,3}(`{3,}|~{3,})/.exec(line);
|
|
45
|
+
if (fence) { out += blank(line) + '\n'; if (m && m[1][0] === fence[0] && m[1].length >= fence.length) fence = null; continue; }
|
|
46
|
+
if (m) { fence = m[1]; out += blank(line) + '\n'; continue; }
|
|
47
|
+
out += line.replace(/(`+)[^`\n]*?\1/g, (s) => blank(s)) + '\n';
|
|
48
|
+
}
|
|
49
|
+
return out.slice(0, -1);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/* Frontmatter values come from the reader workspace/lib/related-docs.js already ships (one-line `key: value`);
|
|
53
|
+
* the body start line is the only thing computed here. */
|
|
54
|
+
const readFm = (typeof require === 'function') ? require('./related-docs').frontmatter : null;
|
|
55
|
+
function frontmatter(text) {
|
|
56
|
+
const m = /^---\n([\s\S]*?)\n---(?:\n|$)/.exec(String(text || ''));
|
|
57
|
+
if (!m) return { fm: {}, bodyStart: 1 };
|
|
58
|
+
return { fm: readFm ? readFm(text) : {}, bodyStart: m[0].split('\n').length };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function headingsOf(stripped, bodyStart) {
|
|
62
|
+
const lines = stripped.split('\n');
|
|
63
|
+
const found = [];
|
|
64
|
+
for (let i = bodyStart - 1; i < lines.length; i++) {
|
|
65
|
+
const m = /^(#{1,6})\s+(.+?)\s*#*\s*$/.exec(lines[i]);
|
|
66
|
+
if (m) found.push({ level: m[1].length, text: m[2].trim(), start: i + 1 });
|
|
67
|
+
}
|
|
68
|
+
for (let k = 0; k < found.length; k++) {
|
|
69
|
+
let end = lines.length;
|
|
70
|
+
for (let j = k + 1; j < found.length; j++) if (found[j].level <= found[k].level) { end = found[j].start - 1; break; }
|
|
71
|
+
found[k].end = end;
|
|
72
|
+
}
|
|
73
|
+
return found;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function cardOf(fm, stripped, bodyStart) {
|
|
77
|
+
if (fm.description) return fm.description.slice(0, CARD_MAX);
|
|
78
|
+
const lines = stripped.split('\n').slice(bodyStart - 1);
|
|
79
|
+
const para = [];
|
|
80
|
+
for (const raw of lines) {
|
|
81
|
+
const l = raw.trim();
|
|
82
|
+
if (!l) { if (para.length) break; continue; }
|
|
83
|
+
if (/^(#|\||>|---|[-*]\s|\d+\.\s|<)/.test(l)) { if (para.length) break; continue; }
|
|
84
|
+
para.push(l);
|
|
85
|
+
}
|
|
86
|
+
return para.join(' ').replace(/\[([^\]]+)\]\([^)]*\)/g, '$1').slice(0, CARD_MAX);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const norm = (from, target) => {
|
|
90
|
+
const t = target.replace(/^<|>$/g, '');
|
|
91
|
+
const parts = (t.startsWith('/') ? t.slice(1) : from.replace(/[^/]*$/, '') + t).split('/');
|
|
92
|
+
const out = [];
|
|
93
|
+
for (const p of parts) { if (p === '.' || p === '') continue; if (p === '..') out.pop(); else out.push(p); }
|
|
94
|
+
return out.join('/');
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const pathsIn = (value) => [...String(value || '').matchAll(/[\w@./-]+\.md\b/g)].map((m) => m[0].replace(/^\//, ''));
|
|
98
|
+
|
|
99
|
+
function build(files) {
|
|
100
|
+
const list = (files || []).filter((f) => f && typeof f.path === 'string').sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
|
|
101
|
+
const have = new Set(list.map((f) => f.path));
|
|
102
|
+
const adrFor = new Map();
|
|
103
|
+
for (const p of have) { const m = /^okf\/products\/pmos\/adr\/d(\d{2,3})-/.exec(p); if (m) adrFor.set('D' + m[1], p); }
|
|
104
|
+
const krFile = new Map();
|
|
105
|
+
for (const f of list) if (f.path.startsWith('planning/okrs/')) for (const m of f.text.matchAll(/\bO\d+\.KR\d+\b/g)) if (!krFile.has(m[0])) krFile.set(m[0], f.path);
|
|
106
|
+
const budgets = have.has('planning/okrs/health-budgets.md') ? 'planning/okrs/health-budgets.md' : null;
|
|
107
|
+
|
|
108
|
+
const nodes = [];
|
|
109
|
+
const edges = new Map();
|
|
110
|
+
const dangling = [];
|
|
111
|
+
const meta = { decisionLines: {} };
|
|
112
|
+
const addEdge = (from, to, type, basis) => {
|
|
113
|
+
if (from === to || !have.has(to)) return;
|
|
114
|
+
const key = from + '\u0000' + to + '\u0000' + type;
|
|
115
|
+
const cur = edges.get(key);
|
|
116
|
+
if (!cur) edges.set(key, { from, to, type, basis });
|
|
117
|
+
else if (cur.basis === 'mention' && basis === 'stated') cur.basis = 'stated';
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
for (const f of list) {
|
|
121
|
+
const text = String(f.text || '');
|
|
122
|
+
const { fm, bodyStart } = frontmatter(text);
|
|
123
|
+
const stripped = stripCode(text);
|
|
124
|
+
const body = stripped;
|
|
125
|
+
const heads = headingsOf(stripped, bodyStart);
|
|
126
|
+
const title = fm.title || (heads.find((h) => h.level === 1) || {}).text || f.path;
|
|
127
|
+
nodes.push({
|
|
128
|
+
id: f.path, title, type: fm.type || null, card: cardOf(fm, stripped, bodyStart),
|
|
129
|
+
described: !!fm.description, tokens_est: estTokens(text), lines: text.split('\n').length,
|
|
130
|
+
headings: heads.map((h) => ({ level: h.level, text: h.text, start: h.start, end: h.end })),
|
|
131
|
+
status: fm.status || null, stale_after: fm.stale_after || null,
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
for (const m of body.matchAll(/\]\(([^)\s]+?)(?:#[^)]*)?\)/g)) {
|
|
135
|
+
const raw = m[1];
|
|
136
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(raw) || raw.startsWith('#')) continue;
|
|
137
|
+
if (!/\.md$/i.test(raw)) continue;
|
|
138
|
+
const to = norm(f.path, raw);
|
|
139
|
+
if (have.has(to)) addEdge(f.path, to, 'link', 'stated');
|
|
140
|
+
else dangling.push({ from: f.path, to: raw });
|
|
141
|
+
}
|
|
142
|
+
for (const m of body.matchAll(/\bD(\d{2,3})\b/g)) {
|
|
143
|
+
const to = adrFor.get('D' + m[1]);
|
|
144
|
+
if (to) addEdge(f.path, to, 'cites', 'mention');
|
|
145
|
+
}
|
|
146
|
+
for (const k of PATH_KEYS) for (const p of pathsIn(fm[k])) addEdge(f.path, p, 'frontmatter', 'stated');
|
|
147
|
+
for (const k of TYPED_KEYS) for (const p of pathsIn(fm[k])) addEdge(f.path, p, k, 'stated');
|
|
148
|
+
const ini = fm.initiative_id || ((/^[-*]?\s*initiative_id:\s*([\w.-]+)/m.exec(body) || [])[1]);
|
|
149
|
+
if (ini) addEdge(f.path, 'planning/initiatives/' + ini + '.md', 'initiative', 'stated');
|
|
150
|
+
const anchor = fm.parent_kr || fm.parent_KR || ((/^[-*]?\s*parent_kr:\s*([\w:.-]+)/im.exec(body) || [])[1]);
|
|
151
|
+
if (anchor) {
|
|
152
|
+
if (/^health:/.test(anchor) && budgets) addEdge(f.path, budgets, 'anchor', 'stated');
|
|
153
|
+
else if (krFile.has(anchor)) addEdge(f.path, krFile.get(anchor), 'anchor', 'stated');
|
|
154
|
+
}
|
|
155
|
+
if (f.path === 'DECISIONS.md') {
|
|
156
|
+
const lines = body.split('\n');
|
|
157
|
+
for (let i = 0; i < lines.length; i++) { const m = /\bAdded \*\*(D\d{2,3})\*\*/.exec(lines[i]); if (m && !(m[1] in meta.decisionLines)) meta.decisionLines[m[1]] = i + 1; }
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
const e = [...edges.values()].sort((a, b) => (a.from + a.to + a.type < b.from + b.to + b.type ? -1 : 1));
|
|
161
|
+
dangling.sort((a, b) => (a.from + a.to < b.from + b.to ? -1 : a.from + a.to > b.from + b.to ? 1 : 0));
|
|
162
|
+
return { nodes, edges: e, dangling, meta };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/* Split dangling links into "history by design" and "needs a decision" by a readable rule list:
|
|
166
|
+
* [{from?: regex, to?: regex, why: string}] — a rule matches when every field it names matches. */
|
|
167
|
+
function splitDangling(graph, rules) {
|
|
168
|
+
const compiled = (rules || []).map((r) => ({ from: r.from ? new RegExp(r.from) : null, to: r.to ? new RegExp(r.to) : null, why: r.why || '' }));
|
|
169
|
+
const history = []; const needs = [];
|
|
170
|
+
for (const d of graph.dangling) {
|
|
171
|
+
const r = compiled.find((x) => (x.from || x.to) && (!x.from || x.from.test(d.from)) && (!x.to || x.to.test(d.to)));
|
|
172
|
+
if (r) history.push({ ...d, why: r.why }); else needs.push(d);
|
|
173
|
+
}
|
|
174
|
+
return { history, needs };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function stats(graph, rules) {
|
|
178
|
+
const byType = {}; const byBasis = { stated: 0, mention: 0 };
|
|
179
|
+
for (const e of graph.edges) { byType[e.type] = (byType[e.type] || 0) + 1; byBasis[e.basis]++; }
|
|
180
|
+
const inbound = new Set(graph.edges.map((e) => e.to));
|
|
181
|
+
const orphans = graph.nodes.filter((n) => !inbound.has(n.id) && !/(^|\/)(index|log)\.md$/.test(n.id)).length;
|
|
182
|
+
const split = splitDangling(graph, rules);
|
|
183
|
+
return {
|
|
184
|
+
nodes: graph.nodes.length, edges: graph.edges.length, byType, byBasis,
|
|
185
|
+
cardsWritten: graph.nodes.filter((n) => n.described).length,
|
|
186
|
+
cardsDerived: graph.nodes.filter((n) => !n.described && n.card).length,
|
|
187
|
+
noCard: graph.nodes.filter((n) => !n.card).length,
|
|
188
|
+
noType: graph.nodes.filter((n) => !n.type).length,
|
|
189
|
+
noIncoming: orphans, dangling: graph.dangling.length,
|
|
190
|
+
danglingHistory: split.history.length, danglingNeedsDecision: split.needs.length, needsDecision: split.needs,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const nodeById = (graph, id) => graph.nodes.find((n) => n.id === id) || null;
|
|
195
|
+
const cardText = (n) => `${n.id} [${n.type || 'untyped'}] ${n.title}${n.card ? ' — ' + n.card : ''} (file ~${n.tokens_est} tokens)`;
|
|
196
|
+
|
|
197
|
+
/* Rank: where the term matches (id 3, title 3, heading 2, card 1), then how many files link to the node. */
|
|
198
|
+
function find(graph, term, { limit = 20 } = {}) {
|
|
199
|
+
const t = String(term || '').toLowerCase().trim();
|
|
200
|
+
if (!t) return [];
|
|
201
|
+
const indeg = new Map();
|
|
202
|
+
for (const e of graph.edges) indeg.set(e.to, (indeg.get(e.to) || 0) + 1);
|
|
203
|
+
const out = [];
|
|
204
|
+
for (const n of graph.nodes) {
|
|
205
|
+
let score = 0; const where = [];
|
|
206
|
+
if (n.id.toLowerCase().includes(t)) { score += 3; where.push('id'); }
|
|
207
|
+
if (n.title.toLowerCase().includes(t)) { score += 3; where.push('title'); }
|
|
208
|
+
const h = n.headings.find((x) => x.text.toLowerCase().includes(t));
|
|
209
|
+
if (h) { score += 2; where.push('heading "' + h.text + '"'); }
|
|
210
|
+
if ((n.card || '').toLowerCase().includes(t)) { score += 1; where.push('card'); }
|
|
211
|
+
if (score) out.push({ node: n, score, where, indegree: indeg.get(n.id) || 0 });
|
|
212
|
+
}
|
|
213
|
+
return out.sort((a, b) => b.score - a.score || b.indegree - a.indegree || (a.node.id < b.node.id ? -1 : 1)).slice(0, limit);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function neighbors(graph, id, { type = null, depth = 1 } = {}) {
|
|
217
|
+
if (!nodeById(graph, id)) return null;
|
|
218
|
+
const seen = new Map([[id, 0]]); const rows = [];
|
|
219
|
+
let frontier = [id];
|
|
220
|
+
for (let d = 1; d <= depth; d++) {
|
|
221
|
+
const next = [];
|
|
222
|
+
for (const e of graph.edges) {
|
|
223
|
+
if (type && e.type !== type) continue;
|
|
224
|
+
for (const [a, b, dir] of [[e.from, e.to, 'out'], [e.to, e.from, 'in']]) {
|
|
225
|
+
if (!frontier.includes(a) || seen.has(b)) continue;
|
|
226
|
+
seen.set(b, d); next.push(b); rows.push({ id: b, via: e.type, basis: e.basis, direction: dir, depth: d, from: a });
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
frontier = next;
|
|
230
|
+
}
|
|
231
|
+
return rows;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/* why("D65"): the ADR view, the amendment-log line in DECISIONS.md, and every file that cites the ADR, by kind. */
|
|
235
|
+
function why(graph, dId) {
|
|
236
|
+
const id = String(dId || '').toUpperCase().replace(/^D(\d)$/, 'D0$1');
|
|
237
|
+
const adr = graph.nodes.find((n) => n.id.startsWith(ADR_DIR + id.toLowerCase() + '-'));
|
|
238
|
+
if (!adr) return null;
|
|
239
|
+
const groups = {};
|
|
240
|
+
const byFrom = new Map();
|
|
241
|
+
for (const e of graph.edges) if (e.to === adr.id) {
|
|
242
|
+
const cur = byFrom.get(e.from) || { id: e.from, via: [], basis: 'mention' };
|
|
243
|
+
if (!cur.via.includes(e.type)) cur.via.push(e.type);
|
|
244
|
+
if (e.basis === 'stated') cur.basis = 'stated';
|
|
245
|
+
byFrom.set(e.from, cur);
|
|
246
|
+
}
|
|
247
|
+
for (const r of byFrom.values()) { const n = nodeById(graph, r.id); const k = (n && n.type) || 'untyped'; r.via.sort(); (groups[k] = groups[k] || []).push(r); }
|
|
248
|
+
for (const k of Object.keys(groups)) groups[k].sort((a, b) => (a.id < b.id ? -1 : 1));
|
|
249
|
+
const line = graph.meta && graph.meta.decisionLines ? graph.meta.decisionLines[id] || null : null;
|
|
250
|
+
return { decision: id, adr, amendmentLog: line ? { path: 'DECISIONS.md', line } : null, citedBy: groups };
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/* section(node, text, spec): spec is "#heading words" or "from-to". Returns {from,to,text} or null. */
|
|
254
|
+
function section(node, text, spec) {
|
|
255
|
+
const lines = String(text || '').split('\n');
|
|
256
|
+
const m = /^(\d+)-(\d+)$/.exec(spec || '');
|
|
257
|
+
if (m) { const from = Math.max(1, +m[1]); const to = Math.min(lines.length, +m[2]); if (from > to) return null; return { from, to, text: lines.slice(from - 1, to).join('\n') }; }
|
|
258
|
+
const want = String(spec || '').replace(/^#/, '').trim().toLowerCase();
|
|
259
|
+
if (!want || !node) return null;
|
|
260
|
+
const h = node.headings.find((x) => x.text.toLowerCase() === want) || node.headings.find((x) => x.text.toLowerCase().includes(want));
|
|
261
|
+
if (!h) return null;
|
|
262
|
+
return { from: h.start, to: h.end, heading: h.text, text: lines.slice(h.start - 1, h.end).join('\n') };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
return { build, stats, splitDangling, find, neighbors, why, section, nodeById, cardText, stripCode, frontmatter, estTokens, ADR_DIR };
|
|
266
|
+
});
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* prepare-queue.js — the rules for background preparation: what may be queued, and when a queued entry may start.
|
|
3
|
+
*
|
|
4
|
+
* Pure: no I/O, no clock, no model. The server passes what is true now (the settings, the entries read from the ledger, a
|
|
5
|
+
* fresh prepare of the same step, the session budget) and this module decides. An approval is ONE plan hash, never a
|
|
6
|
+
* standing permission: a plan that changed is skipped, an approval older than 24 hours expires, and nothing is caught up.
|
|
7
|
+
*
|
|
8
|
+
* fold(events) → the entries, each with its latest status
|
|
9
|
+
* checkRequest(body) → the shape of a queue request, or why it is refused
|
|
10
|
+
* canQueue({settings, entries, req}) → null, or the reason a request is refused
|
|
11
|
+
* decide({entry, now, settings, quiet, manual, fresh, budget}) → {action, reason}
|
|
12
|
+
*
|
|
13
|
+
* Statuses: queued · started · skipped · expired · cancelled. Only `queued` can change; the other four are final.
|
|
14
|
+
*/
|
|
15
|
+
const MAX_QUEUED = 5;
|
|
16
|
+
const EXPIRE_MS = 24 * 60 * 60 * 1000;
|
|
17
|
+
const FINAL = ['started', 'skipped', 'expired', 'cancelled'];
|
|
18
|
+
const HASH = /^[0-9a-f]{16}$/;
|
|
19
|
+
const ID = /^[a-z0-9][a-z0-9._-]*$/i;
|
|
20
|
+
const ITEM = /^[\w.:-]{1,120}$/;
|
|
21
|
+
const CAND = /^[0-9a-f]{12}$/;
|
|
22
|
+
|
|
23
|
+
/** The latest status of every entry, oldest first. A line that names no entry is ignored. */
|
|
24
|
+
function fold(events) {
|
|
25
|
+
const by = new Map();
|
|
26
|
+
for (const e of Array.isArray(events) ? events : []) {
|
|
27
|
+
if (!e || typeof e.id !== 'string') continue;
|
|
28
|
+
if (e.event === 'queued') { if (!by.has(e.id)) by.set(e.id, { id: e.id, status: 'queued', queued_at: e.at, ws: e.ws, initiative_id: e.initiative_id, item_id: e.item_id, key: e.key, candidate_id: e.candidate_id || null, prompt_sha: e.prompt_sha, branch: e.branch || null, reason: null, session_id: null, at: e.at }); continue; }
|
|
29
|
+
const cur = by.get(e.id);
|
|
30
|
+
if (!cur || FINAL.includes(cur.status) || !FINAL.includes(e.event)) continue;
|
|
31
|
+
cur.status = e.event; cur.reason = e.reason || null; cur.session_id = e.session_id || null; cur.at = e.at;
|
|
32
|
+
}
|
|
33
|
+
return [...by.values()];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** @returns {{ok:true, req:object}|{ok:false, error:string}} */
|
|
37
|
+
function checkRequest(body) {
|
|
38
|
+
const b = body && typeof body === 'object' ? body : {};
|
|
39
|
+
if (b.confirm !== true) return { ok: false, error: 'confirm is required: this lets the workspace start a session later' };
|
|
40
|
+
if (typeof b.ws !== 'string' || !b.ws) return { ok: false, error: 'ws is required' };
|
|
41
|
+
if (typeof b.initiative_id !== 'string' || !ID.test(b.initiative_id)) return { ok: false, error: 'initiative_id is required' };
|
|
42
|
+
if (typeof b.item_id !== 'string' || !ITEM.test(b.item_id)) return { ok: false, error: 'item_id is required' };
|
|
43
|
+
if (typeof b.prompt_sha !== 'string' || !HASH.test(b.prompt_sha)) return { ok: false, error: 'prompt_sha is required: queue the plan you were shown' };
|
|
44
|
+
if (b.candidate_id !== undefined && b.candidate_id !== null && b.candidate_id !== '' && !CAND.test(String(b.candidate_id))) return { ok: false, error: 'candidate_id is not a candidate id' };
|
|
45
|
+
return { ok: true, req: { ws: b.ws, initiative_id: b.initiative_id, item_id: b.item_id, prompt_sha: b.prompt_sha, candidate_id: b.candidate_id || null } };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** @returns {string|null} the reason a request is refused, or null */
|
|
49
|
+
function canQueue({ settings, entries, req }) {
|
|
50
|
+
if (!settings || settings.backgroundPrepare !== true) return 'background prepare is off. Turn it on in the Today settings first.';
|
|
51
|
+
const queued = (entries || []).filter((e) => e.status === 'queued');
|
|
52
|
+
if (queued.some((e) => e.ws === req.ws && e.initiative_id === req.initiative_id && e.item_id === req.item_id)) return 'this step is already queued';
|
|
53
|
+
if (queued.length >= MAX_QUEUED) return `${queued.length} entries are already waiting (the limit is ${MAX_QUEUED}). Cancel one or wait for it to start.`;
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* What the runner does with one queued entry right now.
|
|
59
|
+
* @param {{entry:object, now:number, settings:object, quiet:boolean, manual:boolean, fresh:object, budget:object}} s
|
|
60
|
+
* @returns {{action:'start'|'wait'|'skip'|'expire', reason:string|null}}
|
|
61
|
+
*/
|
|
62
|
+
function decide(s) {
|
|
63
|
+
const e = s.entry;
|
|
64
|
+
const age = s.now - Date.parse(e.queued_at);
|
|
65
|
+
if (!Number.isFinite(age) || age > EXPIRE_MS) return { action: 'expire', reason: 'the approval is older than 24 hours — queue it again if you still want it' };
|
|
66
|
+
if (!s.settings || s.settings.backgroundPrepare !== true) return { action: 'wait', reason: 'background prepare is off' };
|
|
67
|
+
if (s.quiet && !s.manual) return { action: 'wait', reason: 'quiet hours' };
|
|
68
|
+
const f = s.fresh || {};
|
|
69
|
+
if (!f.ok) return { action: 'skip', reason: f.error || 'the step could not be prepared again' };
|
|
70
|
+
if (f.refused) return { action: 'skip', reason: f.refused.why };
|
|
71
|
+
if (f.prompt_sha !== e.prompt_sha) return { action: 'skip', reason: 'the plan changed since you approved it — queue it again to approve the new one' };
|
|
72
|
+
if (!s.budget || !s.budget.allowed) return { action: 'wait', reason: (s.budget && s.budget.reason) || 'no session budget' };
|
|
73
|
+
return { action: 'start', reason: null };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
module.exports = { MAX_QUEUED, EXPIRE_MS, FINAL, fold, checkRequest, canQueue, decide };
|
|
@@ -13,13 +13,16 @@
|
|
|
13
13
|
* pmos proactive brief [show]
|
|
14
14
|
* pmos proactive brief submit --file PATH
|
|
15
15
|
* pmos proactive brief schedule [--repo PATH]
|
|
16
|
+
* pmos proactive prepare plan --initiative ID [--repo PATH]
|
|
17
|
+
* pmos proactive prepare queue --initiative ID --item ITEM --sha HASH [--repo PATH]
|
|
18
|
+
* pmos proactive prepare status | cancel ID | run
|
|
16
19
|
*/
|
|
17
20
|
const fs = require('fs');
|
|
18
21
|
const path = require('path');
|
|
19
22
|
const { execFileSync } = require('child_process');
|
|
20
23
|
const { parseArgs } = require('./schedule-cli');
|
|
21
24
|
|
|
22
|
-
const COMMANDS = { proactive: ['today', 'scan', 'settings', 'feedback', 'brief'] };
|
|
25
|
+
const COMMANDS = { proactive: ['today', 'scan', 'settings', 'feedback', 'brief', 'prepare'] };
|
|
23
26
|
const BRIEF_SOURCES = ['calendar', 'wispr', 'mail', 'jira', 'confluence'];
|
|
24
27
|
const onOff = (v, what) => (v === 'on' || v === 'true' ? true : v === 'off' || v === 'false' ? false : { err: `${what} takes on or off` });
|
|
25
28
|
|
|
@@ -63,6 +66,7 @@ async function run(argv, { call, say, cwd = process.cwd() }) {
|
|
|
63
66
|
patch.triggers = { [m[1]]: m[2] === 'on' };
|
|
64
67
|
}
|
|
65
68
|
if ('name' in flags) patch.name = flags.name;
|
|
69
|
+
if ('background-prepare' in flags) { const v = onOff(flags['background-prepare'], '--background-prepare'); if (v.err) return fail(v.err); patch.backgroundPrepare = v; }
|
|
66
70
|
if ('brief-scoring' in flags) { const v = onOff(flags['brief-scoring'], '--brief-scoring'); if (v.err) return fail(v.err); patch.briefScoring = v; }
|
|
67
71
|
if ('brief-source' in flags) {
|
|
68
72
|
const m = /^([a-z]+)=(on|off)$/.exec(flags['brief-source']);
|
|
@@ -82,9 +86,61 @@ async function run(argv, { call, say, cwd = process.cwd() }) {
|
|
|
82
86
|
}
|
|
83
87
|
say(`engine ${s.enabled ? 'on' : 'off'} · stalled after ${s.stalledDays} days · quiet ${s.quiet ? s.quiet.start + '-' + s.quiet.end : 'none'} · learning ${s.pauseLearning ? 'paused' : 'on'}`);
|
|
84
88
|
say('brief sources: ' + BRIEF_SOURCES.map((k) => `${k} ${s.briefSources && s.briefSources[k] ? 'on' : 'off'}`).join(', ') + ' · scoring ' + (s.briefScoring ? 'on' : 'off') + (s.name ? ' · name ' + s.name : ''));
|
|
89
|
+
say('background prepare ' + (s.backgroundPrepare ? 'on' : 'off'));
|
|
85
90
|
say('watching: ' + Object.entries(s.triggers).map(([k, v]) => `${k} ${v ? 'on' : 'off'}`).join(', '));
|
|
86
91
|
return 0;
|
|
87
92
|
}
|
|
93
|
+
if (verb === 'prepare') {
|
|
94
|
+
const sub = pos[0] || 'status';
|
|
95
|
+
const wsOf = async () => {
|
|
96
|
+
let root; try { root = execFileSync('git', ['rev-parse', '--show-toplevel'], { cwd: flags.repo ? path.resolve(cwd, flags.repo) : cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); } catch { return { err: 'this is not inside a git repository — use --repo PATH' }; }
|
|
97
|
+
const wl = await call('GET', '/api/workspaces');
|
|
98
|
+
const hit = ((wl && wl.workspaces) || []).find((w) => path.resolve(w.root) === path.resolve(root));
|
|
99
|
+
return hit ? { ws: hit.id } : { err: `${root} is not a registered workspace — open it with pmos first` };
|
|
100
|
+
};
|
|
101
|
+
if (sub === 'plan' || sub === 'queue') {
|
|
102
|
+
if (!flags.initiative) return fail(`usage: pmos proactive prepare ${sub} --initiative ID${sub === 'queue' ? ' --item ITEM --sha HASH' : ''}`);
|
|
103
|
+
const w = await wsOf(); if (w.err) return fail(w.err);
|
|
104
|
+
if (sub === 'plan') {
|
|
105
|
+
const r = await call('POST', '/api/proactive/prepare/plan', { ws: w.ws, initiative_id: flags.initiative });
|
|
106
|
+
if (!r || r.ok === false) return fail((r && r.error) || 'the server did not answer');
|
|
107
|
+
say(`step: ${r.item.key} — ${r.item.text} (item ${r.item.id})`);
|
|
108
|
+
if (r.refused) { say('refused: ' + r.refused.why); return 1; }
|
|
109
|
+
say(r.plan.why || r.plan.plan);
|
|
110
|
+
say(`budget: ${r.budget.running} of ${r.budget.limits.concurrent} running · ${r.budget.startedToday} of ${r.budget.limits.perDay} started today`);
|
|
111
|
+
say('background prepare is ' + (r.background.enabled ? 'on' : 'off. Turn it on with: pmos proactive settings --background-prepare on'));
|
|
112
|
+
say('plan ' + r.prompt_sha + ' — the exact first input:');
|
|
113
|
+
say(r.prompt);
|
|
114
|
+
say(`queue it with: pmos proactive prepare queue --initiative ${r.initiative_id} --item ${r.item.id} --sha ${r.prompt_sha}`);
|
|
115
|
+
return 0;
|
|
116
|
+
}
|
|
117
|
+
if (!flags.item || !flags.sha) return fail('usage: pmos proactive prepare queue --initiative ID --item ITEM --sha HASH');
|
|
118
|
+
const r = await call('POST', '/api/proactive/prepare/queue', { ws: w.ws, initiative_id: flags.initiative, item_id: flags.item, prompt_sha: flags.sha, confirm: true });
|
|
119
|
+
if (!r || r.ok === false) return fail(`refused — ${(r && r.error) || 'the server did not answer'}. Nothing was queued.`);
|
|
120
|
+
say(`queued ${r.entry.id} — the workspace starts it when the budget allows. It expires in 24 hours.`);
|
|
121
|
+
return 0;
|
|
122
|
+
}
|
|
123
|
+
if (sub === 'cancel') {
|
|
124
|
+
if (!pos[1]) return fail('usage: pmos proactive prepare cancel ID');
|
|
125
|
+
const r = await call('POST', '/api/proactive/prepare/cancel', { id: pos[1] });
|
|
126
|
+
if (!r || r.ok === false) return fail((r && r.error) || 'the server did not answer');
|
|
127
|
+
say('cancelled'); return 0;
|
|
128
|
+
}
|
|
129
|
+
if (sub === 'run') {
|
|
130
|
+
const r = await call('POST', '/api/proactive/prepare/run', {});
|
|
131
|
+
if (!r || r.ok === false) return fail((r && r.error) || 'the server did not answer');
|
|
132
|
+
if (!r.results.length) say('nothing is queued');
|
|
133
|
+
for (const x of r.results) say(`${x.id} ${x.action}${x.reason ? ' — ' + x.reason : ''}${x.session_id ? ' (session ' + x.session_id + ')' : ''}`);
|
|
134
|
+
return 0;
|
|
135
|
+
}
|
|
136
|
+
if (sub !== 'status') return fail('usage: pmos proactive prepare [status|plan|queue|cancel ID|run]');
|
|
137
|
+
const r = await call('GET', '/api/proactive/prepare');
|
|
138
|
+
if (!r || r.ok === false) return fail((r && r.error) || 'the server did not answer');
|
|
139
|
+
say(`background prepare ${r.enabled ? 'on' : 'off'}${r.quiet ? ' · quiet hours now' : ''} · ${r.budget.running} of ${r.budget.limits.concurrent} running · ${r.budget.startedToday} of ${r.budget.limits.perDay} started today`);
|
|
140
|
+
if (!r.entries.length) say('nothing has been queued');
|
|
141
|
+
for (const e of r.entries) say(`${e.id} ${e.status} ${e.label}${e.reason ? ' — ' + e.reason : ''}`);
|
|
142
|
+
return 0;
|
|
143
|
+
}
|
|
88
144
|
if (verb === 'brief') {
|
|
89
145
|
const sub = pos[0] || 'show';
|
|
90
146
|
if (sub === 'submit') {
|
|
@@ -32,7 +32,7 @@ const OWNER = { needs: 'pm', returned: 'pm', review: 'pm', schedule: 'pm', stall
|
|
|
32
32
|
const BRIEF_SOURCES = ['calendar', 'wispr', 'mail', 'jira', 'confluence'];
|
|
33
33
|
// Mail is off by default: a brief reads titles and metadata of the other sources, and the PM turns mail on.
|
|
34
34
|
const BRIEF_DEFAULT = Object.freeze({ calendar: true, wispr: true, mail: false, jira: true, confluence: true });
|
|
35
|
-
const DEFAULTS = Object.freeze({ name: '', briefSources: BRIEF_DEFAULT, briefScoring: false, enabled: true, triggers: Object.freeze(Object.fromEntries(TRIGGERS.map((t) => [t, true]))), stalledDays: 5, quiet: null, pauseLearning: false });
|
|
35
|
+
const DEFAULTS = Object.freeze({ name: '', briefSources: BRIEF_DEFAULT, briefScoring: false, backgroundPrepare: false, enabled: true, triggers: Object.freeze(Object.fromEntries(TRIGGERS.map((t) => [t, true]))), stalledDays: 5, quiet: null, pauseLearning: false });
|
|
36
36
|
|
|
37
37
|
const idOf = (key) => crypto.createHash('sha1').update(String(key)).digest('hex').slice(0, 12);
|
|
38
38
|
const clip = (s, n) => String(s == null ? '' : s).replace(/\s+/g, ' ').trim().slice(0, n);
|
|
@@ -161,10 +161,11 @@ const HHMM = /^([01]\d|2[0-3]):([0-5]\d)$/;
|
|
|
161
161
|
/** @returns {{ok:true, settings:object}|{ok:false, error:string}} — `raw` is merged over the defaults; nothing is partly applied. */
|
|
162
162
|
function normalizeSettings(raw) {
|
|
163
163
|
const r = raw && typeof raw === 'object' ? raw : {};
|
|
164
|
-
const s = { name: DEFAULTS.name, briefSources: { ...BRIEF_DEFAULT }, briefScoring: DEFAULTS.briefScoring, enabled: DEFAULTS.enabled, triggers: { ...DEFAULTS.triggers }, stalledDays: DEFAULTS.stalledDays, quiet: DEFAULTS.quiet, pauseLearning: DEFAULTS.pauseLearning };
|
|
164
|
+
const s = { name: DEFAULTS.name, briefSources: { ...BRIEF_DEFAULT }, briefScoring: DEFAULTS.briefScoring, backgroundPrepare: DEFAULTS.backgroundPrepare, enabled: DEFAULTS.enabled, triggers: { ...DEFAULTS.triggers }, stalledDays: DEFAULTS.stalledDays, quiet: DEFAULTS.quiet, pauseLearning: DEFAULTS.pauseLearning };
|
|
165
165
|
if (r.enabled !== undefined) { if (typeof r.enabled !== 'boolean') return { ok: false, error: 'enabled must be true or false' }; s.enabled = r.enabled; }
|
|
166
166
|
if (r.name !== undefined) { if (typeof r.name !== 'string' || r.name.length > 40) return { ok: false, error: 'name must be text of at most 40 characters' }; s.name = r.name.replace(/\s+/g, ' ').trim(); }
|
|
167
167
|
if (r.briefScoring !== undefined) { if (typeof r.briefScoring !== 'boolean') return { ok: false, error: 'briefScoring must be true or false' }; s.briefScoring = r.briefScoring; }
|
|
168
|
+
if (r.backgroundPrepare !== undefined) { if (typeof r.backgroundPrepare !== 'boolean') return { ok: false, error: 'backgroundPrepare must be true or false' }; s.backgroundPrepare = r.backgroundPrepare; }
|
|
168
169
|
if (r.briefSources !== undefined) {
|
|
169
170
|
if (!r.briefSources || typeof r.briefSources !== 'object' || Array.isArray(r.briefSources)) return { ok: false, error: `briefSources must be an object of ${BRIEF_SOURCES.join(', ')} → true or false` };
|
|
170
171
|
for (const [k, v] of Object.entries(r.briefSources)) {
|
package/workspace/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pmos-workspace",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.43.0",
|
|
4
4
|
"private": true,
|
|
5
5
|
"description": "PMOS Terminal Workspace — local companion hosting real PTYs beside the library and gates (D62)",
|
|
6
6
|
"license": "MIT",
|
|
@@ -10,12 +10,12 @@
|
|
|
10
10
|
"scripts": {
|
|
11
11
|
"start": "node server.js",
|
|
12
12
|
"test": "node test.js && npm run test:modules",
|
|
13
|
-
"test:modules": "node proactive.test.js && node focus.test.js && node handoff.test.js && node repo-scan.test.js && node profiles.test.js && node external-linking.test.js && node story-artifacts.test.js && node no-network.test.js && node typesafe-validation.test.js && node claims.test.js && node jev-claims.test.js && node repo-watch.test.js && node fixture-parity.test.mjs && node markdown.test.mjs && node start-policy.test.js && node state-isolation.test.js && node sync.test.js && node schedule-cli.test.js && node initiative-surface.test.js && node guide.test.js && node next-step-actions.test.js",
|
|
13
|
+
"test:modules": "node proactive.test.js && node focus.test.js && node handoff.test.js && node repo-scan.test.js && node profiles.test.js && node external-linking.test.js && node story-artifacts.test.js && node no-network.test.js && node typesafe-validation.test.js && node claims.test.js && node jev-claims.test.js && node repo-watch.test.js && node fixture-parity.test.mjs && node markdown.test.mjs && node start-policy.test.js && node state-isolation.test.js && node sync.test.js && node schedule-cli.test.js && node initiative-surface.test.js && node guide.test.js && node next-step-actions.test.js && node kg.test.js && node proactive-prepare.test.js",
|
|
14
14
|
"test:ui": "node ui-test.js",
|
|
15
15
|
"test:parity": "node design-parity.js --states",
|
|
16
16
|
"test:all": "npm test && npm run test:panels && npm run test:ui && npm run test:parity && npm run test:offline",
|
|
17
17
|
"doctor": "node doctor.js",
|
|
18
|
-
"test:panels": "node pm-shell-test.js && node pm-repos-test.js && node pm-initiatives-test.js && node pm-detail-test.js && node pm-tabs-test.js && node pm-term-test.js && node pm-modes-test.js && node pm-r1-test.js && node pm-r2-test.js && node pm-r3-test.js && node pm-r4-test.js && node pm-r5-test.js && node pm-r6-test.js && node pm-r7-test.js && node pm-r8-test.js && node pm-r9-test.js && node pm-r10-test.js && node pm-r11-test.js && node pm-r12-test.js && node pm-r13-test.js && node pm-r14-test.js && node pm-r15-test.js && node pm-r16-test.js && node pm-r17-test.js && node pm-r16-test.js && node pm-r18-test.js && node pm-r19-test.js && node pm-r20-test.js && node pm-r21-test.js && node pm-r22-test.js && node pm-r23-test.js && node pm-r24-test.js",
|
|
18
|
+
"test:panels": "node pm-shell-test.js && node pm-repos-test.js && node pm-initiatives-test.js && node pm-detail-test.js && node pm-tabs-test.js && node pm-term-test.js && node pm-modes-test.js && node pm-r1-test.js && node pm-r2-test.js && node pm-r3-test.js && node pm-r4-test.js && node pm-r5-test.js && node pm-r6-test.js && node pm-r7-test.js && node pm-r8-test.js && node pm-r9-test.js && node pm-r10-test.js && node pm-r11-test.js && node pm-r12-test.js && node pm-r13-test.js && node pm-r14-test.js && node pm-r15-test.js && node pm-r16-test.js && node pm-r17-test.js && node pm-r16-test.js && node pm-r18-test.js && node pm-r19-test.js && node pm-r20-test.js && node pm-r21-test.js && node pm-r22-test.js && node pm-r23-test.js && node pm-r24-test.js && node pm-r25-test.js",
|
|
19
19
|
"test:offline": "node offline-tarball.test.js"
|
|
20
20
|
},
|
|
21
21
|
"engines": {
|
|
@@ -831,7 +831,7 @@ async function tScan() {
|
|
|
831
831
|
}
|
|
832
832
|
async function tSettings(patch) {
|
|
833
833
|
const out = await post('/api/proactive/settings', patch);
|
|
834
|
-
reload('today'); reload('brief');
|
|
834
|
+
reload('today'); reload('brief'); reload('prepare');
|
|
835
835
|
tSay(!out || out.ok === false ? 'bad' : 'ok', !out || out.ok === false ? ((out && out.error) || 'not saved') : 'saved');
|
|
836
836
|
}
|
|
837
837
|
function tCard(c) {
|
|
@@ -843,6 +843,7 @@ function tCard(c) {
|
|
|
843
843
|
el('span', { style: 'display:block;font-size:11px;color:var(--muted-foreground);margin:2px 0 4px' }, iv(c.why + (c.evidence ? ' — ' + c.evidence : ''))),
|
|
844
844
|
el('span', { style: 'display:flex;gap:4px;flex-wrap:wrap' },
|
|
845
845
|
c.open ? b('open', 'open', () => tOpen(c)) : null,
|
|
846
|
+
c.initiative_id && c.ws ? el('button', { style: BTN, hover: 'color:var(--foreground)', 'data-r23': 'prepare', onClick: () => bgShowPlan(c) }, 'prepare in the background') : null,
|
|
846
847
|
b('useful', 'useful', () => tFeedback({ candidate_id: c.id, verdict: 'useful' }, 'thanks — recorded as useful')),
|
|
847
848
|
b('not useful', 'not-useful', () => tFeedback({ candidate_id: c.id, verdict: 'not-useful' }, 'recorded as not useful')),
|
|
848
849
|
b('wrong priority', 'wrong-priority', () => tFeedback({ candidate_id: c.id, verdict: 'wrong-priority' }, 'recorded as wrong priority')),
|
|
@@ -900,6 +901,72 @@ function briefSection() {
|
|
|
900
901
|
}
|
|
901
902
|
return kids;
|
|
902
903
|
}
|
|
904
|
+
|
|
905
|
+
/* background preparation (proactive-engine D3): the PM approves one plan; the workspace starts it later, under the same rules as a next-step go */
|
|
906
|
+
let bgPlan = null; // { cid, state: 'loading'|'ok'|'error', data, error }
|
|
907
|
+
let bgNote = null;
|
|
908
|
+
async function bgShowPlan(c) {
|
|
909
|
+
if (bgPlan && bgPlan.cid === c.id) { bgPlan = null; setState({}); return; }
|
|
910
|
+
bgPlan = { cid: c.id, state: 'loading' }; setState({});
|
|
911
|
+
const out = await post('/api/proactive/prepare/plan', { ws: c.ws, initiative_id: c.initiative_id });
|
|
912
|
+
bgPlan = !out || out.ok === false ? { cid: c.id, state: 'error', error: (out && out.error) || 'no plan' } : { cid: c.id, state: 'ok', data: out, c };
|
|
913
|
+
setState({});
|
|
914
|
+
}
|
|
915
|
+
async function bgQueue(c, d) {
|
|
916
|
+
const out = await post('/api/proactive/prepare/queue', { ws: d.ws, initiative_id: d.initiative_id, item_id: d.item.id, prompt_sha: d.prompt_sha, candidate_id: c.id, confirm: true });
|
|
917
|
+
bgNote = !out || out.ok === false ? { tone: 'bad', text: (out && out.error) || 'not queued' } : { tone: 'ok', text: 'queued — the workspace starts it when the budget allows' + (d.background.quiet ? ', after quiet hours' : '') + '. It expires in ' + d.background.expire_hours + ' hours.' };
|
|
918
|
+
if (out && out.ok) bgPlan = null;
|
|
919
|
+
reload('prepare'); setState({});
|
|
920
|
+
}
|
|
921
|
+
async function bgCancel(e) {
|
|
922
|
+
const out = await post('/api/proactive/prepare/cancel', { id: e.id });
|
|
923
|
+
bgNote = !out || out.ok === false ? { tone: 'bad', text: (out && out.error) || 'not cancelled' } : { tone: 'ok', text: 'cancelled' };
|
|
924
|
+
reload('prepare'); setState({});
|
|
925
|
+
}
|
|
926
|
+
async function bgRun() {
|
|
927
|
+
const out = await post('/api/proactive/prepare/run', {});
|
|
928
|
+
bgNote = !out || out.ok === false ? { tone: 'bad', text: (out && out.error) || 'the run failed' }
|
|
929
|
+
: { tone: 'ok', text: out.results.length ? out.results.map((x) => x.action + (x.reason ? ' (' + x.reason + ')' : '')).join('; ') : 'nothing is queued' };
|
|
930
|
+
reload('prepare'); setState({});
|
|
931
|
+
}
|
|
932
|
+
function bgPlanView() {
|
|
933
|
+
if (!bgPlan) return null;
|
|
934
|
+
if (bgPlan.state === 'loading') return el('div', { style: NOTE, 'data-r23': 'plan-loading' }, iv('reading the plan …'));
|
|
935
|
+
if (bgPlan.state === 'error') return el('div', { style: NOTE + ';color:var(--destructive)', 'data-r23': 'plan-error' }, iv(bgPlan.error));
|
|
936
|
+
const d = bgPlan.data; const c = bgPlan.c;
|
|
937
|
+
const kids = [el('div', { style: 'font-size:12px', 'data-r23': 'plan-step' }, iv('Step: ' + d.item.key + ' — ' + d.item.text))];
|
|
938
|
+
if (d.refused) { kids.push(el('div', { style: NOTE + ';color:var(--destructive)', 'data-r23': 'plan-refused' }, iv(d.refused.why))); }
|
|
939
|
+
else {
|
|
940
|
+
kids.push(el('div', { style: NOTE, 'data-r23': 'plan-where' }, iv(d.plan.why || d.plan.plan)));
|
|
941
|
+
kids.push(el('div', { style: NOTE, 'data-r23': 'plan-budget' }, iv(d.budget.running + ' of ' + d.budget.limits.concurrent + ' prepared sessions running · ' + d.budget.startedToday + ' of ' + d.budget.limits.perDay + ' started today. The session proposes only: no push, pull request, send, post, acceptance or merge. No outside source and no typed scoring.')));
|
|
942
|
+
kids.push(el('pre', { 'data-r23': 'plan-prompt', style: 'white-space:pre-wrap;font-family:var(--mono);font-size:10.5px;max-height:140px;overflow:auto;border:1px solid var(--border);padding:6px;margin:4px 0' }, iv(d.prompt)));
|
|
943
|
+
if (!d.background.enabled) kids.push(el('div', { style: NOTE, 'data-r23': 'plan-off' }, iv('Background prepare is off. Turn it on in the settings below to queue this.')));
|
|
944
|
+
else kids.push(el('button', { style: BTN + ';color:var(--foreground);border-color:var(--foreground)', hover: 'background:var(--accent)', 'data-r23': 'queue', onClick: () => bgQueue(c, d) }, 'queue it (plan ' + d.prompt_sha + ')'));
|
|
945
|
+
}
|
|
946
|
+
return el('div', { 'data-r23': 'plan', style: 'padding:6px 14px 8px;border-bottom:1px solid var(--border)' }, kids);
|
|
947
|
+
}
|
|
948
|
+
function backgroundSection() {
|
|
949
|
+
const r = read('prepare', '/api/proactive/prepare');
|
|
950
|
+
const st = status(r, 'the background queue');
|
|
951
|
+
const kids = [el('div', { style: LABEL }, iv('background preparation'))];
|
|
952
|
+
if (st) return [...kids, st];
|
|
953
|
+
const d = r.data;
|
|
954
|
+
kids.push(el('div', { style: NOTE, 'data-r23': 'bg-state' }, iv((d.enabled ? 'On' : 'Off') + '. ' + d.budget.running + ' of ' + d.budget.limits.concurrent + ' prepared sessions running · ' + d.budget.startedToday + ' of ' + d.budget.limits.perDay + ' started today' + (d.quiet ? ' · quiet hours now' : '') + '. An approval is for one plan and expires in ' + d.expire_hours + ' hours. It runs only while the workspace server runs.')));
|
|
955
|
+
if (bgNote) kids.push(el('div', { 'data-r23': 'bg-note', style: NOTE + ';color:' + (bgNote.tone === 'bad' ? 'var(--destructive)' : 'var(--foreground)') }, iv(bgNote.text)));
|
|
956
|
+
if (!d.entries.length) kids.push(el('div', { style: NOTE, 'data-r23': 'bg-empty' }, iv('Nothing has been queued.')));
|
|
957
|
+
for (const e of d.entries) {
|
|
958
|
+
kids.push(el('div', { 'data-r23': 'entry', 'data-id': e.id, 'data-status': e.status, style: 'padding:6px 14px;border-bottom:1px solid var(--border)' },
|
|
959
|
+
el('span', { style: 'display:flex;gap:8px;align-items:baseline' },
|
|
960
|
+
el('span', { style: 'font-size:12px' }, iv(e.label)),
|
|
961
|
+
el('span', { style: 'margin-left:auto;font-family:var(--mono);font-size:10px;color:var(--muted-foreground)' }, iv(e.status))),
|
|
962
|
+
e.reason ? el('span', { style: 'display:block;font-size:11px;color:var(--muted-foreground)', 'data-r23': 'entry-reason' }, iv(e.reason)) : null,
|
|
963
|
+
el('span', { style: 'display:flex;gap:4px;margin-top:3px' },
|
|
964
|
+
e.status === 'queued' ? el('button', { style: BTN, hover: 'color:var(--foreground)', 'data-r23': 'cancel', onClick: () => bgCancel(e) }, 'cancel') : null,
|
|
965
|
+
e.status === 'started' && e.session_id ? el('button', { style: BTN, hover: 'color:var(--foreground)', 'data-r23': 'switch', onClick: async () => { const ini = await import('./panel-initiatives.js'); ini.openTerm(null, { id: e.session_id, name: e.label }); } }, 'switch to it') : null)));
|
|
966
|
+
}
|
|
967
|
+
if (d.entries.some((e) => e.status === 'queued')) kids.push(el('div', { style: 'padding:4px 14px' }, el('button', { style: BTN, hover: 'color:var(--foreground)', title: 'look at the queue now; ignores quiet hours only', 'data-r23': 'run', onClick: bgRun }, 'run the queue now')));
|
|
968
|
+
return kids;
|
|
969
|
+
}
|
|
903
970
|
export function todayList() {
|
|
904
971
|
const r = read('today', '/api/proactive/today');
|
|
905
972
|
const kids = [el('div', { style: NOTE, 'data-r22': 'intro' }, iv('What is waiting for you, worked out from the ledgers and git on this computer. It runs only while the workspace server runs, reads no mail or calendar, and changes nothing: your feedback only changes how this list is ordered later.'))];
|
|
@@ -925,7 +992,8 @@ export function todayList() {
|
|
|
925
992
|
if ((d.unreadable || []).length && !tRetry && typeof setTimeout === 'function') tRetry = setTimeout(() => { tRetry = null; reload('today'); setState({}); }, 4000);
|
|
926
993
|
for (const u of d.unreadable || []) kids.push(el('div', { style: NOTE + ';color:var(--destructive)', 'data-r22': 'unreadable' }, iv('Not read — ' + (u.name || u.repo || 'a repository') + ': ' + (u.error || 'unknown reason') + '. Nothing is shown for it, which is not the same as nothing waiting.')));
|
|
927
994
|
if (d.empty) kids.push(el('div', { style: NOTE, 'data-r22': 'empty' }, iv('Nothing is waiting on any repository that was read.')));
|
|
928
|
-
for (const c of d.candidates || []) kids.push(tCard(c));
|
|
995
|
+
for (const c of d.candidates || []) { kids.push(tCard(c)); if (bgPlan && bgPlan.cid === c.id) kids.push(bgPlanView()); }
|
|
996
|
+
kids.push(...backgroundSection());
|
|
929
997
|
if (d.dismissed) kids.push(el('div', { style: NOTE, 'data-r22': 'dismissed' }, iv(d.dismissed + ' dismissed item(s) hidden for 7 days.')));
|
|
930
998
|
// settings
|
|
931
999
|
kids.push(el('div', { style: LABEL }, iv('settings')));
|
|
@@ -933,6 +1001,7 @@ export function todayList() {
|
|
|
933
1001
|
el('input', { type: 'checkbox', checked: on ? 'checked' : null, 'data-r22': attr, onChange: () => tSettings(patch(!on)) }), iv(label));
|
|
934
1002
|
kids.push(chk('engine on', s.enabled, 'set-enabled', (v) => ({ enabled: v })));
|
|
935
1003
|
for (const t of Object.keys(s.triggers)) kids.push(chk('watch: ' + t, s.triggers[t], 'set-' + t, (v) => ({ triggers: { [t]: v } })));
|
|
1004
|
+
kids.push(chk('background prepare (starts approved sessions by itself)', s.backgroundPrepare, 'set-bg', (v) => ({ backgroundPrepare: v })));
|
|
936
1005
|
kids.push(el('div', { style: LABEL }, iv('morning brief')));
|
|
937
1006
|
for (const k of Object.keys(s.briefSources || {})) kids.push(chk('read ' + k + (k === 'mail' ? ' (titles and senders only)' : ''), s.briefSources[k], 'set-src-' + k, (v) => ({ briefSources: { [k]: v } })));
|
|
938
1007
|
kids.push(chk('typed scoring (sends titles and metadata to the Typesafe service)', s.briefScoring, 'set-scoring', (v) => ({ briefScoring: v })));
|
package/workspace/server.js
CHANGED
|
@@ -79,6 +79,7 @@ const sessionBudget = require('./lib/session-budget');
|
|
|
79
79
|
const judgmentLib = require('./lib/judgment');
|
|
80
80
|
const proactiveLib = require('./lib/proactive');
|
|
81
81
|
const briefLib = require('./lib/brief');
|
|
82
|
+
const prepareQueue = require('./lib/prepare-queue');
|
|
82
83
|
const harness = require('./lib/harness-parse');
|
|
83
84
|
const transcript = require('./lib/transcript-read');
|
|
84
85
|
const skillStore = require('./lib/skill-store');
|
|
@@ -2189,7 +2190,7 @@ function checkDueSchedules() {
|
|
|
2189
2190
|
return fired;
|
|
2190
2191
|
}
|
|
2191
2192
|
// the tick — the persistent server checks on an interval; unref'd so it never blocks exit
|
|
2192
|
-
try { const _tick = setInterval(() => { checkDueSchedules(); proactiveTick(); }, 60000); if (_tick.unref) _tick.unref(); } catch {}
|
|
2193
|
+
try { const _tick = setInterval(() => { checkDueSchedules(); proactiveTick(); try { prepareTick({ manual: false }); } catch { /* the next tick tries again */ } }, 60000); if (_tick.unref) _tick.unref(); } catch {}
|
|
2193
2194
|
|
|
2194
2195
|
// ── Sessions: real PTYs, owned by the server, with disk-backed scrollback + metadata. ──
|
|
2195
2196
|
/** @type {Map<string, {id,ws,name,agent,cwd,state,note,created,pty,buf,cols,rows,clients:Set}>} */
|
|
@@ -3358,6 +3359,58 @@ function prepareNextStep(body) {
|
|
|
3358
3359
|
judgment: { on: judgmentOn, outside_service: judgmentOn, needs_key: judgmentOn, note: judgmentOn ? 'The question text leaves this machine for the Typesafe service. It needs TYPESAFE_API_KEY in the session.' : 'No outside service is used.' } };
|
|
3359
3360
|
}
|
|
3360
3361
|
|
|
3362
|
+
// ── Background prepare, slice D3 (initiative proactive-engine) ──────────────────────────────────────────────────
|
|
3363
|
+
// The PM approves ONE plan hash from Today. The 60-second tick later starts that session through the SAME path a next-step
|
|
3364
|
+
// `go` uses (startWork with origin next-step and the prompt that was shown), only while background prepare is on, outside quiet
|
|
3365
|
+
// hours, with session budget, and while the plan still has the approved hash. Every rule is in lib/prepare-queue.js.
|
|
3366
|
+
const PRO_QUEUE = () => path.join(STATE, 'prepare-queue.jsonl');
|
|
3367
|
+
function queueEntries() { return prepareQueue.fold(nsRead(PRO_QUEUE())); }
|
|
3368
|
+
// The step an agent session may work on now for an initiative: the first `work` step the ladder lets it start.
|
|
3369
|
+
function currentWorkItem(w, id) {
|
|
3370
|
+
const got = nextStepsFor(w, id);
|
|
3371
|
+
if (!got.ok) return { ok: false, status: got.status || 500, error: got.error };
|
|
3372
|
+
const item = got.ns.items.find((x) => x.act && x.act.type === 'work' && !nextStepActions.refusal(x, got.ns));
|
|
3373
|
+
if (item) return { ok: true, item };
|
|
3374
|
+
const first = got.ns.items.find((x) => x.first) || got.ns.items[0];
|
|
3375
|
+
const why = first ? nextStepActions.refusal(first, got.ns) : null;
|
|
3376
|
+
return { ok: false, status: 409, error: 'No step is open for an agent session right now' + (why ? ': ' + why.why : '.') };
|
|
3377
|
+
}
|
|
3378
|
+
function preparePlanFor(w, id, itemId) {
|
|
3379
|
+
return prepareNextStep({ ws: w.id, initiative_id: id, item_id: itemId, agent: 'claude', judgment: false });
|
|
3380
|
+
}
|
|
3381
|
+
function prepareState() {
|
|
3382
|
+
const settings = proSettings();
|
|
3383
|
+
return { enabled: settings.backgroundPrepare === true, quiet: proactiveLib.inQuietHours(Date.now(), settings.quiet), budget: nsBudget(), max_queued: prepareQueue.MAX_QUEUED, expire_hours: prepareQueue.EXPIRE_MS / 3600000 };
|
|
3384
|
+
}
|
|
3385
|
+
function prepareTick({ manual = false } = {}) {
|
|
3386
|
+
const out = [];
|
|
3387
|
+
const settings = proSettings();
|
|
3388
|
+
const queued = queueEntries().filter((e) => e.status === 'queued');
|
|
3389
|
+
if (!queued.length) return out;
|
|
3390
|
+
const now = Date.now();
|
|
3391
|
+
const quiet = proactiveLib.inQuietHours(now, settings.quiet);
|
|
3392
|
+
for (const e of queued) {
|
|
3393
|
+
const w = getWs(e.ws);
|
|
3394
|
+
const fresh = w ? preparePlanFor(w, e.initiative_id, e.item_id) : { ok: false, error: 'the repository is no longer registered' };
|
|
3395
|
+
const d = prepareQueue.decide({ entry: e, now, settings, quiet, manual, fresh, budget: nsBudget() });
|
|
3396
|
+
if (d.action === 'wait') { out.push({ id: e.id, action: 'wait', reason: d.reason }); continue; }
|
|
3397
|
+
if (d.action === 'expire' || d.action === 'skip') {
|
|
3398
|
+
nsAppend(PRO_QUEUE(), { id: e.id, at: nowISO(), event: d.action === 'expire' ? 'expired' : 'skipped', reason: d.reason }, 'prepare queue');
|
|
3399
|
+
out.push({ id: e.id, action: d.action, reason: d.reason }); continue;
|
|
3400
|
+
}
|
|
3401
|
+
const started = startWork({ initiative: fresh.initiative_id, ws: fresh.ws, agent: fresh.agent, name: `${fresh.item.key} · ${fresh.initiative_id} (background)`, origin: 'next-step', initialInput: fresh.prompt + '\r' });
|
|
3402
|
+
if (!started.ok) {
|
|
3403
|
+
nsAppend(PRO_QUEUE(), { id: e.id, at: nowISO(), event: 'skipped', reason: 'it could not start — ' + started.error }, 'prepare queue');
|
|
3404
|
+
out.push({ id: e.id, action: 'skip', reason: started.error }); continue;
|
|
3405
|
+
}
|
|
3406
|
+
nsAppend(NS_RUNS(), { id: 'nsr-' + Date.now().toString(36), at: nowISO(), ws: fresh.ws, initiative_id: fresh.initiative_id, item_id: fresh.item.id, key: fresh.item.key, session_id: started.session.id,
|
|
3407
|
+
branch: fresh.plan.branch || null, plan: fresh.plan.plan, prompt_sha: fresh.prompt_sha, judgment: false, profile: null, via: 'background', queue_id: e.id }, 'next-step run');
|
|
3408
|
+
nsAppend(PRO_QUEUE(), { id: e.id, at: nowISO(), event: 'started', session_id: started.session.id }, 'prepare queue');
|
|
3409
|
+
out.push({ id: e.id, action: 'start', session_id: started.session.id });
|
|
3410
|
+
}
|
|
3411
|
+
return out;
|
|
3412
|
+
}
|
|
3413
|
+
|
|
3361
3414
|
// ── Proactive engine, slice D1 (initiative proactive-engine) ─────────────────────────────────────────────────────
|
|
3362
3415
|
// A scan reads ledgers and git for five conditions and appends each new one to signals.jsonl; Today is computed from what
|
|
3363
3416
|
// is true now. No model call, no network call, no write into any repository, no session started (lib/proactive.js is
|
|
@@ -6221,6 +6274,53 @@ async function handleRequest(req, res) {
|
|
|
6221
6274
|
if (!w) return json(res, { ok: false, error: 'no workspace' }, 400);
|
|
6222
6275
|
return json(res, { ok: true, ws: w.id, name: w.name, ...validateLib.readFindings(validationFindingsFile(w)) });
|
|
6223
6276
|
}
|
|
6277
|
+
/* ── background prepare (D3): plan is read-only; queue records an approval; the runner starts it later ── */
|
|
6278
|
+
if (p === '/api/proactive/prepare/plan' && req.method === 'POST') {
|
|
6279
|
+
let body = {}; try { body = JSON.parse((await readBody(req)) || '{}'); } catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
|
|
6280
|
+
const w = getWs(body.ws);
|
|
6281
|
+
if (!w) return json(res, { ok: false, error: 'no workspace' }, 400);
|
|
6282
|
+
const id = String(body.initiative_id || '');
|
|
6283
|
+
if (!fourPanel.validId(id)) return json(res, { ok: false, error: 'an initiative id is required' }, 400);
|
|
6284
|
+
const cur = currentWorkItem(w, id);
|
|
6285
|
+
if (!cur.ok) return json(res, { ok: false, error: cur.error }, cur.status || 409);
|
|
6286
|
+
const r = preparePlanFor(w, id, cur.item.id);
|
|
6287
|
+
return json(res, { ...r, background: prepareState() }, r.ok ? 200 : (r.status || 400));
|
|
6288
|
+
}
|
|
6289
|
+
if (p === '/api/proactive/prepare/queue' && req.method === 'POST') {
|
|
6290
|
+
let body = {}; try { body = JSON.parse((await readBody(req)) || '{}'); } catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
|
|
6291
|
+
const c = prepareQueue.checkRequest(body);
|
|
6292
|
+
if (!c.ok) return json(res, { ok: false, error: c.error }, 400);
|
|
6293
|
+
const why = prepareQueue.canQueue({ settings: proSettings(), entries: queueEntries(), req: c.req });
|
|
6294
|
+
if (why) return json(res, { ok: false, error: why }, 409);
|
|
6295
|
+
const w = getWs(c.req.ws);
|
|
6296
|
+
if (!w) return json(res, { ok: false, error: 'no workspace' }, 400);
|
|
6297
|
+
const r = preparePlanFor(w, c.req.initiative_id, c.req.item_id);
|
|
6298
|
+
if (!r.ok) return json(res, { ok: false, error: r.error }, r.status || 400);
|
|
6299
|
+
if (r.refused) return json(res, { ok: false, refused: r.refused, error: r.refused.why }, 409);
|
|
6300
|
+
if (r.prompt_sha !== c.req.prompt_sha) return json(res, { ok: false, error: 'the plan changed since you reviewed it — read it again', prompt_sha: r.prompt_sha }, 409);
|
|
6301
|
+
const rec = { id: 'pq-' + Date.now().toString(36) + Math.random().toString(36).slice(2, 5), at: nowISO(), event: 'queued', ws: w.id, initiative_id: c.req.initiative_id, item_id: c.req.item_id, key: r.item.key,
|
|
6302
|
+
candidate_id: c.req.candidate_id, prompt_sha: r.prompt_sha, branch: r.plan.branch || null };
|
|
6303
|
+
const wrote = nsAppend(PRO_QUEUE(), rec, 'prepare queue');
|
|
6304
|
+
if (!wrote.ok) return json(res, { ok: false, error: wrote.error }, 500);
|
|
6305
|
+
return json(res, { ok: true, entry: rec });
|
|
6306
|
+
}
|
|
6307
|
+
if (p === '/api/proactive/prepare/cancel' && req.method === 'POST') {
|
|
6308
|
+
let body = {}; try { body = JSON.parse((await readBody(req)) || '{}'); } catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
|
|
6309
|
+
const e = queueEntries().find((x) => x.id === String(body.id || ''));
|
|
6310
|
+
if (!e) return json(res, { ok: false, error: 'no such queued entry' }, 404);
|
|
6311
|
+
if (e.status !== 'queued') return json(res, { ok: false, error: `it is already ${e.status} — only a queued entry can be cancelled` }, 409);
|
|
6312
|
+
const wrote = nsAppend(PRO_QUEUE(), { id: e.id, at: nowISO(), event: 'cancelled', reason: 'cancelled by you' }, 'prepare queue');
|
|
6313
|
+
if (!wrote.ok) return json(res, { ok: false, error: wrote.error }, 500);
|
|
6314
|
+
return json(res, { ok: true });
|
|
6315
|
+
}
|
|
6316
|
+
if (p === '/api/proactive/prepare/run' && req.method === 'POST') {
|
|
6317
|
+
// A PM press: look at the queue now. Quiet hours are ignored; the switch, the budget, the plan hash and the 24 hours are not.
|
|
6318
|
+
return json(res, { ok: true, results: prepareTick({ manual: true }) });
|
|
6319
|
+
}
|
|
6320
|
+
if (p === '/api/proactive/prepare' && req.method === 'GET') {
|
|
6321
|
+
const entries = queueEntries().slice(-30).reverse().map((e) => ({ ...e, label: e.key + ' · ' + e.initiative_id }));
|
|
6322
|
+
return json(res, { ok: true, ...prepareState(), entries });
|
|
6323
|
+
}
|
|
6224
6324
|
/* ── proactive engine (D1): Today is computed, feedback is recorded, nothing is sent, started or changed ── */
|
|
6225
6325
|
if (p === '/api/proactive/today' && req.method === 'GET') {
|
|
6226
6326
|
const now = Date.now();
|