geneseed 3.8.0 → 3.10.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/CHANGELOG.md +71 -1
- package/DESIGN.md +13 -10
- package/QUICKSTART.md +1 -1
- package/README.md +31 -32
- package/SETUP.md +7 -42
- package/SHIPPED.md +4 -4
- package/adapters/bob/README.md +17 -1
- package/adapters/openclaude/README.md +1 -1
- package/adapters/opencode/GLOBAL-HARNESS-SPEC.md +1 -1
- package/adapters/opencode/README.md +3 -3
- package/adapters/opencode/plugins/geneseed-context.js +78 -8
- package/adapters/opencode/plugins/geneseed-guard.js +9 -9
- package/adapters/opencode/plugins/geneseed-workflow.js +1 -1
- package/adapters/opencode/workflows/_runtime.js +2 -2
- package/adapters/opencode/workflows/dispatch.js +1 -1
- package/bin/geneseed-hook.mjs +1 -1
- package/docs/candidate-hosts.md +12 -5
- package/docs/extending.md +72 -40
- package/docs/token-footprint.md +26 -21
- package/docs/web/claude-hooks.md +1 -1
- package/docs/web/collaboration.md +1 -1
- package/docs/web/exclusions.md +0 -1
- package/docs/web/footprint.md +1 -1
- package/docs/web/harnesses.md +14 -14
- package/docs/web/install-quick.md +1 -1
- package/docs/web/plugin-guard.md +2 -2
- package/docs/web/rules.md +1 -1
- package/docs/web/themes-author.md +2 -2
- package/docs/web-ui.md +2 -2
- package/harness.config.json +1 -1
- package/js/README.md +8 -7
- package/js/build/driver.mjs +26 -95
- package/js/build/emit-claude.mjs +76 -81
- package/js/build/generate.mjs +0 -1
- package/js/build/render.mjs +10 -3
- package/js/build/source.mjs +105 -1
- package/js/build/stubs.mjs +25 -4
- package/js/cli-table.json +8 -8
- package/js/hosts/hooks.mjs +91 -59
- package/js/hosts/hosts.mjs +5 -21
- package/js/hosts/installs.mjs +4 -6
- package/js/hosts/mcp.mjs +3 -13
- package/js/hosts/native.mjs +28 -51
- package/js/hosts/opencode.mjs +1 -1
- package/js/hosts/settings.mjs +4 -78
- package/js/inspect/checks-authoring.mjs +153 -117
- package/js/inspect/checks-repo.mjs +45 -1
- package/js/inspect/diff.mjs +11 -3
- package/js/inspect/doctor.mjs +3 -3
- package/js/inspect/excludes.mjs +0 -4
- package/js/inspect/inventory.mjs +1 -2
- package/js/inspect/status.mjs +4 -4
- package/js/maintain/migrate.mjs +5 -5
- package/js/maintain/setup.mjs +3 -5
- package/js/maintain/uninstall.mjs +8 -42
- package/js/ui/tui.mjs +1 -1
- package/js/web/actions.mjs +4 -5
- package/js/web/api.mjs +10 -5
- package/package.json +1 -2
- package/registry.json +7 -0
- package/src/AGENT.md.tmpl +86 -65
- package/src/agents/_template.md +3 -3
- package/src/agents/advocate.md +3 -3
- package/src/agents/architect.md +3 -3
- package/src/agents/developer.md +4 -4
- package/src/agents/docs.md +3 -3
- package/src/agents/empiricist.md +4 -4
- package/src/agents/explorer.md +4 -4
- package/src/agents/framer.md +3 -3
- package/src/agents/historian.md +3 -3
- package/src/agents/operator.md +3 -3
- package/src/agents/pragmatist.md +3 -3
- package/src/agents/researcher.md +7 -7
- package/src/agents/reviewer.md +3 -3
- package/src/agents/security.md +3 -3
- package/src/agents/skeptic.md +3 -3
- package/src/agents/steward.md +3 -3
- package/src/agents/tester.md +3 -3
- package/src/agents/user-advocate.md +3 -3
- package/src/agents/visionary.md +3 -3
- package/src/doctrines/README.md +4 -3
- package/src/doctrines/comms.md +4 -4
- package/src/doctrines/craft.md +20 -20
- package/src/doctrines/ops.md +19 -19
- package/src/doctrines/process.md +20 -20
- package/src/doctrines/rigor.md +22 -22
- package/src/laws/universal.md +26 -44
- package/src/memory/README.md +3 -3
- package/src/modes/foreman.md +1 -1
- package/src/notebook/README.md +1 -1
- package/src/ontology/universal.md +8 -10
- package/src/postures/artisan.md +2 -2
- package/src/skills/_self-improvement.md +4 -4
- package/src/skills/_template.md +1 -1
- package/src/skills/brainstorm.md +3 -3
- package/src/skills/bruno-collection-generator.md +6 -6
- package/src/skills/bruno-test-writer.md +5 -5
- package/src/skills/ci-fix.md +4 -4
- package/src/skills/commit.md +11 -10
- package/src/skills/consolidate-memory.md +4 -4
- package/src/skills/council.md +2 -2
- package/src/skills/daydream/SKILL.md +10 -3
- package/src/skills/daydream/VENDOR.md +10 -2
- package/src/skills/daydream/instructions.md +2 -2
- package/src/skills/debug.md +4 -4
- package/src/skills/deps-audit.md +3 -3
- package/src/skills/develop.md +7 -7
- package/src/skills/document-project.md +6 -6
- package/src/skills/explain-changes/SKILL.md +69 -0
- package/src/skills/explain-changes/VENDOR.md +20 -0
- package/src/skills/explain-changes/scripts/render_changes.mjs +558 -0
- package/src/skills/forge-mcp.md +4 -4
- package/src/skills/fresh-eyes.md +4 -4
- package/src/skills/frontend-design.md +1 -1
- package/src/skills/geneseed.md +4 -4
- package/src/skills/git-archaeology.md +5 -5
- package/src/skills/git-rescue.md +7 -7
- package/src/skills/handoff.md +3 -3
- package/src/skills/ingest.md +5 -5
- package/src/skills/migrate.md +8 -8
- package/src/skills/parallel-agents.md +2 -2
- package/src/skills/pipeline.md +2 -2
- package/src/skills/plan.md +3 -3
- package/src/skills/ponytail.md +2 -2
- package/src/skills/profile.md +2 -2
- package/src/skills/prose.md +1 -1
- package/src/skills/prototype.md +1 -1
- package/src/skills/react-view-transitions/SKILL.md +1 -1
- package/src/skills/react-view-transitions/VENDOR.md +7 -2
- package/src/skills/refactor.md +3 -3
- package/src/skills/release.md +5 -5
- package/src/skills/repo-map.md +4 -4
- package/src/skills/research.md +9 -9
- package/src/skills/review-response.md +4 -4
- package/src/skills/rule.md +3 -3
- package/src/skills/security-audit.md +5 -5
- package/src/skills/ship.md +5 -5
- package/src/skills/skill-forge.md +4 -4
- package/src/skills/token-report/SKILL.md +2 -3
- package/src/skills/token-report/VENDOR.md +2 -3
- package/src/skills/token-report/scripts/token_report.mjs +7 -121
- package/src/skills/wiki.md +5 -5
- package/src/skills/workflow.md +3 -4
- package/src/skills/worktree.md +4 -4
- package/themes/_TEMPLATE.json +37 -39
- package/themes/biker.json +37 -39
- package/themes/commentator.json +37 -39
- package/themes/cyberpunk.json +37 -39
- package/themes/gamer.json +37 -39
- package/themes/imperial.json +37 -39
- package/themes/joker.json +37 -39
- package/themes/marvin.json +37 -39
- package/themes/mean.json +37 -39
- package/themes/military.json +37 -39
- package/themes/neutral.json +37 -39
- package/themes/pirate.json +37 -39
- package/themes/sports.json +37 -39
- package/themes/verstappen.json +37 -39
- package/themes/wizard.json +37 -39
- package/web/dist/assets/Activity-BjVp522q.js +1 -0
- package/web/dist/assets/ActivityDetail-8ep1bspW.js +1 -0
- package/web/dist/assets/Dashboard-OkC_PCH6.js +2 -0
- package/web/dist/assets/{Docs-DrJNDPwI.js → Docs-CKPWz00o.js} +2 -2
- package/web/dist/assets/FilterInput-CVyqmYWM.js +1 -0
- package/web/dist/assets/GreenhouseView-D0cbCJ5m.js +2 -0
- package/web/dist/assets/Installs-BtGGdoTt.js +2 -0
- package/web/dist/assets/JournalView-Ckl4dmzR.js +1 -0
- package/web/dist/assets/Laws-CLpdRRk8.js +3 -0
- package/web/dist/assets/Library-wPPuKQrI.js +1 -0
- package/web/dist/assets/{Markdown-BQAvN8xx.js → Markdown-CkFyfgNf.js} +14 -14
- package/web/dist/assets/OperatorHudView-CiAkAkQL.js +2 -0
- package/web/dist/assets/Personal-D8CnDo9v.js +1 -0
- package/web/dist/assets/Tabs-B4HNrrCs.js +1 -0
- package/web/dist/assets/drawable-BiwaOo6x.js +1 -0
- package/web/dist/assets/geist-mono-latin-400-normal-DTRLJnHl.woff2 +0 -0
- package/web/dist/assets/geist-mono-latin-500-normal-YINYabwD.woff2 +0 -0
- package/web/dist/assets/index-C1yil5j1.js +2 -0
- package/web/dist/assets/index-CDPDk2vj.css +1 -0
- package/web/dist/assets/{react-DbkvxWG5.js → react-B9Z_tXRV.js} +1 -1
- package/web/dist/assets/sections-GalMS0Zv.js +1 -0
- package/web/dist/assets/usePoll-O0sJEw1Y.js +1 -0
- package/web/dist/index.html +4 -4
- package/web/src/pages/Laws.jsx +616 -361
- package/adapters/copilot/README.md +0 -105
- package/web/dist/assets/Activity-CH-7cPIM.js +0 -1
- package/web/dist/assets/ActivityDetail-DZvz0xS5.js +0 -1
- package/web/dist/assets/CatalogRow-DftXpZHY.js +0 -1
- package/web/dist/assets/Diff-DvwdG0L9.js +0 -2
- package/web/dist/assets/Doctor-OTmwmYZg.js +0 -1
- package/web/dist/assets/FilterInput-BakJLvaV.js +0 -1
- package/web/dist/assets/GreenhouseView-XxrAdriX.js +0 -2
- package/web/dist/assets/Harness-C_LYNk5c.js +0 -1
- package/web/dist/assets/JournalView-BcuyeEaR.js +0 -1
- package/web/dist/assets/Laws-CWHrTKY4.js +0 -3
- package/web/dist/assets/Library-CA9IHghn.js +0 -1
- package/web/dist/assets/OperatorHudView-BMRY1Tt4.js +0 -2
- package/web/dist/assets/Profile-wceET1JY.js +0 -1
- package/web/dist/assets/Rules-BEXvY4Tx.js +0 -1
- package/web/dist/assets/Settings-DS2zF0Ez.js +0 -1
- package/web/dist/assets/Skills-DahlOJuI.js +0 -1
- package/web/dist/assets/StatusBadge-Cg6j-NJL.js +0 -1
- package/web/dist/assets/activity-iWaWVtsR.js +0 -1
- package/web/dist/assets/audiowide-latin-400-normal-6GFCX7ni.woff2 +0 -0
- package/web/dist/assets/drawable-BTHjTzF7.js +0 -1
- package/web/dist/assets/geist-mono-latin-400-normal-DKaoCDn5.woff2 +0 -0
- package/web/dist/assets/geist-mono-latin-500-normal-C3sF8Y1B.woff2 +0 -0
- package/web/dist/assets/geist-mono-latin-600-normal-BHzjB6_C.woff2 +0 -0
- package/web/dist/assets/ibm-plex-mono-latin-400-normal-DMJ8VG8y.woff2 +0 -0
- package/web/dist/assets/ibm-plex-mono-latin-500-normal-DSY6xOcd.woff2 +0 -0
- package/web/dist/assets/ibm-plex-mono-latin-600-normal-BgSNZQsw.woff2 +0 -0
- package/web/dist/assets/ibm-plex-serif-latin-400-normal-BIGslYFI.woff2 +0 -0
- package/web/dist/assets/ibm-plex-serif-latin-500-normal-REKAfBVG.woff2 +0 -0
- package/web/dist/assets/ibm-plex-serif-latin-600-normal-DAxitmSu.woff2 +0 -0
- package/web/dist/assets/index-AN5Emnrg.js +0 -2
- package/web/dist/assets/index-Ruh93A3A.css +0 -1
- package/web/dist/assets/inter-latin-400-normal-C38fXH4l.woff2 +0 -0
- package/web/dist/assets/inter-latin-500-normal-Cerq10X2.woff2 +0 -0
- package/web/dist/assets/inter-latin-600-normal-LgqL8muc.woff2 +0 -0
- package/web/dist/assets/inter-latin-700-normal-Yt3aPRUw.woff2 +0 -0
- package/web/dist/assets/lawCats-Btjp3Hk_.js +0 -1
- package/web/dist/assets/oswald-latin-400-normal-BAFo8sP8.woff2 +0 -0
- package/web/dist/assets/oswald-latin-500-normal-B-eyT0Dw.woff2 +0 -0
- package/web/dist/assets/oswald-latin-600-normal-plFmSr5g.woff2 +0 -0
- package/web/dist/assets/oswald-latin-700-normal-vy6hmKSr.woff2 +0 -0
- package/web/dist/assets/poppins-latin-400-normal-cpxAROuN.woff2 +0 -0
- package/web/dist/assets/poppins-latin-500-normal-C8OXljZJ.woff2 +0 -0
- package/web/dist/assets/poppins-latin-600-normal-zEkxB9Mr.woff2 +0 -0
- package/web/dist/assets/poppins-latin-700-normal-Qrb0O0WB.woff2 +0 -0
- package/web/dist/assets/rolldown-runtime-CbXtAM7H.js +0 -1
|
@@ -0,0 +1,558 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Render an explain-changes report — one self-contained, offline HTML page that walks a human
|
|
4
|
+
* through a diff before they commit it.
|
|
5
|
+
*
|
|
6
|
+
* WHY A SCRIPT AND NOT THE MODEL. The model writes only the narrative (the brief: intent,
|
|
7
|
+
* risks, logical units). Every diff line on the page comes from `git diff`, run here, and is
|
|
8
|
+
* escaped here. A model that retypes a diff pays output tokens in proportion to the diff and
|
|
9
|
+
* can alter a line of the very thing under review; this file cannot.
|
|
10
|
+
*
|
|
11
|
+
* The script only READS git. It never touches the index or the working tree.
|
|
12
|
+
*
|
|
13
|
+
* Usage:
|
|
14
|
+
* node render_changes.mjs --brief <file.json> [--out <notebook-dir>] [--base <ref>]
|
|
15
|
+
* node render_changes.mjs --hash [--base <ref>]
|
|
16
|
+
* Exit: 0 written (mismatches, if any, go to stderr and onto a banner) · 2 usage · 3 empty diff.
|
|
17
|
+
*/
|
|
18
|
+
import { execFileSync } from 'node:child_process';
|
|
19
|
+
import { createHash } from 'node:crypto';
|
|
20
|
+
import fs from 'node:fs';
|
|
21
|
+
import path from 'node:path';
|
|
22
|
+
import { pathToFileURL } from 'node:url';
|
|
23
|
+
|
|
24
|
+
// ponytail: the SHA-1 empty tree; a SHA-256 repository has another id. Upgrade if one shows up:
|
|
25
|
+
// `git hash-object -t tree --stdin` on empty input answers for either object format.
|
|
26
|
+
const EMPTY_TREE ='4b825dc642cb6eb9a060e54bf8d69288fbee4904';
|
|
27
|
+
export const MAX_FILE_LINES = 1500;
|
|
28
|
+
|
|
29
|
+
function git(cwd, args, input) {
|
|
30
|
+
return execFileSync('git', ['-c', 'core.quotePath=false', ...args], {
|
|
31
|
+
cwd, input, encoding: 'utf8', maxBuffer: 512 * 1024 * 1024, stdio: ['pipe', 'pipe', 'pipe'],
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function gitOr(cwd, args, fallback) {
|
|
36
|
+
try { return git(cwd, args).trim(); } catch { return fallback; }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Untracked files as the diff `git add` would produce. Read here rather than via `git add -N`,
|
|
41
|
+
* which would write the index. A nested repository is listed as `sub/` and cannot be read as a
|
|
42
|
+
* file, and a broken symlink has nothing to read: both are written the way git writes them
|
|
43
|
+
* (a gitlink header; a symlink's target as its one line), since reading either would throw.
|
|
44
|
+
*/
|
|
45
|
+
function untrackedAsDiff(root) {
|
|
46
|
+
const paths = git(root, ['ls-files', '--others', '--exclude-standard', '-z'])
|
|
47
|
+
.split('\0').filter(Boolean);
|
|
48
|
+
let out = '';
|
|
49
|
+
for (const listed of paths) {
|
|
50
|
+
const full = path.join(root, listed);
|
|
51
|
+
const st = fs.lstatSync(full);
|
|
52
|
+
const p = listed.replace(/\/$/, '');
|
|
53
|
+
if (st.isDirectory()) { out += `diff --git a/${p} b/${p}\nnew file mode 160000\n`; continue; }
|
|
54
|
+
if (st.isSymbolicLink()) {
|
|
55
|
+
out += `diff --git a/${p} b/${p}\nnew file mode 120000\n--- /dev/null\n+++ b/${p}\n@@ -0,0 +1 @@\n`
|
|
56
|
+
+ `+${fs.readlinkSync(full)}\n\\n`;
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
const buf = fs.readFileSync(full);
|
|
60
|
+
out += `diff --git a/${p} b/${p}\nnew file mode 100644\n`;
|
|
61
|
+
if (buf.includes(0)) { out += `Binary files /dev/null and b/${p} differ\n`; continue; }
|
|
62
|
+
const text = buf.toString('utf8');
|
|
63
|
+
if (!text) continue;
|
|
64
|
+
const lines = text.split('\n');
|
|
65
|
+
const noEol = lines[lines.length - 1] !== '';
|
|
66
|
+
if (!noEol) lines.pop();
|
|
67
|
+
out += `--- /dev/null\n+++ b/${p}\n@@ -0,0 +1,${lines.length} @@\n`
|
|
68
|
+
+ lines.map((l) => `+${l}`).join('\n') + '\n';
|
|
69
|
+
if (noEol) out += '\\n';
|
|
70
|
+
}
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Staged if anything is staged (what the commit will hold), else the working tree. */
|
|
75
|
+
export function selectDiff(root, base) {
|
|
76
|
+
const common = ['diff', '--no-color', '--no-ext-diff', '-M'];
|
|
77
|
+
if (base) return { mode: `${base}...HEAD`, text: git(root, [...common, `${base}...HEAD`]) };
|
|
78
|
+
const head = gitOr(root, ['rev-parse', '--verify', '-q', 'HEAD'], '') ? 'HEAD' : EMPTY_TREE;
|
|
79
|
+
const staged = git(root, [...common, '--cached', head]);
|
|
80
|
+
if (staged) return { mode: 'staged', text: staged };
|
|
81
|
+
return { mode: 'working tree', text: git(root, [...common, head]) + untrackedAsDiff(root) };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export const diffHash = (text) => createHash('sha256').update(text).digest('hex').slice(0, 12);
|
|
85
|
+
|
|
86
|
+
const C_ESC = { a: 7, b: 8, t: 9, n: 10, v: 11, f: 12, r: 13 };
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* A path as git prints it. git C-quotes a path holding `"`, `\`, a tab or a newline even with
|
|
90
|
+
* core.quotePath=false; octal escapes are raw bytes, so they are collected and decoded as UTF-8.
|
|
91
|
+
*/
|
|
92
|
+
export function unquote(s) {
|
|
93
|
+
if (!s.startsWith('"')) return s;
|
|
94
|
+
const parts = [];
|
|
95
|
+
for (const [m, e] of s.slice(1, -1).matchAll(/\\([0-7]{3}|.)|[^\\]+/gs)) {
|
|
96
|
+
if (e === undefined) parts.push(Buffer.from(m));
|
|
97
|
+
else parts.push(Buffer.from([/^[0-7]/.test(e) ? parseInt(e, 8) : (C_ESC[e] ?? e.charCodeAt(0))]));
|
|
98
|
+
}
|
|
99
|
+
return Buffer.concat(parts).toString('utf8');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function parseDiff(text) {
|
|
103
|
+
const files = [];
|
|
104
|
+
let f = null;
|
|
105
|
+
let h = null;
|
|
106
|
+
let o = 0;
|
|
107
|
+
let n = 0;
|
|
108
|
+
for (const line of text.split('\n')) {
|
|
109
|
+
if (line.startsWith('diff --git ')) {
|
|
110
|
+
// ponytail: an unquoted path containing " b/" splits wrong here; the rename/copy lines correct it
|
|
111
|
+
const m = /^diff --git (?:"((?:[^"\\]|\\.)*)"|a\/(.*?)) (?:"((?:[^"\\]|\\.)*)"|b\/(.*))$/.exec(line);
|
|
112
|
+
const a = m && (m[2] ?? unquote(`"${m[1]}"`).slice(2));
|
|
113
|
+
const b = m && (m[4] ?? unquote(`"${m[3]}"`).slice(2));
|
|
114
|
+
f = {
|
|
115
|
+
path: m ? b : line.slice(11), oldPath: m ? a : '', status: 'modified',
|
|
116
|
+
binary: false, generated: false, truncated: false, hunks: [], add: 0, del: 0, shown: 0,
|
|
117
|
+
};
|
|
118
|
+
files.push(f);
|
|
119
|
+
h = null;
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
if (!f) continue;
|
|
123
|
+
const hm = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(line);
|
|
124
|
+
if (hm) {
|
|
125
|
+
h = { header: line, lines: [] };
|
|
126
|
+
f.hunks.push(h);
|
|
127
|
+
o = Number(hm[1]);
|
|
128
|
+
n = Number(hm[2]);
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
if (!h) {
|
|
132
|
+
if (line.startsWith('new file mode')) f.status = 'added';
|
|
133
|
+
else if (line.startsWith('deleted file mode')) f.status = 'deleted';
|
|
134
|
+
else if (line.startsWith('rename from ')) { f.status = 'renamed'; f.oldPath = unquote(line.slice(12)); }
|
|
135
|
+
else if (line.startsWith('rename to ')) f.path = unquote(line.slice(10));
|
|
136
|
+
else if (line.startsWith('copy from ')) { f.status = 'copied'; f.oldPath = unquote(line.slice(10)); }
|
|
137
|
+
else if (line.startsWith('copy to ')) f.path = unquote(line.slice(8));
|
|
138
|
+
else if (line.startsWith('Binary files ') || line === 'GIT binary patch') f.binary = true;
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
const t = line[0];
|
|
142
|
+
if (t === '+') f.add++;
|
|
143
|
+
else if (t === '-') f.del++;
|
|
144
|
+
else if (t !== ' ' && t !== '\\') continue;
|
|
145
|
+
if (f.shown >= MAX_FILE_LINES) { f.truncated = true; continue; }
|
|
146
|
+
f.shown++;
|
|
147
|
+
const body = line.slice(1);
|
|
148
|
+
if (t === '+') h.lines.push({ t: 'add', n: n++, text: body });
|
|
149
|
+
else if (t === '-') h.lines.push({ t: 'del', o: o++, text: body });
|
|
150
|
+
else if (t === ' ') h.lines.push({ t: 'ctx', o: o++, n: n++, text: body });
|
|
151
|
+
else h.lines.push({ t: 'note', text: line });
|
|
152
|
+
}
|
|
153
|
+
return files;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const GENERATED = /(^|\/)(package-lock\.json|npm-shrinkwrap\.json|pnpm-lock\.yaml|yarn\.lock|Cargo\.lock|poetry\.lock|composer\.lock|Gemfile\.lock)$|(^|\/)dist\//;
|
|
157
|
+
|
|
158
|
+
export function markGenerated(root, files) {
|
|
159
|
+
let attrs = '';
|
|
160
|
+
try {
|
|
161
|
+
attrs = git(root, ['check-attr', '--stdin', 'linguist-generated'],
|
|
162
|
+
files.map((f) => f.path).join('\n') + '\n');
|
|
163
|
+
} catch { /* no attributes → only the name patterns apply */ }
|
|
164
|
+
const marked = new Set();
|
|
165
|
+
for (const l of attrs.split('\n')) {
|
|
166
|
+
const i = l.lastIndexOf(': linguist-generated: ');
|
|
167
|
+
if (i > 0 && /: (true|set)$/.test(l)) marked.add(l.slice(0, i));
|
|
168
|
+
}
|
|
169
|
+
for (const f of files) f.generated = GENERATED.test(f.path) || marked.has(f.path);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Inline SVG from the model: one <svg> element and nothing after it, no script, no handler, no
|
|
174
|
+
* foreign HTML, no animation that rewrites a link, nothing outside the page. A denylist can be
|
|
175
|
+
* bypassed; the page's CSP is what actually stops a script or a fetch. This check exists so an
|
|
176
|
+
* unsafe diagram raises the mismatch banner instead of rendering inert and unnoticed.
|
|
177
|
+
*/
|
|
178
|
+
export function safeSvg(svg) {
|
|
179
|
+
const t = String(svg).trim();
|
|
180
|
+
const close = t.toLowerCase().indexOf('</svg>');
|
|
181
|
+
const shape = /^<svg[\s>/][\s\S]*<\/svg>$/i.test(t) ? close === t.length - 6 : /^<svg[^>]*\/>$/i.test(t);
|
|
182
|
+
return shape
|
|
183
|
+
&& !/<script|<foreignObject|\son\w+\s*=|href\s*=\s*(?!["']?#)|url\(\s*(?!["']?#)/i.test(t)
|
|
184
|
+
&& !/attributeName\s*=\s*["']?(xlink:)?href/i.test(t)
|
|
185
|
+
&& !/<(iframe|img|meta|form|link|object|embed|style|base)\b|javascript:/i.test(t);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export const unitFiles = (u) => [...new Set([...(u.files ?? []), ...Object.keys(u.hunks ?? {})])];
|
|
189
|
+
|
|
190
|
+
const isObj = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* The brief with every malformed field reported and dropped, so neither the check nor the page
|
|
194
|
+
* trips over model-written JSON of the wrong shape. `brief` is null when there is nothing to keep.
|
|
195
|
+
*/
|
|
196
|
+
export function shapeBrief(raw) {
|
|
197
|
+
if (!isObj(raw)) return { brief: null, problems: ['the brief must be a JSON object'] };
|
|
198
|
+
const problems = [];
|
|
199
|
+
const list = (v, name) => {
|
|
200
|
+
if (v === undefined) return [];
|
|
201
|
+
if (Array.isArray(v)) return v;
|
|
202
|
+
problems.push(`${name} must be an array`);
|
|
203
|
+
return [];
|
|
204
|
+
};
|
|
205
|
+
const units = list(raw.units, 'units').flatMap((u, i) => {
|
|
206
|
+
if (!isObj(u)) { problems.push(`unit ${i + 1} must be an object`); return []; }
|
|
207
|
+
const c = { ...u };
|
|
208
|
+
if (u.files !== undefined && !(Array.isArray(u.files) && u.files.every((p) => typeof p === 'string'))) {
|
|
209
|
+
problems.push(`unit "${u.title}": files must be an array of paths`);
|
|
210
|
+
delete c.files;
|
|
211
|
+
}
|
|
212
|
+
if (u.hunks !== undefined && !isObj(u.hunks)) {
|
|
213
|
+
problems.push(`unit "${u.title}": hunks must be an object of path → hunk indexes`);
|
|
214
|
+
delete c.hunks;
|
|
215
|
+
} else if (u.hunks) {
|
|
216
|
+
c.hunks = Object.fromEntries(Object.entries(u.hunks).filter(([p, idx]) => {
|
|
217
|
+
const ok = Array.isArray(idx) && idx.every(Number.isInteger);
|
|
218
|
+
if (!ok) problems.push(`unit "${u.title}": hunks for ${p} must be an array of hunk indexes`);
|
|
219
|
+
return ok;
|
|
220
|
+
}));
|
|
221
|
+
}
|
|
222
|
+
return [c];
|
|
223
|
+
});
|
|
224
|
+
const risks = list(raw.risks, 'risks').filter((r, i) => {
|
|
225
|
+
if (!isObj(r) || typeof r.text !== 'string') {
|
|
226
|
+
problems.push(`risk ${i + 1} must be an object with a string "text"`);
|
|
227
|
+
return false;
|
|
228
|
+
}
|
|
229
|
+
if (!Object.hasOwn(RISK_ORDER, r.level)) {
|
|
230
|
+
problems.push(`risk "${r.text}" has level "${r.level}" — use high, medium or low`);
|
|
231
|
+
}
|
|
232
|
+
return true;
|
|
233
|
+
});
|
|
234
|
+
return { brief: { ...raw, units, risks }, problems };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export function checkBrief(raw, files) {
|
|
238
|
+
const { brief, problems } = shapeBrief(raw);
|
|
239
|
+
if (!brief) return problems;
|
|
240
|
+
const byPath = new Map(files.map((f) => [f.path, f]));
|
|
241
|
+
const covered = new Set();
|
|
242
|
+
for (const u of brief.units) {
|
|
243
|
+
for (const p of unitFiles(u)) {
|
|
244
|
+
if (byPath.has(p)) covered.add(p);
|
|
245
|
+
else problems.push(`unit "${u.title}" cites ${p}, which is not in the diff`);
|
|
246
|
+
}
|
|
247
|
+
for (const [p, idx] of Object.entries(u.hunks ?? {})) {
|
|
248
|
+
const f = byPath.get(p);
|
|
249
|
+
if (!f) continue;
|
|
250
|
+
for (const i of idx) {
|
|
251
|
+
if (!f.hunks[i]) problems.push(`unit "${u.title}" cites hunk ${i} of ${p}, which has ${f.hunks.length}`);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
if (u.svg && !safeSvg(u.svg)) problems.push(`unit "${u.title}": diagram dropped (unsafe SVG)`);
|
|
255
|
+
}
|
|
256
|
+
for (const f of files) {
|
|
257
|
+
if (!covered.has(f.path) && !f.generated) problems.push(`${f.path} is changed but no unit explains it`);
|
|
258
|
+
}
|
|
259
|
+
if (!brief.risks.length && !brief.risks_none_reason) {
|
|
260
|
+
problems.push('no risks listed and no risks_none_reason given');
|
|
261
|
+
}
|
|
262
|
+
return problems;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const TEST_PATH = /(^|\/)(tests?|__tests__|spec)\/|\.(test|spec)\.[^/]+$/;
|
|
266
|
+
const RISK_ORDER = { high: 0, medium: 1, low: 2 };
|
|
267
|
+
|
|
268
|
+
const LABELS = {
|
|
269
|
+
en: {
|
|
270
|
+
request: 'Original request', inShort: 'In short', checkFirst: 'Check first',
|
|
271
|
+
walkthrough: 'Walkthrough', appendix: 'Appendix — full diff',
|
|
272
|
+
mismatch: 'The narrative does not match the diff', noRisks: 'No risks listed',
|
|
273
|
+
files: 'files', tests: 'test files', outOfScope: 'out of scope',
|
|
274
|
+
high: 'high', medium: 'medium', low: 'low', binary: 'binary', generated: 'generated',
|
|
275
|
+
truncated: `truncated after ${MAX_FILE_LINES} lines`, light: '☀ Light', dark: '☾ Dark',
|
|
276
|
+
generatedOn: 'generated',
|
|
277
|
+
status: { added: 'added', deleted: 'deleted', modified: 'modified', renamed: 'renamed', copied: 'copied' },
|
|
278
|
+
},
|
|
279
|
+
fr: {
|
|
280
|
+
request: "Demande d'origine", inShort: 'En bref', checkFirst: 'À vérifier en priorité',
|
|
281
|
+
walkthrough: 'Parcours des changements', appendix: 'Annexe — diff complet',
|
|
282
|
+
mismatch: 'Le récit ne correspond pas au diff', noRisks: 'Aucun risque listé',
|
|
283
|
+
files: 'fichiers', tests: 'fichiers de test', outOfScope: 'hors périmètre',
|
|
284
|
+
high: 'élevé', medium: 'moyen', low: 'faible', binary: 'binaire', generated: 'généré',
|
|
285
|
+
truncated: `tronqué après ${MAX_FILE_LINES} lignes`, light: '☀ Clair', dark: '☾ Sombre',
|
|
286
|
+
generatedOn: 'généré le',
|
|
287
|
+
status: { added: 'nouveau', deleted: 'supprimé', modified: 'modifié', renamed: 'renommé', copied: 'copié' },
|
|
288
|
+
},
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
const esc = (s) => String(s ?? '').replace(/[&<>"']/g,
|
|
292
|
+
(c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);
|
|
293
|
+
const code = (s) => esc(s).replace(/\r$/, '<span class="cr" title="CR">␍</span>');
|
|
294
|
+
|
|
295
|
+
function splitRows(h) {
|
|
296
|
+
const side = (l, which) => (l
|
|
297
|
+
? `<td class="n ${l.t}">${which === 'o' ? l.o : l.n}</td><td class="c ${l.t}">${code(l.text)}</td>`
|
|
298
|
+
: '<td class="n empty"></td><td class="c empty"></td>');
|
|
299
|
+
const L = h.lines;
|
|
300
|
+
let r = '';
|
|
301
|
+
let i = 0;
|
|
302
|
+
while (i < L.length) {
|
|
303
|
+
if (L[i].t === 'ctx') { r += `<tr>${side(L[i], 'o')}${side(L[i], 'n')}</tr>`; i++; continue; }
|
|
304
|
+
if (L[i].t === 'note') { r += `<tr><td class="note" colspan="4">${esc(L[i].text)}</td></tr>`; i++; continue; }
|
|
305
|
+
const dels = [];
|
|
306
|
+
const adds = [];
|
|
307
|
+
while (i < L.length && L[i].t === 'del') dels.push(L[i++]);
|
|
308
|
+
while (i < L.length && L[i].t === 'add') adds.push(L[i++]);
|
|
309
|
+
for (let k = 0; k < Math.max(dels.length, adds.length); k++) {
|
|
310
|
+
r += `<tr>${side(dels[k], 'o')}${side(adds[k], 'n')}</tr>`;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
return r;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function singleRows(h, added) {
|
|
317
|
+
return h.lines.map((l) => (l.t === 'note'
|
|
318
|
+
? `<tr><td class="note" colspan="3">${esc(l.text)}</td></tr>`
|
|
319
|
+
: `<tr><td class="n ${l.t}">${added ? l.n : l.o}</td><td class="s ${l.t}">${added ? '+' : '−'}</td>`
|
|
320
|
+
+ `<td class="c ${l.t}">${code(l.text)}</td></tr>`)).join('');
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function diffTable(f, hunks) {
|
|
324
|
+
if (f.binary || !hunks.length) return '';
|
|
325
|
+
const single = f.status === 'added' || f.status === 'deleted';
|
|
326
|
+
const rows = hunks.map((h) => `<tr class="hunk"><td colspan="${single ? 3 : 4}">${esc(h.header)}</td></tr>`
|
|
327
|
+
+ (single ? singleRows(h, f.status === 'added') : splitRows(h))).join('');
|
|
328
|
+
return `<div class="diffwrap"><table class="diff ${single ? 'single' : 'split'}">${rows}</table></div>`;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function fileHead(f, L) {
|
|
332
|
+
const tags = [f.binary && L.binary, f.generated && L.generated, f.truncated && L.truncated]
|
|
333
|
+
.filter(Boolean).map((t) => `<span class="tag">${esc(t)}</span> `).join('');
|
|
334
|
+
return `<div class="fhead"><span class="path">diff --git a/${esc(f.oldPath || f.path)} b/${esc(f.path)}</span>`
|
|
335
|
+
+ `<span>${tags}${esc(L.status[f.status])} · <span class="plus">+${f.add}</span> `
|
|
336
|
+
+ `<span class="minus">−${f.del}</span></span></div>`;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const CSS = `
|
|
340
|
+
:root{--bg:#16181c;--panel:#1d2025;--ink:#e6e7e9;--muted:#9a9ea6;--line:#2e3238;--accent:#8aa4ff;
|
|
341
|
+
--add-bg:#16311f;--add-ink:#8fe0a6;--add-gut:#1d4029;--del-bg:#3a1a1d;--del-ink:#ff9aa2;--del-gut:#4a2226;
|
|
342
|
+
--hunk-bg:#1f2638;--hunk-ink:#8aa4ff;--high:#ff7b6b;--medium:#f0b44c;--low:#a8cf6f;--code-bg:#191b1f;color-scheme:dark}
|
|
343
|
+
:root[data-theme="light"]{--bg:#fbfbfa;--panel:#fff;--ink:#1d1f23;--muted:#62666d;--line:#e3e4e6;--accent:#3a5bd9;
|
|
344
|
+
--add-bg:#e6f6ea;--add-ink:#1a6b33;--add-gut:#cdeed6;--del-bg:#fdebec;--del-ink:#a1232b;--del-gut:#f8d4d7;
|
|
345
|
+
--hunk-bg:#eef2fd;--hunk-ink:#3a5bd9;--high:#c0392b;--medium:#c77c02;--low:#5a7d2a;--code-bg:#f6f7f8;color-scheme:light}
|
|
346
|
+
*{box-sizing:border-box}
|
|
347
|
+
body{margin:0;background:var(--bg);color:var(--ink);font:15px/1.55 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif}
|
|
348
|
+
main{padding:32px clamp(16px,3vw,48px) 64px}
|
|
349
|
+
.ask,.summary,.why{max-width:110ch}
|
|
350
|
+
code,.path,.diff,.chip,footer{font-family:ui-monospace,"Cascadia Code","SF Mono",Consolas,monospace;font-size:12.5px}
|
|
351
|
+
h1{font-size:22px;margin:0 0 4px}h2{font-size:17px;margin:40px 0 12px}h3{font-size:15px;margin:0}
|
|
352
|
+
.muted{color:var(--muted)}
|
|
353
|
+
.top{display:flex;justify-content:space-between;align-items:flex-start;gap:16px}
|
|
354
|
+
button{font:inherit;font-size:13px;background:var(--panel);color:var(--ink);border:1px solid var(--line);border-radius:6px;padding:5px 10px;cursor:pointer}
|
|
355
|
+
.ask{margin:16px 0;padding:12px 16px;border-left:3px solid var(--accent);background:var(--panel);border-radius:0 8px 8px 0}
|
|
356
|
+
.label{font-size:12px;text-transform:uppercase;letter-spacing:.06em;color:var(--muted)}
|
|
357
|
+
.stats{display:flex;flex-wrap:wrap;gap:8px;margin-top:12px}
|
|
358
|
+
.stat{background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:6px 12px;font-size:13px}
|
|
359
|
+
.plus{color:var(--add-ink)}.minus{color:var(--del-ink)}
|
|
360
|
+
.summary{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:16px 20px}
|
|
361
|
+
.mismatch{margin:16px 0;padding:12px 16px;border:1px solid var(--high);border-radius:10px;color:var(--high)}
|
|
362
|
+
.checks{list-style:none;padding:0;margin:0;display:grid;gap:8px}
|
|
363
|
+
.checks li{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:12px 14px;display:grid;grid-template-columns:auto auto 1fr;gap:10px;align-items:start}
|
|
364
|
+
.checks input{margin-top:4px;width:16px;height:16px}
|
|
365
|
+
.checks li.done{opacity:.55}.checks li.done .what{text-decoration:line-through}
|
|
366
|
+
.checks a{color:var(--accent);text-decoration:none;font-size:13px}
|
|
367
|
+
.sev{font-size:11px;font-weight:600;text-transform:uppercase;padding:2px 7px;border-radius:999px;border:1px solid;white-space:nowrap}
|
|
368
|
+
.sev.high{color:var(--high)}.sev.medium{color:var(--medium)}.sev.low{color:var(--low)}
|
|
369
|
+
.card{background:var(--panel);border:1px solid var(--line);border-radius:12px;margin:16px 0;overflow:hidden}
|
|
370
|
+
.card>.head{padding:16px 20px;border-bottom:1px solid var(--line)}
|
|
371
|
+
.step{font-size:12px;color:var(--muted)}.why{margin:6px 0 0}
|
|
372
|
+
.files{margin-top:8px;display:flex;gap:6px;flex-wrap:wrap}
|
|
373
|
+
.chip{background:var(--code-bg);border:1px solid var(--line);border-radius:6px;padding:1px 7px}
|
|
374
|
+
.diagram{padding:16px 20px;border-bottom:1px solid var(--line);color:var(--ink)}
|
|
375
|
+
.diagram svg{width:100%;height:auto;max-width:900px;display:block;margin:0 auto}
|
|
376
|
+
.file+.file{border-top:1px solid var(--line)}
|
|
377
|
+
.fhead{display:flex;justify-content:space-between;gap:8px;padding:8px 14px;background:var(--code-bg);border-bottom:1px solid var(--line);font-size:12.5px}
|
|
378
|
+
.path{font-weight:600;overflow-wrap:anywhere}
|
|
379
|
+
.tag{font-size:11px;color:var(--muted);border:1px solid var(--line);border-radius:999px;padding:0 6px}
|
|
380
|
+
.diffwrap{overflow-x:auto}
|
|
381
|
+
.diff{width:100%;border-collapse:collapse;table-layout:fixed;tab-size:4}
|
|
382
|
+
.diff td{padding:0 8px;vertical-align:top;white-space:pre-wrap;overflow-wrap:anywhere;line-height:1.6}
|
|
383
|
+
.diff td.n{width:52px;text-align:right;color:var(--muted);user-select:none;padding:0 6px}
|
|
384
|
+
.diff td.s{width:18px;text-align:center;user-select:none;padding:0}
|
|
385
|
+
.diff td.add{background:var(--add-bg);color:var(--add-ink)}.diff td.n.add{background:var(--add-gut)}
|
|
386
|
+
.diff td.del{background:var(--del-bg);color:var(--del-ink)}.diff td.n.del{background:var(--del-gut)}
|
|
387
|
+
.diff td.empty{background:var(--code-bg)}
|
|
388
|
+
.diff tr.hunk td{background:var(--hunk-bg);color:var(--hunk-ink);padding:3px 8px}
|
|
389
|
+
.diff td.note{color:var(--muted);font-style:italic}
|
|
390
|
+
.cr{color:var(--muted)}
|
|
391
|
+
details>summary{cursor:pointer;list-style:none}details>summary::-webkit-details-marker{display:none}
|
|
392
|
+
details>summary .fhead::before{content:"▸ "}details[open]>summary .fhead::before{content:"▾ "}
|
|
393
|
+
footer{margin-top:48px;padding-top:16px;border-top:1px solid var(--line);color:var(--muted)}
|
|
394
|
+
`;
|
|
395
|
+
|
|
396
|
+
const JS = `
|
|
397
|
+
const R=document.documentElement,B=document.getElementById('theme');
|
|
398
|
+
function set(t){R.dataset.theme=t;B.textContent=t==='dark'?B.dataset.light:B.dataset.dark;
|
|
399
|
+
try{localStorage.setItem('explain-changes-theme',t)}catch(e){}}
|
|
400
|
+
let t='dark';try{t=localStorage.getItem('explain-changes-theme')||'dark'}catch(e){}
|
|
401
|
+
set(t);B.onclick=()=>set(R.dataset.theme==='dark'?'light':'dark');
|
|
402
|
+
document.querySelectorAll('.checks input').forEach(c=>c.onchange=()=>c.closest('li').classList.toggle('done',c.checked));
|
|
403
|
+
`;
|
|
404
|
+
|
|
405
|
+
// The CSP is the barrier: no script runs but the page's own (pinned by hash, so it must be
|
|
406
|
+
// byte-identical to what sits between <script> and </script>), nothing is fetched, no form posts.
|
|
407
|
+
// safeSvg is only the check that raises the banner.
|
|
408
|
+
const CSP = "default-src 'none'; style-src 'unsafe-inline'; img-src data:; "
|
|
409
|
+
+ `script-src 'sha256-${createHash('sha256').update(JS).digest('base64')}'; form-action 'none'; base-uri 'none'`;
|
|
410
|
+
|
|
411
|
+
export function renderPage({ brief, files, problems, meta }) {
|
|
412
|
+
const lang = Object.hasOwn(LABELS, brief.lang) ? brief.lang : 'en';
|
|
413
|
+
const L = LABELS[lang];
|
|
414
|
+
const byPath = new Map(files.map((f) => [f.path, f]));
|
|
415
|
+
const units = (brief.units ?? []).map((u, i) => ({ ...u, id: u.id ?? `u${i + 1}` }));
|
|
416
|
+
const titleOf = (id) => units.find((u) => u.id === id)?.title ?? id;
|
|
417
|
+
const add = files.reduce((s, f) => s + f.add, 0);
|
|
418
|
+
const del = files.reduce((s, f) => s + f.del, 0);
|
|
419
|
+
const tests = files.filter((f) => TEST_PATH.test(f.path)).length;
|
|
420
|
+
const oos = units.filter((u) => u.out_of_scope).length;
|
|
421
|
+
|
|
422
|
+
const risks = [...(brief.risks ?? [])]
|
|
423
|
+
.map((r) => ({ ...r, level: Object.hasOwn(RISK_ORDER, r.level) ? r.level : 'low' }))
|
|
424
|
+
.sort((a, b) => RISK_ORDER[a.level] - RISK_ORDER[b.level]);
|
|
425
|
+
const riskHtml = risks.length
|
|
426
|
+
? `<ul class="checks">${risks.map((r) => `<li><input type="checkbox"><span class="sev ${r.level}">${esc(L[r.level])}</span>`
|
|
427
|
+
+ `<div><div class="what">${esc(r.text)}</div>`
|
|
428
|
+
+ `${r.unit ? `<a href="#${esc(r.unit)}">→ ${esc(titleOf(r.unit))}</a>` : ''}</div></li>`).join('')}</ul>`
|
|
429
|
+
: `<p class="muted">${esc(L.noRisks)} — ${esc(brief.risks_none_reason)}</p>`;
|
|
430
|
+
|
|
431
|
+
const cards = units.map((u, i) => {
|
|
432
|
+
const paths = unitFiles(u);
|
|
433
|
+
const diffs = paths.map((p) => {
|
|
434
|
+
const f = byPath.get(p);
|
|
435
|
+
if (!f) return '';
|
|
436
|
+
const idx = u.hunks?.[p];
|
|
437
|
+
const hunks = idx ? idx.map((k) => f.hunks[k]).filter(Boolean) : f.hunks;
|
|
438
|
+
return `<div class="file">${fileHead(f, L)}${diffTable(f, hunks)}</div>`;
|
|
439
|
+
}).join('');
|
|
440
|
+
const svg = u.svg && safeSvg(u.svg) ? `<div class="diagram">${u.svg}</div>` : '';
|
|
441
|
+
return `<section class="card" id="${esc(u.id)}"><div class="head">`
|
|
442
|
+
+ `<div class="step">${i + 1} / ${units.length}${u.kind ? ` · ${esc(u.kind)}` : ''}</div>`
|
|
443
|
+
+ `<h3>${esc(u.title)}</h3><p class="why">${esc(u.why)}</p>`
|
|
444
|
+
+ `<div class="files">${paths.map((p) => `<span class="chip">${esc(p)}</span>`).join('')}</div>`
|
|
445
|
+
+ `</div>${svg}${diffs}</section>`;
|
|
446
|
+
}).join('');
|
|
447
|
+
|
|
448
|
+
const appendix = files.map((f) => `<details class="card file"><summary>${fileHead(f, L)}</summary>`
|
|
449
|
+
+ `${f.generated ? '' : diffTable(f, f.hunks)}</details>`).join('');
|
|
450
|
+
const banner = problems.length
|
|
451
|
+
? `<div class="mismatch"><strong>${esc(L.mismatch)}</strong><ul>${problems.map((p) => `<li>${esc(p)}</li>`).join('')}</ul></div>`
|
|
452
|
+
: '';
|
|
453
|
+
|
|
454
|
+
return `<!doctype html>
|
|
455
|
+
<html lang="${lang}" data-theme="dark">
|
|
456
|
+
<head>
|
|
457
|
+
<meta charset="utf-8">
|
|
458
|
+
<meta http-equiv="Content-Security-Policy" content="${CSP}">
|
|
459
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
460
|
+
<title>${esc(brief.title)} — ${esc(meta.branch)}</title>
|
|
461
|
+
<style>${CSS}</style>
|
|
462
|
+
</head>
|
|
463
|
+
<body>
|
|
464
|
+
<main>
|
|
465
|
+
<header>
|
|
466
|
+
<div class="top"><div><h1>${esc(brief.title)}</h1><div class="muted">${esc(meta.branch)} · ${esc(meta.mode)}</div></div>
|
|
467
|
+
<button id="theme" data-light="${esc(L.light)}" data-dark="${esc(L.dark)}">${esc(L.light)}</button></div>
|
|
468
|
+
<div class="ask"><div class="label">${esc(L.request)}</div>${esc(brief.request)}</div>
|
|
469
|
+
<div class="stats"><span class="stat"><b>${files.length}</b> ${esc(L.files)}</span>
|
|
470
|
+
<span class="stat plus"><b>+${add}</b></span><span class="stat minus"><b>−${del}</b></span>
|
|
471
|
+
<span class="stat"><b>${tests}</b> ${esc(L.tests)}</span><span class="stat"><b>${oos}</b> ${esc(L.outOfScope)}</span></div>
|
|
472
|
+
</header>
|
|
473
|
+
${banner}
|
|
474
|
+
<h2>${esc(L.inShort)}</h2>
|
|
475
|
+
<div class="summary">${esc(brief.summary)}</div>
|
|
476
|
+
<h2>${esc(L.checkFirst)}</h2>
|
|
477
|
+
${riskHtml}
|
|
478
|
+
<h2>${esc(L.walkthrough)}</h2>
|
|
479
|
+
${cards}
|
|
480
|
+
<h2>${esc(L.appendix)}</h2>
|
|
481
|
+
${appendix}
|
|
482
|
+
<footer>diff ${esc(meta.hash)} · base ${esc(meta.base)} (${esc(meta.branch)}) · ${esc(L.generatedOn)} ${esc(meta.generated)} · explain-changes</footer>
|
|
483
|
+
</main>
|
|
484
|
+
<script>${JS}</script>
|
|
485
|
+
</body>
|
|
486
|
+
</html>
|
|
487
|
+
`;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
const USAGE = 'usage: render_changes.mjs --brief <file.json> [--out <notebook-dir>] [--base <ref>]\n'
|
|
491
|
+
+ ' render_changes.mjs --hash [--base <ref>]';
|
|
492
|
+
|
|
493
|
+
function parseArgs(argv) {
|
|
494
|
+
const a = {};
|
|
495
|
+
for (let i = 0; i < argv.length; i++) {
|
|
496
|
+
const k = argv[i];
|
|
497
|
+
if (k === '--hash') { a.hash = true; continue; }
|
|
498
|
+
if (k !== '--brief' && k !== '--out' && k !== '--base') return { error: `unknown argument ${k}` };
|
|
499
|
+
const v = argv[++i];
|
|
500
|
+
if (v === undefined) return { error: `${k} needs a value` };
|
|
501
|
+
// A ref starting with "-" would reach git as an option.
|
|
502
|
+
if (k === '--base' && v.startsWith('-')) return { error: `--base ${v}: not a ref` };
|
|
503
|
+
a[k.slice(2)] = v;
|
|
504
|
+
}
|
|
505
|
+
return a;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/** Warn when a report lands where git could pick it up. Exit 1 = not ignored; 128 = outside. */
|
|
509
|
+
function ignored(root, file) {
|
|
510
|
+
try { git(root, ['check-ignore', '-q', '--', file]); return true; } catch (e) { return e.status !== 1; }
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
export function run(argv, cwd = process.cwd()) {
|
|
514
|
+
const fail = (code, msg) => ({ code, out: [], err: [`explain-changes: ${msg}`, ...(code === 2 ? [USAGE] : [])] });
|
|
515
|
+
const a = parseArgs(argv);
|
|
516
|
+
if (a.error) return fail(2, a.error);
|
|
517
|
+
let root;
|
|
518
|
+
try { root = git(cwd, ['rev-parse', '--show-toplevel']).trim(); } catch { return fail(2, 'not inside a git repository'); }
|
|
519
|
+
const d = selectDiff(root, a.base);
|
|
520
|
+
if (!d.text.trim()) return fail(3, 'nothing to explain — the diff is empty');
|
|
521
|
+
const hash = diffHash(d.text);
|
|
522
|
+
if (a.hash) return { code: 0, out: [hash], err: [] };
|
|
523
|
+
if (!a.brief) return fail(2, '--brief <file.json> is required');
|
|
524
|
+
let raw;
|
|
525
|
+
try { raw = JSON.parse(fs.readFileSync(path.resolve(cwd, a.brief), 'utf8')); } catch (e) {
|
|
526
|
+
return fail(2, `cannot read the brief: ${e.message}`);
|
|
527
|
+
}
|
|
528
|
+
const { brief } = shapeBrief(raw);
|
|
529
|
+
if (!brief) return fail(2, 'the brief must be a JSON object');
|
|
530
|
+
const files = parseDiff(d.text);
|
|
531
|
+
markGenerated(root, files);
|
|
532
|
+
const problems = checkBrief(raw, files);
|
|
533
|
+
const branch = gitOr(root, ['rev-parse', '--abbrev-ref', 'HEAD'], 'no-commit');
|
|
534
|
+
const base = gitOr(root, ['rev-parse', '--short', a.base ?? 'HEAD'], 'none');
|
|
535
|
+
const now = new Date().toISOString();
|
|
536
|
+
const html = renderPage({ brief, files, problems,
|
|
537
|
+
meta: { branch, mode: d.mode, hash, base, generated: now.slice(0, 16).replace('T', ' ') } });
|
|
538
|
+
const dir = a.out
|
|
539
|
+
? path.resolve(cwd, a.out, 'changes')
|
|
540
|
+
: path.resolve(root, git(root, ['rev-parse', '--git-dir']).trim(), 'explain-changes');
|
|
541
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
542
|
+
const file = path.join(dir, `${now.slice(0, 10)}-${branch.replace(/[^\w.-]+/g, '-')}-${hash}.html`);
|
|
543
|
+
fs.writeFileSync(file, html);
|
|
544
|
+
const err = [];
|
|
545
|
+
if (a.out && !ignored(root, file)) err.push(`explain-changes: warning — ${file} is not git-ignored, and the report contains code`);
|
|
546
|
+
if (problems.length) {
|
|
547
|
+
err.push(`explain-changes: ${problems.length} narrative mismatch(es) — fix the brief and re-run:`,
|
|
548
|
+
...problems.map((p) => ` - ${p}`));
|
|
549
|
+
}
|
|
550
|
+
return { code: 0, out: [file, `diff ${hash}`], err };
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
|
|
554
|
+
const r = run(process.argv.slice(2));
|
|
555
|
+
if (r.out.length) process.stdout.write(`${r.out.join('\n')}\n`);
|
|
556
|
+
if (r.err.length) process.stderr.write(`${r.err.join('\n')}\n`);
|
|
557
|
+
process.exitCode = r.code;
|
|
558
|
+
}
|
package/src/skills/forge-mcp.md
CHANGED
|
@@ -9,7 +9,7 @@ server — exposing an external API or service to an agent as a set of tools.
|
|
|
9
9
|
depends on it.
|
|
10
10
|
|
|
11
11
|
## Procedure
|
|
12
|
-
1. Research first ({{DOCTRINE}}
|
|
12
|
+
1. Research first ({{DOCTRINE:read-the-docs-first}}): read the MCP specification
|
|
13
13
|
(`modelcontextprotocol.io`) and the target service's API docs, then pick the
|
|
14
14
|
SDK — Python (FastMCP) or TypeScript (MCP SDK) — and load its documentation.
|
|
15
15
|
2. Plan the tool surface: list the service's key operations and design
|
|
@@ -18,15 +18,15 @@ depends on it.
|
|
|
18
18
|
real task with it.
|
|
19
19
|
3. Build the core: an authenticated API client, shared error-handling and
|
|
20
20
|
pagination helpers, and response formatting that returns focused, relevant
|
|
21
|
-
data rather than raw dumps ({{DOCTRINE}}
|
|
21
|
+
data rather than raw dumps ({{DOCTRINE:context-economy}}).
|
|
22
22
|
4. Define each tool's input and output schema (Pydantic or Zod), set the
|
|
23
23
|
annotations (`readOnlyHint`, `destructiveHint`, `idempotentHint`,
|
|
24
24
|
`openWorldHint`), and write error messages that name the specific, actionable
|
|
25
25
|
fix. A destructive tool's guard is enforced server-side, at the boundary the
|
|
26
|
-
call must cross — never left to the calling model's judgement ({{DOCTRINE}}
|
|
26
|
+
call must cross — never left to the calling model's judgement ({{DOCTRINE:external-gate}}).
|
|
27
27
|
5. Review and test: kill duplication, ensure consistent errors and full type
|
|
28
28
|
coverage, then build and exercise every tool with the MCP Inspector — don't
|
|
29
|
-
assume it works ({{LAW}}
|
|
29
|
+
assume it works ({{LAW:verify-before-asserting}}).
|
|
30
30
|
6. Evaluate: pick ~10 realistic, read-only questions that each need multiple tool
|
|
31
31
|
calls and have one verifiable, stable answer; run them and confirm the server
|
|
32
32
|
actually answers them.
|
package/src/skills/fresh-eyes.md
CHANGED
|
@@ -5,12 +5,12 @@
|
|
|
5
5
|
**Trigger:** a task is claimed done and the solving session believes the artifact works — before merge or handoff, or when the user asks to "validate", "fresh eyes", "prove it meets the spec", "did this actually solve it", or "independent sign-off", and the cost of a confidently-wrong "done" is high. This re-judges the *result* from zero context — distinct from [geneseed-code-review](geneseed-code-review.md), which reviews a *diff* with full knowledge of the change. For an open-ended quality critique use [roast-me](roast-me.md); for a multi-stance debate over an undecided question use [council](council.md).
|
|
6
6
|
|
|
7
7
|
## Procedure
|
|
8
|
-
1. Capture the firewall input as TWO things and nothing else: (a) the **verbatim original task/spec** — the raw issue or request text, NOT a solver-written summary or "spec card" (a distilled card launders the solving session's framing into the rubric, the very bias this {{SKILL}} exists to defeat), and (b) a pointer to the **final artifact** as it stands — file paths, built output, or the command to run it. If the raw task is too thin to yield acceptance criteria, have the **user** (never the solver) confirm a one-line acceptance bar, then proceed ({{LAW}}
|
|
8
|
+
1. Capture the firewall input as TWO things and nothing else: (a) the **verbatim original task/spec** — the raw issue or request text, NOT a solver-written summary or "spec card" (a distilled card launders the solving session's framing into the rubric, the very bias this {{SKILL}} exists to defeat), and (b) a pointer to the **final artifact** as it stands — file paths, built output, or the command to run it. If the raw task is too thin to yield acceptance criteria, have the **user** (never the solver) confirm a one-line acceptance bar, then proceed ({{LAW:echo-the-intent}}); if it is too fuzzy even for that, this is a judgement call — convene [council](council.md) instead.
|
|
9
9
|
2. Deliberately WITHHOLD everything else and say so in the brief: the diff and changed-files list, the chat transcript, the solver's rationale and commit messages, prior test output, and every "it works because…" claim. Each is a vector for confirmation bias — they transmit the author's conviction, and a validator that inherits it cannot judge independently ({{ONTOLOGY}}: {{ONT_CONDUCT}}). The validator must re-confront the artifact cold, the way an external recipient would.
|
|
10
|
-
3. Dispatch ONE fresh-context validator whose context starts empty — isolation is structural, not promised — using the [reviewer {{AGENT}}](../{{DIR_AGENTS}}/reviewer.md) (read-only, runs the suite, casts a verdict; NOT the [skeptic](../{{DIR_AGENTS}}/skeptic.md), whose charter forbids running commands and casting verdicts), via the dispatch pattern in [parallel-agents {{SKILL}}](parallel-agents.md), keeping the heavy reading out of the main context ({{DOCTRINE}}
|
|
10
|
+
3. Dispatch ONE fresh-context validator whose context starts empty — isolation is structural, not promised — using the [reviewer {{AGENT}}](../{{DIR_AGENTS}}/reviewer.md) (read-only, runs the suite, casts a verdict; NOT the [skeptic](../{{DIR_AGENTS}}/skeptic.md), whose charter forbids running commands and casting verdicts), via the dispatch pattern in [parallel-agents {{SKILL}}](parallel-agents.md), keeping the heavy reading out of the main context ({{DOCTRINE:context-economy}}); a cheaper model suffices for the validator unless the spec is subtle. Dispatch **fresh** — never resume a prior subagent session, which re-inherits its context and breaches the firewall. Where the host has no subagent capability, degrade: write the brief to a file and have the user run it in a separate fresh process; only as a last resort run the persona in-session and **stamp the verdict "SOFT ISOLATION — context bleed possible"** so the user discounts it. Never silently pretend a persona equals a fresh agent.
|
|
11
11
|
4. Inside the fresh context, FIRST derive the rubric blind: from the task ALONE — before looking at the artifact — write a numbered list of observable acceptance criteria, each a yes/no test with a stated check method (a command to run, an output to see, a behaviour to exercise), tagged must-have or nice-to-have. FREEZE this list. Deriving it inside the firewall, from the task and not the solution, is what prevents criteria-fitting — the rubric cannot be bent to match what the artifact happens to do.
|
|
12
|
-
5. THEN reveal the artifact and rule each criterion against it under an inverted burden of proof: actively check it — run the command, exercise the behaviour, read the named output ({{LAW}}
|
|
13
|
-
6. Return the independent verdict: the per-criterion table, then the overall gate — **SATISFIES** (every must-have PASS, no FAIL), **SATISFIES-WITH-GAPS** (must-haves pass, a nice-to-have fails), or **DOES-NOT-SATISFY** (any must-have FAIL or UNVERIFIABLE), listing the failing criteria with evidence. Report "could not find a fault" as not-proven-false, never as "proven correct". Surface it for the user to decide; the gate advises and applies no changes — write no code, push nothing ({{LAW}}
|
|
12
|
+
5. THEN reveal the artifact and rule each criterion against it under an inverted burden of proof: actively check it — run the command, exercise the behaviour, read the named output ({{LAW:verify-before-asserting}} — verify by running, never assume) — and record `Cn: PASS | FAIL | UNVERIFIABLE — <evidence: the exact output/behaviour checked>`. Default is NOT-PASS: mark PASS only after actively confirming; a criterion that cannot be checked is UNVERIFIABLE (a finding, not a pass — name the missing evidence). Flag any **Extra** the artifact added beyond the spec, but cut gold-plating both ways — never FAIL the artifact for a requirement the task never asked for. The validator does NOT fix, refactor, or polish; it only rules.
|
|
13
|
+
6. Return the independent verdict: the per-criterion table, then the overall gate — **SATISFIES** (every must-have PASS, no FAIL), **SATISFIES-WITH-GAPS** (must-haves pass, a nice-to-have fails), or **DOES-NOT-SATISFY** (any must-have FAIL or UNVERIFIABLE), listing the failing criteria with evidence. Report "could not find a fault" as not-proven-false, never as "proven correct". Surface it for the user to decide; the gate advises and applies no changes — write no code, push nothing ({{LAW:deletion-is-deliberate}}, {{DOCTRINE:consent-before-push}}). On DOES-NOT-SATISFY, hand the failures to the solver (optionally via [review-response](review-response.md)) and re-run fresh-eyes after the fix rather than letting the original context relitigate the ruling.
|
|
14
14
|
|
|
15
15
|
## Done when
|
|
16
16
|
- A fresh context that never saw the solving session derived the acceptance criteria from the original task alone, froze them before seeing the artifact, ruled each PASS/FAIL/UNVERIFIABLE with evidence by checking the live artifact, and returned an overall gate (SATISFIES / SATISFIES-WITH-GAPS / DOES-NOT-SATISFY) for the user to weigh — or, where no true isolation was possible, the verdict is stamped soft-isolation so it is discounted.
|
|
@@ -29,7 +29,7 @@ preview tool) — the second-pass critique in step 6 cannot run without one.
|
|
|
29
29
|
5. Avoid the three tells unless the brief truly calls for them: (a) cream
|
|
30
30
|
`#F4F1EA` + high-contrast serif + terracotta accent; (b) near-black + acid-green
|
|
31
31
|
or vermilion accent; (c) broadsheet hairline rules, zero radius, dense columns.
|
|
32
|
-
6. Second pass — critique against the brief ({{LAW}}
|
|
32
|
+
6. Second pass — critique against the brief ({{LAW:verify-before-asserting}}): screenshot the result
|
|
33
33
|
and read it. If any part reads like the generic default you'd produce for any
|
|
34
34
|
similar page, revise it. Spend boldness in one place and keep the rest quiet;
|
|
35
35
|
remove one accessory. Hold the quality floor without announcing it — responsive,
|
package/src/skills/geneseed.md
CHANGED
|
@@ -5,10 +5,10 @@
|
|
|
5
5
|
**Trigger:** the user mentions Geneseed, the harness, or `AGENT.md`; asks how the install is set up; asks to change its theme, footprint, posture, mode, doctrine packs or excluded rules — or you need to know what {{LAWS}}, {{AGENTS}}, or {{SKILLS}} this install carries.
|
|
6
6
|
|
|
7
7
|
## Procedure
|
|
8
|
-
1. **Locate.** Run `geneseed status --json` first: `installs` lists every install on this machine — host, scope, root, state, theme, footprint, posture, mode, packs, excluded rules — and `rebuild`, the command that reproduces each one exactly. `geneseed` not on PATH → ask the user before using `npx geneseed` (it downloads the package), and prefix every command below the same way. Still nothing → look for a host root file: `~/.config/opencode/AGENT.md` or `.opencode/AGENT.md` (OpenCode), `~/.claude/CLAUDE.md` or `.claude/` (Claude Code), `.openclaude/` (OpenClaude), `AGENTS.md` beside `.bob/`
|
|
9
|
-
2. **Read state** with the read-only verbs, cheapest first ({{DOCTRINE}}
|
|
10
|
-
3. **Read a piece** from disk, not from recall ({{LAW}}
|
|
11
|
-
4. **Change an install only when the user asked for that change** ({{LAW}}
|
|
8
|
+
1. **Locate.** Run `geneseed status --json` first: `installs` lists every install on this machine — host, scope, root, state, theme, footprint, posture, mode, packs, excluded rules — and `rebuild`, the command that reproduces each one exactly. `geneseed` not on PATH → ask the user before using `npx geneseed` (it downloads the package), and prefix every command below the same way. Still nothing → look for a host root file: `~/.config/opencode/AGENT.md` or `.opencode/AGENT.md` (OpenCode), `~/.claude/CLAUDE.md` or `.claude/` (Claude Code), `.openclaude/` (OpenClaude), `AGENTS.md` beside `.bob/` (Bob). None → say so; never install to "fix" it.
|
|
9
|
+
2. **Read state** with the read-only verbs, cheapest first ({{DOCTRINE:context-economy}}): `geneseed status --json`, `geneseed version`, `geneseed catalog` (optionally `agents|skills|laws|ontology|doctrines`), `geneseed doctor`, `geneseed diff`, `geneseed mcp`, `geneseed memory list`, `geneseed exclude list`.
|
|
10
|
+
3. **Read a piece** from disk, not from recall ({{LAW:verify-before-asserting}}): `<root>/agents/<name>.md`, `<root>/skills/<name>/SKILL.md`, `<root>/memory/`, `<root>/notebook/`. The {{ONTOLOGY}}, the {{LAWS}} and the active {{DOCTRINES}} are numbered `## N. …` sections of the install's root file.
|
|
11
|
+
4. **Change an install only when the user asked for that change** ({{LAW:one-intent-one-act}}). Take that install's `rebuild` command from step 1, edit only the flag the user named (add it if the command omits it — `--posture` and `--mode` are left out at their defaults) — `--theme`, `--footprint lean|full`, `--posture`, `--mode direct|foreman`, `--doctrines craft,rigor|none`, `--exclude-rules "process 7"|none` — dry-run it first by running the same flags through `geneseed validate`, show the command, and run it once the user agrees. Never rebuild from a bare `geneseed build`: every omitted flag resets that setting to the generator default. Confirm with `geneseed status`.
|
|
12
12
|
5. **Other writes also need the user's word:** `geneseed memory rm <name>`, `geneseed exclude add|remove <path>`, `geneseed web start|stop`, `geneseed uninstall --target <root> --yes`. Personal rules and the profile belong to their own {{SKILLS}}; do not edit `user-rules.md` or `PROFILE.md` from here.
|
|
13
13
|
6. **Never on your own initiative:** `geneseed setup` (interactive only), `geneseed upgrade`, `geneseed update`, `geneseed bootstrap`, `geneseed sync-self`, `geneseed rebuild-all`, `geneseed migrate`, `geneseed link`, `geneseed unlink`. `learn`, `context` and the `*-gate` verbs are `geneseed-hook` verbs the host's hooks run — never run them by hand.
|
|
14
14
|
7. Directory and section names are plain English in every theme; only prose changes. Do not search by flavour words.
|