create-pmos 0.30.1 → 0.31.1
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 +333 -985
- package/package.json +1 -1
- package/payload/AGENTS.md +52 -149
- package/payload/KIT-VERSION +1 -1
- package/payload/okf/core/concepts/control-plane.md +10 -4
- package/payload/scripts/md-newlines.js +204 -0
- package/payload/scripts/md-normalize.js +223 -0
- package/payload/scripts/workflow-state.js +61 -0
- package/workspace/lib/engine/workflow-state.js +61 -0
- package/workspace/lib/md-normalize.js +223 -0
- package/workspace/package.json +1 -1
- package/workspace/public/index.html +4 -0
- package/workspace/public/pm/tab-doc.js +34 -3
- package/workspace/server.js +27 -3
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
/* md-normalize — a newline in a PMOS document means what it says.
|
|
2
|
+
*
|
|
3
|
+
* ONE implementation, used on three sides, in the idiom `preview-kind.js` established for exactly
|
|
4
|
+
* this reason:
|
|
5
|
+
* - scripts/md-newlines.js requires it (the check, the --fix, the CI ratchet)
|
|
6
|
+
* - workspace/server.js requires it (the doc tab's `normalize` action)
|
|
7
|
+
* - the browser loads it as /lib/md-normalize.js (window.MdNormalize)
|
|
8
|
+
* so the command, the gate and the button can never disagree about what a normalized file is.
|
|
9
|
+
*
|
|
10
|
+
* ── WHAT IT IS FOR ─────────────────────────────────────────────────────────────────────────────
|
|
11
|
+
* PMOS hard-wraps prose at ~100 columns: 30,345 continuation lines across 1,013 of 1,022 tracked
|
|
12
|
+
* files. A single newline inside a paragraph is NOT a line break in markdown — CommonMark joins the
|
|
13
|
+
* lines, GitHub joins them, the workspace's own renderer joins them — so this looks right everywhere
|
|
14
|
+
* inside PMOS. It stops looking right the moment a file is published: Jira wiki markup treats a
|
|
15
|
+
* newline as a line break, a Google Doc keeps every newline, and several markdown→ADF converters
|
|
16
|
+
* emit a hard break per `\n`. The paragraph arrives as a stack of ragged short lines, and no
|
|
17
|
+
* converter can undo it, because an accidental wrap and a deliberate break are THE SAME BYTE in the
|
|
18
|
+
* source. The only place the distinction still exists is the file.
|
|
19
|
+
*
|
|
20
|
+
* ── WHAT IT REFUSES TO TOUCH ───────────────────────────────────────────────────────────────────
|
|
21
|
+
* Frontmatter, fenced code (including unterminated), indented code, table rows, headings, rules,
|
|
22
|
+
* link-reference definitions, HTML blocks, a line ending in two spaces or a backslash (a DELIBERATE
|
|
23
|
+
* hard break — an author's intent, not a wrap), and a line followed by a setext underline. Joining
|
|
24
|
+
* inside any of those changes what the document MEANS rather than how it wraps, and a repair tool
|
|
25
|
+
* that damages a file is worse than the defect it repairs.
|
|
26
|
+
*
|
|
27
|
+
* ── THE CORRECTNESS PROPERTY ───────────────────────────────────────────────────────────────────
|
|
28
|
+
* Idempotence: normalize(normalize(x)) === normalize(x), asserted over all 1,022 tracked files
|
|
29
|
+
* rather than over fixtures. The ratchet, the button and an adopter's repair run are all
|
|
30
|
+
* untrustworthy without it.
|
|
31
|
+
*/
|
|
32
|
+
'use strict';
|
|
33
|
+
(function (root, factory) {
|
|
34
|
+
const api = factory();
|
|
35
|
+
if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
|
36
|
+
else root.MdNormalize = api;
|
|
37
|
+
})(typeof self !== 'undefined' ? self : this, function () {
|
|
38
|
+
const FENCE = /^(\s*)(`{3,}|~{3,})/;
|
|
39
|
+
const HEADING = /^\s{0,3}#{1,6}(\s|$)/;
|
|
40
|
+
const RULE = /^\s{0,3}(?:-{3,}|\*{3,}|_{3,})\s*$/;
|
|
41
|
+
const ITEM = /^(\s*)([-*+]|\d{1,9}[.)])(\s+)(\S.*)$/;
|
|
42
|
+
const QUOTE = /^(\s{0,3}>\s?)(.*)$/;
|
|
43
|
+
const TABLE_DELIM = /^\s*\|?[\s:|-]*-[\s:|-]*\|?\s*$/;
|
|
44
|
+
const SETEXT = /^\s{0,3}(=+|-+)\s*$/;
|
|
45
|
+
const REF_DEF = /^\s{0,3}\[[^\]]+\]:\s/;
|
|
46
|
+
const HTML_BLOCK = /^\s{0,3}<[a-zA-Z!/?]/;
|
|
47
|
+
const INDENTED_CODE = /^(?: {4,}|\t)\S/;
|
|
48
|
+
/* A break the AUTHOR asked for. Markdown spells it two trailing spaces or a trailing backslash,
|
|
49
|
+
* and it is the one newline in a paragraph that is not an accident — eating it would be removing
|
|
50
|
+
* intent, which is the failure this tool exists to avoid, not to commit. */
|
|
51
|
+
const HARD_BREAK = /(?: {2,}|\\)$/;
|
|
52
|
+
|
|
53
|
+
const isBlank = (l) => !l.trim();
|
|
54
|
+
|
|
55
|
+
/** A table's header row: this line has a pipe and the NEXT line is the delimiter. */
|
|
56
|
+
const isTableHeader = (lines, i) =>
|
|
57
|
+
lines[i].includes('|') && TABLE_DELIM.test(lines[i + 1] || '') && (lines[i + 1] || '').includes('-');
|
|
58
|
+
|
|
59
|
+
/** A row inside a table body — a pipe line in a run that began with a header. */
|
|
60
|
+
function tableRunEnd(lines, i) {
|
|
61
|
+
let j = i + 2;
|
|
62
|
+
while (j < lines.length && lines[j].includes('|') && lines[j].trim()) j++;
|
|
63
|
+
return j;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* A line that can never be joined ONTO the previous one — it opens a block of its own. This is
|
|
68
|
+
* the list the blank-line rule is derived from too, so "what needs a blank line above it" and
|
|
69
|
+
* "what stops a paragraph" cannot drift apart.
|
|
70
|
+
*/
|
|
71
|
+
function opensBlock(lines, i) {
|
|
72
|
+
const l = lines[i];
|
|
73
|
+
return HEADING.test(l) || RULE.test(l) || FENCE.test(l) || ITEM.test(l) || QUOTE.test(l)
|
|
74
|
+
|| REF_DEF.test(l) || HTML_BLOCK.test(l) || INDENTED_CODE.test(l) || isTableHeader(lines, i)
|
|
75
|
+
|| SETEXT.test(l);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Normalize `src`.
|
|
80
|
+
*
|
|
81
|
+
* Returns `{ text, changed, joins, blanks }` — the bytes, whether anything moved, how many lines
|
|
82
|
+
* were joined into a paragraph or an item, and how many blank lines were inserted above a block.
|
|
83
|
+
* The counts are what the CLI reports per file: a tool that says "changed" without saying what it
|
|
84
|
+
* changed is asking to be trusted rather than read.
|
|
85
|
+
*/
|
|
86
|
+
function normalize(src) {
|
|
87
|
+
const input = String(src == null ? '' : src);
|
|
88
|
+
const nl = input.indexOf('\r\n') >= 0 ? '\r\n' : '\n';
|
|
89
|
+
const lines = input.replace(/\r\n?/g, '\n').split('\n');
|
|
90
|
+
const out = [];
|
|
91
|
+
let joins = 0;
|
|
92
|
+
let blanks = 0;
|
|
93
|
+
let i = 0;
|
|
94
|
+
|
|
95
|
+
// ── frontmatter: copied through, never parsed and never joined ──────────────────────────────
|
|
96
|
+
if (lines[0] === '---') {
|
|
97
|
+
let j = 1;
|
|
98
|
+
while (j < lines.length && lines[j] !== '---') j++;
|
|
99
|
+
if (j < lines.length) { for (let k = 0; k <= j; k++) out.push(lines[k]); i = j + 1; }
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/* A continuation line, left-trimmed. Trailing whitespace is dropped ONLY when it is not a hard
|
|
103
|
+
* break: `.trim()` would eat the author's two trailing spaces mid-paragraph, which is the one
|
|
104
|
+
* newline in a paragraph that was asked for. */
|
|
105
|
+
const piece = (l) => {
|
|
106
|
+
const s2 = l.replace(/^\s+/, '');
|
|
107
|
+
return HARD_BREAK.test(s2) ? s2 : s2.replace(/\s+$/, '');
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
/** The blank line a block construct needs above it, when prose is what sits there. */
|
|
111
|
+
const blankAbove = () => {
|
|
112
|
+
const prev = out.length ? out[out.length - 1] : '';
|
|
113
|
+
if (!out.length || isBlank(prev)) return;
|
|
114
|
+
// Only prose gets a blank inserted after it. Two adjacent list items, a table row under its
|
|
115
|
+
// header, a fence closing — none of those want one, and inserting it would restructure the
|
|
116
|
+
// document rather than repair its wrapping.
|
|
117
|
+
if (ITEM.test(prev) || QUOTE.test(prev) || prev.includes('|') || HEADING.test(prev)) return;
|
|
118
|
+
out.push('');
|
|
119
|
+
blanks++;
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
while (i < lines.length) {
|
|
123
|
+
const line = lines[i];
|
|
124
|
+
|
|
125
|
+
if (isBlank(line)) { out.push(line); i++; continue; }
|
|
126
|
+
|
|
127
|
+
// fenced code — verbatim, to the closing fence or to EOF
|
|
128
|
+
const fence = FENCE.exec(line);
|
|
129
|
+
if (fence) {
|
|
130
|
+
blankAbove();
|
|
131
|
+
const close = new RegExp('^\\s*' + fence[2][0] + '{' + fence[2].length + ',}\\s*$');
|
|
132
|
+
out.push(line); i++;
|
|
133
|
+
while (i < lines.length && !close.test(lines[i])) { out.push(lines[i]); i++; }
|
|
134
|
+
if (i < lines.length) { out.push(lines[i]); i++; }
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// indented code, headings, rules, reference definitions, HTML — one line, untouched
|
|
139
|
+
if (INDENTED_CODE.test(line) || HEADING.test(line) || RULE.test(line) || REF_DEF.test(line)
|
|
140
|
+
|| HTML_BLOCK.test(line) || SETEXT.test(line)) {
|
|
141
|
+
/* A blank is NEVER inserted above a `---`/`===` line. Directly under prose that is a SETEXT
|
|
142
|
+
* HEADING, not a rule, and separating it would turn one heading into a paragraph and a
|
|
143
|
+
* horizontal rule — the untouchable rule wins over the blank-line rule wherever they meet. */
|
|
144
|
+
if (HEADING.test(line)) blankAbove();
|
|
145
|
+
out.push(line); i++;
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// a table: its header, its delimiter and its rows are records — never joined, never reflowed
|
|
150
|
+
if (isTableHeader(lines, i)) {
|
|
151
|
+
blankAbove();
|
|
152
|
+
const end = tableRunEnd(lines, i);
|
|
153
|
+
for (let k = i; k < end; k++) out.push(lines[k]);
|
|
154
|
+
i = end;
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// a list item, plus the continuation lines that belong to it
|
|
159
|
+
const item = ITEM.exec(line);
|
|
160
|
+
if (item) {
|
|
161
|
+
blankAbove();
|
|
162
|
+
/* INSIDE a list item, four spaces is not code — it is the item's own text column. The
|
|
163
|
+
* generic `opensBlock` test calls an indented continuation an indented code block and stops,
|
|
164
|
+
* which left every nested item's wrapped line un-joined. Code inside an item starts four
|
|
165
|
+
* columns further in than the item's CONTENT does, so that is the line this measures. */
|
|
166
|
+
const contentCol = item[1].length + item[2].length + item[3].length;
|
|
167
|
+
const isItemCode = (l) => (l.match(/^[ \t]*/)[0].replace(/\t/g, ' ').length) >= contentCol + 4;
|
|
168
|
+
let body = line;
|
|
169
|
+
i++;
|
|
170
|
+
while (i < lines.length && !isBlank(lines[i]) && !HARD_BREAK.test(body)
|
|
171
|
+
&& /^\s+\S/.test(lines[i]) && !isItemCode(lines[i])
|
|
172
|
+
&& !(HEADING.test(lines[i]) || RULE.test(lines[i]) || FENCE.test(lines[i])
|
|
173
|
+
|| ITEM.test(lines[i]) || QUOTE.test(lines[i]) || isTableHeader(lines, i))) {
|
|
174
|
+
body += ' ' + piece(lines[i]);
|
|
175
|
+
joins++; i++;
|
|
176
|
+
}
|
|
177
|
+
out.push(body);
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// a blockquote line, plus its lazy continuations
|
|
182
|
+
const quote = QUOTE.exec(line);
|
|
183
|
+
if (quote) {
|
|
184
|
+
/* A `>` with nothing after it is the blank line INSIDE a quote — it is what separates two
|
|
185
|
+
* quoted paragraphs. Joining the next paragraph onto it deletes the separator, silently
|
|
186
|
+
* turns two paragraphs into one, and (because the result then joins again on the next run)
|
|
187
|
+
* is how this tool would fail its own idempotence property. Found by running the proof over
|
|
188
|
+
* the real corpus rather than over fixtures: 50 files did exactly this. */
|
|
189
|
+
if (!quote[2].trim()) { out.push(line); i++; continue; }
|
|
190
|
+
let body = line;
|
|
191
|
+
i++;
|
|
192
|
+
while (i < lines.length && !isBlank(lines[i]) && !HARD_BREAK.test(body)) {
|
|
193
|
+
const q = QUOTE.exec(lines[i]);
|
|
194
|
+
const inner = q ? q[2] : lines[i];
|
|
195
|
+
if (!inner.trim()) break; // a blank quote line ends it
|
|
196
|
+
if (q && (HEADING.test(inner) || ITEM.test(inner) || RULE.test(inner) || FENCE.test(inner))) break;
|
|
197
|
+
if (!q && opensBlock(lines, i)) break; // lazy continuation, but not a new block
|
|
198
|
+
body += ' ' + piece(inner);
|
|
199
|
+
joins++; i++;
|
|
200
|
+
}
|
|
201
|
+
out.push(body);
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// prose — the case the whole tool is about
|
|
206
|
+
let para = line;
|
|
207
|
+
i++;
|
|
208
|
+
while (i < lines.length && !isBlank(lines[i]) && !opensBlock(lines, i) && !HARD_BREAK.test(para)) {
|
|
209
|
+
para += ' ' + piece(lines[i]);
|
|
210
|
+
joins++; i++;
|
|
211
|
+
}
|
|
212
|
+
out.push(para);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const text = out.join(nl);
|
|
216
|
+
return { text, changed: text !== input, joins, blanks };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** True when the file is already normalized — the check half, with no allocation of a new string. */
|
|
220
|
+
const isNormalized = (src) => !normalize(src).changed;
|
|
221
|
+
|
|
222
|
+
return { normalize, isNormalized };
|
|
223
|
+
});
|
|
@@ -333,6 +333,7 @@ function firstAppearance(root, range, rel) {
|
|
|
333
333
|
function buildCommits(root, range, lane, opts) {
|
|
334
334
|
if (!range.ok) return { unknown: true };
|
|
335
335
|
const id = opts && opts.id;
|
|
336
|
+
const recordIdx = opts && opts.recordIdx;
|
|
336
337
|
const whole = !!range.wholeHistory;
|
|
337
338
|
|
|
338
339
|
// Over the trunk, the id must be attributable before any commit counts. The index is built once
|
|
@@ -373,6 +374,19 @@ function buildCommits(root, range, lane, opts) {
|
|
|
373
374
|
// answerable at all. `--name-only` lists no files for a true merge commit, so a branch-attributed
|
|
374
375
|
// initiative is counted from its merge subject below rather than from this per-file loop.
|
|
375
376
|
if (whole && id && !commitNamesId(range, i, id)) continue;
|
|
377
|
+
/* Inside a PR RANGE the id filter above does not apply, because a pull request was assumed to
|
|
378
|
+
* carry one initiative and everything in it to be that initiative's build. A branch carrying
|
|
379
|
+
* several breaks that: PR #287 held five, and the FIRST commit on it — a D01 propagation touching
|
|
380
|
+
* supabase/functions/mcp/index.ts — was attributed to `sink-discipline-backport`, whose record did
|
|
381
|
+
* not yet exist. The gate reported ORDER VIOLATION, spec-after-build, for an initiative whose spec
|
|
382
|
+
* demonstrably preceded its build by three commits.
|
|
383
|
+
*
|
|
384
|
+
* A commit made BEFORE an initiative's record first appeared cannot be that initiative's
|
|
385
|
+
* implementation — the initiative did not exist to be built. That is the whole rule, and it is
|
|
386
|
+
* narrow on purpose: an implementation commit landing AFTER the record but BEFORE the PRD still
|
|
387
|
+
* fails, which is the case D13 actually exists to catch. This removes a false violation without
|
|
388
|
+
* removing a true one. */
|
|
389
|
+
if (!whole && recordIdx != null && recordIdx !== undefined && recordIdx >= 0 && i < recordIdx) continue;
|
|
376
390
|
count++;
|
|
377
391
|
if (first === null) { first = i; sample = hit; }
|
|
378
392
|
}
|
|
@@ -574,6 +588,9 @@ function computeWorkflow(id, opts) {
|
|
|
574
588
|
const prdIdx = prd ? firstAppearance(root, range, prd) : null;
|
|
575
589
|
const build = buildCommits(root, range, effLane, {
|
|
576
590
|
id,
|
|
591
|
+
// Where this initiative's own record first appears in the range — the earliest commit that could
|
|
592
|
+
// possibly be its build. See the note in buildCommits' attribution loop.
|
|
593
|
+
recordIdx: firstAppearance(root, range, recRel),
|
|
577
594
|
deliveryIndex: opts && opts.deliveryIndex,
|
|
578
595
|
declaredDelivery: fm.delivered_by,
|
|
579
596
|
});
|
|
@@ -945,6 +962,50 @@ function computeWorkflow(id, opts) {
|
|
|
945
962
|
}
|
|
946
963
|
}
|
|
947
964
|
|
|
965
|
+
/* ── outcome ─ D57: did the bet pay off? A SECOND verdict, at a second time ───────────────────
|
|
966
|
+
*
|
|
967
|
+
* This rung did not exist, and its absence is why the loop never ran: `outcome_metric` is optional
|
|
968
|
+
* in D57, nothing ever asked for it, and 0 of ~170 records declared one. An optional field with no
|
|
969
|
+
* prompt and no surface is a field that does not exist.
|
|
970
|
+
*
|
|
971
|
+
* `na` is a first-class, non-failing state on purpose. Most work is not a bet, and a rung that
|
|
972
|
+
* renders red for every initiative without a metric becomes noise and gets ignored — which is how
|
|
973
|
+
* you end up with a board nobody reads. */
|
|
974
|
+
{
|
|
975
|
+
const metric = String(fm.outcome_metric || '').trim();
|
|
976
|
+
const horizon = String(fm.outcome_horizon || '').trim();
|
|
977
|
+
const checks = readJsonl(path.join(L.runsDir, 'outcome_checks.jsonl'))
|
|
978
|
+
.filter((c) => c && c.initiative_id === id && ['validated', 'refuted', 'inconclusive'].includes(c.disposition));
|
|
979
|
+
const latest = checks.length ? checks[checks.length - 1] : null;
|
|
980
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
981
|
+
|
|
982
|
+
if (!metric) {
|
|
983
|
+
steps.push(step('outcome', 'outcome', 'na', 'no outcome declared — most work is not a bet (D57)'));
|
|
984
|
+
} else if (!horizon) {
|
|
985
|
+
/* A metric with no horizon can never come due, so it reads as diligence for ever while
|
|
986
|
+
* committing to nothing. That is worse than declaring nothing at all. */
|
|
987
|
+
steps.push(step('outcome', 'outcome', 'blocked',
|
|
988
|
+
`outcome_metric declared with NO outcome_horizon — a bet that can never come due`,
|
|
989
|
+
'add outcome_horizon: YYYY-MM-DD to the initiative record, or remove the metric', { rule: 'D57' }));
|
|
990
|
+
} else if (latest) {
|
|
991
|
+
/* A refuted bet is a COMPLETED check and an UNSUCCESSFUL bet. The rung says both — rendering
|
|
992
|
+
* it as a plain green tick is the same watermelon the review rung was fixed for. */
|
|
993
|
+
const done = latest.disposition === 'refuted' ? 'blocked' : 'done';
|
|
994
|
+
steps.push(step('outcome', 'outcome', done,
|
|
995
|
+
`${latest.disposition}${latest.evidence ? ` — ${String(latest.evidence).slice(0, 60)}` : ''}`,
|
|
996
|
+
latest.disposition === 'refuted'
|
|
997
|
+
? 'the check is complete and the bet did not pay off — that is a result, not a failure to record'
|
|
998
|
+
: undefined));
|
|
999
|
+
} else if (horizon <= today) {
|
|
1000
|
+
steps.push(step('outcome', 'outcome', 'blocked',
|
|
1001
|
+
`outcome check DUE — horizon ${horizon} has passed and no disposition is recorded`,
|
|
1002
|
+
'dispose it: node scripts/record-outcome.js (validated | refuted | inconclusive, evidence required)',
|
|
1003
|
+
{ rule: 'D57' }));
|
|
1004
|
+
} else {
|
|
1005
|
+
steps.push(step('outcome', 'outcome', 'pending', `outcome pending — horizon ${horizon}`));
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
|
|
948
1009
|
// ── stage, derived from what the steps found ─────────────────────────────────────────────────
|
|
949
1010
|
const by = Object.fromEntries(steps.map((s) => [s.key, s]));
|
|
950
1011
|
let stage = 'intake';
|
|
@@ -333,6 +333,7 @@ function firstAppearance(root, range, rel) {
|
|
|
333
333
|
function buildCommits(root, range, lane, opts) {
|
|
334
334
|
if (!range.ok) return { unknown: true };
|
|
335
335
|
const id = opts && opts.id;
|
|
336
|
+
const recordIdx = opts && opts.recordIdx;
|
|
336
337
|
const whole = !!range.wholeHistory;
|
|
337
338
|
|
|
338
339
|
// Over the trunk, the id must be attributable before any commit counts. The index is built once
|
|
@@ -373,6 +374,19 @@ function buildCommits(root, range, lane, opts) {
|
|
|
373
374
|
// answerable at all. `--name-only` lists no files for a true merge commit, so a branch-attributed
|
|
374
375
|
// initiative is counted from its merge subject below rather than from this per-file loop.
|
|
375
376
|
if (whole && id && !commitNamesId(range, i, id)) continue;
|
|
377
|
+
/* Inside a PR RANGE the id filter above does not apply, because a pull request was assumed to
|
|
378
|
+
* carry one initiative and everything in it to be that initiative's build. A branch carrying
|
|
379
|
+
* several breaks that: PR #287 held five, and the FIRST commit on it — a D01 propagation touching
|
|
380
|
+
* supabase/functions/mcp/index.ts — was attributed to `sink-discipline-backport`, whose record did
|
|
381
|
+
* not yet exist. The gate reported ORDER VIOLATION, spec-after-build, for an initiative whose spec
|
|
382
|
+
* demonstrably preceded its build by three commits.
|
|
383
|
+
*
|
|
384
|
+
* A commit made BEFORE an initiative's record first appeared cannot be that initiative's
|
|
385
|
+
* implementation — the initiative did not exist to be built. That is the whole rule, and it is
|
|
386
|
+
* narrow on purpose: an implementation commit landing AFTER the record but BEFORE the PRD still
|
|
387
|
+
* fails, which is the case D13 actually exists to catch. This removes a false violation without
|
|
388
|
+
* removing a true one. */
|
|
389
|
+
if (!whole && recordIdx != null && recordIdx !== undefined && recordIdx >= 0 && i < recordIdx) continue;
|
|
376
390
|
count++;
|
|
377
391
|
if (first === null) { first = i; sample = hit; }
|
|
378
392
|
}
|
|
@@ -574,6 +588,9 @@ function computeWorkflow(id, opts) {
|
|
|
574
588
|
const prdIdx = prd ? firstAppearance(root, range, prd) : null;
|
|
575
589
|
const build = buildCommits(root, range, effLane, {
|
|
576
590
|
id,
|
|
591
|
+
// Where this initiative's own record first appears in the range — the earliest commit that could
|
|
592
|
+
// possibly be its build. See the note in buildCommits' attribution loop.
|
|
593
|
+
recordIdx: firstAppearance(root, range, recRel),
|
|
577
594
|
deliveryIndex: opts && opts.deliveryIndex,
|
|
578
595
|
declaredDelivery: fm.delivered_by,
|
|
579
596
|
});
|
|
@@ -945,6 +962,50 @@ function computeWorkflow(id, opts) {
|
|
|
945
962
|
}
|
|
946
963
|
}
|
|
947
964
|
|
|
965
|
+
/* ── outcome ─ D57: did the bet pay off? A SECOND verdict, at a second time ───────────────────
|
|
966
|
+
*
|
|
967
|
+
* This rung did not exist, and its absence is why the loop never ran: `outcome_metric` is optional
|
|
968
|
+
* in D57, nothing ever asked for it, and 0 of ~170 records declared one. An optional field with no
|
|
969
|
+
* prompt and no surface is a field that does not exist.
|
|
970
|
+
*
|
|
971
|
+
* `na` is a first-class, non-failing state on purpose. Most work is not a bet, and a rung that
|
|
972
|
+
* renders red for every initiative without a metric becomes noise and gets ignored — which is how
|
|
973
|
+
* you end up with a board nobody reads. */
|
|
974
|
+
{
|
|
975
|
+
const metric = String(fm.outcome_metric || '').trim();
|
|
976
|
+
const horizon = String(fm.outcome_horizon || '').trim();
|
|
977
|
+
const checks = readJsonl(path.join(L.runsDir, 'outcome_checks.jsonl'))
|
|
978
|
+
.filter((c) => c && c.initiative_id === id && ['validated', 'refuted', 'inconclusive'].includes(c.disposition));
|
|
979
|
+
const latest = checks.length ? checks[checks.length - 1] : null;
|
|
980
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
981
|
+
|
|
982
|
+
if (!metric) {
|
|
983
|
+
steps.push(step('outcome', 'outcome', 'na', 'no outcome declared — most work is not a bet (D57)'));
|
|
984
|
+
} else if (!horizon) {
|
|
985
|
+
/* A metric with no horizon can never come due, so it reads as diligence for ever while
|
|
986
|
+
* committing to nothing. That is worse than declaring nothing at all. */
|
|
987
|
+
steps.push(step('outcome', 'outcome', 'blocked',
|
|
988
|
+
`outcome_metric declared with NO outcome_horizon — a bet that can never come due`,
|
|
989
|
+
'add outcome_horizon: YYYY-MM-DD to the initiative record, or remove the metric', { rule: 'D57' }));
|
|
990
|
+
} else if (latest) {
|
|
991
|
+
/* A refuted bet is a COMPLETED check and an UNSUCCESSFUL bet. The rung says both — rendering
|
|
992
|
+
* it as a plain green tick is the same watermelon the review rung was fixed for. */
|
|
993
|
+
const done = latest.disposition === 'refuted' ? 'blocked' : 'done';
|
|
994
|
+
steps.push(step('outcome', 'outcome', done,
|
|
995
|
+
`${latest.disposition}${latest.evidence ? ` — ${String(latest.evidence).slice(0, 60)}` : ''}`,
|
|
996
|
+
latest.disposition === 'refuted'
|
|
997
|
+
? 'the check is complete and the bet did not pay off — that is a result, not a failure to record'
|
|
998
|
+
: undefined));
|
|
999
|
+
} else if (horizon <= today) {
|
|
1000
|
+
steps.push(step('outcome', 'outcome', 'blocked',
|
|
1001
|
+
`outcome check DUE — horizon ${horizon} has passed and no disposition is recorded`,
|
|
1002
|
+
'dispose it: node scripts/record-outcome.js (validated | refuted | inconclusive, evidence required)',
|
|
1003
|
+
{ rule: 'D57' }));
|
|
1004
|
+
} else {
|
|
1005
|
+
steps.push(step('outcome', 'outcome', 'pending', `outcome pending — horizon ${horizon}`));
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
|
|
948
1009
|
// ── stage, derived from what the steps found ─────────────────────────────────────────────────
|
|
949
1010
|
const by = Object.fromEntries(steps.map((s) => [s.key, s]));
|
|
950
1011
|
let stage = 'intake';
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
/* md-normalize — a newline in a PMOS document means what it says.
|
|
2
|
+
*
|
|
3
|
+
* ONE implementation, used on three sides, in the idiom `preview-kind.js` established for exactly
|
|
4
|
+
* this reason:
|
|
5
|
+
* - scripts/md-newlines.js requires it (the check, the --fix, the CI ratchet)
|
|
6
|
+
* - workspace/server.js requires it (the doc tab's `normalize` action)
|
|
7
|
+
* - the browser loads it as /lib/md-normalize.js (window.MdNormalize)
|
|
8
|
+
* so the command, the gate and the button can never disagree about what a normalized file is.
|
|
9
|
+
*
|
|
10
|
+
* ── WHAT IT IS FOR ─────────────────────────────────────────────────────────────────────────────
|
|
11
|
+
* PMOS hard-wraps prose at ~100 columns: 30,345 continuation lines across 1,013 of 1,022 tracked
|
|
12
|
+
* files. A single newline inside a paragraph is NOT a line break in markdown — CommonMark joins the
|
|
13
|
+
* lines, GitHub joins them, the workspace's own renderer joins them — so this looks right everywhere
|
|
14
|
+
* inside PMOS. It stops looking right the moment a file is published: Jira wiki markup treats a
|
|
15
|
+
* newline as a line break, a Google Doc keeps every newline, and several markdown→ADF converters
|
|
16
|
+
* emit a hard break per `\n`. The paragraph arrives as a stack of ragged short lines, and no
|
|
17
|
+
* converter can undo it, because an accidental wrap and a deliberate break are THE SAME BYTE in the
|
|
18
|
+
* source. The only place the distinction still exists is the file.
|
|
19
|
+
*
|
|
20
|
+
* ── WHAT IT REFUSES TO TOUCH ───────────────────────────────────────────────────────────────────
|
|
21
|
+
* Frontmatter, fenced code (including unterminated), indented code, table rows, headings, rules,
|
|
22
|
+
* link-reference definitions, HTML blocks, a line ending in two spaces or a backslash (a DELIBERATE
|
|
23
|
+
* hard break — an author's intent, not a wrap), and a line followed by a setext underline. Joining
|
|
24
|
+
* inside any of those changes what the document MEANS rather than how it wraps, and a repair tool
|
|
25
|
+
* that damages a file is worse than the defect it repairs.
|
|
26
|
+
*
|
|
27
|
+
* ── THE CORRECTNESS PROPERTY ───────────────────────────────────────────────────────────────────
|
|
28
|
+
* Idempotence: normalize(normalize(x)) === normalize(x), asserted over all 1,022 tracked files
|
|
29
|
+
* rather than over fixtures. The ratchet, the button and an adopter's repair run are all
|
|
30
|
+
* untrustworthy without it.
|
|
31
|
+
*/
|
|
32
|
+
'use strict';
|
|
33
|
+
(function (root, factory) {
|
|
34
|
+
const api = factory();
|
|
35
|
+
if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
|
36
|
+
else root.MdNormalize = api;
|
|
37
|
+
})(typeof self !== 'undefined' ? self : this, function () {
|
|
38
|
+
const FENCE = /^(\s*)(`{3,}|~{3,})/;
|
|
39
|
+
const HEADING = /^\s{0,3}#{1,6}(\s|$)/;
|
|
40
|
+
const RULE = /^\s{0,3}(?:-{3,}|\*{3,}|_{3,})\s*$/;
|
|
41
|
+
const ITEM = /^(\s*)([-*+]|\d{1,9}[.)])(\s+)(\S.*)$/;
|
|
42
|
+
const QUOTE = /^(\s{0,3}>\s?)(.*)$/;
|
|
43
|
+
const TABLE_DELIM = /^\s*\|?[\s:|-]*-[\s:|-]*\|?\s*$/;
|
|
44
|
+
const SETEXT = /^\s{0,3}(=+|-+)\s*$/;
|
|
45
|
+
const REF_DEF = /^\s{0,3}\[[^\]]+\]:\s/;
|
|
46
|
+
const HTML_BLOCK = /^\s{0,3}<[a-zA-Z!/?]/;
|
|
47
|
+
const INDENTED_CODE = /^(?: {4,}|\t)\S/;
|
|
48
|
+
/* A break the AUTHOR asked for. Markdown spells it two trailing spaces or a trailing backslash,
|
|
49
|
+
* and it is the one newline in a paragraph that is not an accident — eating it would be removing
|
|
50
|
+
* intent, which is the failure this tool exists to avoid, not to commit. */
|
|
51
|
+
const HARD_BREAK = /(?: {2,}|\\)$/;
|
|
52
|
+
|
|
53
|
+
const isBlank = (l) => !l.trim();
|
|
54
|
+
|
|
55
|
+
/** A table's header row: this line has a pipe and the NEXT line is the delimiter. */
|
|
56
|
+
const isTableHeader = (lines, i) =>
|
|
57
|
+
lines[i].includes('|') && TABLE_DELIM.test(lines[i + 1] || '') && (lines[i + 1] || '').includes('-');
|
|
58
|
+
|
|
59
|
+
/** A row inside a table body — a pipe line in a run that began with a header. */
|
|
60
|
+
function tableRunEnd(lines, i) {
|
|
61
|
+
let j = i + 2;
|
|
62
|
+
while (j < lines.length && lines[j].includes('|') && lines[j].trim()) j++;
|
|
63
|
+
return j;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* A line that can never be joined ONTO the previous one — it opens a block of its own. This is
|
|
68
|
+
* the list the blank-line rule is derived from too, so "what needs a blank line above it" and
|
|
69
|
+
* "what stops a paragraph" cannot drift apart.
|
|
70
|
+
*/
|
|
71
|
+
function opensBlock(lines, i) {
|
|
72
|
+
const l = lines[i];
|
|
73
|
+
return HEADING.test(l) || RULE.test(l) || FENCE.test(l) || ITEM.test(l) || QUOTE.test(l)
|
|
74
|
+
|| REF_DEF.test(l) || HTML_BLOCK.test(l) || INDENTED_CODE.test(l) || isTableHeader(lines, i)
|
|
75
|
+
|| SETEXT.test(l);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Normalize `src`.
|
|
80
|
+
*
|
|
81
|
+
* Returns `{ text, changed, joins, blanks }` — the bytes, whether anything moved, how many lines
|
|
82
|
+
* were joined into a paragraph or an item, and how many blank lines were inserted above a block.
|
|
83
|
+
* The counts are what the CLI reports per file: a tool that says "changed" without saying what it
|
|
84
|
+
* changed is asking to be trusted rather than read.
|
|
85
|
+
*/
|
|
86
|
+
function normalize(src) {
|
|
87
|
+
const input = String(src == null ? '' : src);
|
|
88
|
+
const nl = input.indexOf('\r\n') >= 0 ? '\r\n' : '\n';
|
|
89
|
+
const lines = input.replace(/\r\n?/g, '\n').split('\n');
|
|
90
|
+
const out = [];
|
|
91
|
+
let joins = 0;
|
|
92
|
+
let blanks = 0;
|
|
93
|
+
let i = 0;
|
|
94
|
+
|
|
95
|
+
// ── frontmatter: copied through, never parsed and never joined ──────────────────────────────
|
|
96
|
+
if (lines[0] === '---') {
|
|
97
|
+
let j = 1;
|
|
98
|
+
while (j < lines.length && lines[j] !== '---') j++;
|
|
99
|
+
if (j < lines.length) { for (let k = 0; k <= j; k++) out.push(lines[k]); i = j + 1; }
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/* A continuation line, left-trimmed. Trailing whitespace is dropped ONLY when it is not a hard
|
|
103
|
+
* break: `.trim()` would eat the author's two trailing spaces mid-paragraph, which is the one
|
|
104
|
+
* newline in a paragraph that was asked for. */
|
|
105
|
+
const piece = (l) => {
|
|
106
|
+
const s2 = l.replace(/^\s+/, '');
|
|
107
|
+
return HARD_BREAK.test(s2) ? s2 : s2.replace(/\s+$/, '');
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
/** The blank line a block construct needs above it, when prose is what sits there. */
|
|
111
|
+
const blankAbove = () => {
|
|
112
|
+
const prev = out.length ? out[out.length - 1] : '';
|
|
113
|
+
if (!out.length || isBlank(prev)) return;
|
|
114
|
+
// Only prose gets a blank inserted after it. Two adjacent list items, a table row under its
|
|
115
|
+
// header, a fence closing — none of those want one, and inserting it would restructure the
|
|
116
|
+
// document rather than repair its wrapping.
|
|
117
|
+
if (ITEM.test(prev) || QUOTE.test(prev) || prev.includes('|') || HEADING.test(prev)) return;
|
|
118
|
+
out.push('');
|
|
119
|
+
blanks++;
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
while (i < lines.length) {
|
|
123
|
+
const line = lines[i];
|
|
124
|
+
|
|
125
|
+
if (isBlank(line)) { out.push(line); i++; continue; }
|
|
126
|
+
|
|
127
|
+
// fenced code — verbatim, to the closing fence or to EOF
|
|
128
|
+
const fence = FENCE.exec(line);
|
|
129
|
+
if (fence) {
|
|
130
|
+
blankAbove();
|
|
131
|
+
const close = new RegExp('^\\s*' + fence[2][0] + '{' + fence[2].length + ',}\\s*$');
|
|
132
|
+
out.push(line); i++;
|
|
133
|
+
while (i < lines.length && !close.test(lines[i])) { out.push(lines[i]); i++; }
|
|
134
|
+
if (i < lines.length) { out.push(lines[i]); i++; }
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// indented code, headings, rules, reference definitions, HTML — one line, untouched
|
|
139
|
+
if (INDENTED_CODE.test(line) || HEADING.test(line) || RULE.test(line) || REF_DEF.test(line)
|
|
140
|
+
|| HTML_BLOCK.test(line) || SETEXT.test(line)) {
|
|
141
|
+
/* A blank is NEVER inserted above a `---`/`===` line. Directly under prose that is a SETEXT
|
|
142
|
+
* HEADING, not a rule, and separating it would turn one heading into a paragraph and a
|
|
143
|
+
* horizontal rule — the untouchable rule wins over the blank-line rule wherever they meet. */
|
|
144
|
+
if (HEADING.test(line)) blankAbove();
|
|
145
|
+
out.push(line); i++;
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// a table: its header, its delimiter and its rows are records — never joined, never reflowed
|
|
150
|
+
if (isTableHeader(lines, i)) {
|
|
151
|
+
blankAbove();
|
|
152
|
+
const end = tableRunEnd(lines, i);
|
|
153
|
+
for (let k = i; k < end; k++) out.push(lines[k]);
|
|
154
|
+
i = end;
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// a list item, plus the continuation lines that belong to it
|
|
159
|
+
const item = ITEM.exec(line);
|
|
160
|
+
if (item) {
|
|
161
|
+
blankAbove();
|
|
162
|
+
/* INSIDE a list item, four spaces is not code — it is the item's own text column. The
|
|
163
|
+
* generic `opensBlock` test calls an indented continuation an indented code block and stops,
|
|
164
|
+
* which left every nested item's wrapped line un-joined. Code inside an item starts four
|
|
165
|
+
* columns further in than the item's CONTENT does, so that is the line this measures. */
|
|
166
|
+
const contentCol = item[1].length + item[2].length + item[3].length;
|
|
167
|
+
const isItemCode = (l) => (l.match(/^[ \t]*/)[0].replace(/\t/g, ' ').length) >= contentCol + 4;
|
|
168
|
+
let body = line;
|
|
169
|
+
i++;
|
|
170
|
+
while (i < lines.length && !isBlank(lines[i]) && !HARD_BREAK.test(body)
|
|
171
|
+
&& /^\s+\S/.test(lines[i]) && !isItemCode(lines[i])
|
|
172
|
+
&& !(HEADING.test(lines[i]) || RULE.test(lines[i]) || FENCE.test(lines[i])
|
|
173
|
+
|| ITEM.test(lines[i]) || QUOTE.test(lines[i]) || isTableHeader(lines, i))) {
|
|
174
|
+
body += ' ' + piece(lines[i]);
|
|
175
|
+
joins++; i++;
|
|
176
|
+
}
|
|
177
|
+
out.push(body);
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// a blockquote line, plus its lazy continuations
|
|
182
|
+
const quote = QUOTE.exec(line);
|
|
183
|
+
if (quote) {
|
|
184
|
+
/* A `>` with nothing after it is the blank line INSIDE a quote — it is what separates two
|
|
185
|
+
* quoted paragraphs. Joining the next paragraph onto it deletes the separator, silently
|
|
186
|
+
* turns two paragraphs into one, and (because the result then joins again on the next run)
|
|
187
|
+
* is how this tool would fail its own idempotence property. Found by running the proof over
|
|
188
|
+
* the real corpus rather than over fixtures: 50 files did exactly this. */
|
|
189
|
+
if (!quote[2].trim()) { out.push(line); i++; continue; }
|
|
190
|
+
let body = line;
|
|
191
|
+
i++;
|
|
192
|
+
while (i < lines.length && !isBlank(lines[i]) && !HARD_BREAK.test(body)) {
|
|
193
|
+
const q = QUOTE.exec(lines[i]);
|
|
194
|
+
const inner = q ? q[2] : lines[i];
|
|
195
|
+
if (!inner.trim()) break; // a blank quote line ends it
|
|
196
|
+
if (q && (HEADING.test(inner) || ITEM.test(inner) || RULE.test(inner) || FENCE.test(inner))) break;
|
|
197
|
+
if (!q && opensBlock(lines, i)) break; // lazy continuation, but not a new block
|
|
198
|
+
body += ' ' + piece(inner);
|
|
199
|
+
joins++; i++;
|
|
200
|
+
}
|
|
201
|
+
out.push(body);
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// prose — the case the whole tool is about
|
|
206
|
+
let para = line;
|
|
207
|
+
i++;
|
|
208
|
+
while (i < lines.length && !isBlank(lines[i]) && !opensBlock(lines, i) && !HARD_BREAK.test(para)) {
|
|
209
|
+
para += ' ' + piece(lines[i]);
|
|
210
|
+
joins++; i++;
|
|
211
|
+
}
|
|
212
|
+
out.push(para);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const text = out.join(nl);
|
|
216
|
+
return { text, changed: text !== input, joins, blanks };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** True when the file is already normalized — the check half, with no allocation of a new string. */
|
|
220
|
+
const isNormalized = (src) => !normalize(src).changed;
|
|
221
|
+
|
|
222
|
+
return { normalize, isNormalized };
|
|
223
|
+
});
|
package/workspace/package.json
CHANGED
|
@@ -53,6 +53,10 @@
|
|
|
53
53
|
opinion about what an .html file is would mean the endpoint and the pane disagreeing about the
|
|
54
54
|
same document. Classic script (a UMD), like the three above. -->
|
|
55
55
|
<script src="/lib/preview-kind.js"></script>
|
|
56
|
+
<!-- The newline normalizer behind the doc tab's `normalize` action (markdown-newlines). The same
|
|
57
|
+
file scripts/md-newlines.js requires, loaded here as window.MdNormalize, so the one-click
|
|
58
|
+
repair and the CI ratchet can never disagree about what a normalized document is. -->
|
|
59
|
+
<script src="/lib/md-normalize.js"></script>
|
|
56
60
|
<link rel="preload" href="/vendor/ibm-plex-mono-500.woff2" as="font" type="font/woff2" crossorigin />
|
|
57
61
|
</head>
|
|
58
62
|
<body>
|