create-pmos 0.38.4 → 0.38.6

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.
Files changed (35) hide show
  1. package/package.json +1 -1
  2. package/payload/AGENTS.md +1 -1
  3. package/payload/KIT-VERSION +2 -2
  4. package/payload/okf/core/templates/requirements-brief-template.md +61 -0
  5. package/payload/planning/evals/eval-calibration.md +1 -1
  6. package/payload/planning/okrs/health-budgets.md +1 -1
  7. package/payload/scripts/lane-defs.js +226 -0
  8. package/payload/scripts/workflow-state.js +108 -24
  9. package/payload/scripts/workflow-sweep.js +155 -3
  10. package/payload/skills/design-brief.skill +5 -6
  11. package/payload/skills/discovery.skill +1 -2
  12. package/payload/skills/eval-rubric.skill +2 -5
  13. package/payload/skills/pmos-router.skill +3 -2
  14. package/payload/skills/prd.skill +7 -13
  15. package/payload/skills/requirements-elicitation.skill +231 -0
  16. package/payload/skills-native/design-brief/SKILL.md +5 -6
  17. package/payload/skills-native/discovery/SKILL.md +1 -2
  18. package/payload/skills-native/eval-rubric/SKILL.md +2 -5
  19. package/payload/skills-native/pmos-router/SKILL.md +3 -2
  20. package/payload/skills-native/prd/SKILL.md +7 -13
  21. package/payload/skills-native/requirements-elicitation/SKILL.md +233 -0
  22. package/payload/state/discoveries.md +1 -1
  23. package/payload/state/log.md +1 -1
  24. package/workspace/lib/audit.js +54 -0
  25. package/workspace/lib/engine/lane-defs.js +226 -0
  26. package/workspace/lib/engine/workflow-state.js +108 -24
  27. package/workspace/lib/engine/workflow-sweep.js +934 -0
  28. package/workspace/package-lock.json +1 -1
  29. package/workspace/package.json +2 -2
  30. package/workspace/public/pm/data.js +67 -24
  31. package/workspace/public/pm/panel-detail.js +12 -2
  32. package/workspace/public/pm/panel-repos.js +36 -5
  33. package/workspace/public/pm/tab-task.js +4 -1
  34. package/workspace/server.js +22 -1
  35. package/workspace/skills/gather-requirements.skill +13 -1
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.38.4",
3
+ "version": "0.38.6",
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.4",
3
+ "version": "0.38.6",
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 && node pm-r16-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);
@@ -1059,6 +1094,14 @@ export async function removeRepository(name) {
1059
1094
  export function repoInitials(name) { const p = products.get(name); return (p && p.initials) || null; }
1060
1095
 
1061
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; }
1062
1105
  export function repoProfile(name) { const p = products.get(name); return (p && p.profile) || 'product-code'; }
1063
1106
 
1064
1107
  /** panel-repos.js — write or remove `.pmos-profile`. Refusals come back as { ok:false, error }. */
