create-pmos 0.150.0 → 0.152.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.150.0",
3
+ "version": "0.152.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@3f66dcc
1
+ kernel-source: pmos@159c05c
2
2
  assembled: 2026-10-12
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-12T03:30:13Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-12T03:39:15Z"}
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-12T03:30:13Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-12T03:39:15Z"}
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-12T03:30:13Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-12T03:39:15Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -12,20 +12,20 @@ Five presses in the console (`/c3.html`) start an agent from the page. You do no
12
12
  ## Ask the agent on a document
13
13
 
14
14
  1. Open a document in Initiatives › Documents, or open a report as a document from Reports.
15
- 2. Select **Ask the agent**, write what to change, and select **Ask the agent** again.
15
+ 2. Select **Proposals**, write what to change, and select **Ask the agent**.
16
16
  3. The sheet says that the agent is drafting. You can close the sheet and come back.
17
- 4. When the draft is ready, the sheet shows the difference, section by section.
18
- 5. Slide to accept the draft, or select **Change the request** or **Discard**.
17
+ 4. When the draft is ready, it shows in **Proposals** with the difference, section by section.
18
+ 5. Clear the tick of any section that you do not want, and slide to accept. Or select **Change the request** or **Reject**.
19
19
 
20
20
  The agent runs in the background and can only read: it has the Read, Glob and Grep tools and nothing else. It answers with the revised document. The workspace records that answer as a proposal. The file does not change until you slide to accept it, and the slide is bound to the proposal's hash. If the document changed while the agent worked, the sheet tells you to ask again.
21
21
 
22
22
  **Copy the request** stays beside the button. Use it to give the request to a session that you already have open.
23
23
 
24
- On a document's **Health** segment, **Fix with the agent** writes the fix as the request and opens Ask the agent.
24
+ On a document's **Health** segment, **Fix with the agent** writes the fix as the request and opens Proposals.
25
25
 
26
26
  ## Fix with the agent on a report
27
27
 
28
- In Reports, open a section that is not traced and select **Fix with the agent**. The report opens as a document on Ask the agent, with the fix written in. A report that is not issued yet has no stored document, so the sheet tells you to issue it first.
28
+ In Reports, open a section that is not traced and select **Fix with the agent**. The report opens as a document on Proposals, with the fix written in. A report that is not issued yet has no stored document, so the sheet tells you to issue it first.
29
29
 
30
30
  ## Lifecycle: a draft, a spike and an early gate
31
31
 
@@ -21,7 +21,7 @@ Under the title, the note shows its meeting and its initiative. Press **Link to
21
21
 
22
22
  - **Before** shows what the note kept about its meeting: the agenda, what the meeting is for, what to do to be ready, what to read first, the people, and the meeting: its time, its place and who organized it. A tick shows an agenda item that was heard in the transcript. A note with no meeting says so, and has **Link to a meeting** and **Link to an initiative**.
23
23
  - **During** shows the live notes while the note is recorded or paused. At other times it shows the transcript.
24
- - **After** shows **Replay** first when the sound of the note is kept on this machine. Then it shows the overview, the decisions with why, the next steps with owner and due date, the open questions, the statements that differ from a file, the highlights and the screenshots of the shared screen (**Screen**; press one to see it large). The transcript is folded at the bottom. When the final notes are written, click a text to correct it. Leave the text to save it. Press `Esc` to cancel. **Make task** adds a next step to the note's initiative.
24
+ - **After** shows **Replay** first when the sound of the note is kept on this machine. Then it shows the overview, the decisions with why, the next steps with owner and due date, the open questions, the statements that differ from a file, the highlights and the screenshots of the shared screen (**Screen**; press one to see it large). The transcript is folded at the bottom. Click a text to correct it: the summary, a decision and its why, or a next step, its owner and its due day. Press `Enter` or leave the text to save it. Press `Esc` to cancel. Empty an owner or a due day to clear it. A decision caught while the meeting was recorded has no why. Before the final notes are written, press **Write a summary of the meeting** to write your own summary; empty it to go back to the caught one. **Make task** adds a next step to the note's initiative.
25
25
 
26
26
  A note that is recorded now opens on **During**. Other notes open on **After**. **Show in the calendar** goes back to the grid, on the day of the meeting, with the meeting selected. The copy button puts the note on the clipboard as Markdown.
27
27
 
