create-pmos 0.116.0 → 0.117.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-pmos",
3
- "version": "0.116.0",
3
+ "version": "0.117.0",
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@affb04c
1
+ kernel-source: pmos@fc4f107
2
2
  assembled: 2026-10-08
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-08T06:17:49Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-08T06:49:50Z"}
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-08T06:17:49Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-08T06:49:50Z"}
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-08T06:17:49Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-08T06:49:50Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -16,7 +16,7 @@ Above the cards, Today shows the day in two columns.
16
16
 
17
17
  Press **Prepare my day** to start the day. It runs from the brief to the delivery of each agent job, and you do not press anything else until a draft is ready for you. The page [Guide: Today and the proactive engine](proactive-engine.md) describes each step.
18
18
 
19
- The bar at the top of the day is one line. It shows the greeting, the date and how many goals are unfinished. While **Prepare my day** runs, it also shows the four steps (Read, Sort, Agents work, You approve) as a small progress bar; press a step to see its detail. When nothing runs, it says when the day was last prepared. **Sources** and **Check now** are icon buttons in the bar.
19
+ The bar at the top of the day is one line. It shows the greeting, the date and **Prepare my day** (**Prepare again** when the day is prepared). While **Prepare my day** runs, it also shows the four steps (Read, Sort, Agents work, You approve) as a small progress bar; press a step to see its detail. The **⋯** menu holds the rest: when the day was last prepared, how many agent jobs are in each state, the next daytime check, **Check now**, **Settings** (the sources) and, when Today shows one repository, **Show items with no repository**.
20
20
 
21
21
  If the morning-brief session stops before it submits a brief, the Read step says so and gives the reason. **Prepare my day** is then ready again, so press it to try once more. If the session says that its folder is not there, open the repository again from its current folder first.
22
22
 
@@ -32,27 +32,20 @@ An agent job can stop with its session still open, for example when the agent as
32
32
 
33
33
  Each job session is named "Job ·" and the first words of the job, so the session list stays short.
34
34
 
35
- The left column shows one section at a time. A row of tabs at the top chooses it: **Agenda**, **Goals**, **Jobs**, **Mentions** and **Result**. Agenda is the first one shown. The column remembers your last choice in this browser. When you select a job, a goal or a mention, the column goes to its tab. The Agenda tab holds the month. **Result** counts the jobs and lists each finished one with its draft summary or the reason it failed. The **Goals** tab holds your goals for today. 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.
35
+ The left column shows one section at a time. A row of tabs at the top chooses it: **Agenda**, **Goals**, **Jobs** and **Mentions**. Agenda is the first one shown. The column remembers your last choice in this browser. When you select a job, a goal or a mention, the column goes to its tab. The Agenda tab holds the day's meetings and, on a press, the month. A finished job shows its draft summary, or the reason it failed, inside the job. The **Goals** tab holds your goals, and each goal lists its jobs (see [Goals own jobs](#goals-own-jobs)). 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.
36
36
 
37
37
  ### The Agenda month
38
38
 
39
- The menu calls this page **Agenda**. At the top of the left column is the month, with today marked. When a day shows no meetings yet, press **Prepare my day** (or **Prepare again**): the morning-brief session reads your calendar and fills the month. Each week starts with its number, for example W41.
39
+ The menu calls this page **Agenda**. It opens on the meetings of the day, with the arrows to go to another day. The month is hidden. Press **Calendar** to show it across the full width, and **Hide calendar** to put it away. Picking a day closes the month and shows that day's meetings. The page does not remember the day you picked or whether the month was open. When a day shows no meetings yet, press **Prepare my day** (or **Prepare again**): the morning-brief session reads your calendar and fills the month. Each week starts with its number, for example W41.
40
40
 
41
- - Press a day to see its agenda under the month. Three tiles open the agenda: the time in meetings, the meeting that matters most, and your prep.
42
41
  - Press a week number to see the agenda of that week, day by day.
43
42
  - Press a meeting to see its memory in the right column. The month marks every day of the same meeting, so you see when it happens again.
44
43
 
45
44
  Meetings that repeat and have nothing for you fold into one line, for example "+4 repeating, nothing for you". Press it to see them.
46
45
 
47
- Each day of the month answers three questions:
46
+ A day in the month holds three things: the day number, a tint that is darker when the day has more meetings, and a ★ when the day has a key meeting. A meeting matters when you present or decide, when people from outside the company or leadership join, or when 8 people or more join. Meeting titles, the prep badge and the notes count are in the day's agenda, not in the month.
48
47
 
