create-pmos 0.38.3 → 0.38.5
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/lane-defs.js +226 -0
- package/payload/scripts/workflow-state.js +108 -24
- package/payload/scripts/workflow-sweep.js +155 -3
- package/payload/state/discoveries.md +1 -1
- package/workspace/lib/audit.js +54 -0
- package/workspace/lib/engine/lane-defs.js +226 -0
- package/workspace/lib/engine/workflow-state.js +108 -24
- package/workspace/lib/engine/workflow-sweep.js +934 -0
- package/workspace/package-lock.json +1 -1
- package/workspace/package.json +2 -2
- package/workspace/public/pm/data.js +87 -24
- package/workspace/public/pm/panel-detail.js +12 -2
- package/workspace/public/pm/panel-repos.js +54 -0
- package/workspace/public/pm/tab-task.js +4 -1
- package/workspace/server.js +48 -1
package/workspace/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pmos-workspace",
|
|
3
|
-
"version": "0.38.
|
|
3
|
+
"version": "0.38.5",
|
|
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",
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
"test:parity": "node design-parity.js --states",
|
|
16
16
|
"test:all": "npm test && npm run test:panels && npm run test:ui && npm run test:parity && npm run test:offline",
|
|
17
17
|
"doctor": "node doctor.js",
|
|
18
|
-
"test:panels": "node pm-shell-test.js && node pm-repos-test.js && node pm-initiatives-test.js && node pm-detail-test.js && node pm-tabs-test.js && node pm-term-test.js && node pm-modes-test.js && node pm-r1-test.js && node pm-r2-test.js && node pm-r3-test.js && node pm-r4-test.js && node pm-r5-test.js && node pm-r6-test.js && node pm-r7-test.js && node pm-r8-test.js && node pm-r9-test.js && node pm-r10-test.js && node pm-r11-test.js && node pm-r12-test.js && node pm-r13-test.js && node pm-r14-test.js && node pm-r15-test.js",
|
|
18
|
+
"test:panels": "node pm-shell-test.js && node pm-repos-test.js && node pm-initiatives-test.js && node pm-detail-test.js && node pm-tabs-test.js && node pm-term-test.js && node pm-modes-test.js && node pm-r1-test.js && node pm-r2-test.js && node pm-r3-test.js && node pm-r4-test.js && node pm-r5-test.js && node pm-r6-test.js && node pm-r7-test.js && node pm-r8-test.js && node pm-r9-test.js && node pm-r10-test.js && node pm-r11-test.js && node pm-r12-test.js && node pm-r13-test.js && node pm-r14-test.js && node pm-r15-test.js && node pm-r16-test.js && node pm-r17-test.js",
|
|
19
19
|
"test:offline": "node offline-tarball.test.js"
|
|
20
20
|
},
|
|
21
21
|
"engines": {
|
|
@@ -110,43 +110,55 @@ const RUNGS = {
|
|
|
110
110
|
feature: {
|
|
111
111
|
intake: ['route', 'anchor', 'prototype', 'understand', 'priors'],
|
|
112
112
|
spec: ['spec'], contract: ['contract'], build: ['build', 'quality'],
|
|
113
|
-
eval: ['review', 'explain'], accepted: ['accept'],
|
|
113
|
+
eval: ['review', 'explain'], accepted: ['accept', 'outcome'],
|
|
114
114
|
},
|
|
115
115
|
discovery: {
|
|
116
116
|
intake: ['route', 'anchor', 'prototype', 'understand', 'priors', 'spec', 'contract'],
|
|
117
|
-
explore: ['build', 'quality'], synthesize: ['review'], decide: ['explain', 'accept'],
|
|
117
|
+
explore: ['build', 'quality'], synthesize: ['review'], decide: ['explain', 'accept', 'outcome'],
|
|
118
118
|
},
|
|
119
119
|
chore: {
|
|
120
120
|
intake: ['route', 'anchor', 'prototype', 'understand', 'priors', 'spec', 'contract'],
|
|
121
|
-
fix: ['build', 'quality'], verify: ['review', 'explain', 'accept'],
|
|
121
|
+
fix: ['build', 'quality'], verify: ['review', 'explain', 'accept', 'outcome'],
|
|
122
122
|
},
|
|
123
123
|
incident: {
|
|
124
124
|
intake: ['route', 'anchor', 'prototype', 'understand', 'priors'],
|
|
125
|
-
contain: ['build', 'quality'], record: ['spec', 'contract', 'review'], verify: ['explain', 'accept'],
|
|
125
|
+
contain: ['build', 'quality'], record: ['spec', 'contract', 'review'], verify: ['explain', 'accept', 'outcome'],
|
|
126
126
|
},
|
|
127
127
|
content: {
|
|
128
128
|
intake: ['route', 'anchor', 'prototype', 'understand', 'priors'],
|
|
129
|
-
draft: ['spec', 'contract', 'build', 'quality'], review: ['review'], publish: ['explain', 'accept'],
|
|
129
|
+
draft: ['spec', 'contract', 'build', 'quality'], review: ['review'], publish: ['explain', 'accept', 'outcome'],
|
|
130
130
|
},
|
|
131
131
|
prototype: {
|
|
132
132
|
intake: ['route', 'anchor', 'prototype', 'understand', 'priors', 'spec', 'contract'],
|
|
133
|
-
specimens: ['build', 'quality'], grill: ['review', 'explain'], dispose: ['accept'],
|
|
133
|
+
specimens: ['build', 'quality'], grill: ['review', 'explain'], dispose: ['accept', 'outcome'],
|
|
134
134
|
},
|
|
135
135
|
};
|
|
136
136
|
|
|
137
|
-
/* D68 — the pm-artifacts profile's ladder: every
|
|
138
|
-
* (→ handed off). Keyed `pm:<lane>` because `discovery` and `chore` exist in BOTH profiles
|
|
139
|
-
* different ladders; the profile comes from the engine's own ladder row, never guessed.
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
137
|
+
/* D68/D77 — the pm-artifacts profile's ladder: every document lane walks brief → draft → review →
|
|
138
|
+
* approved (→ handed off). Keyed `pm:<lane>` because `discovery` and `chore` exist in BOTH profiles
|
|
139
|
+
* with different ladders; the profile comes from the engine's own ladder row, never guessed.
|
|
140
|
+
*
|
|
141
|
+
* The lane NAMES and whether a lane hands off are not written here. They come from the server's
|
|
142
|
+
* lane definitions (scripts/lane-defs.js, D77) through `setLaneDefs`, so a lane added to the
|
|
143
|
+
* definitions has phases with no edit in this file. Before the first payload arrives, and for a
|
|
144
|
+
* lane the server did not name, the phases are `pm:prd`'s — the profile's strictest. */
|
|
145
|
+
let laneDefsFromServer = null;
|
|
146
|
+
function buildPmLane(l, withHandoff) {
|
|
144
147
|
PHASES['pm:' + l] = ['intake', 'brief', 'draft', 'review', 'approved'].concat(withHandoff ? ['handed off'] : []);
|
|
145
148
|
RUNGS['pm:' + l] = Object.assign({ intake: ['route', 'anchor'], brief: ['brief'], draft: ['draft'], review: ['review'], approved: ['approved'] },
|
|
146
149
|
withHandoff ? { 'handed off': ['handoff'] } : {});
|
|
147
150
|
}
|
|
151
|
+
buildPmLane('prd', true);
|
|
152
|
+
/** Register the lane definitions the server sent with an initiative payload. */
|
|
153
|
+
function setLaneDefs(defs) {
|
|
154
|
+
if (!defs || typeof defs !== 'object') return;
|
|
155
|
+
laneDefsFromServer = defs;
|
|
156
|
+
for (const [l, d] of Object.entries(defs)) {
|
|
157
|
+
if (Array.isArray(d.rungs)) buildPmLane(l, d.rungs.includes('handoff'));
|
|
158
|
+
}
|
|
159
|
+
}
|
|
148
160
|
const laneOf = (lane, profile) => (profile === 'pm-artifacts'
|
|
149
|
-
? 'pm:' + (
|
|
161
|
+
? 'pm:' + (PHASES['pm:' + lane] ? lane : 'prd')
|
|
150
162
|
: (PHASES[lane] ? lane : 'feature'));
|
|
151
163
|
|
|
152
164
|
/**
|
|
@@ -166,7 +178,8 @@ function phaseRungStatus(rungs) {
|
|
|
166
178
|
if (rungs.every((r) => !r.evidence)) return 'pending';
|
|
167
179
|
if (rungs.some((r) => r.status === 'blocked')) return 'blocked';
|
|
168
180
|
if (rungs.some((r) => r.status === 'current')) return 'current';
|
|
169
|
-
|
|
181
|
+
// An outcome still waiting on its horizon is time, not unfinished work: it does not hold the phase open.
|
|
182
|
+
const decided = rungs.filter((r) => r.status !== 'na' && r.status !== 'unknown' && !(r.key === 'outcome' && r.status === 'pending'));
|
|
170
183
|
if (decided.length && decided.every((r) => r.status === 'done')) return 'done';
|
|
171
184
|
return 'pending';
|
|
172
185
|
}
|
|
@@ -200,8 +213,21 @@ function derivedTasks(phase, rungs, ctx) {
|
|
|
200
213
|
? ['resolve the ' + phase + ' blocker', 'produce the ' + phase + ' output', 'record the result']
|
|
201
214
|
: ['read the inputs for ' + phase, 'produce the ' + phase + ' output', 'record the result'];
|
|
202
215
|
const evidence = rungs.filter((r) => r.evidence).map((r) => r.key + ': ' + r.evidence).join(' · ') || null;
|
|
203
|
-
const inputs = [ctx.record, ctx.prd, ctx.rubric].filter(Boolean)
|
|
216
|
+
const inputs = [ctx.record, ctx.brief, ctx.doc, ctx.prd, ctx.rubric].filter(Boolean)
|
|
217
|
+
.filter((v, i, a) => a.indexOf(v) === i).join(' · ') || null;
|
|
218
|
+
// D77 — what each rung read and the sentence that decides it, from the engine's own basis.
|
|
219
|
+
const basis = rungs.map((r) => ({ key: r.key, b: r.basis || null })).filter((x) => x.b);
|
|
220
|
+
const sentences = basis.map((x) => x.b.rule).filter((v, i, a) => v && a.indexOf(v) === i);
|
|
221
|
+
const readLine = basis.filter((x) => x.b.file)
|
|
222
|
+
.map((x) => x.b.file + (x.b.first_seen ? ' (first seen ' + x.b.first_seen + ')' : '')).filter((v, i, a) => a.indexOf(v) === i);
|
|
204
223
|
const rules = rungs.map((r) => r.rule).filter(Boolean);
|
|
224
|
+
// The produced artifact: a file a rung of this phase read, never guessed. A document or record is
|
|
225
|
+
// preferred over a ledger for the second task; the third task shows the ledger the result lands in.
|
|
226
|
+
const files = basis.filter((x) => x.b.file && x.b.source === 'file').map((x) => x.b.file);
|
|
227
|
+
const ledgers = basis.filter((x) => x.b.file && x.b.source === 'ledger').map((x) => x.b.file);
|
|
228
|
+
const produced = files[files.length - 1] || pathIn(lead.evidence) || pathIn(lead.next) || null;
|
|
229
|
+
const recorded = ledgers[ledgers.length - 1] || null;
|
|
230
|
+
const def = ctx.laneDef || null;
|
|
205
231
|
return titles.map((title, i) => ({
|
|
206
232
|
title,
|
|
207
233
|
status: (TASK_STATUS[s] || TASK_STATUS.pending)[i],
|
|
@@ -210,13 +236,14 @@ function derivedTasks(phase, rungs, ctx) {
|
|
|
210
236
|
// engine's sentence, the other is a list of paths the record declares.
|
|
211
237
|
instruction: (i === 0 && inputs ? 'Read ' + inputs : null) || lead.next || lead.evidence || null,
|
|
212
238
|
input: inputs,
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
239
|
+
// What the lane says finishing means, then what the engine found. Both are data, not prose written here.
|
|
240
|
+
expect: [def ? def.done : null, evidence ? 'Found: ' + evidence : null].filter(Boolean).join(' — ') || null,
|
|
241
|
+
evalNote: sentences.length
|
|
242
|
+
? sentences.join(' ') + (readLine.length ? ' Decided from: ' + readLine.join(', ') + '.' : '')
|
|
243
|
+
: rules.length
|
|
244
|
+
? 'workflow rule ' + rules.join(', ') + ' decides this phase'
|
|
245
|
+
: 'the workflow engine derives this phase from ' + rungs.map((r) => r.key).join(', '),
|
|
246
|
+
output: i === 1 ? produced : (i === 2 ? recorded : null),
|
|
220
247
|
}));
|
|
221
248
|
}
|
|
222
249
|
|
|
@@ -385,11 +412,16 @@ function recordFrom(payload) {
|
|
|
385
412
|
return { readError: error, l2: ['read failed', error.slice(0, 60), C.des] };
|
|
386
413
|
}
|
|
387
414
|
const fm = payload.frontmatter || {};
|
|
415
|
+
if (payload.lanes && payload.lanes.defs) setLaneDefs(payload.lanes.defs);
|
|
388
416
|
const row = (payload.ladder && payload.ladder.ok && payload.ladder.row) || null;
|
|
389
417
|
const scope = (payload.ladder && payload.ladder.scope) || null;
|
|
390
418
|
const lane = fm.lane || (row && row.lane) || null;
|
|
391
419
|
const steps = row ? phaseSteps(lane, row.steps, row.profile) : [];
|
|
392
|
-
const
|
|
420
|
+
const laneDef = (payload.lanes && payload.lanes.defs && lane && payload.lanes.defs[lane]) || null;
|
|
421
|
+
const ctx = {
|
|
422
|
+
record: payload.record, prd: fm.prd_path || null, rubric: fm.rubric_path || null,
|
|
423
|
+
brief: fm.brief_path || null, doc: fm.doc_path || null, laneDef,
|
|
424
|
+
};
|
|
393
425
|
// Authored tasks win over derivation, per phase (PRD, "Tasks"). Their absence is not a defect.
|
|
394
426
|
const authored = payload.tasks || {};
|
|
395
427
|
const tasks = {};
|
|
@@ -406,6 +438,8 @@ function recordFrom(payload) {
|
|
|
406
438
|
// the lane this record declares, and the lanes this repository allows — what the header's lane picker offers
|
|
407
439
|
lane: lane || null,
|
|
408
440
|
lanes: payload.lanes && Array.isArray(payload.lanes.lanes) ? payload.lanes.lanes : null,
|
|
441
|
+
laneDefs: (payload.lanes && payload.lanes.defs) || null,
|
|
442
|
+
laneDef,
|
|
409
443
|
stage: row ? row.stage : null,
|
|
410
444
|
flow: fm.flow || null,
|
|
411
445
|
pills: pillsFor({ lane, anchor: payload.anchor, flow: fm.flow || null }),
|
|
@@ -577,6 +611,7 @@ function foldWorkspaces(rows) {
|
|
|
577
611
|
// here and never re-derived: a second opinion about what a repository IS would be a second
|
|
578
612
|
// thing to keep true, and this one is the one that did the read.
|
|
579
613
|
kind: w.kind === 'github' ? 'github' : 'local', remote: w.remote || null,
|
|
614
|
+
laneSets: w.lane_sets || null,
|
|
580
615
|
});
|
|
581
616
|
}
|
|
582
617
|
const p = byRepo.get(key);
|
|
@@ -588,6 +623,7 @@ function foldWorkspaces(rows) {
|
|
|
588
623
|
// the PM's own name for the repository (overhaul-r1 R3.1) lives on the main tree's row
|
|
589
624
|
if (w.label) p.name = w.label;
|
|
590
625
|
p.initials = w.initials || null;
|
|
626
|
+
p.profile = w.profile === 'pm-artifacts' ? 'pm-artifacts' : 'product-code';
|
|
591
627
|
p.kind = w.kind === 'github' ? 'github' : 'local';
|
|
592
628
|
p.remote = w.remote || null;
|
|
593
629
|
}
|
|
@@ -1057,6 +1093,33 @@ export async function removeRepository(name) {
|
|
|
1057
1093
|
/** The PM's own abbreviation for the rail, or null (the rail derives one from the name). */
|
|
1058
1094
|
export function repoInitials(name) { const p = products.get(name); return (p && p.initials) || null; }
|
|
1059
1095
|
|
|
1096
|
+
/** panel-repos.js — the repository's profile (D68): 'pm-artifacts' (documents) or 'product-code'. */
|
|
1097
|
+
/** The workflow audit for one repository (D77): the sweep, read-only. Resolves to the report or `{ok:false,error}`. */
|
|
1098
|
+
export async function loadAudit(name) {
|
|
1099
|
+
const p = products.get(name);
|
|
1100
|
+
if (!p) return { ok: false, error: 'that repository is not registered' };
|
|
1101
|
+
return getJson('/api/audit', { ws: p.ws });
|
|
1102
|
+
}
|
|
1103
|
+
/** The lane names a profile offers, as the server's engine reports them; null when it did not say. */
|
|
1104
|
+
export function repoLanes(name, profile) { const p = products.get(name); return (p && p.laneSets && p.laneSets[profile]) || null; }
|
|
1105
|
+
export function repoProfile(name) { const p = products.get(name); return (p && p.profile) || 'product-code'; }
|
|
1106
|
+
|
|
1107
|
+
/** panel-repos.js — write or remove `.pmos-profile`. Refusals come back as { ok:false, error }. */
|
|
1108
|
+
export async function setRepoProfile(name, profile) {
|
|
1109
|
+
if (FIXTURE) return { ok: false, error: 'fixture mode — nothing is registered' };
|
|
1110
|
+
const p = products.get(name);
|
|
1111
|
+
if (!p) return { ok: false, reason: 'unknown', error: 'unknown repository' };
|
|
1112
|
+
const out = await postJson('/api/workspaces/profile', { id: p.ws, profile });
|
|
1113
|
+
if (out && out.ok) {
|
|
1114
|
+
p.profile = profile;
|
|
1115
|
+
// the lane picker and the ladder read the profile: drop every cached record and re-read the open one
|
|
1116
|
+
for (const k of Object.keys(hydrated)) delete hydrated[k];
|
|
1117
|
+
try { await loadBoard(p); publishBoards(); } catch { /* the chip already shows the new profile */ }
|
|
1118
|
+
if (state.sel) await hydrate(state.sel);
|
|
1119
|
+
}
|
|
1120
|
+
return out || { ok: false, error: 'the workspace server did not answer' };
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1060
1123
|
/** panel-repos.js — `renameRepository(name, label)`. A display name only (R3.1). */
|
|
1061
1124
|
export async function renameRepository(name, label, initials) {
|
|
1062
1125
|
if (FIXTURE) return { ok: false, error: 'fixture mode — nothing is registered' };
|
|
@@ -295,7 +295,8 @@ let laneNote = null;
|
|
|
295
295
|
function lanePicker(slug, rec) {
|
|
296
296
|
if (FIXTURE || !rec || !rec.lane || !Array.isArray(rec.lanes) || !rec.lanes.length) return null;
|
|
297
297
|
const opts = rec.lanes.includes(rec.lane) ? rec.lanes : [rec.lane, ...rec.lanes];
|
|
298
|
-
|
|
298
|
+
const cur = rec.laneDefs && rec.laneDefs[rec.lane];
|
|
299
|
+
const bar = el('div', { 'data-r15': 'lane-bar', style: 'display:flex;gap:6px;align-items:center;flex-wrap:wrap;margin-top:8px;font-family:var(--mono);font-size:10.5px;color:var(--muted-foreground)' },
|
|
299
300
|
el('span', null, 'workflow'),
|
|
300
301
|
el('select', { 'data-r15': 'lane-pick', title: 'change this initiative’s workflow (lane); the ladder re-derives from it',
|
|
301
302
|
style: 'font-family:var(--mono);font-size:10.5px;background:var(--secondary);border:1px solid var(--border);color:var(--foreground);padding:2px 4px;min-height:24px',
|
|
@@ -310,8 +311,17 @@ function lanePicker(slug, rec) {
|
|
|
310
311
|
laneNote = out && out.ok ? 'lane is now ' + next : 'not changed — ' + ((out && out.error) || 'the server did not answer');
|
|
311
312
|
setState({});
|
|
312
313
|
} },
|
|
313
|
-
opts.map((l) =>
|
|
314
|
+
opts.map((l) => {
|
|
315
|
+
const d = rec.laneDefs && rec.laneDefs[l];
|
|
316
|
+
return el('option', { value: l, title: d ? d.when : null, selected: l === rec.lane ? 'selected' : null }, d && d.label ? d.label + ' (' + l + ')' : l);
|
|
317
|
+
})),
|
|
314
318
|
laneNote ? el('span', { 'data-r15': 'lane-note', style: 'color:var(--muted-foreground)' }, iv(laneNote)) : null);
|
|
319
|
+
if (!cur) return bar;
|
|
320
|
+
// D77 — what this lane is: the definition the engine runs on, read from the same module.
|
|
321
|
+
const row = (k, v) => el('div', null, el('span', { style: 'text-transform:uppercase;letter-spacing:.1em;font-size:9.5px;margin-right:6px' }, k), iv(v));
|
|
322
|
+
return el('div', null, bar,
|
|
323
|
+
el('div', { 'data-r17': 'lane-def', style: 'margin-top:6px;font-size:11.5px;line-height:1.5;color:var(--muted-foreground)' },
|
|
324
|
+
row('when', cur.when), row('input', cur.requires || cur.input), row('output', cur.output), row('done', cur.done)));
|
|
315
325
|
}
|
|
316
326
|
|
|
317
327
|
function headerBlock(name, tag, pills, crumb, lane) {
|
|
@@ -274,6 +274,51 @@ async function renameRepo(name) {
|
|
|
274
274
|
setState({ product: String(label).trim(), repoNotice: null });
|
|
275
275
|
}
|
|
276
276
|
|
|
277
|
+
/* The repository's profile (D68). `pm-artifacts` offers document lanes and no build rungs; `product-code` is the
|
|
278
|
+
* default. The confirm states the consequence before anything is written. */
|
|
279
|
+
const lanesText = (name, profile) => {
|
|
280
|
+
const l = dataMod && dataMod.repoLanes ? dataMod.repoLanes(name, profile) : null;
|
|
281
|
+
return l ? l.join(', ') : '(the workspace did not report them)';
|
|
282
|
+
};
|
|
283
|
+
/* The workflow audit (D77). Runs the sweep on the repository and shows what it found. It writes nothing. */
|
|
284
|
+
async function runAudit(name) {
|
|
285
|
+
if (!dataMod || !dataMod.loadAudit) return;
|
|
286
|
+
setState({ repoAudit: { name, busy: true } });
|
|
287
|
+
const out = await dataMod.loadAudit(name);
|
|
288
|
+
setState({ repoAudit: { name, report: out && out.ok !== false ? out : null, error: out && out.ok === false ? out.error : null } });
|
|
289
|
+
}
|
|
290
|
+
function auditBlock(name) {
|
|
291
|
+
const a = state.repoAudit;
|
|
292
|
+
if (!a || a.name !== name) return null;
|
|
293
|
+
const box = (kids) => el('div', { 'data-r17': 'audit-report', style: 'padding:6px 14px 10px 39px;font-size:11px;line-height:1.5;color:var(--muted-foreground)' }, kids);
|
|
294
|
+
if (a.busy) return box(ip('checking…'));
|
|
295
|
+
if (a.error || !a.report) return box(ip('the audit could not run — ' + (a.error || 'no answer')));
|
|
296
|
+
const r = a.report;
|
|
297
|
+
const head = r.counts.defects + ' defect' + (r.counts.defects === 1 ? '' : 's') + ' · ' + r.counts.owed + ' owed · ' + r.counts.repairable + ' repairable';
|
|
298
|
+
const line = (f) => el('div', { style: 'margin-top:4px', 'data-r17': 'finding', 'data-class': f.class },
|
|
299
|
+
el('span', { style: 'font-family:var(--mono);color:var(--foreground)' }, ip(f.class)), ip(' · ' + (f.id || 'repository') + ' — ' + f.what),
|
|
300
|
+
f.repair ? el('div', null, ip('repair: set ' + f.repair.key + ' to ' + f.repair.to + ' — ' + f.command)) : (f.hand ? el('div', null, ip(f.hand)) : null));
|
|
301
|
+
return box([
|
|
302
|
+
el('div', { style: 'color:var(--foreground)', 'data-r17': 'audit-counts' }, ip(head)),
|
|
303
|
+
r.defects.slice(0, 8).map(line),
|
|
304
|
+
r.counts.defects > 8 ? el('div', { style: 'margin-top:4px' }, ip('+ ' + (r.counts.defects - 8) + ' more — node scripts/workflow-sweep.js')) : null,
|
|
305
|
+
r.counts.owed ? el('div', { style: 'margin-top:6px' }, ip('owed (open loops, not corruption): ' + Object.entries(r.owed_by_class).map(([k, v]) => k + ' ×' + v).join(', '))) : null,
|
|
306
|
+
]);
|
|
307
|
+
}
|
|
308
|
+
async function switchProfile(name) {
|
|
309
|
+
if (!dataMod || !dataMod.repoProfile) return;
|
|
310
|
+
const toDocs = dataMod.repoProfile(name) !== 'pm-artifacts';
|
|
311
|
+
const msg = toDocs
|
|
312
|
+
? 'Switch “' + name + '” to document work?\n\nLanes offered: ' + lanesText(name, 'pm-artifacts') + '.\nThe ladder becomes route, anchor, brief, draft, review, approved, handed off — no contract, build, eval or explainer.'
|
|
313
|
+
: 'Switch “' + name + '” to code work?\n\nLanes offered: ' + lanesText(name, 'product-code') + '.\nThe ladder returns to spec, contract, build, eval, explainer, acceptance.';
|
|
314
|
+
const tail = '\n\nAn initiative whose lane is not in the new set is treated as the strictest lane (' + (toDocs ? 'a governed prd' : 'feature') + '). No initiative record is edited.\n\nThis ' + (toDocs ? 'writes' : 'removes') + ' the file .pmos-profile in the repository. It is a change for you to commit.';
|
|
315
|
+
const ok = typeof window === 'undefined' || !window.confirm ? true : window.confirm(msg + tail);
|
|
316
|
+
if (!ok) return;
|
|
317
|
+
const out = await dataMod.setRepoProfile(name, toDocs ? 'pm-artifacts' : 'product-code');
|
|
318
|
+
if (!out || out.ok === false) { setState({ repoNotice: { name, tone: 'bad', text: (out && out.error) || 'the profile was not changed' } }); return; }
|
|
319
|
+
setState({ repoNotice: { name, tone: null, text: 'now ' + (toDocs ? 'document work (pm-artifacts)' : 'code work (product-code)') + ' — .pmos-profile ' + (toDocs ? 'written' : 'removed') + '; commit it' } });
|
|
320
|
+
}
|
|
321
|
+
|
|
277
322
|
async function removeRepo(name) {
|
|
278
323
|
const ok = typeof window === 'undefined' || !window.confirm ? true
|
|
279
324
|
: window.confirm('Remove “' + name + '” from PMOS?\n\nThis forgets the repository and its worktree rows. No folder is deleted.');
|
|
@@ -303,6 +348,14 @@ function repoEntry(tuple, product) {
|
|
|
303
348
|
}, ip('⑂ ' + trees.length)));
|
|
304
349
|
if (product === name) {
|
|
305
350
|
{ const sb = syncButton(name); if (sb) tools.push(sb); }
|
|
351
|
+
{ const docs = dataMod.repoProfile && dataMod.repoProfile(name) === 'pm-artifacts';
|
|
352
|
+
tools.push(el('button', { onClick: () => switchProfile(name), style: chipStyle, hover: 'color:var(--foreground)',
|
|
353
|
+
title: docs ? 'document work (pm-artifacts): lanes are kinds of PM work and documents, with no build rungs. Press to switch to code work.'
|
|
354
|
+
: 'code work (product-code): lanes are feature, discovery, chore … with the full build ladder. Press to switch to document work.',
|
|
355
|
+
'data-r16': 'profile', 'data-profile': docs ? 'pm-artifacts' : 'product-code' }, ip(docs ? 'docs' : 'code'))); }
|
|
356
|
+
tools.push(el('button', { onClick: () => runAudit(name), style: chipStyle, hover: 'color:var(--foreground)',
|
|
357
|
+
title: 'check this repository’s workflow records against its files and its profile (read-only — nothing is written)',
|
|
358
|
+
'data-r17': 'audit' }, ip('audit')));
|
|
306
359
|
tools.push(el('button', { onClick: () => renameRepo(name), style: actStyle, hover: 'color:var(--foreground)', title: 'rename this repository', 'data-r1': 'rename' }, '✎'));
|
|
307
360
|
tools.push(el('button', { onClick: () => removeRepo(name), style: actStyle, hover: 'color:var(--destructive)', title: 'remove this repository from PMOS (no folder is deleted)', 'data-r1': 'remove' }, '✕'));
|
|
308
361
|
}
|
|
@@ -312,6 +365,7 @@ function repoEntry(tuple, product) {
|
|
|
312
365
|
{ const bf = branchForm(name); if (bf) kids.push(bf); }
|
|
313
366
|
if (open && trees.length) kids.push(treesBlock(name, trees, product === name));
|
|
314
367
|
{ const tn = treeNoteFor(name); if (tn) kids.push(tn); }
|
|
368
|
+
{ const ab = auditBlock(name); if (ab) kids.push(ab); }
|
|
315
369
|
const n = state.repoNotice;
|
|
316
370
|
if (n && n.name === name) {
|
|
317
371
|
kids.push(el('div', {
|
|
@@ -49,7 +49,10 @@ export function mountTask(host, tab) {
|
|
|
49
49
|
iv(tab.output),
|
|
50
50
|
)
|
|
51
51
|
: el('div', { style: 'font-size:13px;color:var(--muted-foreground);font-style:italic' },
|
|
52
|
-
|
|
52
|
+
// Only a step that is not done is "not produced yet". A done step with no file says what decided it.
|
|
53
|
+
tab.status === 'done'
|
|
54
|
+
? 'this step read no file of its own — the rule and evidence above are its result'
|
|
55
|
+
: 'no artifact yet — produced when the agent runs this task');
|
|
53
56
|
|
|
54
57
|
const body = el('div', {
|
|
55
58
|
style: 'flex:1;min-height:0;overflow-y:auto;background:var(--background);color:var(--foreground);padding:22px 26px 60px;font-size:14px;line-height:1.65',
|
package/workspace/server.js
CHANGED
|
@@ -74,6 +74,7 @@ const fmParse = require('./lib/file-mode-parse'); // canonical file-mode pa
|
|
|
74
74
|
const skillsReg = require('./lib/skills-registry'); // discovered and generated skills: quarantine, provenance, local approval (D76)
|
|
75
75
|
const modelLib = require('./lib/models'); // tier routing — one code path, shared with the agents' CLI (D75)
|
|
76
76
|
const memoryLib = require('./lib/memory'); // curated repository memory — one ledger code path, shared with the agents' CLI (D72)
|
|
77
|
+
const audit = require('./lib/audit'); // the workflow audit — the sweep the agents' CLI runs, read-only here (D77)
|
|
77
78
|
const syncLib = require('./lib/sync'); // press-only fetch / integrate / commit records / push (D70)
|
|
78
79
|
const gitInfo = require('./lib/git-info'); // read-mostly branch awareness + select/create at session start
|
|
79
80
|
const startPolicy = require('./lib/start-policy'); // ONE stated rule for where a session starts (repo-first R1/R3)
|
|
@@ -584,11 +585,30 @@ function treeNoticeFor(s, mates) {
|
|
|
584
585
|
role: holder === s.id ? 'writer' : 'reader', writerName: held, writerId: holder, tree: s.tree,
|
|
585
586
|
});
|
|
586
587
|
}
|
|
588
|
+
/** The repository's declared profile (D68), read fresh from `.pmos-profile`; anything else is `product-code`. */
|
|
589
|
+
function profileOfRoot(root) {
|
|
590
|
+
try {
|
|
591
|
+
const line = fs.readFileSync(path.join(root, '.pmos-profile'), 'utf8').split('\n').map((l) => l.replace(/#.*$/, '').trim()).find(Boolean);
|
|
592
|
+
return line === 'pm-artifacts' ? 'pm-artifacts' : 'product-code';
|
|
593
|
+
} catch { return 'product-code'; }
|
|
594
|
+
}
|
|
595
|
+
// D77 — the lane names of each profile, from the engine's own definitions. The client never carries a list.
|
|
596
|
+
let laneSetsMemo = null;
|
|
597
|
+
function laneSets() {
|
|
598
|
+
if (laneSetsMemo) return laneSetsMemo;
|
|
599
|
+
try {
|
|
600
|
+
const e = consoleState.loadEngine();
|
|
601
|
+
if (e && e.ok && typeof e.engine.laneNames === 'function') {
|
|
602
|
+
laneSetsMemo = { 'pm-artifacts': e.engine.laneNames('pm-artifacts'), 'product-code': e.engine.laneNames('product-code') };
|
|
603
|
+
}
|
|
604
|
+
} catch { /* the engine is not here; the client then says so rather than guessing */ }
|
|
605
|
+
return laneSetsMemo;
|
|
606
|
+
}
|
|
587
607
|
function wsList() {
|
|
588
608
|
return [...workspaces.values()].map((w) => {
|
|
589
609
|
const rid = repoIdentity(w.root);
|
|
590
610
|
return {
|
|
591
|
-
id: w.id, name: w.name, label: w.label || null, initials: w.initials || null, root: w.root, home: HOME_ENABLED && w.root === SELF_ROOT,
|
|
611
|
+
id: w.id, name: w.name, label: w.label || null, initials: w.initials || null, profile: profileOfRoot(w.root), lane_sets: laneSets(), root: w.root, home: HOME_ENABLED && w.root === SELF_ROOT,
|
|
592
612
|
shape: wsShape(w.root), addedAt: w.addedAt,
|
|
593
613
|
sessions: [...sessions.values()].filter((s) => s.ws === w.id).length,
|
|
594
614
|
// the repository this working tree belongs to, and the branch it is on. `branch_code`
|
|
@@ -4331,6 +4351,25 @@ async function handleRequest(req, res) {
|
|
|
4331
4351
|
saveWorkspaces(); pushWorkspaces();
|
|
4332
4352
|
return json(res, { ok: true, workspace: { id: w.id, name, label: name, initials: w.initials || null } });
|
|
4333
4353
|
}
|
|
4354
|
+
if (p === '/api/workspaces/profile' && req.method === 'POST') {
|
|
4355
|
+
/* Switch a repository between document work (`pm-artifacts`) and code work (`product-code`), D68. The profile
|
|
4356
|
+
* is the one-line `.pmos-profile` file at the repository root, so this writes or removes that file and nothing
|
|
4357
|
+
* else. No initiative record is edited: a lane that is not in the new set is read as the profile's strictest
|
|
4358
|
+
* lane by the engine. The PM commits the file. */
|
|
4359
|
+
let body = {}; try { body = JSON.parse((await readBody(req)) || '{}'); } catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
|
|
4360
|
+
const w = workspaces.get(body.id);
|
|
4361
|
+
if (!w) return json(res, { ok: false, reason: 'unknown', error: 'unknown repository' }, 404);
|
|
4362
|
+
const want = String(body.profile || '');
|
|
4363
|
+
if (want !== 'pm-artifacts' && want !== 'product-code') return json(res, { ok: false, error: `“${want || '(none)'}” is not a profile — one of: pm-artifacts, product-code` }, 400);
|
|
4364
|
+
const file = path.join(w.root, '.pmos-profile');
|
|
4365
|
+
try {
|
|
4366
|
+
if (want === 'pm-artifacts') { fs.writeFileSync(file + '.tmp', 'pm-artifacts\n'); fs.renameSync(file + '.tmp', file); }
|
|
4367
|
+
else fs.rmSync(file, { force: true });
|
|
4368
|
+
} catch (e) { return json(res, { ok: false, error: 'could not write .pmos-profile — ' + String((e && e.message) || e).slice(0, 120) }, 500); }
|
|
4369
|
+
boardCache.invalidate(w.root);
|
|
4370
|
+
pushWorkspaces();
|
|
4371
|
+
return json(res, { ok: true, id: w.id, profile: want, file: '.pmos-profile', commit: 'the file is a change in the repository — commit it' });
|
|
4372
|
+
}
|
|
4334
4373
|
if (p === '/api/git/branch' && req.method === 'POST') {
|
|
4335
4374
|
let body = {};
|
|
4336
4375
|
try { body = JSON.parse((await readBody(req)) || '{}'); } catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
|
|
@@ -5152,6 +5191,14 @@ async function handleRequest(req, res) {
|
|
|
5152
5191
|
try { if (typeof engine.engine.lanesFor === 'function') lanes = engine.engine.lanesFor(root); } catch { lanes = null; }
|
|
5153
5192
|
return json(res, { ws: w ? w.id : null, ...out, lanes });
|
|
5154
5193
|
}
|
|
5194
|
+
if (p === '/api/audit' && req.method === 'GET') {
|
|
5195
|
+
/* The workflow audit (D77): the sweep run on one repository, read-only. It reports state that contradicts
|
|
5196
|
+
* the repository (a document no record points at, a lane the profile does not offer, a done step with
|
|
5197
|
+
* nothing to open) and work honestly not done. It writes nothing; repairs are the CLI's, on the PM's word. */
|
|
5198
|
+
const w = getWs(url.searchParams.get('ws'));
|
|
5199
|
+
const root = w ? w.root : SELF_ROOT;
|
|
5200
|
+
return json(res, audit.run(root));
|
|
5201
|
+
}
|
|
5155
5202
|
if (p === '/api/initiative/lane' && req.method === 'POST') {
|
|
5156
5203
|
/* Change an existing initiative's lane — the PM's word. The ladder is DERIVED from `lane:` (workflow-state.js),
|
|
5157
5204
|
* so this edits that one frontmatter line and nothing else; the next read re-derives the rungs. It never
|