create-pmos 0.73.0 → 0.75.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.73.0",
3
+ "version": "0.75.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@a99e09f
1
+ kernel-source: pmos@a8b70bb
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-07T05:41:12Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-07T07:33:59Z"}
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-07T05:41:12Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-07T07:33:59Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -1396,6 +1396,21 @@ function duplicatesArchive(root, L) {
1396
1396
  return path.relative(root, path.join(path.dirname(L.initiatives), 'archive', 'duplicates')).split(path.sep).join('/') + '/';
1397
1397
  }
1398
1398
  function scanRepository(root, L) { return scanDetail(root, L).index; }
1399
+ /** The kinds the PM confirmed (S11): `<state>/document-kinds.jsonl`, the latest line for a path. A kind is a role, or `none`. */
1400
+ const DOCUMENT_KINDS = ['brief', 'draft', 'review', 'handoff', 'none'];
1401
+ function documentKinds(L) {
1402
+ const out = new Map();
1403
+ let text = '';
1404
+ try { text = fs.readFileSync(path.join(path.dirname(L.initiatives), 'document-kinds.jsonl'), 'utf8'); } catch { return out; }
1405
+ for (const line of text.split('\n')) {
1406
+ if (!line.trim()) continue;
1407
+ try {
1408
+ const o = JSON.parse(line);
1409
+ if (o && typeof o.path === 'string' && DOCUMENT_KINDS.includes(o.kind)) out.set(o.path, o.kind);
1410
+ } catch { /* a torn line is skipped */ }
1411
+ }
1412
+ return out;
1413
+ }
1399
1414
  /** The scan's index, and how many initiatives each found document belongs to (`owners`, for the tidy). */
