create-pmos 0.65.0 → 0.65.2

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-pmos",
3
- "version": "0.65.0",
3
+ "version": "0.65.2",
4
4
  "description": "Scaffold the PMOS portable kit — run the PMOS product-management workflow (initiative → lane-fit spec → contract → build → three gates, with work-type lanes for features, research, maintenance, incidents, and content) entirely inside your own repo, with all state as git-versioned files. Includes `workflow-sweep`, which audits the state every board derives from and separates real defects from work you simply have not done. `create-pmos workspace` also installs + launches the local Terminal Workspace GUI. No database, no network, no telemetry.",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -1,3 +1,3 @@
1
- kernel-source: pmos@bcf4a86
1
+ kernel-source: pmos@cc06cf0
2
2
  assembled: 2026-10-07
3
3
  mode: vendor
@@ -2,7 +2,7 @@
2
2
  type: Reference
3
3
  title: "Eval calibration log"
4
4
  description: "Append-only record of every PM override of an evaluator verdict."
5
- generated: {by: process:create-pmos, at: "2026-10-07T00:08:29Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-07T01:20:37Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -2,7 +2,7 @@
2
2
  type: Reference
3
3
  title: "Health budgets"
4
4
  description: "Closed registry of health-budget anchors (D59), edited only by the PM."
5
- generated: {by: process:create-pmos, at: "2026-10-07T00:08:29Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-07T01:20:37Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -2,7 +2,7 @@
2
2
  type: Reference
3
3
  title: "Discoveries and friction log"
4
4
  description: "Append-only record of what runs discovered and where the process rubbed."
5
- generated: {by: process:create-pmos, at: "2026-10-07T00:08:29Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-07T01:20:37Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  type: Guide
3
- title: "Docs: is each document the same as its Google Docs and Confluence copy, and which version to use"
3
+ title: "Docs: every repository file by category, the files each one links to, and whether each copy in Google Docs or Confluence is the same"
4
4
  timestamp: 2026-10-06
5
5
  ---
6
6
 
@@ -8,15 +8,65 @@ timestamp: 2026-10-06
8
8
 
9
9
  Open Docs from the top bar, from ⌘K (**Docs**), or press `g d`.
10
10
 
11
- Docs lists every document in the repository that has a copy in Google Docs or Confluence. For each copy, it shows whether the repository version (PMOS) or the copy is newer. You can then use one version over the other, one copy at a time.
11
+ Docs lists every markdown file in the repository by category. For each file, it shows the files that it links to and the files that link to it. For each document that has a copy in Google Docs or Confluence, it shows whether the repository version (PMOS) or the copy is newer. You can then use one version over the other, one copy at a time.
12
12
 
13
13
  Nothing syncs by itself. A copy changes only when you press a button for that copy.
14
14
 
15
15
  ## The list
16
16
 
17
- The copies are grouped by initiative. Copies of files that no initiative owns are under **Other**.
17
+ Docs has three views:
18
18
 
19
- **Needs you** shows the copies that are not the same. **All** shows every copy.
19
+ - **Needs you** shows the copies that are not the same.
20
+ - **With a copy** shows every copy.
21
+ - **All files** shows every markdown file in the repository.
22
+
23
+ The copies are grouped by category.
24
+
25
+ ## Categories
26
+
27
+ The category of a file comes from its folder, then from its frontmatter `type`. You do not tag a file.
28
+
29
+ | Category | Files |
30
+ |---|---|
31
+ | Specs | `planning/prd/`, or `type: PRD` |
32
+ | Initiatives | `planning/initiatives/` |
33
+ | Decisions | the ADR folder, `DECISIONS.md`, `pm-decisions.md` |
34
+ | Research & plans | the other `planning/` folders |
35
+ | Explainers | `planning/explainers/` |
36
+ | Evals | `planning/evals/`, or `type: Rubric` |
37
+ | Knowledge | the other `okf/` folders |
38
+ | Guides & rules | all other files |
39
+ | Prototypes | `prototype/` |
40
+
41
+ ## All files
42
+
43
+ **All files** opens with one line for each category: its name, its number of files, and "N need you" when a copy in it is not the same. Press a category to open it. It shows its first 20 files: the files with a copy first, then the newest. Press **Show all** to see the rest.
44
+
45
+ A file with no copy is one line: its title, the short name of its initiative (or its path when no initiative owns it), and the date of its last edit. **Add copy** shows when you point at the line, and always on a touch screen.
46
+
47
+ ## The links of a file
48
+
49
+ Press a file in **All files** to see its links:
50
+
51
+ - on the left, the files that link to it;
52
+ - in the middle, the file;
53
+ - on the right, the files that it links to.
54
+
55
+ Each side shows at most 7 files. A dashed line is a decision that the file cites. Press a file in the graph to put it in the middle. Press **Back** to go back. **Open file** opens the file in the reader.
56
+
57
+ **List all N links** lists every link in both directions, grouped by kind.
58
+
59
+ A link counts only when a file writes it:
60
+
61
+ - a markdown link to another file;
62
+ - a path in the frontmatter, such as `prd_path`;
63
+ - the `initiative_id` of the file;
64
+ - the anchor (`parent_kr`);
65
+ - a decision number, such as D65.
66
+
67
+ PMOS does not guess a link from similar words. `pmos kg neighbors <path>` shows the same links in the terminal.
68
+
69
+ On a phone the graph is not shown. The list is shown.
20
70
 
21
71
  Each copy is one line:
22
72
 
@@ -49,7 +99,8 @@ Click a line to see its details:
49
99
  - the copy: its address and its last edit;
50
100
  - a **Use … version** button for each side;
51
101
  - the first difference that the compare found, when it found one;
52
- - **Compare again** and **Stop tracking this copy**.
102
+ - **Compare again** and **Stop tracking this copy**;
103
+ - the links of the file, as in **All files**.
53
104
 
54
105
  **Stop tracking this copy** removes the link. The copy is not deleted. Press **Undo** in the message to link it again.
55
106
 
@@ -72,7 +123,9 @@ A compare runs only when you press it. There is no schedule. An edit to a reposi
72
123
 
73
124
  ## Add a copy
74
125
 
75
- Type in **Find a document**. The search finds every markdown file in the repository by its title or path, also files that have no copy. A file with no copy shows **No copy** and **Add copy**.
126
+ Type in the search box. The search finds every markdown file in the repository by its title, its path or the name of its initiative, also files that have no copy. The results are grouped by category, and the match is marked. Press Esc to clear the search.
127
+
128
+ Press **Add copy** on a file with no copy.
76
129
 
77
130
  Press **Add copy**, paste the address of the Google Doc or Confluence page, and press Enter. A document can have one Google Doc and one Confluence page.
78
131
 
@@ -76,6 +76,14 @@ The Files tab lists every file that belongs to the initiative, in three groups:
76
76
  - **Linked documents.** The documents the library joins to the initiative: a document that declares its `initiative_id`, a document the record names, a file name that starts with the id.
77
77
  - **Changed for it in git.** The files that the commits for this initiative changed and that still exist. These are its own branch, trunk commits that name the id, and merged changes that carried its documents. The file opens read-only.
78
78
 
79
+ **List · Graph** at the top of the tab changes the view. **List** shows the groups above. **Graph** shows the files of the initiative as a network:
80
+
81
+ - a filled dot is a markdown file of the initiative: a file that the record names, or a linked document. Its label is its role, such as PRD, Rubric or Explainer;
82
+ - a hollow dot is a file outside the initiative that one of its files links to, or that links to one of its files. A decision shows its number, such as D65;
83
+ - a line is a link that a file writes. A dashed line is a decision that a file cites.
84
+
85
+ The graph shows at most 14 outside files, the most-linked first. The legend says how many are left out. Press a dot to mark its lines and to see its title, its path and **Open file**. The same files and links always give the same picture. On a phone the list is shown.
86
+
79
87
  ### A breakdown read from the spec
80
88
 
81
89
  When nobody has planned items for an initiative, the Breakdown tab shows the items of its spec above the empty Breakdown: the user stories, or the entries of the Requirements section of the PRD that the record names. Each item shows the state of the whole initiative: **Done** after you accept it, **Built** when its build is on the trunk, and **Todo** before that. These items are read-only and nothing is written. To track an item, add it to the Breakdown. The card and the row show the same count, for example "0 of 8 · from board-delivery-truth-prd.md".
@@ -30,7 +30,7 @@ An agent job can stop with its session still open, for example when the agent as
30
30
 
31
31
  Each job session is named "Job ·" and the first words of the job, so the session list stays short.
32
32
 
33
- The Agenda month is at the top of the left column. **Agent results** comes under it, counts the jobs and lists each finished one with its draft summary or the reason it failed. Your goals for today come under Agent results. The day is open when you go to Agenda, also before you press **Prepare my day**. The other things that wait for you, such as outcome checks, report drafts and sessions, are in **Also waiting for you** at the end of the left column. When the day is folded, they show under it.
33
+ The Agenda month is at the top of the left column. **Agent results** comes under it, counts the jobs and lists each finished one with its draft summary or the reason it failed. Your goals for today come under Agent results. The day is open when you go to Agenda, also before you press **Prepare my day**. The other things that wait for you, such as outcome checks, report drafts and sessions, are jobs too. Each one is a row of **Needs you** in the jobs. Press it to see its card, with its own buttons (for example **Draft now**), in the detail column. When the day is folded, they show as cards under it.
34
34
 
35
35
  ### The Agenda month
36
36
 
@@ -50,7 +50,9 @@ Each day of the month answers three questions:
50
50
 
51
51
  The switch above the month shows one answer at a time: **Overview** (all three), **Full** (the time in meetings), **Key** (the meeting that matters), **Prep** (how ready you are) or **Free** (the free blocks of 90 minutes or more).
52
52
 
53
- The memory of a meeting says what kind of meeting it is (one-to-one, onboarding or group), how many times it was held, how many are to come, and why it matters. **Before this meeting** lists the prep. **Last time** is folded: open it to read the notes and what stayed open.
53
+ The memory of a meeting says what kind of meeting it is (one-to-one, onboarding or group), how many times it was held, how many are to come, and why it matters.
54
+
55
+ To mark a meeting yourself, open it. Press **Mark as key** to make this time a key meeting, or **Key every time** for every time it happens. On a meeting that is key already, press **Not key** or **Never key**. Your mark is used before what the calendar says. Press **Remove my mark** to go back to what the calendar says. The marks are kept in `calendar-marks.json` in the state folder. **Before this meeting** lists the prep. **Last time** is folded: open it to read the notes and what stayed open.
54
56
 
55
57
  The month comes from the calendar memory. The morning-brief session reads your calendar 90 days back and 31 days ahead the first time, and from today to 31 days ahead after that, and sends it with the brief. The workspace keeps 100 days back and 45 days ahead, in `calendar-memory.json` in the state folder. Nothing on the Agenda writes to your calendar. To see what the memory holds, run `pmos proactive calendar`.
56
58
 
@@ -66,7 +68,13 @@ To tie an item to a repository:
66
68
  1. For a goal, open it and pick a **Repository**. A goal you add while Today shows one repository belongs to that repository.
67
69
  2. For a Slack channel, Jira project or Confluence space, open **Sources**, open the service, and pick a repository next to the place.
68
70
 
69
- A job takes the repository of its goal. A job with no goal takes the repository of the place it came from. A job with neither belongs to no repository.
71
+ A job takes the repository of its goal. A job with no goal takes the repository of the initiative it names, then of the place it came from. A goal tied to an initiative takes that initiative's repository when you pick none. A job with none of these belongs to no repository.
72
+
73
+ When **Repository** hides a goal of another repository, the goals say how many it hides. Press **Show all repositories** to see them.
74
+
75
+ A job's agent session runs in the folder of the job's repository, so it can read that repository's files. A job with no repository runs in the repository where you pressed **Prepare my day**.
76
+
77
+ Your goals stay until you mark them done or drop them. A new day starts with the goals that were still open on the last day you had goals.
70
78
 
71
79
  Each job has one of three kinds.
72
80
 
@@ -5,7 +5,8 @@
5
5
  * mergeMemory(stored, memory, { now }) → { ok, memory } the stored file with the submitted window replaced
6
6
  * seriesKey(e) · normTitle(title) → the key that joins the occurrences of one meeting
7
7
  * kindOf(e) · isImportant(e) → 'one' | 'onboard' | 'group' · true | false
8
- * agendaMonth({ memory, brief, month, now }) → the month the Agenda draws: weeks, days with their three answers, series
8
+ * agendaMonth({ memory, brief, month, marks, now }) → the month the Agenda draws: weeks, days with their three answers, series
9
+ * readMarks(raw) · setMark(marks, { title, start, scope, on }) · markOf(marks, e) → the PM's key-meeting marks
9
10
  *
10
11
  * The morning-brief session reads the calendar wide (90 days back, 31 ahead) and submits it with the brief. The workspace
11
12
  * checks it here and keeps it in one file. Every answer the month gives is derived here, with no model and no I/O:
@@ -62,6 +63,51 @@ function isImportant(e) {
62
63
  return !!e && (e.role === 'present' || e.role === 'decide' || e.external === true || e.exec === true || (typeof e.attendees === 'number' && e.attendees >= 8));
63
64
  }
64
65
 
66
+ /* ── the PM's own marks ───────────────────────────────────────────────────────────────────────────────────────────
67
+ * The PM can mark a meeting as key, or as not key, over what isImportant derives: this one time, or every time it
68
+ * happens. A mark is keyed by the title (normTitle), and a time mark also by the start minute, so it holds whether the
69
+ * meeting comes from the calendar memory or from today's brief. */
70
+ const MAX_MARKS = 500;
71
+ const seriesMark = (title) => 't:' + normTitle(title);
72
+ const timeMark = (title, start) => { const t = Date.parse(start); return Number.isFinite(t) ? seriesMark(title) + '@' + new Date(t).toISOString().slice(0, 16) : null; };
73
+ const emptyMarks = () => ({ one: {}, series: {} });
74
+
75
+ /** The marks file, checked: anything else reads as no marks. */
76
+ function readMarks(raw) {
77
+ const out = emptyMarks();
78
+ if (!raw || typeof raw !== 'object') return out;
79
+ for (const k of ['one', 'series']) {
80
+ const m = raw[k] && typeof raw[k] === 'object' && !Array.isArray(raw[k]) ? raw[k] : {};
81
+ for (const [key, v] of Object.entries(m).slice(0, MAX_MARKS)) if (typeof v === 'boolean' && key.length <= 300 && key.startsWith('t:')) out[k][key] = v;
82
+ }
83
+ return out;
84
+ }
85
+
86
+ /** One mark set or cleared. `scope` 'one' (this time) or 'series' (every time); `on` true (key), false (not key) or null
87
+ * (clear, back to what the calendar says). A mark for every time also clears this time's mark. */
88
+ function setMark(marks, { title, start, scope, on } = {}) {
89
+ if (!str(title, 300)) return { ok: false, error: 'a mark needs the meeting title' };
90
+ if (!['one', 'series'].includes(scope)) return { ok: false, error: "scope must be 'one' or 'series'" };
91
+ if (!(on === true || on === false || on === null)) return { ok: false, error: 'on must be true, false or null' };
92
+ const key = scope === 'one' ? timeMark(title, start) : seriesMark(title);
93
+ if (!key || key === 't:') return { ok: false, error: 'a mark needs a title with letters and, for one time, its start' };
94
+ const next = readMarks(marks);
95
+ if (on === null) delete next[scope][key]; else next[scope][key] = on;
96
+ if (scope === 'series') { const t = timeMark(title, start); if (t) delete next.one[t]; }
97
+ if (Object.keys(next[scope]).length > MAX_MARKS) return { ok: false, error: 'too many marks — clear some first' };
98
+ return { ok: true, marks: next };
99
+ }
100
+
101
+ /** The PM's mark of a meeting: { on, scope } or null when there is none. A time mark wins over an every-time mark. */
102
+ function markOf(marks, e) {
103
+ if (!marks || !e) return null;
104
+ const t = timeMark(e.title, e.start);
105
+ if (t && marks.one && typeof marks.one[t] === 'boolean') return { on: marks.one[t], scope: 'one' };
106
+ const s = seriesMark(e.title);
107
+ if (marks.series && typeof marks.series[s] === 'boolean') return { on: marks.series[s], scope: 'series' };
108
+ return null;
109
+ }
110
+
65
111
  /* ── the check ─────────────────────────────────────────────────────────────────────────────────────────────────── */
66
112
 
67
113
  function validateMemory(raw, { now = Date.now() } = {}) {
@@ -166,14 +212,16 @@ function fromBrief(e) {
166
212
  * The month the Agenda draws. `month` is YYYY-MM (default: this month). Weeks run Monday to Sunday. Each day of the month
167
213
  * carries its events and the three answers; `series` describes every series that has an event in the month.
168
214
  */
169
- function agendaMonth({ memory, brief, month, now = Date.now() } = {}) {
215
+ function agendaMonth({ memory, brief, month, marks = null, now = Date.now() } = {}) {
170
216
  const today = localDate(now);
171
217
  const ym = typeof month === 'string' && /^\d{4}-\d{2}$/.test(month) ? month : today.slice(0, 7);
172
218
  const all = ((memory && memory.events) || []).map((e) => ({ ...e, src: 'memory' }));
173
219
  // today's events come from the brief of today when there is one; memory events of today are then left out
174
220
  const b = brief && brief.date === today && Array.isArray(brief.calendar) ? brief.calendar.map(fromBrief) : null;
221
+ // key: the PM's mark when there is one, else what the calendar says (isImportant)
222
+ const impOf = (e) => { const auto = isImportant(e); const mk = markOf(marks, e); return { imp: mk ? mk.on : auto, auto, mark: mk ? mk.scope : null }; };
175
223
  const events = (b ? [...all.filter((e) => localDate(Date.parse(e.start)) !== today), ...b] : all)
176
- .map((e) => { const s = Date.parse(e.start); const f = Date.parse(e.end); return { ...e, s, f, date: localDate(s), from: minuteOfDay(s), to: f - s >= DAY_MS ? DAY_END : (localDate(f) === localDate(s) ? minuteOfDay(f) : DAY_END), key: seriesKey(e), kind: kindOf(e), imp: isImportant(e) }; })
224
+ .map((e) => { const s = Date.parse(e.start); const f = Date.parse(e.end); return { ...e, s, f, date: localDate(s), from: minuteOfDay(s), to: f - s >= DAY_MS ? DAY_END : (localDate(f) === localDate(s) ? minuteOfDay(f) : DAY_END), key: seriesKey(e), kind: kindOf(e), ...impOf(e) }; })
177
225
  .sort((x, y) => x.s - y.s);
178
226
 
179
227
  // series over everything remembered, so "held 12 of 13" and "the last time" see beyond the month
@@ -227,7 +275,7 @@ function agendaMonth({ memory, brief, month, now = Date.now() } = {}) {
227
275
  if (lv === 'risk' || (lv === 'warn' && level !== 'risk') || (lv === 'ok' && !level)) level = lv;
228
276
  const x = series.get(e.key);
229
277
  return { id: e.id, src: e.src, title: e.title, start: e.start, end: e.end, time: `${pad(Math.floor(e.from / 60))}:${pad(e.from % 60)}`, minutes: Math.round((e.f - e.s) / 60000),
230
- kind: e.kind, imp: e.imp, key: e.key, repeats: x.count > 1, routine: routine(e), ended, prep: p ? e.prep : null, prep_done: p ? pd : null, ready: lv,
278
+ kind: e.kind, imp: e.imp, auto: e.auto, mark: e.mark, key: e.key, repeats: x.count > 1, routine: routine(e), ended, prep: p ? e.prep : null, prep_done: p ? pd : null, ready: lv,
231
279
  notes: e.notes || null, open: e.open || null, role: e.role || null, external: e.external === true, attendees: typeof e.attendees === 'number' ? e.attendees : null, with: e.with || null };
232
280
  });
233
281
  if (need && done >= need) level = 'ok';
@@ -253,6 +301,6 @@ function agendaMonth({ memory, brief, month, now = Date.now() } = {}) {
253
301
  }
254
302
 
255
303
  module.exports = {
256
- validateMemory, mergeMemory, agendaMonth, seriesKey, normTitle, kindOf, isImportant, dayLoad,
304
+ validateMemory, mergeMemory, agendaMonth, seriesKey, normTitle, kindOf, isImportant, dayLoad, readMarks, setMark, markOf,
257
305
  MAX_EVENTS, MAX_SPAN_DAYS, KEEP_BACK_DAYS, KEEP_AHEAD_DAYS, FREE_BLOCK, PER_STEP,
258
306
  };
@@ -0,0 +1,131 @@
1
+ 'use strict';
2
+ /* doc-library.js — every tracked markdown file of a workspace, by category, with its initiative and the links between the
3
+ * files (initiative docs-library-graph, PRD R-L1 to R-L5).
4
+ *
5
+ * The links are the knowledge graph's (lib/kg.js through kg-cli `graphFor`): a markdown link, a path in the header, the
6
+ * initiative, the anchor, a decision number. Nothing is inferred from wording. A category is derived from where a file
7
+ * lives, then from its frontmatter type; it is never written into a file. The result is kept in memory per repository
8
+ * state (git HEAD plus the uncommitted markdown files), and the kg cache stays in the runtime state directory. Nothing
9
+ * here writes into the repository or reads the network.
10
+ */
11
+ const fs = require('fs');
12
+ const path = require('path');
13
+ const { execFileSync } = require('child_process');
14
+ const kgCli = require('./kg-cli');
15
+
16
+ /* [key, name] in display order */
17
+ const CATEGORIES = [
18
+ ['spec', 'Specs'], ['ini', 'Initiatives'], ['dec', 'Decisions'], ['res', 'Research & plans'], ['exp', 'Explainers'],
19
+ ['eval', 'Evals'], ['know', 'Knowledge'], ['guide', 'Guides & rules'], ['proto', 'Prototypes'],
20
+ ];
21
+ /* the link kinds, in the order the client numbers them */
22
+ const KINDS = ['link', 'header', 'initiative', 'anchor', 'cites'];
23
+
24
+ const BY_TYPE = { prd: 'spec', initiative: 'ini', adr: 'dec', decision: 'dec', rubric: 'eval', eval: 'eval', verdict: 'eval', explainer: 'exp' };
25
+
26
+ /** The category of one file: where it lives first, then its frontmatter type, then Guides & rules. */
27
+ function categoryOf(rel, type) {
28
+ const p = String(rel || '');
29
+ if (/^prototype\//.test(p)) return 'proto';
30
+ if (/(^|\/)planning\/initiatives\//.test(p)) return 'ini';
31
+ if (/(^|\/)planning\/prd\//.test(p)) return 'spec';
32
+ if (/(^|\/)planning\/evals\//.test(p)) return 'eval';
33
+ if (/(^|\/)planning\/explainers\//.test(p)) return 'exp';
34
+ if (/^okf\/products\/[^/]+\/adr\//.test(p) || /^(DECISIONS|pm-decisions)\.md$/.test(p) || /(^|\/)planning\/decisions-/.test(p)) return 'dec';
35
+ if (/(^|\/)planning\//.test(p)) return 'res';
36
+ if (/^okf\//.test(p)) return 'know';
37
+ const t = String(type || '').trim().toLowerCase();
38
+ return BY_TYPE[t] || 'guide';
39
+ }
40
+
41
+ /** A short name for an initiative title: no "PRD:" prefix, the part before the first dash or colon, at most 44 characters. */
42
+ function shortTitle(title) {
43
+ let t = String(title || '').trim();
44
+ t = t.replace(/^(PRD|Initiative|Chore|Spike|Prototype Brief|Discovery|Incident|Content brief|Feature)\s*(?:[:—–]|\s-\s)\s*/i, '');
45
+ t = t.split(/\s[—–]\s|:\s|\.\s/)[0].trim();
46
+ if (t.length > 44) { const cut = t.slice(0, 43); t = (cut.includes(' ') ? cut.replace(/\s+\S*$/, '') : cut) + '…'; }
47
+ return t ? t[0].toUpperCase() + t.slice(1) : '';
48
+ }
49
+
50
+ /** The kind of one kg edge, or null for a kind the library does not show. */
51
+ function kindOf(e) {
52
+ if (e.type === 'link') return 'link';
53
+ if (e.type === 'initiative') return 'initiative';
54
+ if (e.type === 'anchor') return 'anchor';
55
+ if (e.type === 'cites') return e.typed ? 'header' : 'cites';
56
+ if (e.type === 'frontmatter' || e.type === 'supersedes' || e.type === 'amended_by') return 'header';
57
+ return null;
58
+ }
59
+
60
+ const git = (cwd, args) => execFileSync('git', args, { cwd, encoding: 'utf8', maxBuffer: 256 * 1024 * 1024, stdio: ['ignore', 'pipe', 'pipe'] });
61
+
62
+ /** path → YYYY-MM-DD of its last commit (one git log; merge commits are skipped, their changes are in their parents). */
63
+ function commitDates(root) {
64
+ const out = new Map();
65
+ let text = '';
66
+ try { text = git(root, ['log', '--format=%x01%cs', '--name-only', '--', '*.md']); } catch { return out; }
67
+ let day = null;
68
+ for (const line of text.split('\n')) {
69
+ if (line.startsWith('\x01')) { day = line.slice(1); continue; }
70
+ if (line && day && !out.has(line)) out.set(line, day);
71
+ }
72
+ return out;
73
+ }
74
+
75
+ /** The uncommitted markdown files: their date is the file's own. */
76
+ function dirtyPaths(root) {
77
+ try {
78
+ return new Set(git(root, ['status', '--porcelain', '-z', '--untracked-files=no']).split('\0').filter(Boolean)
79
+ .map((l) => l.slice(3)).filter((p) => /\.md$/.test(p)));
80
+ } catch { return new Set(); }
81
+ }
82
+
83
+ const cache = new Map(); // root -> { key, value }
84
+
85
+ /**
86
+ * The library of one repository.
87
+ * owners: () => Map path → { id, title } — the library's own attribution (server.js initiativeIndex), real initiatives
88
+ * only. It is called only when the repository state changed, because the index reads every planning file.
89
+ * Returns { ok, files: [{path,title,category,date,initiative}], initiatives: {id: {title, short}}, kinds, edges: [[from, to, kind]], categories }.
90
+ * `from` and `to` index `files`; `kind` indexes `kinds`.
91
+ */
92
+ function library(root, { stateDir, owners = () => new Map() } = {}) {
93
+ let key;
94
+ try { key = kgCli.cacheKey(root); } catch (e) { return { ok: false, error: 'this workspace is not a git repository, so there are no tracked files to list' }; }
95
+ const hit = cache.get(root);
96
+ if (hit && hit.key === key) return hit.value;
97
+ const { graph, unreadable } = kgCli.graphFor(root, stateDir ? { stateDir } : {});
98
+ let own = new Map();
99
+ try { own = owners() || new Map(); } catch { /* no attribution: every file has no initiative */ }
100
+ const dates = commitDates(root);
101
+ const dirty = dirtyPaths(root);
102
+ const dayOf = (rel) => {
103
+ if (!dirty.has(rel) && dates.has(rel)) return dates.get(rel);
104
+ try { return fs.statSync(path.join(root, rel)).mtime.toISOString().slice(0, 10); } catch { return dates.get(rel) || null; }
105
+ };
106
+ // An initiative is named by its record's title (planning/initiatives/<id>.md), else by the index's title for it.
107
+ const titleOf = new Map(graph.nodes.map((n) => [n.id, n.title]));
108
+ const named = (o) => String(titleOf.get(`planning/initiatives/${o.id}.md`) || o.title || o.id);
109
+ const initiatives = {};
110
+ const files = graph.nodes.map((n) => {
111
+ const o = own.get(n.id) || null;
112
+ if (o && !initiatives[o.id]) initiatives[o.id] = { title: named(o), short: shortTitle(named(o)) };
113
+ return { path: n.id, title: String(n.title || n.id), category: categoryOf(n.id, n.type), date: dayOf(n.id), initiative: o ? o.id : null };
114
+ });
115
+ const index = new Map(files.map((f, i) => [f.path, i]));
116
+ const seen = new Set();
117
+ const edges = [];
118
+ for (const e of graph.edges) {
119
+ const k = kindOf(e);
120
+ if (!k || e.from === e.to || !index.has(e.from) || !index.has(e.to)) continue;
121
+ const a = index.get(e.from); const b = index.get(e.to); const ki = KINDS.indexOf(k);
122
+ const id = a + '|' + b + '|' + ki;
123
+ if (seen.has(id)) continue;
124
+ seen.add(id); edges.push([a, b, ki]);
125
+ }
126
+ const value = { ok: true, key, files, initiatives, kinds: KINDS, edges, categories: CATEGORIES.map(([k, name]) => ({ key: k, name })), unreadable: unreadable || [] };
127
+ cache.set(root, { key, value });
128
+ return value;
129
+ }
130
+
131
+ module.exports = { CATEGORIES, KINDS, categoryOf, shortTitle, kindOf, library, commitDates };
@@ -11,12 +11,15 @@
11
11
  * | { action: 'tag', id, repo } (repo: a workspace id, or null for none)
12
12
  * readDay(raw, date) → a checked day record, or an empty day when `raw` is not one
13
13
  * planDay({ day, brief, cards, now, endOfDay }) → the plan Today and `pmos proactive goals` show
14
- * itemRepos({ day, brief, placeRepos }) → { itemId: repo } for the brief items that have a repository
14
+ * itemRepos({ day, brief, placeRepos, iniRepos }) → { itemId: repo } for the brief items that have a repository
15
+ * goalRepos({ day, iniRepos }) → { goalId: repo } for the untagged goals whose initiative names one
16
+ * carryOver(prev, date) → a new day holding the goals still open on `prev`
15
17
  *
16
18
  * ONE DAY, REPOSITORY TAGS. The day is the PM's, not a repository's: every repository shows the same goals, brief and
17
19
  * jobs. A goal can carry a repository (`repo`, a workspace id), and so can a channel, project or space (the settings'
18
20
  * `placeRepos`). An item takes the repository of the open goal it is under; failing that, of the place it came from
19
- * (its `where`). An item with neither is personal and shows under every repository.
21
+ * (its `where`). An initiative held by one repository tags the items that name it, and the goals tied to it. An item
22
+ * with none of these is personal and shows under every repository.
20
23
  *
21
24
  * THE JOIN IS CHECKABLE. A brief item or calendar event belongs to a goal when it names the goal's id (the morning-brief
22
25
  * session sets it). Failing that, an item or Today card belongs to the first open goal tied to the same initiative. There
@@ -56,6 +59,13 @@ function readDay(raw, date) {
56
59
  return { date, goals };
57
60
  }
58
61
 
62
+ /** A new day starts with the goals still open on the last day that had any: a goal is the PM's until it is done or
63
+ * dropped, not until midnight. `prev` is that day's record (readDay); a day with its own file never takes these. */
64
+ function carryOver(prev, date) {
65
+ if (!prev || !Array.isArray(prev.goals) || !prev.date || prev.date >= date) return emptyDay(date);
66
+ return { date, goals: prev.goals.filter((g) => g.status === 'open').slice(0, MAX_GOALS).map((g) => ({ ...g, closed_at: null })) };
67
+ }
68
+
59
69
  /** One edit of the day's goals. A refused edit returns the reason and the caller writes nothing. */
60
70
  function edit(day, op, { now = Date.now() } = {}) {
61
71
  if (!day || !DATE_RE.test(String(day.date)) || !Array.isArray(day.goals)) return { ok: false, error: 'no day record to edit' };
@@ -205,8 +215,9 @@ function planDay({ day, brief = null, cards = [], now = Date.now(), endOfDay = E
205
215
  const placeKey = (s) => { const t = String(s || '').trim().toLowerCase().replace(/^#/, ''); const k = /^([a-z][a-z0-9]*)-\d+$/.exec(t); return k ? k[1] : t; };
206
216
  const placeNameKeys = (n) => { const t = String(n || '').trim().toLowerCase().replace(/^#/, ''); return [t, t.split(/[\s·:|]+/)[0]].filter(Boolean); };
207
217
 
208
- /** { itemId: repo } for today's brief items: the repository of the open goal an item is under, else of its place. */
209
- function itemRepos({ day, brief, placeRepos = {} } = {}) {
218
+ /** { itemId: repo } for today's brief items: the repository of the open goal an item is under, else of the initiative
219
+ * it names (`iniRepos`: { initiativeId: repo }, only the ids one repository holds), else of its place. */
220
+ function itemRepos({ day, brief, placeRepos = {}, iniRepos = {} } = {}) {
210
221
  const out = {};
211
222
  if (!brief || !day || brief.date !== day.date) return out;
212
223
  const open = (day.goals || []).filter((g) => g.status === 'open');
@@ -214,7 +225,8 @@ function itemRepos({ day, brief, placeRepos = {} } = {}) {
214
225
  const byIni = (ini) => (ini ? open.find((g) => g.initiative_id === ini) || null : null);
215
226
  for (const x of [...(brief.priorities || []), ...(brief.worth_asking || [])]) {
216
227
  const g = (x.goal && byId.get(x.goal)) || byIni(x.initiative_id);
217
- let r = g ? repoOrNull(g.repo) : null;
228
+ let r = g ? repoOrNull(g.repo) || (g.initiative_id ? repoOrNull(iniRepos[g.initiative_id]) : null) : null;
229
+ if (!r && x.initiative_id) r = repoOrNull(iniRepos[x.initiative_id]);
218
230
  if (!r && x.where) {
219
231
  const want = placeKey(x.where);
220
232
  for (const [name, repo] of Object.entries(placeRepos[x.source] || {})) if (placeNameKeys(name).includes(want) && repoOrNull(repo)) { r = repo; break; }
@@ -224,4 +236,11 @@ function itemRepos({ day, brief, placeRepos = {} } = {}) {
224
236
  return out;
225
237
  }
226
238
 
227
- module.exports = { MAX_GOALS, MAX_TEXT, STATUSES, ACTIONS, GOAL_ID_RE, START_OF_DAY, END_OF_DAY, emptyDay, readDay, edit, freeTime, planDay, itemRepos };
239
+ /** { goalId: repo } for the goals with no repository of their own whose initiative one repository holds. */
240
+ function goalRepos({ day, iniRepos = {} } = {}) {
241
+ const out = {};
242
+ for (const g of (day && day.goals) || []) if (!g.repo && g.initiative_id && repoOrNull(iniRepos[g.initiative_id])) out[g.id] = iniRepos[g.initiative_id];
243
+ return out;
244
+ }
245
+
246
+ module.exports = { MAX_GOALS, MAX_TEXT, STATUSES, ACTIONS, GOAL_ID_RE, START_OF_DAY, END_OF_DAY, emptyDay, readDay, carryOver, edit, freeTime, planDay, itemRepos, goalRepos };
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.65.0",
3
+ "version": "0.65.2",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.65.0",
3
+ "version": "0.65.2",
4
4
  "private": true,
5
5
  "description": "PMOS Terminal Workspace — local companion hosting real PTYs beside the library and gates (D62)",
6
6
  "license": "MIT",