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 +1 -1
- package/payload/KIT-VERSION +1 -1
- package/payload/planning/evals/eval-calibration.md +1 -1
- package/payload/planning/okrs/health-budgets.md +1 -1
- package/payload/state/discoveries.md +1 -1
- package/workspace/guide/ask-the-agent.md +5 -5
- package/workspace/guide/console-calendar.md +1 -1
- package/workspace/guide/document-sheet.md +58 -0
- package/workspace/guide/index.md +1 -0
- package/workspace/lib/engine/doc-health.js +4 -2
- package/workspace/lib/engine/doc-sheet.js +49 -1
- package/workspace/lib/notes.js +31 -6
- package/workspace/lib/routes-doc-health.js +15 -4
- package/workspace/lib/routes-doc-sheet.js +154 -13
- package/workspace/lib/routes-notes.js +4 -4
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/public/pm/c3-demo-boards.js +6 -1
- package/workspace/public/pm/c3-demo-init.js +44 -3
- package/workspace/public/pm/c3-demo-rep2.js +27 -6
- package/workspace/public/pm/c3-doc-sheet.js +586 -184
- package/workspace/public/pm/c3-md.js +395 -0
- package/workspace/public/pm/c3-notes.js +35 -16
- package/workspace/public/pm/console3.css +169 -0
- package/workspace/public/pm/dest-notes.js +1 -0
- package/workspace/public/pm/pm-work-view.js +1 -1
- package/workspace/public/pm/tokens.css +2 -2
- package/workspace/test-lists/c3-doc-sheet-rich.json +6 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-pmos",
|
|
3
|
-
"version": "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": {
|
package/payload/KIT-VERSION
CHANGED
|
@@ -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:
|
|
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:
|
|
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:
|
|
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 **
|
|
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,
|
|
18
|
-
5.
|
|
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
|
|
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
|
|
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.
|
|
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.
|
package/workspace/guide/index.md
CHANGED
|
@@ -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
|
|
28
|
-
|
|
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
|
-
|
|
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 };
|
package/workspace/lib/notes.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
141
|
-
const
|
|
142
|
-
if (
|
|
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
|
-
|
|
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);
|