1400
1415
  function scanDetail(root, L) {
1401
1416
  const key = path.resolve(root);
@@ -1403,6 +1418,8 @@ function scanDetail(root, L) {
1403
1418
  if (hit && Date.now() - hit.at < SCAN_TTL_MS) return hit;
1404
1419
  const index = new Map();
1405
1420
  const owners = new Map();
1421
+ const owned = []; // every document tied to an initiative, with or without a role (the Jev check reads them, S11)
1422
+ const kinds = documentKinds(L);
1406
1423
  const keys = recordKeys(L);
1407
1424
  const idSet = new Set(keys.map((k) => k.id));
1408
1425
  const recDir = path.relative(root, L.initiatives).split(path.sep).join('/') + '/';
@@ -1421,10 +1438,13 @@ function scanDetail(root, L) {
1421
1438
  }
1422
1439
  const owner = documentOwners(rel, fm, idSet, keys);
1423
1440
  if (!owner.ids.length) continue;
1424
- const role = documentRole(rel, fm.type, owner.key);
1425
- if (!role) continue;
1426
1441
  // git's index still lists a tracked file that was moved or deleted and not yet staged
1427
1442
  if (!SCAN_TEXT.test(rel) && !fs.existsSync(path.join(root, rel))) continue;
1443
+ const local = documentRole(rel, fm.type, owner.key);
1444
+ const kind = kinds.get(rel) || null; // a kind the PM confirmed outranks the name (S11)
1445
+ const role = kind ? (kind === 'none' ? null : kind) : local;
1446
+ owned.push({ path: rel, ids: owner.ids, role, local, kind, title: fm.title ? String(fm.title) : null });
1447
+ if (!role) continue;
1428
1448
  owners.set(rel, owner.ids.length);
1429
1449
  for (const id of owner.ids) {
1430
1450
  if (!index.has(id)) index.set(id, { brief: [], draft: [], review: [], handoff: [] });
@@ -1432,7 +1452,7 @@ function scanDetail(root, L) {
1432
1452
  }
1433
1453
  }
1434
1454
  for (const v of index.values()) for (const k of Object.keys(v)) v[k].sort();
1435
- const out = { at: Date.now(), index, owners };
1455
+ const out = { at: Date.now(), index, owners, owned };
1436
1456
  _scanCache.set(key, out);
1437
1457
  return out;
1438
1458
  }
@@ -1442,6 +1462,27 @@ function documentChangedAt(root, rel) {
1442
1462
  if (t > 0) return t;
1443
1463
  try { return Math.floor(fs.statSync(path.join(root, rel)).mtimeMs / 1000); } catch { return 0; }
1444
1464
  }
1465
+ /**
1466
+ * Every document the scan ties to an initiative, for the Jev check (S11): { ok, profile, kinds, documents: [{ path, ids,
1467
+ * role, local, kind, title, text }] }. `local` is the role the name gives, `kind` the one the PM confirmed, `role` the one
1468
+ * the scan uses, and `text` says whether the file is Markdown or text (only those can be read by Jev). Nothing is written.
1469
+ */
1470
+ function documentCandidates(root, opts) {
1471
+ const L = layout(root);
1472
+ const profile = profileOf(root);
1473
+ const kinds = path.relative(root, path.join(path.dirname(L.initiatives), 'document-kinds.jsonl')).split(path.sep).join('/');
1474
+ if (profile !== 'pm-artifacts') return { ok: true, profile, kinds, documents: [] };
1475
+ if (opts && opts.fresh) forgetDocumentScan(root);
1476
+ const { owned } = scanDetail(root, L);
1477
+ const titles = {};
1478
+ try {
1479
+ for (const f of fs.readdirSync(L.initiatives)) {
1480
+ if (!f.endsWith('.md') || /^(README|index|log)\.md$/i.test(f)) continue;
1481
+ titles[f.slice(0, -3)] = String((readFrontmatter(path.join(L.initiatives, f)) || {}).title || f.slice(0, -3));
1482
+ }
1483
+ } catch { /* no records */ }
1484
+ return { ok: true, profile, kinds, titles, documents: owned.map((d) => ({ ...d, text: SCAN_TEXT.test(d.path), changed: documentChangedAt(root, d.path) })) };
1485
+ }
1445
1486
  /**
1446
1487
  * The duplicates the tidy offers (S10): for each initiative, each role with more than one document. The group keeps the
1447
1488
  * file the record names, else the most recently changed, else the shortest path. A document that belongs to more than one
@@ -1920,7 +1961,7 @@ module.exports = {
1920
1961
  historyRange,
1921
1962
  ENFORCED_FROM,
1922
1963
  lanesFor, laneNames,
1923
- forgetDocumentScan, documentDuplicates,
1964
+ forgetDocumentScan, documentDuplicates, documentCandidates, DOCUMENT_KINDS,
1924
1965
  };
1925
1966
 
1926
1967
  /*
@@ -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-07T05:41:12Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-07T07:33:59Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -48,6 +48,16 @@ Select another file to keep a different one. Clear a group's check box to leave
48
48
 
49
49
  Press **Scan without tidying** to scan with every document in place. Press **Cancel** or Escape to do neither. The tidy writes no initiative record.
50
50
 
51
+ ### Check documents with Jev
52
+
53
+ A file name, a folder or a `type:` line can give a document the wrong kind. When Jev is set up for the repository, **Scan repository** first asks Jev to read each document that is tied to an initiative and say what it is: a problem brief, a PRD, an evaluation, a handoff, or none of these. Jev is set up when three things are true: a TypeSafe key is saved, validation is on for the repository, and the switch that lets Jev categorize documents is on.
54
+
55
+ 1. The **Check documents with Jev** sheet says how many documents Jev reads and how many requests that sends. Nothing is sent before you press **Check with Jev**. Jev gets each document's title, headings and opening only. It never gets the path or the whole file. A document in an excluded folder, or one that may contain a secret, is not sent. A document Jev read before, and that did not change, is not sent again.
56
+ 2. The next sheet lists each document that Jev reads as a different kind, with how sure Jev is. A kind that Jev is unsure of is not listed. Clear a check box to keep the kind the scan gives now. Then the sheet lists the duplicates by Jev's kinds, and in each group it selects the version Jev picks as the most complete and current. The file the record names is still kept first.
57
+ 3. Press the confirm button. Each changed kind is one line in `pmos/state/document-kinds.jsonl`, and the scan uses that kind from now on. A document set to "none of these" leaves the scan. Then the duplicates move to the archive as in [Tidy duplicate documents](#tidy-duplicate-documents), and the scan runs. Its toast says how many kinds changed and how many documents moved.
58
+
59
+ Press **Scan without Jev** to use the file names only. When Jev is not set up, the scan toast says which part is missing, and the tidy sheet uses the file names.
60
+
51
61
  ## The Roadmap
52
62
 
53
63
  The Roadmap has three columns: Now, Next and Later. Each column is a ranked list of cards. The line at the top reads, for example, "Now · 5 KR · 3 health". It counts the initiatives in Now by what they serve: a key result (KR) or a health budget. When the rules set an advisory cap for Now, the line shows it. The cap only advises. It never stops a move.
@@ -82,12 +82,33 @@ Each job has one of three kinds.
82
82
 
83
83
  | Kind | Who does it | What you do |
84
84
  |---|---|---|
85
- | you | Only you. | Do it. Nothing runs for it. |
85
+ | you | Only you. | Do it, then mark it. See [Jobs you do and meetings you prepare for](#jobs-you-do-and-meetings-you-prepare-for). |
86
86
  | approve | An agent prepares one draft. | Read the draft. Press **Approve** to let the one outward step happen, or **Revise** with a note. |
87
87
  | auto | An agent does it, and nothing leaves the computer. | Read the result. Press **Revise** if it is wrong. |
88
88
 
89
89
  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**.
90
90
 
91
+ ### Jobs you do and meetings you prepare for
92
+
93
+ 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.
94
+
95
+ A job card holds what you need to do the job without opening another file. **The conversation** shows the last messages, up to 4. A step that reads a source shows what the source says, in a few lines, under the step. **What the sources say** shows the other sources. **Open the source** opens the source itself when you want more.
96
+
97
+ When the job is a reply or a review, you can finish it in the card. Write your answer in the box, or press a suggestion to start from it. For a review, choose **Approve** or **Ask for changes**. Then press **Send to …**. An agent posts exactly your text, as you, to the place the button names. Nothing else is sent, and nothing is sent before you press. The job then shows **Sending**, and **Sent** when it is posted. If the send fails, the job shows the reason and **Retry**, which sends the same text again.
98
+
99
+ A meeting that needs prep is a row of **Needs you**, with the verb **Prepare**. Its detail shows the prep, how many steps are ready, the free time before the meeting, and the steps before it. A step that reads a source shows what the source says under it. A step that asks for your view, such as **Write your recommendation**, has a box for your note. The note is saved when you leave the box. It stays with the meeting on this computer and is not sent. A step that the brief already marked done stays checked.
100
+
101
+ When you finish or put off a job or a meeting, mark it:
102
+
103
+ | Press | What it does |
104
+ |---|---|
105
+ | **Mark done** (a meeting: **Mark ready**) | Moves it to **Done today**. |
106
+ | **Snooze** (a meeting: **Remind me**) | Moves it to **Snoozed** until a time later today. At that time it comes back to **Needs you**. |
107
+ | **Waiting on…** | Puts a job on hold. Write who or what you wait on, and when to ask again today. It shows in **Waiting on others** until that time. |
108
+ | **Not mine** (a meeting: **Skip prep**) | Takes it off **Needs you**, with a reason. |
109
+
110
+ Each press shows **Undo**. **Bring it back now** or **Reopen** puts a marked item back in **Needs you**. A mark lasts for the day. A mark does not send anything and does not change the source. Only **Send to …** sends, and only your text. **Snoozed**, **Waiting on others** and **Done today** are folded. Press a group to open it.
111
+
91
112
  The page also shows which sources were read and how many items each gave, and the next daytime check. **Check now** starts a check at once.
92
113
 
93
114
  ## What waits for you
@@ -41,6 +41,8 @@ const EFFORTS = ['minutes', 'an-hour', 'half-day', 'day'];
41
41
  const KINDS = ['you', 'approve', 'auto'];
42
42
  const DIGEST_SOURCES = ['slack', 'jira', 'confluence'];
43
43
  const STEP_WHEN = ['before', 'during', 'after'];
44
+ // The verb of an item the PM acts on (initiative today-act-prepare, PRD A2): the chip Your day shows first.
45
+ const VERBS = ['reply', 'review', 'decide', 'sign-off', 'read', 'do'];
44
46
  const WORTH_ASKING = 0.5;
45
47
  const MAX_ITEMS = 30;
46
48
  const MAX_EVENTS = 20;
@@ -95,6 +97,10 @@ function eventExtras(e, at, sources) {
95
97
  const err = optText(e[k], n, `${at}: ${k}`); if (err) return { error: err };
96
98
  if (typeof e[k] === 'string' && e[k].trim()) f[k] = clip(e[k], n);
97
99
  }
100
+ // The sources the PM reads to prepare, each in a few lines, so no other file needs opening (PRD X2).
101
+ const refs = refList(e.refs, at);
102
+ if (refs.error) return refs;
103
+ if (refs.refs.length) f.refs = refs.refs;
98
104
  if (e.steps !== undefined && e.steps !== null) {
99
105
  if (typeof e.steps !== 'object' || Array.isArray(e.steps)) return { error: `${at}: steps must be {before, during, after}` };
100
106
  for (const k of Object.keys(e.steps)) if (!STEP_WHEN.includes(k)) return { error: `${at}: steps has "${k}" — only ${STEP_WHEN.join(', ')}` };
@@ -112,7 +118,10 @@ function eventExtras(e, at, sources) {
112
118
  if (tooLong(st.t, 140)) return { error: `${sa}: t is over 140 characters` };
113
119
  if (!OWNERS.includes(st.who)) return { error: `${sa}: who must be one of ${OWNERS.join(', ')}` };
114
120
  if (st.done !== undefined && typeof st.done !== 'boolean') return { error: `${sa}: done must be true or false` };
115
- steps[k].push({ t: clip(st.t, 140), who: st.who, done: st.done === true });
121
+ if (st.ref !== undefined && st.ref !== null && !(Number.isInteger(st.ref) && st.ref >= 0 && st.ref < refs.refs.length)) return { error: `${sa}: ref must name one of the refs` };
122
+ if (st.note !== undefined && typeof st.note !== 'boolean') return { error: `${sa}: note must be true or false` };
123
+ if (st.note && (k !== 'before' || st.who !== 'pm')) return { error: `${sa}: note is for a step the PM does before the meeting` };
124
+ steps[k].push({ t: clip(st.t, 140), who: st.who, done: st.done === true, ...(Number.isInteger(st.ref) ? { ref: st.ref } : {}), ...(st.note ? { note: true } : {}) });
116
125
  }
117
126
  }
118
127
  f.steps = steps;
@@ -177,9 +186,86 @@ function itemExtras(x, at) {
177
186
  if (!KINDS.includes(kind)) return { error: `${at}: kind must be one of ${KINDS.join(', ')}` };
178
187
  if (x.owner === 'pm' && kind !== 'you') return { error: `${at}: an item the PM owns has kind "you" — "${kind}" is for an item the agent owns` };
179
188
  f.kind = kind;
189
+ // What the PM does, said once (PRD A2): a verb, one sentence, a due time and at most 4 steps. All optional.
190
+ if (x.verb !== undefined && x.verb !== null) {
191
+ if (!VERBS.includes(x.verb)) return { error: `${at}: verb must be one of ${VERBS.join(', ')}` };
192
+ f.verb = x.verb;
193
+ }
194
+ const terr = optText(x.todo, 200, `${at}: todo`);
195
+ if (terr) return { error: terr };
196
+ if (typeof x.todo === 'string' && x.todo.trim()) f.todo = clip(x.todo, 200);
197
+ // The context the PM reads in the card, so no other file needs opening (PRD X1, X2), and where the PM's own answer goes
198
+ // (PRD F1). All optional.
199
+ const ctx = cardContext(x, at);
200
+ if (ctx.error) return { error: ctx.error };
201
+ Object.assign(f, ctx.fields);
202
+ if (x.due !== undefined && x.due !== null) {
203
+ if (typeof x.due !== 'string' || Number.isNaN(Date.parse(x.due))) return { error: `${at}: due must be a date and time` };
204
+ f.due = x.due;
205
+ }
206
+ const st = stepList(x.steps, 4, f.refs ? f.refs.length : 0, at);
207
+ if (st.error) return { error: st.error };
208
+ if (st.steps.length) f.steps = st.steps;
180
209
  return { fields: f };
181
210
  }
182
211
 
212
+ /* The messages of the conversation (at most 4) and the sources it points to, each read in a few lines (at most 3). */
213
+ function cardContext(x, at) {
214
+ const f = {};
215
+ if (x.thread !== undefined && x.thread !== null) {
216
+ if (!Array.isArray(x.thread) || x.thread.length > 4) return { error: `${at}: thread must be a list of at most 4 messages` };
217
+ const thread = [];
218
+ for (const [i, m] of x.thread.entries()) {
219
+ const w = `${at}: thread message ${i + 1}`;
220
+ if (!m || typeof m !== 'object' || typeof m.text !== 'string' || !m.text.trim()) return { error: `${w} needs text` };
221
+ const e = optText(m.text, 280, `${w} text`) || optText(m.who, 80, `${w} who`) || optText(m.at, 20, `${w} at`);
222
+ if (e) return { error: e };
223
+ thread.push({ who: m.who ? clip(m.who, 80) : null, at: m.at ? clip(m.at, 20) : null, text: clip(m.text, 280) });
224
+ }
225
+ if (thread.length) f.thread = thread;
226
+ }
227
+ const refs = refList(x.refs, at);
228
+ if (refs.error) return refs;
229
+ if (refs.refs.length) f.refs = refs.refs;
230
+ if (x.send !== undefined && x.send !== null) {
231
+ const sd = x.send;
232
+ if (typeof sd !== 'object' || Array.isArray(sd)) return { error: `${at}: send must be {to, suggest}` };
233
+ const e = optText(sd.to, 120, `${at}: send.to`) || optLines(sd.suggest, 3, 280, `${at}: send.suggest`);
234
+ if (e) return { error: e };
235
+ if (sd.review !== undefined && typeof sd.review !== 'boolean') return { error: `${at}: send.review must be true or false` };
236
+ f.send = { to: sd.to ? clip(sd.to, 120) : null, suggest: lines(sd.suggest, 280), ...(sd.review ? { review: true } : {}) };
237
+ }
238
+ return { fields: f };
239
+ }
240
+ function refList(v, at) {
241
+ if (v === undefined || v === null) return { refs: [] };
242
+ if (!Array.isArray(v) || v.length > 3) return { error: `${at}: refs must be a list of at most 3 sources` };
243
+ const refs = [];
244
+ for (const [i, r] of v.entries()) {
245
+ const w = `${at}: ref ${i + 1}`;
246
+ if (!r || typeof r !== 'object' || typeof r.label !== 'string' || !r.label.trim()) return { error: `${w} needs a label` };
247
+ const e = optText(r.label, 120, `${w} label`) || optText(r.source, 20, `${w} source`) || optLink(r.link, `${w} link`) || optLines(r.lines, 5, 200, `${w} lines`);
248
+ if (e) return { error: e };
249
+ if (!Array.isArray(r.lines) || !r.lines.length) return { error: `${w} needs lines: what the source says, in your words` };
250
+ refs.push({ label: clip(r.label, 120), source: r.source ? clip(r.source, 20) : null, link: r.link || null, lines: lines(r.lines, 200) });
251
+ }
252
+ return { refs };
253
+ }
254
+ /* Steps of an item: text, or {t, ref} where ref is the index of the source the step reads. */
255
+ function stepList(v, max, nRefs, at) {
256
+ if (v === undefined || v === null) return { steps: [] };
257
+ if (!Array.isArray(v) || v.length > max) return { error: `${at}: steps must be a list of at most ${max}` };
258
+ const steps = [];
259
+ for (const [i, s] of v.entries()) {
260
+ const o = typeof s === 'string' ? { t: s } : s;
261
+ if (!o || typeof o !== 'object' || typeof o.t !== 'string' || !o.t.trim()) return { error: `${at}: step ${i + 1} must be text` };
262
+ if (tooLong(o.t, 140)) return { error: `${at}: step ${i + 1} is over 140 characters` };
263
+ if (o.ref !== undefined && o.ref !== null && !(Number.isInteger(o.ref) && o.ref >= 0 && o.ref < nRefs)) return { error: `${at}: step ${i + 1}: ref must name one of the refs` };
264
+ steps.push(Number.isInteger(o.ref) ? { t: clip(o.t, 140), ref: o.ref } : { t: clip(o.t, 140) });
265
+ }
266
+ return { steps };
267
+ }
268
+
183
269
  /* The fixed order of items: higher confidence first, then PM-owned before agent-owned, then by title. */
184
270
  function sortItems(items) {
185
271
  return items.sort((a, b) => b.confidence - a.confidence || (a.owner === b.owner ? 0 : a.owner === 'pm' ? -1 : 1) || a.title.localeCompare(b.title));
@@ -419,5 +505,5 @@ function rereadBrief(stored, incoming) {
419
505
  digests, places, resolved: [...gone].slice(-MAX_RESOLVED) } };
420
506
  }
