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
|
@@ -130,7 +130,10 @@ function deriveStage(run, brief) {
|
|
|
130
130
|
return 'done';
|
|
131
131
|
}
|
|
132
132
|
|
|
133
|
-
|
|
133
|
+
/* One day for every repository. `view` is { here: the repository Today was opened from (a workspace id), all: the
|
|
134
|
+
* Show switch is on All repositories }. An item, a goal or a place carries a repository or none; with the switch on one
|
|
135
|
+
* repository, the day keeps that repository's items and the ones with none. The calendar is never narrowed. */
|
|
136
|
+
function model(d, view) {
|
|
134
137
|
const today = d && d.today;
|
|
135
138
|
const brief = d && d.brief && (!today || d.brief.date === today) ? d.brief : null;
|
|
136
139
|
const plan = (d && d.plan) || null;
|
|
@@ -156,17 +159,31 @@ function model(d) {
|
|
|
156
159
|
const level = (svc, name) => (scopes[svc] && scopes[svc][name]) || 'search';
|
|
157
160
|
const digests = ((brief && Array.isArray(brief.digests)) ? brief.digests : []).map((g) => ({ ...g, id: g.source + '|' + g.name, level: level(g.source, g.name) }));
|
|
158
161
|
const places = (brief && Array.isArray(brief.places)) ? brief.places : [];
|
|
162
|
+
const placeRepos = (settings && settings.placeRepos) || {};
|
|
163
|
+
const placeRepo = (svc, name) => (placeRepos[svc] && placeRepos[svc][name]) || null;
|
|
164
|
+
const itemRepos = (d && d.item_repos) || {};
|
|
165
|
+
const repos = (d && Array.isArray(d.repos)) ? d.repos : [];
|
|
166
|
+
const repoName = (id) => { const r = repos.find((x) => x.id === id); return r ? r.name : id; };
|
|
167
|
+
for (const j of jobs) j.repo = itemRepos[j.id] || null;
|
|
168
|
+
for (const g of digests) g.repo = placeRepo(g.source, g.name);
|
|
169
|
+
const v = view || {};
|
|
170
|
+
const narrow = !v.all && !!v.here;
|
|
171
|
+
const inView = (r) => !narrow || !r || r === v.here;
|
|
172
|
+
const shownJobs = jobs.filter((j) => inView(j.repo));
|
|
173
|
+
const shownIds = new Set(shownJobs.map((j) => j.task && j.task.id).filter(Boolean));
|
|
174
|
+
const shownTasks = narrow ? tasks.filter((t) => shownIds.has(t.id)) : tasks;
|
|
159
175
|
return {
|
|
160
|
-
d, brief, plan, goals, goalNo, run, tasks, jobs, events, now, settings, scopes, level,
|
|
176
|
+
d, brief, plan, goals: goals.filter((g) => inView(g.repo)), allGoals: goals, goalNo, run, tasks: shownTasks, jobs: shownJobs, events, now, settings, scopes, level,
|
|
177
|
+
digests: digests.filter((g) => inView(g.repo)), places, placeRepo, repos, repoName, here: v.here || null, narrow,
|
|
161
178
|
stage: run ? (d.stage || deriveStage(run, brief)) : null,
|
|
162
|
-
mentions:
|
|
179
|
+
mentions: shownJobs.filter((j) => j.item && j.item.mention),
|
|
163
180
|
sources: (d && Array.isArray(d.sources)) ? d.sources : [],
|
|
164
181
|
check: (d && d.check) || null,
|
|
165
182
|
readFailed: (d && d.read_failed) || null,
|
|
166
183
|
free: plan && plan.time ? plan.time.free_minutes : null,
|
|
167
184
|
maxGoals: (d && d.max_goals) || 7,
|
|
168
|
-
needs:
|
|
169
|
-
done:
|
|
185
|
+
needs: shownJobs.filter((j) => j.group === 'needs').length,
|
|
186
|
+
done: shownJobs.filter((j) => j.group === 'done').length,
|
|
170
187
|
};
|
|
171
188
|
}
|
|
172
189
|
|
|
@@ -179,6 +196,20 @@ const TONE = { '': 'muted', ok: 'outline', info: 'outline', goal: 'outline', war
|
|
|
179
196
|
function pill(text, tone, attrs) {
|
|
180
197
|
return badge({ text, tone: TONE[tone || ''] || 'muted', attrs });
|
|
181
198
|
}
|
|
199
|
+
/** The repository a row belongs to, as a small tag. A row with none (yours) carries no tag. */
|
|
200
|
+
function repoTag(m, r) {
|
|
201
|
+
return r ? badge({ text: m.repoName(r), tone: 'outline', attrs: { 'data-day-repo': r, title: COPY.day.repo.label } }) : null;
|
|
202
|
+
}
|
|
203
|
+
/** A picker of the repository: none, or one of the registered repositories. `onPick(id|null)`. */
|
|
204
|
+
export function repoPicker(m, cur, onPick, attrs) {
|
|
205
|
+
const sel = el('select', { class: 'td-day__repo', 'aria-label': COPY.day.repo.label, ...(attrs || {}) },
|
|
206
|
+
el('option', { value: '' }, COPY.day.repo.none), ...m.repos.map((r) => el('option', { value: r.id }, r.name)),
|
|
207
|
+
// a repository that is no longer registered stays shown, so the picker never hides what is stored
|
|
208
|
+
cur && !m.repos.some((r) => r.id === cur) ? el('option', { value: cur }, cur) : null);
|
|
209
|
+
sel.value = cur || '';
|
|
210
|
+
sel.addEventListener('change', () => onPick(sel.value || null));
|
|
211
|
+
return sel;
|
|
212
|
+
}
|
|
182
213
|
function statusPill(j) {
|
|
183
214
|
const s = D.status2;
|
|
184
215
|
const map = {
|
|
@@ -365,7 +396,7 @@ function jobRow(ctx, j) {
|
|
|
365
396
|
const n = j.goal ? ctx.m.goalNo.get(j.goal) : null;
|
|
366
397
|
const sub = j.status === 'failed' && j.task && j.task.error ? j.task.error
|
|
367
398
|
: [j.item && j.item.where, srcName(j.source)].filter(Boolean).join(' · ');
|
|
368
|
-
return rowBtn(ctx, 'job', j.id, { lead: n ? D.pill.goal(n) : '·', title: j.title, meta: sub, trail: statusPill(j),
|
|
399
|
+
return rowBtn(ctx, 'job', j.id, { lead: n ? D.pill.goal(n) : '·', title: j.title, meta: sub, trail: el('span', { class: 'td-day__chips' }, repoTag(ctx.m, j.repo), statusPill(j)),
|
|
369
400
|
dot: j.isNew && !seen.has(j.id) ? el('span', { class: 'td-day__newdot', 'data-day': 'new', role: 'img', 'aria-label': D.newItem }) : null });
|
|
370
401
|
}
|
|
371
402
|
|
|
@@ -412,7 +443,7 @@ function waitingPanel(owed) {
|
|
|
412
443
|
|
|
413
444
|
function goalsPanel(ctx) {
|
|
414
445
|
const { m } = ctx;
|
|
415
|
-
return panel('goals', D.goalsCount(m.
|
|
446
|
+
return panel('goals', D.goalsCount(m.allGoals.filter((g) => g.status === 'open').length, m.maxGoals), goalRows(ctx), goalAdd(ctx));
|
|
416
447
|
}
|
|
417
448
|
|
|
418
449
|
function mentionStatus(j) {
|
|
@@ -423,7 +454,7 @@ function mentionStatus(j) {
|
|
|
423
454
|
|
|
424
455
|
function digestRow(ctx, g) {
|
|
425
456
|
const sub = D.digestLine(D.levelWord[g.level] || g.level, (g.decisions || []).length, (g.open || []).length);
|
|
426
|
-
return rowBtn(ctx, 'channel', g.id, { lead: el('span', { class: 'td-day__chip' }, srcName(g.source)), title: g.name, meta: sub });
|
|
457
|
+
return rowBtn(ctx, 'channel', g.id, { lead: el('span', { class: 'td-day__chip' }, srcName(g.source)), title: g.name, meta: sub, trail: repoTag(ctx.m, g.repo) });
|
|
427
458
|
}
|
|
428
459
|
|
|
429
460
|
function mentionsPanel(ctx) {
|
|
@@ -438,7 +469,7 @@ function mentionsPanel(ctx) {
|
|
|
438
469
|
const rows = shown.length ? el('ul', { class: 'td-day__rows', 'data-day-list': 'mentions' }, ...shown.map((j) => {
|
|
439
470
|
const it = j.item;
|
|
440
471
|
return rowBtn(ctx, 'mention', j.id, { lead: el('span', { class: 'td-day__chip' }, srcName(j.source)),
|
|
441
|
-
title: it.about && it.ask ? `${it.about}: ${it.ask}` : it.ask || j.title, meta: D.fromWho(it.who, it.where) || it.why_now || '', trail: mentionStatus(j) });
|
|
472
|
+
title: it.about && it.ask ? `${it.about}: ${it.ask}` : it.ask || j.title, meta: D.fromWho(it.who, it.where) || it.why_now || '', trail: el('span', { class: 'td-day__chips' }, repoTag(ctx.m, j.repo), mentionStatus(j)) });
|
|
442
473
|
})) : note(D.noMentions);
|
|
443
474
|
const live = m.digests.filter((g) => g.level !== 'ignore');
|
|
444
475
|
const ign = m.digests.filter((g) => g.level === 'ignore');
|
|
@@ -469,7 +500,7 @@ function stepItem(st) {
|
|
|
469
500
|
function eventDetail(ctx, e) {
|
|
470
501
|
const { m } = ctx;
|
|
471
502
|
const n = e.goal ? m.goalNo.get(e.goal) : null;
|
|
472
|
-
const g = n ? m.
|
|
503
|
+
const g = n ? m.allGoals[n - 1] : null;
|
|
473
504
|
const ini = e.initiative && e.initiative.id ? e.initiative : null;
|
|
474
505
|
const steps = e.steps || {};
|
|
475
506
|
const notes = e.notes || {};
|
|
@@ -484,7 +515,7 @@ function eventDetail(ctx, e) {
|
|
|
484
515
|
list(D.actions, (w.actions || []).map((a) => (typeof a === 'string' ? a : stepItem(a))))] : note(D.noWispr),
|
|
485
516
|
mailBlock || note(D.noMail)) : null;
|
|
486
517
|
const tie = g ? el('p', { class: 'td-day__goalline', 'data-day': 'tie' }, D.countsToward(n, g.text))
|
|
487
|
-
: [note(D.tieHow, { 'data-day': 'tie' }), m.
|
|
518
|
+
: [note(D.tieHow, { 'data-day': 'tie' }), m.allGoals.length < m.maxGoals ? el('div', { class: 'td-day__acts' }, button({ label: D.addAsGoal, size: 'sm', attrs: { 'data-day-act': 'add-goal' },
|
|
488
519
|
onClick: async () => { if (await addGoal(ctx, e.title, ini ? ini.id : null)) toast({ text: D.goalAdded }); } })) : null];
|
|
489
520
|
return [
|
|
490
521
|
dhead(D.kind.meeting, e.title),
|
|
@@ -557,6 +588,7 @@ function jobDetail(ctx, j) {
|
|
|
557
588
|
const mention = j.item && j.item.mention;
|
|
558
589
|
return [
|
|
559
590
|
dhead(n ? D.kind.jobGoal(n) : D.kind.job, null),
|
|
591
|
+
j.repo ? el('div', { class: 'td-day__acts' }, repoTag(m, j.repo)) : null,
|
|
560
592
|
jobCard(ctx, j),
|
|
561
593
|
meets.length || mention ? el('div', { class: 'td-day__rel' }, el('h4', null, D.related), el('ul', { class: 'td-day__rows' },
|
|
562
594
|
...meets.map((e) => relRow(ctx, 'event', e.id, e.title, e.time)),
|
|
@@ -586,6 +618,10 @@ function setLevel(ctx, svc, name, lvl) {
|
|
|
586
618
|
return setSettings(ctx.ws, { scopes });
|
|
587
619
|
}
|
|
588
620
|
|
|
621
|
+
function setPlaceRepo(ctx, svc, name, repo) {
|
|
622
|
+
return setSettings(ctx.ws, { placeRepos: { [svc]: { [name]: repo } } });
|
|
623
|
+
}
|
|
624
|
+
|
|
589
625
|
function channelDetail(ctx, id) {
|
|
590
626
|
const g = ctx.m.digests.find((x) => x.id === id);
|
|
591
627
|
if (!g) return null;
|
|
@@ -594,6 +630,7 @@ function channelDetail(ctx, id) {
|
|
|
594
630
|
dhead(D.kind.place(srcName(g.source), D.place[g.source] || '', D.levelWord[g.level] || g.level), g.name),
|
|
595
631
|
sec(D.decisions, g.decisions), sec(D.open, g.open), sec(D.forGoals, g.goals),
|
|
596
632
|
el('div', { class: 'td-day__ch' }, el('span', null, D.changeLevel), levelSeg(ctx, g.source, g.name)),
|
|
633
|
+
el('div', { class: 'td-day__ch' }, el('span', null, D.repo.label), repoPicker(ctx.m, g.repo, (r) => setPlaceRepo(ctx, g.source, g.name, r), { 'data-day-place-repo': g.source + ':' + g.name })),
|
|
597
634
|
note(D.levelHelp),
|
|
598
635
|
el('div', { class: 'td-day__acts' }, extLink(D.openIn(srcName(g.source)), g.link, { 'data-day-link': 'place' }),
|
|
599
636
|
button({ label: D.manageSources, size: 'sm', variant: 'ghost', onClick: () => select('sources', 'all') })),
|
|
@@ -619,7 +656,7 @@ function stepDetail(ctx, i) {
|
|
|
619
656
|
if (i === 1) {
|
|
620
657
|
const live = m.jobs.filter((j) => !j.worth);
|
|
621
658
|
return [head, el('p', null, D.madeJobs(live.length, live.filter((j) => j.kind === 'you').length, live.filter((j) => j.kind !== 'you').length)),
|
|
622
|
-
m.goals.length ? el('ul', { class: 'td-day__rows' }, ...m.goals.map((g
|
|
659
|
+
m.goals.length ? el('ul', { class: 'td-day__rows' }, ...m.goals.map((g) => relRow(ctx, 'goal', g.id, g.text, String(m.goalNo.get(g.id))))) : null];
|
|
623
660
|
}
|
|
624
661
|
if (i === 2) {
|
|
625
662
|
const ts = m.jobs.filter((j) => j.task);
|
|
@@ -641,7 +678,8 @@ function placeControls(ctx, svc) {
|
|
|
641
678
|
// every place the last read found you in, then any you set a level for or that has a digest
|
|
642
679
|
const names = [...new Set([...m.places.filter((x) => x.source === svc).map((x) => x.name), ...Object.keys(m.scopes[svc] || {}),
|
|
643
680
|
...m.digests.filter((g) => g.source === svc).map((g) => g.name)])];
|
|
644
|
-
const row = (name) => el('div', { class: 'td-day__ch', 'data-day-place': svc + ':' + name }, el('span', null, name),
|
|
681
|
+
const row = (name) => el('div', { class: 'td-day__ch', 'data-day-place': svc + ':' + name }, el('span', null, name),
|
|
682
|
+
el('span', { class: 'td-day__chips' }, repoPicker(m, m.placeRepo(svc, name), (r) => setPlaceRepo(ctx, svc, name, r), { 'data-day-place-repo': svc + ':' + name }), levelSeg(ctx, svc, name)));
|
|
645
683
|
const live = names.filter((x) => m.level(svc, x) !== 'ignore');
|
|
646
684
|
const ign = names.filter((x) => m.level(svc, x) === 'ignore');
|
|
647
685
|
const input = field({ label: D.placeAdd[svc], value: '', placeholder: D.placeHint, hideLabel: true, attrs: { maxlength: '120', 'data-day-input': 'place-' + svc },
|
|
@@ -663,6 +701,7 @@ function placeControls(ctx, svc) {
|
|
|
663
701
|
ui.showIgn[svc] ? ign.map(row) : null,
|
|
664
702
|
input,
|
|
665
703
|
note(D.levelHelp),
|
|
704
|
+
note(D.repo.placeHint),
|
|
666
705
|
];
|
|
667
706
|
}
|
|
668
707
|
|
|
@@ -753,14 +792,14 @@ function fold(open) {
|
|
|
753
792
|
}
|
|
754
793
|
|
|
755
794
|
/** The section. `ws` is the repository the day run belongs to; `openIni(id)` opens an initiative of it. */
|
|
756
|
-
export function daySection({ ws, setState, openIni, owed } = {}) {
|
|
795
|
+
export function daySection({ ws, setState, openIni, owed, here, all } = {}) {
|
|
757
796
|
if (FIXTURE) return null;
|
|
758
797
|
// like Today's own feeds, a read that lands re-renders only while the section is on the page: a hidden Today must not
|
|
759
798
|
// cause a whole-page render under whatever the PM is doing elsewhere
|
|
760
799
|
if (setState) rerender = () => { if (document.querySelector('[data-today="day"]')) setState({}); };
|
|
761
800
|
ensure(ws);
|
|
762
801
|
const c = cache.get(ws) || {};
|
|
763
|
-
const m = model(c.data);
|
|
802
|
+
const m = model(c.data, { here: here || null, all: !!all });
|
|
764
803
|
const open = ui.open !== null ? ui.open : !!m.run;
|
|
765
804
|
if (!open) {
|
|
766
805
|
return el('section', { class: 'td-day', 'data-today': 'day', 'data-open': 'false', 'aria-label': D.title },
|
|
@@ -768,7 +807,7 @@ export function daySection({ ws, setState, openIni, owed } = {}) {
|
|
|
768
807
|
}
|
|
769
808
|
const sel = ui.sel;
|
|
770
809
|
const ctx = {
|
|
771
|
-
ws, m, sel, openIni, reload: () => load(ws), rerender: () => rerender(), select,
|
|
810
|
+
ws, m, sel, openIni, reload: () => load(ws), rerender: () => rerender(), select, repoPicker,
|
|
772
811
|
cur: (type, id) => { const s = sel || defaultSel(m); return s.type === type && s.id === String(id) ? 'true' : 'false'; },
|
|
773
812
|
};
|
|
774
813
|
ctx.sel = sel || defaultSel(m);
|
|
@@ -784,10 +823,11 @@ export function daySection({ ws, setState, openIni, owed } = {}) {
|
|
|
784
823
|
return el('section', { class: 'td-day', 'data-today': 'day', 'data-open': 'true', 'aria-label': D.title },
|
|
785
824
|
topBar(ctx),
|
|
786
825
|
runLine(ctx),
|
|
826
|
+
m.narrow && m.repos.length > 1 ? note(D.repo.only(m.repoName(m.here)), { 'data-day': 'repo-view' }) : null,
|
|
787
827
|
c.error ? el('p', { class: 'td-day__run td-day__run--failed', role: 'status', 'data-day': 'error' }, D.refused(c.error)) : null,
|
|
788
828
|
m.d && m.d.legacy ? note(D.oldServer) : null,
|
|
789
829
|
el('div', { class: 'td-day__cols' },
|
|
790
|
-
el('div', { class: 'td-day__left' }, summaryPanel(ctx),
|
|
830
|
+
el('div', { class: 'td-day__left' }, summaryPanel(ctx), goalsPanel(ctx), calendar(ctx), jobsPanel(ctx), mentionsPanel(ctx), waitingPanel(owed)),
|
|
791
831
|
detail(ctx)));
|
|
792
832
|
}
|
|
793
833
|
|
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
* goalDetail(ctx, goalId) → the right column's detail of one goal: its meetings, its jobs, the steps found, and the
|
|
6
6
|
* presses done, drop, reopen and remove; on an agent step that names an initiative, Prepare
|
|
7
7
|
* then Queue it (the shipped background prepare: one approval is one prompt hash)
|
|
8
|
-
* addGoal(ctx, text, ini) → POST the add; true when the goal is on the list
|
|
8
|
+
* addGoal(ctx, text, ini) → POST the add; true when the goal is on the list. While Today shows one repository, the
|
|
9
|
+
* goal belongs to it (repo); the goal's detail can change that.
|
|
9
10
|
* goalsSig() → a string that changes whenever what this module draws changes on its own
|
|
10
11
|
*
|
|
11
12
|
* `ctx` is the day section's (today-day.js): { ws, m (the day model), select(type, id), reload(), rerender(), cur(type, id) }.
|
|
@@ -33,6 +34,8 @@ async function press(ctx, body) {
|
|
|
33
34
|
export function addGoal(ctx, text, ini) {
|
|
34
35
|
const body = { action: 'add', text: String(text || '').trim().slice(0, 140) };
|
|
35
36
|
if (ini) body.initiative_id = ini;
|
|
37
|
+
// one day for every repository: a goal added while Today shows one repository belongs to it; otherwise to none
|
|
38
|
+
if (ctx.m && ctx.m.narrow && ctx.m.here) body.repo = ctx.m.here;
|
|
36
39
|
return press(ctx, body);
|
|
37
40
|
}
|
|
38
41
|
|
|
@@ -42,7 +45,7 @@ const jobsOf = (m, id) => m.jobs.filter((j) => j.goal === id && !j.worth);
|
|
|
42
45
|
export function goalRows(ctx) {
|
|
43
46
|
const { m } = ctx;
|
|
44
47
|
if (!m.goals.length) return null;
|
|
45
|
-
return el('ul', { class: 'td-day__rows', 'data-day-list': 'goals' }, ...m.goals.map((g
|
|
48
|
+
return el('ul', { class: 'td-day__rows', 'data-day-list': 'goals' }, ...m.goals.map((g) => {
|
|
46
49
|
const js = jobsOf(m, g.id);
|
|
47
50
|
const done = js.filter((j) => j.group === 'done').length;
|
|
48
51
|
const need = js.filter((j) => j.group === 'needs').length;
|
|
@@ -51,8 +54,8 @@ export function goalRows(ctx) {
|
|
|
51
54
|
const cur = ctx.cur('goal', g.id);
|
|
52
55
|
return el('li', null, row({
|
|
53
56
|
title: g.text, meta: [sub], done: closed, onOpen: () => ctx.select('goal', g.id),
|
|
54
|
-
lead: [el('span', { class: 'td-day__lead' }, String(
|
|
55
|
-
trail: [meter({ done: js.length ? done : g.status === 'done' ? 1 : 0, total: js.length || 1, label: '' })],
|
|
57
|
+
lead: [el('span', { class: 'td-day__lead' }, String(m.goalNo.get(g.id)))],
|
|
58
|
+
trail: [el('span', { class: 'td-day__chips' }, g.repo ? badge({ text: m.repoName(g.repo), tone: 'outline', attrs: { 'data-day-repo': g.repo } }) : null), meter({ done: js.length ? done : g.status === 'done' ? 1 : 0, total: js.length || 1, label: '' })],
|
|
56
59
|
attrs: { class: 'td-day__r', 'data-day-sel': 'goal:' + g.id, 'data-goal': g.id, 'data-goal-coverage': g.coverage, 'data-cur': cur },
|
|
57
60
|
openAttrs: { 'aria-current': cur },
|
|
58
61
|
}));
|
|
@@ -62,7 +65,7 @@ export function goalRows(ctx) {
|
|
|
62
65
|
export function goalAdd(ctx) {
|
|
63
66
|
const { m } = ctx;
|
|
64
67
|
const max = m.maxGoals;
|
|
65
|
-
if (m.
|
|
68
|
+
if (m.allGoals.length >= max) return el('p', { class: 'td-day__note' }, G.full(max));
|
|
66
69
|
const f = field({ label: G.inputLabel, value: '', placeholder: G.inputHint, hideLabel: true, attrs: { 'data-goals': 'input', 'data-day-input': 'goal', maxlength: '140' },
|
|
67
70
|
onCommit: async (v) => {
|
|
68
71
|
if (!String(v).trim()) { draft = v; return; }
|
|
@@ -111,9 +114,9 @@ function stepNode(ctx, st) {
|
|
|
111
114
|
/** The detail of one goal. `row(type, id, label, lead, trail)` draws a selectable row (today-day.js). */
|
|
112
115
|
export function goalDetail(ctx, id, row) {
|
|
113
116
|
const { m } = ctx;
|
|
114
|
-
const i = m.
|
|
117
|
+
const i = m.allGoals.findIndex((g) => g.id === id);
|
|
115
118
|
if (i < 0) return null;
|
|
116
|
-
const g = m.
|
|
119
|
+
const g = m.allGoals[i];
|
|
117
120
|
const closed = g.status !== 'open';
|
|
118
121
|
const js = jobsOf(m, g.id);
|
|
119
122
|
const meets = m.events.filter((e) => e.goal === g.id);
|
|
@@ -128,6 +131,9 @@ export function goalDetail(ctx, id, row) {
|
|
|
128
131
|
el('div', { class: 'td-day__dhead' }, el('span', { class: 'td-day__kind' }, D.kind.goal(i + 1)), el('h3', null, g.text)),
|
|
129
132
|
el('div', { class: 'td-day__acts' }, badge({ text: G.coverage[g.coverage] || g.coverage, tone: g.coverage === 'needs-plan' ? 'warning' : 'outline' }),
|
|
130
133
|
g.initiative_id ? el('span', { class: 'td-day__mono' }, g.initiative_id) : null),
|
|
134
|
+
ctx.repoPicker && m.repos.length ? el('div', { class: 'td-day__ch', 'data-goals': 'repo' }, el('span', null, D.repo.label),
|
|
135
|
+
ctx.repoPicker(m, g.repo, (r) => press(ctx, { action: 'tag', id: g.id, repo: r }), { 'data-goal-repo': g.id })) : null,
|
|
136
|
+
ctx.repoPicker && m.repos.length ? el('p', { class: 'td-day__note' }, D.repo.goalHint) : null,
|
|
131
137
|
js.length ? el('p', null, D.goalJobs(js.filter((j) => j.group === 'done').length, js.length)) : el('p', { class: 'td-day__note' }, G.noStep),
|
|
132
138
|
meets.length ? el('div', { class: 'td-day__rel' }, el('h4', null, D.meetings), el('ul', { class: 'td-day__rows' }, ...meets.map((e) => row('event', e.id, e.title, e.time)))) : null,
|
|
133
139
|
js.length ? el('div', { class: 'td-day__rel' }, el('h4', null, D.jobs), el('ul', { class: 'td-day__rows' }, ...js.map((j) => row('job', j.id, j.title, '', j)))) : null,
|
|
@@ -2904,6 +2904,20 @@
|
|
|
2904
2904
|
"names": [
|
|
2905
2905
|
"E3b: an outcome check recorded ONLY in planning/runs/ disposes its initiative"
|
|
2906
2906
|
]
|
|
2907
|
+
},
|
|
2908
|
+
{
|
|
2909
|
+
"id": "E72",
|
|
2910
|
+
"group": "E",
|
|
2911
|
+
"file": "workspace/d86-seams.test.js",
|
|
2912
|
+
"section": "assertion 7: the registry's destination modules",
|
|
2913
|
+
"change": "replaced",
|
|
2914
|
+
"slice": "docs-three-way-sync",
|
|
2915
|
+
"reason": "docs-three-way-sync adds a sixth destination, Docs (dest-docs.js), opened from ⌘K and g d. The assertion counted exactly five destination modules, so its name and count change.",
|
|
2916
|
+
"replacement": "The same assertion 7 under a new name counts six destination modules; the three extensions and provider-cards.js first are unchanged.",
|
|
2917
|
+
"pm_approval": null,
|
|
2918
|
+
"names": [
|
|
2919
|
+
"7 registry.js lists the five destination modules and the three extensions (provider-cards.js first)"
|
|
2920
|
+
]
|
|
2907
2921
|
}
|
|
2908
2922
|
]
|
|
2909
2923
|
}
|
package/workspace/server.js
CHANGED
|
@@ -128,6 +128,12 @@ const profilesLib = require('./lib/profiles'); // readProfiles(homeDir)
|
|
|
128
128
|
const focus = require('./lib/focus'); // buildFocus(input) → FocusView (cross-product)
|
|
129
129
|
const handoff = require('./lib/handoff'); // readLinks(parsedFrontmatterObject) — never a path
|
|
130
130
|
const fourPanel = require('./lib/four-panel'); // GET /api/initiative — the one-initiative join + the declaration stores
|
|
131
|
+
const docSyncStore = require('./lib/doc-sync-store');
|
|
132
|
+
// The session each repository's Check now started (workspace id → session id), so a second press while it runs refuses.
|
|
133
|
+
const docCheckSessions = new Map();
|
|
134
|
+
// The session each Use … version press started (workspace id|path|kind → { session, use }), so the line says "Updating…"
|
|
135
|
+
// before the session has opened its sync, and a second press on the same copy refuses.
|
|
136
|
+
const docSyncSessions = new Map();
|
|
131
137
|
const planItems = require('./lib/plan-items.js'); // pm-os-absorb Run 1 — the ONE writer and fold of plan-items.jsonl (control graph)
|
|
132
138
|
const planTree = require('./lib/plan-tree.js'); // …and the Breakdown layout: derived stages, owed rows, the Board columns
|
|
133
139
|
const routeModules = require('./lib/route-modules.js'); // pm-os-absorb Runs 2–5 — each run's routes in its own lib/routes-<name>.js
|
|
@@ -6389,6 +6395,103 @@ async function handleRequest(req, res) {
|
|
|
6389
6395
|
pushSessions();
|
|
6390
6396
|
return json(res, { ok: true, id: s.id, initiative: next, attributed_by: s.attributedBy, previous });
|
|
6391
6397
|
}
|
|
6398
|
+
// ── Docs: the sync signal for documents linked to Google Docs and Confluence (docs-three-way-sync) ──────────────
|
|
6399
|
+
// GET reads the links (the artifact-link store), the check readings and the files, and decides one signal per copy in
|
|
6400
|
+
// lib/doc-sync.js. The POSTs add or remove a link, or start the read-only `doc-check` skill as a session — the same
|
|
6401
|
+
// path a scheduled agent skill takes. No route here reads Google or Confluence: a session holding the PM's own
|
|
6402
|
+
// connectors does, and records what it read (D53). There is no sync and no schedule: the PM asked for a signal.
|
|
6403
|
+
if (p === '/api/docs' && req.method === 'GET') {
|
|
6404
|
+
const w = getWs(url.searchParams.get('ws'));
|
|
6405
|
+
const root = w ? w.root : SELF_ROOT;
|
|
6406
|
+
const v = docSyncStore.view(root);
|
|
6407
|
+
if (!v.ok) return json(res, v, 500);
|
|
6408
|
+
const alive = (id) => !!(id && sessions.get(id) && sessions.get(id).state !== 'exited');
|
|
6409
|
+
const sess = docCheckSessions.get(w ? w.id : '') || null;
|
|
6410
|
+
const live = alive(sess) ? sess : null;
|
|
6411
|
+
// Group by initiative: the library's own attribution of each file (pmos-shaped repositories only).
|
|
6412
|
+
const owner = new Map();
|
|
6413
|
+
try {
|
|
6414
|
+
if (wsShape(root) === 'pmos') {
|
|
6415
|
+
for (const g of initiativeIndex(root).initiatives) for (const a of g.artifacts) if (!owner.has(a.path)) owner.set(a.path, { id: g.id, title: g.title || g.id });
|
|
6416
|
+
}
|
|
6417
|
+
} catch { /* no grouping: every document is under Other */ }
|
|
6418
|
+
const wid = w ? w.id : '';
|
|
6419
|
+
const docs = v.docs.map((d) => ({
|
|
6420
|
+
...d,
|
|
6421
|
+
initiative: owner.get(d.path) || (d.initiative_id ? { id: d.initiative_id, title: d.initiative_id } : null),
|
|
6422
|
+
links: d.links.map((l) => {
|
|
6423
|
+
if (l.syncing) return l;
|
|
6424
|
+
const ps = docSyncSessions.get(`${wid}|${d.path}|${l.kind}`);
|
|
6425
|
+
return ps && alive(ps.session) ? { ...l, syncing: { run: null, use: ps.use, started_at: ps.at } } : l;
|
|
6426
|
+
}),
|
|
6427
|
+
}));
|
|
6428
|
+
return json(res, { ...v, docs, ws: w ? w.id : null, session: live, connectors: fourPanel.connectors(os.homedir()) });
|
|
6429
|
+
}
|
|
6430
|
+
if (p === '/api/docs/files' && req.method === 'GET') {
|
|
6431
|
+
const w = getWs(url.searchParams.get('ws'));
|
|
6432
|
+
const root = w ? w.root : SELF_ROOT;
|
|
6433
|
+
const found = docSyncStore.findFiles(root, String(url.searchParams.get('q') || '').slice(0, 200));
|
|
6434
|
+
const v = docSyncStore.view(root);
|
|
6435
|
+
const linked = new Set(v.ok ? v.docs.map((d) => d.path) : []);
|
|
6436
|
+
return json(res, { ok: true, files: found.filter((f) => !linked.has(f.path)) });
|
|
6437
|
+
}
|
|
6438
|
+
if ((p === '/api/docs/link' || p === '/api/docs/unlink' || p === '/api/docs/check' || p === '/api/docs/sync') && req.method === 'POST') {
|
|
6439
|
+
let body = {}; try { body = JSON.parse((await readBody(req)) || '{}'); } catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
|
|
6440
|
+
const g = pmGuard.requirePagePress(req, body);
|
|
6441
|
+
if (!g.ok) return json(res, { ok: false, error: g.error }, g.status);
|
|
6442
|
+
const w = body.ws ? workspaces.get(String(body.ws)) : getWs(null);
|
|
6443
|
+
if (!w) return json(res, { ok: false, error: 'no such workspace' }, 404);
|
|
6444
|
+
if (p === '/api/docs/link') {
|
|
6445
|
+
const r = docSyncStore.addLink(w.root, { path: String(body.path || '').trim(), kind: body.kind, target: String(body.target || '').trim() });
|
|
6446
|
+
return json(res, r.ok ? { ok: true } : { ok: false, error: r.error }, r.ok ? 200 : 400);
|
|
6447
|
+
}
|
|
6448
|
+
if (p === '/api/docs/unlink') {
|
|
6449
|
+
const r = docSyncStore.removeLink(w.root, { path: String(body.path || ''), kind: body.kind });
|
|
6450
|
+
return json(res, r.ok ? { ok: true } : { ok: false, error: r.error }, r.ok ? 200 : 400);
|
|
6451
|
+
}
|
|
6452
|
+
// Use … version (amended 2026-10-06): one doc-sync session for one copy, in the direction the PM pressed. The page
|
|
6453
|
+
// holds the press for five seconds behind Undo before it posts here. The session writes one side with the PM's own
|
|
6454
|
+
// connectors and records it through `pmos docs sync-start|sync-finish|sync-fail`; nothing here touches a copy.
|
|
6455
|
+
if (p === '/api/docs/sync') {
|
|
6456
|
+
const rel = String(body.path || '').trim();
|
|
6457
|
+
const kind = String(body.kind || '');
|
|
6458
|
+
const use = String(body.use || '');
|
|
6459
|
+
if (!['pmos', 'copy'].includes(use)) return json(res, { ok: false, error: 'say which version to use: pmos or copy' }, 400);
|
|
6460
|
+
const v = docSyncStore.view(w.root);
|
|
6461
|
+
if (!v.ok) return json(res, v, 500);
|
|
6462
|
+
const doc = v.docs.find((d) => d.path === rel);
|
|
6463
|
+
const link = doc && doc.links.find((l) => l.kind === kind);
|
|
6464
|
+
if (!link) return json(res, { ok: false, error: `${rel} has no such copy` }, 404);
|
|
6465
|
+
const key = `${w.id}|${rel}|${kind}`;
|
|
6466
|
+
const prevSync = docSyncSessions.get(key);
|
|
6467
|
+
if (link.syncing || (prevSync && sessions.get(prevSync.session) && sessions.get(prevSync.session).state !== 'exited')) {
|
|
6468
|
+
return json(res, { ok: false, error: 'that copy is already being synced' }, 409);
|
|
6469
|
+
}
|
|
6470
|
+
const task = taskById('doc-sync', w.root);
|
|
6471
|
+
if (!task || !task.prompt) return json(res, { ok: false, error: 'the doc-sync skill is not installed' }, 500);
|
|
6472
|
+
const ask = `\n\nThis sync:\n- file: ${rel}\n- copy: ${kind} (${link.target})\n- use: ${use}`;
|
|
6473
|
+
const s = spawnSession({ name: 'Doc sync', agent: task.agent || 'claude', ws: w.id, origin: 'docs:sync',
|
|
6474
|
+
initialInput: task.prompt + ask + '\r', modelAsk: { skillTier: task.tier || null, skill: task.skill } });
|
|
6475
|
+
const started = s.state !== 'exited';
|
|
6476
|
+
if (started) docSyncSessions.set(key, { session: s.id, use, at: new Date().toISOString() });
|
|
6477
|
+
return json(res, { ok: started, session: s.id, error: started ? undefined : (s.note || 'could not start the session') }, started ? 200 : 500);
|
|
6478
|
+
}
|
|
6479
|
+
// Check now: one check per repository. A session already running, or a check the ledger still holds open, refuses.
|
|
6480
|
+
const prev = docCheckSessions.get(w.id);
|
|
6481
|
+
if (prev && sessions.get(prev) && sessions.get(prev).state !== 'exited') {
|
|
6482
|
+
return json(res, { ok: false, error: 'a check is already running', session: prev }, 409);
|
|
6483
|
+
}
|
|
6484
|
+
const v = docSyncStore.view(w.root);
|
|
6485
|
+
if (v.ok && v.open) return json(res, { ok: false, error: `a check is already running (started ${v.open.started_at})` }, 409);
|
|
6486
|
+
if (v.ok && !v.docs.length) return json(res, { ok: false, error: 'no document is linked yet — add a link first' }, 400);
|
|
6487
|
+
const task = taskById('doc-check', w.root);
|
|
6488
|
+
if (!task || !task.prompt) return json(res, { ok: false, error: 'the doc-check skill is not installed' }, 500);
|
|
6489
|
+
const s = spawnSession({ name: 'Doc check', agent: task.agent || 'claude', ws: w.id, origin: 'docs:check',
|
|
6490
|
+
initialInput: task.prompt + '\r', modelAsk: { skillTier: task.tier || null, skill: task.skill } });
|
|
6491
|
+
const started = s.state !== 'exited';
|
|
6492
|
+
if (started) docCheckSessions.set(w.id, s.id);
|
|
6493
|
+
return json(res, { ok: started, session: s.id, error: started ? undefined : (s.note || 'could not start the session') }, started ? 200 : 500);
|
|
6494
|
+
}
|
|
6392
6495
|
if (p === '/api/initiative/link' && req.method === 'POST') {
|
|
6393
6496
|
// Recording that an artifact corresponds to an external page. This writes a DECLARATION and
|
|
6394
6497
|
// makes no request: PMOS holds no credential, so it can state what the PM says the mapping is
|
|
@@ -7257,7 +7360,10 @@ async function handleRequest(req, res) {
|
|
|
7257
7360
|
: (!!bs.ended_at || !bsess || bsess.state === 'exited') ? { session: bs.id, why: (bsess && bsess.note) || 'the session ended without submitting a brief.' }
|
|
7258
7361
|
: briefSessionDone(run, date, now) === 'idle' ? { session: bs.id, idle: true, why: 'the session has printed nothing for two minutes and has not submitted a brief.' } : null;
|
|
7259
7362
|
return json(res, { ok: true, ws: w ? w.id : null, today: date, now: new Date(now).toISOString(), run, stage: dayRunLib.stage(run, brief), brief, plan, cards_error: cardsError, read_failed: readFailed,
|
|
7260
|
-
settings: { briefSources: st.briefSources, scopes: st.scopes, dayCheck: st.dayCheck, websites: st.websites }, sources,
|
|
7363
|
+
settings: { briefSources: st.briefSources, scopes: st.scopes, placeRepos: st.placeRepos, dayCheck: st.dayCheck, websites: st.websites }, sources,
|
|
7364
|
+
// one day for every repository: the repositories it can be narrowed to, and the repository of each tagged item
|
|
7365
|
+
repos: [...workspaces.values()].map((x) => ({ id: x.id, name: x.name || path.basename(x.root) })),
|
|
7366
|
+
item_repos: goalsLib.itemRepos({ day: readGoalsDay(date), brief, placeRepos: st.placeRepos || {} }),
|
|
7261
7367
|
check: { every: st.dayCheck.every, last: run ? run.checks.last : null, next: due.next, running: !!(run && run.checks.session_id), log: run ? run.checks.log : [] } });
|
|
7262
7368
|
}
|
|
7263
7369
|
if (p.startsWith('/api/proactive/day/') && req.method === 'POST') {
|
|
@@ -7396,7 +7502,9 @@ async function handleRequest(req, res) {
|
|
|
7396
7502
|
const bs = body.scopes && typeof body.scopes === 'object' && !Array.isArray(body.scopes) ? body.scopes : null;
|
|
7397
7503
|
const scopes = bs ? { ...cur.scopes, ...Object.fromEntries(Object.entries(bs).map(([k, v]) => [k, v && typeof v === 'object' && !Array.isArray(v) ? { ...(cur.scopes[k] || {}), ...v } : v])) } : cur.scopes;
|
|
7398
7504
|
const dayCheck = body.dayCheck && typeof body.dayCheck === 'object' && !Array.isArray(body.dayCheck) ? { ...cur.dayCheck, ...body.dayCheck } : (body.dayCheck === undefined ? cur.dayCheck : body.dayCheck);
|
|
7399
|
-
const
|
|
7505
|
+
const bpr = body.placeRepos && typeof body.placeRepos === 'object' && !Array.isArray(body.placeRepos) ? body.placeRepos : null;
|
|
7506
|
+
const placeRepos = bpr ? { ...cur.placeRepos, ...Object.fromEntries(Object.entries(bpr).map(([k, v]) => [k, v && typeof v === 'object' && !Array.isArray(v) ? { ...((cur.placeRepos || {})[k] || {}), ...v } : v])) } : cur.placeRepos;
|
|
7507
|
+
const n = proactiveLib.normalizeSettings({ ...cur, ...body, placeRepos, triggers: { ...cur.triggers, ...(body.triggers || {}) }, briefSources: { ...cur.briefSources, ...(body.briefSources || {}) }, scopes, dayCheck });
|
|
7400
7508
|
if (!n.ok) return json(res, { ok: false, error: n.error }, 400);
|
|
7401
7509
|
try { fs.mkdirSync(STATE, { recursive: true }); fs.writeFileSync(PRO_SETTINGS(), JSON.stringify(n.settings) + '\n'); }
|
|
7402
7510
|
catch (e) { warnWriteFail('proactive-settings.json', e); return json(res, { ok: false, error: 'could not save — nothing changed' }, 500); }
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: doc-check
|
|
3
|
+
label: "Check linked documents against Google Docs and Confluence"
|
|
4
|
+
description: "Read the Google Docs and Confluence copies of every linked document and record whether each is the same as the repository file. Read-only: it writes no document, page or file."
|
|
5
|
+
run: agent
|
|
6
|
+
agent: claude
|
|
7
|
+
output:
|
|
8
|
+
- Same
|
|
9
|
+
- Ahead or behind
|
|
10
|
+
- Edited in both
|
|
11
|
+
- Could not check
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
/doc-check — read every Google Docs and Confluence copy of a linked document in THIS repository and record what you saw. PMOS then shows the PM, per document, whether the repository copy is the same as, ahead of or behind each copy.
|
|
15
|
+
|
|
16
|
+
The PM asked for a signal, not a sync. You read; you never write.
|
|
17
|
+
|
|
18
|
+
Rules that always hold:
|
|
19
|
+
1. READ-ONLY EVERYWHERE. Never create, update, move, rename, comment on, share or delete a Google Doc, a Drive file or a Confluence page. Never edit, create or delete a file in this repository. Never commit or push.
|
|
20
|
+
2. THE ONLY WRITES are `pmos docs check-start`, `pmos docs record` and `pmos docs check-finish`. Never write a `*.jsonl` file by hand.
|
|
21
|
+
3. RECORD ONLY WHAT YOU READ. A version, a modified time or a title that you did not read from the copy is not recorded. If you cannot read a copy, record the error. Do not guess.
|
|
22
|
+
4. USE THE PM'S CONNECTORS. Google Docs: the Google Drive tools (file metadata and file content). Confluence: the Atlassian tools (get the page, with its version). If a connector is missing or refuses, record that as the error for each of its copies and go on with the others.
|
|
23
|
+
5. "SAME" MEANS THE SAME DOCUMENT. Compare the copy's text with the repository file's body as documents: the same headings, paragraphs, lists and tables, in the same order. Ignore formatting, whitespace, the YAML frontmatter of the repository file and the copy's title. If any sentence, item or row differs, it is not the same. If you cannot tell, it is not the same: set `same: false` and say why in `note`.
|
|
24
|
+
|
|
25
|
+
Procedure:
|
|
26
|
+
1. Run `pmos docs check-start --json`. Keep `run` (the check id) and `links`. Each link has `path`, `kind` (google or confluence), `target` (the address), `remote_id` and `repo_hash`. If it says a check is already running, stop and report that.
|
|
27
|
+
2. For each link, in order:
|
|
28
|
+
a. Read the copy's metadata. Google: the file's `modifiedTime`, its revision or version, who last modified it, and whether it is trashed. Confluence: the page's `version.number`, its last-modified time and who made that version.
|
|
29
|
+
b. If the copy does not exist or is in the trash, the reading is `{"path","kind","remote_id","exists":false}`.
|
|
30
|
+
c. If reading failed for any other reason (no access, no connector, an error), the reading is `{"path","kind","remote_id","error":"<the error, in one line>"}`.
|
|
31
|
+
d. Otherwise read the copy's content and the repository file `path` (skip this when `repo_hash` is null: the file is not in the repository). Decide `same` by rule 5. The reading is `{"path","kind","remote_id","exists":true,"version":"<version>","modified_at":"<ISO time>","modified_by":"<the person's name, when the copy says it>","title":"<the copy's title>","url":"<its address>","same":true|false,"repo_hash":"<repo_hash from step 1>","note":"<one line when same is false: the first difference>"}`.
|
|
32
|
+
3. Write all readings as a JSON list to a file outside the repository (for example in /tmp) and run `pmos docs record <run> --file <that file>`. If a reading is refused, fix that reading from what you actually read and record it again; never invent a value to get it accepted.
|
|
33
|
+
4. Run `pmos docs check-finish <run> --summary "<n> same, <n> ahead or behind, <n> edited in both, <n> could not check"`.
|
|
34
|
+
5. Run `pmos docs list` and report it in the output sections below.
|
|
35
|
+
|
|
36
|
+
Output contract — produce EXACTLY these sections, the same shape every run, one short line per document and copy:
|
|
37
|
+
- Same
|
|
38
|
+
- Ahead or behind
|
|
39
|
+
- Edited in both
|
|
40
|
+
- Could not check
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: doc-sync
|
|
3
|
+
label: "Sync one linked document with its Google Docs or Confluence copy"
|
|
4
|
+
description: "Write one version of a linked document over the other, in the direction the PM pressed: the PMOS version to the copy, or the copy's version to the repository file. One copy, one side, nothing else."
|
|
5
|
+
run: agent
|
|
6
|
+
agent: claude
|
|
7
|
+
output:
|
|
8
|
+
- Written
|
|
9
|
+
- Not written
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
/doc-sync — the PM pressed "Use … version" for ONE copy of ONE document in Docs. Write that version over the other side, record it, and stop. The lines after this procedure name the file, the copy and the version to use.
|
|
13
|
+
|
|
14
|
+
Rules that always hold:
|
|
15
|
+
1. ONE COPY, ONE SIDE. With `use: pmos`, write the repository file's text to the copy, and change nothing in the repository. With `use: copy`, write the copy's text to the repository file, and change nothing in Google Docs or Confluence. Never write the other side, another copy, or another file.
|
|
16
|
+
2. NEVER CREATE, MOVE, RENAME, SHARE OR DELETE. Update the existing Google Doc or Confluence page in place. If it does not exist or is in the trash, do not create it: record the sync as failed.
|
|
17
|
+
3. KEEP THE FRONTMATTER. The repository file may start with a YAML frontmatter block (between two `---` lines). With `use: copy`, keep that block exactly as it is and replace only the text after it. With `use: pmos`, do not write the frontmatter to the copy.
|
|
18
|
+
4. KEEP THE DOCUMENT. Convert between markdown and the copy's format faithfully: the same headings, paragraphs, lists, tables and links, in the same order. Do not add a summary, a note or a heading of your own.
|
|
19
|
+
5. THE ONLY RECORDS are `pmos docs sync-start`, `pmos docs sync-finish` and `pmos docs sync-fail`. Never write a `*.jsonl` file by hand. Never commit or push: the PM's own Sync does that.
|
|
20
|
+
6. USE THE PM'S CONNECTORS. Google Docs: the Google Drive tools. Confluence: the Atlassian tools. If the PM is asked to allow the write, that is expected; wait for their answer. If the connector is missing or refuses, record the sync as failed with the error.
|
|
21
|
+
|
|
22
|
+
Procedure:
|
|
23
|
+
1. Run `pmos docs sync-start <file> <google|confluence> --use <pmos|copy> --json` with the values below. Keep `run`, `target` and `remote_id`. If it is refused (for example, the copy is already being synced), stop and report the reason.
|
|
24
|
+
2. Read the copy (its content, its version and its last-modified time) and the repository file.
|
|
25
|
+
3. Write, by rule 1 to rule 4:
|
|
26
|
+
- `use: pmos`: update the copy's content with the repository file's body. Confluence: update the page with its next version number and keep its title. Google Docs: replace the document's content.
|
|
27
|
+
- `use: copy`: replace the repository file's body with the copy's content, converted to markdown. Keep the frontmatter.
|
|
28
|
+
4. Read the copy again and take its version and last-modified time AFTER the write (with `use: copy` the copy did not change; take what you read in step 2).
|
|
29
|
+
5. Run `pmos docs sync-finish <run> --version "<version>" --modified-at "<ISO time>" --url "<the copy's address>"`.
|
|
30
|
+
6. If any step failed, run `pmos docs sync-fail <run> --reason "<the error, in one line>"` instead of step 5, and say what was and was not written.
|
|
31
|
+
|
|
32
|
+
Output contract — produce EXACTLY these sections, one short line each:
|
|
33
|
+
- Written
|
|
34
|
+
- Not written
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
{
|
|
2
|
+
"_comment": "docs-three-way-sync: the sync signal for documents linked to Google Docs and Confluence.",
|
|
3
|
+
"modules": ["doc-sync.test.js", "docs-cli.test.js"],
|
|
4
|
+
"panels": ["pm-docs-test.js"],
|
|
5
|
+
"seconds": { "doc-sync.test.js": 1, "docs-cli.test.js": 6, "pm-docs-test.js": 60 }
|
|
6
|
+
}
|