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.
@@ -130,7 +130,10 @@ function deriveStage(run, brief) {
130
130
  return 'done';
131
131
  }
132
132
 
133
- function model(d) {
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, digests, places,
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: jobs.filter((j) => j.item && j.item.mention),
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: jobs.filter((j) => j.group === 'needs').length,
169
- done: jobs.filter((j) => j.group === 'done').length,
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.goals.filter((g) => g.status === 'open').length, m.maxGoals), goalRows(ctx), goalAdd(ctx));
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.goals[n - 1] : null;
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.goals.length < m.maxGoals ? el('div', { class: 'td-day__acts' }, button({ label: D.addAsGoal, size: 'sm', attrs: { 'data-day-act': 'add-goal' },
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, k) => relRow(ctx, 'goal', g.id, g.text, String(k + 1)))) : null];
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), levelSeg(ctx, svc, 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), calendar(ctx), jobsPanel(ctx), goalsPanel(ctx), mentionsPanel(ctx), waitingPanel(owed)),
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, i) => {
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(i + 1))],
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.goals.length >= max) return el('p', { class: 'td-day__note' }, G.full(max));
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.goals.findIndex((g) => g.id === id);
117
+ const i = m.allGoals.findIndex((g) => g.id === id);
115
118
  if (i < 0) return null;
116
- const g = m.goals[i];
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
  }
@@ -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 n = proactiveLib.normalizeSettings({ ...cur, ...body, triggers: { ...cur.triggers, ...(body.triggers || {}) }, briefSources: { ...cur.briefSources, ...(body.briefSources || {}) }, scopes, dayCheck });
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
+ }