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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.38.3",
3
+ "version": "0.38.5",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.38.3",
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 PM lane walks brief → draft → review → approved
138
- * (→ handed off). Keyed `pm:<lane>` because `discovery` and `chore` exist in BOTH profiles with
139
- * different ladders; the profile comes from the engine's own ladder row, never guessed. */
140
- const PM_LANES_CLIENT = ['strategy', 'prd', 'project-plan', 'discovery', 'decision-record', 'report', 'chore'];
141
- const PM_NO_HANDOFF_CLIENT = ['decision-record', 'report', 'chore'];
142
- for (const l of PM_LANES_CLIENT) {
143
- const withHandoff = !PM_NO_HANDOFF_CLIENT.includes(l);
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:' + (PM_LANES_CLIENT.includes(lane) ? lane : 'prd')
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
- const decided = rungs.filter((r) => r.status !== 'na' && r.status !== 'unknown');
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).join(' · ') || null;
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
- expect: evidence,
214
- evalNote: rules.length
215
- ? 'workflow rule ' + rules.join(', ') + ' decides this phase'
216
- : 'the workflow engine derives this phase from ' + rungs.map((r) => r.key).join(', '),
217
- // The produced artifact, when the engine named a path in its own evidence. Never guessed:
218
- // an artifact the engine did not name is "no artifact yet", which the task pane says in words.
219
- output: i === 1 ? (pathIn(lead.evidence) || pathIn(lead.next) || null) : null,
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 ctx = { record: payload.record, prd: fm.prd_path || null, rubric: fm.rubric_path || null };
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
- return 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)' },
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) => el('option', { value: l, selected: l === rec.lane ? 'selected' : null }, 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
- 'no artifact yet — produced when the agent runs this task');
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',
@@ -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