421
507
 
422
- module.exports = { SOURCES, ITEM_SOURCES, SOURCE_STATES, OWNERS, EFFORTS, KINDS, DIGEST_SOURCES, WORTH_ASKING, MAX_ITEMS, MAX_EVENTS, MAX_DIGESTS, MAX_PLACES,
508
+ module.exports = { VERBS, SOURCES, ITEM_SOURCES, SOURCE_STATES, OWNERS, EFFORTS, KINDS, DIGEST_SOURCES, WORTH_ASKING, MAX_ITEMS, MAX_EVENTS, MAX_DIGESTS, MAX_PLACES,
423
509
  isUrl, validateBrief, mergeBrief, rereadBrief, greeting, localDate };
@@ -0,0 +1,163 @@
1
+ 'use strict';
2
+ /* day-marks.js — the PM's marks on today's items and meetings (initiative `today-act-prepare`, PRD M1 to M6).
3
+ *
4
+ * A brief item the PM owns, or a meeting with prep, used to stay in Needs you all day: nothing the PM did changed it.
5
+ * A mark is what the PM did with it: done (a meeting: ready), snoozed until a time, waiting on someone until a time, or
6
+ * dismissed with a reason (not mine, or skip prep). A mark also holds the steps the PM checked, by their text.
7
+ *
8
+ * PURE: no I/O and no clock of its own. The server reads goals/<date>.marks.json, passes it with `now` and the ids of
9
+ * today's brief, and writes what `edit` returns. A refused edit returns the reason and the caller writes nothing.
10
+ *
11
+ * emptyMarks(date) → { date, marks: {} }
12
+ * readMarks(raw, date) → a checked record, or an empty one when `raw` is not one for `date`
13
+ * edit(rec, op, { now, known }) → { ok:true, rec, mark, prev } | { ok:false, error }
14
+ * op: { action: 'done' | 'reopen', id }
15
+ * | { action: 'snooze', id, until } until: a time later today
16
+ * | { action: 'hold', id, on, until } on: who or what (1 to 80 characters), until: a time later today
17
+ * | { action: 'dismiss', id, why } why: no-action | someone-else | done-elsewhere (an item)
18
+ * nothing | elsewhere (a meeting)
19
+ * | { action: 'step', id, step, done } step: the step's text, done: true | false
20
+ * | { action: 'note', id, text } the PM's own note, written in the card (at most 1000
21
+ * characters; empty clears it). It stays on this machine.
22
+ * | { 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.
24
+ * effective(rec, now) → { id: mark } with a snooze or hold whose time has passed read as open
25
+ *
26
+ * 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
+ */
28
+
29
+ const STATES = ['done', 'snoozed', 'waiting', 'dismissed'];
30
+ const ACTIONS = ['done', 'reopen', 'snooze', 'hold', 'dismiss', 'step', 'note', 'restore'];
31
+ const KINDS = ['item', 'event'];
32
+ const REASONS = { item: ['no-action', 'someone-else', 'done-elsewhere'], event: ['nothing', 'elsewhere'] };
33
+ const ID_RE = /^[0-9a-f]{12}$/;
34
+ const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
35
+ const MAX_IDS = 400;
36
+ const MAX_STEPS = 12;
37
+ const MAX_STEP = 140;
38
+ const MAX_ON = 80;
39
+ const MAX_NOTE = 1000;
40
+
41
+ const clip = (s, n) => String(s == null ? '' : s).replace(/\s+/g, ' ').trim().slice(0, n);
42
+ const pad = (n) => String(n).padStart(2, '0');
43
+ const localDate = (ms) => { const d = new Date(ms); return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; };
44
+ const iso = (ms) => new Date(ms).toISOString();
45
+
46
+ const emptyMarks = (date) => ({ date, marks: {} });
47
+
48
+ /** One mark read from disk or from an Undo, checked. Returns the clean mark, or null when it is not one. */
49
+ function cleanMark(m, date) {
50
+ if (!m || typeof m !== 'object' || Array.isArray(m) || !KINDS.includes(m.kind)) return null;
51
+ const state = m.state === null || m.state === undefined ? null : m.state;
52
+ if (state !== null && !STATES.includes(state)) return null;
53
+ const out = { kind: m.kind, state, at: typeof m.at === 'string' ? m.at : null };
54
+ if (state === 'snoozed' || state === 'waiting') {
55
+ const t = Date.parse(m.until);
56
+ if (!Number.isFinite(t) || localDate(t) !== date) return null;
57
+ out.until = iso(t);
58
+ }
59
+ 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; }
61
+ const steps = Array.isArray(m.steps) ? [...new Set(m.steps.map((s) => clip(s, MAX_STEP)).filter(Boolean))].slice(0, MAX_STEPS) : [];
62
+ if (steps.length) out.steps = steps;
63
+ if (typeof m.note === 'string' && m.note.trim()) out.note = m.note.trim().slice(0, MAX_NOTE);
64
+ return out;
65
+ }
66
+
67
+ /** A marks record read from disk, checked. Anything that is not one for `date` reads as an empty record. */
68
+ function readMarks(raw, date) {
69
+ if (!raw || typeof raw !== 'object' || raw.date !== date || !raw.marks || typeof raw.marks !== 'object') return emptyMarks(date);
70
+ const marks = {};
71
+ for (const [id, m] of Object.entries(raw.marks).slice(0, MAX_IDS)) {
72
+ if (!ID_RE.test(id)) continue;
73
+ const c = cleanMark(m, date);
74
+ if (c) marks[id] = c;
75
+ }
76
+ return { date, marks };
77
+ }
78
+
79
+ /** A time later today, or the reason it is not one. */
80
+ function laterToday(v, now, date) {
81
+ const t = typeof v === 'number' ? v : Date.parse(v);
82
+ if (!Number.isFinite(t)) return { error: 'until must be a date and time' };
83
+ if (t <= now) return { error: 'until must be later than now' };
84
+ if (localDate(t) !== date) return { error: 'until must be today: a mark ends with the day' };
85
+ return { at: t };
86
+ }
87
+
88
+ function edit(rec, op, { now = Date.now(), known } = {}) {
89
+ if (!rec || !DATE_RE.test(String(rec.date)) || !rec.marks || typeof rec.marks !== 'object') return { ok: false, error: 'no marks record to edit' };
90
+ if (!op || typeof op !== 'object' || !ACTIONS.includes(op.action)) return { ok: false, error: `action must be one of ${ACTIONS.join(', ')}` };
91
+ const id = String(op.id || '');
92
+ if (!ID_RE.test(id)) return { ok: false, error: 'id must be the id of an item or a meeting of today\'s brief' };
93
+ const kind = known && typeof known.get === 'function' ? known.get(id) : null;
94
+ if (!KINDS.includes(kind)) return { ok: false, error: 'no item or meeting with that id in today\'s brief' };
95
+ if (localDate(now) !== rec.date) return { ok: false, error: `the marks are for ${rec.date}, and today is ${localDate(now)}` };
96
+ const marks = { ...rec.marks };
97
+ const prev = marks[id] ? JSON.parse(JSON.stringify(marks[id])) : null;
98
+ const cur = prev ? { ...prev } : { kind, state: null, at: null };
99
+ cur.kind = kind;
100
+ const at = iso(now);
101
+ const setState = (state, extra) => {
102
+ delete cur.until; delete cur.on; delete cur.why;
103
+ Object.assign(cur, { state, at }, extra || {});
104
+ };
105
+
106
+ if (op.action === 'restore') {
107
+ if (op.mark === null || op.mark === undefined) { delete marks[id]; return { ok: true, rec: { date: rec.date, marks }, mark: null, prev }; }
108
+ const c = cleanMark({ ...op.mark, kind }, rec.date);
109
+ if (!c) return { ok: false, error: 'mark is not a mark of this item' };
110
+ marks[id] = c;
111
+ return { ok: true, rec: { date: rec.date, marks }, mark: c, prev };
112
+ }
113
+ if (op.action === 'step') {
114
+ const step = clip(op.step, MAX_STEP);
115
+ if (!step) return { ok: false, error: 'step must be the text of a step' };
116
+ if (typeof op.done !== 'boolean') return { ok: false, error: 'done must be true or false' };
117
+ const steps = new Set(cur.steps || []);
118
+ if (op.done) steps.add(step); else steps.delete(step);
119
+ if (steps.size > MAX_STEPS) return { ok: false, error: `at most ${MAX_STEPS} steps are kept for one item` };
120
+ if (steps.size) cur.steps = [...steps]; else delete cur.steps;
121
+ } else if (op.action === 'note') {
122
+ if (typeof op.text !== 'string') return { ok: false, error: 'text must be the note' };
123
+ if (op.text.trim().length > MAX_NOTE) return { ok: false, error: `the note is over ${MAX_NOTE} characters` };
124
+ if (op.text.trim()) cur.note = op.text.trim(); else delete cur.note;
125
+ } else if (op.action === 'done') {
126
+ if (cur.state === 'done') return { ok: false, error: kind === 'event' ? 'that meeting is already marked ready' : 'that item is already done' };
127
+ setState('done');
128
+ } else if (op.action === 'reopen') {
129
+ if (cur.state === null) return { ok: false, error: 'that item is already open' };
130
+ setState(null);
131
+ } else if (op.action === 'snooze') {
132
+ const t = laterToday(op.until, now, rec.date);
133
+ if (t.error) return { ok: false, error: t.error };
134
+ setState('snoozed', { until: iso(t.at) });
135
+ } else if (op.action === 'hold') {
136
+ if (kind !== 'item') return { ok: false, error: 'a meeting is not put on hold: snooze it, or skip prep' };
137
+ const on = clip(op.on, MAX_ON);
138
+ if (!on) return { ok: false, error: `on must say who or what you wait on, in 1 to ${MAX_ON} characters` };
139
+ if (typeof op.on === 'string' && op.on.trim().length > MAX_ON) return { ok: false, error: `on is over ${MAX_ON} characters` };
140
+ const t = laterToday(op.until, now, rec.date);
141
+ if (t.error) return { ok: false, error: t.error };
142
+ setState('waiting', { on, until: iso(t.at) });
143
+ } else if (op.action === 'dismiss') {
144
+ if (!REASONS[kind].includes(op.why)) return { ok: false, error: `why must be one of ${REASONS[kind].join(', ')}` };
145
+ setState('dismissed', { why: op.why });
146
+ }
147
+ if (!marks[id] && Object.keys(marks).length >= MAX_IDS) return { ok: false, error: `at most ${MAX_IDS} marks a day` };
148
+ const clean = cleanMark(cur, rec.date);
149
+ if (clean.state === null && !clean.steps && !clean.note) delete marks[id]; else marks[id] = clean;
150
+ return { ok: true, rec: { date: rec.date, marks }, mark: marks[id] || null, prev };
151
+ }
152
+
153
+ /** The marks as the page shows them now: a snooze or a hold whose time has passed is open again, with `back: true`. */
154
+ function effective(rec, now = Date.now()) {
155
+ const out = {};
156
+ for (const [id, m] of Object.entries((rec && rec.marks) || {})) {
157
+ const passed = (m.state === 'snoozed' || m.state === 'waiting') && Date.parse(m.until) <= now;
158
+ out[id] = passed ? { ...m, state: null, back: true, was: m.state } : { ...m };
159
+ }
160
+ return out;
161
+ }
162
+
163
+ module.exports = { STATES, ACTIONS, REASONS, MAX_ON, MAX_STEPS, MAX_NOTE, emptyMarks, readMarks, edit, effective };
@@ -15,6 +15,7 @@
15
15
  * newRun(date, now) · readRun(raw, date)
