create-pmos 0.55.4 → 0.56.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-pmos",
3
- "version": "0.55.4",
3
+ "version": "0.56.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": {
@@ -1,3 +1,3 @@
1
- kernel-source: pmos@46269fd
1
+ kernel-source: pmos@b024452
2
2
  assembled: 2026-10-06
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-06T09:30:22Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-06T10:33:25Z"}
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-06T09:30:22Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-06T10:33:25Z"}
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-06T09:30:22Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-06T10:33:25Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -0,0 +1,96 @@
1
+ ---
2
+ type: Guide
3
+ title: "Docs: is each document the same as its Google Docs and Confluence copy, and which version to use"
4
+ timestamp: 2026-10-06
5
+ ---
6
+
7
+ # Docs
8
+
9
+ Open Docs from ⌘K (**Docs**) or press `g d`.
10
+
11
+ Docs lists every document in the repository that has a copy in Google Docs or Confluence. For each copy, it shows whether the repository version (PMOS) or the copy is newer. You can then use one version over the other, one copy at a time.
12
+
13
+ Nothing syncs by itself. A copy changes only when you press a button for that copy.
14
+
15
+ ## The list
16
+
17
+ The copies are grouped by initiative. Copies of files that no initiative owns are under **Other**.
18
+
19
+ **Needs you** shows the copies that are not the same. **All** shows every copy.
20
+
21
+ Each copy is one line:
22
+
23
+ - a status dot;
24
+ - the document title, and a link that opens the copy in a new tab;
25
+ - one sentence, for example "PMOS is newer · You, today 09:12";
26
+ - at most one button.
27
+
28
+ | Sentence | Means | Button |
29
+ |---|---|---|
30
+ | Same | The last compare found the same text, and neither side changed since. | none |
31
+ | PMOS is newer | The repository file changed since the two sides last matched. The copy did not. | Use PMOS version |
32
+ | Confluence is newer (or Google Docs) | The copy changed since the two sides last matched. The repository file did not. | Use Confluence version |
33
+ | Edited on both sides | Both changed since the two sides last matched. | Choose… |
34
+ | Not compared yet | No compare has read the copy. | none |
35
+ | Can't open in Confluence | The compare could not read the copy. The reason is shown. | none |
36
+ | Not found in Confluence | The copy is deleted or in the trash. | none |
37
+ | Not in PMOS any more | The repository file is not there. | none |
38
+ | Sync failed | The last sync of this copy wrote nothing. The reason is shown. | as before the sync |
39
+
40
+ "by date" after a sentence means that the two sides have never matched. The newer modified date then decides.
41
+
42
+ Frontmatter is not part of the text. A change to the frontmatter only does not change the sentence.
43
+
44
+ ## The details
45
+
46
+ Click a line to see its details:
47
+
48
+ - **PMOS**: the repository path and its last edit;
49
+ - the copy: its address and its last edit;
50
+ - a **Use … version** button for each side;
51
+ - the first difference that the compare found, when it found one;
52
+ - **Compare again** and **Stop tracking this copy**.
53
+
54
+ **Stop tracking this copy** removes the link. The copy is not deleted. Press **Undo** in the message to link it again.
55
+
56
+ ## Use a version
57
+
58
+ **Use PMOS version** writes the repository text to the copy. **Use Confluence version** (or Google Docs) writes the copy's text to the repository file.
59
+
60
+ 1. Press the button. The line says "Updating Confluence…" and a message shows **Undo**.
61
+ 2. To cancel, press **Undo** within five seconds. Nothing is changed.
62
+ 3. After five seconds, PMOS starts a session with the `doc-sync` skill. The session writes one side only, with your own Google Drive or Atlassian connector. If PMOS asks you to allow the write, allow it in the session.
63
+ 4. When the session finishes, the line says **Same**.
64
+
65
+ A sync never creates, moves or deletes a page or a file. When the copy's text is written to the repository file, the file's frontmatter stays as it is. The session does not commit: use Sync in the bar to commit and push the change.
66
+
67
+ ## Compare
68
+
69
+ PMOS cannot read Google Docs or Confluence itself. **↻** next to "Compared … ago" starts a session that reads every copy with your own connectors. The session reads only. The sentences change when it finishes.
70
+
71
+ A compare runs only when you press it. There is no schedule. An edit to a repository file changes its sentence at once, without a compare.
72
+
73
+ ## Add a copy
74
+
75
+ Type in **Find a document**. The search finds every markdown file in the repository by its title or path, also files that have no copy. A file with no copy shows **No copy** and **Add copy**.
76
+
77
+ Press **Add copy**, paste the address of the Google Doc or Confluence page, and press Enter. A document can have one Google Doc and one Confluence page.
78
+
79
+ A link that you add in an initiative's artifacts is the same link and shows here too.
80
+
81
+ ## From a terminal
82
+
83
+ | Command | Does |
84
+ |---|---|
85
+ | `pmos docs list` | Each linked document and its signal per copy. `--attention` lists only what is not the same. |
86
+ | `pmos docs find [words]` | The repository's markdown files that match, and their copies. |
87
+ | `pmos docs link <file> <address>` | Links a repository file to a Google Doc or a Confluence page. |
88
+ | `pmos docs unlink <file> google` | Removes the link (`google` or `confluence`). The copy is not touched. |
89
+ | `pmos docs check-start` | Used by the compare session: opens a compare and lists what to read. |
90
+ | `pmos docs record <check> --file <readings.json>` | Used by the compare session: records what it read. |
91
+ | `pmos docs check-finish <check>` | Used by the compare session: closes the compare. |
92
+ | `pmos docs sync-start <file> confluence --use pmos` | Used by the sync session: opens a sync of one copy (`--use copy` for the other direction). |
93
+ | `pmos docs sync-finish <sync> --version <v>` | Used by the sync session: closes the sync. The two sides are now the same. |
94
+ | `pmos docs sync-fail <sync> --reason "…"` | Used by the sync session: closes the sync. Nothing was written. |
95
+
96
+ The session commands must name who read or wrote. In a workspace session that is the session. Outside the workspace, add `--by <profile>`.
@@ -13,7 +13,7 @@ This guide describes the PMOS workspace as it works today. It ships with the app
13
13
  On a desktop the workspace is one frame. [Layout](layout.md) explains each part.
14
14
 
15
15
  1. **The top bar.** Repository ▾, the four places, search (⌘K), Sync when the repository is not in sync, and Settings.
16
- 2. **The place.** One of Today, Initiatives, Board or Reports. It fills the screen under the bar.
16
+ 2. **The place.** One of Today, Initiatives, Board or Reports, or Docs (⌘K or `g d`). It fills the screen under the bar.
17
17
  3. **The detail.** The item you opened: an initiative with its status, next steps and artifacts, or the profiles. It opens beside the place and closes with ×.
18
18
  4. **The agent dock.** Terminals, documents, conversations and web pages. It is open on Today and a thin rail elsewhere.
19
19
 
@@ -26,6 +26,7 @@ On a desktop the workspace is one frame. [Layout](layout.md) explains each part.
26
26
  | The sessions of a repository, by initiative | The agent dock. ⌘K → **Branches and sessions** lists the branches. |
27
27
  | The items of an initiative | Board, or Breakdown in the initiative detail |
28
28
  | Run reports | Reports |
29
+ | Whether a document is the same as its Google Docs or Confluence copy | Docs: ⌘K → **Docs**, or press `g d` |
29
30
  | Repositories, Profiles, New sessions (the default CLI and model), Validate consent, Theme | Settings |
30
31
  | Validate | Settings, under Validate consent: **Open Validate** |
31
32
  | A document, a decision record, a memory entry, a page of this guide | ⌘K, with its scope: Knowledge, Memory or Guide |
@@ -35,6 +36,7 @@ On a desktop the workspace is one frame. [Layout](layout.md) explains each part.
35
36
  ## Where to read next
36
37
 
37
38
  - [Start the workspace without a terminal](launcher.md): the PMOS app, the login item and `pmos launcher`.
39
+ - [Docs](docs.md): whether each linked document is the same as, ahead of or behind its Google Docs and Confluence copy.
38
40
  - [Layout](layout.md): the frame, the keys and the rule for where a list or a detail opens.
39
41
  - [Statuses](statuses.md): what each status means, where it comes from, and what you can change.
40
42
  - [Initiatives and next steps](initiatives-and-next-steps.md): the detail, the next steps, and the artifacts.
@@ -20,7 +20,21 @@ If the morning-brief session stops before it submits a brief, the Read step says
20
20
 
21
21
  Each agent job runs in its own session, so you can open any job and follow or steer it. The workspace types the job's prompt into the session when the agent is ready to read, and presses Enter for you. When you approve or revise a job and its session is still open, the workspace uses that same session again. Prepare again works the same way: when the morning-brief session is still open and has finished its read, or has printed nothing for two minutes, the workspace types the read into that session and runs it again.
22
22
 
23
- **Agent results**, at the top of the left column, counts the jobs and lists each finished one with its draft summary or the reason it failed. The other things that wait for you, such as outcome checks, report drafts and sessions, are in **Also waiting for you** at the end of the left column. When the day is folded, they show under it.
23
+ **Agent results**, at the top of the left column, counts the jobs and lists each finished one with its draft summary or the reason it failed. Your goals for today come under it, above the calendar. The other things that wait for you, such as outcome checks, report drafts and sessions, are in **Also waiting for you** at the end of the left column. When the day is folded, they show under it.
24
+
25
+ ### One day for every repository
26
+
27
+ The day is yours, not a repository's. Every repository shows the same calendar, goals, jobs and mentions. To narrow the day, use the **Show** switch at the top of Today:
28
+
29
+ - **All repositories** shows the whole day. A job, mention, goal or watched place that belongs to a repository carries its name.
30
+ - **Repository** shows the items of the repository you opened Today from, and the items that belong to no repository. The calendar always shows.
31
+
32
+ To tie an item to a repository:
33
+
34
+ 1. For a goal, open it and pick a **Repository**. A goal you add while Today shows one repository belongs to that repository.
35
+ 2. For a Slack channel, Jira project or Confluence space, open **Sources**, open the service, and pick a repository next to the place.
36
+
37
+ A job takes the repository of its goal. A job with no goal takes the repository of the place it came from. A job with neither belongs to no repository.
24
38
 
25
39
  Each job has one of three kinds.
26
40
 
@@ -0,0 +1,322 @@
1
+ 'use strict';
2
+ /* doc-sync-store.js — the file side of the docs sync signal (initiative docs-three-way-sync).
3
+ *
4
+ * lib/doc-sync.js decides; this module reads and appends. Both the `pmos docs` verb and the /api/docs routes call it,
5
+ * so the terminal and the Docs place can never hold two opinions about a link.
6
+ *
7
+ * view(root) → { ok, docs, summary, open, last, file }
8
+ * addLink(root, { path, kind, target }) → the artifact-link row (four-panel's store — one link store, not two)
9
+ * removeLink(root, { path, kind }) → a `retracted` row; nothing is deleted
10
+ * checkStart(root, { by, now }) → { ok, run, links } — what the session must read, with each file's hash
11
+ * record(root, { run, by, readings, now }) → { ok, recorded, refused }
12
+ * checkFinish(root, { run, by, summary, now })
13
+ * syncStart(root, { path, kind, use, by, now }) → { ok, run, … } — one open sync per copy (amended 2026-10-06)
14
+ * syncFinish(root, { run, by, version, modified_at, url, title, now }) → the reading that makes the copy read Same
15
+ * syncFail(root, { run, by, reason, now })
16
+ * findFiles(root, q) → the repository's markdown files that match, for search and Add copy
17
+ * repoInfo(root, rel) · bodyHash(text) · titleOf(text, rel)
18
+ *
19
+ * Local git only (log, status): no fetch, no network. Every write is an append through doc-sync.js#checkRow or
20
+ * four-panel.js's validators, so a refused row is never on disk.
21
+ */
22
+ const fs = require('fs');
23
+ const path = require('path');
24
+ const crypto = require('crypto');
25
+ const { execFileSync } = require('child_process');
26
+ const D = require('./doc-sync');
27
+ const fourPanel = require('./four-panel');
28
+ const consoleState = require('./console-state');
29
+
30
+ const LEDGER = 'doc-checks.jsonl';
31
+ // A check left open this long is taken to have ended without saying so (the session closed). A new one may start.
32
+ const OPEN_RUN_MS = 2 * 60 * 60 * 1000;
33
+ // A sync left open this long is taken to have ended (the session closed without finishing). The copy may be synced again.
34
+ const OPEN_SYNC_MS = 30 * 60 * 1000;
35
+
36
+ function stateDir(root) {
37
+ const e = consoleState.loadEngine();
38
+ if (!e.ok) return { ok: false, error: e.error };
39
+ const dir = fourPanel.storeDir(e.engine, root);
40
+ return dir ? { ok: true, dir } : { ok: false, error: 'this repository has no file-mode state directory' };
41
+ }
42
+
43
+ /** The body a copy is compared with: the file without its leading frontmatter, line endings and trailing spaces normalised. */
44
+ function bodyOf(text) {
45
+ let t = String(text || '').replace(/\r\n?/g, '\n');
46
+ const m = /^---\n[\s\S]*?\n---[ \t]*(?:\n|$)/.exec(t);
47
+ if (m) t = t.slice(m[0].length);
48
+ return t.split('\n').map((l) => l.replace(/[ \t]+$/, '')).join('\n').replace(/^\n+/, '').replace(/\n+$/, '');
49
+ }
50
+ function bodyHash(text) {
51
+ return crypto.createHash('sha256').update(bodyOf(text)).digest('hex').slice(0, 16);
52
+ }
53
+ function titleOf(text, rel) {
54
+ const t = String(text || '');
55
+ const fm = /^---\r?\n([\s\S]*?)\r?\n---/.exec(t);
56
+ if (fm) {
57
+ const m = /^title:\s*(.+?)\s*$/m.exec(fm[1]);
58
+ if (m) return m[1].replace(/^["']|["']$/g, '').slice(0, 200);
59
+ }
60
+ const h = /^#\s+(.+)$/m.exec(bodyOf(t));
61
+ if (h) return h[1].trim().slice(0, 200);
62
+ return path.basename(rel).replace(/\.[^.]+$/, '');
63
+ }
64
+
65
+ function git(root, args) {
66
+ try { return execFileSync('git', ['-C', root, ...args], { encoding: 'utf8', timeout: 10000, stdio: ['ignore', 'pipe', 'ignore'] }).trim(); }
67
+ catch { return null; }
68
+ }
69
+
70
+ /** The repository file now: whether it exists, its body hash, its title and when it last changed. */
71
+ function repoInfo(root, rel) {
72
+ if (!D.okPath(rel)) return { exists: false };
73
+ const full = path.join(root, rel);
74
+ let text;
75
+ try { text = fs.readFileSync(full, 'utf8'); } catch { return { exists: false }; }
76
+ // The last commit time, unless the file has changes not yet committed: then its own file time is the truth.
77
+ // Who: the last commit's author, or "You" when that is this repository's git user or the change is not committed yet.
78
+ let modified_at = null;
79
+ let modified_by = null;
80
+ const dirty = git(root, ['status', '--porcelain', '--', rel]);
81
+ if (!dirty) {
82
+ const c = git(root, ['log', '-1', '--format=%cI%x09%an', '--', rel]);
83
+ if (c) {
84
+ const [when, who] = c.split('\t');
85
+ modified_at = new Date(when).toISOString();
86
+ const me = git(root, ['config', 'user.name']);
87
+ modified_by = who && me && who === me ? 'You' : (who || null);
88
+ }
89
+ }
90
+ if (!modified_at) {
91
+ try { modified_at = fs.statSync(full).mtime.toISOString(); modified_by = 'You'; } catch { /* none */ }
92
+ }
93
+ return { exists: true, hash: bodyHash(text), title: titleOf(text, rel), modified_at, modified_by };
94
+ }
95
+
96
+ function readRows(root) {
97
+ const s = stateDir(root);
98
+ if (!s.ok) return { ok: false, error: s.error, rows: [] };
99
+ const r = fourPanel.readDeclarations(s.dir, LEDGER);
100
+ return { ...r, dir: s.dir };
101
+ }
102
+ function append(root, row) {
103
+ const c = D.checkRow(row);
104
+ if (!c.ok) return { ok: false, error: c.error };
105
+ const s = stateDir(root);
106
+ if (!s.ok) return { ok: false, error: s.error };
107
+ return fourPanel.appendDeclaration(s.dir, LEDGER, c.row);
108
+ }
109
+
110
+ /** The links the signal is about: the PM's artifact links to Google Docs and Confluence, latest row per pair. */
111
+ function links(root) {
112
+ const s = stateDir(root);
113
+ if (!s.ok) return { ok: false, error: s.error, links: [] };
114
+ const r = fourPanel.readDeclarations(s.dir, fourPanel.ARTIFACT_LINKS);
115
+ if (!r.ok) return { ok: false, error: r.error, links: [] };
116
+ return { ok: true, links: fourPanel.linkView(r.rows).filter((l) => D.KINDS.includes(l.kind) && D.okPath(l.artifact_path)) };
117
+ }
118
+
119
+ function view(root, { now = Date.now() } = {}) {
120
+ const L = links(root);
121
+ if (!L.ok) return { ok: false, error: L.error };
122
+ const rows = readRows(root);
123
+ if (!rows.ok) return { ok: false, error: rows.error };
124
+ const readings = D.readingsByLink(rows.rows);
125
+ const { open, last, syncs } = D.runsOf(rows.rows);
126
+ const byPath = new Map();
127
+ for (const l of L.links) {
128
+ let doc = byPath.get(l.artifact_path);
129
+ if (!doc) {
130
+ const repo = repoInfo(root, l.artifact_path);
131
+ doc = { path: l.artifact_path, title: repo.title || path.basename(l.artifact_path), initiative_id: l.initiative_id || null,
132
+ repo: { exists: !!repo.exists, modified_at: repo.modified_at || null, modified_by: repo.modified_by || null }, links: [], _repo: repo };
133
+ byPath.set(l.artifact_path, doc);
134
+ }
135
+ const mine = readings.get(D.keyOf(l.artifact_path, l.kind)) || [];
136
+ const sig = D.signal({ kind: l.kind, target: l.target }, mine, doc._repo);
137
+ const st = syncState(syncs, l.artifact_path, l.kind, mine, now);
138
+ doc.links.push({ ...sig, target: l.target, linked_at: l.at, ...st });
139
+ }
140
+ const docs = [...byPath.values()].map(({ _repo, ...d }) => ({
141
+ ...d, attention: d.links.some((x) => D.ATTENTION.includes(x.signal)),
142
+ }));
143
+ const all = docs.flatMap((d) => d.links);
144
+ const count = (s) => all.filter((x) => x.signal === s).length;
145
+ const summary = {
146
+ documents: docs.length, links: all.length,
147
+ same: count('same'), ahead: count('ahead'), behind: count('behind'), diverged: count('diverged'),
148
+ unchecked: count('unchecked'), problems: count('unreadable') + count('missing-remote') + count('missing-repo'),
149
+ attention: docs.filter((d) => d.attention).length,
150
+ };
151
+ return { ok: true, docs, summary, open: openRun(open), last, file: rows.file || null };
152
+ }
153
+ const openRun = (open, now = Date.now()) => (open && now - Date.parse(open.started_at) < OPEN_RUN_MS ? open : null);
154
+
155
+ /** A copy's sync, as the line shows it: one still open (`syncing`), or the last one that failed after the last reading. */
156
+ function syncState(syncs, rel, kind, readings, now = Date.now()) {
157
+ const mine = (syncs || []).filter((x) => x.path === rel && x.kind === kind);
158
+ const open = mine.filter((x) => x.state === 'started' && now - Date.parse(x.started_at) < OPEN_SYNC_MS).pop() || null;
159
+ const lastAt = readings.length ? readings[readings.length - 1].at : '';
160
+ const failed = mine.filter((x) => x.state === 'failed' && x.ended_at > lastAt).pop() || null;
161
+ return {
162
+ syncing: open ? { run: open.run, use: open.use, started_at: open.started_at } : null,
163
+ sync_failed: !open && failed ? { use: failed.use, reason: failed.reason, at: failed.ended_at } : null,
164
+ };
165
+ }
166
+
167
+ function addLink(root, { path: rel, kind, target, initiative_id } = {}) {
168
+ if (!D.KINDS.includes(kind)) return { ok: false, error: `choose ${D.KINDS.map((k) => D.KIND_LABEL[k]).join(' or ')}` };
169
+ if (!D.okPath(rel)) return { ok: false, error: 'give the file as a path inside the repository' };
170
+ if (!fs.existsSync(path.join(root, rel))) return { ok: false, error: `${rel} is not in the repository` };
171
+ if (!D.remoteIdOf(kind, target)) return { ok: false, error: `that is not a ${D.KIND_LABEL[kind]} link — paste the page's address` };
172
+ const built = fourPanel.artifactLinkRow({ artifact_path: rel, kind, target, origin: 'typed', initiative_id: initiative_id || null });
173
+ if (!built.ok) return built;
174
+ const s = stateDir(root);
175
+ if (!s.ok) return s;
176
+ return fourPanel.appendDeclaration(s.dir, fourPanel.ARTIFACT_LINKS, built.row);
177
+ }
178
+
179
+ function removeLink(root, { path: rel, kind } = {}) {
180
+ const L = links(root);
181
+ if (!L.ok) return L;
182
+ const cur = L.links.find((l) => l.artifact_path === rel && l.kind === kind);
183
+ if (!cur) return { ok: false, error: `${rel} has no ${D.KIND_LABEL[kind] || kind} link` };
184
+ const built = fourPanel.artifactLinkRow({ artifact_path: rel, kind, target: cur.target, origin: 'typed', state: 'retracted',
185
+ initiative_id: cur.initiative_id || null });
186
+ if (!built.ok) return built;
187
+ const s = stateDir(root);
188
+ if (!s.ok) return s;
189
+ return fourPanel.appendDeclaration(s.dir, fourPanel.ARTIFACT_LINKS, built.row);
190
+ }
191
+
192
+ function newRunId() { return 'r-' + crypto.randomBytes(6).toString('hex'); }
193
+
194
+ /** Open a check. Refused while another is open (and not stale). Returns every link with what the session needs. */
195
+ function checkStart(root, { by, now = new Date() } = {}) {
196
+ if (!by) return { ok: false, error: 'a check must name the session or profile that runs it' };
197
+ const rows = readRows(root);
198
+ if (!rows.ok) return rows;
199
+ const open = openRun(D.runsOf(rows.rows).open, now.getTime());
200
+ if (open) return { ok: false, error: `a check is already running (${open.run}, started ${open.started_at})`, open };
201
+ const L = links(root);
202
+ if (!L.ok) return L;
203
+ const run = newRunId();
204
+ const wrote = append(root, { ev: 'run', run, state: 'started', by, at: now.toISOString() });
205
+ if (!wrote.ok) return wrote;
206
+ const list = L.links.map((l) => {
207
+ const repo = repoInfo(root, l.artifact_path);
208
+ return { path: l.artifact_path, kind: l.kind, target: l.target, remote_id: D.remoteIdOf(l.kind, l.target),
209
+ repo_exists: !!repo.exists, repo_hash: repo.hash || null, title: repo.title || null };
210
+ });
211
+ return { ok: true, run, links: list };
212
+ }
213
+
214
+ /** Record what the session read. Each reading is checked on its own; a refused one is reported and not written. */
215
+ function record(root, { run, by, readings, now = new Date() } = {}) {
216
+ if (!Array.isArray(readings)) return { ok: false, error: 'readings is a list' };
217
+ const rows = readRows(root);
218
+ if (!rows.ok) return rows;
219
+ const known = D.runsOf(rows.rows).runs.find((r) => r.run === run);
220
+ if (!known) return { ok: false, error: `${run || 'that'} is not a check that was started — run pmos docs check-start first` };
221
+ if (known.finished_at) return { ok: false, error: `${run} is finished — start a new check` };
222
+ const recorded = []; const refused = [];
223
+ for (const r of readings) {
224
+ const wrote = append(root, { ...r, ev: 'reading', run, by, at: now.toISOString() });
225
+ if (wrote.ok) recorded.push(`${r.path}|${r.kind}`); else refused.push({ path: r && r.path, kind: r && r.kind, error: wrote.error });
226
+ }
227
+ return { ok: refused.length === 0, recorded, refused, error: refused.length ? `${refused.length} reading(s) refused` : undefined };
228
+ }
229
+
230
+ function checkFinish(root, { run, by, summary, now = new Date() } = {}) {
231
+ const rows = readRows(root);
232
+ if (!rows.ok) return rows;
233
+ const known = D.runsOf(rows.rows).runs.find((r) => r.run === run);
234
+ if (!known) return { ok: false, error: `${run || 'that'} is not a check that was started` };
235
+ if (known.finished_at) return { ok: true, already: true };
236
+ return append(root, { ev: 'run', run, state: 'finished', by, summary: summary || null, at: now.toISOString() });
237
+ }
238
+
239
+ /** Open a sync of one copy, in the direction the PM pressed. Refused while another sync of that copy is open. */
240
+ function syncStart(root, { path: rel, kind, use, by, now = new Date() } = {}) {
241
+ if (!by) return { ok: false, error: 'a sync must name the session or profile that runs it' };
242
+ if (!D.USES.includes(use)) return { ok: false, error: 'say which version to use: pmos or copy' };
243
+ const L = links(root);
244
+ if (!L.ok) return L;
245
+ const l = L.links.find((x) => x.artifact_path === rel && x.kind === kind);
246
+ if (!l) return { ok: false, error: `${rel} has no ${D.KIND_LABEL[kind] || kind} copy` };
247
+ const rows = readRows(root);
248
+ if (!rows.ok) return rows;
249
+ const st = syncState(D.runsOf(rows.rows).syncs, rel, kind, [], now.getTime());
250
+ if (st.syncing) return { ok: false, error: `that copy is already being synced (${st.syncing.run}, started ${st.syncing.started_at})`, open: st.syncing };
251
+ const repo = repoInfo(root, rel);
252
+ if (!repo.exists) return { ok: false, error: `${rel} is not in the repository` };
253
+ const run = newRunId();
254
+ const wrote = append(root, { ev: 'run', mode: 'sync', run, state: 'started', by, path: rel, kind, use, at: now.toISOString() });
255
+ if (!wrote.ok) return wrote;
256
+ return { ok: true, run, path: rel, kind, label: D.KIND_LABEL[kind], use, target: l.target, remote_id: D.remoteIdOf(kind, l.target),
257
+ repo_hash: repo.hash, title: repo.title };
258
+ }
259
+
260
+ function openSync(root, run) {
261
+ const rows = readRows(root);
262
+ if (!rows.ok) return rows;
263
+ const s = D.runsOf(rows.rows).syncs.find((x) => x.run === run);
264
+ if (!s) return { ok: false, error: `${run || 'that'} is not a sync that was started — run pmos docs sync-start first` };
265
+ if (s.state !== 'started') return { ok: false, error: `${run} is already ${s.state}` };
266
+ return { ok: true, sync: s };
267
+ }
268
+
269
+ /**
270
+ * Close a sync that wrote one side. The session names the copy's version after the write; the repository hash is read
271
+ * here, now, so the reading says what is on disk and not what the session thinks it wrote.
272
+ */
273
+ function syncFinish(root, { run, by, version, modified_at, modified_by, url, title, now = new Date() } = {}) {
274
+ const o = openSync(root, run);
275
+ if (!o.ok) return o;
276
+ const s = o.sync;
277
+ const repo = repoInfo(root, s.path);
278
+ if (!repo.exists) return { ok: false, error: `${s.path} is not in the repository — record the sync as failed` };
279
+ const label = D.KIND_LABEL[s.kind];
280
+ const reading = { ev: 'reading', run, by, at: now.toISOString(), path: s.path, kind: s.kind,
281
+ remote_id: null, exists: true, version: version == null ? null : String(version), same: true, repo_hash: repo.hash,
282
+ modified_at: modified_at || null, modified_by: modified_by || null, url: url || null, title: title || null,
283
+ note: s.use === 'pmos' ? `PMOS version written to ${label}` : `${label} version written to PMOS` };
284
+ const L = links(root);
285
+ const l = L.ok ? L.links.find((x) => x.artifact_path === s.path && x.kind === s.kind) : null;
286
+ if (l) reading.remote_id = D.remoteIdOf(s.kind, l.target);
287
+ const r = append(root, reading);
288
+ if (!r.ok) return r;
289
+ return append(root, { ev: 'run', mode: 'sync', run, state: 'finished', by, path: s.path, kind: s.kind, use: s.use, at: now.toISOString() });
290
+ }
291
+
292
+ function syncFail(root, { run, by, reason, now = new Date() } = {}) {
293
+ const o = openSync(root, run);
294
+ if (!o.ok) return o;
295
+ const s = o.sync;
296
+ return append(root, { ev: 'run', mode: 'sync', run, state: 'failed', by, path: s.path, kind: s.kind, use: s.use,
297
+ summary: reason || null, at: now.toISOString() });
298
+ }
299
+
300
+ /** The repository's markdown files whose path or title matches `q`: what search and Add copy offer. */
301
+ function findFiles(root, q, { limit = 20 } = {}) {
302
+ const words = String(q || '').toLowerCase().split(/\s+/).filter(Boolean);
303
+ const out = git(root, ['ls-files', '-co', '--exclude-standard', '--', '*.md']);
304
+ const files = (out ? out.split('\n') : []).filter((f) => f && D.okPath(f) && !/(^|\/)node_modules\//.test(f));
305
+ const found = [];
306
+ for (const f of files) {
307
+ if (found.length >= limit) break;
308
+ const low = f.toLowerCase();
309
+ let title = null;
310
+ const hit = (t) => words.every((w) => t.includes(w));
311
+ if (!words.length || hit(low)) { found.push(f); continue; }
312
+ try { title = titleOf(fs.readFileSync(path.join(root, f), 'utf8').slice(0, 4000), f); } catch { continue; }
313
+ if (hit(title.toLowerCase())) found.push(f);
314
+ }
315
+ return found.map((f) => {
316
+ let title = path.basename(f);
317
+ try { title = titleOf(fs.readFileSync(path.join(root, f), 'utf8').slice(0, 4000), f); } catch { /* keep the name */ }
318
+ return { path: f, title };
319
+ });
320
+ }
321
+
322
+ module.exports = { LEDGER, OPEN_RUN_MS, OPEN_SYNC_MS, syncStart, syncFinish, syncFail, findFiles, view, addLink, removeLink, checkStart, record, checkFinish, repoInfo, bodyHash, bodyOf, titleOf, links };