49
- - **How full is the day?** The bar covers 09:00 to 18:00. Each block is a meeting. A dark block is an important meeting.
50
- - **What matters?** The ★ line names the one meeting of the day that matters. A meeting matters when you present or decide, when people from outside the company or leadership join, or when 8 people or more join. A meeting whose prep is at risk also shows.
51
- - **How ready are you?** The badge shows the prep steps done out of the steps needed, for example 1/3. It is red when the free time before the meeting is shorter than 30 minutes for each step left. A past day shows how many one-to-ones and important meetings have no notes.
52
-
53
- 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).
54
-
55
- A meeting's detail shows one focus. Under the title is one line: the time, how many people join, and a star when the meeting is key. Under it are the goal of the meeting and, for a meeting with prep, one line of progress (the steps done, a bar, the free minutes before the meeting). Only the first open step is expanded, with what its source says, its link and, for a note step, a box for your note. The one primary button follows it: **I have read it**, **Mark done** or **Done**, then **Mark ready** when every step is done, then **Take notes** once the meeting has started. Press the arrow at the end of another step to open it. **More** holds the rare presses: **Mark ready** while steps are left, **Remind me**, **Skip prep**, the key mark and **Add as a goal for today**. **Details** holds the people, the kind of meeting and how often it met, the initiative, the goal it counts toward, During and After, the mail and the links.
48
+ A meeting's detail shows one focus. Under the title is one line: the time, how many people join, and a star when the meeting is key. Under it are the goal of the meeting and, for a meeting with prep, one line of progress (the steps done, a bar, the free minutes before the meeting). Only the first open step is expanded, with what its source says, its link and, for a note step, a box for your note. The one primary button follows it: **I have read it**, **Mark done** or **Done**, then **Mark ready** when every step is done, then **Take notes** once the meeting has started. Press the arrow at the end of another step to open it. **More** holds the rare presses: **Mark ready** while steps are left, **Remind me**, **Skip prep**, the key mark and **Add as a goal for today**. **Details** holds the people, the kind of meeting and how often it met, the initiative, During and After, the mail and the links. When the meeting has a goal, the detail also shows what the goal is done when and the goal's jobs, with the buttons to add a job and to link one.
56
49
 
57
50
  A meeting's detail ends with **Meeting notes**. Press **Take notes** to start a note linked to the meeting, and to its initiative when the brief names one. PMOS starts to listen at once and keeps the note small at the bottom right. Press it to open the note as a pop-up. When the meeting has a note, the detail shows its summary and **Open notes**, which opens it in the pop-up, and the meeting's row shows **notes**. See [Notes](notes.md).
58
51
 
@@ -92,6 +85,22 @@ Each job has one of three kinds.
92
85
 
93
86
  A draft that waits for you shows its summary, its text and the link of the place it is for. **Approve** sends only that text, to that place. **Revise** takes a note of at most 500 characters and asks for a new draft. You can revise a job 3 times. A fourth revise is refused, so do the job yourself or drop it. A failed job shows its reason and **Retry**.
94
87
 
88
+ ### Goals own jobs
89
+
90
+ A goal is something you want to finish. Add one in the **Goals** tab: type it and press Enter. **Add context** is optional and takes three fields: what is true when the goal is done (**done when**), what the goal serves (a key result or a health budget), and when it is due. A goal with no **done when** shows **Add done when**.
91
+
92
+ Each goal lists its jobs. A job belongs to one goal at most, and a job can have no goal. A job comes from a step of the goal, from preparing, from a mention, or from you. Its detail names where it came from and has a **Goal** list: pick a goal to link the job, or **No goal** to unlink it. The **Goals** tab ends with **Jobs without a goal**, each with the same list. To add a job to a goal, type it in the goal and press **Add job**. A job you add this way is yours: you check it and remove it, and no agent works on it.
93
+
94
+ The goal row shows how many jobs are done, for example "2 of 4 jobs done". That is information only. A goal is never closed for you. Press **Done** when the goal is finished, or **Drop** to give it up. **Add as a goal** in a meeting's **More** menu makes a goal from the meeting.
95
+
96
+ ### Who acts on a job
97
+
98
+ Each job says who acts: **Waiting on you**, **Agent is working** or **Agent finished**. A filled square marks the agent and an open square marks you. Every button on a job has a small **YOU** or **AGENT** tag. A button with **AGENT** starts the agent in a session, for example **Have the agent draft it**, **Revise** and **Retry**. A button with **YOU** is a press that you do yourself, for example **Approve** and **Mark done**. When a job has a session, whoever acted last, **Open session** opens it. A finished job shows its result inside the job.
99
+
100
+ ### Mentions
101
+
102
+ The **Mentions** tab lists what people asked of you. A mention from Slack, Confluence or Figma shows as a card in the layout of that service: a Slack message with the sender and the text; a Confluence comment with the page and, when the brief has one, the passage it is about; a Figma comment with the file it is on. The card shows only what the brief has. It never invents a reaction, a reply count or a picture. Press a mention to open its job. **Open in …** opens the conversation in the service. Nothing in PMOS posts to Slack, Confluence or Figma, resolves a comment or reacts. Mentions from other places show as a plain row.
103
+
95
104
  ### Jobs you do and meetings you prepare for
96
105
 
97
106
  A job that only you can do starts with a verb, such as **Reply**, **Review** or **Sign off**, and one sentence that says what to do. Under the sentence are who asked, why it is for today, and the steps. Check each step when you finish it. The first button opens the place where you do the job, for example **Reply in Slack**. **Have the agent draft it** gives the job to an agent. The draft then waits for your approval, as for any approve job.
