create-pmos 0.55.5 → 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.
@@ -0,0 +1,225 @@
1
+ 'use strict';
2
+ /* doc-sync.js — the sync SIGNAL for documents linked to Google Docs and Confluence (initiative docs-three-way-sync).
3
+ *
4
+ * The PM asked for no automatic sync: "I want signal if synced file on pmos is obsolete or ahead or same as the one of
5
+ * other services." So this module decides one word per link — same, ahead, behind, diverged — and never a write.
6
+ *
7
+ * PURE: no require, no file, no network, no clock. lib/doc-sync-store.js reads the links, the readings and the
8
+ * repository files and passes them in.
9
+ *
10
+ * checkRow(row) → { ok:true, row } | { ok:false, error } the gate every ledger line passes
11
+ * readingsByLink(rows) → Map "<path>|<kind>" → readings, oldest first (refused lines skipped)
12
+ * runsOf(rows) → { runs, open, last, syncs } checks, and the syncs the PM pressed
13
+ * signal(link, readings, repo) → { signal, words, basis, checked_at, … }
14
+ * remoteIdOf(kind, target) → the Google Doc id or Confluence page id in a pasted link
15
+ *
16
+ * ── How a signal is decided ────────────────────────────────────────────────────────────────────────────────────
17
+ * A READING is what a check session saw for one link: whether the copy exists, its version and modified time, and
18
+ * whether its text is the same as the repository file's body, with the hash of that file at that moment.
19
+ * The BASELINE is the latest reading that found the text the same. Against it:
20
+ * repo changed = the file's hash now ≠ the baseline's hash
21
+ * remote changed = the latest reading's version ≠ the baseline's version
22
+ * neither → same · repo only → ahead · remote only → behind · both → diverged.
23
+ * With no baseline (the copies have never matched) the newer modified time decides and `basis` says 'date'.
24
+ *
25
+ * ── The rule this module carries forward ───────────────────────────────────────────────────────────────────────
26
+ * A reading cannot be asserted, only evidenced (four-panel.js, `resolved` and `applied`). A reading must name the
27
+ * session or profile that read, and the check it belongs to. `same: true` must carry the repository hash it was
28
+ * compared with. No signal but `unchecked` exists without a reading, and nothing here turns a clock into a claim about
29
+ * a copy.
30
+ *
31
+ * ── Sync on the PM's press (amended 2026-10-06) ────────────────────────────────────────────────────────────────
32
+ * The PM reviewed the mockup and asked to sync one copy at a time, in the direction they pick. A sync is a `run` row
33
+ * with `mode: 'sync'`, the link it is about and `use` (pmos: the repository text is written to the copy; copy: the
34
+ * copy's text is written to the repository file). It is started, then finished or failed. A finished sync writes a
35
+ * reading that says the two sides are the same, so the baseline moves to what was just written. This module still
36
+ * writes nothing: a session holding the PM's connectors does the write and records it through `pmos docs`.
37
+ */
38
+
39
+ const KINDS = Object.freeze(['google', 'confluence']);
40
+ const KIND_LABEL = Object.freeze({ google: 'Google Docs', confluence: 'Confluence' });
41
+ const EVENTS = Object.freeze(['run', 'reading']);
42
+ const SIGNALS = Object.freeze(['same', 'ahead', 'behind', 'diverged', 'unchecked', 'unreadable', 'missing-remote', 'missing-repo']);
43
+ // What needs the PM's eye: everything but `same`. `unchecked` is included — a copy nobody read is not known to match.
44
+ const ATTENTION = Object.freeze(SIGNALS.filter((s) => s !== 'same'));
45
+ const RUN_ID = /^r-[0-9a-z]{6,32}$/;
46
+ const USES = Object.freeze(['pmos', 'copy']);
47
+ const HASH = /^[0-9a-f]{16,64}$/;
48
+
49
+ const str = (v, n = 300) => (v == null || v === '' ? null : String(v).slice(0, n));
50
+ const isObj = (v) => v && typeof v === 'object' && !Array.isArray(v);
51
+ const keyOf = (p, kind) => `${p}|${kind}`;
52
+
53
+ /** A repository path a link may name: relative, inside the repository. */
54
+ function okPath(p) {
55
+ if (typeof p !== 'string' || !p || p.length > 300) return false;
56
+ return !(p.startsWith('/') || p.includes('\\') || p.split('/').some((s) => s === '..' || s === '.' || s === ''));
57
+ }
58
+
59
+ /** The id inside a pasted link, so the same copy pasted in two shapes is one copy. */
60
+ function remoteIdOf(kind, target) {
61
+ const t = String(target || '').trim();
62
+ if (!t) return null;
63
+ if (kind === 'google') {
64
+ const m = /\/d\/([A-Za-z0-9_-]{10,})/.exec(t) || /[?&]id=([A-Za-z0-9_-]{10,})/.exec(t);
65
+ if (m) return m[1];
66
+ return /^[A-Za-z0-9_-]{10,}$/.test(t) ? t : null;
67
+ }
68
+ if (kind === 'confluence') {
69
+ const m = /\/pages\/(?:edit-v2\/)?(\d+)/.exec(t) || /[?&]pageId=(\d+)/.exec(t);
70
+ if (m) return m[1];
71
+ return /^\d+$/.test(t) ? t : null;
72
+ }
73
+ return null;
74
+ }
75
+
76
+ /** The one gate every ledger line passes. A refused row is never written, and the readers skip it. */
77
+ function checkRow(input) {
78
+ if (!isObj(input)) return { ok: false, error: 'a ledger row is an object' };
79
+ const ev = String(input.ev || '');
80
+ if (!EVENTS.includes(ev)) return { ok: false, error: `ev must be one of: ${EVENTS.join(', ')}` };
81
+ const at = str(input.at, 40);
82
+ if (!at || Number.isNaN(Date.parse(at))) return { ok: false, error: 'at must be an ISO time' };
83
+ if (!RUN_ID.test(String(input.run || ''))) return { ok: false, error: 'run must name a check (r-…)' };
84
+ const by = str(input.by, 120);
85
+ if (!by) return { ok: false, error: `a ${ev === 'run' ? 'check' : 'reading'} must name the session or profile that made it — PMOS read nothing itself` };
86
+
87
+ if (ev === 'run') {
88
+ if (input.mode === 'sync') {
89
+ if (!['started', 'finished', 'failed'].includes(input.state)) return { ok: false, error: 'a sync state is started, finished or failed' };
90
+ if (!okPath(input.path)) return { ok: false, error: 'a sync names the repository file it is about' };
91
+ if (!KINDS.includes(input.kind)) return { ok: false, error: `a sync names its copy: ${KINDS.join(' or ')}` };
92
+ if (!USES.includes(input.use)) return { ok: false, error: 'a sync says which version it uses: pmos or copy' };
93
+ const summary = str(input.summary, 400);
94
+ if (input.state === 'failed' && !summary) return { ok: false, error: 'a failed sync says why' };
95
+ return { ok: true, row: { ev, at, run: input.run, mode: 'sync', state: input.state, by, path: input.path, kind: input.kind, use: input.use, summary } };
96
+ }
97
+ if (!['started', 'finished'].includes(input.state)) return { ok: false, error: 'state must be started or finished' };
98
+ return { ok: true, row: { ev, at, run: input.run, state: input.state, by, summary: str(input.summary, 400) } };
99
+ }
100
+
101
+ if (!okPath(input.path)) return { ok: false, error: 'path must be a repository-relative path' };
102
+ if (!KINDS.includes(input.kind)) return { ok: false, error: `kind must be one of: ${KINDS.join(', ')}` };
103
+ const error = str(input.error, 300);
104
+ if (!error && typeof input.exists !== 'boolean') return { ok: false, error: 'a reading says exists: true or false, or carries the error' };
105
+ const exists = error ? null : input.exists;
106
+ const version = str(input.version, 120);
107
+ if (exists === true && !version) return { ok: false, error: 'a reading of a copy that exists must carry its version' };
108
+ let same = null;
109
+ if (exists === true && input.same != null) {
110
+ if (typeof input.same !== 'boolean') return { ok: false, error: 'same is true or false' };
111
+ same = input.same;
112
+ }
113
+ const repo_hash = input.repo_hash == null || input.repo_hash === '' ? null : String(input.repo_hash);
114
+ if (repo_hash && !HASH.test(repo_hash)) return { ok: false, error: 'repo_hash is the hash `pmos docs check-start` gave for the file' };
115
+ if (same === true && !repo_hash) return { ok: false, error: 'a reading that says the text is the same must carry the repository hash it was compared with' };
116
+ const modified_at = str(input.modified_at, 40);
117
+ if (modified_at && Number.isNaN(Date.parse(modified_at))) return { ok: false, error: 'modified_at must be an ISO time' };
118
+ return {
119
+ ok: true,
120
+ row: {
121
+ ev, at, run: input.run, by, path: input.path, kind: input.kind,
122
+ remote_id: str(input.remote_id, 200), exists, version, modified_at, same, repo_hash,
123
+ title: str(input.title, 300), url: str(input.url, 400), error, note: str(input.note, 300),
124
+ modified_by: str(input.modified_by, 120),
125
+ },
126
+ };
127
+ }
128
+
129
+ /** The readings of every link, oldest first. A line that fails checkRow is skipped, never guessed. */
130
+ function readingsByLink(rows) {
131
+ const by = new Map();
132
+ for (const raw of Array.isArray(rows) ? rows : []) {
133
+ const c = checkRow(raw);
134
+ if (!c.ok || c.row.ev !== 'reading') continue;
135
+ const k = keyOf(c.row.path, c.row.kind);
136
+ if (!by.has(k)) by.set(k, []);
137
+ by.get(k).push(c.row);
138
+ }
139
+ return by;
140
+ }
141
+
142
+ /** Every check, the one still open (started, not finished), the last one that finished, and every sync. */
143
+ function runsOf(rows) {
144
+ const runs = new Map();
145
+ const syncs = new Map();
146
+ for (const raw of Array.isArray(rows) ? rows : []) {
147
+ const c = checkRow(raw);
148
+ if (!c.ok || c.row.ev !== 'run') continue;
149
+ const r = c.row;
150
+ if (r.mode === 'sync') {
151
+ const cur = syncs.get(r.run) || { run: r.run, by: r.by, path: r.path, kind: r.kind, use: r.use, state: 'started', started_at: null, ended_at: null, reason: null };
152
+ if (r.state === 'started') cur.started_at = cur.started_at || r.at;
153
+ else if (!cur.ended_at) { cur.state = r.state; cur.ended_at = r.at; cur.reason = r.state === 'failed' ? r.summary : null; }
154
+ syncs.set(r.run, cur);
155
+ continue;
156
+ }
157
+ const cur = runs.get(r.run) || { run: r.run, by: r.by, started_at: null, finished_at: null, summary: null };
158
+ if (r.state === 'started') cur.started_at = cur.started_at || r.at;
159
+ else { cur.finished_at = r.at; cur.summary = r.summary; }
160
+ runs.set(r.run, cur);
161
+ }
162
+ const list = [...runs.values()];
163
+ return {
164
+ runs: list,
165
+ open: list.filter((r) => r.started_at && !r.finished_at).pop() || null,
166
+ last: list.filter((r) => r.finished_at).pop() || null,
167
+ syncs: [...syncs.values()].filter((x) => x.started_at),
168
+ };
169
+ }
170
+
171
+ /**
172
+ * The signal for one link. `link` = { artifact_path, kind, target }. `readings` = this link's readings, oldest first.
173
+ * `repo` = { exists, hash, modified_at } for the repository file now. A reading of a different copy (the PM changed
174
+ * the link since) does not count: it read something this link no longer names.
175
+ */
176
+ function signal(link, readings, repo) {
177
+ const kind = link.kind;
178
+ const label = KIND_LABEL[kind] || kind;
179
+ const rid = remoteIdOf(kind, link.target);
180
+ const mine = (Array.isArray(readings) ? readings : []).filter((r) => !rid || !r.remote_id || r.remote_id === rid);
181
+ const last = mine[mine.length - 1] || null;
182
+ const base = [...mine].reverse().find((r) => r.exists === true && r.same === true) || null;
183
+ const out = (sig, words, extra = {}) => ({
184
+ signal: sig, words, basis: null, kind, label,
185
+ checked_at: last ? last.at : null, checked_by: last ? last.by : null,
186
+ title: last && last.title ? last.title : null,
187
+ url: (last && last.url) || (/^https?:\/\//i.test(String(link.target || '')) ? link.target : null),
188
+ note: last && last.note ? last.note : null,
189
+ remote_modified_at: last && last.modified_at ? last.modified_at : null,
190
+ remote_modified_by: last && last.modified_by ? last.modified_by : null, ...extra,
191
+ });
192
+ const r = repo || { exists: false };
193
+
194
+ if (!r.exists) return out('missing-repo', 'Not in the repo');
195
+ if (!last) return out('unchecked', 'Not checked yet');
196
+ if (last.error) return out('unreadable', `Could not check — ${last.error}`);
197
+ if (last.exists === false) return out('missing-remote', `Not found in ${label}`);
198
+
199
+ // The latest reading compared the text and found it different, with nothing changed since the baseline it would
200
+ // otherwise use: that baseline was wrong, so it is not used.
201
+ const stale = base && last !== base && last.same === false && last.version === base.version && last.repo_hash === base.repo_hash;
202
+ if (base && !stale) {
203
+ const repoChanged = base.repo_hash !== r.hash;
204
+ const remoteChanged = last.version !== base.version;
205
+ if (!repoChanged && !remoteChanged) return out('same', 'Same', { basis: 'text' });
206
+ if (repoChanged && !remoteChanged) return out('ahead', 'Ahead — the repo is newer', { basis: 'text' });
207
+ if (!repoChanged && remoteChanged) return out('behind', `Behind — ${label} is newer`, { basis: 'text' });
208
+ return out('diverged', 'Edited in both', { basis: 'text' });
209
+ }
210
+
211
+ // Never matched. The text differs (or was not compared): the newer side by modified time, and the row says so.
212
+ const remoteAt = Date.parse(last.modified_at || '');
213
+ const repoAt = Date.parse(r.modified_at || '');
214
+ if (Number.isNaN(remoteAt) || Number.isNaN(repoAt)) {
215
+ return out('diverged', 'Different, and no date says which is newer', { basis: 'date' });
216
+ }
217
+ return remoteAt > repoAt
218
+ ? out('behind', `Behind — ${label} is newer (by date)`, { basis: 'date' })
219
+ : out('ahead', 'Ahead — the repo is newer (by date)', { basis: 'date' });
220
+ }
221
+
222
+ module.exports = {
223
+ KINDS, KIND_LABEL, EVENTS, SIGNALS, ATTENTION, RUN_ID, USES,
224
+ checkRow, readingsByLink, runsOf, signal, remoteIdOf, okPath, keyOf,
225
+ };
@@ -0,0 +1,221 @@
1
+ 'use strict';
2
+ /* docs-cli.js — `pmos docs`: the sync signal for documents linked to Google Docs and Confluence, from a terminal
3
+ * (initiative docs-three-way-sync).
4
+ *
5
+ * pmos docs [list] [--attention] [--json] each linked document and its signal per copy
6
+ * pmos docs link <file> <google-or-confluence-link> link a repository file to a copy
7
+ * pmos docs unlink <file> google|confluence remove that link (the copy is not touched)
8
+ * pmos docs check-start [--json] open a check: what to read, with each file's hash
9
+ * pmos docs record <check> --file <readings.json> record what the session read
10
+ * pmos docs check-finish <check> [--summary "…"] close the check
11
+ * pmos docs find [words] the repository's markdown files that match, with or without a copy
12
+ * pmos docs sync-start <file> google|confluence --use pmos|copy [--json] open a sync of one copy (the sync session)
13
+ * pmos docs sync-finish <sync> --version <v> [--modified-at <t>] [--url <u>] close it: both sides now the same
14
+ * pmos docs sync-fail <sync> --reason "…" close it: nothing was written, and why
15
+ *
16
+ * The same module the Docs place reads (lib/doc-sync-store.js), so a terminal line and a row cannot disagree.
17
+ * No command writes a document. A check reads; a sync (amended 2026-10-06: the PM presses Use … version for one copy) is
18
+ * written by the doc-sync session with the PM's connectors, and these commands only record that it started and ended.
19
+ * check-start, record and check-finish are the check session's commands; each must name who read, which is the
20
+ * session's PMOS_SESSION token, or `--by <profile>` when the session runs outside the workspace.
21
+ *
22
+ * Exit codes: 0 done, 1 refused, 2 usage.
23
+ */
24
+ const fs = require('fs');
25
+ const path = require('path');
26
+ const { execFileSync } = require('child_process');
27
+ const D = require('./doc-sync');
28
+ const store = require('./doc-sync-store');
29
+ const { parseArgs } = require('./schedule-cli');
30
+
31
+ const COMMANDS = { docs: ['list', 'link', 'unlink', 'check-start', 'record', 'check-finish', 'find', 'sync-start', 'sync-finish', 'sync-fail'] };
32
+ const USAGE = [
33
+ 'usage: pmos docs [list|link|unlink|find|check-start|record|check-finish|sync-start|sync-finish|sync-fail] — is each linked document the same as its Google Docs and Confluence copy',
34
+ ' pmos docs [list] [--attention] [--json] each linked document and its signal: same, ahead, behind, edited in both',
35
+ ' pmos docs link <file> <link> link a repository file to a Google Doc or a Confluence page',
36
+ ' pmos docs unlink <file> google|confluence remove that link; the copy is not touched',
37
+ ' pmos docs check-start [--json] (check session) open a check and list what to read',
38
+ ' pmos docs record <check> --file <readings.json> (check session) record what was read',
39
+ ' pmos docs check-finish <check> [--summary "…"] (check session) close the check',
40
+ ' pmos docs find [words] the repository\'s markdown files that match',
41
+ ' pmos docs sync-start <file> google|confluence --use pmos|copy (sync session) open a sync of one copy',
42
+ ' pmos docs sync-finish <sync> --version <v> [--modified-at <t>] [--url <u>] (sync session) both sides are now the same',
43
+ ' pmos docs sync-fail <sync> --reason "…" (sync session) nothing was written, and why',
44
+ 'Nothing here writes a document. Press Check now, or Use … version on one copy, in Docs.',
45
+ ];
46
+ const BOOL = new Set(['json', 'attention', 'help']);
47
+
48
+ function parse(argv) {
49
+ const rest = []; const bools = {};
50
+ for (const a of argv) {
51
+ if (a.startsWith('--') && BOOL.has(a.slice(2))) bools[a.slice(2)] = true;
52
+ else if (a === '-h') bools.help = true;
53
+ else rest.push(a);
54
+ }
55
+ const { pos, flags } = parseArgs(rest);
56
+ return { pos, flags: { ...flags, ...bools } };
57
+ }
58
+ function repoRoot(cwd) {
59
+ try { return execFileSync('git', ['rev-parse', '--show-toplevel'], { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim() || cwd; }
60
+ catch { return cwd; }
61
+ }
62
+ /** Who is reading: the workspace session's token names it; outside the workspace, the profile the PM names. */
63
+ function whoOf(flags, env) {
64
+ const tok = String(env.PMOS_SESSION || '').trim();
65
+ if (tok) return 'session:' + tok.split('.')[0].slice(0, 60);
66
+ if (flags.by) return 'profile:' + String(flags.by).slice(0, 60);
67
+ return null;
68
+ }
69
+ const kindOf = (target) => {
70
+ if (D.remoteIdOf('confluence', target) && /atlassian|confluence|\/wiki\/|pageId=/i.test(target)) return 'confluence';
71
+ if (D.remoteIdOf('google', target) && /google|docs\.|drive\./i.test(target)) return 'google';
72
+ return null;
73
+ };
74
+ const ago = (iso, now) => {
75
+ if (!iso) return 'never';
76
+ const m = Math.round((now - Date.parse(iso)) / 60000);
77
+ if (m < 1) return 'just now';
78
+ if (m < 60) return `${m} min ago`;
79
+ if (m < 48 * 60) return `${Math.round(m / 60)} h ago`;
80
+ return `${Math.round(m / 1440)} days ago`;
81
+ };
82
+
83
+ async function run(argv, { say = console.log, cwd = process.cwd(), env = process.env, now = Date.now() } = {}) {
84
+ const { pos, flags } = parse(argv.slice(1));
85
+ const sub = pos[0] || 'list';
86
+ if (flags.help || sub === 'help') { USAGE.forEach((l) => say(l)); return 0; }
87
+ const json = !!flags.json;
88
+ const root = flags.root ? path.resolve(cwd, flags.root) : repoRoot(cwd);
89
+ const fail = (error, code = 1) => { if (json) say(JSON.stringify({ ok: false, error })); else say(`✗ ${error}`); return code; };
90
+ const rel = (p) => {
91
+ const abs = path.resolve(cwd, p);
92
+ const r = path.relative(root, abs).split(path.sep).join('/');
93
+ return r.startsWith('..') ? null : r;
94
+ };
95
+
96
+ if (sub === 'list') {
97
+ const v = store.view(root);
98
+ if (!v.ok) return fail(v.error);
99
+ const docs = flags.attention ? v.docs.filter((d) => d.attention) : v.docs;
100
+ if (json) { say(JSON.stringify({ ...v, docs })); return 0; }
101
+ if (!v.docs.length) { say('No document is linked to Google Docs or Confluence. Link one: pmos docs link <file> <link>'); return 0; }
102
+ const s = v.summary;
103
+ say(`Docs · ${s.documents} document${s.documents === 1 ? '' : 's'} · ${s.same} same · ${s.ahead} ahead · ${s.behind} behind · ${s.diverged} edited in both · ${s.unchecked} not checked`);
104
+ say(v.last ? `Last check ${ago(v.last.finished_at, now)}${v.last.summary ? ': ' + v.last.summary : ''}` : 'Never checked. Press Check now in Docs.');
105
+ if (v.open) say(`A check is running (started ${ago(v.open.started_at, now)}).`);
106
+ for (const d of docs) {
107
+ say(`${d.title} (${d.path})`);
108
+ for (const l of d.links) say(` ${l.label.padEnd(12)} ${l.words}${l.checked_at ? ' · checked ' + ago(l.checked_at, now) : ''}`);
109
+ }
110
+ return 0;
111
+ }
112
+
113
+ if (sub === 'link') {
114
+ const [file, target] = [pos[1], pos[2]];
115
+ if (!file || !target) return fail('usage: pmos docs link <file> <google-or-confluence-link>', 2);
116
+ const kind = flags.kind || kindOf(target);
117
+ if (!kind) return fail('that is not a Google Docs or Confluence link — paste the page\'s address');
118
+ const r = rel(file);
119
+ if (!r) return fail(`${file} is not inside this repository`);
120
+ const w = store.addLink(root, { path: r, kind, target });
121
+ if (!w.ok) return fail(w.error);
122
+ if (json) say(JSON.stringify({ ok: true, path: r, kind })); else say(`✓ ${r} is linked to ${D.KIND_LABEL[kind]}. Its signal shows after the next check.`);
123
+ return 0;
124
+ }
125
+
126
+ if (sub === 'unlink') {
127
+ const [file, kind] = [pos[1], pos[2]];
128
+ if (!file || !D.KINDS.includes(kind)) return fail('usage: pmos docs unlink <file> google|confluence', 2);
129
+ const r = rel(file);
130
+ if (!r) return fail(`${file} is not inside this repository`);
131
+ const w = store.removeLink(root, { path: r, kind });
132
+ if (!w.ok) return fail(w.error);
133
+ if (json) say(JSON.stringify({ ok: true })); else say(`✓ ${r} is no longer linked to ${D.KIND_LABEL[kind]}. The copy is not touched.`);
134
+ return 0;
135
+ }
136
+
137
+ if (sub === 'find') {
138
+ const found = store.findFiles(root, pos.slice(1).join(' '));
139
+ const v = store.view(root);
140
+ const linked = new Map((v.ok ? v.docs : []).map((d) => [d.path, d.links.map((l) => l.label)]));
141
+ const files = found.map((f) => ({ ...f, copies: linked.get(f.path) || [] }));
142
+ if (json) { say(JSON.stringify({ ok: true, files })); return 0; }
143
+ if (!files.length) { say('No markdown file matches.'); return 0; }
144
+ for (const f of files) say(`${f.title} (${f.path}) ${f.copies.length ? f.copies.join(', ') : 'no copy'}`);
145
+ return 0;
146
+ }
147
+
148
+ const by = whoOf(flags, env);
149
+ if (sub === 'check-start') {
150
+ if (!by) return fail('a check must name who reads: run it in a workspace session, or add --by <profile>');
151
+ const s = store.checkStart(root, { by, now: new Date(now) });
152
+ if (!s.ok) return fail(s.error);
153
+ if (json) { say(JSON.stringify(s)); return 0; }
154
+ say(`Check ${s.run} · ${s.links.length} link${s.links.length === 1 ? '' : 's'} to read`);
155
+ for (const l of s.links) say(` ${l.path} ${D.KIND_LABEL[l.kind]} ${l.target} hash ${l.repo_hash || '(no file)'}`);
156
+ return 0;
157
+ }
158
+ if (sub === 'record') {
159
+ const runId = pos[1];
160
+ if (!runId || !flags.file) return fail('usage: pmos docs record <check> --file <readings.json>', 2);
161
+ if (!by) return fail('a reading must name who read: run it in a workspace session, or add --by <profile>');
162
+ let readings;
163
+ try {
164
+ const parsed = JSON.parse(fs.readFileSync(path.resolve(cwd, flags.file), 'utf8'));
165
+ readings = Array.isArray(parsed) ? parsed : parsed.readings;
166
+ } catch (e) { return fail(`could not read ${flags.file}: ${String((e && e.message) || e).slice(0, 160)}`); }
167
+ const r = store.record(root, { run: runId, by, readings, now: new Date(now) });
168
+ if (json) say(JSON.stringify(r));
169
+ else {
170
+ say(`${r.recorded ? r.recorded.length : 0} reading(s) recorded.`);
171
+ for (const x of r.refused || []) say(`✗ ${x.path} ${x.kind}: ${x.error}`);
172
+ if (!r.recorded && r.error) say(`✗ ${r.error}`);
173
+ }
174
+ return r.ok ? 0 : 1;
175
+ }
176
+ if (sub === 'check-finish') {
177
+ const runId = pos[1];
178
+ if (!runId) return fail('usage: pmos docs check-finish <check> [--summary "…"]', 2);
179
+ if (!by) return fail('a check must name who ran it: run it in a workspace session, or add --by <profile>');
180
+ const r = store.checkFinish(root, { run: runId, by, summary: flags.summary || null, now: new Date(now) });
181
+ if (!r.ok) return fail(r.error);
182
+ const v = store.view(root);
183
+ if (json) say(JSON.stringify({ ok: true, summary: v.ok ? v.summary : null }));
184
+ else say(v.ok ? `✓ Check closed · ${v.summary.same} same · ${v.summary.ahead} ahead · ${v.summary.behind} behind · ${v.summary.diverged} edited in both` : '✓ Check closed');
185
+ return 0;
186
+ }
187
+ if (sub === 'sync-start') {
188
+ const [file, kind] = [pos[1], pos[2]];
189
+ if (!file || !D.KINDS.includes(kind) || !D.USES.includes(flags.use)) return fail('usage: pmos docs sync-start <file> google|confluence --use pmos|copy', 2);
190
+ if (!by) return fail('a sync must name who writes: run it in a workspace session, or add --by <profile>');
191
+ const r = rel(file);
192
+ if (!r) return fail(`${file} is not inside this repository`);
193
+ const s = store.syncStart(root, { path: r, kind, use: flags.use, by, now: new Date(now) });
194
+ if (!s.ok) return fail(s.error);
195
+ if (json) { say(JSON.stringify(s)); return 0; }
196
+ say(`Sync ${s.run} · ${s.path} · ${s.use === 'pmos' ? 'write the PMOS version to ' + s.label : 'write the ' + s.label + ' version to PMOS'} · ${s.target}`);
197
+ return 0;
198
+ }
199
+ if (sub === 'sync-finish') {
200
+ const runId = pos[1];
201
+ if (!runId || !flags.version) return fail('usage: pmos docs sync-finish <sync> --version <v> [--modified-at <t>] [--url <u>]', 2);
202
+ if (!by) return fail('a sync must name who wrote: run it in a workspace session, or add --by <profile>');
203
+ const r = store.syncFinish(root, { run: runId, by, version: flags.version, modified_at: flags['modified-at'] || null,
204
+ modified_by: flags['modified-by'] || null, url: flags.url || null, now: new Date(now) });
205
+ if (!r.ok) return fail(r.error);
206
+ if (json) say(JSON.stringify({ ok: true })); else say('✓ Sync closed. Both sides are the same.');
207
+ return 0;
208
+ }
209
+ if (sub === 'sync-fail') {
210
+ const runId = pos[1];
211
+ if (!runId || !flags.reason) return fail('usage: pmos docs sync-fail <sync> --reason "…"', 2);
212
+ if (!by) return fail('a sync must name who ran it: run it in a workspace session, or add --by <profile>');
213
+ const r = store.syncFail(root, { run: runId, by, reason: String(flags.reason), now: new Date(now) });
214
+ if (!r.ok) return fail(r.error);
215
+ if (json) say(JSON.stringify({ ok: true })); else say('✓ Sync closed as failed. Nothing was written.');
216
+ return 0;
217
+ }
218
+ return fail(`pmos docs ${sub} is not a command. ${USAGE[0]}`, 2);
219
+ }
220
+
221
+ module.exports = { run, COMMANDS, USAGE };
@@ -35,6 +35,9 @@ const VERBS = Object.freeze({
35
35
  report: Object.freeze({ module: 'report-cli.js', run: 5, guide: 'reports.md',
36
36
  help: 'pmos report draft|issue|list … — draft and issue reports (pmos report help)' }),
37
37
  // Not a pm-os-absorb verb (run: null): the workspace-launcher initiative added it through the same table.
38
+ // Not a pm-os-absorb verb (run: null): docs-three-way-sync added it through the same table.
39
+ docs: Object.freeze({ module: 'docs-cli.js', run: null, guide: 'docs.md',
40
+ help: 'pmos docs [list|link|unlink|find] … — is each linked document the same as, ahead of or behind its Google Docs and Confluence copy, and record a sync (pmos docs help)' }),
38
41
  launcher: Object.freeze({ module: 'launcher-cli.js', run: null, guide: 'launcher.md',
39
42
  help: 'pmos launcher on|off|status — a PMOS app and a login item, so the workspace starts without a terminal' }),
40
43
  });
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.55.5",
3
+ "version": "0.56.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.55.5",
3
+ "version": "0.56.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",
@@ -406,7 +406,7 @@ function render() {
406
406
 
407
407
  const FRAME = !FIXTURE && !LEGACY_LAYOUT_FLAG;
408
408
  const FRAME_KEY = 'pmos.workspace.pm.frame';
409
- const FRAME_DESTS = Object.freeze(['today', 'initiatives', 'board', 'reports', 'settings']);
409
+ const FRAME_DESTS = Object.freeze(['today', 'initiatives', 'board', 'reports', 'docs', 'settings']);
410
410
 
411
411
  function readFrame() {
412
412
  if (!FRAME) return {};
@@ -637,7 +637,7 @@ function pressIn(r, key) {
637
637
  if (b && !b.disabled) { b.click(); return true; }
638
638
  return false;
639
639
  }
640
- const G_KEYS = { t: 'today', i: 'initiatives', b: 'board', r: 'reports' };
640
+ const G_KEYS = { t: 'today', i: 'initiatives', b: 'board', r: 'reports', d: 'docs' };
641
641
 
642
642
  function frameKey(e) {
643
643
  const mod = e.metaKey || e.ctrlKey;
@@ -4067,6 +4067,152 @@
4067
4067
  }
4068
4068
  }
4069
4069
 
4070
+ /* Docs (docs-three-way-sync): one line per copy — a status dot, the title over the copy's link, one sentence, one action. */
4071
+ .rp-docs {
4072
+ display: flex;
4073
+ flex-direction: column;
4074
+ gap: 16px;
4075
+ }
4076
+ .rp-docs-top {
4077
+ display: flex;
4078
+ flex-wrap: wrap;
4079
+ align-items: center;
4080
+ gap: 12px;
4081
+ }
4082
+ .rp-docs-top > .rp-head__title {
4083
+ margin-right: auto;
4084
+ }
4085
+ .rp-docs-find {
4086
+ flex: 1 1 260px;
4087
+ max-width: 420px;
4088
+ }
4089
+ .rp-docs-body {
4090
+ display: flex;
4091
+ flex-direction: column;
4092
+ gap: 16px;
4093
+ }
4094
+ .rp-docs-views {
4095
+ display: flex;
4096
+ flex-wrap: wrap;
4097
+ align-items: center;
4098
+ gap: 12px;
4099
+ border-bottom: 1px solid var(--border);
4100
+ }
4101
+ .rp-docs-when {
4102
+ display: flex;
4103
+ align-items: center;
4104
+ gap: 4px;
4105
+ margin-left: auto;
4106
+ font-size: 12px;
4107
+ color: var(--muted-foreground);
4108
+ }
4109
+ .rp-docs-group__title {
4110
+ margin: 0;
4111
+ padding: 8px 0;
4112
+ font-size: 12px;
4113
+ font-weight: 500;
4114
+ color: var(--muted-foreground);
4115
+ }
4116
+ .rp-docs-row {
4117
+ border-top: 1px solid var(--border);
4118
+ }
4119
+ .rp-docs-row:last-child {
4120
+ border-bottom: 1px solid var(--border);
4121
+ }
4122
+ .rp-docs-dot {
4123
+ display: inline-block;
4124
+ width: 8px;
4125
+ height: 8px;
4126
+ background: var(--muted-foreground);
4127
+ }
4128
+ .rp-docs-dot--ok {
4129
+ background: var(--muted-foreground);
4130
+ }
4131
+ .rp-docs-dot--warn {
4132
+ background: var(--warning);
4133
+ }
4134
+ .rp-docs-dot--bad {
4135
+ background: var(--destructive);
4136
+ }
4137
+ .rp-docs-dot--none {
4138
+ background: transparent;
4139
+ border: 1px solid var(--muted-foreground);
4140
+ }
4141
+ .rp-docs-open {
4142
+ font-size: 12px;
4143
+ color: var(--muted-foreground);
4144
+ white-space: nowrap;
4145
+ text-underline-offset: 2px;
4146
+ }
4147
+ .rp-docs-detail {
4148
+ display: grid;
4149
+ grid-template-columns: 1fr 1fr;
4150
+ gap: 12px;
4151
+ padding: 4px 0 16px 24px;
4152
+ }
4153
+ .rp-docs-side {
4154
+ display: flex;
4155
+ flex-direction: column;
4156
+ align-items: flex-start;
4157
+ gap: 4px;
4158
+ min-width: 0;
4159
+ padding: 12px;
4160
+ border: 1px solid var(--border);
4161
+ }
4162
+ .rp-docs-side--new {
4163
+ border-color: var(--foreground);
4164
+ }
4165
+ .rp-docs-side__label {
4166
+ font-size: 12px;
4167
+ color: var(--muted-foreground);
4168
+ }
4169
+ .rp-docs-side__where {
4170
+ max-width: 100%;
4171
+ font-family: var(--mono);
4172
+ font-size: 12px;
4173
+ overflow-wrap: anywhere;
4174
+ }
4175
+ .rp-docs-side__edit {
4176
+ margin-bottom: 8px;
4177
+ font-size: 14px;
4178
+ }
4179
+ .rp-docs-diff {
4180
+ grid-column: 1 / -1;
4181
+ margin: 0;
4182
+ padding: 8px 10px;
4183
+ border: 1px solid var(--border);
4184
+ font-family: var(--mono);
4185
+ font-size: 12px;
4186
+ overflow-wrap: anywhere;
4187
+ }
4188
+ .rp-docs-more {
4189
+ grid-column: 1 / -1;
4190
+ display: flex;
4191
+ flex-wrap: wrap;
4192
+ gap: 8px;
4193
+ }
4194
+ .rp-docs-add {
4195
+ display: flex;
4196
+ flex-wrap: wrap;
4197
+ align-items: center;
4198
+ gap: 8px;
4199
+ padding: 6px 0 12px 24px;
4200
+ }
4201
+ .rp-docs-add__input {
4202
+ flex: 1 1 240px;
4203
+ }
4204
+ .rp-docs-add__err {
4205
+ flex-basis: 100%;
4206
+ font-size: 12px;
4207
+ color: var(--destructive);
4208
+ }
4209
+ @media (max-width: 640px) {
4210
+ .rp-docs-detail {
4211
+ grid-template-columns: 1fr;
4212
+ padding-left: 0;
4213
+ }
4214
+ }
4215
+
4070
4216
  /* -- layouts · .lg- the legacy and fixture shells (Run 3, slice 3D) -- */
4071
4217
  /* The legacy and fixture cockpit's two columns: the list head (a stack) and the rail's tile column. */
4072
4218
  .lg-stack {