create-pmos 0.55.5 → 0.57.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/REMOTE.md +38 -3
- package/workspace/guide/docs.md +96 -0
- package/workspace/guide/index.md +3 -1
- package/workspace/guide/layout.md +2 -1
- package/workspace/lib/access-jwt.js +66 -0
- package/workspace/lib/access-keys.js +104 -0
- package/workspace/lib/doc-sync-store.js +322 -0
- package/workspace/lib/doc-sync.js +225 -0
- package/workspace/lib/docs-cli.js +221 -0
- package/workspace/lib/online-mode.js +158 -0
- package/workspace/lib/pmos-verbs.js +3 -0
- package/workspace/lib/remote-access.js +5 -3
- package/workspace/package-lock.json +1 -1
- package/workspace/package.json +1 -1
- package/workspace/public/pm/app.js +2 -2
- package/workspace/public/pm/components.css +146 -0
- package/workspace/public/pm/dest-docs.js +405 -0
- package/workspace/public/pm/dest-settings.js +56 -3
- package/workspace/public/pm/frame-copy.js +19 -0
- package/workspace/public/pm/registry.js +2 -0
- package/workspace/replaced-tests.json +25 -0
- package/workspace/server.js +215 -9
- package/workspace/skills/doc-check.skill +40 -0
- package/workspace/skills/doc-sync.skill +34 -0
- package/workspace/test-lists/docs-three-way-sync.json +6 -0
- package/workspace/test-lists/workspace-online-mode.json +5 -0
|
@@ -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 };
|
|
@@ -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
|
+
};
|