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