create-pmos 0.240.0 → 0.241.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.
Files changed (37) hide show
  1. package/package.json +1 -1
  2. package/payload/KIT-VERSION +1 -1
  3. package/payload/planning/evals/eval-calibration.md +1 -1
  4. package/payload/planning/okrs/health-budgets.md +1 -1
  5. package/payload/state/discoveries.md +1 -1
  6. package/workspace/c3-report-structure-real-test.js +211 -0
  7. package/workspace/c3-report-structure-test.js +382 -0
  8. package/workspace/guide/index.md +2 -0
  9. package/workspace/guide/report-design.md +4 -0
  10. package/workspace/guide/report-structure.md +91 -0
  11. package/workspace/guide/reports-dashboards.md +1 -1
  12. package/workspace/guide/reports-set-up.md +2 -0
  13. package/workspace/lib/charts-store.js +124 -0
  14. package/workspace/lib/headless.js +17 -3
  15. package/workspace/lib/needs-providers.js +1 -0
  16. package/workspace/lib/report-structure-needs.js +64 -0
  17. package/workspace/lib/report-structure.js +515 -0
  18. package/workspace/lib/route-modules.js +1 -0
  19. package/workspace/lib/routes-report-setups.js +11 -6
  20. package/workspace/lib/routes-report-structure.js +619 -0
  21. package/workspace/package-lock.json +2 -2
  22. package/workspace/package.json +1 -1
  23. package/workspace/public/pm/c3-demo-rd.js +4 -0
  24. package/workspace/public/pm/c3-design-studio.js +7 -3
  25. package/workspace/public/pm/c3-report-setup.js +3 -0
  26. package/workspace/public/pm/c3-report-structure.css +40 -0
  27. package/workspace/public/pm/c3-report-structure.js +455 -0
  28. package/workspace/public/pm/c3-reports.css +2 -0
  29. package/workspace/public/pm/c3-reports.js +22 -3
  30. package/workspace/public/pm/c3-shell.js +2 -0
  31. package/workspace/server.js +38 -9
  32. package/workspace/skills/report-source-check.skill +45 -0
  33. package/workspace/skills/report-structure-dry.skill +51 -0
  34. package/workspace/skills/report-structure.skill +61 -0
  35. package/workspace/skills/report-template.skill +10 -0
  36. package/workspace/test-lists/report-structure-backend.json +5 -0
  37. package/workspace/test-lists/report-structure-ui.json +11 -0