@@ -173,10 +173,10 @@ function eventExtras(e, at, sources) {
173
173
  function itemExtras(x, at) {
174
174
  const f = {};
175
175
  const err = optLink(x.link, `${at}: link`) || optText(x.where, 120, `${at}: where`) || optText(x.who, 120, `${at}: who`) || optText(x.about, 480, `${at}: about`)
176
- || optText(x.ask, 240, `${at}: ask`) || optText(x.how, 480, `${at}: how`);
176
+ || optText(x.ask, 240, `${at}: ask`) || optText(x.quote, 280, `${at}: quote`) || optText(x.how, 480, `${at}: how`);
177
177
  if (err) return { error: err };
178
178
  if (x.link) f.link = x.link;
179
- for (const [k, n] of [['where', 120], ['who', 120], ['about', 480], ['ask', 240], ['how', 480]]) if (typeof x[k] === 'string' && x[k].trim()) f[k] = clip(x[k], n);
179
+ for (const [k, n] of [['where', 120], ['who', 120], ['about', 480], ['ask', 240], ['quote', 280], ['how', 480]]) if (typeof x[k] === 'string' && x[k].trim()) f[k] = clip(x[k], n);
180
180
  if (x.reply_needed !== undefined && x.reply_needed !== null) {
181
181
  if (typeof x.reply_needed !== 'boolean') return { error: `${at}: reply_needed must be true or false` };
182
182
  f.reply_needed = x.reply_needed;
@@ -19,18 +19,24 @@
19
19
  * | { action: 'step', id, step, done } step: the step's text, done: true | false
20
20
  * | { action: 'note', id, text } the PM's own note, written in the card (at most 1000
21
21
  * characters; empty clears it). It stays on this machine.
22
+ * | { action: 'link', id, goal } goal: the id of one of today's open goals, or null for "no goal".
23
+ * It overrides the goal the item or task came with (initiative
24
+ * today-goals-jobs, PRD L1). An item or a task, never a meeting.
22
25
  * | { action: 'restore', id, mark } mark: a `prev` an earlier edit returned, or null (Undo)
23
- * known: Map of today's ids → 'item' | 'event'. An id not in it is refused.
26
+ * known: Map of today's ids → 'item' | 'event' | 'task'. An id not in it is refused.
27
+ * goals: Set of the ids of today's open goals, for `link`.
24
28
  * effective(rec, now) → { id: mark } with a snooze or hold whose time has passed read as open
29
+ * links(rec) → { id: goalId | '' } for the marks that link a job ('' is "no goal")
25
30
  *
26
31
  * A mark lasts for its day: brief item ids are per day (lib/brief.js), so a snooze or a hold ends at midnight.
27
32
  */
28
33
 
29
34
  const STATES = ['done', 'snoozed', 'waiting', 'dismissed'];
30
- const ACTIONS = ['done', 'reopen', 'snooze', 'hold', 'dismiss', 'step', 'note', 'restore'];
31
- const KINDS = ['item', 'event'];
35
+ const ACTIONS = ['done', 'reopen', 'snooze', 'hold', 'dismiss', 'step', 'note', 'link', 'restore'];
36
+ const KINDS = ['item', 'event', 'task'];
37
+ const GOAL_ID_RE = /^g-[0-9a-f]{8}$/;
32
38
  const REASONS = { item: ['no-action', 'someone-else', 'done-elsewhere'], event: ['nothing', 'elsewhere'] };
33
- const ID_RE = /^[0-9a-f]{12}$/;
39
+ const ID_RE = /^(t-)?[0-9a-f]{12}$/;
34
40
  const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
35
41
  const MAX_IDS = 400;
36
42
  const MAX_STEPS = 12;
@@ -57,7 +63,8 @@ function cleanMark(m, date) {
57
63
  out.until = iso(t);
58
64
  }
59
65
  if (state === 'waiting') { const on = clip(m.on, MAX_ON); if (!on) return null; out.on = on; }
60
- if (state === 'dismissed') { if (!REASONS[m.kind].includes(m.why)) return null; out.why = m.why; }
66
+ if (state === 'dismissed') { if (!(REASONS[m.kind] || []).includes(m.why)) return null; out.why = m.why; }
67
+ if (m.kind !== 'event' && (m.goal === '' || GOAL_ID_RE.test(String(m.goal)))) out.goal = m.goal;
61
68
  const steps = Array.isArray(m.steps) ? [...new Set(m.steps.map((s) => clip(s, MAX_STEP)).filter(Boolean))].slice(0, MAX_STEPS) : [];
62
69
  if (steps.length) out.steps = steps;
63
70
  if (typeof m.note === 'string' && m.note.trim()) out.note = m.note.trim().slice(0, MAX_NOTE);
@@ -85,7 +92,7 @@ function laterToday(v, now, date) {
85
92
  return { at: t };
86
93
  }
87
94
 
88
- function edit(rec, op, { now = Date.now(), known } = {}) {
95
+ function edit(rec, op, { now = Date.now(), known, goals } = {}) {
89
96
  if (!rec || !DATE_RE.test(String(rec.date)) || !rec.marks || typeof rec.marks !== 'object') return { ok: false, error: 'no marks record to edit' };
90
97
  if (!op || typeof op !== 'object' || !ACTIONS.includes(op.action)) return { ok: false, error: `action must be one of ${ACTIONS.join(', ')}` };
91
98
  const id = String(op.id || '');
@@ -93,6 +100,7 @@ function edit(rec, op, { now = Date.now(), known } = {}) {
93
100
  const kind = known && typeof known.get === 'function' ? known.get(id) : null;
94
101
  if (!KINDS.includes(kind)) return { ok: false, error: 'no item or meeting with that id in today\'s brief' };
95
102
  if (localDate(now) !== rec.date) return { ok: false, error: `the marks are for ${rec.date}, and today is ${localDate(now)}` };
103
+ if (kind === 'task' && !['link', 'restore'].includes(op.action)) return { ok: false, error: 'only a link is kept for a task' };
96
104
  const marks = { ...rec.marks };
97
105
  const prev = marks[id] ? JSON.parse(JSON.stringify(marks[id])) : null;
98
106
  const cur = prev ? { ...prev } : { kind, state: null, at: null };
@@ -110,7 +118,12 @@ function edit(rec, op, { now = Date.now(), known } = {}) {
110
118
  marks[id] = c;
111
119
  return { ok: true, rec: { date: rec.date, marks }, mark: c, prev };
112
120
  }
113
- if (op.action === 'step') {
121
+ if (op.action === 'link') {
122
+ if (kind === 'event') return { ok: false, error: 'a meeting is not linked to a goal here: make the meeting a goal instead' };
123
+ const goal = op.goal === null || op.goal === undefined || op.goal === '' ? '' : String(op.goal);
124
+ if (goal && (!GOAL_ID_RE.test(goal) || !(goals && typeof goals.has === 'function' && goals.has(goal)))) return { ok: false, error: 'goal must be the id of an open goal of today, or null' };
125
+ cur.goal = goal;
126
+ } else if (op.action === 'step') {
114
127
  const step = clip(op.step, MAX_STEP);
115
128
  if (!step) return { ok: false, error: 'step must be the text of a step' };
116
129
  if (typeof op.done !== 'boolean') return { ok: false, error: 'done must be true or false' };
@@ -146,7 +159,7 @@ function edit(rec, op, { now = Date.now(), known } = {}) {
146
159
  }
147
160
  if (!marks[id] && Object.keys(marks).length >= MAX_IDS) return { ok: false, error: `at most ${MAX_IDS} marks a day` };
148
161
  const clean = cleanMark(cur, rec.date);
149
- if (clean.state === null && !clean.steps && !clean.note) delete marks[id]; else marks[id] = clean;
162
+ if (clean.state === null && !clean.steps && !clean.note && clean.goal === undefined) delete marks[id]; else marks[id] = clean;
150
163
  return { ok: true, rec: { date: rec.date, marks }, mark: marks[id] || null, prev };
151
164
  }
152
165
 
@@ -160,4 +173,11 @@ function effective(rec, now = Date.now()) {
160
173
  return out;
161
174
  }
162
175
 
163
- module.exports = { STATES, ACTIONS, REASONS, MAX_ON, MAX_STEPS, MAX_NOTE, emptyMarks, readMarks, edit, effective };
176
+ /** { id: goalId | '' } for the marks that link a job to a goal (or, with '', unlink it from the goal it came with). */
177
+ function links(rec) {
178
+ const out = {};
179
+ for (const [id, m] of Object.entries((rec && rec.marks) || {})) if (m.goal !== undefined) out[id] = m.goal;
180
+ return out;
181
+ }
182
+
183
+ module.exports = { STATES, ACTIONS, REASONS, MAX_ON, MAX_STEPS, MAX_NOTE, emptyMarks, readMarks, edit, effective, links };
@@ -35,7 +35,14 @@ const crypto = require('crypto');
35
35
  const MAX_GOALS = 7;
36
36
  const MAX_TEXT = 140;
37
37
  const STATUSES = ['open', 'done', 'dropped'];
38
- const ACTIONS = ['add', 'done', 'drop', 'reopen', 'remove', 'tag'];
38
+ const ACTIONS = ['add', 'done', 'drop', 'reopen', 'remove', 'tag', 'context', 'job-add', 'job-done', 'job-reopen', 'job-remove'];
39
+ const MAX_JOBS = 12; // jobs the PM types on one goal (initiative today-goals-jobs, PRD G3)
40
+ const MAX_JOB = 140;
41
+ const MAX_DONE_WHEN = 200;
42
+ const MAX_ANCHOR = 80;
43
+ const MAX_DUE = 40;
44
+ const JOB_ID_RE = /^j-[0-9a-f]{8}$/;
45
+ const EVENT_ID_RE = /^[0-9a-f]{12}$/;
39
46
  const REPO_RE = /^[A-Za-z0-9._-]{1,80}$/;
40
47
  const repoOrNull = (r) => (typeof r === 'string' && REPO_RE.test(r) ? r : null);
41
48
  const INI_RE = /^[a-z0-9][a-z0-9._-]*$/i;
@@ -47,6 +54,22 @@ const clip = (s, n) => String(s == null ? '' : s).replace(/\s+/g, ' ').trim().sl
47
54
 
48
55
  const emptyDay = (date) => ({ date, goals: [] });
49
56
 
57
+ /** The jobs the PM typed on a goal, checked: { id, text, done, added_at }. */
58
+ function cleanJobs(raw) {
59
+ if (!Array.isArray(raw)) return [];
60
+ return raw.filter((j) => j && typeof j === 'object' && JOB_ID_RE.test(j.id) && typeof j.text === 'string' && j.text.trim()).slice(0, MAX_JOBS)
61
+ .map((j) => ({ id: j.id, text: clip(j.text, MAX_JOB), done: j.done === true, added_at: typeof j.added_at === 'string' ? j.added_at : null }));
62
+ }
63
+ /** The optional context of a goal (PRD G1): what finished looks like, what it serves, when, and the meeting it came from. */
64
+ function cleanContext(g) {
65
+ const out = {};
66
+ const dw = clip(g.done_when, MAX_DONE_WHEN); if (dw) out.done_when = dw;
67
+ const an = clip(g.anchor, MAX_ANCHOR); if (an) out.anchor = an;
68
+ const du = clip(g.due, MAX_DUE); if (du) out.due = du;
69
+ if (typeof g.event_id === 'string' && EVENT_ID_RE.test(g.event_id)) out.event_id = g.event_id;
70
+ return out;
71
+ }
72
+
50
73
  /** A day record read from disk, checked. Anything that is not a day record for `date` reads as an empty day. */
51
74
  function readDay(raw, date) {
52
75
  if (!raw || typeof raw !== 'object' || raw.date !== date || !Array.isArray(raw.goals)) return emptyDay(date);
@@ -54,7 +77,7 @@ function readDay(raw, date) {
54
77
  && STATUSES.includes(g.status)).slice(0, MAX_GOALS).map((g) => ({
55
78
  id: g.id, text: clip(g.text, MAX_TEXT), initiative_id: typeof g.initiative_id === 'string' && INI_RE.test(g.initiative_id) ? g.initiative_id : null,
56
79
  status: g.status, added_at: typeof g.added_at === 'string' ? g.added_at : null, closed_at: typeof g.closed_at === 'string' ? g.closed_at : null,
57
- repo: repoOrNull(g.repo),
80
+ repo: repoOrNull(g.repo), ...cleanContext(g), jobs: cleanJobs(g.jobs),
58
81
  }));
59
82
  return { date, goals };
60
83
  }
@@ -63,7 +86,7 @@ function readDay(raw, date) {
63
86
  * dropped, not until midnight. `prev` is that day's record (readDay); a day with its own file never takes these. */
64
87
  function carryOver(prev, date) {
65
88
  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 })) };
89
+ return { date, goals: prev.goals.filter((g) => g.status === 'open').slice(0, MAX_GOALS).map((g) => ({ ...g, closed_at: null, jobs: cleanJobs(g.jobs).filter((j) => !j.done) })) };
67
90
  }
68
91
 
69
92
  /** One edit of the day's goals. A refused edit returns the reason and the caller writes nothing. */
@@ -79,10 +102,14 @@ function edit(day, op, { now = Date.now() } = {}) {
79
102
  const ini = op.initiative_id == null || op.initiative_id === '' ? null : op.initiative_id;
80
103
  if (ini !== null && (typeof ini !== 'string' || !INI_RE.test(ini))) return { ok: false, error: 'initiative_id is not an initiative id' };
81
104
  if (op.repo != null && op.repo !== '' && !repoOrNull(op.repo)) return { ok: false, error: 'repo is not a workspace id' };
105
+ for (const [k, n] of [['done_when', MAX_DONE_WHEN], ['anchor', MAX_ANCHOR], ['due', MAX_DUE]]) {
106
+ if (op[k] != null && (typeof op[k] !== 'string' || op[k].trim().length > n)) return { ok: false, error: `${k} is text of at most ${n} characters` };
107
+ }
108
+ if (op.event_id != null && op.event_id !== '' && !EVENT_ID_RE.test(String(op.event_id))) return { ok: false, error: 'event_id is not a meeting id' };
82
109
  const text = clip(op.text, MAX_TEXT);
83
110
  if (goals.some((g) => g.text.toLowerCase() === text.toLowerCase())) return { ok: false, error: 'that goal is already on the list' };
84
111
  const id = 'g-' + crypto.createHash('sha1').update(`${day.date}|${text}|${at}|${goals.length}`).digest('hex').slice(0, 8);
85
- goals.push({ id, text, initiative_id: ini, status: 'open', added_at: at, closed_at: null, repo: repoOrNull(op.repo) });
112
+ goals.push({ id, text, initiative_id: ini, status: 'open', added_at: at, closed_at: null, repo: repoOrNull(op.repo), ...cleanContext(op), jobs: [] });
86
113
  return { ok: true, day: { date: day.date, goals }, goal: goals[goals.length - 1] };
87
114
  }
88
115
  const i = goals.findIndex((g) => g.id === op.id);
@@ -94,6 +121,34 @@ function edit(day, op, { now = Date.now() } = {}) {
94
121
  g.repo = repoOrNull(op.repo);
95
122
  return { ok: true, day: { date: day.date, goals }, goal: g };
96
123
  }
124
+ if (op.action === 'context') {
125
+ for (const [k, n] of [['done_when', MAX_DONE_WHEN], ['anchor', MAX_ANCHOR], ['due', MAX_DUE]]) {
126
+ if (op[k] === undefined) continue;
127
+ if (typeof op[k] !== 'string' || op[k].trim().length > n) return { ok: false, error: `${k} is text of at most ${n} characters` };
128
+ const v = clip(op[k], n); if (v) g[k] = v; else delete g[k];
129
+ }
130
+ return { ok: true, day: { date: day.date, goals }, goal: g };
131
+ }
132
+ if (op.action.startsWith('job-')) {
133
+ g.jobs = cleanJobs(g.jobs);
134
+ if (op.action === 'job-add') {
135
+ if (typeof op.text !== 'string' || !op.text.trim()) return { ok: false, error: 'a job needs text' };
136
+ if (op.text.trim().length > MAX_JOB) return { ok: false, error: `a job is at most ${MAX_JOB} characters` };
137
+ if (g.jobs.length >= MAX_JOBS) return { ok: false, error: `a goal has at most ${MAX_JOBS} jobs you typed` };
138
+ const text = clip(op.text, MAX_JOB);
139
+ if (g.jobs.some((j) => j.text.toLowerCase() === text.toLowerCase())) return { ok: false, error: 'that job is already on this goal' };
140
+ const id = 'j-' + crypto.createHash('sha1').update(`${g.id}|${text}|${at}|${g.jobs.length}`).digest('hex').slice(0, 8);
141
+ g.jobs.push({ id, text, done: false, added_at: at });
142
+ return { ok: true, day: { date: day.date, goals }, goal: g, job: g.jobs[g.jobs.length - 1] };
143
+ }
144
+ const j = g.jobs.find((x) => x.id === op.job);
145
+ if (!j) return { ok: false, error: 'no job with that id on this goal' };
146
+ if (op.action === 'job-remove') { g.jobs = g.jobs.filter((x) => x !== j); return { ok: true, day: { date: day.date, goals }, goal: g, job: j }; }
147
+ const want = op.action === 'job-done';
148
+ if (j.done === want) return { ok: false, error: `that job is already ${want ? 'done' : 'open'}` };
149
+ j.done = want;
150
+ return { ok: true, day: { date: day.date, goals }, goal: g, job: j };
151
+ }
97
152
  const to = { done: 'done', drop: 'dropped', reopen: 'open' }[op.action];
98
153
  if (g.status === to) return { ok: false, error: `that goal is already ${to}` };
99
154
  g.status = to; g.closed_at = to === 'open' ? null : at;
@@ -127,6 +182,9 @@ function freeTime(events, at, endOfDay = END_OF_DAY) {
127
182
  return { free_minutes: Math.max(0, Math.round((end - now - busy) / 60000)), until: endOfDay, next: upcoming ? { start: upcoming.start, title: upcoming.title } : null };
128
183
  }
129
184
 
185
+ /** The goal id an item is under: the PM's link mark when there is one ('' is "no goal"), else the item's own. */
186
+ const goalOf = (x, links) => (links && Object.prototype.hasOwnProperty.call(links, x.id) ? links[x.id] || null : x.goal || null);
187
+
130
188
  const stepOrder = (a, b) => (a.owner === b.owner ? 0 : a.owner === 'pm' ? -1 : 1) || (b.confidence - a.confidence) || a.title.localeCompare(b.title);
131
189
 
132
190
  // The context a job carries (G18) rides on its step unchanged, so Today and the CLI show what the brief said.
@@ -153,7 +211,7 @@ function cardStep(c) {
153
211
  * now ms; the clock the server passes
154
212
  * A brief for another day is not joined: it is named as out of date, and only the Today cards plan the goals.
155
213
  */
156
- function planDay({ day, brief = null, cards = [], now = Date.now(), endOfDay = END_OF_DAY } = {}) {
214
+ function planDay({ day, brief = null, cards = [], now = Date.now(), endOfDay = END_OF_DAY, links = {} } = {}) {
157
215
  const date = day && day.date;
158
216
  const fresh = !!brief && brief.date === date;
159
217
  const items = fresh ? [...(brief.priorities || []), ...(brief.worth_asking || [])] : [];
@@ -168,8 +226,8 @@ function planDay({ day, brief = null, cards = [], now = Date.now(), endOfDay = E
168
226
  // G9: a step goes only under an OPEN goal. A done or dropped goal draws no steps, so an item that names one is
169
227
  // placed as if it named no goal (by initiative, else "also" or "worth asking"); it is never lost under a closed goal.
170
228
  for (const x of items) {
171
- const g = (x.goal && openById.get(x.goal)) || byIni(x.initiative_id);
172
- if (g) g.steps.push(itemStep(x)); else unplaced.items.push(x);
229
+ const g = (goalOf(x, links) && openById.get(goalOf(x, links))) || byIni(x.initiative_id);
230
+ if (g) g.steps.push(itemStep({ ...x, goal: goalOf(x, links) })); else unplaced.items.push(x);
173
231
  }
174
232
  for (const c of cards || []) {
175
233
  const g = byIni(c.initiative_id);
@@ -217,14 +275,14 @@ const placeNameKeys = (n) => { const t = String(n || '').trim().toLowerCase().re
217
275
 
218
276
  /** { itemId: repo } for today's brief items: the repository of the open goal an item is under, else of the initiative
219
277
  * it names (`iniRepos`: { initiativeId: repo }, only the ids one repository holds), else of its place. */
220
- function itemRepos({ day, brief, placeRepos = {}, iniRepos = {} } = {}) {
278
+ function itemRepos({ day, brief, placeRepos = {}, iniRepos = {}, links = {} } = {}) {
221
279
  const out = {};
222
280
  if (!brief || !day || brief.date !== day.date) return out;
223
281
  const open = (day.goals || []).filter((g) => g.status === 'open');
224
282
  const byId = new Map(open.map((g) => [g.id, g]));
225
283
  const byIni = (ini) => (ini ? open.find((g) => g.initiative_id === ini) || null : null);
226
284
  for (const x of [...(brief.priorities || []), ...(brief.worth_asking || [])]) {
227
- const g = (x.goal && byId.get(x.goal)) || byIni(x.initiative_id);
285
+ const g = (goalOf(x, links) && byId.get(goalOf(x, links))) || byIni(x.initiative_id);
228
286
  let r = g ? repoOrNull(g.repo) || (g.initiative_id ? repoOrNull(iniRepos[g.initiative_id]) : null) : null;
229
287
  if (!r && x.initiative_id) r = repoOrNull(iniRepos[x.initiative_id]);
230
288
  if (!r && x.where) {
@@ -243,4 +301,4 @@ function goalRepos({ day, iniRepos = {} } = {}) {
243
301
  return out;
244
302
  }
245
303
 
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 };
304
+ module.exports = { MAX_GOALS, MAX_TEXT, MAX_JOBS, MAX_JOB, MAX_DONE_WHEN, MAX_ANCHOR, MAX_DUE, STATUSES, ACTIONS, GOAL_ID_RE, START_OF_DAY, END_OF_DAY, emptyDay, readDay, carryOver, edit, freeTime, planDay, itemRepos, goalRepos };
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.116.0",
3
+ "version": "0.117.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "pmos-workspace",
9
- "version": "0.116.0",
9
+ "version": "0.117.0",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "@xterm/addon-fit": "^0.11.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.116.0",
3
+ "version": "0.117.0",
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",
@@ -3499,6 +3499,7 @@
3499
3499
  .td-day__ts[data-step-state="done"] .td-day__seg { background: currentColor; }
3500
3500
  .td-day__ts[data-step-state="now"] .td-day__seg { background: var(--foreground); animation: td-day-pulse 1.2s infinite; }
3501
3501
  @media (prefers-reduced-motion: reduce) { .td-day__ts[data-step-state="now"] .td-day__seg { animation: none; } }
3502
+ .td-day__spacer { flex: 1 1 0; align-self: stretch; min-width: 0; }
3502
3503
  .td-day__status { flex: 1 1 0; min-width: 80px; text-align: right; font-size: 12px; color: var(--muted-foreground); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
3503
3504
  .td-day__check { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; color: var(--muted-foreground); }
3504
3505
  .td-day__run { margin: 0; font-size: 12px; line-height: 16px; color: var(--foreground); }
@@ -3549,6 +3550,19 @@
3549
3550
  .td-day__group { display: grid; gap: 2px; }
3550
3551
  .td-day__newdot { display: inline-block; flex: none; width: 7px; height: 7px; border-radius: 50%; background: var(--warning); }
3551
3552
  .td-day__chip { min-width: 84px; padding: 3px 8px; font: 500 11px/1.2 var(--mono); text-align: center; background: var(--muted); color: var(--foreground); }
3553
+ /* a mention drawn as a card of its source (initiative today-goals-jobs, PRD M1): layout only, no brand colour */
3554
+ .td-day__mc { display: grid; gap: 4px; min-width: 0; text-align: left; font-weight: 400; }
3555
+ .td-day__mc > span { display: block; min-width: 0; }
3556
+ .td-day__mc-head { display: inline-flex; align-items: center; gap: 6px; font: 11px var(--mono); letter-spacing: 0.04em; color: var(--muted-foreground); }
3557
+ .td-day__mc-main { display: flex; align-items: flex-start; gap: 8px; }
3558
+ .td-day__av { display: grid; place-items: center; flex: none; width: 32px; height: 32px; background: var(--muted); color: var(--foreground); font: 600 12px var(--mono); }
3559
+ .td-day__mc-col { display: grid; gap: 2px; min-width: 0; }
3560
+ .td-day__mc-name { font-size: 14px; line-height: 20px; font-weight: 600; color: var(--foreground); }
3561
+ .td-day__mc-time { margin-left: 6px; font: 12px var(--mono); color: var(--muted-foreground); }
3562
+ .td-day__mc-text { font-size: 14px; line-height: 20px; color: var(--foreground); overflow-wrap: anywhere; }
3563
+ .td-day__mc-quote { padding: 2px 8px; border-left: 3px solid var(--border); font-size: 12px; line-height: 16px; color: var(--muted-foreground); overflow-wrap: anywhere; }
3564
+ .td-day__mc-frame { display: grid; place-items: center; flex: none; width: 64px; height: 44px; border: 1px solid var(--border); background: var(--muted); color: var(--foreground); }
3565
+ .td-day__mcbox { padding: 8px; border: 1px solid var(--border); }
3552
3566
 
3553
3567
  /* the detail */
3554
3568
  .td-day__dhead { display: grid; gap: 4px; }
@@ -3659,33 +3673,32 @@
3659
3673
  /* the Agenda month (today-agenda.js, G25 to G27): weeks of 8 columns, the week number and seven day cells */
3660
3674
  .td-ag__mhead { display: flex; align-items: center; gap: 6px; }
3661
3675
  .td-ag__mlabel { min-width: 120px; font-size: 14px; text-align: center; color: var(--foreground); }
3662
- .td-ag__lens { flex-wrap: wrap; max-width: 100%; }
3676
+ .td-ag__bar { display: flex; align-items: center; gap: 8px; }
3677
+ .td-ag__month { display: grid; gap: 6px; width: 100%; min-width: 0; }
3663
3678
  .td-ag__grid { display: grid; gap: 2px; }
3664
3679
  .td-ag__wk { display: grid; grid-template-columns: 36px repeat(7, minmax(0, 1fr)); gap: 2px; }
3665
3680
  .td-ag__wk--head { font: 11px var(--mono); color: var(--muted-foreground); text-transform: uppercase; }
3666
3681
  .td-ag__wd { padding: 0 4px; }
3667
- .td-day .td-ag__wno { height: auto; min-height: 64px; padding: 0; font: 11px var(--mono); letter-spacing: 0; color: var(--muted-foreground); }
3682
+ .td-day .td-ag__wno { height: auto; min-height: 44px; padding: 0; font: 11px var(--mono); letter-spacing: 0; color: var(--muted-foreground); }
3668
3683
  .td-day .td-ag__wno[aria-pressed="true"] { background: var(--accent); color: var(--foreground); border-color: var(--ring); }
3669
3684
  .td-day .td-ag__cell {
3670
- display: grid; align-items: start; justify-content: stretch; height: auto; min-height: 64px; padding: 4px;
3685
+ display: grid; align-items: start; justify-content: stretch; height: auto; min-height: 44px; padding: 4px;
3671
3686
  border: 1px solid var(--border); background: var(--background); text-align: left; text-transform: none; letter-spacing: 0;
3672
3687
  font-weight: 400; white-space: normal;
3673
3688
  }
3689
+ .td-day .td-ag__cell[data-load="1"] { background: color-mix(in oklab, var(--background) 85%, var(--ring)); }
3690
+ .td-day .td-ag__cell[data-load="2"] { background: color-mix(in oklab, var(--background) 65%, var(--ring)); }
3691
+ .td-day .td-ag__cell[data-load="3"] { background: color-mix(in oklab, var(--background) 40%, var(--ring)); }
3674
3692
  .td-day .td-ag__cell[data-out="true"] { opacity: 0.45; }
3675
3693
  .td-day .td-ag__cell[data-weekend="true"] { background: var(--muted); }
3676
3694
  .td-day .td-ag__cell[data-today="true"] { border: 2px solid var(--foreground); }
3677
3695
  .td-day .td-ag__cell[aria-pressed="true"] { background: var(--accent); border-color: var(--ring); }
3678
3696
  .td-day .td-ag__cell[data-lit="true"] { box-shadow: inset 0 0 0 2px var(--warning); }
3679
- .td-ag__in { display: grid; gap: 3px; min-width: 0; }
3697
+ .td-ag__in { display: flex; align-items: center; justify-content: space-between; gap: 3px; min-width: 0; width: 100%; }
3698
+ .td-ag__star { font-size: 12px; line-height: 16px; color: var(--foreground); }
3680
3699
  .td-ag__num { font: 12px var(--mono); color: var(--foreground); font-variant-numeric: tabular-nums; }
3681
3700
  .td-ag__cell[data-past="true"] .td-ag__num { color: var(--muted-foreground); }
3682
- .td-ag__strip { position: relative; height: 6px; background: var(--muted); }
3683
- .td-ag__blk { position: absolute; top: 0; bottom: 0; left: var(--td-ag-l, 0); width: var(--td-ag-w, 0); background: var(--muted-foreground); }
3684
- .td-ag__blk--imp { background: var(--foreground); }
3685
- .td-ag__blk--free { background: var(--ring); }
3686
- .td-ag__key, .td-ag__meta, .td-ag__nonotes { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 11px; line-height: 14px; }
3687
- .td-ag__key { font-weight: 600; color: var(--foreground); }
3688
- .td-ag__meta { color: var(--muted-foreground); }
3701
+ .td-ag__nonotes { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 11px; line-height: 14px; }
3689
3702
  .td-ag__nonotes { color: var(--warning); }
3690
3703
  .td-ag__ready { justify-self: start; padding: 0 4px; font: 11px/16px var(--mono); border: 1px solid var(--border); color: var(--foreground); }
3691
3704
  .td-ag__ready[data-ag-ready="warn"] { border-color: var(--warning); color: var(--warning); }
@@ -3714,6 +3727,21 @@
3714
3727
  margin: 0;
3715
3728
  }
3716
3729
  .td-goal__prep { display: inline-flex; align-items: center; gap: 8px; }
3730
+ /* goals own jobs (initiative today-goals-jobs): the square for who acts, the YOU and AGENT tags, a goal's context and jobs */
3731
+ .td-day__sq { display: inline-block; flex: none; width: 10px; height: 10px; border: 1.5px solid var(--foreground); }
3732
+ .td-day__sq[data-who="agent"] { background: var(--foreground); }
3733
+ .td-day__marks2 { display: inline-flex; align-items: center; gap: 6px; }
3734
+ .td-day__tag { display: inline-block; margin-left: 4px; padding: 0 4px; border: 1px solid currentColor; font: 600 10px/1.4 var(--mono); letter-spacing: 0.04em; vertical-align: middle; }
3735
+ .td-day__tag[data-who="agent"] { background: color-mix(in oklab, currentColor 18%, transparent); }
3736
+ .td-day__who2 { display: flex; align-items: center; gap: 8px; margin: 0; font-size: 14px; color: var(--foreground); }
3737
+ .td-day__jobmeta { display: grid; gap: 8px; padding: 10px 12px; border: 1px solid var(--border); background: var(--background); }
3738
+ .td-day__goaladd { display: grid; gap: 6px; }
3739
+ .td-goal__jobs { gap: 6px; }
3740
+ .td-goal__donewhen { margin: 0; font-size: 14px; color: var(--foreground); }
3741
+ .td-goal__ctxfields { display: grid; gap: 6px; }
3742
+ .td-goal__ctxfold { margin-top: 6px; }
3743
+ .td-goal__nogoal { margin-top: 12px; padding-top: 8px; border-top: 1px solid var(--border); }
3744
+ .td-goal__pick { display: block; min-width: 140px; max-width: 220px; }
3717
3745
  .td-card {
3718
3746
  display: flex;
3719
3747
  flex-direction: column;