create-pmos 0.493.0 → 0.495.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 +99 -27
- 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/briefing-premium-adddoc-test.js +2 -1
- package/workspace/briefing-premium-board-test.js +5 -4
- package/workspace/briefing-premium-initiative-test.js +4 -2
- package/workspace/changelog.json +98 -0
- package/workspace/guide/briefing-premium.md +4 -1
- package/workspace/guide/claude-proposes.md +2 -2
- package/workspace/guide/data-freshness.md +3 -1
- package/workspace/guide/initiatives-screen.md +3 -1
- package/workspace/guide/report-slides.md +14 -1
- package/workspace/guide/reports.md +2 -0
- package/workspace/guide/workflows.md +15 -3
- package/workspace/guide/your-impact.md +6 -7
- package/workspace/ini-workflow-test.js +2 -1
- package/workspace/lib/board-trail.js +30 -3
- package/workspace/lib/engine/workflows.js +19 -6
- package/workspace/lib/git-shape.js +21 -0
- package/workspace/lib/grill-runs.js +21 -6
- package/workspace/lib/grill.js +1 -1
- package/workspace/lib/impact.js +29 -16
- package/workspace/lib/initiative-cli.js +15 -1
- package/workspace/lib/open-mission.js +14 -0
- package/workspace/lib/page-warm.js +68 -0
- package/workspace/lib/report-design.js +6 -1
- package/workspace/lib/report-export.js +150 -9
- package/workspace/lib/report-pyramid.js +535 -0
- package/workspace/lib/report-structure.js +5 -1
- package/workspace/lib/report-templates.js +111 -20
- package/workspace/lib/routes-boards.js +36 -3
- package/workspace/lib/routes-brief-page.js +21 -3
- package/workspace/lib/routes-grill.js +10 -4
- package/workspace/lib/routes-impact.js +12 -4
- package/workspace/lib/routes-initiatives.js +1 -0
- package/workspace/lib/routes-programs.js +6 -4
- package/workspace/lib/routes-report-design.js +15 -0
- package/workspace/lib/routes-report-flow.js +115 -19
- package/workspace/lib/routes-report-setups.js +4 -2
- package/workspace/lib/routes-report-structure.js +3 -0
- package/workspace/lib/routes-workflows.js +6 -1
- package/workspace/lib/wf-bridge.js +5 -3
- package/workspace/lib/workflow-cli.js +2 -2
- package/workspace/lib/workflows-store.js +15 -1
- package/workspace/metric-drafts-ui-test.js +2 -1
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/premium-board-actions-test.js +2 -1
- package/workspace/premium-chat-report-tab-test.js +5 -1
- package/workspace/premium-vw-backlog-test.js +3 -2
- package/workspace/premium-vw-item-test.js +3 -2
- package/workspace/premium-vw-jira-test.js +3 -2
- package/workspace/program-panel-test.js +2 -1
- package/workspace/public/pm/cozy-home.css +1 -0
- package/workspace/public/pm/cozy-vw-rp.css +12 -0
- package/workspace/public/pm/fetch-share.js +38 -0
- package/workspace/public/pm/home-app.js +24 -4
- package/workspace/public/pm/home-chat.js +53 -11
- package/workspace/public/pm/home-impact.css +23 -13
- package/workspace/public/pm/home-impact.js +49 -32
- package/workspace/public/pm/home-ini.css +2 -0
- package/workspace/public/pm/ini-view.css +55 -0
- package/workspace/public/pm/ini-view.js +7 -4
- package/workspace/public/pm/premium-board.js +1 -0
- package/workspace/public/pm/premium-insights.js +1 -0
- package/workspace/public/pm/premium-report-read.js +25 -6
- package/workspace/public/pm/premium-report-tab.js +69 -14
- package/workspace/public/pm/premium-shell.js +57 -6
- package/workspace/public/pm/premium.css +1 -0
- package/workspace/public/pm/report-flow.js +93 -10
- package/workspace/public/pm/wf-page.css +8 -0
- package/workspace/public/pm/wf-page.js +33 -5
- package/workspace/server.js +24 -3
- package/workspace/skills/report-template.skill +10 -0
- package/workspace/split-merge-ui-test.js +3 -2
- package/workspace/stage-card-test.js +2 -1
- package/workspace/task-press-test.js +2 -1
- package/workspace/test-lists/page-open-speed.json +5 -0
- package/workspace/test-lists/report-pyramid.json +9 -0
- package/workspace/tk-tasks-test.js +2 -1
|
@@ -3,9 +3,12 @@
|
|
|
3
3
|
*
|
|
4
4
|
* ledgerOf(root, id) <state>/reports/<id>/template.jsonl
|
|
5
5
|
* readLines(root, id) · fold(lines) → { current, versions: [{ n, settings, at, why, current }], chat, log }
|
|
6
|
-
* settingsOf(setup)
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* settingsOf(setup, refs?) → { output, audience, schedule, structure?, design? } what a template is
|
|
7
|
+
* liveRefs(root, id) → { structure: S<n> | null, design: D<n> | null } the saved sections and look now
|
|
8
|
+
* namesOf(root, id) → { sections(n) → [title] | null, look(n) → words } for the words below
|
|
9
|
+
* sameSettings(a, b) · diffWords(a, b, names) "Format: Slides → Document", "Sections: + Customers", "Look: Standard PMOS look → D3 · warmer"
|
|
10
|
+
* sectionsDiff(was, now) → "+ Customers, − Risk watch" | "new order" | ''
|
|
11
|
+
* ensureFirst(root, id, setup, { chat, refs, now }) → T1 from the setup, once. `chat` is the conversation key the report was proposed in
|
|
9
12
|
* addVersion(root, id, settings, { why, now }) → { ok, n } the PM's ✓ on a changed template: T<n+1> becomes the current one
|
|
10
13
|
* restore(root, id, n, { now }) → { ok, n } Go back to T<n>: that version is the current one again. Nothing is deleted
|
|
11
14
|
* cyclesOf(folded, issued) → Map n → issued cycles while that version was the current one
|
|
@@ -15,7 +18,10 @@
|
|
|
15
18
|
* THE LINES. { v: 1, ev, …, at }:
|
|
16
19
|
* version { n, settings, why, chat? } T<n>; the newest version line is the current template until a restore line
|
|
17
20
|
* restore { n } Go back to T<n>
|
|
18
|
-
* A template is its settings: the format (output), who reads it (audience)
|
|
21
|
+
* A template is its settings: the format (output), who reads it (audience), when it goes out (schedule), its sections (structure: the saved
|
|
22
|
+
* structure version S<n> of lib/report-structure.js, or null = the standard sections) and its look (design: the saved design version D<n> of
|
|
23
|
+
* lib/report-design.js, or null = the standard look) (canvas TplA, brief 3). A T line written before TplA has no structure or design key: it is
|
|
24
|
+
* read as "not recorded", never as a change, and Go back to it leaves the sections and look as they are. A change to any of them is a new
|
|
19
25
|
* version (the agreed note: "Changing any setting makes a different template"); the drafts and issued cycles stay with the version they were made
|
|
20
26
|
* under. The setup line in report-setups.jsonl keeps the live values: the route applies a version there with report-setups change and reschedule.
|
|
21
27
|
*
|
|
@@ -50,13 +56,33 @@ function append(root, id, line, now) {
|
|
|
50
56
|
return rec;
|
|
51
57
|
}
|
|
52
58
|
|
|
53
|
-
/**
|
|
54
|
-
function settingsOf(s) {
|
|
59
|
+
/** A template's settings: the three report-setups keeps, and the saved sections and look when `refs` names them. */
|
|
60
|
+
function settingsOf(s, refs) {
|
|
55
61
|
const x = isObj(s) ? s : {};
|
|
56
62
|
const sc = isObj(x.schedule) ? x.schedule : null;
|
|
57
63
|
const paused = x.status === 'paused';
|
|
58
|
-
|
|
64
|
+
const out = { output: String(x.output || ''), audience: String(x.audience || ''), schedule: !sc || paused || sc.cadence === 'off' ? 'off' : { cadence: sc.cadence, ...(sc.day ? { day: sc.day } : {}), ...(sc.dayOfMonth ? { dayOfMonth: sc.dayOfMonth } : {}), time: sc.time || '09:00' } };
|
|
65
|
+
if (isObj(refs)) { if (refs.structure !== undefined) out.structure = refN(refs.structure); if (refs.design !== undefined) out.design = refN(refs.design); }
|
|
66
|
+
return out;
|
|
67
|
+
}
|
|
68
|
+
/** A saved version number, or null (the standard sections or look). */
|
|
69
|
+
const refN = (v) => (Number.isInteger(v) && v >= 1 ? v : null);
|
|
70
|
+
/** The sections and look saved now: the structure's and the design's accept lines. */
|
|
71
|
+
function liveRefs(root, id) {
|
|
72
|
+
let structure = null, design = null;
|
|
73
|
+
try { const st = require('./report-structure.js').stateOf(root, id); structure = st.saved ? refN(st.saved.n) : null; } catch { structure = null; }
|
|
74
|
+
try { const st = require('./report-design.js').stateOf(root, id); design = st.saved ? refN(st.saved.version) : null; } catch { design = null; }
|
|
75
|
+
return { structure, design };
|
|
76
|
+
}
|
|
77
|
+
/** The words of a version's sections and look, read from their stores. */
|
|
78
|
+
function namesOf(root, id) {
|
|
79
|
+
const S = require('./report-structure.js'), D = require('./report-design.js');
|
|
80
|
+
return {
|
|
81
|
+
sections: (n) => { if (n == null) return null; try { const p = S.readVersion(root, id, n); return p && Array.isArray(p.outline) ? p.outline.map((o) => String(o.title || o.id)) : null; } catch { return null; } },
|
|
82
|
+
look: (n) => { if (n == null) return STANDARD_LOOK; let m = null; try { m = D.versionMeta(root, id, n); } catch { m = null; } const t = m && String(m.turn || '').replace(/\s+/g, ' ').trim(); return 'D' + n + (t ? ' · ' + (t.length > 40 ? t.slice(0, 39) + '…' : t) : ''); },
|
|
83
|
+
};
|
|
59
84
|
}
|
|
85
|
+
const STANDARD_LOOK = 'Standard PMOS look';
|
|
60
86
|
function cleanSettings(s) {
|
|
61
87
|
if (!isObj(s)) return null;
|
|
62
88
|
const out = {};
|
|
@@ -64,10 +90,22 @@ function cleanSettings(s) {
|
|
|
64
90
|
if (typeof s.audience === 'string' && s.audience.trim()) out.audience = s.audience.replace(/\s+/g, ' ').trim().slice(0, 120);
|
|
65
91
|
if (s.schedule === 'off' || (isObj(s.schedule) && s.schedule.cadence === 'off')) out.schedule = 'off';
|
|
66
92
|
else if (isObj(s.schedule) && typeof s.schedule.cadence === 'string') out.schedule = { cadence: s.schedule.cadence, ...(s.schedule.day ? { day: String(s.schedule.day) } : {}), ...(s.schedule.dayOfMonth ? { dayOfMonth: +s.schedule.dayOfMonth } : {}), time: /^\d{1,2}:\d{2}$/.test(String(s.schedule.time || '')) ? String(s.schedule.time) : '09:00' };
|
|
93
|
+
for (const k of ['structure', 'design']) if (s[k] === null || (Number.isInteger(s[k]) && s[k] >= 1)) out[k] = s[k];
|
|
67
94
|
return Object.keys(out).length ? out : null;
|
|
68
95
|
}
|
|
69
96
|
const key = (sc) => JSON.stringify(sc === 'off' || !sc ? 'off' : { c: sc.cadence, d: sc.day || null, m: sc.dayOfMonth || null, t: sc.time || '09:00' });
|
|
70
|
-
|
|
97
|
+
/** A sections or look ref differs only when both sides recorded one: an old T line (no key) is "not recorded", never a change. */
|
|
98
|
+
const refDiffers = (a, b, k) => a[k] !== undefined && b[k] !== undefined && a[k] !== b[k];
|
|
99
|
+
function sameSettings(a, b) { return !!a && !!b && a.output === b.output && a.audience === b.audience && key(a.schedule) === key(b.schedule) && !refDiffers(a, b, 'structure') && !refDiffers(a, b, 'design'); }
|
|
100
|
+
/** What changed between two section lists, in words: "+ Customers, − Risk watch", or "new order" when only the order moved. */
|
|
101
|
+
function sectionsDiff(was, now) {
|
|
102
|
+
const A = Array.isArray(was) ? was : [], B = Array.isArray(now) ? now : [];
|
|
103
|
+
const low = (L) => L.map((t) => String(t).toLowerCase());
|
|
104
|
+
const a = low(A), b = low(B);
|
|
105
|
+
const out = B.filter((t, i) => !a.includes(b[i])).map((t) => '+ ' + t).concat(A.filter((t, i) => !b.includes(a[i])).map((t) => '− ' + t));
|
|
106
|
+
if (out.length) return out.join(', ');
|
|
107
|
+
return a.join('\n') !== b.join('\n') ? 'new order' : '';
|
|
108
|
+
}
|
|
71
109
|
|
|
72
110
|
const FORMAT = { 'google-slides': 'Slides', 'google-doc': 'Document', pdf: 'PDF', 'google-sheets': 'Sheets', confluence: 'Confluence page' };
|
|
73
111
|
const DAYS = { Mon: 'Monday', Tue: 'Tuesday', Wed: 'Wednesday', Thu: 'Thursday', Fri: 'Friday', Sat: 'Saturday', Sun: 'Sunday' };
|
|
@@ -78,19 +116,31 @@ function goesWords(sc) {
|
|
|
78
116
|
if (sc.cadence === 'monthly') return 'every month on day ' + (sc.dayOfMonth || 1) + ' ' + t;
|
|
79
117
|
return (sc.cadence === 'bi-weekly' ? 'every two weeks, ' : 'every week, ') + (DAYS[sc.day] || 'Monday') + ' ' + t;
|
|
80
118
|
}
|
|
81
|
-
|
|
82
|
-
|
|
119
|
+
const lookOf = (names, n) => (n == null ? STANDARD_LOOK : names && typeof names.look === 'function' ? names.look(n) : 'D' + n);
|
|
120
|
+
const titlesOf = (names, n) => (n == null ? null : names && typeof names.sections === 'function' ? names.sections(n) : null);
|
|
121
|
+
/** What changed between two templates, in words: ["Format: Slides → Document", "Sections: + Customers", "Look: D2 → D3 · warmer"]. `names`
|
|
122
|
+
* (namesOf) reads the section titles and look words; without it a sections change is said by its version ("Sections: S2 → S3"). */
|
|
123
|
+
function diffWords(a, b, names) {
|
|
83
124
|
const out = [];
|
|
84
125
|
if (!a || !b) return out;
|
|
85
126
|
if (a.output !== b.output) out.push('Format: ' + (FORMAT[a.output] || a.output || 'none') + ' → ' + (FORMAT[b.output] || b.output || 'none'));
|
|
86
127
|
if (a.audience !== b.audience) out.push('Readers: ' + (a.audience || 'none') + ' → ' + (b.audience || 'none'));
|
|
87
128
|
if (key(a.schedule) !== key(b.schedule)) out.push('Goes out: ' + goesWords(a.schedule) + ' → ' + goesWords(b.schedule));
|
|
129
|
+
if (refDiffers(a, b, 'structure')) {
|
|
130
|
+
const A = titlesOf(names, a.structure), B = titlesOf(names, b.structure);
|
|
131
|
+
const d = A && B ? sectionsDiff(A, B) : '';
|
|
132
|
+
out.push('Sections: ' + (d || (a.structure == null ? 'standard' : 'S' + a.structure) + ' → ' + (b.structure == null ? 'standard' : 'S' + b.structure)));
|
|
133
|
+
}
|
|
134
|
+
if (refDiffers(a, b, 'design')) out.push('Look: ' + lookOf(names, a.design) + ' → ' + lookOf(names, b.design));
|
|
88
135
|
return out;
|
|
89
136
|
}
|
|
90
|
-
/** One line naming a template's settings: "Format: Slides · readers: The team · goes out once". */
|
|
91
|
-
function settingsWords(s) {
|
|
137
|
+
/** One line naming a template's settings: "Format: Slides · readers: The team · goes out once · 6 sections · Standard PMOS look". */
|
|
138
|
+
function settingsWords(s, names) {
|
|
92
139
|
if (!s) return '';
|
|
93
|
-
|
|
140
|
+
const T = s.structure !== undefined ? titlesOf(names, s.structure) : null;
|
|
141
|
+
const sec = s.structure === undefined ? '' : s.structure == null ? ' · standard sections' : ' · ' + (T ? T.length + (T.length === 1 ? ' section' : ' sections') : 'sections S' + s.structure);
|
|
142
|
+
const look = s.design === undefined ? '' : ' · ' + lookOf(names, s.design) + (s.design == null ? '' : ' look');
|
|
143
|
+
return 'Format: ' + (FORMAT[s.output] || s.output || 'none') + ' · readers: ' + (s.audience || 'none') + ' · goes out ' + goesWords(s.schedule) + sec + look;
|
|
94
144
|
}
|
|
95
145
|
|
|
96
146
|
function fold(lines) {
|
|
@@ -107,26 +157,58 @@ function fold(lines) {
|
|
|
107
157
|
}
|
|
108
158
|
|
|
109
159
|
/** T1, from the setup as it is now. Once: a report with a version already keeps it. `chat` names the conversation it came from. */
|
|
110
|
-
function ensureFirst(root, id, setup, { chat, now } = {}) {
|
|
160
|
+
function ensureFirst(root, id, setup, { chat, refs, why, now } = {}) {
|
|
111
161
|
if (!ID_RE.test(String(id || ''))) return refuse(400, 'id must be a report id');
|
|
112
162
|
const f = fold(readLines(root, id));
|
|
113
163
|
if (f.current != null) return { ok: true, n: f.current, existing: true };
|
|
114
|
-
const line = { ev: 'version', n: 1, settings: settingsOf(setup), why: 'First shape, from your chat.' };
|
|
164
|
+
const line = { ev: 'version', n: 1, settings: settingsOf(setup, refs || liveRefs(root, id)), why: typeof why === 'string' && why ? why.slice(0, 200) : 'First shape, from your chat.' };
|
|
115
165
|
if (typeof chat === 'string' && CHAT_RE.test(chat)) line.chat = chat;
|
|
116
166
|
append(root, id, line, now);
|
|
117
167
|
return { ok: true, n: 1 };
|
|
118
168
|
}
|
|
119
169
|
/** The PM's ✓ on a changed template: T<n+1> with these settings is the current one. Unchanged settings write nothing. */
|
|
120
|
-
function addVersion(root, id, settings, { why, now } = {}) {
|
|
170
|
+
function addVersion(root, id, settings, { why, names, fill, now } = {}) {
|
|
121
171
|
if (!ID_RE.test(String(id || ''))) return refuse(400, 'id must be a report id');
|
|
122
172
|
const f = fold(readLines(root, id));
|
|
123
173
|
const cur = f.versions.find((v) => v.current);
|
|
124
|
-
|
|
125
|
-
|
|
174
|
+
// `fill`: the sections and look saved before this change, for a current T line that did not record them (written before TplA)
|
|
175
|
+
const was = cur ? { ...(isObj(fill) ? fill : {}), ...cur.settings } : null;
|
|
176
|
+
const next = { ...(was || {}), ...(cleanSettings(settings) || {}) };
|
|
177
|
+
if (cur && sameSettings(was, next)) return { ok: true, n: cur.n, unchanged: true };
|
|
126
178
|
const n = f.versions.reduce((m, v) => Math.max(m, v.n), 0) + 1;
|
|
127
|
-
append(root, id, { ev: 'version', n, settings: next, why: typeof why === 'string' && why.trim() ? why.trim().slice(0, 200) : diffWords(
|
|
179
|
+
append(root, id, { ev: 'version', n, settings: next, why: typeof why === 'string' && why.trim() ? why.trim().slice(0, 200) : diffWords(was, next, names).join('; ').slice(0, 200) }, now);
|
|
128
180
|
return { ok: true, n, settings: next };
|
|
129
181
|
}
|
|
182
|
+
/** The saved version (accept n, unsave null) of one store's events at a time; undefined when the store has no event at or before it. */
|
|
183
|
+
function savedAt(events, t) {
|
|
184
|
+
let out;
|
|
185
|
+
for (const e of Array.isArray(events) ? events : []) {
|
|
186
|
+
const at = Date.parse((e && e.at) || '');
|
|
187
|
+
if (!Number.isFinite(at) || at > t) continue;
|
|
188
|
+
if (e.ev === 'accept') out = refN(e.n); else if (e.ev === 'unsave') out = null;
|
|
189
|
+
}
|
|
190
|
+
return out;
|
|
191
|
+
}
|
|
192
|
+
/** Backfill on read (live check of 0.494.0, brief 4 item 10): a T line written before TplA has no sections or look. Each such version takes the
|
|
193
|
+
* S<n> and D<n> that were saved at its time (no saved one then = the standard ones); the current version takes the ones saved now. Nothing is
|
|
194
|
+
* written. `filled` names the keys that were backfilled. Pure apart from reading the two stores' events. */
|
|
195
|
+
function backfill(root, id, folded) {
|
|
196
|
+
if (!folded || !Array.isArray(folded.versions) || !folded.versions.some((v) => v.settings.structure === undefined || v.settings.design === undefined)) return folded;
|
|
197
|
+
let sEv = [], dEv = [];
|
|
198
|
+
try { sEv = require('./report-structure.js').readEvents(root, id); } catch { sEv = []; }
|
|
199
|
+
try { dEv = require('./report-design.js').readEvents(root, id); } catch { dEv = []; }
|
|
200
|
+
const live = liveRefs(root, id);
|
|
201
|
+
const versions = folded.versions.map((v) => {
|
|
202
|
+
const fill = {}; const t = Date.parse(v.at || '');
|
|
203
|
+
for (const [k, ev] of [['structure', sEv], ['design', dEv]]) {
|
|
204
|
+
if (v.settings[k] !== undefined) continue;
|
|
205
|
+
const then = Number.isFinite(t) ? savedAt(ev, t) : undefined;
|
|
206
|
+
fill[k] = v.current || !Number.isFinite(t) ? live[k] : then === undefined ? null : then;
|
|
207
|
+
}
|
|
208
|
+
return Object.keys(fill).length ? { ...v, settings: { ...v.settings, ...fill }, filled: Object.keys(fill) } : v;
|
|
209
|
+
});
|
|
210
|
+
return { ...folded, versions };
|
|
211
|
+
}
|
|
130
212
|
/** Go back to T<n>: that version is the current one again. Nothing is deleted. */
|
|
131
213
|
function restore(root, id, n, { now } = {}) {
|
|
132
214
|
const f = fold(readLines(root, id));
|
|
@@ -169,4 +251,13 @@ function review(markdown, { slot = '', now = Date.now(), listed = [] } = {}) {
|
|
|
169
251
|
return { fix, passed };
|
|
170
252
|
}
|
|
171
253
|
|
|
172
|
-
|
|
254
|
+
/**
|
|
255
|
+
* A sections or look change saved in its own studio (Structure "Use this", Design "Use it") is the PM's ✓ too: the template follows it as
|
|
256
|
+
* T<n+1>. Call `followBefore(root, id, setup)` BEFORE the studio's accept line (T1 then records the sections and look as they were; it answers
|
|
257
|
+
* them), and `followAfter(root, id, { structure | design }, was)` after it, with that answer.
|
|
258
|
+
*/
|
|
259
|
+
function followBefore(root, id, setup) { try { const was = liveRefs(root, id); ensureFirst(root, id, setup, { refs: was }); return was; } catch { return null; } }
|
|
260
|
+
function followAfter(root, id, patch, was) { try { return addVersion(root, id, patch, { names: namesOf(root, id), fill: was }); } catch { return null; } }
|
|
261
|
+
|
|
262
|
+
module.exports = { ID_RE, CHAT_RE, FORMAT, STANDARD_LOOK, ledgerOf, readLines, fold, settingsOf, cleanSettings, sameSettings, sectionsDiff, diffWords, settingsWords, goesWords,
|
|
263
|
+
liveRefs, namesOf, refN, ensureFirst, backfill, savedAt, addVersion, restore, cyclesOf, chatOf, review, followBefore, followAfter };
|
|
@@ -207,10 +207,43 @@ function createAndPlace(ctx, w, boardId, input, actor, tie) {
|
|
|
207
207
|
const stage = (ownWf || board.workflow)[0].name;
|
|
208
208
|
tie({ file, board, initiative: r.id, stage });
|
|
209
209
|
if (ownWf) append(file, actor, { kind: 'ini-workflow', board: board.id, id: r.id, template: tpl, stages: ownWf, moved: null });
|
|
210
|
+
const wfStart = startOnDefault(ctx, w, r.id, actor, { boardId: board.id });
|
|
210
211
|
rowsLib.invalidate(w.root);
|
|
211
212
|
rowCache.delete(w.root);
|
|
212
213
|
if (ctx.invalidateNeedsAgg) ctx.invalidateNeedsAgg(w.id);
|
|
213
|
-
return { ok: true, initiative: { id: r.id, path: r.path }, stage, board: board.id };
|
|
214
|
+
return { ok: true, initiative: { id: r.id, path: r.path }, stage, board: board.id, ...(wfStart ? { wf: wfStart } : {}) };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Batch 24 item 3 (canvas DfA): a new initiative starts on the default workflow (Settings › Workflows, the star). It writes one ini-wf
|
|
218
|
+
* line on the initiative's board, the line a workflow switch writes, so every surface reads the workflow from it; with no `moved` the
|
|
219
|
+
* initiative stands in the first stage (Triage) and no move shows in its history. Product feature (the default when none is set, or when
|
|
220
|
+
* the default is archived) writes nothing. The workspace's own default first; a workspace that never set one reads the home workspace's
|
|
221
|
+
* (Settings opens on home), and that workflow is adopted into this workspace's library first, as a switch does. PMOS still reads the BRD
|
|
222
|
+
* and proposes a switch. Never throws: a new initiative is made even when this line cannot be written.
|
|
223
|
+
* boardId: the board the initiative is on; else its domain board (products/<domain>/) or the workspace board. → the workflow id | null */
|
|
224
|
+
function startOnDefault(ctx, w, iniId, actor, { boardId = null, recordPath = null } = {}) {
|
|
225
|
+
try {
|
|
226
|
+
const file = ledgerFile(ctx, w);
|
|
227
|
+
const dir = ctx.stateDirFor ? ctx.stateDirFor(w) : null;
|
|
228
|
+
if (!file || !dir || isSelf(ctx, w)) return null;
|
|
229
|
+
let lib = WS.library(dir), from = null;
|
|
230
|
+
if (!lib.defaultId) {
|
|
231
|
+
const home = ctx.getWs ? ctx.getWs(null) : null;
|
|
232
|
+
const hd = home && home.id !== w.id && ctx.stateDirFor ? ctx.stateDirFor(home) : null;
|
|
233
|
+
const hl = hd ? WS.library(hd) : null;
|
|
234
|
+
if (hl && hl.defaultId) { lib = hl; from = home; }
|
|
235
|
+
}
|
|
236
|
+
const def = WF.defaultOf(lib);
|
|
237
|
+
if (def.id === WF.PRODUCT_FEATURE_ID) return null;
|
|
238
|
+
const fr = frameFor(ctx, w);
|
|
239
|
+
const dm = recordPath ? /^products\/([^/]+)\//.exec(recordPath) : null;
|
|
240
|
+
const bid = boardId || (dm && fr.boards.some((b) => b.id === dm[1] && b.kind === 'domain') ? dm[1] : w.id);
|
|
241
|
+
const board = fr.boards.find((b) => b.id === bid);
|
|
242
|
+
if (!board || board.self) return null;
|
|
243
|
+
if (from && !WS.library(dir).byId.has(def.id)) { const a = WS.adopt(dir, def, actor, from.id); if (!a.ok) return null; }
|
|
244
|
+
append(file, actor, { kind: 'ini-wf', board: board.id, id: iniId, wf: def.id, why: 'the default workflow for new initiatives', start: true });
|
|
245
|
+
return def.id;
|
|
246
|
+
} catch { return null; }
|
|
214
247
|
}
|
|
215
248
|
|
|
216
249
|
// ── boards of a workspace ─────────────────────────────────────────────────────────────────────────
|
|
@@ -225,7 +258,7 @@ function domainsOf(root) {
|
|
|
225
258
|
|
|
226
259
|
function boardsOf(ctx, w, folded) {
|
|
227
260
|
const self = isSelf(ctx, w);
|
|
228
|
-
const base = [{ id: w.id, name: w.name || path.basename(w.root), domain: null, kind: 'default' }];
|
|
261
|
+
const base = [{ id: w.id, name: w.label || w.name || path.basename(w.root), domain: null, kind: 'default' }];
|
|
229
262
|
for (const d of domainsOf(w.root)) if (d !== w.id) base.push({ id: d, name: d, domain: d, kind: 'domain' });
|
|
230
263
|
const known = new Set(base.map((b) => b.id));
|
|
231
264
|
for (const [id, b] of folded.boards) if (!known.has(id) && b.created) { base.push({ id, name: b.name || id, domain: b.domain || null, kind: 'created' }); known.add(id); }
|
|
@@ -1784,4 +1817,4 @@ function meetingBoards(ctx) {
|
|
|
1784
1817
|
return { list, detail };
|
|
1785
1818
|
}
|
|
1786
1819
|
|
|
1787
|
-
module.exports = { prefixes: ['/api/boards'], handle, tasksOfIni, postFrame, needRows, itemsOf, readLedger, foldOf, epicsOf, rowsOfBoard, invalidateRows: (w) => { rowsLib.invalidate(w.root); rowCache.delete(w.root); }, move, switchWorkflow, taskState, taskAdd, taskEdit, writeAs, taskDelete, taskRestore, initiativeWorkflow, seedInitiative, stageTasks, boardIndex, appendScanAsk, scanDeclineSignal, createAndPlace, ledgerFile, append, frameFor, boardIdOf, meetingBoards, boardsOf, baseRows, boardRow, taskSpanOf, workflowRowOf, foldOf, portfolio, SELF_REFUSAL, NO_STATE };
|
|
1820
|
+
module.exports = { prefixes: ['/api/boards'], handle, tasksOfIni, postFrame, needRows, itemsOf, readLedger, foldOf, epicsOf, rowsOfBoard, invalidateRows: (w) => { rowsLib.invalidate(w.root); rowCache.delete(w.root); }, move, switchWorkflow, taskState, taskAdd, taskEdit, writeAs, taskDelete, taskRestore, initiativeWorkflow, seedInitiative, stageTasks, boardIndex, appendScanAsk, scanDeclineSignal, createAndPlace, startOnDefault, ledgerFile, append, frameFor, boardIdOf, meetingBoards, boardsOf, baseRows, boardRow, taskSpanOf, workflowRowOf, foldOf, portfolio, SELF_REFUSAL, NO_STATE };
|
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
* has it. A deferred item ('Not yet') is left out until its defer date or time. Writes go through the existing pm-items routes; a mention's
|
|
29
29
|
* read mark goes through POST /api/mentions/read (routes-mentions.js). */
|
|
30
30
|
const path = require('path');
|
|
31
|
+
const fs = require('fs');
|
|
31
32
|
const pmItems = require('./pm-items.js');
|
|
32
33
|
const { repoScript } = require('./repo-script.js');
|
|
33
34
|
|
|
@@ -233,12 +234,29 @@ function notesAdapter(runtimeDir) {
|
|
|
233
234
|
};
|
|
234
235
|
}
|
|
235
236
|
|
|
236
|
-
/** The PM's first name for the greeting: the first word of `git config user.name` where PMOS runs. null when git holds none (the page then greets without a name).
|
|
237
|
+
/** The PM's first name for the greeting: the first word of `git config user.name` where PMOS runs. null when git holds none (the page then greets without a name).
|
|
238
|
+
* Kept until one of git's config files changes (page-open-speed): Home asked `git` twice per open, about 35 ms each, on the thread every
|
|
239
|
+
* other answer waits for. The key is the size and time of the files `git config` reads, so an edited name shows on the next open. */
|
|
240
|
+
const nameCache = new Map(); // cwd -> { key, name }
|
|
241
|
+
function gitConfigKey(cwd) {
|
|
242
|
+
const os = require('os');
|
|
243
|
+
const home = os.homedir();
|
|
244
|
+
const files = [path.join(home, '.gitconfig'), path.join(process.env.XDG_CONFIG_HOME || path.join(home, '.config'), 'git', 'config')];
|
|
245
|
+
if (cwd) files.push(path.join(cwd, '.git', 'config')); // a caller with no folder (routes-mentions answer in a test) reads the global config only
|
|
246
|
+
if (process.env.GIT_CONFIG_GLOBAL) files.push(process.env.GIT_CONFIG_GLOBAL);
|
|
247
|
+
return files.map((f) => { try { const st = fs.statSync(f); return st.size + ':' + st.mtimeMs; } catch { return '-'; } }).join('|');
|
|
248
|
+
}
|
|
237
249
|
function firstName(cwd) {
|
|
250
|
+
const key = gitConfigKey(cwd);
|
|
251
|
+
const hit = nameCache.get(cwd);
|
|
252
|
+
if (hit && hit.key === key) return hit.name;
|
|
253
|
+
let name = null;
|
|
238
254
|
try {
|
|
239
255
|
const n = String(require('child_process').execFileSync('git', ['config', 'user.name'], { cwd, encoding: 'utf8', timeout: 2000, stdio: ['ignore', 'pipe', 'ignore'] })).trim().split(/\s+/)[0];
|
|
240
|
-
|
|
241
|
-
} catch {
|
|
256
|
+
name = n && n.length <= 40 ? n : null;
|
|
257
|
+
} catch { name = null; }
|
|
258
|
+
nameCache.set(cwd, { key, name });
|
|
259
|
+
return name;
|
|
242
260
|
}
|
|
243
261
|
|
|
244
262
|
module.exports = {
|
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
* spent, left this month, in US dollars (lib/grill.js spentIn: an open run counts its whole budget)
|
|
8
8
|
* paused the cap held a run back this month
|
|
9
9
|
* runs for each initiative asked for: its last ended run { ini, why, at, cost, n, turn? }; n = its open grill findings
|
|
10
|
-
* news the newest ended run of this workspace in the last 7 days that left open findings (Home's one line); null is silence
|
|
10
|
+
* news the newest ended run of this workspace in the last 7 days that left open findings (Home's one line); null is silence.
|
|
11
|
+
* news.more: how many other initiatives were grilled this week with open findings; news.total: all their open findings
|
|
11
12
|
* run.turn: { scope, id, to } when the run started on a forecast turn (lib/forecast.js `turned` line, its key)
|
|
12
13
|
*
|
|
13
14
|
* Read-only: no run starts from a page (Claude grills by itself, lib/grill-runs.js), and the settings are written by /api/settings. */
|
|
@@ -44,13 +45,18 @@ function model(ctx, w, inis, now = Date.now()) {
|
|
|
44
45
|
const shape = (r) => ({ ini: r.ini, why: r.why, at: r.endAt || r.at, cost: r.cost, n: P ? openFindings(P, r.ini) : 0, ...(r.why === 'forecast' ? { turn: turnOf(r, turned) } : {}) });
|
|
45
46
|
const runs = {};
|
|
46
47
|
for (const id of inis) { const r = ended.filter((x) => x.ini === id).pop(); if (r) runs[id] = shape(r); }
|
|
47
|
-
|
|
48
|
-
|
|
48
|
+
// news: the newest; more and total: the other initiatives grilled this week that left open findings, so Home says how many, not just one
|
|
49
|
+
let news = null; const others = new Set(); let total = 0;
|
|
50
|
+
for (let i = ended.length - 1; i >= 0; i--) {
|
|
49
51
|
const r = ended[i];
|
|
50
52
|
if (now - (Date.parse(r.endAt || r.at) || 0) > WEEK) break;
|
|
53
|
+
if ((news && r.ini === news.ini) || others.has(r.ini)) continue;
|
|
51
54
|
const s = shape(r);
|
|
52
|
-
if (s.n > 0)
|
|
55
|
+
if (!(s.n > 0)) continue;
|
|
56
|
+
if (!news) news = s; else others.add(r.ini);
|
|
57
|
+
total += s.n;
|
|
53
58
|
}
|
|
59
|
+
if (news) Object.assign(news, { more: others.size, total });
|
|
54
60
|
return { ok: true, mode: G.modeOf(get, ctx.env || process.env), envOff: G.off(ctx.env || process.env), cap: Number.isFinite(cap) ? cap : null,
|
|
55
61
|
spent, left: Number.isFinite(cap) ? Math.max(0, Math.round((cap - spent) * 100) / 100) : null, month, paused: F.paused.has(month), runs, news };
|
|
56
62
|
}
|
|
@@ -5,10 +5,11 @@
|
|
|
5
5
|
*
|
|
6
6
|
* GET /api/impact/week?week=YYYY-MM-DD → { ok, on, live, first, week, from, to, label, grow, celebrate, quiet, items } Home's Adler, the Viewer
|
|
7
7
|
* GET /api/impact/weeks → { ok, on, live, weeks: [{ week, from, to, label, quiet, items }] } the log, newest first
|
|
8
|
-
* GET /api/impact/check → { ok, on, due, week, label, items, gate }
|
|
8
|
+
* GET /api/impact/check → { ok, on, due, week, label, items, gate, looked } the weekly check in Home's chat
|
|
9
9
|
* POST /api/impact/verdict { id, real } → { ok } ✓ (true) or ✕ (false) one row
|
|
10
10
|
* POST /api/impact/verdict-all { week } → { ok } ✓✓ every row of that week not checked yet
|
|
11
|
-
* POST /api/impact/undo { id } → { ok } the row back to not checked
|
|
11
|
+
* POST /api/impact/undo { id } → { ok } the row back to not checked (a dropped row counts again)
|
|
12
|
+
* POST /api/impact/looked { week } → { ok } the check card was shown for last week (a closed week then counts for the gate)
|
|
12
13
|
* POST /api/impact/seen { ids } → { ok } read on a Phase 2 surface; ends its one celebration
|
|
13
14
|
* `ws` is accepted on every route and ignored: the ledger is per person, across products.
|
|
14
15
|
*
|
|
@@ -50,7 +51,7 @@ async function handle(req, res, url, ctx) {
|
|
|
50
51
|
: what === 'weeks' ? IM.weeksView(F, optsOf(ctx)) : IM.checkView(F, optsOf(ctx));
|
|
51
52
|
return send({ status: 200, body: { ok: true, on: true, ...body } });
|
|
52
53
|
}
|
|
53
|
-
if (!['verdict', 'verdict-all', 'undo', 'seen'].includes(what)) return send(bad(404, 'no such impact route'));
|
|
54
|
+
if (!['verdict', 'verdict-all', 'undo', 'seen', 'looked'].includes(what)) return send(bad(404, 'no such impact route'));
|
|
54
55
|
if (req.method !== 'POST') return send(bad(405, 'POST only'));
|
|
55
56
|
if (req.headers && req.headers['x-pmos-session']) return send(bad(403, 'Only you check your impact, from the page. Claude\'s run files items with pmos impact.'));
|
|
56
57
|
const rd = await ctx.readJson(req);
|
|
@@ -63,10 +64,17 @@ async function handle(req, res, url, ctx) {
|
|
|
63
64
|
const F = IM.read(dir), at = new Date(nowMs(ctx)).toISOString(), by = { by: 'pm' };
|
|
64
65
|
|
|
65
66
|
if (what === 'seen') {
|
|
66
|
-
const ids = (Array.isArray(body.ids) ? body.ids : []).map(String).filter((id) => { const it = F.items.get(id); return it && it.real
|
|
67
|
+
const ids = (Array.isArray(body.ids) ? body.ids : []).map(String).filter((id) => { const it = F.items.get(id); return it && it.real !== false && !it.read; }).slice(0, 100);
|
|
67
68
|
if (ids.length) IM.append(dir, by, { kind: 'seen', ids }, at);
|
|
68
69
|
return send({ status: 200, body: { ok: true } });
|
|
69
70
|
}
|
|
71
|
+
if (what === 'looked') {
|
|
72
|
+
const week = text(body.week), o = optsOf(ctx);
|
|
73
|
+
if (week !== IM.addDays(IM.weekOf(o.today, o.start), -7)) return send(bad(400, 'week is the first day of last week, YYYY-MM-DD: the week the check is open for'));
|
|
74
|
+
if (!F.looked.has(week)) IM.append(dir, by, { kind: 'looked', week }, at);
|
|
75
|
+
noteGate(ctx, dir);
|
|
76
|
+
return send({ status: 200, body: { ok: true } });
|
|
77
|
+
}
|
|
70
78
|
if (what === 'verdict-all') {
|
|
71
79
|
const week = text(body.week), o = optsOf(ctx);
|
|
72
80
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(week)) return send(bad(400, 'week is the first day of the week, YYYY-MM-DD'));
|
|
@@ -666,6 +666,7 @@ async function handle(req, res, url, ctx) {
|
|
|
666
666
|
const { ws: _ws, ...input } = body;
|
|
667
667
|
const r = writer.createInitiative(w.root, input, { actor: g.actor });
|
|
668
668
|
if (!r.ok) { ctx.json(res, { ok: false, status: r.status, error: r.error, field: r.field }, r.status); return true; }
|
|
669
|
+
require('./routes-boards.js').startOnDefault(ctx, w, r.id, g.actor, { recordPath: r.path }); // batch 24 item 3: the default workflow
|
|
669
670
|
rowsLib.invalidate(w.root);
|
|
670
671
|
if (ctx.invalidateNeedsAgg) ctx.invalidateNeedsAgg(w.id);
|
|
671
672
|
ctx.json(res, { ok: true, id: r.id, path: r.path, written: r.written, sig: sigOf(w.root) });
|
|
@@ -33,6 +33,7 @@ const guard = require('./pm-guard.js');
|
|
|
33
33
|
const pmItems = require('./pm-items.js');
|
|
34
34
|
const P = require('./programs.js');
|
|
35
35
|
const MS = require('./milestone-suggest.js');
|
|
36
|
+
const gitShape = require('./git-shape.js');
|
|
36
37
|
const gitInfo = require('./git-info.js');
|
|
37
38
|
const deletedIds = require('./ini-deleted.js');
|
|
38
39
|
|
|
@@ -54,17 +55,18 @@ const read = (f) => { try { return f ? pmItems.readLines(f).filter(Boolean) : []
|
|
|
54
55
|
* such, a charter saved from the worktree puts the program under the worktree product while its members sit under the main one, and a
|
|
55
56
|
* member line that both hold shows twice. So the ledgers of a repo's trees are read as ONE product, the main tree's. Null when the
|
|
56
57
|
* workspace is not a linked worktree, or its main tree is not registered (nothing to fold into). Memoised: all() runs on every poll. */
|
|
57
|
-
|
|
58
|
-
const mainCache = new Map(); // root -> {
|
|
58
|
+
// Kept while the folder's git shape stamp is unchanged (page-open-speed): a 15 s clock re-ran `git` on every Board open after a break.
|
|
59
|
+
const mainCache = new Map(); // root -> { stamp, main: root of the repo's main tree | null }
|
|
59
60
|
function mainTreeRoot(w) {
|
|
60
61
|
const hit = mainCache.get(w.root);
|
|
61
|
-
|
|
62
|
+
const stamp = gitShape.shapeStamp(w.root);
|
|
63
|
+
if (hit && hit.stamp === stamp) return hit.main;
|
|
62
64
|
let main = null;
|
|
63
65
|
try {
|
|
64
66
|
const top = gitInfo.repoRoot(w.root), trees = top ? gitInfo.worktrees(w.root) : [];
|
|
65
67
|
if (top && trees.length && trees[0].dir && path.resolve(trees[0].dir) !== path.resolve(top)) main = path.join(path.resolve(trees[0].dir), path.relative(path.resolve(top), path.resolve(w.root)));
|
|
66
68
|
} catch { main = null; }
|
|
67
|
-
mainCache.set(w.root, {
|
|
69
|
+
mainCache.set(w.root, { stamp, main });
|
|
68
70
|
return main;
|
|
69
71
|
}
|
|
70
72
|
function foldedInto(list, w) {
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
* POST /api/report-design/revert { ws, id, n } → { ok, design }
|
|
15
15
|
* GET /api/report-design/cycle?ws&path → { ok, designed, design_version, format, outline, differences, decisions, matches, rendering, previous }
|
|
16
16
|
* GET /api/report-design/cycle-html?ws&path → text/html, the sanitized cycle render
|
|
17
|
+
* GET /api/report-design/pyramid?ws&path → text/html, the finished slides of a cadence draft (lib/report-pyramid.js; 404 for another document)
|
|
17
18
|
* POST /api/report-design/cycle-decision { ws, path, diff, choice: keep | adopt | match } → { ok, cycle }
|
|
18
19
|
* GET /api/report-notes?ws&path → { ok, speaker, suggestions, pins }
|
|
19
20
|
* POST /api/report-notes/speaker { ws, path, slide, text } | { ws, path, slide, keep: true }
|
|
@@ -43,9 +44,11 @@ const path = require('path');
|
|
|
43
44
|
const crypto = require('crypto');
|
|
44
45
|
const guard = require('./pm-guard.js');
|
|
45
46
|
const D = require('./report-design.js');
|
|
47
|
+
const RT = require('./report-templates.js');
|
|
46
48
|
const RS = require('./report-setups.js');
|
|
47
49
|
const RC = require('./report-checks.js');
|
|
48
50
|
const RDraft = require('./report-draft.js');
|
|
51
|
+
const RP = require('./report-pyramid.js');
|
|
49
52
|
const headless = require('./headless.js');
|
|
50
53
|
|
|
51
54
|
const text = (v) => (typeof v === 'string' ? v : '');
|
|
@@ -475,6 +478,14 @@ async function handle(req, res, url, ctx) {
|
|
|
475
478
|
const setup = e ? setupOf(w.root, e.id) : null;
|
|
476
479
|
return send({ status: 200, body: { ok: true, id: setup ? e.id : null, name: setup ? setup.name : null, at: setup ? e.at : null } });
|
|
477
480
|
}
|
|
481
|
+
if (sub === 'pyramid') {
|
|
482
|
+
// The finished slides of a cadence draft (canvas RsA): the Pyramid deck in the report's saved look, else the standard PMOS look.
|
|
483
|
+
const d = reportDoc(w, text(url.searchParams.get('path'))); if (d.error) return send(d.error);
|
|
484
|
+
let md; try { md = fs.readFileSync(d.c.md, 'utf8'); } catch { return send(bad(404, 'no such report document')); }
|
|
485
|
+
const deck = RP.deckOf(md, RP.lookOf(w.root, d.c.cadence), { data: RP.dataOf(d.c.md) });
|
|
486
|
+
if (!deck) return send(bad(404, 'this document is not a cadence draft, so it has no Pyramid deck'));
|
|
487
|
+
return sendHtml(res, D.sanitize(RP.deckHtml(deck)));
|
|
488
|
+
}
|
|
478
489
|
if (sub === 'cycle' || sub === 'cycle-html') {
|
|
479
490
|
const d = reportDoc(w, text(url.searchParams.get('path'))); if (d.error) return send(d.error);
|
|
480
491
|
if (sub === 'cycle') return send({ status: 200, body: cycleAnswer(w, d.c) });
|
|
@@ -532,7 +543,9 @@ async function handle(req, res, url, ctx) {
|
|
|
532
543
|
rules: D.rulesFor(root, c.cadence, st.saved.version), from: 'adopt', actor: g.actor });
|
|
533
544
|
if (!v.ok) return fail(v);
|
|
534
545
|
newVersion = v.n;
|
|
546
|
+
const tw = RT.followBefore(root, c.cadence, setupOf(root, c.cadence));
|
|
535
547
|
D.append(root, c.cadence, { ev: 'accept', n: v.n, rules: D.rulesFor(root, c.cadence, v.n) }, { actor: g.actor });
|
|
548
|
+
RT.followAfter(root, c.cadence, { design: v.n }, tw); // the template follows the saved look as T<n+1> (canvas TplA)
|
|
536
549
|
D.writeBrief(root, c.cadence, setupOf(root, c.cadence));
|
|
537
550
|
}
|
|
538
551
|
const r = D.decideCycle(root, c.rel, { diff: diff.id, choice, newVersion });
|
|
@@ -597,7 +610,9 @@ async function handle(req, res, url, ctx) {
|
|
|
597
610
|
const n = body.n;
|
|
598
611
|
if (!Number.isInteger(n) || !D.stateOf(root, id).versions.has(n)) return send(bad(404, 'no such version'));
|
|
599
612
|
if (sub === 'revert') { D.append(root, id, { ev: 'revert', n }, { actor: g.actor }); return answer(); }
|
|
613
|
+
const tw = RT.followBefore(root, id, setup);
|
|
600
614
|
D.append(root, id, { ev: 'accept', n, rules: D.rulesFor(root, id, n) }, { actor: g.actor });
|
|
615
|
+
RT.followAfter(root, id, { design: n }, tw); // the template follows the saved look as T<n+1> (canvas TplA)
|
|
601
616
|
const b = D.writeBrief(root, id, setup);
|
|
602
617
|
if (!b.ok) return fail(b);
|
|
603
618
|
// the current cycle is redrawn in the saved design at once (Wawan, 2026-10-13); older cycles, and a cycle already sent, keep their look
|