@@ -0,0 +1,124 @@
1
+ 'use strict';
2
+ /* charts-store.js — the chart store: the charts a report structure defines once (initiative report-structure, contract section 6).
3
+ *
4
+ * paths(root) → { file, rel } <state>/charts.jsonl
5
+ * append(root, line, { actor, now }) → the line written. THE SINGLE WRITER of charts.jsonl. `at` and `actor` are set here.
6
+ * readEvents(root) · fold(events) → Map id → chart a torn line is skipped
7
+ * get(root, id) → chart | null
8
+ * addChart(root, chart, { actor }) → { ok, chart } | refusal slugs the id, makes it unique, writes one 'add' line
9
+ * list(root, { runtimeDir }) → [chart + { users: [{ kind: 'report' | 'dashboard', id, name }] }]
10
+ * slugOf(text) → a chart id slug
11
+ *
12
+ * THE LINES. Every line is { ev, id, chart?, at, actor }: 'add' (a whole chart), 'change' (the fields that changed), 'remove'.
13
+ * A CHART is { id, name, question, source: { kind, name, via?, ref? }, from, how, compare, flag, query, why, created_by }.
14
+ * USERS are folded from the ACCEPTED plans (a report whose saved structure names the chart in charts[].chart) and from the Reports dashboards'
15
+ * layouts (<runtime>/report-dashboards/layouts.json: a key whose list holds the chart id). A chart from the store is REFERENCED by a plan, never copied.
16
+ *
17
+ * NOTHING HERE STARTS A JOB OR SENDS ANYTHING.
18
+ */
19
+ const fs = require('fs');
20
+ const path = require('path');
21
+ const RD = require('./report-draft.js');
22
+
23
+ const ID_RE = /^[a-z][a-z0-9-]{0,39}$/;
24
+ const EVENTS = Object.freeze(['add', 'change', 'remove']);
25
+ const FIELDS = Object.freeze(['name', 'question', 'from', 'how', 'compare', 'flag', 'query', 'why']);
26
+ const PMOS_ACTOR = Object.freeze({ by: 'pmos', via: 'job' });
27
+
28
+ const isObj = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
29
+ const str = (v) => (typeof v === 'string' ? v : '');
30
+ const refuse = (status, error) => ({ ok: false, status, error });
31
+
32
+ function stateRel(root) { try { return RD.stateRel(root); } catch { return 'planning'; } }
33
+ function paths(root) {
34
+ const abs = path.resolve(String(root));
35
+ const rel = `${stateRel(abs)}/charts.jsonl`;
36
+ return { abs, rel, file: path.join(abs, rel) };
37
+ }
38
+ function slugOf(text) {
39
+ const s = String(text == null ? '' : text).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').replace(/^[^a-z]+/, '').slice(0, 36).replace(/-+$/, '');
40
+ return s || 'chart';
41
+ }
42
+
43
+ function append(root, line, { actor, now } = {}) {
44
+ if (!isObj(line) || !EVENTS.includes(line.ev) || !ID_RE.test(str(line.id))) throw new Error(`charts-store: bad line ${JSON.stringify(line && line.ev)}`);
45
+ const p = paths(root);
46
+ const out = { ...line, at: new Date(now == null ? Date.now() : now).toISOString(), actor: isObj(actor) ? { by: str(actor.by), via: str(actor.via) } : { ...PMOS_ACTOR } };
47
+ fs.mkdirSync(path.dirname(p.file), { recursive: true });
48
+ fs.appendFileSync(p.file, JSON.stringify(out) + '\n');
49
+ return out;
50
+ }
51
+ function readEvents(root) {
52
+ let text = '';
53
+ try { text = fs.readFileSync(paths(root).file, 'utf8'); } catch { return []; }
54
+ const out = [];
55
+ for (const raw of text.split('\n')) {
56
+ if (!raw.trim()) continue;
57
+ try { const l = JSON.parse(raw); if (isObj(l) && EVENTS.includes(l.ev) && ID_RE.test(str(l.id))) out.push(l); } catch { /* a torn line is skipped */ }
58
+ }
59
+ return out;
60
+ }
61
+ function fold(events) {
62
+ const m = new Map();
63
+ for (const l of events) {
64
+ if (l.ev === 'add' && isObj(l.chart)) m.set(l.id, { ...l.chart, id: l.id, added_at: l.at });
65
+ else if (l.ev === 'change' && m.has(l.id) && isObj(l.chart)) m.set(l.id, { ...m.get(l.id), ...l.chart, id: l.id, changed_at: l.at });
66
+ else if (l.ev === 'remove') m.delete(l.id);
67
+ }
68
+ return m;
69
+ }
70
+ const stateOf = (root) => fold(readEvents(root));
71
+ function get(root, id) { return stateOf(root).get(String(id)) || null; }
72
+
73
+ /** Write one new chart. The id is slugged from chart.id (else the name) and made unique with -2, -3, …. → { ok, chart } */
74
+ function addChart(root, chart, { actor, now } = {}) {
75
+ if (!isObj(chart)) return refuse(400, 'chart must be an object');
76
+ const have = stateOf(root);
77
+ const base = slugOf(chart.id || chart.name);
78
+ let id = base; let k = 2;
79
+ while (have.has(id)) { id = `${base.slice(0, 36)}-${k++}`; }
80
+ const clean = { id, name: str(chart.name), question: str(chart.question), source: isObj(chart.source) ? chart.source : { kind: 'pmos-level', name: '' },
81
+ from: str(chart.from), how: str(chart.how), compare: str(chart.compare), flag: str(chart.flag), query: str(chart.query), why: str(chart.why), created_by: str(chart.created_by) };
82
+ append(root, { ev: 'add', id, chart: clean }, { actor, now });
83
+ return { ok: true, chart: clean };
84
+ }
85
+
86
+ /** The reports whose ACCEPTED structure uses each chart id: Map chart id → [{ kind: 'report', id, name }]. */
87
+ function reportUsers(root, nameOf) {
88
+ const users = new Map();
89
+ const base = path.join(paths(root).abs, stateRel(root), 'reports');
90
+ let cadences = [];
91
+ try { cadences = fs.readdirSync(base); } catch { return users; }
92
+ const RS = require('./report-structure.js');
93
+ for (const cadence of cadences) {
94
+ if (!/^[a-z][a-z0-9-]{0,39}$/.test(cadence) || cadence === 'definitions') continue;
95
+ let st; try { st = RS.stateOf(root, cadence); } catch { continue; }
96
+ if (!st.saved) continue;
97
+ const plan = RS.readVersion(root, cadence, st.saved.n);
98
+ if (!plan) continue;
99
+ for (const c of plan.charts || []) {
100
+ if (!c.chart) continue;
101
+ if (!users.has(c.chart)) users.set(c.chart, []);
102
+ const list = users.get(c.chart);
103
+ if (!list.some((u) => u.id === cadence)) list.push({ kind: 'report', id: cadence, name: (nameOf && nameOf(cadence)) || cadence });
104
+ }
105
+ }
106
+ return users;
107
+ }
108
+ /** The dashboard layout keys whose list names the chart id. The layout reader keeps only the built-in ids, so the file is read as it is. */
109
+ function dashboardUsers(runtimeDir, id) {
110
+ if (!runtimeDir) return [];
111
+ let raw; try { raw = JSON.parse(fs.readFileSync(path.join(runtimeDir, 'report-dashboards', 'layouts.json'), 'utf8')); } catch { return []; }
112
+ const src = isObj(raw) && isObj(raw.layouts) ? raw.layouts : raw;
113
+ if (!isObj(src)) return [];
114
+ return Object.entries(src).filter(([, v]) => Array.isArray(v) && v.includes(id)).map(([k]) => ({ kind: 'dashboard', id: k, name: k }));
115
+ }
116
+
117
+ function list(root, { runtimeDir, nameOf } = {}) {
118
+ const users = reportUsers(root, nameOf);
119
+ return [...stateOf(root).values()].sort((a, b) => (a.added_at < b.added_at ? -1 : a.added_at > b.added_at ? 1 : 0))
120
+ .map((c) => ({ ...c, users: [...(users.get(c.id) || []), ...dashboardUsers(runtimeDir, c.id)] }));
121
+ }
122
+ function one(root, id, opts) { return list(root, opts).find((c) => c.id === id) || null; }
123
+
124
+ module.exports = { ID_RE, EVENTS, FIELDS, paths, append, readEvents, fold, stateOf, get, addChart, list, one, slugOf };
@@ -3,7 +3,7 @@
3
3
  * (initiative workspace-headless-jobs; PM decision 2026-10-11: keep Jev, no direct Claude API, background
4
4
  * jobs that need no human run headless).
