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 +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/guide/docs.md +96 -0
- package/workspace/guide/index.md +3 -1
- package/workspace/guide/today.md +15 -1
- 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/goals.js +44 -4
- package/workspace/lib/pmos-verbs.js +3 -0
- package/workspace/lib/proactive.js +20 -2
- 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 +149 -0
- package/workspace/public/pm/dest-docs.js +405 -0
- package/workspace/public/pm/dest-today.js +4 -1
- package/workspace/public/pm/registry.js +2 -0
- package/workspace/public/pm/today-copy.js +5 -0
- package/workspace/public/pm/today-day.js +57 -17
- package/workspace/public/pm/today-goals.js +13 -7
- package/workspace/replaced-tests.json +14 -0
- package/workspace/server.js +110 -2
- 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
|
@@ -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 };
|
package/workspace/lib/goals.js
CHANGED
|
@@ -7,9 +7,16 @@
|
|
|
7
7
|
*
|
|
8
8
|
* emptyDay(date) → { date, goals: [] }
|
|
9
9
|
* edit(day, op, { now }) → { ok:true, day } | { ok:false, error }
|
|
10
|
-
* op: { action: 'add', text, initiative_id? } | { action: 'done'|'drop'|'reopen'|'remove', id }
|
|
10
|
+
* op: { action: 'add', text, initiative_id?, repo? } | { action: 'done'|'drop'|'reopen'|'remove', id }
|
|
11
|
+
* | { action: 'tag', id, repo } (repo: a workspace id, or null for none)
|
|
11
12
|
* readDay(raw, date) → a checked day record, or an empty day when `raw` is not one
|
|
12
13
|
* planDay({ day, brief, cards, now, endOfDay }) → the plan Today and `pmos proactive goals` show
|
|
14
|
+
* itemRepos({ day, brief, placeRepos }) → { itemId: repo } for the brief items that have a repository
|
|
15
|
+
*
|
|
16
|
+
* ONE DAY, REPOSITORY TAGS. The day is the PM's, not a repository's: every repository shows the same goals, brief and
|
|
17
|
+
* jobs. A goal can carry a repository (`repo`, a workspace id), and so can a channel, project or space (the settings'
|
|
18
|
+
* `placeRepos`). An item takes the repository of the open goal it is under; failing that, of the place it came from
|
|
19
|
+
* (its `where`). An item with neither is personal and shows under every repository.
|
|
13
20
|
*
|
|
14
21
|
* THE JOIN IS CHECKABLE. A brief item or calendar event belongs to a goal when it names the goal's id (the morning-brief
|
|
15
22
|
* session sets it). Failing that, an item or Today card belongs to the first open goal tied to the same initiative. There
|
|
@@ -25,7 +32,9 @@ const crypto = require('crypto');
|
|
|
25
32
|
const MAX_GOALS = 7;
|
|
26
33
|
const MAX_TEXT = 140;
|
|
27
34
|
const STATUSES = ['open', 'done', 'dropped'];
|
|
28
|
-
const ACTIONS = ['add', 'done', 'drop', 'reopen', 'remove'];
|
|
35
|
+
const ACTIONS = ['add', 'done', 'drop', 'reopen', 'remove', 'tag'];
|
|
36
|
+
const REPO_RE = /^[A-Za-z0-9._-]{1,80}$/;
|
|
37
|
+
const repoOrNull = (r) => (typeof r === 'string' && REPO_RE.test(r) ? r : null);
|
|
29
38
|
const INI_RE = /^[a-z0-9][a-z0-9._-]*$/i;
|
|
30
39
|
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
31
40
|
const GOAL_ID_RE = /^g-[0-9a-f]{8}$/;
|
|
@@ -42,6 +51,7 @@ function readDay(raw, date) {
|
|
|
42
51
|
&& STATUSES.includes(g.status)).slice(0, MAX_GOALS).map((g) => ({
|
|
43
52
|
id: g.id, text: clip(g.text, MAX_TEXT), initiative_id: typeof g.initiative_id === 'string' && INI_RE.test(g.initiative_id) ? g.initiative_id : null,
|
|
44
53
|
status: g.status, added_at: typeof g.added_at === 'string' ? g.added_at : null, closed_at: typeof g.closed_at === 'string' ? g.closed_at : null,
|
|
54
|
+
repo: repoOrNull(g.repo),
|
|
45
55
|
}));
|
|
46
56
|
return { date, goals };
|
|
47
57
|
}
|
|
@@ -58,16 +68,22 @@ function edit(day, op, { now = Date.now() } = {}) {
|
|
|
58
68
|
if (goals.length >= MAX_GOALS) return { ok: false, error: `a day has at most ${MAX_GOALS} goals. Drop or remove one first.` };
|
|
59
69
|
const ini = op.initiative_id == null || op.initiative_id === '' ? null : op.initiative_id;
|
|
60
70
|
if (ini !== null && (typeof ini !== 'string' || !INI_RE.test(ini))) return { ok: false, error: 'initiative_id is not an initiative id' };
|
|
71
|
+
if (op.repo != null && op.repo !== '' && !repoOrNull(op.repo)) return { ok: false, error: 'repo is not a workspace id' };
|
|
61
72
|
const text = clip(op.text, MAX_TEXT);
|
|
62
73
|
if (goals.some((g) => g.text.toLowerCase() === text.toLowerCase())) return { ok: false, error: 'that goal is already on the list' };
|
|
63
74
|
const id = 'g-' + crypto.createHash('sha1').update(`${day.date}|${text}|${at}|${goals.length}`).digest('hex').slice(0, 8);
|
|
64
|
-
goals.push({ id, text, initiative_id: ini, status: 'open', added_at: at, closed_at: null });
|
|
75
|
+
goals.push({ id, text, initiative_id: ini, status: 'open', added_at: at, closed_at: null, repo: repoOrNull(op.repo) });
|
|
65
76
|
return { ok: true, day: { date: day.date, goals }, goal: goals[goals.length - 1] };
|
|
66
77
|
}
|
|
67
78
|
const i = goals.findIndex((g) => g.id === op.id);
|
|
68
79
|
if (i < 0) return { ok: false, error: 'no goal with that id today' };
|
|
69
80
|
const g = goals[i];
|
|
70
81
|
if (op.action === 'remove') { goals.splice(i, 1); return { ok: true, day: { date: day.date, goals }, goal: g }; }
|
|
82
|
+
if (op.action === 'tag') {
|
|
83
|
+
if (op.repo != null && op.repo !== '' && !repoOrNull(op.repo)) return { ok: false, error: 'repo is not a workspace id' };
|
|
84
|
+
g.repo = repoOrNull(op.repo);
|
|
85
|
+
return { ok: true, day: { date: day.date, goals }, goal: g };
|
|
86
|
+
}
|
|
71
87
|
const to = { done: 'done', drop: 'dropped', reopen: 'open' }[op.action];
|
|
72
88
|
if (g.status === to) return { ok: false, error: `that goal is already ${to}` };
|
|
73
89
|
g.status = to; g.closed_at = to === 'open' ? null : at;
|
|
@@ -184,4 +200,28 @@ function planDay({ day, brief = null, cards = [], now = Date.now(), endOfDay = E
|
|
|
184
200
|
};
|
|
185
201
|
}
|
|
186
202
|
|
|
187
|
-
|
|
203
|
+
/** The place an item came from, as a key that a place name can match: lower case, no leading '#', and a Jira issue key
|
|
204
|
+
* (PAY-412) reduced to its project key (pay). A place named "PAY · Payments" matches by its first word. */
|
|
205
|
+
const placeKey = (s) => { const t = String(s || '').trim().toLowerCase().replace(/^#/, ''); const k = /^([a-z][a-z0-9]*)-\d+$/.exec(t); return k ? k[1] : t; };
|
|
206
|
+
const placeNameKeys = (n) => { const t = String(n || '').trim().toLowerCase().replace(/^#/, ''); return [t, t.split(/[\s·:|]+/)[0]].filter(Boolean); };
|
|
207
|
+
|
|
208
|
+
/** { itemId: repo } for today's brief items: the repository of the open goal an item is under, else of its place. */
|
|
209
|
+
function itemRepos({ day, brief, placeRepos = {} } = {}) {
|
|
210
|
+
const out = {};
|
|
211
|
+
if (!brief || !day || brief.date !== day.date) return out;
|
|
212
|
+
const open = (day.goals || []).filter((g) => g.status === 'open');
|
|
213
|
+
const byId = new Map(open.map((g) => [g.id, g]));
|
|
214
|
+
const byIni = (ini) => (ini ? open.find((g) => g.initiative_id === ini) || null : null);
|
|
215
|
+
for (const x of [...(brief.priorities || []), ...(brief.worth_asking || [])]) {
|
|
216
|
+
const g = (x.goal && byId.get(x.goal)) || byIni(x.initiative_id);
|
|
217
|
+
let r = g ? repoOrNull(g.repo) : null;
|
|
218
|
+
if (!r && x.where) {
|
|
219
|
+
const want = placeKey(x.where);
|
|
220
|
+
for (const [name, repo] of Object.entries(placeRepos[x.source] || {})) if (placeNameKeys(name).includes(want) && repoOrNull(repo)) { r = repo; break; }
|
|
221
|
+
}
|
|
222
|
+
if (r) out[x.id] = r;
|
|
223
|
+
}
|
|
224
|
+
return out;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
module.exports = { MAX_GOALS, MAX_TEXT, STATUSES, ACTIONS, GOAL_ID_RE, START_OF_DAY, END_OF_DAY, emptyDay, readDay, edit, freeTime, planDay, itemRepos };
|
|
@@ -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
|
});
|
|
@@ -44,7 +44,7 @@ const MAX_WEBSITES = 10;
|
|
|
44
44
|
const DAY_CHECK_EVERY = [0, 30, 60];
|
|
45
45
|
const DAY_CHECK_DEFAULT = Object.freeze({ every: 60, start: '09:00', end: '18:00' });
|
|
46
46
|
const DEFAULTS = Object.freeze({ name: '', briefSources: BRIEF_DEFAULT, briefScoring: false, backgroundPrepare: false, enabled: true, triggers: Object.freeze(Object.fromEntries(TRIGGERS.map((t) => [t, true]))), stalledDays: 5, quiet: null, pauseLearning: false,
|
|
47
|
-
scopes: Object.freeze({ slack: Object.freeze({}), jira: Object.freeze({}), confluence: Object.freeze({}) }), dayCheck: DAY_CHECK_DEFAULT, websites: Object.freeze([]) });
|
|
47
|
+
scopes: Object.freeze({ slack: Object.freeze({}), jira: Object.freeze({}), confluence: Object.freeze({}) }), placeRepos: Object.freeze({ slack: Object.freeze({}), jira: Object.freeze({}), confluence: Object.freeze({}) }), dayCheck: DAY_CHECK_DEFAULT, websites: Object.freeze([]) });
|
|
48
48
|
|
|
49
49
|
const idOf = (key) => crypto.createHash('sha1').update(String(key)).digest('hex').slice(0, 12);
|
|
50
50
|
const clip = (s, n) => String(s == null ? '' : s).replace(/\s+/g, ' ').trim().slice(0, n);
|
|
@@ -178,7 +178,7 @@ const HHMM = /^([01]\d|2[0-3]):([0-5]\d)$/;
|
|
|
178
178
|
function normalizeSettings(raw) {
|
|
179
179
|
const r = raw && typeof raw === 'object' ? raw : {};
|
|
180
180
|
const s = { name: DEFAULTS.name, briefSources: { ...BRIEF_DEFAULT }, briefScoring: DEFAULTS.briefScoring, backgroundPrepare: DEFAULTS.backgroundPrepare, enabled: DEFAULTS.enabled, triggers: { ...DEFAULTS.triggers }, stalledDays: DEFAULTS.stalledDays, quiet: DEFAULTS.quiet, pauseLearning: DEFAULTS.pauseLearning,
|
|
181
|
-
scopes: { slack: {}, jira: {}, confluence: {} }, dayCheck: { ...DAY_CHECK_DEFAULT }, websites: [] };
|
|
181
|
+
scopes: { slack: {}, jira: {}, confluence: {} }, placeRepos: { slack: {}, jira: {}, confluence: {} }, dayCheck: { ...DAY_CHECK_DEFAULT }, websites: [] };
|
|
182
182
|
if (r.enabled !== undefined) { if (typeof r.enabled !== 'boolean') return { ok: false, error: 'enabled must be true or false' }; s.enabled = r.enabled; }
|
|
183
183
|
if (r.name !== undefined) { if (typeof r.name !== 'string' || r.name.length > 40) return { ok: false, error: 'name must be text of at most 40 characters' }; s.name = r.name.replace(/\s+/g, ' ').trim(); }
|
|
184
184
|
if (r.briefScoring !== undefined) { if (typeof r.briefScoring !== 'boolean') return { ok: false, error: 'briefScoring must be true or false' }; s.briefScoring = r.briefScoring; }
|
|
@@ -228,6 +228,24 @@ function normalizeSettings(raw) {
|
|
|
228
228
|
s.scopes[svc] = out;
|
|
229
229
|
}
|
|
230
230
|
}
|
|
231
|
+
// placeRepos: the repository a channel, project or space belongs to (a workspace id). Today shows its mentions and jobs
|
|
232
|
+
// under that repository; a place with none is personal. null clears it; the route merges a patch over what is stored.
|
|
233
|
+
if (r.placeRepos !== undefined) {
|
|
234
|
+
if (!r.placeRepos || typeof r.placeRepos !== 'object' || Array.isArray(r.placeRepos)) return { ok: false, error: `placeRepos must be an object of ${SCOPE_SERVICES.join(', ')} → {name: workspace id}` };
|
|
235
|
+
for (const [svc, names] of Object.entries(r.placeRepos)) {
|
|
236
|
+
if (!SCOPE_SERVICES.includes(svc)) return { ok: false, error: `unknown placeRepos service "${svc}" — one of ${SCOPE_SERVICES.join(', ')}` };
|
|
237
|
+
if (!names || typeof names !== 'object' || Array.isArray(names)) return { ok: false, error: `placeRepos.${svc} must be an object of name → workspace id` };
|
|
238
|
+
const out = {};
|
|
239
|
+
for (const [name, repo] of Object.entries(names)) {
|
|
240
|
+
if (repo === null || repo === '') continue;
|
|
241
|
+
if (!name.trim() || name.length > 120) return { ok: false, error: `a ${svc} name is 1 to 120 characters` };
|
|
242
|
+
if (typeof repo !== 'string' || !/^[A-Za-z0-9._-]{1,80}$/.test(repo)) return { ok: false, error: `the repository of ${svc} "${name}" must be a workspace id` };
|
|
243
|
+
out[name] = repo;
|
|
244
|
+
}
|
|
245
|
+
if (Object.keys(out).length > MAX_SCOPE_NAMES) return { ok: false, error: `placeRepos.${svc} has ${Object.keys(out).length} names — at most ${MAX_SCOPE_NAMES}` };
|
|
246
|
+
s.placeRepos[svc] = out;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
231
249
|
if (r.dayCheck !== undefined) {
|
|
232
250
|
const d = r.dayCheck;
|
|
233
251
|
if (!d || typeof d !== 'object' || Array.isArray(d)) return { ok: false, error: 'dayCheck must be {every, start, end}' };
|
package/workspace/package.json
CHANGED
|
@@ -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;
|