create-pmos 0.4.0 → 0.6.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 +149 -4
- package/bin/create-pmos.js +97 -8
- package/package.json +7 -3
- package/payload/AGENTS-HUMAN.md +6 -0
- package/payload/AGENTS.md +62 -3
- package/payload/KIT-VERSION +2 -2
- package/payload/README.md +16 -2
- package/payload/SECURITY.md +59 -0
- package/payload/okf/core/concepts/design-loop.md +6 -3
- package/payload/okf/core/concepts/frontmatter-format.md +75 -0
- package/payload/okf/core/concepts/index.md +3 -0
- package/payload/okf/core/concepts/log.md +13 -0
- package/payload/okf/core/templates/design-brief-template.md +1 -1
- package/payload/okf/core/templates/eval-rubric-template.md +12 -10
- package/payload/okf/core/templates/prd-template.md +6 -0
- package/payload/okf/core/templates/prototype-brief-template.md +45 -21
- package/payload/planning/evals/agent-run-eval-rubric.md +10 -7
- package/payload/scripts/frontier.sh +60 -0
- package/payload/scripts/metrics.py +163 -102
- package/payload/scripts/pmos-log.sh +16 -6
- package/payload/skills/SKILL-FORMAT.md +72 -9
- package/payload/skills/discovery.skill +4 -0
- package/payload/skills/eval-rubric.skill +1 -1
- package/payload/skills/evaluator.skill +65 -20
- package/payload/skills/explain-run.skill +9 -6
- package/payload/skills/explain-surface.skill +156 -0
- package/payload/skills/ingest-repo.skill +4 -3
- package/payload/skills/pmos-router.skill +25 -3
- package/payload/skills/prd.skill +8 -0
- package/payload/skills/prototype.skill +106 -41
- package/payload/skills-native/copywriting/SKILL.md +57 -0
- package/payload/skills-native/discovery/SKILL.md +75 -0
- package/payload/skills-native/eval-rubric/SKILL.md +159 -0
- package/payload/skills-native/evaluator/SKILL.md +182 -0
- package/payload/skills-native/explain-run/SKILL.md +121 -0
- package/payload/skills-native/explain-surface/SKILL.md +158 -0
- package/payload/skills-native/ingest-repo/SKILL.md +120 -0
- package/payload/skills-native/okr/SKILL.md +70 -0
- package/payload/skills-native/pmos-router/SKILL.md +81 -0
- package/payload/skills-native/prd/SKILL.md +117 -0
- package/payload/skills-native/prototype/SKILL.md +147 -0
- package/payload/state/tickets/.gitkeep +0 -0
- package/payload/templates/agent-run-eval-rubric.md +10 -7
- package/payload/templates/ticket-template.md +20 -0
- package/workspace/README.md +112 -0
- package/workspace/REMOTE.md +131 -0
- package/workspace/bin/pmos.js +206 -0
- package/workspace/doctor.js +99 -0
- package/workspace/lib/console-state.js +117 -0
- package/workspace/lib/file-mode-parse.js +58 -0
- package/workspace/lib/harness-parse.js +222 -0
- package/workspace/lib/preview-kind.js +98 -0
- package/workspace/lib/rate-limit.js +26 -0
- package/workspace/lib/remote-access.js +235 -0
- package/workspace/lib/review-gate.js +39 -0
- package/workspace/lib/schedule-logic.js +132 -0
- package/workspace/lib/skill-store.js +143 -0
- package/workspace/lib/surface-contract.js +79 -0
- package/workspace/lib/transcript-read.js +247 -0
- package/workspace/package-lock.json +94 -0
- package/workspace/package.json +23 -0
- package/workspace/public/app.js +1710 -0
- package/workspace/public/index.html +175 -0
- package/workspace/public/styles.css +766 -0
- package/workspace/server.js +1753 -0
- package/workspace/skills/backlog-triage.skill +13 -0
- package/workspace/skills/weekly-report.skill +19 -0
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/*
|
|
3
|
+
* Read-only console state readers (D64 / pivot-workspace-first, Track B).
|
|
4
|
+
*
|
|
5
|
+
* The workspace console surfaces — the "needs you" queue, a radar-style health summary, and a
|
|
6
|
+
* metrics view — derive ONLY from local file-mode state: the kit's `pmos/state/*.jsonl` and, as a
|
|
7
|
+
* seed/demo source when no live state exists yet, the committed hosted snapshot under
|
|
8
|
+
* `planning/data-export/<date>-hosted-snapshot/*.json`. Live file-mode state is ALWAYS preferred.
|
|
9
|
+
*
|
|
10
|
+
* HONESTY BY CONSTRUCTION (the anti-watermelon rule): every reader returns a discriminated result —
|
|
11
|
+
* { ok:true, empty:false, source, data } data present
|
|
12
|
+
* { ok:true, empty:true, source } genuinely nothing (calm empty state on the client)
|
|
13
|
+
* { ok:false, error } a missing source / parse failure — the client MUST
|
|
14
|
+
* render this RED, never a green "all clear".
|
|
15
|
+
* A missing DATA source (no live file AND no snapshot) is an ERROR, not an empty — a health surface
|
|
16
|
+
* that cannot read its inputs is broken, and a broken honesty surface must read red.
|
|
17
|
+
*/
|
|
18
|
+
const fs = require('fs');
|
|
19
|
+
const path = require('path');
|
|
20
|
+
const reviewGate = require('./review-gate');
|
|
21
|
+
|
|
22
|
+
function loadJson(file) {
|
|
23
|
+
let text;
|
|
24
|
+
try { text = fs.readFileSync(file, 'utf8'); } catch { return { missing: true }; }
|
|
25
|
+
try { return { ok: true, data: JSON.parse(text) }; }
|
|
26
|
+
catch { return { ok: false, error: `${path.basename(file)} is not valid JSON — the file-mode source is corrupt, not empty` }; }
|
|
27
|
+
}
|
|
28
|
+
function loadJsonl(file) {
|
|
29
|
+
let text;
|
|
30
|
+
try { text = fs.readFileSync(file, 'utf8'); } catch { return { missing: true }; }
|
|
31
|
+
const rows = []; let bad = 0;
|
|
32
|
+
for (const line of text.split('\n')) {
|
|
33
|
+
const t = line.trim(); if (!t) continue;
|
|
34
|
+
try { rows.push(JSON.parse(t)); } catch { bad++; }
|
|
35
|
+
}
|
|
36
|
+
// A file that is entirely unparseable is corrupt state — surface it red rather than call it empty.
|
|
37
|
+
if (bad > 0 && rows.length === 0) return { ok: false, error: `every line in ${path.basename(file)} is malformed — corrupt file-mode state` };
|
|
38
|
+
return { ok: true, data: rows, bad };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function computeMetricsFromRuns(runs) {
|
|
42
|
+
const corr = runs.map((r) => Number(r.human_corrections) || 0);
|
|
43
|
+
const total = corr.reduce((a, b) => a + b, 0);
|
|
44
|
+
const needing = corr.filter((c) => c > 0).length;
|
|
45
|
+
const n = runs.length;
|
|
46
|
+
return {
|
|
47
|
+
total_runs: n,
|
|
48
|
+
avg_corrections_per_run: n ? +(total / n).toFixed(3) : 0,
|
|
49
|
+
runs_needing_correction: needing,
|
|
50
|
+
share_runs_corrected: n ? +(needing / n).toFixed(3) : 0,
|
|
51
|
+
per_initiative: [],
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ── metrics: prefer live pmos/state/runs.jsonl; fall back to the snapshot's metrics.json ──
|
|
56
|
+
function metrics({ stateDir, snapshotDir }) {
|
|
57
|
+
if (stateDir) {
|
|
58
|
+
const live = loadJsonl(path.join(stateDir, 'runs.jsonl'));
|
|
59
|
+
if (live.ok) {
|
|
60
|
+
if (!live.data.length) return { ok: true, empty: true, source: 'file-mode' };
|
|
61
|
+
return { ok: true, empty: false, source: 'file-mode', data: computeMetricsFromRuns(live.data) };
|
|
62
|
+
}
|
|
63
|
+
if (live.ok === false) return { ok: false, error: live.error };
|
|
64
|
+
// live.missing → fall through to the snapshot
|
|
65
|
+
}
|
|
66
|
+
if (!snapshotDir) return { ok: false, error: 'no metrics source — no live runs.jsonl and no hosted snapshot for this workspace' };
|
|
67
|
+
const snap = loadJson(path.join(snapshotDir, 'metrics.json'));
|
|
68
|
+
if (snap.missing) return { ok: false, error: 'metrics source missing — neither live runs.jsonl nor the hosted snapshot is present' };
|
|
69
|
+
if (!snap.ok) return { ok: false, error: snap.error };
|
|
70
|
+
const m = snap.data || {};
|
|
71
|
+
return { ok: true, empty: false, source: 'snapshot', data: {
|
|
72
|
+
total_runs: m.total_runs, avg_corrections_per_run: m.avg_corrections_per_run,
|
|
73
|
+
runs_needing_correction: m.runs_needing_correction, share_runs_corrected: m.share_runs_corrected,
|
|
74
|
+
per_initiative: Array.isArray(m.per_initiative) ? m.per_initiative.slice(0, 12) : [],
|
|
75
|
+
} };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ── radar: prefer a live pmos/state/radar.json; fall back to the snapshot's radar_all_sections.json ──
|
|
79
|
+
function radar({ stateDir, snapshotDir }) {
|
|
80
|
+
let src = null, source = null;
|
|
81
|
+
if (stateDir) {
|
|
82
|
+
const live = loadJson(path.join(stateDir, 'radar.json'));
|
|
83
|
+
if (live.ok) { src = live.data; source = 'file-mode'; }
|
|
84
|
+
else if (live.ok === false) return { ok: false, error: live.error };
|
|
85
|
+
}
|
|
86
|
+
if (!src) {
|
|
87
|
+
if (!snapshotDir) return { ok: false, error: 'no radar source — no live radar.json and no hosted snapshot for this workspace' };
|
|
88
|
+
const snap = loadJson(path.join(snapshotDir, 'radar_all_sections.json'));
|
|
89
|
+
if (snap.missing) return { ok: false, error: 'radar source missing — neither live radar.json nor the hosted snapshot is present' };
|
|
90
|
+
if (!snap.ok) return { ok: false, error: snap.error };
|
|
91
|
+
src = snap.data; source = 'snapshot';
|
|
92
|
+
}
|
|
93
|
+
const d = src || {};
|
|
94
|
+
const gates = Array.isArray(d.eval_gates) ? d.eval_gates : [];
|
|
95
|
+
const passing = gates.filter((g) => reviewGate.isReviewPass(g.verdict, g.score)).length;
|
|
96
|
+
const wms = (Array.isArray(d.watermelons) ? d.watermelons : []).map((w) => ({
|
|
97
|
+
id: w.id, title: w.title, rag: w.rag,
|
|
98
|
+
verdict: w.latest_eval_verdict, score: w.latest_eval_score, corrections: w.avg_human_corrections,
|
|
99
|
+
}));
|
|
100
|
+
const calRaw = Array.isArray(d.calibration) ? d.calibration[0] : d.calibration;
|
|
101
|
+
const calibration = calRaw ? {
|
|
102
|
+
n: calRaw.n, kappa: calRaw.kappa, tnr: calRaw.tnr, status: calRaw.status,
|
|
103
|
+
meets_d40: !!calRaw.meets_d40_kappa, caveat: calRaw.leakage_caveat || null,
|
|
104
|
+
} : null;
|
|
105
|
+
const okr = (Array.isArray(d.okr_pulse) ? d.okr_pulse : []).slice(0, 8).map((o) => ({
|
|
106
|
+
objective: o.objective, kr: o.kr, progress: o.progress_pct, pulse: o.pulse,
|
|
107
|
+
}));
|
|
108
|
+
return { ok: true, empty: false, source, data: {
|
|
109
|
+
// watermelon flags = anything not green (amber/red) leads; the whole point is to surface drift
|
|
110
|
+
watermelons: wms,
|
|
111
|
+
flags: wms.filter((w) => w.rag && String(w.rag).toLowerCase() !== 'green').length,
|
|
112
|
+
eval_gate: { total: gates.length, passing, pass_rate: gates.length ? +(passing / gates.length).toFixed(3) : null },
|
|
113
|
+
calibration, okr_pulse: okr,
|
|
114
|
+
} };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
module.exports = { metrics, radar, computeMetricsFromRuns, loadJson, loadJsonl };
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/*
|
|
3
|
+
* Canonical file-mode state parsers (D64 / pivot-workspace-first, Track D1).
|
|
4
|
+
*
|
|
5
|
+
* The workspace server (server.js) and the kit's metrics.py both read the SAME file-mode records —
|
|
6
|
+
* acceptances.jsonl, evals.jsonl, runs.jsonl, and the leading frontmatter of initiative markdown —
|
|
7
|
+
* and MUST agree on what a repo declared. Before D64 each had its own inline copy of these parsers,
|
|
8
|
+
* so they were free to drift. server.js now delegates to this module; metrics.py mirrors it in
|
|
9
|
+
* Python; kit/fixtures/test_parsers.py runs both against kit/fixtures/** and fails CI on disagreement.
|
|
10
|
+
*/
|
|
11
|
+
const fs = require('fs');
|
|
12
|
+
const reviewGate = require('./review-gate');
|
|
13
|
+
|
|
14
|
+
// The LEADING YAML-ish frontmatter block ONLY — body text must never be able to fake a declaration.
|
|
15
|
+
// Mirrors metrics.py exactly: `key: value` lines (first value wins), `---` fences and blank lines are
|
|
16
|
+
// skipped, and the FIRST non-frontmatter, non-fence, non-blank line ends the block.
|
|
17
|
+
function parseLeadingFrontmatter(text) {
|
|
18
|
+
const fm = {};
|
|
19
|
+
for (const line of String(text == null ? '' : text).split('\n')) {
|
|
20
|
+
const m = line.match(/^([a-z_]+):\s*(.+?)\s*$/);
|
|
21
|
+
if (m) { if (!(m[1] in fm)) fm[m[1]] = m[2]; continue; }
|
|
22
|
+
if (line.trim() && !line.startsWith('---')) break;
|
|
23
|
+
}
|
|
24
|
+
return fm;
|
|
25
|
+
}
|
|
26
|
+
function leadingFrontmatter(file) {
|
|
27
|
+
let text;
|
|
28
|
+
try { text = fs.readFileSync(file, 'utf8'); } catch { return {}; }
|
|
29
|
+
return parseLeadingFrontmatter(text);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// One JSON object per line; blank lines skipped, torn lines tolerated (dropped), never guessed.
|
|
33
|
+
function readJsonlRows(file) {
|
|
34
|
+
let text;
|
|
35
|
+
try { text = fs.readFileSync(file, 'utf8'); } catch { return []; }
|
|
36
|
+
const out = [];
|
|
37
|
+
for (const line of text.split('\n')) {
|
|
38
|
+
const t = line.trim();
|
|
39
|
+
if (!t) continue;
|
|
40
|
+
try { out.push(JSON.parse(t)); } catch { /* torn line — skipped, mirrors metrics.py */ }
|
|
41
|
+
}
|
|
42
|
+
return out;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// The kit's north-star / throughput field: an acceptance counts only when accepted === true.
|
|
46
|
+
function isAccepted(row) { return !!row && row.accepted === true; }
|
|
47
|
+
|
|
48
|
+
// A declared outcome is DUE when it has an id + outcome_metric and its horizon is past today. A
|
|
49
|
+
// MISSING horizon counts as due (horizon-gaming-by-omission) — identical to metrics.py.
|
|
50
|
+
function outcomeDue(fm, today) {
|
|
51
|
+
if (!fm || !fm.id || !fm.outcome_metric) return false;
|
|
52
|
+
return String(fm.outcome_horizon || '0000-00-00') < String(today);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
module.exports = {
|
|
56
|
+
parseLeadingFrontmatter, leadingFrontmatter, readJsonlRows, isAccepted, outcomeDue,
|
|
57
|
+
isReviewPass: reviewGate.isReviewPass,
|
|
58
|
+
};
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* harness-parse — the five scrollback-derived signals for the harness tower.
|
|
4
|
+
* PRD: planning/prd/workspace-harness-tower-prd.md (R3). Rubric dimension 1 is a VETO.
|
|
5
|
+
*
|
|
6
|
+
* ─────────────────────────────────────────────────────────────────────────────────────────
|
|
7
|
+
* VENDOR COUPLING — READ THIS BEFORE CHANGING ANYTHING HERE.
|
|
8
|
+
*
|
|
9
|
+
* Owner: the PM (wawan). There is no upstream contract behind any of this.
|
|
10
|
+
*
|
|
11
|
+
* Every signal below is derived by reading the raw bytes ONE VENDOR'S CLI (Claude Code)
|
|
12
|
+
* happened to print into a PTY. Claude Code renders its TUI in place with cursor motion; we
|
|
13
|
+
* are reading a byte ring, not a screen. The markers we anchor on (`⏺` for a block, `⎿` for
|
|
14
|
+
* tool output) are that CLI's presentation, not an API. When Anthropic restyles the terminal
|
|
15
|
+
* UI, these parsers degrade — and because the PM ratified FIVE of them over the recommended
|
|
16
|
+
* one (pm-decisions.md, 2026-07-25), a single restyle can degrade five surfaces at once.
|
|
17
|
+
*
|
|
18
|
+
* What to check when output looks wrong:
|
|
19
|
+
* 1. Do `⏺` / `⎿` still prefix blocks and tool results? (BLOCK / RESULT below)
|
|
20
|
+
* 2. Are tool calls still `Name(args)` and MCP still `server - tool (MCP)`? (TOOL_CALL / MCP_CALL)
|
|
21
|
+
* 3. Does the CLI still print a context percentage at all? (CONTEXT_PCT)
|
|
22
|
+
* If (1) breaks, everything here returns its failure state — which is the correct behaviour,
|
|
23
|
+
* not a bug to paper over.
|
|
24
|
+
*
|
|
25
|
+
* THE RULE THAT MAKES THIS SAFE TO SHIP: every signal has a FAILURE STATE that the UI draws.
|
|
26
|
+
* A parser that cannot read must say so. It must never return an empty string, a zero, or a
|
|
27
|
+
* plausible-looking value — a monitoring surface that guesses is worse than one that is absent.
|
|
28
|
+
* `missing[signal]` carries the reason, and the client renders it verbatim.
|
|
29
|
+
*
|
|
30
|
+
* REVERSAL CONDITION (pre-agreed, pm-decisions.md): if two or more of these break in one CLI
|
|
31
|
+
* update, or any one is found rendering a value where it should have rendered a failure, scope
|
|
32
|
+
* reverts to parser 1 alone and the rest are deleted.
|
|
33
|
+
*
|
|
34
|
+
* INPUT MUST ALREADY BE SCRUBBED. Callers pass text through server.js's `scrub()` first —
|
|
35
|
+
* ANSI stripped and secrets redacted (D08). Scrollback holds whatever the agent printed, so a
|
|
36
|
+
* verbatim "last words" line could otherwise put a token on screen.
|
|
37
|
+
* ─────────────────────────────────────────────────────────────────────────────────────────
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
// Claude Code's block markers. Everything in this file anchors on these two glyphs.
|
|
41
|
+
const BLOCK = /^\s*⏺\s+(.+)$/; // an assistant block: prose OR a tool call
|
|
42
|
+
const RESULT = /^\s*⎿/; // tool output under a call
|
|
43
|
+
const TOOL_CALL = /^([A-Z][A-Za-z0-9_]*)\(/; // Read(…), Bash(…), Write(…)
|
|
44
|
+
const MCP_CALL = /^(\S+)\s+-\s+(\S+)/; // pmos - save_spec (MCP)
|
|
45
|
+
const CONTEXT_PCT = /context[^\n%]{0,24}?(\d{1,3})\s*%|(\d{1,3})\s*%\s+context/i;
|
|
46
|
+
const MODEL = /\bclaude-(?:opus|sonnet|haiku|fable)[\w.-]*/i;
|
|
47
|
+
const EFFORT = /\beffort[:\s]+(low|medium|high|xhigh|max)\b/i;
|
|
48
|
+
|
|
49
|
+
// Only the tail is parsed. The ring holds 200 KB; re-reading all of it for every session on
|
|
50
|
+
// every broadcast is waste. The consequence is stated honestly in the UI: tool counts are
|
|
51
|
+
// "recent", not "for the whole run", because a ring cannot promise the whole run anyway.
|
|
52
|
+
const TAIL_BYTES = 32_768;
|
|
53
|
+
|
|
54
|
+
const NOT_CLAUDE = 'only claude sessions are parsed';
|
|
55
|
+
const UNREADABLE = "can't read its output — no recognisable Claude Code blocks";
|
|
56
|
+
|
|
57
|
+
/** Split the scrubbed tail into lines, dropping a partial first line. */
|
|
58
|
+
function tailLines(scrubbed) {
|
|
59
|
+
const tail = scrubbed.length > TAIL_BYTES ? scrubbed.slice(scrubbed.length - TAIL_BYTES) : scrubbed;
|
|
60
|
+
const lines = tail.split(/\r?\n/);
|
|
61
|
+
if (scrubbed.length > TAIL_BYTES) lines.shift(); // first line is mid-sentence; never quote it
|
|
62
|
+
return lines;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Is this block line a tool call rather than prose? */
|
|
66
|
+
function isToolCall(body) {
|
|
67
|
+
return TOOL_CALL.test(body) || /\(MCP\)/.test(body);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Name a tool call for counting. Returns null when the body is prose. */
|
|
71
|
+
function toolName(body) {
|
|
72
|
+
const direct = body.match(TOOL_CALL);
|
|
73
|
+
if (direct) return direct[1];
|
|
74
|
+
if (/\(MCP\)/.test(body)) {
|
|
75
|
+
const mcp = body.match(MCP_CALL);
|
|
76
|
+
if (mcp) return `${mcp[1]}/${mcp[2]}`;
|
|
77
|
+
return 'mcp';
|
|
78
|
+
}
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Parser 1 — the agent's own last words, verbatim.
|
|
84
|
+
* No paraphrase: a summary would be another model call per session, and a paraphrase that
|
|
85
|
+
* drifts from what the agent actually said is the defect the leg-A grilling caught.
|
|
86
|
+
*/
|
|
87
|
+
function lastWords(lines) {
|
|
88
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
89
|
+
const m = lines[i].match(BLOCK);
|
|
90
|
+
if (!m || isToolCall(m[1])) continue;
|
|
91
|
+
const parts = [m[1].trim()];
|
|
92
|
+
// prose wraps onto indented continuation lines; stop at the next marker or a blank line
|
|
93
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
94
|
+
const nxt = lines[j];
|
|
95
|
+
if (!nxt.trim() || BLOCK.test(nxt) || RESULT.test(nxt) || /^\s*[❯>]\s/.test(nxt)) break;
|
|
96
|
+
if (!/^\s{1,6}\S/.test(nxt)) break;
|
|
97
|
+
parts.push(nxt.trim());
|
|
98
|
+
}
|
|
99
|
+
const text = parts.join(' ').replace(/\s+/g, ' ').trim();
|
|
100
|
+
if (text) return text;
|
|
101
|
+
}
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Parser 3 — tool counts across the retained tail. */
|
|
106
|
+
function toolCounts(lines) {
|
|
107
|
+
const counts = {};
|
|
108
|
+
let total = 0;
|
|
109
|
+
for (const line of lines) {
|
|
110
|
+
const m = line.match(BLOCK);
|
|
111
|
+
if (!m) continue;
|
|
112
|
+
const name = toolName(m[1]);
|
|
113
|
+
if (!name) continue;
|
|
114
|
+
counts[name] = (counts[name] || 0) + 1;
|
|
115
|
+
total++;
|
|
116
|
+
}
|
|
117
|
+
return total ? { counts, total } : null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Parser 4 — model + effort, only if the CLI printed them. Never guessed. */
|
|
121
|
+
function modelInfo(text) {
|
|
122
|
+
const m = text.match(MODEL);
|
|
123
|
+
if (!m) return null;
|
|
124
|
+
const e = text.match(EFFORT);
|
|
125
|
+
return { model: m[0], effort: e ? e[1].toLowerCase() : null };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Parser 5 — context utilisation.
|
|
130
|
+
* Reported ONLY as a percentage the CLI itself printed. We never compute one: the window size
|
|
131
|
+
* is not in the scrollback, and a percentage of an unknown denominator is not a quantity
|
|
132
|
+
* (PRD R3). If the CLI never printed one, this signal is absent — which is the common case.
|
|
133
|
+
*/
|
|
134
|
+
function contextPct(text) {
|
|
135
|
+
const m = text.match(CONTEXT_PCT);
|
|
136
|
+
if (!m) return null;
|
|
137
|
+
const raw = Number(m[1] != null ? m[1] : m[2]);
|
|
138
|
+
if (!Number.isFinite(raw) || raw < 0 || raw > 100) return null;
|
|
139
|
+
return { pct: raw, source: 'printed by the CLI' };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Parse the four scrollback signals for one session.
|
|
144
|
+
* Parser 2 (quiet timing) is NOT here — it is a server-side timestamp (`lastOutputAt`), not a
|
|
145
|
+
* parse, which is why it is the only one of the five that cannot lie about what it read.
|
|
146
|
+
*
|
|
147
|
+
* @param {string} scrubbed buffer already through scrub() — ANSI stripped, secrets redacted
|
|
148
|
+
* @param {string} agent 'claude' | 'codex' | 'shell'
|
|
149
|
+
* @returns {{lastWords:string|null, tools:object|null, model:object|null, context:object|null,
|
|
150
|
+
* missing:Record<string,string>}}
|
|
151
|
+
*/
|
|
152
|
+
function parseHarness(scrubbed, agent) {
|
|
153
|
+
const out = { lastWords: null, tools: null, model: null, context: null, missing: {} };
|
|
154
|
+
|
|
155
|
+
if (agent !== 'claude') {
|
|
156
|
+
out.missing.lastWords = `can't read its last words — ${NOT_CLAUDE}`;
|
|
157
|
+
out.missing.tools = NOT_CLAUDE;
|
|
158
|
+
out.missing.model = NOT_CLAUDE;
|
|
159
|
+
out.missing.context = NOT_CLAUDE;
|
|
160
|
+
return out;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const text = String(scrubbed || '');
|
|
164
|
+
const lines = tailLines(text);
|
|
165
|
+
const anyBlock = lines.some((l) => BLOCK.test(l));
|
|
166
|
+
|
|
167
|
+
if (!anyBlock) {
|
|
168
|
+
// The single most important branch in this file: nothing recognisable was found, so every
|
|
169
|
+
// signal says so. This is what the forced-failure test exercises.
|
|
170
|
+
out.missing.lastWords = UNREADABLE;
|
|
171
|
+
out.missing.tools = 'no recognisable tool calls in the retained scrollback';
|
|
172
|
+
out.missing.model = 'the CLI has not printed a model name';
|
|
173
|
+
out.missing.context = 'the CLI has not printed a context percentage';
|
|
174
|
+
return out;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
out.lastWords = lastWords(lines);
|
|
178
|
+
if (!out.lastWords) out.missing.lastWords = "can't read its last words — no prose block yet";
|
|
179
|
+
|
|
180
|
+
out.tools = toolCounts(lines);
|
|
181
|
+
if (!out.tools) out.missing.tools = 'no tool calls in the retained scrollback';
|
|
182
|
+
|
|
183
|
+
const tailText = lines.join('\n');
|
|
184
|
+
out.model = modelInfo(tailText);
|
|
185
|
+
if (!out.model) out.missing.model = 'the CLI has not printed a model name';
|
|
186
|
+
|
|
187
|
+
out.context = contextPct(tailText);
|
|
188
|
+
if (!out.context) out.missing.context = 'the CLI has not printed a context percentage';
|
|
189
|
+
|
|
190
|
+
return out;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Parser 2 — quiet timing, derived from a timestamp rather than a parse.
|
|
195
|
+
* `quiet` means EXACTLY "no bytes for >= QUIET_AFTER_MS". It does NOT mean "waiting on you":
|
|
196
|
+
* quiescence proves quiet, not waiting (PRD R1). The word in the UI must stay "quiet".
|
|
197
|
+
*
|
|
198
|
+
* `failed` deliberately does not exist. The server knows only working|exited; deriving failure
|
|
199
|
+
* by grepping scrollback for "error" would install a watermelon generator in the product whose
|
|
200
|
+
* thesis is anti-watermelon.
|
|
201
|
+
*/
|
|
202
|
+
function deriveState(state, lastOutputAt, now) {
|
|
203
|
+
if (state === 'exited') return { state: 'exited', quietMs: null };
|
|
204
|
+
if (!lastOutputAt) return { state: 'working', quietMs: null }; // no timestamp ⇒ no claim
|
|
205
|
+
const quietMs = Math.max(0, now - lastOutputAt);
|
|
206
|
+
return quietMs >= module.exports.QUIET_AFTER_MS
|
|
207
|
+
? { state: 'quiet', quietMs }
|
|
208
|
+
: { state: 'working', quietMs };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
module.exports = {
|
|
212
|
+
parseHarness,
|
|
213
|
+
deriveState,
|
|
214
|
+
// Provisional, and knowingly so. It must exceed the longest gap a WORKING claude session can
|
|
215
|
+
// go without emitting a byte; the spinner redraws continuously, so real gaps are sub-second.
|
|
216
|
+
// 30s is a deliberate over-estimate: a false "quiet" is the failure that matters, because the
|
|
217
|
+
// whole feature exists to stop the PM mistaking blocked for busy. Needs one field measurement
|
|
218
|
+
// (the byte rate of a working session) to tighten — carried as an open question in the PRD.
|
|
219
|
+
QUIET_AFTER_MS: 30_000,
|
|
220
|
+
TAIL_BYTES,
|
|
221
|
+
_internal: { lastWords, toolCounts, modelInfo, contextPct, tailLines, isToolCall, toolName },
|
|
222
|
+
};
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/* preview-kind — the SINGLE classifier the reader trusts (workspace-reader-preview).
|
|
2
|
+
*
|
|
3
|
+
* One source of truth for "what can the reader do with this file", used on BOTH sides:
|
|
4
|
+
* - server.js requires it (readTextDoc allowlist, /api/asset MIME, tree() kind flag)
|
|
5
|
+
* - the browser loads it as /lib/preview-kind.js before app.js (window.PreviewKind)
|
|
6
|
+
* so the tree flag, the read endpoints, and the renderer can never disagree (R1/R2/R8).
|
|
7
|
+
*
|
|
8
|
+
* The one security-critical function is isRepoRelativeSrc(): it decides whether a markdown
|
|
9
|
+
* image renders inline (repo-relative, served through the guarded /api/asset) or stays
|
|
10
|
+
* downgraded to alt-text (remote / data: / any scheme). That single boolean is the
|
|
11
|
+
* no-remote-image-beacon invariant (R4) — a poisoned `` must never
|
|
12
|
+
* cause a fetch on view. It is pure, so it is unit-tested directly.
|
|
13
|
+
*/
|
|
14
|
+
'use strict';
|
|
15
|
+
(function (root, factory) {
|
|
16
|
+
const api = factory();
|
|
17
|
+
if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
|
18
|
+
else root.PreviewKind = api;
|
|
19
|
+
})(typeof self !== 'undefined' ? self : this, function () {
|
|
20
|
+
// Textual formats that ARE markdown — rendered through the markdown pipeline. `.skill` is
|
|
21
|
+
// markdown + YAML frontmatter, so it renders like any concept doc (R1).
|
|
22
|
+
const MARKDOWN_EXT = new Set(['md', 'markdown', 'mdown', 'mkd', 'mdx', 'skill']);
|
|
23
|
+
|
|
24
|
+
// Textual formats shown as ESCAPED source (never executed, R2). Broad on purpose — every entry
|
|
25
|
+
// is escaped text, so breadth adds no risk (PRD open-question resolved: broad allowlist).
|
|
26
|
+
const TEXT_EXT = new Set([
|
|
27
|
+
'txt', 'text', 'log', 'csv', 'tsv', 'json', 'jsonc', 'json5', 'ndjson',
|
|
28
|
+
'yaml', 'yml', 'toml', 'ini', 'cfg', 'conf', 'config', 'properties', 'env', 'example',
|
|
29
|
+
'xml', 'rss', 'atom', // NB: .svg is classified 'image' (IMAGE_MIME, checked first) — it renders, not source
|
|
30
|
+
'sh', 'bash', 'zsh', 'fish', 'ps1', 'bat',
|
|
31
|
+
'js', 'mjs', 'cjs', 'jsx', 'ts', 'tsx', 'css', 'scss', 'sass', 'less',
|
|
32
|
+
'sql', 'py', 'rb', 'go', 'rs', 'java', 'kt', 'kts', 'c', 'h', 'cc', 'cpp', 'hpp',
|
|
33
|
+
'cs', 'php', 'pl', 'pm', 'lua', 'r', 'swift', 'scala', 'clj', 'ex', 'exs', 'erl',
|
|
34
|
+
'diff', 'patch', 'lock', 'gitignore', 'gitattributes', 'editorconfig', 'npmrc', 'nvmrc',
|
|
35
|
+
'dockerfile', 'makefile', 'gradle', 'graphql', 'gql', 'proto', 'tf', 'hcl',
|
|
36
|
+
]);
|
|
37
|
+
|
|
38
|
+
const HTML_EXT = new Set(['html', 'htm']);
|
|
39
|
+
|
|
40
|
+
// Raster/vector formats servable through /api/asset as an <img>. Loaded via <img>, SVG scripts
|
|
41
|
+
// do not execute; /api/asset additionally sends nosniff + a script-blocking CSP for direct hits.
|
|
42
|
+
const IMAGE_MIME = {
|
|
43
|
+
png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif',
|
|
44
|
+
webp: 'image/webp', svg: 'image/svg+xml', avif: 'image/avif', bmp: 'image/bmp',
|
|
45
|
+
ico: 'image/x-icon', apng: 'image/apng',
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
// Extension-less files that are really text (open as source).
|
|
49
|
+
const TEXT_BASENAMES = new Set(['dockerfile', 'makefile', 'license', 'licence', 'authors',
|
|
50
|
+
'notice', 'copying', 'changelog', 'procfile', 'brewfile', 'gemfile', 'rakefile', 'vagrantfile']);
|
|
51
|
+
|
|
52
|
+
/** lowercase extension without the dot; '' when there is none. Dotfiles (.gitignore) → 'gitignore'. */
|
|
53
|
+
function extOf(name) {
|
|
54
|
+
const base = String(name || '').split(/[\\/]/).pop().toLowerCase();
|
|
55
|
+
if (!base) return '';
|
|
56
|
+
const dot = base.lastIndexOf('.');
|
|
57
|
+
if (dot < 0) return ''; // no dot at all
|
|
58
|
+
if (dot === 0) return base.slice(1); // dotfile: whole thing after the leading dot
|
|
59
|
+
return base.slice(dot + 1);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** name/path → 'markdown' | 'text' | 'html' | 'image' | 'inert'. */
|
|
63
|
+
function previewKind(name) {
|
|
64
|
+
const base = String(name || '').split(/[\\/]/).pop().toLowerCase();
|
|
65
|
+
const ext = extOf(base);
|
|
66
|
+
if (MARKDOWN_EXT.has(ext)) return 'markdown';
|
|
67
|
+
if (HTML_EXT.has(ext)) return 'html';
|
|
68
|
+
// an image extension wins over its text twin (.svg) only when there is a real extension;
|
|
69
|
+
// opening an .svg as a DOC still shows source (TEXT_EXT), inline ![]() treats it as an image.
|
|
70
|
+
if (Object.prototype.hasOwnProperty.call(IMAGE_MIME, ext)) return 'image';
|
|
71
|
+
if (TEXT_EXT.has(ext)) return 'text';
|
|
72
|
+
if (!ext && TEXT_BASENAMES.has(base)) return 'text';
|
|
73
|
+
return 'inert';
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** image MIME for /api/asset, or null when the extension is not an allowlisted image. */
|
|
77
|
+
function imageMime(name) {
|
|
78
|
+
return IMAGE_MIME[extOf(name)] || null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** a markdown-body kind served as escaped source (vs. rendered markdown). */
|
|
82
|
+
function isTextSource(kind) { return kind === 'text'; }
|
|
83
|
+
|
|
84
|
+
/** THE no-beacon invariant (R4). true ⇒ inline via the guarded /api/asset; false ⇒ downgrade
|
|
85
|
+
* to alt-text and never fetch. Anything with a scheme (http:, https:, data:, blob:, mailto:),
|
|
86
|
+
* protocol-relative //host, or a bare #anchor is treated as NON-repo-relative. */
|
|
87
|
+
function isRepoRelativeSrc(src) {
|
|
88
|
+
const s = String(src == null ? '' : src).trim();
|
|
89
|
+
if (!s) return false;
|
|
90
|
+
if (s.startsWith('#')) return false; // an in-page anchor is not an image file
|
|
91
|
+
if (/^\/\//.test(s)) return false; // protocol-relative → remote
|
|
92
|
+
if (/^[a-zA-Z][a-zA-Z0-9+.\-]*:/.test(s)) return false; // any URI scheme (http:, data:, blob:, mailto:)
|
|
93
|
+
return true; // a plain relative / root-relative repo path
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return { previewKind, imageMime, isTextSource, isRepoRelativeSrc, extOf,
|
|
97
|
+
MARKDOWN_EXT, TEXT_EXT, HTML_EXT, IMAGE_MIME };
|
|
98
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/*
|
|
3
|
+
* A tiny, pure token-bucket rate limiter (D64 / pivot-workspace-first, Track A3).
|
|
4
|
+
*
|
|
5
|
+
* The WS control messages that SPAWN shells ({t:'new'}, {t:'respawn'}) and FEED a PTY ({t:'input'})
|
|
6
|
+
* are the abuse surface if the socket is ever reached by something hostile. A per-connection bucket
|
|
7
|
+
* bounds that abuse while staying invisible to a human at an interactive terminal: the input bucket
|
|
8
|
+
* is sized so ordinary typing (and normal pastes) never trip it, and the spawn bucket allows a small
|
|
9
|
+
* burst then drips.
|
|
10
|
+
*
|
|
11
|
+
* Pure + time-injectable so the limiter is unit-testable without sleeping. No I/O, no globals.
|
|
12
|
+
*/
|
|
13
|
+
function createBucket(capacity, refillPerSec, now = Date.now()) {
|
|
14
|
+
return { capacity, tokens: capacity, refillPerSec, last: now };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Try to spend `n` tokens. Refills continuously from elapsed wall-time. Returns true if allowed.
|
|
18
|
+
function take(bucket, n = 1, now = Date.now()) {
|
|
19
|
+
const elapsed = Math.max(0, now - bucket.last) / 1000;
|
|
20
|
+
bucket.tokens = Math.min(bucket.capacity, bucket.tokens + elapsed * bucket.refillPerSec);
|
|
21
|
+
bucket.last = now;
|
|
22
|
+
if (bucket.tokens >= n) { bucket.tokens -= n; return true; }
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
module.exports = { createBucket, take };
|