5
5
  *
6
- * kindFor({ agent, origin, skill, env }) → 'notes' | 'skill' | 'ask' | 'drive' | 'scan-web' | 'scan-repo' | 'scan-research-web' | 'scan-research-repo' | 'report-setup' | 'report-find' | 'core-doc' | 'report-design' | 'report-sample-fetch' | null (null: open a pane, as before)
6
+ * kindFor({ agent, origin, skill, env }) → 'notes' | 'skill' | 'ask' | 'drive' | 'scan-web' | 'scan-repo' | 'scan-research-web' | 'scan-research-repo' | 'report-setup' | 'report-find' | 'core-doc' | 'report-design' | 'report-sample-fetch' | 'report-structure' | 'report-structure-dry' | null (null: open a pane, as before)
7
7
  * argsFor(kind, { modelArgs, addDirs, writePaths }) → the argv after `claude` (writePaths: the one render file a report-design job may write)
8
8
  * parseResult(stdout) → { ok, text, isError, turns, durationMs, costUsd, denied[] }
9
9
  * streamLines(line) → { log: [lines], result: object|null } one stream-json event, for the live log
@@ -48,6 +48,11 @@
48
48
  * (the cycle document, the samples, the earlier versions and the brief) and writes ONE file: the render at the pending path the server gives it, named in