@@ -0,0 +1,58 @@
1
+ ---
2
+ type: Guide
3
+ title: "The document sheet"
4
+ description: "How to read a document as rich text, edit it, review the agent's proposals section by section, read the edit log, and clear a stale document."
5
+ timestamp: 2026-10-12
6
+ ---
7
+
8
+ # The document sheet
9
+
10
+ Open a document in Initiatives › Documents, in an initiative's Docs, or from Reports. The document opens in a sheet on the right. On a phone, it opens from the bottom. The segment under the title has six modes.
11
+
12
+ ## Read
13
+
14
+ Read shows the document as formatted text: headings, bold, links, tables, checklists and callouts. The front matter shows as properties at the top.
15
+
16
+ - Each section shows who changed it last: a person, or the agent and the person who accepted it, with the revision and the day. Select the name to open that revision in the edit log.
17
+ - **Show who wrote what** colours each section: blue for a person, purple for the agent.
18
+ - **Markdown source** shows the file as text. **Copy the markdown** copies it.
19
+ - **Contents** goes to a section.
20
+ - When an open proposal changes a section, a note shows above that section. Select **Show change** to see the difference in place.
21
+
22
+ ## Edit
23
+
24
+ Edit opens the whole document as rich text. Use the toolbar for the paragraph style, bold, italic, code, links, lists, checklists, tables and dividers. Use **Markdown** to edit the same draft as text, and **Rich** to go back. The front matter shows as fields.
25
+
26
+ 1. Change the text. A changed section shows **Edited** next to its heading.
27
+ 2. Write why you change it. The sheet does not save a change without a reason.
28
+ 3. Select **Save**.
29
+
30
+ A section that you did not change is saved exactly as it was. The reason goes to the edit log with your name.
31
+
32
+ ## Proposals
33
+
34
+ Proposals lists what the agent drafted and you did not accept or reject yet. To ask for a change, see [Ask the agent from the page](ask-the-agent.md).
35
+
36
+ 1. Read the difference of each section. Added words are green. Removed words are struck through.
37
+ 2. Clear the tick of each section that you do not want.
38
+ 3. Slide to accept. Only the ticked sections are written, as one revision.
39
+
40
+ If a section changed after the agent drafted it, you cannot tick it. Ask the agent again. To reject a proposal, select **Reject** and write why.
41
+
42
+ ## Edit log
43
+
44
+ The edit log lists every change, newest first: who wrote the text, how it came in, the reason, the sections and the words added and removed. It also lists rejected proposals, requests to the agent, and documents marked as still current. Use **All**, **People**, **Agent** and **Outside PMOS** to filter it.
45
+
46
+ - A change made outside the sheet (in an editor, or by a commit) shows **outside PMOS**, with its commit when git knows it. When it has no reason, select **Add a reason**. The reason is added to the log; the revision itself does not change.
47
+ - Select **Show the change** to see the difference.
48
+
49
+ ## Stale documents
50
+
51
+ A document is stale when a document that it links to changed after it. The sheet shows a banner that lists those sources and the sections that cite them.
52
+
53
+ - Edit the sections, or ask the agent for an update.
54
+ - If the document is still right, select **Mark as still current** and write why. The mark counts as a change, so the banner clears.
55
+
56
+ ## Health and Relations
57
+
58
+ Health shows the checks of the document and how many pass. Relations shows the documents around this one.
@@ -69,6 +69,7 @@ On a desktop the workspace is one frame. [Layout](layout.md) explains each part.
69
69
  - [Integrated work](integrated-work.md): how `pmos verify-work` turns a merged commit into Done, and what "Done · your call · not merged" means.
70
70
  - [Report slides](report-slides.md): how the Reports page shows a report as the PM-OS biweekly slides, Present and Print / PDF, and how your notes become the words on the slides.
71
71
  - [Reports: dashboards of reports](reports-dashboards.md): the console Reports page, its dashboards at portfolio, board and initiative scope, how to edit and share one as a draft, and the Drafts tab.
72
+ - [The document sheet](document-sheet.md): read a document as rich text, edit it, accept the agent's proposals section by section, read the edit log, and clear a stale document.
72
73
  - [Ask the agent from the page](ask-the-agent.md): the five presses that start an agent from the console, what the agent may do, and what stays yours.
73
74
  - [Sessions in console3](console3-sessions.md): the Sessions panel, how to open the terminal of a session on the same page, and how to end a session.
74
75
  - [Settings in console3](settings.md): how to open Settings, what each section holds, the scope tags, the environment notes, and where the settings are kept.