@@ -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) {
@@ -276,14 +276,41 @@ async function renameRepo(name) {
276
276
 
277
277
  /* The repository's profile (D68). `pm-artifacts` offers document lanes and no build rungs; `product-code` is the
278
278
  * default. The confirm states the consequence before anything is written. */
279
- const DOC_LANES = 'strategy, prd, project-plan, discovery, decision-record, report, chore';
280
- const CODE_LANES = 'feature, discovery, chore, incident, content, prototype';
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
+ }
281
308
  async function switchProfile(name) {
282
309
  if (!dataMod || !dataMod.repoProfile) return;
283
310
  const toDocs = dataMod.repoProfile(name) !== 'pm-artifacts';
284
311
  const msg = toDocs
285
- ? 'Switch “' + name + '” to document work?\n\nLanes offered: ' + DOC_LANES + '.\nThe ladder becomes route, anchor, brief, draft, review, approved, handed off — no contract, build, eval or explainer.'
286
- : 'Switch “' + name + '” to code work?\n\nLanes offered: ' + CODE_LANES + '.\nThe ladder returns to spec, contract, build, eval, explainer, acceptance.';
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.';
287
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.';
288
315
  const ok = typeof window === 'undefined' || !window.confirm ? true : window.confirm(msg + tail);
289
316
  if (!ok) return;
@@ -323,9 +350,12 @@ function repoEntry(tuple, product) {
323
350
  { const sb = syncButton(name); if (sb) tools.push(sb); }
324
351
  { const docs = dataMod.repoProfile && dataMod.repoProfile(name) === 'pm-artifacts';
325
352
  tools.push(el('button', { onClick: () => switchProfile(name), style: chipStyle, hover: 'color:var(--foreground)',
326
- title: docs ? 'document work (pm-artifacts): lanes are strategy, prd, project-plan … with no build rungs. Press to switch to code work.'
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.'
327
354
  : 'code work (product-code): lanes are feature, discovery, chore … with the full build ladder. Press to switch to document work.',
328
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')));
329
359
  tools.push(el('button', { onClick: () => renameRepo(name), style: actStyle, hover: 'color:var(--foreground)', title: 'rename this repository', 'data-r1': 'rename' }, '✎'));
330
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' }, '✕'));
331
361
  }
@@ -335,6 +365,7 @@ function repoEntry(tuple, product) {
335
365
  { const bf = branchForm(name); if (bf) kids.push(bf); }
336
366
  if (open && trees.length) kids.push(treesBlock(name, trees, product === name));
337
367
  { const tn = treeNoteFor(name); if (tn) kids.push(tn); }
368
+ { const ab = auditBlock(name); if (ab) kids.push(ab); }
338
369
  const n = state.repoNotice;
339
370
  if (n && n.name === name) {
340
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)
@@ -591,11 +592,23 @@ function profileOfRoot(root) {
591
592
  return line === 'pm-artifacts' ? 'pm-artifacts' : 'product-code';
592
593
  } catch { return 'product-code'; }
593
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
+ }
594
607
  function wsList() {
595
608
  return [...workspaces.values()].map((w) => {
596
609
  const rid = repoIdentity(w.root);
597
610
  return {
598
- id: w.id, name: w.name, label: w.label || null, initials: w.initials || null, profile: profileOfRoot(w.root), 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,
599
612
  shape: wsShape(w.root), addedAt: w.addedAt,
600
613
  sessions: [...sessions.values()].filter((s) => s.ws === w.id).length,
601
614
  // the repository this working tree belongs to, and the branch it is on. `branch_code`
@@ -5178,6 +5191,14 @@ async function handleRequest(req, res) {
5178
5191
  try { if (typeof engine.engine.lanesFor === 'function') lanes = engine.engine.lanesFor(root); } catch { lanes = null; }
5179
5192
  return json(res, { ws: w ? w.id : null, ...out, lanes });
5180
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
+ }
5181
5202
  if (p === '/api/initiative/lane' && req.method === 'POST') {
5182
5203
  /* Change an existing initiative's lane — the PM's word. The ladder is DERIVED from `lane:` (workflow-state.js),
5183
5204
  * so this edits that one frontmatter line and nothing else; the next read re-derives the rungs. It never
@@ -4,6 +4,10 @@ label: "Gather requirements"
4
4
  description: "Read what changed in the PM's mail, chat, Jira, Confluence, calendar and meeting notes since the last note, and file one dated note of requirements — each with its source, how sure it is, and which initiative it touches."
5
5
  run: agent
6
6
  agent: claude
7
+ version: 1.1.0
8
+ owner: wawan
9
+ risk: low
10
+ scope: read:connectors, read:state, write:state/requirements
7
11
  output:
8
12
  - New or changed requirements
9
13
  - Validations and contradictions
@@ -20,17 +24,22 @@ Rules that always hold:
20
24
  4. NOTHING INVENTED. If a source is not reachable in this session, say so under "Sources reached and not reached". If nothing changed, say that. An honest empty note is correct.
21
25
  5. UNCONFIRMED IS ALLOWED. Mark every item `confidence: unconfirmed` or `confirmed` (confirmed = a person with authority said it in a decision, an approved document or a closed ticket). A note is evidence, never a decision or an acceptance.
22
26
 
23
- Procedure:
27
+ Procedure (each step ends with a check that shows it is done):
24
28
  1. Find the state directory: `pmos/state/` if it exists, otherwise `planning/`. The note goes in `<state>/requirements/YYYY-MM-DD.md` (today's date). If today's file already exists, do NOT overwrite it: append a section `## Later run HH:MM`.
29
+ Done when: the target path is named, and an existing file is still intact.
25
30
  2. Read the previous note in that folder to learn the cutoff. With no previous note, look back 3 days.
31
+ Done when: you can state the cutoff as a date and time.
26
32
  3. Read the initiatives in `<state>/initiatives/` (or `planning/initiatives/`) so you can say which one an item touches. Match by what the item is about, not by keyword.
33
+ Done when: you have the list of initiative ids that an item can name.
27
34
  4. For each source you can reach, read what changed since the cutoff: mail, chat, Jira, Confluence, calendar events with their notes, meeting notes.
35
+ Done when: each source kind is either read or marked not reached.
28
36
  5. For each requirement found, write one item:
29
37
  - the requirement, in your words
30
38
  - pointer: kind · id or link · date · author
31
39
  - confidence: unconfirmed | confirmed
32
40
  - relation: validates | clarifies | extends | contradicts | none
33
41
  - initiative: its id, or `unmatched`
42
+ Done when: every item has all five fields and a pointer.
34
43
  6. Start the file with this frontmatter (OKF v0.2, D73). Every key is on ONE line — never indent a key under another, because the readers stop at the first indented line. Then the four sections above, in this order:
35
44
  ```
36
45
  ---
@@ -43,8 +52,11 @@ Procedure:
43
52
  ---
44
53
  ```
45
54
  `sources` lists every item the note is derived from (the same pointers you give per item). Do not add a `timestamp` and do not add `verified`: only the PM confirms a note.
55
+ Done when: the file starts with this frontmatter and holds the four sections in order.
46
56
  7. Under "Sources reached and not reached", list every source kind you tried, with `reached` or `not reached — <why>`, and the number of private items skipped.
57
+ Done when: every source kind you tried has a line, and the private count is stated.
47
58
  8. Finish by saying in one line how many items you filed and where. Do not commit; the PM's Sync carries the file.
59
+ Done when: the count and the path are said once, and no commit was made.
48
60
 
49
61
  Output contract — produce EXACTLY these sections, the same shape every run:
50
62
  - New or changed requirements