49
49
  * its tool list as `Write(//<path>)` and `Edit(//<path>)`. It has no web tool, no Bash and no connector tool, and nothing that sends. The server sanitizes
50
50
  * the render before anything shows it. It never runs in a pane.
51
+ * The `report-structure` and `report-structure-dry` kinds are the report structure step (initiative report-structure): origins `report-structure:<id>` and
52
+ * `report-structure-dry:<id>`. A turn proposes or refines the plan of a report (sources, charts, outline); a dry run tries a saved version on the last complete
53
+ * period. Each reads the repository and, where the PM's own Claude Code settings allow it, reads through connectors (none is named in the list, as for
54
+ * `report-sample-fetch`), and each writes ONE file: structure/pending/<job>.json, named in its tool list like the design render. No Bash, no web fetch, no
55
+ * connector write tool, nothing that sends. The server validates the file before it lands. Neither kind ever runs in a pane.
51
56
  */
52
57
 
53
58
  // The skills whose every start runs headless: they read, file a result for PMOS, and send nothing out.
@@ -82,7 +87,10 @@ const CORE_DOC_ORIGINS = Object.freeze(['core-doc:']);
82
87
  const REPORT_DESIGN_ORIGINS = Object.freeze(['report-design:']);
83
88
  // report-design-studio: reading ONE linked Google or Confluence file through the PM's own connectors (origin `report-sample-fetch:<cadence>:<sampleId>`).
84
89
  const REPORT_SAMPLE_FETCH_ORIGINS = Object.freeze(['report-sample-fetch:']);
85
- const WRITE_PATH_KINDS = Object.freeze(['report-design', 'report-sample-fetch']); // kinds whose one writable file the caller names (argsFor writePaths)
90
+ // report-structure: a structure turn and a dry run on real data (origins `report-structure:<id>`, `report-structure-dry:<id>`).
91
+ const REPORT_STRUCTURE_ORIGINS = Object.freeze(['report-structure:']);
92
+ const REPORT_STRUCTURE_DRY_ORIGINS = Object.freeze(['report-structure-dry:']);
93
+ const WRITE_PATH_KINDS = Object.freeze(['report-design', 'report-sample-fetch', 'report-structure', 'report-structure-dry']); // kinds whose one writable file the caller names (argsFor writePaths)
86
94
 
87
95
  // What each kind may use. Read-only search is always allowed. The notes passes write their JSON (and rename the .tmp
88
96
  // file) in their notes folder, which --add-dir opens to them; the skills also run `pmos` verbs and repository scripts.
@@ -114,6 +122,9 @@ const TOOLS = Object.freeze({
114
122
  'report-design': Object.freeze(['Read', 'Glob', 'Grep']),
115
123
  // The link fetch run: Read, and its one write (the text copy of the linked file) is added per run by argsFor. No Bash, no web, no connector tool named.
116
124
  'report-sample-fetch': Object.freeze(['Read']),
125
+ // The structure runs: read and search; a connector read runs only where the PM's own settings allow it. Their one write, the JSON file, is added per run by argsFor.
126
+ 'report-structure': Object.freeze(['Read', 'Glob', 'Grep']),
127
+ 'report-structure-dry': Object.freeze(['Read', 'Glob', 'Grep']),
117
128
  skill: Object.freeze(['Read', 'Write', 'Edit', 'Glob', 'Grep', 'Bash(pmos *)', 'Bash(node *)',
118
129
  'Bash(git log *)', 'Bash(git status *)', 'Bash(git diff *)', 'Bash(ls *)', 'Bash(date *)']),
119
130
  });
