create-pmos 0.151.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.151.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@ccceced
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:34:16Z"}
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:34:16Z"}
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:34:16Z"}
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
 
@@ -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 };
@@ -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);
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.151.0",
3
+ "version": "0.152.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "pmos-workspace",
9
- "version": "0.151.0",
9
+ "version": "0.152.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.151.0",
3
+ "version": "0.152.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",
@@ -562,7 +562,7 @@ export function handle(S, method, path, p, body) {
562
562
 
563
563
  /* ── views of other routes, for the Releases, Docs and initiative tabs (c3-ib-releases.js, c3-ib-docs.js, c3-ii-tabs.js) ──
564
564
  * These answer only a request that carries `board=` (the tabs add it; the old page never does), so the old demo fixtures keep answering
565
- * everything else: GET /api/releases, /api/docs/library, /api/my-work, /api/c3-init/breakdown, /api/doc and /api/doc-sheet/revisions.
565
+ * everything else: GET /api/releases, /api/docs/library, /api/my-work, /api/c3-init/breakdown, /api/doc and /api/doc-sheet/revisions, /proposals and /log.
566
566
  * The rows are made from the boards' featured initiatives. IDPF-07 gets no documents on purpose, so the empty state can be seen. */
567
567
  function handleMore(S, method, path, p) {
568
568
  if (method !== 'GET') return undefined;
@@ -574,6 +574,11 @@ function handleMore(S, method, path, p) {
574
574
  if (path === '/api/doc') return { status: 200, text };
575
575
  return okb({ path: p.path, revisions: [{ rev: 1, by: 'human:wawan', at: at(-3, 9) + '.000Z', why: 'First version', before_hash: '', after_hash: '' }] });
576
576
  }
577
+ /* the document sheet's Proposals and Edit log tabs: none yet, one first version */
578
+ if ((path === '/api/doc-sheet/proposals' || path === '/api/doc-sheet/log') && /^planning\/ib\//.test(p.path || '')) {
579
+ if (path === '/api/doc-sheet/proposals') return okb({ path: p.path, proposals: [] });
580
+ return okb({ path: p.path, revisions: [{ rev: 1, by: 'human:wawan', at: at(-3, 9) + '.000Z', why: 'First version', via: 'sheet', text_by: 'person', before_hash: '', after_hash: '' }], events: [] });
581
+ }
577
582
  if (!p.board) return undefined;
578
583
  if (path === '/api/releases') {
579
584
  return okb({ board: { name: 'Demo board' }, releases: S.releases.filter((x) => x.board === p.board).map((x) => ({ id: x.id, name: x.name, type: 'both', initiative_ids: x.scope.slice(), deploy_at: x.date + 'T11:00:00', release_at: x.date + 'T12:00:00', accepted_at: x.status === 'Released' ? x.date + 'T12:30:00' : null, gapFlags: [], tags: { detected: [], reason: null }, summary: [], status: x.status, board: x.board })) });