16
16
  * addTasks(run, brief) agent priorities (kind approve|auto) → waiting tasks
17
17
  * addBriefed(run, spec) one task the PM briefed in My work (initiative my-work, PRD R5)
18
+ * addSend(run, spec, now) the PM's own text, written in the card, to post as written (today-act-prepare)
18
19
  * toStart(run, limit) the tasks to start now, each with its mode (do | revise | send)
19
20
  * started · deliver · decide · sent · fail · sessionEnded · sessionStalled
20
21
  * stage(run, brief) reading | working | review | done
@@ -113,7 +114,8 @@ function addTasks(run, brief) {
113
114
  }
114
115
 
115
116
  /**
116
- * One task the PM briefed in My work: kind approve, with the PM's instruction and the item's context card on it, so the
117
+ * One task the PM briefed in My work, or a job of Your day the PM gave to the agent (`item_id`, initiative
118
+ * today-act-prepare, PRD C2): kind approve, with the PM's instruction and the item's context card on it, so the
117
119
  * day-task session reads the instruction as the ask (PRD R5, R6). `spec` is lib/my-work.js taskSpec. A run opened only
118
120
  * for this (no Prepare my day yet) is marked `opened_by: 'my-work'`, so it does not read as a morning read that failed.
119
121
  */
@@ -124,12 +126,43 @@ function addBriefed(run, spec) {
124
126
  if (!text(s.instruction)) return { ok: false, error: 'a briefed task needs the PM\'s instruction' };
125
127
  if (findTask(run, s.id)) return { ok: false, error: `task ${s.id} is already in today's run` };
126
128
  const next = clone(run);
127
- next.tasks.push({ id: s.id, item_id: null, title: text(s.title).slice(0, 250) || s.id, kind: 'approve', source: s.source || 'my-work', link: isUrl(s.link) ? s.link : null,
129
+ next.tasks.push({ id: s.id, item_id: /^[0-9a-f]{12}$/.test(String(s.item_id || '')) ? s.item_id : null, title: text(s.title).slice(0, 250) || s.id, kind: 'approve', source: s.source || 'my-work', link: isUrl(s.link) ? s.link : null,
128
130
  goal: null, my_work: s.my_work || null, brief_at: s.brief_at || null, instruction: text(s.instruction), context: s.context && typeof s.context === 'object' ? clone(s.context) : null,
129
131
  status: 'waiting', session_id: null, started_at: null, attempts: 0, revisions: [], draft: null, decided: null, error: null });
130
132
  return { ok: true, run: next, added: s.id };
131
133
  }
132
134
 
135
+ /**
136
+ * The PM's own text, written in a Your day card, to post as written (initiative today-act-prepare, PRD F1 to F3). The press
137
+ * IS the approval: the task goes straight to `sending` with the PM's text as its draft (`by: 'pm'`), and one send session
138
+ * does that one outward step. A task already in the run for the item (the agent's draft) takes the PM's text instead.
139
+ * `spec`: { id, item_id, title, source, link, to, text, verdict? }.
140
+ */
141
+ function addSend(run, spec, now) {
142
+ if (!run) return { ok: false, error: 'no day run today' };
143
+ const s = spec && typeof spec === 'object' ? spec : {};
144
+ if (!TASK_RE.test(String(s.id || ''))) return { ok: false, error: 'a task needs an id like t-0123456789ab' };
145
+ const body = typeof s.text === 'string' ? s.text.trim() : '';
146
+ if (!body) return { ok: false, error: 'write the text to send' };
147
+ if (body.length > 4000) return { ok: false, error: 'the text is over 4000 characters' };
148
+ const to = text(s.to).slice(0, 120) || 'the place it came from';
149
+ const what = s.verdict === 'approve' ? 'review, approving' : s.verdict === 'changes' ? 'review, asking for changes' : 'reply';
150
+ const at = iso(now);
151
+ const draft = { summary: `The PM's own ${what}: post it as written to ${to}`.slice(0, 240), body, link: isUrl(s.link) ? s.link : null, at, by: 'pm' };
152
+ const next = clone(run);
153
+ const t = findTask(next, s.id);
154
+ if (t) {
155
+ if (['working', 'sending', 'sent'].includes(t.status)) return { ok: false, error: `task ${s.id} is ${t.status} — wait for it, or open its session` };
156
+ keepSession(t);
157
+ Object.assign(t, { kind: 'approve', status: 'sending', draft, decided: { action: 'approve', at, by: 'pm' }, error: null, started_at: null });
158
+ return { ok: true, run: next, task: s.id };
159
+ }
160
+ next.tasks.push({ id: s.id, item_id: /^[0-9a-f]{12}$/.test(String(s.item_id || '')) ? s.item_id : null, title: text(s.title).slice(0, 250) || s.id, kind: 'approve',
161
+ source: s.source || 'today', link: isUrl(s.link) ? s.link : null, goal: null, instruction: `Post the PM's own text exactly as written to ${to}. Change nothing.`,
162
+ status: 'sending', session_id: null, started_at: null, attempts: 0, revisions: [], draft, decided: { action: 'approve', at, by: 'pm' }, error: null });
163
+ return { ok: true, run: next, task: s.id };
164
+ }
165
+
133
166
  /**
134
167
  * The tasks to start now, oldest first, so that the sessions running (working, and sending with a session) stay at
135
168
  * most `limit`. An approved task waiting for its send session comes first: the PM is waiting on it.
@@ -208,7 +241,8 @@ function decide(run, taskId, op, now) {
208
241
  }
209
242
  if (t.status !== 'failed') return `task ${taskId} is ${t.status} — only a failed task can be run again`;
210
243
  const stalled = !!t.stalled_session;
211
- t.status = 'waiting'; keepSession(t); t.error = null; t.decided = { action: 'retry', at };
244
+ // the PM's own text is sent again as it is: a retry never asks an agent to draft over it
245
+ t.status = t.draft && t.draft.by === 'pm' ? 'sending' : 'waiting'; keepSession(t); t.error = null; t.decided = { action: 'retry', at };
212
246
  if (stalled) { t.prev_session = null; delete t.stalled_session; delete t.stalled_status; }
213
247
  return null;
214
248
  });
@@ -350,5 +384,5 @@ function checkDone(run, { found = 0, resolved = 0 } = {}, now) {
350
384
  }
351
385
 
352
386
  module.exports = { MAX_PARALLEL, MAX_REVISIONS, MAX_CHECK_LOG, MAX_INTERVAL_MIN, STATUSES, KINDS, ACTIONS, TASK_RE,
353
- newRun, readRun, findTask, modeOf, addTasks, addBriefed, toStart, started, deliver, decide, sent, fail, sessionEnded, sessionStalled, stage, intervalMin,
387
+ newRun, readRun, findTask, modeOf, addTasks, addBriefed, addSend, toStart, started, deliver, decide, sent, fail, sessionEnded, sessionStalled, stage, intervalMin,
354
388
  checkDue, checkStarted, checkDone };