@@ -139,6 +150,9 @@ function kindFor({ agent, origin, skill, env } = {}) {
139
150
  if (CORE_DOC_ORIGINS.some((p) => o.startsWith(p))) return 'core-doc';
140
151
  if (REPORT_DESIGN_ORIGINS.some((p) => o.startsWith(p))) return 'report-design';
141
152
  if (REPORT_SAMPLE_FETCH_ORIGINS.some((p) => o.startsWith(p))) return 'report-sample-fetch';
153
+ // 'report-structure-dry:' is not a prefix of 'report-structure:' (the colon differs), so the two are told apart by their prefix alone
154
+ if (REPORT_STRUCTURE_DRY_ORIGINS.some((p) => o.startsWith(p))) return 'report-structure-dry';
155
+ if (REPORT_STRUCTURE_ORIGINS.some((p) => o.startsWith(p))) return 'report-structure';
142
156
  if (skill && HEADLESS_SKILLS.includes(String(skill))) return 'skill';
143
157
  return null;
144
158
  }
@@ -265,4 +279,4 @@ function logLines(r, exitCode) {
265
279
  return out;
266
280
  }
267
281
 
268
- module.exports = { HEADLESS_SKILLS, HEADLESS_ORIGINS, ASK_ORIGINS, DRIVE_ORIGINS, SCAN_WEB_ORIGINS, SCAN_REPO_ORIGINS, SCAN_RESEARCH_WEB_ORIGINS, SCAN_RESEARCH_REPO_ORIGINS, WEB_KINDS, REPORT_SETUP_ORIGINS, REPORT_FIND_ORIGINS, CORE_DOC_ORIGINS, REPORT_DESIGN_ORIGINS, REPORT_SAMPLE_FETCH_ORIGINS, TOOLS, DEFAULT_TIMEOUT_MS, kindFor, argsFor, timeoutMs, parseResult, streamLines, outcomeOf, logLines, switchedOff };
282
+ module.exports = { HEADLESS_SKILLS, HEADLESS_ORIGINS, ASK_ORIGINS, DRIVE_ORIGINS, SCAN_WEB_ORIGINS, SCAN_REPO_ORIGINS, SCAN_RESEARCH_WEB_ORIGINS, SCAN_RESEARCH_REPO_ORIGINS, WEB_KINDS, REPORT_SETUP_ORIGINS, REPORT_FIND_ORIGINS, CORE_DOC_ORIGINS, REPORT_DESIGN_ORIGINS, REPORT_SAMPLE_FETCH_ORIGINS, REPORT_STRUCTURE_ORIGINS, REPORT_STRUCTURE_DRY_ORIGINS, TOOLS, DEFAULT_TIMEOUT_MS, kindFor, argsFor, timeoutMs, parseResult, streamLines, outcomeOf, logLines, switchedOff };
@@ -35,6 +35,7 @@ const PROVIDERS = Object.freeze([
35
35
  'report-needs', // Run 5 (5C): a report to issue (kind 1), cycle rows (kind 7), a schedule left no output (kind 8)
36
36
  'driver-needs', // initiative-driver: drafts the agent filed (kind 7), a target the PM is blocking (kind 7)
37
37
  'core-docs-needs', // core-documents: one row per scope with core document drafts waiting for the PM (kind 1)
38
+ 'report-structure-needs', // report-structure: a Chrome or computer source that failed its check while a draft waits for it (kind 8)
38
39
  ]);
39
40
  /** Kinds that already mean something in the needs queue. A provider may not reuse one. */
40
41
  const RESERVED_KINDS = Object.freeze(['question', 'accept', 'outcome', 'keep', 'evidence', 'plan-flag', 'provider-error']);