@@ -24,8 +24,10 @@ const ts = (v) => { const t = Date.parse(v); return Number.isNaN(t) ? null : t;
24
24
  function fresh(doc, c) {
25
25
  const at = ts(doc.changed);
26
26
  if (at == null) return na('fresh');
27
- const newer = (c.sources || []).filter((s) => ts(s.changed) != null && ts(s.changed) > at).map((s) => s.path);
28
- if (newer.length) return { id: 'fresh', pass: false, reason: 'a cited source changed after this document: ' + newer.join(', ') };
27
+ const newerSrc = (c.sources || []).filter((s) => ts(s.changed) != null && ts(s.changed) > at);
28
+ const newer = newerSrc.map((s) => s.path);
29
+ // `sources` names each newer source and when it changed, so a page can list them (the document sheet's stale banner)
30
+ if (newer.length) return { id: 'fresh', pass: false, reason: 'a cited source changed after this document: ' + newer.join(', '), sources: newerSrc.map((s) => ({ path: s.path, changed: s.changed })) };
29
31
  if (c.staleAfterDays != null) {
30
32
  const now = ts(c.now) != null ? ts(c.now) : Date.now();
31
33
  const age = Math.floor((now - at) / day);
@@ -7,6 +7,9 @@
7
7
  * askAgentProposal({ before, after, prompt }) → { state:'proposed', prompt, diff, hash } only a proposal; nothing is written
8
8
  * acceptProposal(proposal, { hash, by }) → { ok:true, event } | { ok:false, reason } PM only, hash-bound
9
9
  * changeNote(doc, downstreamDocs) → a DRAFT note for owners of documents whose source rev is behind; never sent
10
+ * splitSections(text) → the text cut at its headings: [{ i, prefix, name, lead, text, tail }], joined back exactly
11
+ * mergeSections(current, before, after, names) → { ok, text } with only the named sections of `after` applied to `current`;
12
+ * { ok:false, stale:[name] } when a named section changed since `before`
10
13
  *
11
14
  * The slide-to-accept is the same hash-bound confirm as the outbox: the hash covers the proposed text, so an edited
12
15
  * proposal has a new hash and the PM's press on the old one is void. An agent can create a proposal and never accept it.
@@ -72,4 +75,49 @@ function changeNote(doc, downstreamDocs) {
72
75
  return { draft: true, sent: false, path: doc.path, rev: doc.rev, behind, body };
73
76
  }
74
77
 
75
- module.exports = { editWithWhy, revisions, foldAt, askAgentProposal, acceptProposal, changeNote, hashText, proposalHash };
78
+ /* Sections: the same cut as the page's splitDoc (public/pm/c3-doc-sheet.js), so a name means the same section on both sides. */
79
+ function splitSections(text) {
80
+ const t = String(text == null ? '' : text);
81
+ const heads = []; let off = 0; let fence = false;
82
+ for (const line of t.split('\n')) {
83
+ if (/^\s*(```|~~~)/.test(line)) fence = !fence;
84
+ if (!fence && /^#{1,6}\s+\S/.test(line)) heads.push({ at: off, line, end: off + line.length + 1 });
85
+ off += line.length + 1;
86
+ }
87
+ const raw = [];
88
+ const first = heads.length ? heads[0].at : t.length;
89
+ if (first > 0 || !heads.length) raw.push({ prefix: '', name: /^---\n/.test(t) ? 'Front matter' : 'Overview', body: t.slice(0, first) });
90
+ heads.forEach((h, i) => raw.push({ prefix: t.slice(h.at, Math.min(h.end, t.length)), name: h.line.replace(/^#+\s+/, '').replace(/\s+#+\s*$/, '').trim(), body: t.slice(Math.min(h.end, t.length), i + 1 < heads.length ? heads[i + 1].at : t.length) }));
91
+ return raw.map((x, i) => {
92
+ const lead = x.body.match(/^\s*/)[0]; const rest = x.body.slice(lead.length); const tail = rest.match(/\s*$/)[0];
93
+ return { i, prefix: x.prefix, name: x.name, lead, tail, text: rest.slice(0, rest.length - tail.length) };
94
+ });
95
+ }
96
+ const joinSections = (secs) => secs.map((x) => x.prefix + x.lead + x.text + x.tail).join('');
97
+
98
+ // Apply only the named sections of a proposal. A named section must read in `current` as it read in `before` (the text the
99
+ // proposal was drafted against); otherwise it is stale and nothing is applied. A section new in `after` goes after the
100
+ // section that precedes it there, or last; a section `after` dropped is removed.
101
+ function mergeSections(current, before, after, names) {
102
+ const C = splitSections(current), B = splitSections(before), P = splitSections(after);
103
+ const want = [...new Set((names || []).map(String))];
104
+ const stale = want.filter((n) => { const b = B.find((x) => x.name === n), c = C.find((x) => x.name === n); return b ? (!c || c.text !== b.text) : !!c; });
105
+ const unknown = want.filter((n) => !B.some((x) => x.name === n) && !P.some((x) => x.name === n));
106
+ if (unknown.length) return { ok: false, unknown };
107
+ if (stale.length) return { ok: false, stale };
108
+ let out = C.map((x) => ({ ...x }));
109
+ for (const n of want) {
110
+ const p = P.find((x) => x.name === n); const k = out.findIndex((x) => x.name === n);
111
+ if (!p) { if (k >= 0) out.splice(k, 1); continue; }
112
+ if (k >= 0) { out[k] = { ...out[k], text: p.text }; continue; }
113
+ const prev = P[P.indexOf(p) - 1]; const at = prev ? out.findIndex((x) => x.name === prev.name) : -1;
114
+ const add = { ...p, tail: p.tail || '\n' };
115
+ if (at >= 0) { if (!/\n\s*\n$/.test(out[at].tail)) out[at] = { ...out[at], tail: '\n\n' }; out.splice(at + 1, 0, add); }
116
+ else if (prev === undefined && out.length && out[0].prefix === '' && out[0].name === 'Front matter') out.splice(1, 0, add);
117
+ else if (prev === undefined) out.unshift(add);
118
+ else { const l = out.length - 1; if (l >= 0 && !/\n\s*\n$/.test(out[l].tail)) out[l] = { ...out[l], tail: '\n\n' }; out.push(add); }
119
+ }
120
+ return { ok: true, text: joinSections(out) };
121
+ }
122
+
123
+ module.exports = { splitSections, joinSections, mergeSections, editWithWhy, revisions, foldAt, askAgentProposal, acceptProposal, changeNote, hashText, proposalHash };
@@ -903,11 +903,11 @@ function summarize(note) {
903
903
  // PM labelled it; without bullets, from the lines as before
904
904
  const bl = ((note && note.bullets) || []).filter((b) => b && b.text);
905
905
  const bitems = bl.map((b, k) => ({ i: n + k, bid: b.id, text: b.text, t: Number.isFinite(b.t) ? b.t : null, src: 'bullet', cat: b.cat || null, by_pm: b.cat_by === 'pm',
906
- dropped: !!b.dropped, open: !!b.open, owner: typeof b.owner === 'string' && b.owner ? b.owner : null }));
906
+ dropped: !!b.dropped, open: !!b.open, owner: typeof b.owner === 'string' && b.owner ? b.owner : null, due: typeof b.due === 'string' && b.due ? b.due : null }));
907
907
  const caught = bl.length ? bitems.concat(items.filter((x) => x.src === 'transcript' && x.by_pm)) : items;
908
908
  const decisions = cap(caught.filter((x) => kindOf(x, 'decision')).filter(once), 'decisions');
909
909
  const actions = cap(caught.filter((x) => kindOf(x, 'action')).filter(once), 'actions')
910
- .map((x) => ({ ...x, owner: x.owner || ownerOf(x.text, names), due: dueOf(x.text) }));
910
+ .map((x) => ({ ...x, owner: x.owner || ownerOf(x.text, names), due: x.due || dueOf(x.text) }));
911
911
  const questions = cap(caught.filter((x) => kindOf(x, 'question')).filter(once), 'questions');
912
912
  const points = cap(bitems.filter((x) => !x.dropped && !x.open && (x.cat === 'topic' || x.cat === 'statement')).filter(once), 'points');
913
913
  const facts = items.filter((x) => x.src === 'transcript' && isFact(x)).filter(once);
@@ -1569,7 +1569,7 @@ function updateBullet(dir, { id, bid, text, patch = {} } = {}) {
1569
1569
  return { ok: true, bullet: b };
1570
1570
  }
1571
1571
  /** The PM's correction to one bullet: its label (the seven), `drop` (out of its section and the summary), or its words. */
1572
- function editBullet(dir, { id, bid, cat, drop, text, now = Date.now() } = {}) {
1572
+ function editBullet(dir, { id, bid, cat, drop, text, owner, due, now = Date.now() } = {}) {
1573
1573
  const note = load(dir, id);
1574
1574
  if (!note) return err(404, 'no such note');
1575
1575
  if (!BULLET_ID_RE.test(String(bid || ''))) return err(400, 'that is not a bullet id');
@@ -1583,6 +1583,9 @@ function editBullet(dir, { id, bid, cat, drop, text, now = Date.now() } = {}) {
1583
1583
  if (t.length > MAX_BULLET) return err(413, 'that bullet is too long');
1584
1584
  if (t !== b.text) { if (b.orig_text === undefined) b.orig_text = b.text; b.text = t; delete b.check; }
1585
1585
  }
1586
+ // the PM's owner or due day for a next step; an empty one clears it
1587
+ if (owner !== undefined) { const o = String(owner == null ? '' : owner).replace(/\s+/g, ' ').trim().slice(0, 80); if (o) b.owner = o; else delete b.owner; }
1588
+ if (due !== undefined) { const d = String(due == null ? '' : due).replace(/\s+/g, ' ').trim().slice(0, 60); if (d) b.due = d; else delete b.due; }
1586
1589
  if (cat !== undefined) { b.cat = cat; b.cat_by = 'pm'; delete b.open; }
1587
1590
  if (drop === true) b.dropped = true; else if (drop === false) delete b.dropped;
1588
1591
  refreshLocal(note, now);
@@ -1642,14 +1645,36 @@ function removeHighlight(dir, { id, hid, now = Date.now() } = {}) {
1642
1645
  store(dir, note);
1643
1646
  return { ok: true };
1644
1647
  }
1645
- /** The PM's correction to the final notes: the overview, or the text of one decision, next step or open question. The
1646
- * first words the agent wrote are kept as `orig_text`. */
1648
+ /** The PM's correction to the final notes: the overview (before the final notes, the PM's summary of the note), or the text of one decision, next step or open question. The
1649
+ * first words the agent wrote are kept as `orig_text`. `field` corrects another part of one item instead: a next step's
1650
+ * `owner` or `due`, or a decision's `why`; an empty one clears it. */
1647
1651
  const FINAL_SECS = { decision: 'decisions', action: 'actions', question: 'questions' };
1648
- function editFinal(dir, { id, sec, i, text, now = Date.now() } = {}) {
1652
+ const FINAL_FIELDS = { action: { owner: 80, due: 60 }, decision: { why: 400 } };
1653
+ function editFinal(dir, { id, sec, i, text, field, now = Date.now() } = {}) {
1649
1654
  const note = load(dir, id);
1650
1655
  if (!note) return err(404, 'no such note');
1651
1656
  const f = note.final_notes;
1657
+ // before the final notes exist, the PM's summary is kept beside what was caught live; an empty one goes back to that
1658
+ if (!f && sec === 'overview') {
1659
+ const t = clip(text, 2000);
1660
+ if (t) note.overview_pm = t; else delete note.overview_pm;
1661
+ note.updated_at = iso(now);
1662
+ store(dir, note);
1663
+ return { ok: true, final_notes: null, overview_pm: note.overview_pm || null };
1664
+ }
1652
1665
  if (!f) return err(409, 'this note has no final notes yet');
1666
+ if (field !== undefined && field !== 'text') {
1667
+ const max = (FINAL_FIELDS[sec] || {})[field];
1668
+ if (!max) return err(400, 'a ' + (sec || 'note') + ' has no ' + String(field).slice(0, 20) + ' to correct');
1669
+ const list = f[FINAL_SECS[sec]];
1670
+ const x = Array.isArray(list) ? list[Number(i)] : null;
1671
+ if (!x) return err(404, 'no such item');
1672
+ x[field] = clip(text, max) || null;
1673
+ keepVersion(note, now);
1674
+ note.updated_at = iso(now);
1675
+ store(dir, note);
1676
+ return { ok: true, final_notes: f };
1677
+ }
1653
1678
  const t = clip(text, sec === 'overview' ? 2000 : 400);
1654
1679
  if (!t) return err(400, 'the text is empty');
1655
1680
  if (sec === 'overview') {
@@ -5,7 +5,7 @@
5
5
  *
6
6
  * A read: nothing is written and no page press is needed. The file is read from the repository (or the named workspace's
7
7
  * root) and refused when it resolves outside that root. From git it derives: the last change of the file, the last change
8
- * of every relative Markdown link it cites, and the last change of each ADR a `D<n>` id in its text names. Everything
8
+ * of every relative Markdown link it cites (a save or a "still current" mark in the document sheet counts as a change), and the last change of each ADR a `D<n>` id in its text names. Everything
9
9
  * else (requiredHeadings, data, readers, current, staleAfterDays) comes only from `context` in the body; a check without
10
10
  * its input answers "not enough input". No write routes in this wave.
11
11
  */
@@ -34,7 +34,18 @@ function adrFor(root, id) {
34
34
  try { return fs.readdirSync(dir).find((f) => new RegExp('^d0*' + n + '-', 'i').test(f)) || null; } catch { return null; }
35
35
  }
36
36
 
37
- function check(root, body) {
37
+ // The document's last change: its last commit, or a later save or "still current" mark in the document sheet, which
38
+ // writes the file without committing it (routes-doc-sheet.js lastSheetChange).
39
+ function lastChange(root, relOut, runtimeDir) {
40
+ const git = gitChanged(root, relOut);
41
+ let sheet = null;
42
+ if (runtimeDir) { try { sheet = require('./routes-doc-sheet.js').lastSheetChange(runtimeDir, relOut); } catch { sheet = null; } }
43
+ if (!git) return sheet;
44
+ if (!sheet) return git;
45
+ return Date.parse(sheet) > Date.parse(git) ? sheet : git;
46
+ }
47
+
48
+ function check(root, body, runtimeDir) {
38
49
  const rel = typeof body.path === 'string' ? body.path : '';
39
50
  if (!rel) return bad('path is required');
40
51
  const full = path.resolve(root, rel);
@@ -42,7 +53,7 @@ function check(root, body) {
42
53
  let text;
43
54
  try { if (!inside(root, full)) return bad('outside workspace', 404); text = fs.readFileSync(full, 'utf8'); } catch { return bad('file not found', 404); }
44
55
  const relOut = path.relative(root, full);
45
- const changed = gitChanged(root, relOut);
56
+ const changed = lastChange(root, relOut, runtimeDir);
46
57
  const sources = [];
47
58
  for (const m of text.matchAll(/\]\(([^)#\s]+\.md)(?:#[^)]*)?\)/g)) {
48
59
  if (/^[a-z]+:/i.test(m[1])) continue;
@@ -73,7 +84,7 @@ module.exports = {
73
84
  const body = got.body || {};
74
85
  const w = body.ws && ctx.getWs ? ctx.getWs(body.ws) : null;
75
86
  if (body.ws && !w) { ctx.json(res, { ok: false, error: 'no workspace' }, 404); return true; }
76
- const r = check(w ? w.root : (ctx.root || ROOT), body);
87
+ const r = check(w ? w.root : (ctx.root || ROOT), body, ctx.runtimeDir);
77
88
  ctx.json(res, r.body, r.status);
78
89
  return true;
79
90
  },
@@ -11,6 +11,17 @@
11
11
  * (lib/headless.js 'ask'); its answer becomes a proposal, never a write
12
12
  * GET /api/doc-sheet/ask?id= → { ok, id, state: working | proposed | failed, error?, proposal? }
13
13
  * POST /api/doc-sheet/change-note { path, downstream? } a draft body only; nothing is sent
14
+ * GET /api/doc-sheet/proposals?path= → { ok, proposals: [{ id, state, prompt, at, hash, after, sections: [{ name, kind, stale }] }] }
15
+ * GET /api/doc-sheet/log?path= → { ok, revisions, events } the edit log: revisions (with a reason added later) and
16
+ * the other entries (proposed, rejected, asked, marked current)
17
+ * POST /api/doc-sheet/reject { path, id, why } page press; a why is required; the proposal is closed, the file untouched
18
+ * POST /api/doc-sheet/why { path, rev, why } page press; a reason for a revision that has none (an outside change);
19
+ * appended to annotations.jsonl, the revision itself is not rewritten
20
+ * POST /api/doc-sheet/mark-current { path, why } page press; "still current" with a reason; it counts as the last change
21
+ *
22
+ * Every revision says who wrote the text and how it came in: via 'sheet' (an edit here), 'proposal' (accepted; text_by 'agent',
23
+ * by 'pm', the proposal id and the sections taken) or 'outside' (the file changed outside the sheet; git's last commit of
24
+ * the file names the author when there is one).
14
25
  *
15
26
  * Storage (append-only JSONL): <runtime>/doc-sheet/revisions.jsonl, proposals.jsonl and asks.jsonl.
16
27
  * The write is the same one /api/doc/save makes: the path must resolve inside the repository root, only Markdown is
@@ -56,12 +67,29 @@ function resolveDoc(root, rel) {
56
67
 
57
68
  const revsOf = (runtimeDir, rel) => S.revisions(readLines(path.join(dirOf(runtimeDir), 'revisions.jsonl')), rel);
58
69
 
70
+ // git's last commit of a file: who and why, when the file is committed as it reads now. Best effort; null otherwise.
71
+ function lastCommit(root, rel, text) {
72
+ try {
73
+ const { spawnSync } = require('child_process');
74
+ const r = spawnSync('git', ['log', '-1', '--format=%h%x09%an%x09%cI%x09%s', '--', rel], { cwd: root, encoding: 'utf8', timeout: 3000 });
75
+ if (r.status !== 0 || !r.stdout.trim()) return null;
76
+ const [commit, author, at, subject] = r.stdout.trim().split('\t');
77
+ const head = spawnSync('git', ['show', commit + ':' + rel.split(path.sep).join('/')], { cwd: root, encoding: 'utf8', timeout: 3000, maxBuffer: 8 << 20 });
78
+ if (head.status !== 0 || head.stdout !== text) return { committed: false };
79
+ return { committed: true, commit, author, at, subject: (subject || '').slice(0, 200), agent: /\b(claude|agent|bot)\b/i.test(author || '') };
80
+ } catch { return null; }
81
+ }
82
+
59
83
  // Record the file's current text first when it moved outside this sheet (or was never seen), so the fold is complete.
60
- function syncBaseline(runtimeDir, rel, text, now) {
84
+ function syncBaseline(runtimeDir, rel, text, now, root) {
61
85
  const revs = revsOf(runtimeDir, rel), last = revs[revs.length - 1];
62
86
  if (last && last.after_hash === S.hashText(text)) return last.rev;
63
87
  const rev = last ? last.rev + 1 : 1;
64
- const ev = { ...S.editWithWhy(last ? last.text : text, text, last ? 'changed outside the sheet' : 'first seen by the sheet', 'baseline', { rev, path: rel, at: now.toISOString() }) };
88
+ const git = root ? lastCommit(root, rel, text) : null;
89
+ const why = git && git.committed && git.subject ? git.subject : (last ? 'changed outside the sheet' : 'first seen by the sheet');
90
+ const ev = { ...S.editWithWhy(last ? last.text : text, text, why, 'baseline', { rev, path: rel, at: now.toISOString() }), via: last ? 'outside' : 'first',
91
+ why_known: !!(git && git.committed && git.subject) };
92
+ if (git && git.committed) { ev.git = { commit: git.commit, author: git.author, at: git.at }; ev.text_by = git.agent ? 'agent' : 'person'; }
65
93
  append(runtimeDir, 'revisions.jsonl', ev);
66
94
  return rev;
67
95
  }
@@ -75,21 +103,30 @@ function logChange(ctx, w, rel, before, after, why, by, now) {
75
103
  try { fs.mkdirSync(dir, { recursive: true }); fs.appendFileSync(path.join(dir, 'doc-changes.jsonl'), JSON.stringify(rec) + '\n'); return true; } catch { return null; }
76
104
  }
77
105
 
78
- function writeDoc(root, rel, before, after, why, runtimeDir, ctx, w, now) {
106
+ function writeDoc(root, rel, before, after, why, runtimeDir, ctx, w, now, extra) {
79
107
  const r = resolveDoc(root, rel); if (r.error) return r.error;
80
108
  if (r.text !== before) return fail(409, 'stale: the document changed since it was read');
81
- syncBaseline(runtimeDir, r.rel, r.text, now);
109
+ syncBaseline(runtimeDir, r.rel, r.text, now, root);
82
110
  const rev = revsOf(runtimeDir, r.rel).length + 1;
83
- const ev = S.editWithWhy(before, after, why, 'pm', { rev, path: r.rel, at: now.toISOString() });
111
+ const ev = { ...S.editWithWhy(before, after, why, 'pm', { rev, path: r.rel, at: now.toISOString() }), via: 'sheet', text_by: 'person', ...(extra || {}) };
84
112
  try { fs.writeFileSync(r.full, after); } catch { return fail(500, 'could not write the document — nothing was changed'); }
85
113
  append(runtimeDir, 'revisions.jsonl', ev);
86
114
  const logged = logChange(ctx, w, r.rel, before, after, why, 'pm', now);
87
115
  return { status: 200, body: { ok: true, path: r.rel, rev, revision: { ...ev, text: undefined }, logged: !!logged } };
88
116
  }
89
117
 
118
+ const annotationsOf = (runtimeDir, rel) => readLines(path.join(dirOf(runtimeDir), 'annotations.jsonl')).filter((a) => a && a.path === rel);
119
+ function revsOut(runtimeDir, rel) {
120
+ const notes = annotationsOf(runtimeDir, rel);
121
+ return revsOf(runtimeDir, rel).map(({ text, ...rest }) => {
122
+ const n = notes.filter((a) => a.rev === rest.rev).pop();
123
+ return n ? { ...rest, why_added: n.why, why_added_at: n.at } : rest;
124
+ });
125
+ }
90
126
  function revisionsRoute(root, q, runtimeDir) {
91
127
  const r = resolveDoc(root, q.path); if (r.error) return r.error;
92
- return { status: 200, body: { ok: true, path: r.rel, revisions: revsOf(runtimeDir, r.rel).map(({ text, ...rest }) => rest) } };
128
+ syncBaseline(runtimeDir, r.rel, r.text, new Date(), root);
129
+ return { status: 200, body: { ok: true, path: r.rel, revisions: revsOut(runtimeDir, r.rel) } };
93
130
  }
94
131
  function atRoute(root, q, runtimeDir) {
95
132
  const r = resolveDoc(root, q.path); if (r.error) return r.error;
@@ -100,6 +137,14 @@ function atRoute(root, q, runtimeDir) {
100
137
  return { status: 200, body: { ok: true, path: r.rel, rev, text } };
101
138
  }
102
139
 
140
+ /** The last time the document changed through this sheet or was marked current: the Fresh check counts it as a change. */
141
+ function lastSheetChange(runtimeDir, rel) {
142
+ let t = null;
143
+ for (const e of revsOf(runtimeDir, rel)) if (e.by !== 'baseline' && e.at && (!t || e.at > t)) t = e.at;
144
+ for (const m of readLines(path.join(dirOf(runtimeDir), 'marks.jsonl'))) if (m && m.path === rel && m.at && (!t || m.at > t)) t = m.at;
145
+ return t;
146
+ }
147
+
103
148
  function edit(req, body, ctx, now = new Date()) {
104
149
  const g = guard.requirePagePress(req, body); if (!g.ok) return fail(g.status, g.error);
105
150
  if (typeof body.why !== 'string' || !body.why.trim()) return fail(400, 'a why is required: say why the document changes');
@@ -120,11 +165,16 @@ function propose(req, body, ctx, now = new Date()) {
120
165
  if (!body || typeof body !== 'object' || Array.isArray(body)) return fail(400, 'body must be an object');
121
166
  if (typeof body.after !== 'string') return fail(400, 'after is required');
122
167
  const r = resolveDoc(ctx.root, body.path); if (r.error) return r.error;
168
+ // an agent answering a request from the sheet names the request; its prompt is the PM's words, kept on the request
169
+ const asked = typeof body.ask === 'string' ? readLines(path.join(dirOf(ctx.runtimeDir), 'asks.jsonl')).find((a) => a.id === body.ask && a.path === r.rel) : null;
170
+ if (asked && (typeof body.prompt !== 'string' || !body.prompt.trim())) body = { ...body, prompt: asked.prompt };
123
171
  let p; try { p = S.askAgentProposal({ before: r.text, after: body.after, prompt: body.prompt }); } catch (e) { return fail(400, e.message); }
124
172
  const id = typeof body.id === 'string' && proposalsOf(ctx.runtimeDir).has(body.id) ? body.id : 'prop-' + require('crypto').randomBytes(6).toString('hex');
125
173
  const prior = proposalsOf(ctx.runtimeDir).get(id);
126
174
  if (prior && prior.path !== r.rel) return fail(400, 'that proposal belongs to another document');
127
- const row = { event: prior ? 'revised' : 'proposed', id, path: r.rel, at: now.toISOString(), state: 'proposed', prompt: p.prompt, before_hash: p.before_hash, before_text: r.text, after: p.after, hash: p.hash };
175
+ const row = { event: prior ? 'revised' : 'proposed', id, path: r.rel, at: now.toISOString(), state: 'proposed', prompt: p.prompt, before_hash: p.before_hash, before_text: r.text, after: p.after, hash: p.hash,
176
+ by: body.by === 'pm' ? 'pm' : 'agent' };
177
+ if (asked) { row.ask = asked.id; if (asked.session) row.session = asked.session; }
128
178
  append(ctx.runtimeDir, 'proposals.jsonl', row);
129
179
  return { status: 200, body: { ok: true, id, state: 'proposed', hash: p.hash, diff: p.diff, voided_hash: prior ? prior.hash : null } };
130
180
  }
@@ -137,12 +187,100 @@ function accept(req, body, ctx, now = new Date()) {
137
187
  if (!p || p.path !== r.rel) return fail(404, 'no such proposal');
138
188
  const res = S.acceptProposal(p, { hash: body.hash, by: 'pm' }); // by is set here, never read from the body
139
189
  if (!res.ok) return fail(409, res.reason, { reason: res.reason });
140
- if (r.text !== p.before_text) return fail(409, 'stale: the document changed since this proposal was made', { reason: 'stale' });
141
- const out = writeDoc(root, body.path, r.text, p.after, p.prompt, ctx.runtimeDir, ctx, ctx.w || null, now);
142
- if (out.status === 200) append(ctx.runtimeDir, 'proposals.jsonl', { event: 'accepted', id: p.id, path: r.rel, at: now.toISOString(), state: 'accepted', hash: p.hash });
190
+ const all = changedNames(p.before_text, p.after);
191
+ const names = Array.isArray(body.sections) ? body.sections.map(String).filter((n) => all.includes(n)) : null;
192
+ if (names && !names.length) return fail(400, 'pick at least one section of the proposal');
193
+ let text;
194
+ if (names) {
195
+ const m = S.mergeSections(r.text, p.before_text, p.after, names);
196
+ if (!m.ok) return fail(409, m.stale ? 'stale: ' + m.stale.join(', ') + ' changed since this proposal was made' : 'not in the proposal: ' + m.unknown.join(', '), { reason: 'stale', stale: m.stale || [] });
197
+ text = m.text;
198
+ } else {
199
+ if (r.text !== p.before_text) return fail(409, 'stale: the document changed since this proposal was made', { reason: 'stale' });
200
+ text = p.after;
201
+ }
202
+ const taken = names || all;
203
+ const out = writeDoc(root, body.path, r.text, text, p.prompt, ctx.runtimeDir, ctx, ctx.w || null, now,
204
+ { via: 'proposal', proposal: p.id, text_by: p.by === 'pm' ? 'person' : 'agent', sections: taken, of: all.length });
205
+ if (out.status === 200) append(ctx.runtimeDir, 'proposals.jsonl', { event: 'accepted', id: p.id, path: r.rel, at: now.toISOString(), state: 'accepted', hash: p.hash, sections: taken, of: all.length, rev: out.body.rev });
143
206
  return out;
144
207
  }
145
208
 
209
+ const changedNames = (a, b) => {
210
+ const A = S.splitSections(a), B = S.splitSections(b); const out = [];
211
+ B.forEach((x) => { const y = A.find((q) => q.name === x.name); if (!y || y.text !== x.text) out.push(x.name); });
212
+ A.forEach((y) => { if (!B.some((x) => x.name === y.name)) out.push(y.name); });
213
+ return out;
214
+ };
215
+
216
+ function proposalsRoute(root, q, runtimeDir) {
217
+ const r = resolveDoc(root, q.path); if (r.error) return r.error;
218
+ const cur = S.splitSections(r.text);
219
+ const list = [...proposalsOf(runtimeDir).values()].filter((p) => p.path === r.rel).map((p) => {
220
+ const A = S.splitSections(p.before_text || ''), B = S.splitSections(p.after || '');
221
+ const sections = changedNames(p.before_text || '', p.after || '').map((name) => {
222
+ const a = A.find((x) => x.name === name), b = B.find((x) => x.name === name), c = cur.find((x) => x.name === name);
223
+ const kind = !a ? 'added' : !b ? 'removed' : 'changed';
224
+ const stale = a ? (!c || c.text !== a.text) : !!c;
225
+ return { name, kind, before: a ? a.text : '', after: b ? b.text : '', current: c ? c.text : null, stale };
226
+ });
227
+ return { id: p.id, state: p.state, prompt: p.prompt, at: p.at, hash: p.hash, by: p.by || 'agent', session: p.session || null, ask: p.ask || null,
228
+ closed_at: p.state === 'proposed' ? null : p.at, why: p.why || null, taken: p.sections || null, of: p.of || null, rev: p.rev || null, sections };
229
+ }).sort((a, b) => String(b.at).localeCompare(String(a.at)));
230
+ return { status: 200, body: { ok: true, path: r.rel, proposals: list } };
231
+ }
232
+
233
+ function logRoute(root, q, runtimeDir) {
234
+ const r = resolveDoc(root, q.path); if (r.error) return r.error;
235
+ syncBaseline(runtimeDir, r.rel, r.text, new Date(), root);
236
+ const events = [];
237
+ const firstOf = new Map();
238
+ for (const e of readLines(path.join(dirOf(runtimeDir), 'proposals.jsonl'))) {
239
+ if (!e || e.path !== r.rel) continue;
240
+ if (e.event === 'proposed' || e.event === 'revised') { if (!firstOf.has(e.id)) firstOf.set(e.id, e.prompt); events.push({ kind: e.event, at: e.at, id: e.id, prompt: e.prompt || firstOf.get(e.id), by: e.by || 'agent', session: e.session || null }); }
241
+ else if (e.event === 'rejected') events.push({ kind: 'rejected', at: e.at, id: e.id, prompt: firstOf.get(e.id) || null, why: e.why, by: 'pm' });
242
+ }
243
+ for (const a of readLines(path.join(dirOf(runtimeDir), 'asks.jsonl'))) {
244
+ if (!a || a.path !== r.rel) continue;
245
+ if (a.event === 'asked') events.push({ kind: 'asked', at: a.at, id: a.id, prompt: a.prompt, session: a.session || null, by: 'pm' });
246
+ else if (a.event === 'failed') events.push({ kind: 'ask-failed', at: a.at, id: a.id, why: a.error, by: 'agent' });
247
+ }
248
+ for (const m of readLines(path.join(dirOf(runtimeDir), 'marks.jsonl'))) if (m && m.path === r.rel) events.push({ kind: 'marked', at: m.at, why: m.why, by: 'pm' });
249
+ return { status: 200, body: { ok: true, path: r.rel, revisions: revsOut(runtimeDir, r.rel), events } };
250
+ }
251
+
252
+ const needWhy = (body) => (typeof body.why !== 'string' || !body.why.trim() ? fail(400, 'a why is required') : null);
253
+
254
+ function reject(req, body, ctx, now = new Date()) {
255
+ const g = guard.requirePagePress(req, body); if (!g.ok) return fail(g.status, g.error);
256
+ const nw = needWhy(body); if (nw) return nw;
257
+ const r = resolveDoc(ctx.root, body.path); if (r.error) return r.error;
258
+ const p = proposalsOf(ctx.runtimeDir).get(body.id);
259
+ if (!p || p.path !== r.rel) return fail(404, 'no such proposal');
260
+ if (p.state !== 'proposed') return fail(409, 'the proposal is already ' + p.state);
261
+ append(ctx.runtimeDir, 'proposals.jsonl', { event: 'rejected', id: p.id, path: r.rel, at: now.toISOString(), state: 'rejected', why: body.why.trim().slice(0, 400) });
262
+ return { status: 200, body: { ok: true, id: p.id, state: 'rejected' } };
263
+ }
264
+
265
+ function addWhy(req, body, ctx, now = new Date()) {
266
+ const g = guard.requirePagePress(req, body); if (!g.ok) return fail(g.status, g.error);
267
+ const nw = needWhy(body); if (nw) return nw;
268
+ const r = resolveDoc(ctx.root, body.path); if (r.error) return r.error;
269
+ const rev = Number(body.rev);
270
+ if (!revsOf(ctx.runtimeDir, r.rel).some((e) => e.rev === rev)) return fail(404, 'no such revision');
271
+ append(ctx.runtimeDir, 'annotations.jsonl', { path: r.rel, rev, why: body.why.trim().slice(0, 400), at: now.toISOString(), by: 'pm' });
272
+ return { status: 200, body: { ok: true, rev } };
273
+ }
274
+
275
+ function markCurrent(req, body, ctx, now = new Date()) {
276
+ const g = guard.requirePagePress(req, body); if (!g.ok) return fail(g.status, g.error);
277
+ const nw = needWhy(body); if (nw) return nw;
278
+ const r = resolveDoc(ctx.root, body.path); if (r.error) return r.error;
279
+ append(ctx.runtimeDir, 'marks.jsonl', { path: r.rel, why: body.why.trim().slice(0, 400), at: now.toISOString(), by: 'pm' });
280
+ return { status: 200, body: { ok: true, at: now.toISOString() } };
281
+ }
282
+
283
+
146
284
  /* ── Ask the agent from the page (page-starts-agent) ──
147
285
  * The run reads the document and answers with the whole revised text between two marker lines. It has Read, Glob and Grep
148
286
  * only, so it cannot write; its answer is recorded through propose() and changes nothing until the PM's slide accepts it. */
@@ -187,7 +325,7 @@ function settleAsk(ctx, a, { result, timedOut, stopped, note } = {}, now = new D
187
325
  if (r.text !== a.before_text) return end({ event: 'failed', state: 'failed', error: 'The document changed while the agent worked. Ask again.' });
188
326
  if (/\n$/.test(r.text) && !/\n$/.test(after)) after += '\n';
189
327
  if (after === r.text) return end({ event: 'failed', state: 'failed', error: 'The agent proposed no change.' });
190
- const p = propose(null, { path: a.path, prompt: a.prompt, after }, ctx, now);
328
+ const p = propose(null, { path: a.path, prompt: a.prompt, after, by: 'agent', ask: a.id }, ctx, now);
191
329
  if (p.status !== 200) return end({ event: 'failed', state: 'failed', error: p.body.error });
192
330
  return end({ event: 'proposed', state: 'proposed', proposal: p.body.id, hash: p.body.hash });
193
331
  }
@@ -231,7 +369,7 @@ const respond = (ctx, res, r) => { ctx.json(res, r.body, r.status); return true;
231
369
 
232
370
  module.exports = {
233
371
  prefixes: ['/api/doc-sheet'],
234
- resolveDoc, revisionsRoute, atRoute, edit, propose, accept, changeNote, ask, askState, askPrompt, parseAsk, settleAsk,
372
+ resolveDoc, revisionsRoute, atRoute, edit, propose, accept, changeNote, proposalsRoute, logRoute, reject, addWhy, markCurrent, lastSheetChange, ask, askState, askPrompt, parseAsk, settleAsk,
235
373
  handle: async (req, res, url, ctx) => {
236
374
  const p = url.pathname;
237
375
  const w = (url.searchParams.get('ws') && ctx.getWs) ? ctx.getWs(url.searchParams.get('ws')) : null;
@@ -239,7 +377,10 @@ module.exports = {
239
377
  if (req.method === 'GET' && p === '/api/doc-sheet/revisions') return respond(ctx, res, revisionsRoute(base.root, Object.fromEntries(url.searchParams), base.runtimeDir));
240
378
  if (req.method === 'GET' && p === '/api/doc-sheet/ask') return respond(ctx, res, askState(Object.fromEntries(url.searchParams), base));
241
379
  if (req.method === 'GET' && p === '/api/doc-sheet/at') return respond(ctx, res, atRoute(base.root, Object.fromEntries(url.searchParams), base.runtimeDir));
242
- const fn = req.method === 'POST' && { '/api/doc-sheet/edit': edit, '/api/doc-sheet/propose': propose, '/api/doc-sheet/accept': accept, '/api/doc-sheet/ask': ask,
380
+ if (req.method === 'GET' && p === '/api/doc-sheet/proposals') return respond(ctx, res, proposalsRoute(base.root, Object.fromEntries(url.searchParams), base.runtimeDir));
381
+ if (req.method === 'GET' && p === '/api/doc-sheet/log') return respond(ctx, res, logRoute(base.root, Object.fromEntries(url.searchParams), base.runtimeDir));
382
+ const fn = req.method === 'POST' && { '/api/doc-sheet/edit': edit, '/api/doc-sheet/propose': propose, '/api/doc-sheet/accept': accept,
383
+ '/api/doc-sheet/reject': reject, '/api/doc-sheet/why': addWhy, '/api/doc-sheet/mark-current': markCurrent, '/api/doc-sheet/ask': ask,
243
384
  '/api/doc-sheet/change-note': (rq, b, c) => changeNote(c.root, b, c.runtimeDir) }[p];
244
385
  if (!fn) return false;
245
386
  const got = await ctx.readJson(req);