create-pmos 0.6.4 → 0.7.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.
@@ -0,0 +1,171 @@
1
+ /* layout — the workspace's arrangement model (workspace-layout-system, R1/R2/R7/R8).
2
+ *
3
+ * One source of truth for "what is an arrangement", used on BOTH sides:
4
+ * - server.js requires it (validate + clamp before anything is written to disk)
5
+ * - the browser loads it as /lib/layout.js before app.js (window.PMOSLayout)
6
+ * so a layout the client can produce and a layout the server will store can never disagree.
7
+ *
8
+ * A layout is a NAMED STATE TUPLE. The grid template is COMPUTED from it (gridFor) rather than
9
+ * hand-written per layout, because the twelve side×reader combinations are exactly where a
10
+ * hand-written template drifts: a column count that no longer matches its area row produces an
11
+ * unusable window, and the PM's live agent sessions are inside it.
12
+ *
13
+ * Two properties here are load-bearing and tested directly:
14
+ * 1. gridFor() always returns a template whose column count equals every area row's cell count.
15
+ * 2. sanitize() turns ANY input — a truncated file, a hand-edited JSON, a future version's
16
+ * extra keys — into a usable set of layouts. It never throws and never returns empty.
17
+ * The second is the reason the veto dimension on the rubric can be satisfied at all: the failure
18
+ * mode this feature must not have is a blank body or a zero-width column at boot.
19
+ */
20
+ 'use strict';
21
+ (function (root, factory) {
22
+ const api = factory();
23
+ if (typeof module !== 'undefined' && module.exports) module.exports = api;
24
+ else root.PMOSLayout = api;
25
+ })(typeof self !== 'undefined' ? self : this, function () {
26
+
27
+ const SIDE = ['left', 'right', 'off'];
28
+ const READER = ['left', 'right', 'bottom', 'off']; // FOUR positions (R1) — every position
29
+ const SPLIT = ['off', 'on']; // the state can hold has a control
30
+ const FOLD_KEYS = ['ws', 'sess', 'lib', 'doc'];
31
+
32
+ const SIDE_MIN = 160, SIDE_MAX = 520;
33
+ const READ_MIN = 280, READ_MAX = 1000;
34
+ const BOTTOM_H = 300; // the reader's height when docked bottom
35
+
36
+ // `review` is byte-for-byte the arrangement that shipped before this feature (R2): a PM who
37
+ // never opens the layout control must see no change at all.
38
+ const STOCK = [
39
+ { id: 'focus', name: 'focus', key: 1, side: 'off', reader: 'off', split: 'off', sideW: 262, readW: 400 },
40
+ { id: 'read', name: 'read', key: 2, side: 'left', reader: 'left', split: 'off', sideW: 214, readW: 760 },
41
+ { id: 'compare', name: 'compare', key: 3, side: 'left', reader: 'off', split: 'on', sideW: 214, readW: 400 },
42
+ { id: 'review', name: 'review', key: 4, side: 'left', reader: 'right', split: 'off', sideW: 262, readW: 400 },
43
+ ];
44
+ const DEFAULT_ID = 'review';
45
+ const STOCK_IDS = STOCK.map((l) => l.id);
46
+ const isStock = (id) => STOCK_IDS.indexOf(id) !== -1;
47
+
48
+ const clamp = (n, lo, hi) => (n < lo ? lo : n > hi ? hi : n);
49
+ const oneOf = (v, list, fallback) => (list.indexOf(v) !== -1 ? v : fallback);
50
+ const num = (v, fallback) => {
51
+ const n = typeof v === 'number' ? v : parseFloat(v);
52
+ return Number.isFinite(n) ? n : fallback;
53
+ };
54
+
55
+ function defaultFolds() { return { ws: true, sess: true, lib: true, doc: true }; }
56
+ function folds(v) {
57
+ const out = defaultFolds();
58
+ if (v && typeof v === 'object') for (const k of FOLD_KEYS) if (typeof v[k] === 'boolean') out[k] = v[k];
59
+ return out;
60
+ }
61
+
62
+ /* A stock layout, fresh. `restore` (R4) is this function — auto-keep is destructive, so the
63
+ definition has to be re-derivable from code, never read back from the stored copy. */
64
+ function stockLayout(id) {
65
+ const s = STOCK.find((l) => l.id === id);
66
+ if (!s) return null;
67
+ return Object.assign({}, s, { folds: defaultFolds(), lastUsedAt: 0 });
68
+ }
69
+ function stockLayouts() { return STOCK.map((s) => stockLayout(s.id)); }
70
+
71
+ /* Coerce one layout-shaped thing into a valid layout. Unknown keys are dropped, bad values fall
72
+ back, sizes clamp. Never throws. */
73
+ function layout(raw, index) {
74
+ const r = (raw && typeof raw === 'object') ? raw : {};
75
+ const id = typeof r.id === 'string' && r.id.trim() ? r.id.trim().slice(0, 64) : 'c' + (index + 1);
76
+ const stock = isStock(id);
77
+ const base = stock ? STOCK.find((l) => l.id === id) : null;
78
+ let name = typeof r.name === 'string' ? r.name.trim().slice(0, 40) : '';
79
+ if (!name) name = base ? base.name : 'layout ' + (index + 1);
80
+ return {
81
+ id,
82
+ name: stock ? base.name : name, // stock names are not editable (R5)
83
+ key: stock ? base.key : null, // ⌃1–⌃4 belong to the stock four
84
+ side: oneOf(r.side, SIDE, base ? base.side : 'left'),
85
+ reader: oneOf(r.reader, READER, base ? base.reader : 'right'),
86
+ split: oneOf(r.split, SPLIT, base ? base.split : 'off'),
87
+ sideW: Math.round(clamp(num(r.sideW, base ? base.sideW : 262), SIDE_MIN, SIDE_MAX)),
88
+ readW: Math.round(clamp(num(r.readW, base ? base.readW : 400), READ_MIN, READ_MAX)),
89
+ folds: folds(r.folds),
90
+ lastUsedAt: Math.max(0, Math.round(num(r.lastUsedAt, 0))),
91
+ };
92
+ }
93
+
94
+ /* Turn ANY stored value into a usable {layouts, currentId}. This is the boot path, so its
95
+ contract is absolute: it never throws, the four stock layouts are always present, and
96
+ currentId always names a layout that exists. A file containing `{`, `null`, `[]`, a string,
97
+ or a future version's shape all land on a usable window (rubric dimension 1). */
98
+ function sanitize(raw) {
99
+ let doc = raw;
100
+ if (typeof doc === 'string') { try { doc = JSON.parse(doc); } catch { doc = null; } }
101
+ if (!doc || typeof doc !== 'object' || Array.isArray(doc)) doc = {};
102
+
103
+ const list = Array.isArray(doc.layouts) ? doc.layouts : [];
104
+ const seen = new Set();
105
+ const custom = [];
106
+ for (let i = 0; i < list.length && custom.length < 40; i++) {
107
+ const l = layout(list[i], i);
108
+ if (isStock(l.id) || seen.has(l.id)) continue; // stock entries are rebuilt below
109
+ seen.add(l.id);
110
+ custom.push(l);
111
+ }
112
+ // stock layouts keep the PM's adjustments (auto-keep, R4) but can never go missing
113
+ const stored = new Map();
114
+ for (const raw2 of list) if (raw2 && isStock(raw2.id)) stored.set(raw2.id, raw2);
115
+ const stock = STOCK.map((s, i) => (stored.has(s.id) ? layout(stored.get(s.id), i) : stockLayout(s.id)));
116
+
117
+ const layouts = stock.concat(custom);
118
+ const wanted = typeof doc.currentId === 'string' ? doc.currentId : DEFAULT_ID;
119
+ const currentId = layouts.some((l) => l.id === wanted) ? wanted : DEFAULT_ID;
120
+ return { layouts, currentId };
121
+ }
122
+
123
+ /* The computed grid. Returns {columns, rows, areas} as CSS strings.
124
+ INVARIANT: every area row names exactly as many cells as `columns` has tracks. */
125
+ function gridFor(l) {
126
+ const S = l.sideW + 'px', R = l.readW + 'px';
127
+ if (l.reader === 'bottom') {
128
+ const rows = 'minmax(0,1fr) ' + BOTTOM_H + 'px';
129
+ if (l.side === 'left') return { columns: S + ' minmax(0,1fr)', rows, areas: '"side term" "side read"' };
130
+ if (l.side === 'right') return { columns: 'minmax(0,1fr) ' + S, rows, areas: '"term side" "read side"' };
131
+ return { columns: 'minmax(0,1fr)', rows, areas: '"term" "read"' };
132
+ }
133
+ const cols = [], names = [];
134
+ if (l.side === 'left') { cols.push(S); names.push('side'); }
135
+ if (l.reader === 'left') { cols.push(R); names.push('read'); }
136
+ cols.push('minmax(0,1fr)'); names.push('term');
137
+ if (l.reader === 'right'){ cols.push(R); names.push('read'); }
138
+ if (l.side === 'right') { cols.push(S); names.push('side'); }
139
+ return { columns: cols.join(' '), rows: 'minmax(0,1fr)', areas: '"' + names.join(' ') + '"' };
140
+ }
141
+
142
+ /* A layout that hides every pane is legal (`focus` is exactly that) — but a layout the PM
143
+ cannot get OUT of is not. The header control is always rendered, so the only real dead end
144
+ would be a template with no terminal track; assert it here so a future edit cannot introduce
145
+ one silently (rubric dimension 1, checklist item 4). */
146
+ function hasEscape(l) {
147
+ return gridFor(l).areas.indexOf('term') !== -1;
148
+ }
149
+
150
+ /* R8 — carry the pre-feature client keys into this workspace's `review` so nobody's tuning is
151
+ lost on upgrade. Reads the five localStorage values the shipped app wrote:
152
+ rail(on|off) railw(<n>px) libsplit(<n>%) fold-lib(on|off) fold-doc(on|off). */
153
+ function migrateLegacy(legacy) {
154
+ const g = (k) => (legacy && typeof legacy === 'object' ? legacy[k] : null);
155
+ const review = stockLayout(DEFAULT_ID);
156
+ const railOff = g('rail') === 'off';
157
+ if (railOff) review.reader = 'off';
158
+ const w = num(String(g('railw') || '').replace('px', ''), null);
159
+ if (w !== null) review.readW = Math.round(clamp(w, READ_MIN, READ_MAX));
160
+ if (g('fold-lib') === 'off') review.folds.lib = false;
161
+ if (g('fold-doc') === 'off') review.folds.doc = false;
162
+ const layouts = stockLayouts().map((l) => (l.id === DEFAULT_ID ? review : l));
163
+ return { layouts, currentId: DEFAULT_ID };
164
+ }
165
+
166
+ return {
167
+ SIDE, READER, SPLIT, FOLD_KEYS, STOCK_IDS, DEFAULT_ID,
168
+ SIDE_MIN, SIDE_MAX, READ_MIN, READ_MAX, BOTTOM_H,
169
+ isStock, stockLayout, stockLayouts, layout, sanitize, gridFor, hasEscape, migrateLegacy,
170
+ };
171
+ });
@@ -107,7 +107,7 @@ function scaffoldSkill({ name, description, output, body } = {}) {
107
107
  let out = Array.isArray(output) ? output : String(output || '').split(/[\n,]/);
108
108
  out = out.map((x) => String(x).replace(/\s+/g, ' ').trim()).filter(Boolean).slice(0, MAX_OUTPUT);
109
109
  if (!out.length) out = ['Summary', 'Findings', 'Next steps']; // always a valid contract
110
- const proc = String(body || '').trim() || defaultBody(label, desc, out);
110
+ const proc = String(body || '').trim() || defaultBody(label, desc, out, slug);
111
111
  const content = [
112
112
  '---',
113
113
  `name: ${slug}`,
@@ -125,18 +125,37 @@ function scaffoldSkill({ name, description, output, body } = {}) {
125
125
  return { ok: true, slug, label, description: desc, output: out, content };
126
126
  }
127
127
 
128
- function defaultBody(label, desc, out) {
128
+ // A spec-shaped scaffold body (SKILL-FORMAT.md): procedure-first, every step ending on a checkable
129
+ // done-condition, and — load-bearing — the workspace FILING CONTRACT. A scheduled agent skill is NOT
130
+ // auto-filed by the server (fireSchedule's agent branch only spawns the PTY); the body itself must
131
+ // write the deliverable and append a STRUCTURED needs.json object, or the console reds the queue
132
+ // (needsResult treats a non-array queue as corrupt). No prose "note to needs-you", ever. This is a
133
+ // starting shape a PM or the AI-draft session then sharpens with the task's real steps.
134
+ function defaultBody(label, desc, out, slug) {
135
+ const id = slug || slugify(label) || 'run';
129
136
  return [
130
- `You are running the "${label}" skill in this PMOS workspace (local, file mode).`,
137
+ `You are running the "${label}" skill in this PMOS workspace (local, file mode). Work only from`,
138
+ "this repository's own files, git, and CLIs — no network (D54).",
131
139
  '',
132
- `Goal: ${desc || '(describe what this skill should do)'}`,
140
+ `Goal: ${desc || '(describe, in one line, what this skill produces)'}`,
133
141
  '',
134
- 'Produce a deliverable with EXACTLY these sections — the consistency contract, the same shape',
135
- 'every run:',
136
- ...out.map((o) => ` - ${o}`),
142
+ 'Procedure — run the numbered steps in order; each ends on a checkable done-condition:',
143
+ '',
144
+ "1. Gather inputs from this repo's own local sources — name the exact commands/paths the run",
145
+ ' depends on (e.g. `git log`, `gh pr view <n> --json title,body`, a file under `planning/`).',
146
+ ' Done when: every input the sections below need is in hand, each traceable to a real source.',
147
+ '2. Produce each required section from those inputs, in the exact order of the output contract.',
148
+ ' An empty section is the literal line "None this period." — never a dropped heading, never invented content.',
149
+ ' Done when: every contract section is filled or explicitly "None this period.".',
150
+ `3. File the result so the PM sees it. Pick a unique run id (e.g. \`${id}-<UTC-timestamp>\`), then:`,
151
+ ' a. Write the deliverable Markdown to `workspace/state/deliverables/<run-id>.md`.',
152
+ ' b. Read `workspace/state/needs.json` (treat a missing file as `[]`), append EXACTLY this object,',
153
+ ' and write the whole array back — it MUST stay a valid JSON array or the console reds the queue:',
154
+ ` {"run_id":"<run-id>","kind":"report","initiative_id":"${id}","question":"${label} — ready","deliverable":"<run-id>"}`,
155
+ ' Done when: the deliverable file exists and needs.json is a valid array containing the new object.',
137
156
  '',
138
- 'Ground everything in this repository\'s own local files and tools only (no network). When you are',
139
- 'done, write the deliverable and file a short note to needs-you so the PM sees the result.',
157
+ 'Output contract — produce EXACTLY these sections, the same shape every run:',
158
+ ...out.map((o) => ` - ${o}`),
140
159
  ].join('\n');
141
160
  }
142
161
 
@@ -0,0 +1,124 @@
1
+ 'use strict';
2
+ // ── tmux CONTROL MODE (`tmux -CC attach-session`) — the parser + command encoder. ──────────────
3
+ //
4
+ // Why this exists. A normal tmux client PAINTS a screen: it emits `smcup` on attach, which puts
5
+ // xterm.js into the alternate screen buffer, where xterm.js keeps NO scrollback. The workspace
6
+ // terminal was therefore unscrollable — `scrollback: 5000` (public/app.js) never applied, and the
7
+ // pane never left the alt buffer for the life of the session. Measured alternatives (see
8
+ // planning/initiatives/workspace-terminal-scrollback.md) both failed: stripping smcup/rmcup from
9
+ // the client's terminfo reaches the normal buffer but scrolls 0px, because tmux repaints by cursor
10
+ // addressing rather than scrolling lines off the top; and `mouse on` scrolls only via tmux
11
+ // copy-mode, at the cost of swallowing keystrokes typed while scrolled back.
12
+ //
13
+ // In control mode the client never paints. tmux instead reports the pane's RAW byte stream as
14
+ // `%output` notifications, so the bytes reaching xterm.js are the same sequential stream the
15
+ // no-tmux fallback path already delivers — and xterm.js accumulates its own scrollback normally.
16
+ //
17
+ // The protocol, as it actually arrives on the wire:
18
+ // \x1bP1000p DCS introducer, once, before the first line
19
+ // %begin <t> <n> <f> … %end a command's reply is wrapped in a begin/end block
20
+ // %output %0 line-1\015\012… pane bytes, NON-PRINTABLES OCTAL-ESCAPED (\015 = CR)
21
+ // %exit [reason] the control client is going away
22
+ // %session-changed, %window-*, … notifications we do not need
23
+ // Lines are CRLF-terminated. Because every byte outside printable ASCII is octal-escaped, the
24
+ // control stream itself is pure ASCII — so reading the client PTY as utf8 is safe, and the real
25
+ // UTF-8 decoding happens HERE, after unescaping, with a streaming decoder (a multi-byte character
26
+ // can be split across two %output frames).
27
+
28
+ const { StringDecoder } = require('string_decoder');
29
+
30
+ // tmux escapes a byte as a backslash + exactly three octal digits (backslash itself is \134),
31
+ // so there is no two-character `\\` case to handle — only the octal form.
32
+ function unescapeToBytes(s) {
33
+ const out = Buffer.allocUnsafe(s.length);
34
+ let n = 0;
35
+ for (let i = 0; i < s.length; i++) {
36
+ const c = s.charCodeAt(i);
37
+ if (c === 0x5c && i + 3 < s.length) { // '\'
38
+ const oct = s.slice(i + 1, i + 4);
39
+ if (/^[0-7]{3}$/.test(oct)) { out[n++] = parseInt(oct, 8) & 0xff; i += 3; continue; }
40
+ }
41
+ out[n++] = c & 0xff;
42
+ }
43
+ return out.subarray(0, n);
44
+ }
45
+
46
+ /**
47
+ * Streaming parser for a control-mode client's stdout.
48
+ * @param {object} h
49
+ * @param {(paneId: string, text: string) => void} h.onOutput decoded pane bytes
50
+ * @param {(reason: string) => void} [h.onExit] %exit seen
51
+ * @param {(lines: string[], isError: boolean) => void} [h.onBlock] a %begin…%end command reply
52
+ * @returns {{feed: (chunk: string) => void}}
53
+ */
54
+ function createParser({ onOutput, onExit, onBlock } = {}) {
55
+ let pending = ''; // partial line carried between chunks
56
+ let inBlock = false;
57
+ let block = [];
58
+ const decoders = new Map(); // paneId → StringDecoder, for split multi-byte chars
59
+
60
+ function line(raw) {
61
+ // The DCS introducer rides in front of the very first line; the terminator can trail the last.
62
+ let l = raw.replace(/^\x1bP1000p/, '').replace(/\x1b\\$/, '');
63
+ if (inBlock) {
64
+ if (l.startsWith('%end') || l.startsWith('%error')) {
65
+ inBlock = false;
66
+ if (onBlock) onBlock(block, l.startsWith('%error'));
67
+ block = [];
68
+ } else block.push(l);
69
+ return;
70
+ }
71
+ if (l.startsWith('%begin')) { inBlock = true; block = []; return; }
72
+ if (l.startsWith('%output ')) {
73
+ // %output %<pane> <escaped bytes> — the data may legitimately be empty
74
+ const m = /^%output (%\d+) ?([\s\S]*)$/.exec(l);
75
+ if (!m || !onOutput) return;
76
+ const pane = m[1];
77
+ let dec = decoders.get(pane);
78
+ if (!dec) { dec = new StringDecoder('utf8'); decoders.set(pane, dec); }
79
+ const text = dec.write(unescapeToBytes(m[2]));
80
+ if (text) onOutput(pane, text);
81
+ return;
82
+ }
83
+ if (l.startsWith('%exit')) { if (onExit) onExit(l.slice(5).trim()); return; }
84
+ // every other %notification (session-changed, window-add, layout-change, …) is not needed here
85
+ }
86
+
87
+ return {
88
+ feed(chunk) {
89
+ pending += chunk;
90
+ let i;
91
+ while ((i = pending.indexOf('\n')) !== -1) {
92
+ let l = pending.slice(0, i);
93
+ pending = pending.slice(i + 1);
94
+ if (l.endsWith('\r')) l = l.slice(0, -1);
95
+ line(l);
96
+ }
97
+ },
98
+ };
99
+ }
100
+
101
+ // Input goes back through the control client's OWN stdin as commands — never a spawned
102
+ // `tmux send-keys` per keystroke, which would fork a process on every character typed.
103
+ // `-H` takes raw byte values, so arbitrary input (escape sequences, paste, UTF-8) passes through
104
+ // unaltered. Long input is chunked: a tmux command line is not unbounded.
105
+ const SEND_CHUNK = 180; // bytes per send-keys command
106
+
107
+ function sendKeysCommands(paneId, data) {
108
+ const buf = Buffer.from(data, 'utf8');
109
+ const cmds = [];
110
+ for (let i = 0; i < buf.length; i += SEND_CHUNK) {
111
+ const hex = [];
112
+ for (const b of buf.subarray(i, i + SEND_CHUNK)) hex.push(b.toString(16).padStart(2, '0'));
113
+ cmds.push(`send-keys -t ${paneId} -H ${hex.join(' ')}\n`);
114
+ }
115
+ return cmds;
116
+ }
117
+
118
+ // In control mode the client has no window size of its own to resize — the pane follows the
119
+ // client's declared size, which is set with refresh-client -C.
120
+ function resizeCommand(cols, rows) {
121
+ return `refresh-client -C ${Math.max(1, cols | 0)}x${Math.max(1, rows | 0)}\n`;
122
+ }
123
+
124
+ module.exports = { createParser, unescapeToBytes, sendKeysCommands, resizeCommand, SEND_CHUNK };