@@ -0,0 +1,64 @@
1
+ 'use strict';
2
+ /* report-structure-needs.js — the needs provider of the report structure step (initiative report-structure, contract section 8).
3
+ * lib/needs-providers.js folds these rows into server.js deriveNeeds, and from there into /api/needs, the Briefing and Work.
4
+ *
5
+ * kinds: report-source-check
6
+ * rowsFor(w, { lead }) → rows · sig(w) → string
7
+ *
8
+ * report-source-check one row for each Chrome or computer source that failed its check while a scheduled draft waits for it:
9
+ * "Check <source> for <report>". The draft waits up to 4 hours (lib/routes-report-structure.js sweep), then is drafted
10
+ * without the source. The row goes when the draft starts. Kind 8 (needs repair); the act is Check again on the report.
11
+ *
12
+ * The rows are repository-wide, so a tree that is not the lead returns []. Nothing here writes.
13
+ */
14
+ const fs = require('fs');
15
+ const path = require('path');
16
+ const RD = require('./report-draft.js');
17
+
18
+ const NAME = 'report-structure-needs';
19
+
20
+ function holds(root) {
21
+ const out = [];
22
+ let base;
23
+ try { base = path.join(path.resolve(String(root)), RD.stateRel(root), 'reports'); } catch { return out; }
24
+ let ids = []; try { ids = fs.readdirSync(base); } catch { return out; }
25
+ for (const id of ids) {
26
+ if (!/^[a-z][a-z0-9-]{0,39}$/.test(id) || id === 'definitions') continue;
27
+ const dir = path.join(base, id, 'structure');
28
+ let hold = null; try { hold = JSON.parse(fs.readFileSync(path.join(dir, 'hold.json'), 'utf8')); } catch { hold = null; }
29
+ if (!hold || typeof hold !== 'object') continue;
30
+ out.push({ id, dir, hold });
31
+ }
32
+ return out;
33
+ }
34
+ const readJson = (f) => { try { const o = JSON.parse(fs.readFileSync(f, 'utf8')); return o && typeof o === 'object' ? o : null; } catch { return null; } };
35
+
36
+ function rowsFor(w, { lead } = {}) {
37
+ if (!lead || !w || !w.root) return [];
38
+ const rows = [];
39
+ for (const { id, dir, hold } of holds(w.root)) {
40
+ const since = Date.parse(hold.since);
41
+ let name = id;
42
+ try { const s = require('./report-setups.js').stateOf(w.root).setups.get(id); if (s && s.name) name = s.name; } catch { /* the id is the name */ }
43
+ let plan = null;
44
+ try { const v = readJson(path.join(dir, 'versions', `S${hold.saved}.json`)); plan = v; } catch { plan = null; }
45
+ for (const sid of Array.isArray(hold.sources) ? hold.sources : []) {
46
+ const c = readJson(path.join(dir, 'checks', `${sid}.json`));
47
+ if (!c || c.running || c.ok || !(Date.parse(c.at) >= since - 1000)) continue;
48
+ const src = plan && Array.isArray(plan.sources) ? plan.sources.find((s) => s.id === sid) : null;
49
+ const step = (c.steps || []).find((s) => s.state === 'needs-you');
50
+ rows.push({
51
+ run_id: `${NAME}:${id}:${sid}`, kind: 'report-source-check', initiative_id: null, question: `Check ${(src && src.name) || sid} for ${name}`, doc: null, ws: w.id,
52
+ today: { kind: 8, source: NAME, cause: step ? `${step.label}: ${step.reason || 'needs you'}` : 'The check did not pass', evidence: `${path.relative(path.resolve(w.root), dir).split(path.sep).join('/')}/checks/${sid}.json`,
53
+ act: { label: 'Check again', cli: null } },
54
+ });
55
+ }
56
+ }
57
+ return rows;
58
+ }
59
+ function sig(w) {
60
+ if (!w || !w.root) return '';
61
+ return holds(w.root).map((h) => `${h.id}:${h.hold.since}:${(Array.isArray(h.hold.sources) ? h.hold.sources : []).map((s) => { try { return fs.statSync(path.join(h.dir, 'checks', `${s}.json`)).mtimeMs; } catch { return 0; } }).join(',')}`).join('|');
62
+ }
63
+
64
+ module.exports = { kinds: ['report-source-check'], rowsFor, sig };