create-pmos 0.16.1 → 0.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +30 -0
- package/package.json +1 -1
- package/payload/KIT-VERSION +1 -1
- package/workspace/lib/engine/workflow-state.js +129 -22
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +3 -3
- package/workspace/server.js +242 -44
package/README.md
CHANGED
|
@@ -333,6 +333,36 @@ Refreshes the skills, concepts, and scripts only. Your records — `state/`, `pl
|
|
|
333
333
|
`okf/product/` — are yours, and an update never touches them. `pmos/KIT-VERSION` always says
|
|
334
334
|
exactly what you're running.
|
|
335
335
|
|
|
336
|
+
## What's new in 0.17.0
|
|
337
|
+
|
|
338
|
+
**Your board stops calling shipped work unstarted.** Run `npx create-pmos@latest workspace`; the
|
|
339
|
+
version bump means an existing install auto-upgrades.
|
|
340
|
+
|
|
341
|
+
- **An initiative's status is now read from what happened, not from what someone typed.** The
|
|
342
|
+
workspace took each initiative's state from a `status:` line written by hand into a PRD and
|
|
343
|
+
matched it against a list of words. In the repo this was built in, that one field held 23
|
|
344
|
+
different values, 33 initiatives had none at all, and several were cut off mid-sentence by a
|
|
345
|
+
stray comma. The plainest value of the lot — `build complete, PR #138` — matched the word
|
|
346
|
+
"build" first and displayed as *in progress*. Status is now derived from your acceptance
|
|
347
|
+
records, your logged runs, your open items, and your repository's own merge history, and each
|
|
348
|
+
row states which of those it was read from. Nothing you type can move it.
|
|
349
|
+
- **`delivered` and `accepted` are no longer the same thing.** Work that merged and work you
|
|
350
|
+
actually signed off are now separate states. That gap is usually the interesting number: in the
|
|
351
|
+
repo this was built in, 62 of 63 delivered initiatives had no acceptance recorded — invisible
|
|
352
|
+
while both wore one label.
|
|
353
|
+
- **The board reads your whole project history, not just the branch you are standing on.** It
|
|
354
|
+
shared a commit range with the CI gate, and that range is *empty* when your branch is level with
|
|
355
|
+
`main` — so every initiative reported "no implementation commits" and fell back to its earliest
|
|
356
|
+
stage. Work finished weeks ago looked identical to work never begun. The gate still reads only
|
|
357
|
+
the pull request, which is correct for judging whether a spec came before its build.
|
|
358
|
+
- **A history it cannot read is now said out loud.** In a shallow clone (some CI checkouts) the
|
|
359
|
+
delivery history genuinely is not there. The board reports that, with the reason, instead of
|
|
360
|
+
quietly showing everything as unstarted — which would look exactly like a project where nothing
|
|
361
|
+
has happened.
|
|
362
|
+
|
|
363
|
+
Unchanged where it matters: **no database, no network at runtime, no telemetry.** The server still
|
|
364
|
+
binds `127.0.0.1` only. The file-mode kit itself is unchanged and still dependency-free.
|
|
365
|
+
|
|
336
366
|
## What's new in 0.16.1
|
|
337
367
|
|
|
338
368
|
**Two fixes on the Terminal Workspace's entry path — how a repository reaches a running workspace.**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-pmos",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.17.0",
|
|
4
4
|
"description": "Scaffold the PMOS portable kit — run the PMOS product-management workflow (initiative → lane-fit spec → contract → build → three gates, with work-type lanes for features, research, maintenance, incidents, and content) entirely inside your own repo, with all state as git-versioned files. `create-pmos workspace` also installs + launches the local Terminal Workspace GUI. No database, no network, no telemetry.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"bin": {
|
package/payload/KIT-VERSION
CHANGED
|
@@ -164,25 +164,88 @@ function git(root, args) {
|
|
|
164
164
|
* evaluated has not been satisfied, and saying so is the whole point of the honesty rule above.
|
|
165
165
|
*/
|
|
166
166
|
function commitRange(root) {
|
|
167
|
+
return buildRange(root, { spec: null });
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/*
|
|
171
|
+
* The BOARD's range: the whole trunk, not the pull request.
|
|
172
|
+
*
|
|
173
|
+
* These are two different questions and they were sharing an answer. The gate asks "inside this
|
|
174
|
+
* diff, did the spec precede the build?" — only decidable within the PR. The board asks "where does
|
|
175
|
+
* this initiative stand?", which the PR range cannot answer at all: on a branch level with `main`
|
|
176
|
+
* the range is EMPTY, so every initiative reported `no implementation commits in this range` and
|
|
177
|
+
* collapsed to stage `spec`. A delivered initiative rendered identically to one never started.
|
|
178
|
+
*
|
|
179
|
+
* Splitting them means a rule can read `done` on the board and still be evaluated inside the gate.
|
|
180
|
+
* That is intended and is stated in the board's own footer, so it never reads as a discrepancy.
|
|
181
|
+
*/
|
|
182
|
+
function historyRange(root) {
|
|
183
|
+
const trunk = ['origin/main', 'main', 'origin/master', 'master']
|
|
184
|
+
.find((c) => git(root, ['rev-parse', '--verify', '--quiet', c]));
|
|
185
|
+
return buildRange(root, { spec: trunk || 'HEAD', whole: true });
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/*
|
|
189
|
+
* ONE pass over the range, not one git call per question.
|
|
190
|
+
*
|
|
191
|
+
* Two `git log` invocations build every index the ladder needs: which paths each commit CHANGED
|
|
192
|
+
* (for buildCommits) and where each path was ADDED (for firstAppearance). Previously firstAppearance
|
|
193
|
+
* shelled out once per path per initiative and buildCommits ran `git show` once per commit per
|
|
194
|
+
* initiative — 330+ processes for the board on an empty range (23 s), and O(commits × initiatives)
|
|
195
|
+
* on a full one, which is what made full history look impossible rather than merely unimplemented.
|
|
196
|
+
*/
|
|
197
|
+
function buildRange(root, { spec, whole }) {
|
|
167
198
|
if ((git(root, ['rev-parse', '--is-shallow-repository']) || '').trim() === 'true') {
|
|
168
199
|
return { ok: false, reason: 'shallow clone — no history to order commits by (CI needs fetch-depth: 0)' };
|
|
169
200
|
}
|
|
170
|
-
const candidates = [];
|
|
171
|
-
if (process.env.GITHUB_BASE_REF) candidates.push(`origin/${process.env.GITHUB_BASE_REF}`);
|
|
172
|
-
candidates.push('origin/main', 'main', 'origin/master', 'master');
|
|
173
201
|
let base = null;
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
const
|
|
177
|
-
if (
|
|
202
|
+
let range = spec;
|
|
203
|
+
if (!range) {
|
|
204
|
+
const candidates = [];
|
|
205
|
+
if (process.env.GITHUB_BASE_REF) candidates.push(`origin/${process.env.GITHUB_BASE_REF}`);
|
|
206
|
+
candidates.push('origin/main', 'main', 'origin/master', 'master');
|
|
207
|
+
for (const c of candidates) {
|
|
208
|
+
if (!git(root, ['rev-parse', '--verify', '--quiet', c])) continue;
|
|
209
|
+
const mb = git(root, ['merge-base', c, 'HEAD']);
|
|
210
|
+
if (mb && mb.trim()) { base = mb.trim(); break; }
|
|
211
|
+
}
|
|
212
|
+
range = base ? `${base}..HEAD` : 'HEAD';
|
|
178
213
|
}
|
|
179
|
-
const
|
|
180
|
-
const log = git(root, ['log', '--reverse', '--format=%H', spec]);
|
|
214
|
+
const log = git(root, ['log', '--reverse', '--format=%x1e%H %ct', '--name-only', range]);
|
|
181
215
|
if (log == null) return { ok: false, reason: 'git history unreadable' };
|
|
182
|
-
|
|
216
|
+
|
|
217
|
+
const shas = [];
|
|
218
|
+
const changed = []; // index -> string[] of paths touched
|
|
219
|
+
const dates = []; // index -> commit unix seconds (what `legacy` is decided by)
|
|
220
|
+
for (const chunk of log.split('\x1e')) {
|
|
221
|
+
if (!chunk.trim()) continue;
|
|
222
|
+
const lines = chunk.split('\n');
|
|
223
|
+
const head = (lines.shift() || '').trim();
|
|
224
|
+
const [sha, ct] = head.split(/\s+/);
|
|
225
|
+
if (!sha) continue;
|
|
226
|
+
shas.push(sha);
|
|
227
|
+
dates.push(Number.isFinite(parseInt(ct, 10)) ? parseInt(ct, 10) : null);
|
|
228
|
+
changed.push(lines.map((s) => s.trim()).filter(Boolean));
|
|
229
|
+
}
|
|
183
230
|
const index = new Map();
|
|
184
231
|
shas.forEach((sha, i) => index.set(sha, i));
|
|
185
|
-
|
|
232
|
+
|
|
233
|
+
// Where each path was ADDED, as an index into the same range. `--diff-filter=A` in one pass.
|
|
234
|
+
const added = new Map();
|
|
235
|
+
const addLog = git(root, ['log', '--reverse', '--format=%x1e%H', '--diff-filter=A', '--name-only', range]);
|
|
236
|
+
if (addLog != null) {
|
|
237
|
+
for (const chunk of addLog.split('\x1e')) {
|
|
238
|
+
if (!chunk.trim()) continue;
|
|
239
|
+
const lines = chunk.split('\n');
|
|
240
|
+
const sha = (lines.shift() || '').trim();
|
|
241
|
+
const at = index.has(sha) ? index.get(sha) : null;
|
|
242
|
+
if (at == null) continue;
|
|
243
|
+
for (const f of lines.map((s) => s.trim()).filter(Boolean)) {
|
|
244
|
+
if (!added.has(f)) added.set(f, at);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
return { ok: true, base, index, shas, changed, dates, added, wholeHistory: whole ? true : !base };
|
|
186
249
|
}
|
|
187
250
|
|
|
188
251
|
/*
|
|
@@ -200,6 +263,10 @@ function commitRange(root) {
|
|
|
200
263
|
function firstAppearance(root, range, rel) {
|
|
201
264
|
const onDisk = fs.existsSync(path.join(root, rel));
|
|
202
265
|
if (!range.ok) return onDisk ? undefined : null;
|
|
266
|
+
// The single-pass index answers first, and over the whole trunk it answers everything that has
|
|
267
|
+
// ever been committed — so the fallback below only runs for a PR range, where "added before the
|
|
268
|
+
// range" and "never committed" are genuinely different answers and only git can tell them apart.
|
|
269
|
+
if (range.added && range.added.has(rel)) return range.added.get(rel);
|
|
203
270
|
const log = git(root, ['log', '--diff-filter=A', '--reverse', '--format=%H', '--', rel]);
|
|
204
271
|
const sha = log ? log.split('\n').map((s) => s.trim()).filter(Boolean)[0] : null;
|
|
205
272
|
if (!sha) return onDisk ? Infinity : null;
|
|
@@ -213,9 +280,10 @@ function buildCommits(root, range, lane) {
|
|
|
213
280
|
if (!range.ok) return { unknown: true };
|
|
214
281
|
let first = null; let count = 0; let sample = null;
|
|
215
282
|
for (let i = 0; i < range.shas.length; i++) {
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
const
|
|
283
|
+
// range.changed came from the range's own single pass; the per-commit `git show` this replaced
|
|
284
|
+
// ran once per commit PER INITIATIVE, which is why the board took 23 s before it read anything.
|
|
285
|
+
const files = (range.changed && range.changed[i]) || [];
|
|
286
|
+
const hit = files.find((f) => isBuildPath(f, lane));
|
|
219
287
|
if (!hit) continue;
|
|
220
288
|
count++;
|
|
221
289
|
if (first === null) { first = i; sample = hit; }
|
|
@@ -290,10 +358,29 @@ function computeWorkflow(id, opts) {
|
|
|
290
358
|
const effLane = knownLane ? lane : 'feature'; // unknown lane = strictest, as the bash gate does
|
|
291
359
|
const range = (opts && opts.range) || commitRange(root);
|
|
292
360
|
// See ENFORCED_FROM: git decides, the frontmatter only breaks a tie git cannot see.
|
|
361
|
+
//
|
|
362
|
+
// `recIdx === -1` — "the record predates this range" — is the right test for a PR range and a
|
|
363
|
+
// MEANINGLESS one over the whole trunk, where nothing predates the range and every legacy record
|
|
364
|
+
// would suddenly be enforced. So the commit DATE decides whenever the range can supply it, and
|
|
365
|
+
// -1 keeps its meaning for the gate's range. Same question, asked of evidence that survives both.
|
|
293
366
|
const recIdx = firstAppearance(root, range, recRel);
|
|
294
|
-
const
|
|
295
|
-
|
|
296
|
-
|
|
367
|
+
const recAt = (range.dates && typeof recIdx === 'number' && recIdx >= 0) ? range.dates[recIdx] : null;
|
|
368
|
+
/*
|
|
369
|
+
* `reconstructed_from` — the merge date of work whose record was written afterwards.
|
|
370
|
+
*
|
|
371
|
+
* A record backfilled today for a PR that merged in June has TODAY's commit date, so the date
|
|
372
|
+
* test would enforce today's rules on work that predates them and report a violation nobody
|
|
373
|
+
* could ever have avoided. The declared merge date decides instead.
|
|
374
|
+
*
|
|
375
|
+
* This is not an escape hatch: it must name a date, that date is checkable against the merge it
|
|
376
|
+
* claims, and declaring one AFTER `ENFORCED_FROM` makes the record non-legacy — so work that
|
|
377
|
+
* genuinely skipped the loop still reports blocked, which is the point of writing it down.
|
|
378
|
+
*/
|
|
379
|
+
const recon = String(fm.reconstructed_from || '').trim().slice(0, 10);
|
|
380
|
+
const legacy = recIdx === -1 ? true
|
|
381
|
+
: /^\d{4}-\d{2}-\d{2}$/.test(recon) ? recon < ENFORCED_FROM
|
|
382
|
+
: recAt ? new Date(recAt * 1000).toISOString().slice(0, 10) < ENFORCED_FROM
|
|
383
|
+
: String(fm.timestamp || '').slice(0, 10) < ENFORCED_FROM; // fallback — see ENFORCED_FROM
|
|
297
384
|
/*
|
|
298
385
|
* `legacy` answers the ENFORCEMENT question — should this PR fail? It is NOT the visibility
|
|
299
386
|
* question. Filtering the board by it was a real bug: on `main`, every record is legacy, so the
|
|
@@ -304,7 +391,12 @@ function computeWorkflow(id, opts) {
|
|
|
304
391
|
* `lastActivity` is the honest fallback for everything else, because "when was this last worked
|
|
305
392
|
* on" is a fact the repo already holds and nobody has to maintain.
|
|
306
393
|
*/
|
|
307
|
-
|
|
394
|
+
// `● in this change` must keep meaning "the diff under review touches this". Over the whole trunk
|
|
395
|
+
// that is true of everything, which makes the marker noise — so the board passes the GATE's range
|
|
396
|
+
// separately and the marker is answered from that, or dropped when there is no such range.
|
|
397
|
+
const changeRange = (opts && opts.changeRange) || (range.wholeHistory ? null : range);
|
|
398
|
+
const chgIdx = changeRange ? firstAppearance(root, changeRange, recRel) : null;
|
|
399
|
+
const inChange = !!changeRange && chgIdx !== -1 && chgIdx !== null && chgIdx !== undefined;
|
|
308
400
|
const lastActivity = (() => {
|
|
309
401
|
const paths = [recRel, fm.prd_path, fm.rubric_path].filter(Boolean);
|
|
310
402
|
const out = git(root, ['log', '-1', '--format=%ct', '--', ...paths]);
|
|
@@ -522,10 +614,14 @@ function computeWorkflow(id, opts) {
|
|
|
522
614
|
steps.push(step('spec', 'spec', 'done', `${specNames} — first-appeared at or before the first build commit`));
|
|
523
615
|
} else {
|
|
524
616
|
const late = rubricIdx !== null && rubricIdx > build.first && prdIdx <= build.first ? rubric : prd;
|
|
525
|
-
|
|
617
|
+
// `legacy` applies here exactly as it does to O4. It always should have — the rule simply
|
|
618
|
+
// never fired for a legacy record while the board shared the gate's PR range, because an
|
|
619
|
+
// empty range has no build commit to be out of order with. Reading the trunk exposed it:
|
|
620
|
+
// 81 records that predate enforcement were suddenly failing a rule written after them.
|
|
621
|
+
steps.push(step('spec', 'spec', legacy ? 'unknown' : 'blocked',
|
|
526
622
|
`ORDER VIOLATION — implementation (${build.sample}) was committed BEFORE ${late} first appeared`,
|
|
527
623
|
'the rubric is the spec (D13). Land the spec artifacts in an earlier commit than the build, or split this PR so the spec lands first',
|
|
528
|
-
{ blocking:
|
|
624
|
+
{ blocking: !legacy, rule: rubricIdx !== null && rubricIdx > build.first && prdIdx <= build.first ? 'O2' : 'O1' }));
|
|
529
625
|
}
|
|
530
626
|
}
|
|
531
627
|
}
|
|
@@ -776,6 +872,10 @@ function renderBoard(rows, opts) {
|
|
|
776
872
|
out.push('');
|
|
777
873
|
// Never a silent cap — a truncated list that does not say so reads as "that is everything".
|
|
778
874
|
if (hidden) out.push(` ${c(33, `+ ${hidden} more without a recorded acceptance, not shown`)} ${c(90, `— node ${opts.self} --all`)}`);
|
|
875
|
+
// The two ranges, said out loud. The board reads the whole trunk so a delivered initiative does
|
|
876
|
+
// not render as unstarted; the gate reads the PR so "spec before build" stays decidable. A rule
|
|
877
|
+
// can therefore read done here and still be evaluated on the PR — intended, and not a discrepancy.
|
|
878
|
+
out.push(` ${c(90, 'derived from the whole trunk history; the CI gate evaluates order within the PR only')}`);
|
|
779
879
|
out.push(` ${c(90, `one initiative in detail: node ${opts.self} <id>`)}`);
|
|
780
880
|
out.push('');
|
|
781
881
|
return out.join('\n');
|
|
@@ -871,8 +971,12 @@ function main(argv) {
|
|
|
871
971
|
return 0;
|
|
872
972
|
}
|
|
873
973
|
|
|
874
|
-
|
|
875
|
-
|
|
974
|
+
// R-7: the BOARD reads the trunk, not the pull request. The gate path above keeps commitRange().
|
|
975
|
+
// The PR range is still computed and passed alongside — it is what `● in this change` means, and
|
|
976
|
+
// it is the one thing the trunk range genuinely cannot answer.
|
|
977
|
+
const range = historyRange(root);
|
|
978
|
+
const changeRange = commitRange(root);
|
|
979
|
+
const rows = listInitiatives(root).map((i) => computeWorkflow(i, { root, range, changeRange }));
|
|
876
980
|
if (json) { process.stdout.write(JSON.stringify(rows, null, 2) + '\n'); return 0; }
|
|
877
981
|
if (!rows.length) { process.stdout.write('\n no initiative records found — is this a PMOS repo?\n\n'); return 1; }
|
|
878
982
|
process.stdout.write(renderBoard(rows, { self, all }));
|
|
@@ -882,6 +986,9 @@ function main(argv) {
|
|
|
882
986
|
module.exports = {
|
|
883
987
|
computeWorkflow, renderLadder, renderBoard, listInitiatives, layout, inFlight, BOARD_CAP,
|
|
884
988
|
parseLeadingFrontmatter, specimenRoute, surfaceRoute, priorsCitation, isBuildPath, commitRange,
|
|
989
|
+
// the board's trunk range, exported so the suite can assert the split: the same record is legacy
|
|
990
|
+
// under BOTH ranges, and `in this change` is false under the trunk one.
|
|
991
|
+
historyRange,
|
|
885
992
|
ENFORCED_FROM,
|
|
886
993
|
};
|
|
887
994
|
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pmos-workspace",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.17.0",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "pmos-workspace",
|
|
9
|
-
"version": "0.
|
|
9
|
+
"version": "0.17.0",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@xterm/addon-fit": "^0.11.0",
|
package/workspace/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pmos-workspace",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.17.0",
|
|
4
4
|
"private": true,
|
|
5
|
-
"description": "PMOS Terminal Workspace
|
|
5
|
+
"description": "PMOS Terminal Workspace — local companion hosting real PTYs beside the library and gates (D62)",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"bin": {
|
|
8
8
|
"pmos": "bin/pmos.js"
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
"playwright-core": "^1.61.1",
|
|
30
30
|
"ws": "^8.18.0"
|
|
31
31
|
},
|
|
32
|
-
"//devDependencies": "design-parity's pixel axis only. Kept OUT of dependencies deliberately: `create-pmos workspace` installs this package with `npm install -g`, which takes dependencies and not devDependencies, so an adopter installing a local PM tool does not also install a screenshot differ. design-parity.js requires both optionally and degrades to text+style+geometry when they are absent, so the parity check still runs
|
|
32
|
+
"//devDependencies": "design-parity's pixel axis only. Kept OUT of dependencies deliberately: `create-pmos workspace` installs this package with `npm install -g`, which takes dependencies and not devDependencies, so an adopter installing a local PM tool does not also install a screenshot differ. design-parity.js requires both optionally and degrades to text+style+geometry when they are absent, so the parity check still runs — it just stops measuring pixels, and says so.",
|
|
33
33
|
"devDependencies": {
|
|
34
34
|
"pixelmatch": "^7.1.0",
|
|
35
35
|
"pngjs": "^7.0.0"
|
package/workspace/server.js
CHANGED
|
@@ -523,6 +523,10 @@ const ROLE_BY_DIR = {
|
|
|
523
523
|
prd: 'PRD', evals: 'eval', design: 'design', research: 'research', briefs: 'brief',
|
|
524
524
|
okrs: 'OKR', radar: 'radar', gtm: 'gtm', 'out-of-scope': 'rejected', 'harness-refine': 'harness',
|
|
525
525
|
'design-export': 'design', explainers: 'explainer', fixtures: 'fixture',
|
|
526
|
+
// The initiative RECORD (planning/initiatives/<id>.md) is the canonical anchor+lane carrier and
|
|
527
|
+
// the only artifact that exists for work backfilled from a merge. Before it was a role, a record
|
|
528
|
+
// with no sibling PRD landed in `ungrouped` and the initiative was invisible here.
|
|
529
|
+
initiatives: 'record',
|
|
526
530
|
};
|
|
527
531
|
// ── D60 specimens: prototype/<id>/, deliberately OUTSIDE planning/ ───────────────────────────
|
|
528
532
|
// The planning walk cannot reach them (planningRoots only finds `planning`), so a prototype leg was
|
|
@@ -575,17 +579,23 @@ function parseHead(full) {
|
|
|
575
579
|
head = buf.slice(0, n).toString('utf8');
|
|
576
580
|
} catch { return {}; }
|
|
577
581
|
const id = head.match(/^\s*[-*]?\s*initiative_id\s*:\s*["']?([A-Za-z0-9][\w.-]*)["']?\s*$/m);
|
|
582
|
+
// An initiative RECORD names itself with `id:`, not `initiative_id:`. Only the record role reads
|
|
583
|
+
// this (see docMeta) — a bare `id:` on any other document must not invent an initiative.
|
|
584
|
+
const rid = head.match(/^\s*id\s*:\s*["']?([A-Za-z0-9][\w.-]*)["']?\s*$/m);
|
|
578
585
|
const h1 = head.match(/^#\s+(.+)$/m);
|
|
579
|
-
//
|
|
580
|
-
//
|
|
586
|
+
// The anchor and the lane are DECLARED state and are read as such — they say what the work is
|
|
587
|
+
// pointed at, never how far along it is. `status:`/`stage:` are deliberately NOT read: they were
|
|
588
|
+
// hand-typed free text (23 distinct values in this repo, 33 absent, several truncated mid-YAML)
|
|
589
|
+
// funnelled through a regex, so `build complete, PR #138` rendered as `building`. Position is
|
|
590
|
+
// derived from evidence in deliveryIndex/deriveState instead — see the surface explainer.
|
|
581
591
|
const clean = (m) => m ? m[1].replace(/\*\*/g, '').split(/\s+[—–-]\s|\(/)[0].trim().slice(0, 48) : null;
|
|
582
|
-
const status = head.match(/^\s*[-*]?\s*status\s*:\s*(.+?)\s*$/mi);
|
|
583
|
-
const stage = head.match(/^\s*[-*]?\s*stage\s*:\s*(.+?)\s*$/mi);
|
|
584
592
|
const kr = head.match(/^\s*[-*]?\s*parent[_-]?kr\s*:\s*(.+?)\s*$/mi);
|
|
593
|
+
const lane = head.match(/^\s*[-*]?\s*lane\s*:\s*(.+?)\s*$/mi);
|
|
585
594
|
return {
|
|
586
595
|
initiative: id ? id[1] : null,
|
|
596
|
+
recordId: rid ? rid[1] : null,
|
|
587
597
|
title: h1 ? h1[1].replace(/^(PRD|Eval Rubric|Design Brief)\s*:\s*/i, '').trim() : null,
|
|
588
|
-
|
|
598
|
+
kr: clean(kr), lane: clean(lane),
|
|
589
599
|
};
|
|
590
600
|
}
|
|
591
601
|
function roleAndStem(rel, base) {
|
|
@@ -608,8 +618,9 @@ function docMeta(root, full) {
|
|
|
608
618
|
const meta = {
|
|
609
619
|
mtimeMs: st.mtimeMs, path: rel, name: base, role,
|
|
610
620
|
// frontmatter wins; the filename convention is the fallback, never the override
|
|
611
|
-
initiative: head.initiative ||
|
|
612
|
-
|
|
621
|
+
initiative: head.initiative || (role === 'record' ? head.recordId : null) || null,
|
|
622
|
+
stem, title: head.title || null,
|
|
623
|
+
kr: head.kr || null, lane: head.lane || null,
|
|
613
624
|
};
|
|
614
625
|
metaCache.set(full, meta);
|
|
615
626
|
return meta;
|
|
@@ -650,17 +661,17 @@ function initiativeIndex(root) {
|
|
|
650
661
|
const k = keyFor(m);
|
|
651
662
|
const art = { name: m.name, path: m.path, role: m.role, title: m.title };
|
|
652
663
|
if (!k) { ungrouped.push(art); continue; }
|
|
653
|
-
if (!groups.has(k)) groups.set(k, { id: k, title: null,
|
|
664
|
+
if (!groups.has(k)) groups.set(k, { id: k, title: null, kr: null, lane: null, artifacts: [] });
|
|
654
665
|
const g = groups.get(k);
|
|
655
666
|
g.artifacts.push(art);
|
|
656
667
|
if (m.role === 'PRD' && m.title) g.title = m.title; // the PRD names the initiative
|
|
657
668
|
else if (!g.title && m.title) g.title = m.title;
|
|
658
|
-
//
|
|
659
|
-
|
|
660
|
-
if (m.
|
|
661
|
-
if (m.
|
|
669
|
+
// The anchor and lane are the RECORD's to declare (D56/D59 — it is what the gates read); the
|
|
670
|
+
// PRD fills a gap for legacy initiatives that predate repo-native records, never overrides it.
|
|
671
|
+
if (m.kr && (m.role === 'record' || !g.kr)) g.kr = m.kr;
|
|
672
|
+
if (m.lane && (m.role === 'record' || !g.lane)) g.lane = m.lane;
|
|
662
673
|
}
|
|
663
|
-
const ROLE_ORDER = ['PRD', 'rubric', 'eval', 'design brief', 'design', 'reconcile', 'dogfood'];
|
|
674
|
+
const ROLE_ORDER = ['record', 'PRD', 'rubric', 'eval', 'design brief', 'design', 'reconcile', 'dogfood'];
|
|
664
675
|
const initiatives = [...groups.values()].map((g) => {
|
|
665
676
|
g.artifacts.sort((a, b) => {
|
|
666
677
|
const ia = ROLE_ORDER.indexOf(a.role), ib = ROLE_ORDER.indexOf(b.role);
|
|
@@ -695,57 +706,207 @@ function library(root) {
|
|
|
695
706
|
// client renders it; a surface that can't be produced returns ok:false and the client shows an
|
|
696
707
|
// honest can't-produce state. RICE/effort/milestones/progress are NET-NEW state → emitted as null
|
|
697
708
|
// ("— not tracked"), never invented (D14/D40 honesty).
|
|
698
|
-
const PHASE_OF = { planning: 'plan', building: 'deliver', blocked: 'deliver',
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
709
|
+
const PHASE_OF = { planning: 'plan', building: 'deliver', blocked: 'deliver', delivered: 'report', accepted: 'report', unknown: 'plan' };
|
|
710
|
+
|
|
711
|
+
// ── Delivery evidence (board-delivery-truth R-2/R-3). ──────────────────────────────────────────
|
|
712
|
+
// "Has this shipped?" answered from the trunk's own history rather than from anything anyone typed.
|
|
713
|
+
//
|
|
714
|
+
// ONE `git log` per trunk head, memoised on that sha — ~0.3s and ~800 KB on this repo's 1,214
|
|
715
|
+
// commits, paid once and reused by every surface until the trunk moves. Local only: git reads the
|
|
716
|
+
// object store on disk, so the suite's "no network" guarantee (K. Surfaces) is untouched.
|
|
717
|
+
//
|
|
718
|
+
// TWO signals, because one is not enough. The branch convention (`initiative/<id>`) is precise but
|
|
719
|
+
// incomplete: 31% of this repo's merged PRs shipped on `claude/*` branches, which match no id. So a
|
|
720
|
+
// commit that MENTIONS the id and TOUCHES a build path counts too. The build-path requirement is
|
|
721
|
+
// what keeps the mention signal honest — `feat(planning): add <id> PRD` mentions the id while
|
|
722
|
+
// delivering nothing, and without the path test every specified initiative would read as shipped.
|
|
723
|
+
const BUILD_EXCLUDE = /^(planning|okf|prototype)\//;
|
|
724
|
+
function isDeliveryPath(f) {
|
|
725
|
+
if (!f) return false;
|
|
726
|
+
if (BUILD_EXCLUDE.test(f)) return false;
|
|
727
|
+
return !/^[^/]+\.md$/.test(f); // root governance prose (DECISIONS.md, CLAUDE.md…) is not a build
|
|
728
|
+
}
|
|
729
|
+
const deliveryCache = new Map(); // root -> { head, index }
|
|
730
|
+
function trunkRef(root) {
|
|
731
|
+
for (const c of ['origin/main', 'main', 'origin/master', 'master']) {
|
|
732
|
+
const r = gitCap(root, ['rev-parse', '--verify', '--quiet', c]);
|
|
733
|
+
if (r.ok && r.out.trim()) return { ref: c, head: r.out.trim() };
|
|
734
|
+
}
|
|
705
735
|
return null;
|
|
706
736
|
}
|
|
707
|
-
function
|
|
708
|
-
|
|
737
|
+
function gitCap(root, args) {
|
|
738
|
+
try {
|
|
739
|
+
const r = spawnSync('git', args, { cwd: root, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
|
|
740
|
+
if (r.error || r.status !== 0) return { ok: false, out: '', err: (r.stderr || (r.error && r.error.message) || '').trim() };
|
|
741
|
+
return { ok: true, out: r.stdout || '' };
|
|
742
|
+
} catch (e) { return { ok: false, out: '', err: String((e && e.message) || e) }; }
|
|
743
|
+
}
|
|
744
|
+
/**
|
|
745
|
+
* → { ok, reason?, byBranch:Set<id>, byMention:Set<id> }
|
|
746
|
+
* `ok:false` is a REAL answer and never degrades into "nothing was delivered" — the caller renders
|
|
747
|
+
* it red and marks every initiative's delivery `unknown` (R-3). A board that cannot read the
|
|
748
|
+
* history must say so; silently reporting `planning` for 110 shipped initiatives is the failure
|
|
749
|
+
* this whole initiative exists to remove.
|
|
750
|
+
*/
|
|
751
|
+
function deliveryIndex(root, ids) {
|
|
752
|
+
const t = trunkRef(root);
|
|
753
|
+
if (!t) return { ok: false, reason: 'no trunk branch (main/master) is reachable from this repository — delivery cannot be derived', byBranch: new Set(), byMention: new Set() };
|
|
754
|
+
const hit = deliveryCache.get(root);
|
|
755
|
+
if (hit && hit.head === t.head) return hit.index;
|
|
756
|
+
|
|
757
|
+
// %x1e separates commits, %x1f separates the message from --name-only's file list.
|
|
758
|
+
const r = gitCap(root, ['log', '--format=%x1e%s %b%x1f', '--name-only', t.head]);
|
|
759
|
+
if (!r.ok) {
|
|
760
|
+
return { ok: false, reason: `git could not read the history at ${t.ref}${r.err ? ` — ${r.err}` : ''}`, byBranch: new Set(), byMention: new Set() };
|
|
761
|
+
}
|
|
762
|
+
const byBranch = new Set(), byMention = new Set();
|
|
763
|
+
const known = Array.isArray(ids) ? ids : [...ids];
|
|
764
|
+
for (const chunk of r.out.split('\x1e')) {
|
|
765
|
+
if (!chunk.trim()) continue;
|
|
766
|
+
const cut = chunk.indexOf('\x1f');
|
|
767
|
+
const msg = cut < 0 ? chunk : chunk.slice(0, cut);
|
|
768
|
+
const files = cut < 0 ? [] : chunk.slice(cut + 1).split('\n').map((s) => s.trim()).filter(Boolean);
|
|
769
|
+
for (const m of msg.matchAll(/initiative\/([A-Za-z0-9][\w.-]*)/g)) byBranch.add(m[1]);
|
|
770
|
+
if (!files.some(isDeliveryPath)) continue;
|
|
771
|
+
for (const id of known) if (id.length > 3 && msg.includes(id)) byMention.add(id);
|
|
772
|
+
}
|
|
773
|
+
const index = { ok: true, byBranch, byMention };
|
|
774
|
+
deliveryCache.set(root, { head: t.head, index });
|
|
775
|
+
return index;
|
|
776
|
+
}
|
|
777
|
+
/**
|
|
778
|
+
* The five states, in precedence order. Every one of them names the artifact it was read from, so
|
|
779
|
+
* the surface can show its own evidence instead of asking to be trusted.
|
|
780
|
+
*
|
|
781
|
+
* `blocked` deliberately outranks `delivered`: work the PM RETURNED is work that needs them, and
|
|
782
|
+
* hiding that behind a delivery glyph would reproduce, in a new place, the exact failure of a board
|
|
783
|
+
* that reports green over a red underneath. (The PRD's R-1 table lists delivered first; this is the
|
|
784
|
+
* one ordering departure, made because the alternative buries a returned initiative.)
|
|
785
|
+
*/
|
|
786
|
+
function deriveState(ev) {
|
|
787
|
+
if (ev.accepted) return { state: 'accepted', why: `accepted by the PM${ev.acceptedAt ? ` on ${String(ev.acceptedAt).slice(0, 10)}` : ''}` };
|
|
788
|
+
if (ev.returned) return { state: 'blocked', why: 'the PM returned this at the Acceptance Gate' };
|
|
789
|
+
if (ev.needs) return { state: 'blocked', why: `${ev.needs} open item${ev.needs > 1 ? 's' : ''} in needs.json` };
|
|
790
|
+
// Not `planning`. An unreadable history means the evidence for the delivery states is missing,
|
|
791
|
+
// and `planning` is itself a claim ("nothing has happened") that the missing evidence cannot
|
|
792
|
+
// support. `unknown` is the only honest answer, and it is visibly not a resting state.
|
|
793
|
+
if (ev.delivery === 'unknown') return { state: 'unknown', why: 'the trunk history could not be read — delivery cannot be derived here' };
|
|
794
|
+
if (ev.deliveredBy) return { state: 'delivered', why: ev.deliveredBy === 'branch' ? 'an initiative/<id> branch merged to the trunk' : 'a commit on the trunk names this initiative and changes build files' };
|
|
795
|
+
if (ev.runs) return { state: 'building', why: `${ev.runs} run${ev.runs > 1 ? 's' : ''} logged, nothing merged yet` };
|
|
796
|
+
return { state: 'planning', why: 'no run, no merge, no acceptance — nothing has happened yet' };
|
|
797
|
+
}
|
|
798
|
+
// BOTH acceptance ledgers, oldest-wins-never: the workspace writes to its state dir, while the
|
|
799
|
+
// ladder and every hand-recorded acceptance in pmos-self live in planning/runs/. Reading only the
|
|
800
|
+
// state dir is why this surface reported 0 accepted against 23 real acceptance rows — the same
|
|
801
|
+
// "one field, several readers, none agreeing" defect the anchor resolver was built to end.
|
|
802
|
+
function acceptanceFilesFor(w) {
|
|
803
|
+
const out = [];
|
|
804
|
+
const d = stateDirFor(w);
|
|
805
|
+
if (d) out.push(path.join(d, 'acceptances.jsonl'));
|
|
806
|
+
if (w && w.root) out.push(path.join(w.root, 'planning', 'runs', 'acceptances.jsonl'));
|
|
807
|
+
return out;
|
|
709
808
|
}
|
|
710
809
|
function readAcceptances(w) {
|
|
711
|
-
const acc = new Map(); // initiative_id ->
|
|
712
|
-
const f
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
for (const line of
|
|
810
|
+
const acc = new Map(); // initiative_id -> { action, at }
|
|
811
|
+
for (const f of acceptanceFilesFor(w)) {
|
|
812
|
+
let text = '';
|
|
813
|
+
try { text = fs.readFileSync(f, 'utf8'); } catch { continue; }
|
|
814
|
+
for (const line of text.split('\n')) {
|
|
716
815
|
if (!line.trim()) continue;
|
|
717
816
|
// Tolerate both shapes: this server's {action} and the kit's {accepted} (D54 adapter).
|
|
817
|
+
// THE BOOLEAN OUTRANKS THE LABEL. `action` is an open vocabulary describing what the PM did
|
|
818
|
+
// — this corpus holds accept, merge, publish, return — while `accepted` is the D12 verdict
|
|
819
|
+
// itself. Reading `action` first (the previous behaviour) meant every row labelled `merge` or
|
|
820
|
+
// `publish` carried `accepted: true` and was still not counted: 7 of 16 real acceptances went
|
|
821
|
+
// missing, and the surface reported those initiatives as merely `building`.
|
|
718
822
|
try {
|
|
719
823
|
const r = JSON.parse(line);
|
|
720
824
|
if (!r.initiative_id) continue;
|
|
721
|
-
|
|
825
|
+
const label = String(r.action || '').toLowerCase();
|
|
826
|
+
const action = r.accepted === true ? 'accept'
|
|
827
|
+
: r.accepted === false ? 'return'
|
|
828
|
+
: label === 'return' ? 'return'
|
|
829
|
+
: ['accept', 'approve', 'merge', 'publish'].includes(label) ? 'accept'
|
|
830
|
+
: null;
|
|
831
|
+
const at = r.at || r.date || null;
|
|
832
|
+
// Last write wins, but a row with no timestamp never displaces a dated one.
|
|
833
|
+
const prev = acc.get(r.initiative_id);
|
|
834
|
+
if (prev && prev.at && !at) continue;
|
|
835
|
+
acc.set(r.initiative_id, { action, at });
|
|
722
836
|
} catch {}
|
|
723
837
|
}
|
|
724
|
-
}
|
|
838
|
+
}
|
|
725
839
|
return acc;
|
|
726
840
|
}
|
|
841
|
+
// How many runs each initiative has logged — evidence that work STARTED, which is the only thing
|
|
842
|
+
// separating `building` from `planning`. The file is the same one the ladder reads.
|
|
843
|
+
function readRunCounts(w) {
|
|
844
|
+
const counts = new Map();
|
|
845
|
+
const d = stateDirFor(w);
|
|
846
|
+
const files = [];
|
|
847
|
+
if (d) files.push(path.join(d, 'agent-runs.jsonl'));
|
|
848
|
+
if (w && w.root) files.push(path.join(w.root, 'planning', 'runs', 'agent-runs.jsonl'));
|
|
849
|
+
for (const f of files) {
|
|
850
|
+
let text = '';
|
|
851
|
+
try { text = fs.readFileSync(f, 'utf8'); } catch { continue; }
|
|
852
|
+
for (const line of text.split('\n')) {
|
|
853
|
+
if (!line.trim()) continue;
|
|
854
|
+
try {
|
|
855
|
+
const r = JSON.parse(line);
|
|
856
|
+
if (r && r.initiative_id) counts.set(r.initiative_id, (counts.get(r.initiative_id) || 0) + 1);
|
|
857
|
+
} catch {}
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
return counts;
|
|
861
|
+
}
|
|
727
862
|
function surfaceInitiatives(root, w) {
|
|
728
863
|
const { initiatives } = initiativeIndex(root);
|
|
729
864
|
const acc = readAcceptances(w);
|
|
865
|
+
const runs = readRunCounts(w);
|
|
866
|
+
const needsRaw = readNeeds(w) || [];
|
|
867
|
+
const needsBy = new Map();
|
|
868
|
+
for (const n of needsRaw) if (n && n.initiative_id) needsBy.set(n.initiative_id, (needsBy.get(n.initiative_id) || 0) + 1);
|
|
869
|
+
|
|
870
|
+
const del = deliveryIndex(root, initiatives.map((g) => g.id));
|
|
730
871
|
const items = initiatives.map((g) => {
|
|
731
|
-
const
|
|
872
|
+
const a = acc.get(g.id) || {};
|
|
873
|
+
const deliveredBy = !del.ok ? null : del.byBranch.has(g.id) ? 'branch' : del.byMention.has(g.id) ? 'mention' : null;
|
|
874
|
+
const { state, why } = deriveState({
|
|
875
|
+
accepted: a.action === 'accept' || a.action === 'approve', acceptedAt: a.at,
|
|
876
|
+
returned: a.action === 'return',
|
|
877
|
+
needs: needsBy.get(g.id) || 0,
|
|
878
|
+
delivery: del.ok ? 'known' : 'unknown',
|
|
879
|
+
deliveredBy,
|
|
880
|
+
runs: runs.get(g.id) || 0,
|
|
881
|
+
});
|
|
732
882
|
return {
|
|
733
883
|
id: g.id, title: g.title || null,
|
|
734
|
-
status:
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
884
|
+
status: state, statusText: why, phase: PHASE_OF[state] || 'plan',
|
|
885
|
+
// `stage` was the declared field this surface used to trust. It is gone, not renamed —
|
|
886
|
+
// the client renders `statusText`, which always names the artifact the state was read from.
|
|
887
|
+
stage: null, kr: g.kr || null, lane: g.lane || null,
|
|
888
|
+
accepted: state === 'accepted',
|
|
889
|
+
delivered: state === 'accepted' || state === 'delivered',
|
|
890
|
+
delivery_evidence: deliveredBy,
|
|
891
|
+
artifacts: g.artifacts.map((a2) => ({ role: a2.role, name: a2.name, path: a2.path })),
|
|
738
892
|
// NET-NEW state — not on disk yet, so null (the surface renders "— not tracked", never a guess)
|
|
739
893
|
rice: null, effort: null, milestones: null, progress: null, tracked: false,
|
|
740
894
|
};
|
|
741
895
|
});
|
|
742
896
|
const by = (k) => items.filter((i) => i.status === k).length;
|
|
743
|
-
const needs =
|
|
897
|
+
const needs = needsRaw.map((n) => ({
|
|
744
898
|
t: n.question || '(unstated)', kind: n.kind || 'question', initiative_id: n.initiative_id || null,
|
|
745
899
|
}));
|
|
746
900
|
return {
|
|
747
901
|
initiatives: items,
|
|
748
|
-
pulse: {
|
|
902
|
+
pulse: {
|
|
903
|
+
total: items.length, planning: by('planning'), building: by('building'),
|
|
904
|
+
blocked: by('blocked'), delivered: by('delivered'), accepted: by('accepted'),
|
|
905
|
+
unknown: by('unknown'),
|
|
906
|
+
},
|
|
907
|
+
// R-3: a failed read is reported, never absorbed. The client renders this RED above the list
|
|
908
|
+
// rather than letting 110 shipped initiatives quietly read `planning`.
|
|
909
|
+
delivery_read: del.ok ? null : (del.reason || 'the trunk history could not be read'),
|
|
749
910
|
needs,
|
|
750
911
|
};
|
|
751
912
|
}
|
|
@@ -755,7 +916,7 @@ function surfaceRoadmap(root, w) {
|
|
|
755
916
|
label, dates_tracked: false,
|
|
756
917
|
items: items.filter((i) => keys.includes(i.status)).map((i) => ({ id: i.id, status: i.status, phase: i.phase, progress: i.progress })),
|
|
757
918
|
});
|
|
758
|
-
const groups = [bucket('In flight', ['building', 'blocked']), bucket('Planned', ['planning']), bucket('Landed', ['
|
|
919
|
+
const groups = [bucket('In flight', ['building', 'blocked']), bucket('Planned', ['planning']), bucket('Landed', ['delivered', 'accepted'])]
|
|
759
920
|
.filter((g) => g.items.length);
|
|
760
921
|
return { groups, note: 'dates are net-new state — grouped by status until a timeline is tracked' };
|
|
761
922
|
}
|
|
@@ -932,12 +1093,16 @@ function surfaceReport(root, w) {
|
|
|
932
1093
|
const s = surfaceInitiatives(root, w);
|
|
933
1094
|
const line = (i) => `${i.id}${i.title ? ' — ' + i.title : ''}`;
|
|
934
1095
|
const inflight = s.initiatives.filter((i) => i.status === 'building' || i.status === 'blocked');
|
|
935
|
-
|
|
1096
|
+
// Delivered and accepted are reported SEPARATELY, not summed into one "landed" heading. The gap
|
|
1097
|
+
// between them is the D12 Acceptance Gate's backlog — the number this report exists to surface.
|
|
1098
|
+
const delivered = s.initiatives.filter((i) => i.status === 'delivered');
|
|
1099
|
+
const accepted = s.initiatives.filter((i) => i.status === 'accepted');
|
|
936
1100
|
const sections = [
|
|
937
1101
|
{ heading: 'In flight', items: inflight.map(line) },
|
|
938
|
-
{ heading: '
|
|
1102
|
+
{ heading: 'Delivered — acceptance not recorded', items: delivered.map(line) },
|
|
1103
|
+
{ heading: 'Accepted', items: accepted.map(line) },
|
|
939
1104
|
{ heading: 'Needs you', items: s.needs.map((n) => n.t) },
|
|
940
|
-
{ heading: 'Metrics pulse', items: [`${s.pulse.total} initiatives · ${s.pulse.building} building · ${s.pulse.blocked} blocked · ${s.pulse.
|
|
1105
|
+
{ heading: 'Metrics pulse', items: [`${s.pulse.total} initiatives · ${s.pulse.building} building · ${s.pulse.blocked} blocked · ${s.pulse.delivered} delivered · ${s.pulse.accepted} accepted`] },
|
|
941
1106
|
];
|
|
942
1107
|
return { cadence: 'weekly', sections };
|
|
943
1108
|
}
|
|
@@ -3608,10 +3773,27 @@ async function handleRequest(req, res) {
|
|
|
3608
3773
|
'and useful answer.',
|
|
3609
3774
|
].filter((l) => l !== '').join('\n');
|
|
3610
3775
|
|
|
3776
|
+
// NOT typed blind into the PTY. An agent CLI opening a folder it has never seen shows a
|
|
3777
|
+
// trust prompt first — and a fresh tenant repo is by definition such a folder, so the case this
|
|
3778
|
+
// feature exists for is the exact case where blind input is swallowed by a security dialog and
|
|
3779
|
+
// the drafting silently never happens. Found by installing the published kit and watching a real
|
|
3780
|
+
// session sit at "Is this a project you trust?" with the prompt already gone.
|
|
3781
|
+
//
|
|
3782
|
+
// Auto-answering that dialog is not on the table: it is the PM's decision, not ours. So the
|
|
3783
|
+
// prompt is PERSISTED and STAGED instead — it cannot be lost, and the PM sends it once the agent
|
|
3784
|
+
// is actually at its input.
|
|
3785
|
+
const promptFile = path.join(adir, 'prompt-' + Date.now() + '.md');
|
|
3786
|
+
try { fs.writeFileSync(promptFile, prompt + '\n'); }
|
|
3787
|
+
catch (e) { warnWriteFail('alignment prompt', e); return json(res, { ok: false, error: 'could not stage the drafting prompt' }, 500); }
|
|
3788
|
+
|
|
3611
3789
|
const s = spawnSession({ name: 'align: ' + path.basename(rel), agent: 'claude', ws: w.id,
|
|
3612
|
-
origin: 'align:' + rel
|
|
3790
|
+
origin: 'align:' + rel });
|
|
3613
3791
|
const started = !!(s && s.pty);
|
|
3792
|
+
let staged = false;
|
|
3793
|
+
if (started) { try { writeScratchpad(w, s.id, prompt); staged = true; } catch {} }
|
|
3614
3794
|
return json(res, { ok: started, sessionId: s && s.id, targets, sink: adir,
|
|
3795
|
+
prompt_file: promptFile, staged,
|
|
3796
|
+
next: started ? 'the drafting prompt is staged in this session\'s scratchpad — answer any trust prompt the agent shows, then send it' : undefined,
|
|
3615
3797
|
error: started ? undefined : ((s && s.note) || 'could not start the session') });
|
|
3616
3798
|
}
|
|
3617
3799
|
if (p === '/api/align/drafts') {
|
|
@@ -3626,8 +3808,19 @@ async function handleRequest(req, res) {
|
|
|
3626
3808
|
let doc;
|
|
3627
3809
|
try { doc = JSON.parse(fs.readFileSync(path.join(adir, pick), 'utf8')); }
|
|
3628
3810
|
catch { return json(res, { ok: false, error: `the draft file '${pick}' is unreadable or not valid JSON` }, 500); }
|
|
3629
|
-
|
|
3630
|
-
|
|
3811
|
+
// A malformed draft is REFUSED at read, not half-rendered (contract crit 4). Silently dropping a
|
|
3812
|
+
// bad draft would let the agent's mistake reach the PM as a shorter, unexplained list — the same
|
|
3813
|
+
// "looks fine, is wrong" failure the anchored write guard exists to stop. An empty `drafts` array
|
|
3814
|
+
// is valid and calm (crit 3): the agent looked and found nothing to realign.
|
|
3815
|
+
if (!Array.isArray(doc.drafts)) {
|
|
3816
|
+
return json(res, { ok: false, error: `the draft file '${pick}' has no drafts array — the agent did not produce the documented shape` }, 422);
|
|
3817
|
+
}
|
|
3818
|
+
for (const d of doc.drafts) {
|
|
3819
|
+
if (!d || typeof d.file !== 'string' || typeof d.after !== 'string' || (d.before != null && typeof d.before !== 'string')) {
|
|
3820
|
+
return json(res, { ok: false, error: `a draft in '${pick}' is missing a file or an after — refusing to render a partial set (crit 4)` }, 422);
|
|
3821
|
+
}
|
|
3822
|
+
}
|
|
3823
|
+
return json(res, { ok: true, id: pick.replace(/\.json$/, ''), source: doc.source || null, at: doc.at || null, drafts: doc.drafts });
|
|
3631
3824
|
}
|
|
3632
3825
|
if (p === '/api/align/apply' && req.method === 'POST') {
|
|
3633
3826
|
// Apply ONE drafted realignment. Same anchored guard as everything else that writes here: the
|
|
@@ -3969,4 +4162,9 @@ module.exports = { server, spawnSession, removeSession, renameSession, sessions,
|
|
|
3969
4162
|
// where the anchor resolver is looked for — exported so the suite can assert the INSTALL
|
|
3970
4163
|
// location is in the list. A dev checkout satisfies entry 1 and would hide the loss of entry 2
|
|
3971
4164
|
// exactly as it hid the workflow engine's absence for four releases (PKG4).
|
|
3972
|
-
ANCHOR_LIB_PATHS
|
|
4165
|
+
ANCHOR_LIB_PATHS,
|
|
4166
|
+
// the delivery derivation (board-delivery-truth), exported so the suite can assert the three
|
|
4167
|
+
// things that matter independently of a rendered page: that no declared field reaches a status,
|
|
4168
|
+
// that an unreadable history reports rather than degrades, and that a planning-only commit does
|
|
4169
|
+
// not count as delivery.
|
|
4170
|
+
surfaceInitiatives, deliveryIndex, deriveState, isDeliveryPath };
|