create-pmos 0.131.0 → 0.133.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/bin/pmos.js +18 -2
- package/workspace/guide/data-freshness.md +40 -0
- package/workspace/guide/index.md +2 -0
- package/workspace/guide/welcome.md +38 -0
- package/workspace/lib/calendar-cli.js +84 -0
- package/workspace/lib/facts-async.js +144 -35
- package/workspace/lib/freshness.js +167 -0
- package/workspace/lib/import-overlay.js +32 -0
- package/workspace/lib/offloop.js +94 -0
- package/workspace/lib/pmos-verbs.js +3 -0
- package/workspace/lib/route-modules.js +2 -0
- package/workspace/lib/routes-board.js +3 -1
- package/workspace/lib/routes-my-work.js +27 -9
- package/workspace/lib/routes-onboarding.js +215 -0
- package/workspace/lib/routes-sources.js +64 -0
- package/workspace/lib/vendor/scripts/anchor-resolve.js +351 -0
- package/workspace/lib/vendor/scripts/delivery-index.js +356 -0
- package/workspace/lib/vendor/scripts/intake.js +78 -0
- package/workspace/lib/vendor/scripts/lane-defs.js +226 -0
- package/workspace/lib/vendor/scripts/migrate-layout.js +775 -0
- package/workspace/lib/vendor/scripts/nine-stages.js +81 -0
- package/workspace/lib/vendor/scripts/onboarding/apply.js +110 -0
- package/workspace/lib/vendor/scripts/onboarding/brief.js +154 -0
- package/workspace/lib/vendor/scripts/onboarding/first-briefing.js +41 -0
- package/workspace/lib/vendor/scripts/onboarding/first-look.js +84 -0
- package/workspace/lib/vendor/scripts/onboarding/infer-brief.js +70 -0
- package/workspace/lib/vendor/scripts/onboarding/proposals.js +98 -0
- package/workspace/lib/vendor/scripts/onboarding/scan-worker.js +27 -0
- package/workspace/lib/vendor/scripts/onboarding/scan.js +859 -0
- package/workspace/lib/vendor/scripts/onboarding/structure.js +217 -0
- package/workspace/lib/vendor/scripts/plan-items-stage.js +271 -0
- package/workspace/lib/vendor/scripts/read-jsonl.js +53 -0
- package/workspace/lib/vendor/scripts/signals/layout-paths.js +43 -0
- package/workspace/lib/vendor/scripts/status-facts.js +214 -0
- package/workspace/lib/vendor/scripts/trust/import-guard.js +36 -0
- package/workspace/lib/vendor/scripts/trust/ledger.js +53 -0
- package/workspace/lib/vendor/scripts/workflow-state.js +1888 -0
- package/workspace/lib/vendor/workspace/lib/actor.js +91 -0
- package/workspace/lib/vendor/workspace/lib/board-cache.js +156 -0
- package/workspace/lib/vendor/workspace/lib/console-state.js +295 -0
- package/workspace/lib/vendor/workspace/lib/doc-category.js +227 -0
- package/workspace/lib/vendor/workspace/lib/factory-join.js +158 -0
- package/workspace/lib/vendor/workspace/lib/git-info.js +306 -0
- package/workspace/lib/vendor/workspace/lib/plan-items.js +922 -0
- package/workspace/lib/vendor/workspace/lib/plan-tree.js +419 -0
- package/workspace/lib/vendor/workspace/lib/review-gate.js +39 -0
- package/workspace/lib/vendor/workspace/lib/sink.js +321 -0
- package/workspace/lib/vendor/workspace/lib/state-root.js +587 -0
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/public/pm/c3-shell.js +4 -1
- package/workspace/public/pm/components.css +313 -0
- package/workspace/public/pm/dest-settings.js +10 -1
- package/workspace/public/pm/freshness-line.js +123 -0
- package/workspace/public/pm/welcome.js +283 -0
- package/workspace/public/welcome.html +20 -0
- package/workspace/server.js +41 -4
- package/workspace/skills/calendar-sync.skill +30 -0
- package/workspace/test-lists/onboarding-firstrun.json +15 -0
- package/workspace/test-lists/workspace-data-freshness.json +6 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-pmos",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.133.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
|
@@ -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-11T04:26:11Z"}
|
|
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-11T04:26:11Z"}
|
|
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-11T04:26:11Z"}
|
|
6
6
|
status: stable
|
|
7
7
|
---
|
|
8
8
|
|
package/workspace/bin/pmos.js
CHANGED
|
@@ -188,6 +188,7 @@ function ensureInstalled() {
|
|
|
188
188
|
// Register the target directory and open the browser ON IT. The `focus` broadcast matters:
|
|
189
189
|
// the OS may refocus an already-open tab instead of opening our URL, and without it the PM
|
|
190
190
|
// would be looking at the previously-opened repo — the defect this replaces.
|
|
191
|
+
let FORCE_WELCOME = false; // `pmos welcome [path]`
|
|
191
192
|
async function openWorkspace(dir, port, { open = true } = {}) {
|
|
192
193
|
const root = repoRootOf(dir);
|
|
193
194
|
const reg = await api(port, 'POST', '/api/workspaces', { root, name: path.basename(root) });
|
|
@@ -199,7 +200,17 @@ async function openWorkspace(dir, port, { open = true } = {}) {
|
|
|
199
200
|
const w = reg.workspace;
|
|
200
201
|
say(`✓ workspace “${w.name}” — ${urlOf(port)}?ws=${w.id}`);
|
|
201
202
|
say(` ${root}`);
|
|
202
|
-
|
|
203
|
+
// First run (onboarding-scan-and-map): a plain folder PMOS has never imported opens on Welcome (the route's first_run);
|
|
204
|
+
// `pmos welcome` opens it on purpose. Any failure keeps the old URL.
|
|
205
|
+
let first = FORCE_WELCOME;
|
|
206
|
+
if (!first) {
|
|
207
|
+
try {
|
|
208
|
+
const st = await api(port, 'GET', `/api/onboarding/status?ws=${encodeURIComponent(w.id)}`);
|
|
209
|
+
first = !!(st && st.ok && st.first_run);
|
|
210
|
+
} catch { first = false; }
|
|
211
|
+
}
|
|
212
|
+
if (first) say(FORCE_WELCOME ? '✓ opening Welcome' : '✓ first run — opening Welcome');
|
|
213
|
+
if (open) openBrowser(first ? `${urlOf(port)}/welcome?ws=${w.id}` : `${urlOf(port)}?ws=${w.id}`);
|
|
203
214
|
return 0;
|
|
204
215
|
}
|
|
205
216
|
|
|
@@ -511,9 +522,10 @@ function doctor() {
|
|
|
511
522
|
if (r.error) { say('✗ could not run ' + npx + ' — ' + r.error.message); code = 1; }
|
|
512
523
|
else code = r.status === null ? 1 : r.status;
|
|
513
524
|
} else if (cmd === 'help' || cmd === '--help' || cmd === '-h') {
|
|
514
|
-
say('usage: pmos [<path>|status|ls|stop|doctor|phone|upgrade|schedule|skill|proactive|kg|item|' + require('../lib/pmos-verbs.js').names().join('|') + ']');
|
|
525
|
+
say('usage: pmos [<path>|welcome|status|ls|stop|doctor|phone|upgrade|schedule|skill|proactive|kg|item|' + require('../lib/pmos-verbs.js').names().join('|') + ']');
|
|
515
526
|
say(' pmos open the current repo as a workspace (starts the server if needed)');
|
|
516
527
|
say(' pmos <path> open another repo as a workspace');
|
|
528
|
+
say(' pmos welcome [<path>] scan a folder and bring its products and initiatives into PMOS (undoable)');
|
|
517
529
|
say(' pmos phone turn on phone access over Tailscale and show the QR code to scan');
|
|
518
530
|
say(' pmos schedule … create, edit and run scheduled tasks (pmos schedule --help)');
|
|
519
531
|
say(' pmos skill … list, read, create and edit skills');
|
|
@@ -523,6 +535,10 @@ function doctor() {
|
|
|
523
535
|
for (const line of require('../lib/pmos-verbs.js').helpLines()) say(line);
|
|
524
536
|
say(' pmos upgrade reinstall the workspace app from the latest published create-pmos');
|
|
525
537
|
code = 0;
|
|
538
|
+
} else if (cmd === 'welcome') {
|
|
539
|
+
// the first-run page on purpose: scan this folder, import it, or undo an import (onboarding-scan-and-map)
|
|
540
|
+
FORCE_WELCOME = true;
|
|
541
|
+
code = await up(process.argv[3] && !process.argv[3].startsWith('-') ? path.resolve(process.argv[3]) : process.cwd());
|
|
526
542
|
} else if (cmd === 'up' || cmd === 'start' || cmd === 'open') {
|
|
527
543
|
// `pmos open ../other-repo` — the path is the argument AFTER the verb
|
|
528
544
|
code = await up(process.argv[3] && !process.argv[3].startsWith('-') ? path.resolve(process.argv[3]) : process.cwd());
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
---
|
|
2
|
+
type: Guide
|
|
3
|
+
title: "Data freshness: how the workspace keeps up to date, and how old each source is"
|
|
4
|
+
timestamp: 2026-10-11
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Data freshness
|
|
8
|
+
|
|
9
|
+
The workspace reads your repositories, and your agents read Jira, your calendar and My work for it. This page says when each one is read again and where you see how old it is.
|
|
10
|
+
|
|
11
|
+
## Your repositories
|
|
12
|
+
|
|
13
|
+
The workspace checks each registered repository every 10 seconds for a change: a commit, a checkout, a pull, or a file saved by you or an agent. The check is two read-only git commands. When something changed and then stays the same for 3 seconds, the workspace reads the repository again. It reads nothing when nothing changed.
|
|
14
|
+
|
|
15
|
+
- **Initiatives** (Briefing, Calendar and the reports) are read in a separate process, so the workspace keeps answering while it reads. While a read runs, the pages show the last result. The result is kept on this machine, so after a restart the pages open at once with the last result and a new read starts only if the repository changed.
|
|
16
|
+
- **Board and My work** are folded in a separate thread for the same reason.
|
|
17
|
+
|
|
18
|
+
To stop the checks for one repository, press **Stop watching this repository** in Settings › Data freshness. The pages then show what was read last, and **Refresh** reads it again. Press **Watch this repository for changes** to start the checks again.
|
|
19
|
+
|
|
20
|
+
## Jira, calendar, My work and tracker items
|
|
21
|
+
|
|
22
|
+
The workspace holds no password or token, and it never calls these services. An agent session reads each one with your own connection, on a schedule or when you press **Refresh**:
|
|
23
|
+
|
|
24
|
+
| Source | Skill | Default schedule | Shown as old after |
|
|
25
|
+
|---|---|---|---|
|
|
26
|
+
| Calendar | calendar-sync | 06:30 and 12:30 every day (set `calendar`) | 8 hours |
|
|
27
|
+
| Jira status | jira-sync | 06:30 every day (set `jira`) | 26 hours |
|
|
28
|
+
| My work | my-work-read | when you press Read again | 26 hours |
|
|
29
|
+
| Tracker items | tracker-refresh | Friday 15:30 (set `tracker`) | 8 days |
|
|
30
|
+
|
|
31
|
+
The calendar-sync skill reads today and the next 14 days, read-only, and saves the events with this command:
|
|
32
|
+
|
|
33
|
+
- `pmos calendar import --from <file|-> --sensitivity <label>` saves the events the agent read. The label is `public`, `internal`, `confidential` or `private`. `--dry-run` checks the file and saves nothing.
|
|
34
|
+
- `pmos calendar status` says how many events are saved and when they were read.
|
|
35
|
+
|
|
36
|
+
The events are kept in your own runtime folder on this machine, never in a repository.
|
|
37
|
+
|
|
38
|
+
## Where you see it
|
|
39
|
+
|
|
40
|
+
Each page shows a short **as of** line for the sources it reads. The line turns amber when a source is older than its limit, and says **never read** when nothing was read yet. Settings › Data freshness shows every source in one table: when it was read, its state, its schedule, and **Refresh**.
|
package/workspace/guide/index.md
CHANGED
|
@@ -39,10 +39,12 @@ On a desktop the workspace is one frame. [Layout](layout.md) explains each part.
|
|
|
39
39
|
|
|
40
40
|
## Where to read next
|
|
41
41
|
|
|
42
|
+
- [Welcome (first run)](welcome.md): scan a new folder, review the proposed portfolio, import it and undo it.
|
|
42
43
|
- [The Briefing](briefing.md): the console's first page: Today's meetings, Needs you (Done, Not yet, Drop) and the Portfolio pulse.
|
|
43
44
|
- [Start the workspace without a terminal](launcher.md): the PMOS app, the login item and `pmos launcher`.
|
|
44
45
|
- [Jira tickets](jira.md): Jira tickets drafted from the PRD, pushed when you select them, and their status.
|
|
45
46
|
- [My work](my-work.md): the work assigned to you, its order, the brief to an agent, and `pmos my-work`.
|
|
47
|
+
- [Data freshness](data-freshness.md): when each source is read again, how old it is, and `pmos calendar`.
|
|
46
48
|
- [People](people.md): the people you work with in a repository, how they get there, and `pmos people`.
|
|
47
49
|
- [Docs](docs.md): whether each linked document is the same as, ahead of or behind its Google Docs and Confluence copy.
|
|
48
50
|
- [Calendar popup](calendar-popup.md): open the full calendar over any page of the console with the calendar button or the C key, and close it.
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
---
|
|
2
|
+
type: Guide
|
|
3
|
+
title: "Guide: Welcome (first run)"
|
|
4
|
+
timestamp: 2026-10-11
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Welcome (first run)
|
|
8
|
+
|
|
9
|
+
The Welcome page is the first screen of a new folder. It reads your folder, shows what it found, and sets up your portfolio from it. You do not need to answer questions first.
|
|
10
|
+
|
|
11
|
+
## How you get there
|
|
12
|
+
|
|
13
|
+
Run `pmos` in a plain folder that PMOS has not imported. The terminal says "first run — opening Welcome" and the browser opens `/welcome`. A folder that already has its own PMOS state folder opens the console as before. After an import, `pmos` opens the console.
|
|
14
|
+
|
|
15
|
+
Run `pmos welcome` to open the page on purpose, for example to scan a folder again or to undo an import. `pmos welcome <path>` opens it for another folder.
|
|
16
|
+
|
|
17
|
+
Choose **Not now** on the first screen to go to the console. Welcome then stops opening on its own for that folder. `pmos welcome` still opens it.
|
|
18
|
+
|
|
19
|
+
## The promise
|
|
20
|
+
|
|
21
|
+
The page says it before the scan: PMOS reads only, writes nothing in your folder, and nothing leaves this computer. The scan runs on your machine and uses no model. The import is stored in the PMOS state folder, never in your folder.
|
|
22
|
+
|
|
23
|
+
## The steps
|
|
24
|
+
|
|
25
|
+
1. **Welcome.** The page shows the folder name and path. Choose **Add another folder** to open the system folder chooser and add a second folder. Choose **Scan** to start. A spinner shows while the scan runs. A large folder can take a minute or two, and the rest of PMOS keeps working.
|
|
26
|
+
2. **First look.** One sentence gives the counts: initiatives, documents, deadlines, stale documents, duplicates, people and risks. Up to ten findings follow. Each finding shows the file and line it comes from. Choose **Continue**.
|
|
27
|
+
3. **Your portfolio.** PMOS proposes one board for each product folder (`products/<name>/`). If there is no product folder, it proposes one board for the whole folder. Each board lists its initiatives. Each row has a checkbox, a title you can edit, a lane, an anchor, a stage and a document count. Choose the document count to see the documents. Use the board list on a row to move it to another board. Each board shows how many documents PMOS could not place. High and medium confidence rows start checked. The button reads **Import N initiatives into M boards**. The numbers change as you check, uncheck and move rows.
|
|
28
|
+
4. **Questions.** This step appears only when the scan could not decide something. There are three questions at most. Each question shows its default already chosen. Choose **Import** to accept what is chosen. Choose **Skip** to leave a question open: for the anchor question, the proposed anchors then stay marked for you to confirm later.
|
|
29
|
+
5. **First Briefing.** After the import, the page lists up to five things that need you first. An item that belongs to an imported initiative names the initiative and its board. Choose **Continue**. Choose **Undo this import** to take the import back and return to your portfolio.
|
|
30
|
+
6. **More sources.** This step is optional. It offers Calendar (your meetings today and for the next 14 days) and Jira (the issues assigned to you). PMOS reads nothing from them until you choose **Bring in**. Bring in starts the source's read-only skill as an agent session in the workspace, with your own connection. The skill writes nothing to the tool. Choose **Skip** to leave a source out. You can bring it in later from Settings. Inbox and Slack have no import yet. Choose **Open PMOS** to go to the console.
|
|
31
|
+
|
|
32
|
+
## Undo
|
|
33
|
+
|
|
34
|
+
**Undo this import** retracts the import. Nothing is deleted: the import and its undo stay in the record. You can then change the portfolio and import again. When you open Welcome after an import, the page shows the first Briefing with the Undo link.
|
|
35
|
+
|
|
36
|
+
## What the page does not do
|
|
37
|
+
|
|
38
|
+
The page does not change your folder. It does not open the files that the exclusion list names, such as `.git`, `node_modules` and files that look like secrets. It does not read Calendar or Jira until you choose **Bring in** on More sources, and the PMOS server itself calls nothing outside this computer. It does not import from Drive, Inbox, Slack or other tools.
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* calendar-cli.js — `pmos calendar …`: the calendar events an agent session read, saved for Briefing and Calendar
|
|
3
|
+
* (workspace-data-freshness R8).
|
|
4
|
+
* pmos calendar import --from <file|-> --sensitivity public|internal|confidential|private [--dry-run] [--json]
|
|
5
|
+
* pmos calendar status [--json]
|
|
6
|
+
* This command never talks to a calendar. The calendar-sync skill reads it in an agent session, with the PM's own
|
|
7
|
+
* connection, and hands the events to `import`. The import goes through scripts/calendar-import.js unchanged: hidden text
|
|
8
|
+
* is stripped, every line is marked, the raw text is hashed, and a missing sensitivity is refused. The events are kept in
|
|
9
|
+
* the per-user runtime folder, never in a repository.
|
|
10
|
+
*/
|
|
11
|
+
const fs = require('fs');
|
|
12
|
+
const path = require('path');
|
|
13
|
+
const stateRoot = require('./state-root');
|
|
14
|
+
const CI = require('../../scripts/calendar-import.js');
|
|
15
|
+
const L = require('../../scripts/trust/ledger.js');
|
|
16
|
+
|
|
17
|
+
const COMMANDS = { calendar: ['import', 'status'] };
|
|
18
|
+
const USAGE = [
|
|
19
|
+
'usage: pmos calendar import|status — the calendar events an agent session read for Briefing and Calendar',
|
|
20
|
+
' pmos calendar import --from <file|-> --sensitivity <label> [--dry-run] save the events (the calendar-sync skill runs this)',
|
|
21
|
+
' pmos calendar status how many events are saved, and when they were read',
|
|
22
|
+
' every command: [--json]',
|
|
23
|
+
'This command never calls a calendar. Your agent reads it with your own connection.',
|
|
24
|
+
];
|
|
25
|
+
const BOOL = new Set(['json', 'dry-run', 'help']);
|
|
26
|
+
const VALUE = new Set(['from', 'sensitivity']);
|
|
27
|
+
|
|
28
|
+
function parseArgs(argv) {
|
|
29
|
+
const out = { _: [] };
|
|
30
|
+
for (let i = 0; i < argv.length; i++) {
|
|
31
|
+
const t = argv[i];
|
|
32
|
+
if (!t.startsWith('--')) { out._.push(t); continue; }
|
|
33
|
+
const eq = t.indexOf('=');
|
|
34
|
+
const key = eq > 0 ? t.slice(2, eq) : t.slice(2);
|
|
35
|
+
if (BOOL.has(key)) out[key] = true;
|
|
36
|
+
else if (eq > 0) out[key] = t.slice(eq + 1);
|
|
37
|
+
else if (VALUE.has(key) && argv[i + 1] !== undefined) out[key] = argv[++i];
|
|
38
|
+
else out[key] = '';
|
|
39
|
+
}
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const ledgerOf = (dir) => path.join(dir, 'calendar-import', 'events.jsonl');
|
|
44
|
+
|
|
45
|
+
function readInput(from, stdin) {
|
|
46
|
+
if (from === '-') return stdin();
|
|
47
|
+
return fs.readFileSync(path.resolve(from), 'utf8');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function run(argv, { say = console.log, env = process.env, stateDir, stdin = () => fs.readFileSync(0, 'utf8'), now = () => new Date(), isTTY } = {}) {
|
|
51
|
+
const a = parseArgs(argv.slice(1));
|
|
52
|
+
const sub = a._ && a._[0];
|
|
53
|
+
const out = (o, line) => { if (a.json) say(JSON.stringify(o)); else say(line); };
|
|
54
|
+
if (!sub || a.help || sub === 'help') { USAGE.forEach((l) => say(l)); return sub || a.help ? 0 : 2; }
|
|
55
|
+
if (!COMMANDS.calendar.includes(sub)) { say(`✗ pmos calendar ${sub} is not a command`); USAGE.forEach((l) => say(l)); return 2; }
|
|
56
|
+
const dir = stateDir || stateRoot.defaultRoot({ env });
|
|
57
|
+
const file = ledgerOf(dir);
|
|
58
|
+
|
|
59
|
+
if (sub === 'status') {
|
|
60
|
+
const events = CI.foldEvents(CI.readLines(file));
|
|
61
|
+
const last = events.reduce((m, e) => (e.fetched_at && e.fetched_at > m ? e.fetched_at : m), '');
|
|
62
|
+
out({ ok: true, events: events.length, read_at: last || null }, events.length ? `${events.length} events saved, last read ${last}` : 'no calendar events are saved yet');
|
|
63
|
+
return 0;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (!a.from) { say('✗ --from <file|-> is required'); return 2; }
|
|
67
|
+
let raw;
|
|
68
|
+
try { raw = JSON.parse(readInput(a.from, stdin)); } catch (e) { say(`✗ the events file is not JSON: ${String(e.message).slice(0, 160)}`); return 1; }
|
|
69
|
+
const list = Array.isArray(raw) ? raw : raw && Array.isArray(raw.events) ? raw.events : null;
|
|
70
|
+
if (!list) { say('✗ the file must hold a list of events, or { "events": [ … ] }'); return 1; }
|
|
71
|
+
const at = now().toISOString();
|
|
72
|
+
let evs;
|
|
73
|
+
try { evs = CI.importEvents(list, { fetchedAt: at, sensitivity: a.sensitivity }); }
|
|
74
|
+
catch (e) { say(`✗ ${e.message}`); return 1; }
|
|
75
|
+
if (a['dry-run']) { out({ ok: true, dryRun: true, count: evs.length }, `would save ${evs.length} events (dry run, nothing written)`); return 0; }
|
|
76
|
+
const actor = L.actorFrom({ env, isTTY: isTTY == null ? !!(process.stdin.isTTY && process.stdout.isTTY) : isTTY });
|
|
77
|
+
try { CI.appendImport(file, evs, actor, at); }
|
|
78
|
+
catch (e) { say(`✗ the events could not be saved: ${String(e.message).slice(0, 160)}`); return 1; }
|
|
79
|
+
const flagged = evs.filter((e) => e.findings && e.findings.length).length;
|
|
80
|
+
out({ ok: true, count: evs.length, flagged, read_at: at }, `✓ saved ${evs.length} calendar events${flagged ? ` (${flagged} carried hidden or suspicious text, marked)` : ''}`);
|
|
81
|
+
return 0;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
module.exports = { run, COMMANDS, USAGE };
|
|
@@ -1,51 +1,160 @@
|
|
|
1
1
|
'use strict';
|
|
2
|
-
/* facts-async.js — the repo's initiative facts (scripts/signals/facts.js readRepoFacts) WITHOUT blocking the server
|
|
2
|
+
/* facts-async.js — the repo's initiative facts (scripts/signals/facts.js readRepoFacts) WITHOUT blocking the server, and
|
|
3
|
+
* read again only when the repository changed (workspace-data-freshness R2, R3).
|
|
3
4
|
*
|
|
4
|
-
* readRepoFacts derives every initiative's stage from git
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
5
|
+
* readRepoFacts derives every initiative's stage from git and is synchronous, so it runs in a child process
|
|
6
|
+
* (scripts/signals/facts-cli.js). One read in flight is shared between callers.
|
|
7
|
+
*
|
|
8
|
+
* ONE ENTRY PER REPOSITORY. The entry is not keyed by day: a caller that passes `today` gets the same facts normalized for
|
|
9
|
+
* that day (normalizeFacts is pure and cheap), so Briefing and Calendar share one read instead of running two.
|
|
10
|
+
*
|
|
11
|
+
* REFRESHED ON A CHANGE, NOT ON A CLOCK. The entry carries the repository's signature (lib/repo-watch.js: HEAD, the
|
|
12
|
+
* porcelain status, and the size and time of each dirty file) and the engine version (a hash of the scripts that derive
|
|
13
|
+
* the facts). A read whose signature and engine match is served from the entry. A changed signature returns the entry at
|
|
14
|
+
* once and starts one rebuild (stale while revalidate); only a repository never read before waits. An entry older than
|
|
15
|
+
* MAX_AGE_MS is rebuilt in the background even when the signature holds, which catches an input the signature cannot see
|
|
16
|
+
* (an ignored file). A folder that is not a git checkout has no signature and keeps the old rule: a TTL of TTL_MS.
|
|
17
|
+
*
|
|
18
|
+
* KEPT ON DISK. configure({ stateDir }) names the runtime state folder; each entry is then written to
|
|
19
|
+
* <stateDir>/facts-cache/<hash of root>.json and read back after a restart. Never into the repository. Unconfigured (a
|
|
20
|
+
* suite), nothing is written.
|
|
9
21
|
*/
|
|
10
22
|
const { execFile } = require('child_process');
|
|
23
|
+
const crypto = require('crypto');
|
|
24
|
+
const fs = require('fs');
|
|
11
25
|
const path = require('path');
|
|
26
|
+
const repoWatch = require('./repo-watch.js');
|
|
12
27
|
|
|
13
28
|
const CLI = path.resolve(__dirname, '..', '..', 'scripts', 'signals', 'facts-cli.js');
|
|
14
|
-
const TTL_MS = 5 * 60 * 1000;
|
|
15
|
-
const
|
|
16
|
-
const
|
|
29
|
+
const TTL_MS = 5 * 60 * 1000; // only for a folder with no signature
|
|
30
|
+
const MAX_AGE_MS = 24 * 3600 * 1000; // an entry this old is rebuilt even when the signature holds
|
|
31
|
+
const SIG_REUSE_MS = 2000; // a burst of requests asks git once
|
|
32
|
+
const memo = new Map(); // root -> { at, sig, engine, facts, ms }
|
|
33
|
+
const inflight = new Map(); // root -> Promise
|
|
34
|
+
const sigMemo = new Map(); // root -> { at, sig }
|
|
35
|
+
const lastError = new Map(); // root -> { at, error }
|
|
36
|
+
let stateDir = null;
|
|
37
|
+
let engineMemo = null;
|
|
38
|
+
|
|
39
|
+
const sha1 = (s) => crypto.createHash('sha1').update(s).digest('hex');
|
|
40
|
+
|
|
41
|
+
/** The version of the code that derives the facts: a change to it makes every entry stale. */
|
|
42
|
+
function engineVersion() {
|
|
43
|
+
if (engineMemo) return engineMemo;
|
|
44
|
+
const sig = path.resolve(__dirname, '..', '..', 'scripts');
|
|
45
|
+
const parts = [CLI, path.join(sig, 'signals', 'facts.js'), path.join(sig, 'workflow-state.js')].map((f) => {
|
|
46
|
+
try { return sha1(fs.readFileSync(f)); } catch { return '-'; }
|
|
47
|
+
});
|
|
48
|
+
engineMemo = sha1(parts.join('|')).slice(0, 12);
|
|
49
|
+
return engineMemo;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function configure(o = {}) { stateDir = o.stateDir || null; }
|
|
53
|
+
const fileOf = (root) => (stateDir ? path.join(stateDir, 'facts-cache', sha1(root).slice(0, 16) + '.json') : null);
|
|
54
|
+
|
|
55
|
+
function loadDisk(root) {
|
|
56
|
+
const f = fileOf(root);
|
|
57
|
+
if (!f) return null;
|
|
58
|
+
try {
|
|
59
|
+
const e = JSON.parse(fs.readFileSync(f, 'utf8'));
|
|
60
|
+
return e && e.root === root && e.facts && typeof e.at === 'number' ? e : null;
|
|
61
|
+
} catch { return null; }
|
|
62
|
+
}
|
|
63
|
+
function saveDisk(root, entry) {
|
|
64
|
+
const f = fileOf(root);
|
|
65
|
+
if (!f) return;
|
|
66
|
+
try {
|
|
67
|
+
fs.mkdirSync(path.dirname(f), { recursive: true });
|
|
68
|
+
const tmp = `${f}.${process.pid}.tmp`;
|
|
69
|
+
fs.writeFileSync(tmp, JSON.stringify({ root, ...entry }));
|
|
70
|
+
fs.renameSync(tmp, f);
|
|
71
|
+
} catch { /* a cache that cannot be written is only a slower next start */ }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function currentSig(root, opts, clock) {
|
|
75
|
+
if (opts.signature) return opts.signature(root);
|
|
76
|
+
const hit = sigMemo.get(root);
|
|
77
|
+
if (hit && clock() - hit.at < SIG_REUSE_MS) return hit.sig;
|
|
78
|
+
let sig = null;
|
|
79
|
+
try {
|
|
80
|
+
// a folder with no HEAD is not a checkout: no signature, and the TTL rule decides
|
|
81
|
+
const head = await new Promise((resolve) => execFile('git', ['rev-parse', 'HEAD'], { cwd: root, timeout: 15000 }, (e, out) => resolve(e ? null : out)));
|
|
82
|
+
if (head != null) sig = await repoWatch.signature(root, async () => ({ files: [] }));
|
|
83
|
+
} catch { sig = null; }
|
|
84
|
+
sigMemo.set(root, { at: clock(), sig });
|
|
85
|
+
return sig;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function normalize(facts, today) {
|
|
89
|
+
try { return require('../../scripts/signals/facts.js').normalizeFacts(facts, today || undefined); } catch { return facts; }
|
|
90
|
+
}
|
|
17
91
|
|
|
18
92
|
function readFactsAsync(root, opts = {}) {
|
|
19
|
-
const today = opts.today || '';
|
|
20
93
|
const resolved = path.resolve(root || process.cwd());
|
|
21
|
-
const key = resolved + '|' + today;
|
|
22
94
|
const clock = opts.clock || Date.now;
|
|
23
|
-
const
|
|
24
|
-
const
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
95
|
+
const today = opts.today || '';
|
|
96
|
+
const out = (facts) => (opts.run ? facts : normalize(facts, today));
|
|
97
|
+
return (async () => {
|
|
98
|
+
let hit = memo.get(resolved);
|
|
99
|
+
if (!hit) { const d = loadDisk(resolved); if (d) { hit = d; memo.set(resolved, d); } }
|
|
100
|
+
const sig = await currentSig(resolved, opts, clock);
|
|
101
|
+
const engine = opts.run ? 'test' : engineVersion();
|
|
102
|
+
|
|
103
|
+
const start = () => {
|
|
104
|
+
if (inflight.has(resolved)) return inflight.get(resolved);
|
|
105
|
+
const run = opts.run || ((r) => new Promise((resolve, reject) => {
|
|
106
|
+
execFile(process.execPath, [CLI, r], { maxBuffer: 512 * 1024 * 1024, timeout: 15 * 60 * 1000 }, (err, stdout) => {
|
|
107
|
+
if (err) return reject(err);
|
|
108
|
+
try { resolve(JSON.parse(stdout)); } catch (e) { reject(e); }
|
|
109
|
+
});
|
|
110
|
+
}));
|
|
111
|
+
const t0 = Date.now();
|
|
112
|
+
const p = Promise.resolve(run(resolved)).then((facts) => {
|
|
113
|
+
const entry = { at: clock(), sig, engine, facts, ms: Date.now() - t0 };
|
|
114
|
+
memo.set(resolved, entry);
|
|
115
|
+
lastError.delete(resolved);
|
|
116
|
+
saveDisk(resolved, entry);
|
|
117
|
+
return facts;
|
|
118
|
+
}, (e) => { lastError.set(resolved, { at: clock(), error: String((e && e.message) || e).slice(0, 200) }); throw e; })
|
|
119
|
+
.finally(() => { inflight.delete(resolved); });
|
|
120
|
+
inflight.set(resolved, p);
|
|
121
|
+
return p;
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
if (hit) {
|
|
125
|
+
const age = clock() - hit.at;
|
|
126
|
+
const fresh = sig != null
|
|
127
|
+
? hit.sig === sig && hit.engine === engine && age < MAX_AGE_MS
|
|
128
|
+
: age < (opts.ttlMs == null ? TTL_MS : opts.ttlMs);
|
|
129
|
+
if (fresh) return out(hit.facts);
|
|
130
|
+
if (opts.stale !== false) { start().catch(() => {}); return out(hit.facts); }
|
|
131
|
+
}
|
|
132
|
+
return out(await start());
|
|
133
|
+
})();
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** How old the facts of `root` are, for the freshness surface (R6). Reads nothing but memory and the disk entry. */
|
|
137
|
+
function status(root) {
|
|
138
|
+
const resolved = path.resolve(root);
|
|
139
|
+
const hit = memo.get(resolved) || loadDisk(resolved);
|
|
140
|
+
const err = lastError.get(resolved);
|
|
141
|
+
return {
|
|
142
|
+
as_of: hit ? new Date(hit.at).toISOString() : null,
|
|
143
|
+
building: inflight.has(resolved),
|
|
144
|
+
took_ms: hit && hit.ms != null ? hit.ms : null,
|
|
145
|
+
error: err ? err.error : null,
|
|
41
146
|
};
|
|
147
|
+
}
|
|
42
148
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
149
|
+
/** Start a rebuild now (a change the watcher saw, or the PM's Refresh). The signature is asked again first. */
|
|
150
|
+
function refresh(root, opts = {}) {
|
|
151
|
+
const resolved = path.resolve(root);
|
|
152
|
+
sigMemo.delete(resolved);
|
|
153
|
+
const hit = memo.get(resolved);
|
|
154
|
+
if (hit && opts.force) memo.set(resolved, { ...hit, sig: '(forced)' });
|
|
155
|
+
return readFactsAsync(resolved, opts).catch(() => null);
|
|
47
156
|
}
|
|
48
157
|
|
|
49
|
-
function resetFactsCache() { memo.clear(); inflight.clear(); }
|
|
158
|
+
function resetFactsCache() { memo.clear(); inflight.clear(); sigMemo.clear(); lastError.clear(); }
|
|
50
159
|
|
|
51
|
-
module.exports = { readFactsAsync, resetFactsCache, TTL_MS };
|
|
160
|
+
module.exports = { readFactsAsync, resetFactsCache, refresh, status, configure, engineVersion, TTL_MS, MAX_AGE_MS };
|