app
Adom Gantt
Public Made by Adomby adom
Interactive Gantt chart viewer for project timelines, with a table view and an analytics page over the same data. Data-driven, dark by default with a print-friendly light mode, and fully editable in the browser: drag bars to move or resize, multi-select and batch-shift dates, manage dependency arrows inline, reorder rows, filter by phase, and undo. One click auto-schedules the whole plan so every dependency lines up finish-to-start on working days, skipping weekends and US federal holidays, push
← Commit history
Publish 0.35.0
4 files changed
+243−14
docs/BUILD-NOTES.md+58@@ -3,6 +3,64 @@ Decisions taken while building, and the places where the plan and the code disagreed. Newest first. +## Build notes, 2026-08-17 (0.35.0, the changelog is now tamper-evident — a hash chain catches out-of-band edits)++The HTTP door stops a stranger from POSTing to `/log`, but it cannot stop a trusted+local process — an agent, a stray script — from opening the `-changelog.jsonl` and+editing a line in place. You cannot cryptographically PREVENT a write to a file the+process owns. So this build does the next best thing: it makes an out-of-band write+**detectable**, loudly, everywhere the plan is read. (This is the approved "Option C".+The deferred "Option A" — a service token that routes agent writes back through the+gate — is NOT built here.)++**A hash chain over the append-only log, laid down without touching a single existing+line.** Some deployments already have thousands of events and none of them carry a+hash. Rewriting them to insert hashes would itself be the destructive act the feature+exists to catch, so genesis is **appended, never applied**. On the first startup+against a log that has history, the server appends one `chain.genesis` event:+`{ action:"chain.genesis", via:"chain-init", actor:"adom-gantt integrity", rev, ts,+digest, h }`. `digest` is `sha256` over the canonical concatenation (`lines.join("\n")`)+of every pre-genesis line exactly as it sits on disk — the whole prior history+fingerprinted as one baseline — and `h = sha256(digest)` is the chain head. Every event+appended after genesis carries `h = sha256(prevH + canon(event-without-h))`, where+`canon` is a stable JSON serialisation with keys sorted at every level and the event's+own top-level `h` removed.++**One chokepoint.** `appendLog()` is the single path every mutation already funnels+through — `POST /log`, compact, import, the startup uid mint, the sidecar upgrade — so+attaching `h` there means nothing appended by the server is ever unchained, and no new+code path had to learn about the chain. `ensureGenesis()` runs first inside it (and+once at startup via `ensureGenesisIfHistory()`), so the marker is always present before+a chained event lands. It is idempotent: it looks for the genesis marker and appends+nothing if one is already there, so a restart never double-appends. An empty log is+left alone at startup and gets its genesis lazily on the first real append (its+pre-genesis digest is `sha256("")`).++**Verify on read and at startup.** `verifyChain()` re-derives the pre-genesis digest+from the current lines (catching any edit at or before genesis), checks `h ===+sha256(digest)`, then walks each post-genesis event recomputing `sha256(prevH ++canon(e))` against the stored `h`. The first mismatch is the break. It surfaces three+ways: a loud `INTEGRITY FAILURE` server log naming the first bad `rev`; an `integrity`+object on `GET /state` (`{ok:false, brokenAtRev:N, reason:"..."}`, or `{ok:true}` clean);+and the same object on a new public `GET /health`, which keeps a strict no-leak posture+— counts, revs and booleans only, never event contents — so a health probe reachable+from anywhere still cannot read the plan. Verify is memoised on the log's size+mtime, so+the frequently-polled `/state` only recomputes when the file actually changes, and the+scary log fires once per change rather than once per request.++**The consequence, which is the point, not a bug.** Once this is live, a direct+out-of-band edit to the changelog — an agent rewriting a line without going through the+server — breaks the chain and shows `integrity.ok:false` with the offending rev. That is+exactly the intended nudge toward routing agent writes through the server (the deferred+Option A). Pre-genesis history — everything up to and including the tail at the moment+the server first runs this version — is captured whole in the genesis digest, so it will+**not** falsely flag; only writes made *after* genesis and *outside* the server's append+path light up. A fixture that gave rows numeric uids instead of the real zero-padded+strings taught the one gotcha worth recording: `isUid()` only accepts strings, so a+numeric `uid` looks unassigned and triggers a remint (and thus an extra, correctly+chained, `uid.assign` at startup) — fixtures must mirror the real `"00001"` shape and+carry `project.nextUid` to sit in the same steady state a live plan is in.+ ## Build notes, 2026-08-17 (0.34.0, milestones are phase-scoped, caps track the top, panes lock, the name text is its own hit target) Four changes, all in the same scroll and geometry subsystem, shipped together.
package.json+1−1@@ -2,7 +2,7 @@ "slug": "adom-gantt", "title": "Adom Gantt", "type": "app",- "version": "0.34.0",+ "version": "0.35.0", "description": "Interactive Gantt chart viewer for project timelines, with a table view over the same data. Data-driven, dark by default with a print-friendly light mode, and fully editable in the browser: drag bars to move or resize, multi-select and batch-shift dates, manage dependency arrows inline, reorder rows, filter by phase, and undo. Toggle to the table for every task and milestone as a row with owner, deadline and status, filtered per owner and edited in place. Every edit is recorded in an append-only changelog so an AI can read what a human changed and build on it. Imports JSON, CSV, MS Project XML and Jira CSV; exports JSON, CSV, SVG and a self-contained HTML snapshot.", "main": "server.js", "bin": {
page.json+1−1@@ -2,7 +2,7 @@ "slug": "adom-gantt", "title": "Adom Gantt", "type": "app",- "version": "0.34.0",+ "version": "0.35.0", "description": "Interactive Gantt chart viewer for project timelines, with a table view over the same data. Data-driven, dark by default with a print-friendly light mode, and fully editable in the browser: drag bars to move or resize, multi-select and batch-shift dates, manage dependency arrows inline, reorder rows, filter by phase, and undo. Toggle to the table for every task and milestone as a row with owner, deadline and status, filtered per owner and edited in place. Every edit is recorded in an append-only changelog so an AI can read what a human changed and build on it. Imports JSON, CSV, MS Project XML and Jira CSV; exports JSON, CSV, SVG and a self-contained HTML snapshot.", "main": "server.js", "bin": {
server.js+183−12@@ -319,18 +319,153 @@ function currentRev() { return log.length ? log[log.length - 1].rev : 0; } +// ── Tamper-evidence: a hash chain over the append-only changelog ──+//+// The HTTP door stops a stranger POSTing to /log, but it cannot stop a trusted+// local process (an agent, a stray script) from opening the .jsonl and editing a+// line in place. We cannot cryptographically PREVENT a write to a file the process+// owns, so instead we make an out-of-band write DETECTABLE.+//+// One `chain.genesis` event is APPENDED (never a rewrite) the first time the server+// runs against a log that has history. It fingerprints all prior lines as one+// baseline `digest`, and its `h` is the chain head. Every event appended after it+// carries `h = sha256(prevH + canon(event-without-h))`. Recomputing from genesis+// catches any post-genesis line that was altered, inserted, or deleted, and the+// baseline digest catches any change to the pre-genesis history. See BUILD-NOTES.++function sha256hex(s) { return crypto.createHash('sha256').update(s, 'utf8').digest('hex'); }++// Stable JSON: keys sorted at every level, so canon(event) is byte-identical no+// matter what order the fields were written in. Arrays keep their order.+function stableStringify(v) {+ if (v === null || typeof v !== 'object') return JSON.stringify(v);+ if (Array.isArray(v)) return '[' + v.map(stableStringify).join(',') + ']';+ const keys = Object.keys(v).sort();+ return '{' + keys.map(k => JSON.stringify(k) + ':' + stableStringify(v[k])).join(',') + '}';+}+// The event minus its own `h`, canonically serialised. Only the top-level `h` is+// dropped: a payload is free to carry a nested field named "h".+function canonEvent(row) {+ const rest = {};+ for (const k of Object.keys(row)) if (k !== 'h') rest[k] = row[k];+ return stableStringify(rest);+}+function chainHashEvent(prevH, row) { return sha256hex((prevH || '') + canonEvent(row)); }++// The exact lines as they sit on disk. filter(Boolean) drops the trailing empty+// string from the final newline; every real line is non-empty JSON.+function rawLogLines() {+ if (!fs.existsSync(LOG_FILE)) return [];+ return fs.readFileSync(LOG_FILE, 'utf8').split('\n').filter(Boolean);+}+function isGenesis(e) { return e && e.action === 'chain.genesis' && e.via === 'chain-init'; }+function genesisIndex(parsed) { return parsed.findIndex(isGenesis); }+// The `h` of the last event that has one — the head to chain the next append onto.+function chainHeadH(parsed) {+ for (let i = parsed.length - 1; i >= 0; i--) if (parsed[i] && typeof parsed[i].h === 'string') return parsed[i].h;+ return null;+}++// Lay down the single genesis marker, once, if the chain has not been initialised.+// Non-destructive: it only ever APPENDS. Returns true if it wrote genesis.+function ensureGenesis() {+ const lines = rawLogLines();+ const parsed = lines.map(l => { try { return JSON.parse(l); } catch { return null; } });+ if (genesisIndex(parsed) >= 0) return false; // chain already established+ const digest = sha256hex(lines.join('\n')); // fingerprint of ALL prior lines+ const last = parsed.length ? parsed[parsed.length - 1] : null;+ const rev = (last && typeof last.rev === 'number' ? last.rev : parsed.length) + 1;+ const genesis = {+ rev, ts: new Date().toISOString(), actor: 'adom-gantt integrity',+ action: 'chain.genesis', via: 'chain-init', digest, h: sha256hex(digest),+ };+ fs.appendFileSync(LOG_FILE, JSON.stringify(genesis) + '\n', 'utf8');+ return true;+}+// At startup, only fingerprint a log that actually has history. An empty log has+// nothing to baseline yet; its genesis is laid down lazily by the first append.+function ensureGenesisIfHistory() {+ if (rawLogLines().length === 0) return false;+ return ensureGenesis();+}++// Walk the chain and report the first break. Cheap and total: any error is caught+// and surfaced as not-ok rather than thrown.+function verifyChain() {+ const lines = rawLogLines();+ if (lines.length === 0) return { ok: true };+ const parsed = lines.map(l => { try { return JSON.parse(l); } catch { return null; } });+ const gIdx = genesisIndex(parsed);+ // No genesis yet: the chain is not initialised, which is not the same as tampered.+ if (gIdx < 0) return { ok: true, chained: false };+ const g = parsed[gIdx];+ const digest = sha256hex(lines.slice(0, gIdx).join('\n'));+ if (digest !== g.digest) {+ return { ok: false, brokenAtRev: g.rev, reason: 'pre-genesis history was changed (baseline digest mismatch)' };+ }+ if (g.h !== sha256hex(g.digest)) {+ return { ok: false, brokenAtRev: g.rev, reason: 'genesis hash does not match its digest' };+ }+ let prevH = g.h;+ for (let i = gIdx + 1; i < parsed.length; i++) {+ const e = parsed[i];+ if (!e || typeof e !== 'object') {+ return { ok: false, brokenAtRev: (e && e.rev) || null, reason: 'unparseable event after genesis' };+ }+ if (typeof e.h !== 'string') {+ return { ok: false, brokenAtRev: e.rev, reason: 'event has no chain hash (appended outside the server)' };+ }+ if (e.h !== chainHashEvent(prevH, e)) {+ return { ok: false, brokenAtRev: e.rev, reason: 'event hash mismatch (altered, inserted or reordered outside the server)' };+ }+ prevH = e.h;+ }+ return { ok: true, chained: true };+}++// Verify is O(log) sha256; the handoff loop hits /state often, so memoise on the+// file's size+mtime and only recompute when the log actually changes. The loud log+// line fires on each recompute that finds a break (once per change, not per request).+let _integrityCache = { key: null, result: null };+function integrityStatus() {+ try {+ const st = fs.existsSync(LOG_FILE) ? fs.statSync(LOG_FILE) : null;+ const key = st ? st.size + ':' + st.mtimeMs : 'none';+ if (_integrityCache.key === key && _integrityCache.result) return _integrityCache.result;+ const result = verifyChain();+ _integrityCache = { key, result };+ if (!result.ok) {+ console.error(`adom-gantt: INTEGRITY FAILURE — changelog hash chain broken at rev ${result.brokenAtRev}: ${result.reason}. A line was written outside the server's append path.`);+ }+ return result;+ } catch (e) {+ return { ok: false, brokenAtRev: null, reason: 'integrity check error: ' + e.message };+ }+}+ function appendLog(events, actor) {- let rev = currentRev();+ // The chokepoint. Every mutation (POST /log, compact, import, uid, sidecar+ // upgrade) lands here, so genesis is guaranteed present and every appended event+ // is chained before it touches disk.+ ensureGenesis();+ const parsed = readLog();+ let rev = parsed.length ? parsed[parsed.length - 1].rev : 0;+ let prevH = chainHeadH(parsed); const ts = new Date().toISOString(); // The server's own fields go on LAST. Spreading the caller's event last let a // posted `rev` overwrite the counter, which breaks ?since= monotonicity for // every reader after it.- const rows = events.map(e => ({- ...e,- rev: ++rev,- ts: e.ts || ts,- actor: actor || e.actor || 'user',- }));+ const rows = events.map(e => {+ const row = {+ ...e,+ rev: ++rev,+ ts: e.ts || ts,+ actor: actor || e.actor || 'user',+ };+ row.h = chainHashEvent(prevH, row); // h last, over everything else+ prevH = row.h;+ return row;+ }); fs.appendFileSync(LOG_FILE, rows.map(r => JSON.stringify(r)).join('\n') + '\n', 'utf8'); return { rev, added: rows.length, rows }; }@@ -1827,6 +1962,24 @@ const server = http.createServer(async (req, res) => { return; } + // ── Health / integrity (public, no-leak) ──+ //+ // Reachable without a session so a health check works from anywhere, and it+ // leaks nothing: counts and booleans only, never event contents. The integrity+ // object is the loud channel for a broken changelog hash chain — a line written+ // outside the server's append path shows up here as ok:false with the first bad+ // rev. On a clean log it is {ok:true}.+ if (urlPath === '/health') {+ const log = readLog();+ jsonResp(res, 200, {+ ok: true,+ rev: log.length ? log[log.length - 1].rev : 0,+ events: log.length,+ integrity: integrityStatus(),+ });+ return;+ }+ // ── The door ── // // Everything below this line is either the plan or something that changes it.@@ -1968,15 +2121,21 @@ const server = http.createServer(async (req, res) => { // belt and braces for a sidecar dropped in underneath a running server: the // client is v4-only on purpose, and the one translator lives on this side. if (urlPath === '/state') {+ const integrity = integrityStatus(); if (fs.existsSync(STATE_FILE)) { let out; try { out = upgradeState(loadState(), loadData()); } catch (e) { out = null; }- if (out) { jsonResp(res, 200, out); return; }- res.writeHead(200, { 'Content-Type': 'application/json' });- res.end(fs.readFileSync(STATE_FILE, 'utf8'));+ if (out) { out.integrity = integrity; jsonResp(res, 200, out); return; }+ // Fallback: the raw sidecar, with the integrity object merged in so a+ // reader gets the tamper signal whichever branch served the state.+ let raw;+ try { raw = JSON.parse(fs.readFileSync(STATE_FILE, 'utf8')); }+ catch (e) { raw = {}; }+ raw.integrity = integrity;+ jsonResp(res, 200, raw); } else {- jsonResp(res, 200, { empty: true });+ jsonResp(res, 200, { empty: true, integrity }); } return; }@@ -2185,6 +2344,14 @@ process.on('uncaughtException', err => { if (SESSION_FILE) loadSessions(); +// Tamper-evidence, before any startup append: fingerprint the existing history as+// the genesis baseline FIRST, so a real prior log becomes the pre-genesis digest+// and any startup-time append (a uid mint, a sidecar upgrade) is a properly chained+// post-genesis event rather than part of the baseline. Idempotent: a restart finds+// the genesis marker and appends nothing. An empty log gets its genesis lazily on+// the first real append.+ensureGenesisIfHistory();+ // Before the door opens, not after: the first request served must already see a // plan where every row has a durable id, or the copy button hands out slugs for // as long as it takes the first person to load the page.@@ -2197,7 +2364,11 @@ server.listen(PORT, '127.0.0.1', () => { console.log(`adom-gantt serving ${path.basename(DATA_FILE)} on http://127.0.0.1:${PORT}`); console.log(`State file: ${STATE_FILE}`); console.log(`Data dir: ${DATA_DIR}`);- console.log(`Endpoints: /data /state /save /uid /uid/backfill /compact /import/{json,csv,msproject-xml,jira-csv} /export/{json,csv,svg,html}`);+ console.log(`Endpoints: /health /data /state /save /uid /uid/backfill /compact /import/{json,csv,msproject-xml,jira-csv} /export/{json,csv,svg,html}`);+ const integ = integrityStatus();+ console.log(integ.ok+ ? `Changelog integrity: OK (hash chain verified${integ.chained ? '' : ', not yet chained'})`+ : `Changelog integrity: TAMPER — chain broken at rev ${integ.brokenAtRev}: ${integ.reason}`); console.log(AUTH_OFF ? 'Auth: OFF (--no-auth). Anyone who can reach this port can read and change the plan.' : 'Auth: Sign in with Adom required. Direct-loopback GETs of /data /state /changelog /export/* stay open for the AI handoff loop.');