← Commit history

Publish 0.51.0

Drew Owens ·7009126c62 · ·parent 9bc2101
6 files changed +404−17
README.md+54
@@ -214,6 +214,55 @@ already running on loopback in the container can read the data file directly.  Run with `--no-auth` to turn all of this off for a single-user local session. +### Letting an AI agent write, without breaking the history++Reading is open over loopback, but writing needs a signed-in person, and an agent has no+browser to sign in with. That left an agent asked to change a plan with one option:+writing the data file and the changelog directly. The changelog's hash chain catches+exactly that and reports it on `/health` as `integrity.ok:false`, so an agent edit was+either indistinguishable from tampering or it never happened.++The **agent token** is the door for it instead. At startup the server writes 32 random+bytes, hex, to a `0600` file beside the plan, and reuses that file forever after:++```+dc-demo.json                 the plan+dc-demo-state.json           the sidecar+dc-demo-changelog.jsonl      the history+dc-demo-agent-token          the agent's key       <- 0600+```++The startup log prints the path, never the token. Send it as a bearer token and the+request is allowed the same mutations a signed-in person gets:++```bash+TOK=$(cat /path/to/dc-demo-agent-token)+curl -s -X POST http://127.0.0.1:8901/log \+  -H "Authorization: Bearer $TOK" -H 'Content-Type: application/json' \+  -d '{"events":[{"action":"note","id":"log-ship","to":{"note":"venue confirmed"}}]}'+```++What that buys you is an **audit trail instead of a mystery**. The write goes through the+same handler, the same append path and the same hash chain as everybody else's, so+integrity stays `ok:true`, and every event it produces is recorded under the actor+`adom-gantt agent`. The changelog then reads:++```+adom-gantt agent labelled "Ship the workcells to DC" "venue confirmed"+```++The name is forced. An `actor` in the request body is ignored the same way a person's+would be, so an agent edit can never be read back as somebody's own.++The token does **not** hand out a session or a cookie, and it does **not** open the app+page: `GET /` with a token returns the sign-in page exactly as it does with nothing,+because the token is an API credential and not a seat in the app. A missing or wrong+token is a 401 with the same body a request with no session gets.++This is not a secret from anything already running in the container, and does not pretend+to be: a local process can read the plan and the token alike. Its job is to make agent+edits **auditable** rather than invisible.+ ### Known limitation: two people editing at once  The save model is whole-state, and it is **last-write-wins**. If two teammates have@@ -277,8 +326,13 @@ table, the item editor or the batch editor are taken exactly as typed.  ## Server endpoints +Everything below the door needs a signed-in session, or an `Authorization: Bearer+<agent-token>` header. GETs of `/data`, `/state`, `/changelog` and `/export/*` over direct+loopback are open. `/health` is public and leaks nothing but counts.+ | Endpoint | Method | Description | |----------|--------|-------------|+| `/health` | GET | Public. Rev, event count, and the changelog's `integrity` object | | `/data` | GET | Current project data | | `/state` | GET | Saved edits (sidecar) | | `/save` | POST | Persist edits |
SKILL.md+62−2
@@ -457,16 +457,76 @@ When you change the plan yourself, record it so the next reader can tell your wo theirs:  ```bash-curl -s -X POST localhost:8901/log -H 'Content-Type: application/json' \-  -d '{"actor":"ai","events":[{"action":"dates","id":"dvt","name":"DVT build",+curl -s -X POST localhost:8901/log \+  -H "Authorization: Bearer $(cat <datafile>-agent-token)" \+  -H 'Content-Type: application/json' \+  -d '{"events":[{"action":"dates","id":"dvt","name":"DVT build",        "from":{"start":"2026-07-01","end":"2026-08-15"},        "to":{"start":"2026-07-15","end":"2026-08-29"},        "shift":{"start":14,"end":14},"via":"agent-replan"}]}' ``` +The header is the agent token; see the next section. Without it the server is+session-gated and this call is a 401. The actor is stamped for you.+ The log is append-only and never rewritten. `POST /changelog/reset` deletes it outright, so only run it when the user asks for a clean slate. +## Writing to a live plan: use the agent token, never the files++**Never edit `<datafile>-changelog.jsonl` by hand, and never write the data file or the+sidecar behind a running server.** The changelog is a hash chain. A line written outside+the server's append path is detected and reported on `/health` as+`"integrity": {"ok": false, "brokenAtRev": N}`, and it is indistinguishable from+tampering, because from the file's point of view that is what it is.++A plan you can only read is a plan you edit through the API, and the API's mutations need+a signed-in person. You are not one and cannot become one. So the server keeps a token for+you, in a `0600` file beside the plan, created at startup and reused forever after:++```bash+TOK=$(cat ~/project/dc-demo-gantt/dc-demo-agent-token)   # <datafile>-agent-token+```++Send it as a bearer token on any mutation and you are allowed exactly what a signed-in+person is allowed, through exactly the same handlers:++```bash+# read first, always+curl -s localhost:8901/export/json > /tmp/before.json+curl -s localhost:8901/health          # note stateRev, and integrity.ok must be true++# then write, as yourself+curl -s -X POST localhost:8901/log -H "Authorization: Bearer $TOK" \+  -H 'Content-Type: application/json' \+  -d '{"events":[{"action":"note","id":"log-ship","uid":"00120",+       "name":"Ship the workcells to DC","from":{"note":""},+       "to":{"note":"Venue confirmed: Hart SH-216"},"via":"agent"}]}'+```++`/save`, `/log`, `/uid`, `/uid/backfill`, `/compact` and the four imports all take it.++Four things to know about it:++* **Your name is forced.** Every event a token-authed request produces is recorded with+  the actor `adom-gantt agent`. An `actor` in your body is ignored, exactly as a person's+  would be. This is the point of the token: a reader of the history can always tell your+  work from theirs.+* **The chain stays whole.** Your write goes through the same append path, so+  `/health` keeps saying `integrity.ok: true`. Check it after you write.+* **It is not a session.** No cookie comes back, and it does not open the app page: a+  `GET /` with the token returns the sign-in page, the same as with nothing. It is an API+  credential.+* **A wrong or missing token is a 401.** If you get one, read the startup log or look+  for the `-agent-token` file rather than reaching for the data file.++**The safe write loop.** Capture `/export/json` and `/health` first. Locate items by+`uid`, and check the name still matches what you were told before you write, because the+user edits the same plan live and a uid whose name has changed is a decision you have not+read yet. Send `baseRev` on `/save` so a plan that moved under you comes back 409 instead+of erasing somebody. Then re-read `/export/json` and confirm both that your change landed+and that nothing else moved.+ ## Dates are required  A task needs a `start` and an `end`; a milestone needs a `date`, each looking like
docs/BUILD-NOTES.md+173
@@ -3,6 +3,179 @@ Decisions taken while building, and the places where the plan and the code disagreed. Newest first. +## Build notes, 2026-08-21 (0.51.0, the agent comes through the door)++One change, server-side. `server.js` only. `gantt.html` did not move a byte this round:+nothing about the client, the sidecar's shape or the save protocol changed.++### The hole this closes++Since 0.21.0 every mutation has sat behind Sign in with Adom. That is right for a person+and impossible for an agent: the sign-in flow ends in a browser tab where somebody+approves an intent, and an agent has no browser and nobody to be.++So an agent that was asked to change a plan did the only thing left to it and wrote the+files directly: the data file, the sidecar, and worst of all the changelog. 0.35.0 made+that visible. The hash chain fingerprints the history and chains every appended event, so+a line written outside the server's append path shows up on `/health` as+`integrity.ok:false` with the rev it broke at. Which means that from 0.35.0 an agent edit+was one of exactly two things:++* a direct file write, which broke integrity and looked identical to tampering, or+* nothing at all, because the agent correctly refused to break it.++The detector worked perfectly and left the legitimate writer with nowhere to go. This+round gives that writer a key to the same door everyone else uses.++### What the token is++One random 32 bytes, hex, in a file beside the plan:++```+<data-file>.json        the plan+<data-file>-state.json  the sidecar+<data-file>-changelog.jsonl+<data-file>-sessions.json+<data-file>-agent-token     <- new, 0600+```++The naming follows the sidecars that were already there, so a plan's files stay one+`ls` away from each other and a backup that globs the basename picks the token up with+the rest.++It is created once, at startup, before the door opens, and reused for the life of the+plan. A restart finds it and says nothing new. The startup log prints the PATH and never+the token:++```+Agent token: /path/to/dc-demo-agent-token (0600). Send it as+"Authorization: Bearer <token>" to write through the gate as "adom-gantt agent".+```++That line is read over shoulders and pasted into issues, and what the reader needs from+it is where to look.++**A malformed token file is replaced, not accepted.** A file that does not hold at least+64 hex characters is a broken credential rather than a weak one: an `echo` into the wrong+path would otherwise lock the agent out silently and permanently. It is overwritten with+a real token and the replacement is logged. **A loosened one is tightened**: the mode is+re-asserted to 0600 on every boot, because `writeFileSync`'s `mode` only applies to a+file it creates.++**Failure is never fatal.** A plan whose token could not be written still serves every+person on it; what is lost is the agent's door, and the startup line says so.++### What it grants, and what it does not++A request carrying `Authorization: Bearer <token>` is the AGENT. It gets exactly the+mutations a signed-in person gets, through exactly the same handlers: `/save`, `/log`,+`/uid`, `/uid/backfill`, `/compact`, the four imports, and the reads besides.++It does not get:++* **a session or a cookie.** Nothing is stored, nothing is set on the way out, and the+  token is presented again on the next request. There is no state to steal or expire.+* **the app page.** `GET /` and `GET /gantt.html` with a token return the sign-in page,+  exactly as they do with nothing. The token is an API credential, not a seat in the+  app, and who may open the page is precisely what it was before the token existed.++An Authorization header **decides the request on its own**. Right token, or 401. It never+falls through to the cookie or to the open loopback reads, because a wrong token quietly+demoting itself to an anonymous read is how a broken agent goes on looking like a working+one for a week. An invalid token is a 401 with the same body a request with no session+gets, so nothing about the response says whether a token exists to be guessed.++The comparison is constant time. `timingSafeEqual` throws on a length mismatch and+throwing IS a length oracle, so both sides are hashed to a fixed 32 bytes first and the+digests are what get compared.++This is not a secret from anything already running in this container. A local process can+read the plan and the token alike, and there is nothing here to escalate to. It exists so+that agent edits are AUDITABLE: through the same gate, in the same order, in the same+chain as everyone else's.++### Attribution, which is the whole point++Every changelog event a token-authed request produces is stamped `adom-gantt agent`.+Never a human name, and never anything the request asked for: the `actor` in a `/log`+body and the `actor` on an individual event are both ignored, the same way a session's+are. That rule already existed for people ("the actor is taken from the session, never+from the body") and this is the same rule with the agent in it.++The three call sites that hard-coded an actor now take one:++| Path | Was | Under a token |+|---|---|---|+| `POST /log` | the session's display name | `adom-gantt agent` |+| `POST /compact` | `user` | `adom-gantt agent` |+| `POST /import/*` | `user` | `adom-gantt agent` |+| `POST /uid/backfill` | `adom-gantt` | `adom-gantt agent` |++The server's own startup mint stays `adom-gantt`. That is the server, not the agent, and+conflating the two would put the agent's name on work it never asked for. v51's F13 pins+it.++**No new event shapes.** A token-authed write emits the same actions with the same+fields, so `describeEvent` renders them through the branches it already had, with the new+name in the `${who}` slot it already reads from `e.actor`. Nothing in the markdown+changelog needed a line.++**The chain is untouched.** Token-authed writes go through `appendLog` like every other+write, which means genesis, the rev counter and `h` are all computed the same way in the+same place. Integrity stays `ok:true` across them, and v51 asserts it after every group.++### The suite++`g51/v51.mjs`, 64 assertions, 0 fail. Server-only, so it runs no browser and drives real+HTTP against a real server on its own fixture and its own port (9031).++It runs with auth **ON**, unlike every browser bench, because half of what is being+tested is the 401. The session half is exercised through a sessions file seeded before+boot, which is the same file the server writes for itself, so no part of the test reaches+Carbon.++* **A** the token file: created, 0600, at least 32 bytes of hex, its path logged exactly+  once, and the token itself never logged.+* **B** reuse: the same token across a restart; a loosened file tightened back to 0600+  without changing the token; a garbage file replaced with a real one and said out loud;+  no second mint on an ordinary restart.+* **C** the door: `/save`, `/log`, `/uid`, `/uid/backfill` with nothing are 401; a wrong+  token, a one-character-off token, a truncated token and an empty Bearer are all 401;+  the token is not accepted as a query parameter; a wrong token on `GET /state` is 401+  rather than falling through to the loopback read; the loopback read with no header+  still works.+* **D** writing: `/uid`, `/save` and `/log` all 200 with the token; the saved override is+  really in `/export/json`; both a body-level and a per-event `actor` are overridden; no+  human name survives anywhere in the rows; every row carries a 64-character `h`;+  `/health` reports `integrity.ok:true, chained:true` afterwards; the rev advanced; and+  the events render in the markdown changelog under the agent's name.+* **E** the session half: a signed-in person still gets the app page and still writes+  under their own display name; the token does NOT open the app page and gets the same+  sign-in page a stranger does; no `Set-Cookie` on any token-authed response.+* **F** the rest: `/compact` and `/uid/backfill` under the token, both attributed to the+  agent, integrity intact after each, and one final sweep asserting the agent's whole+  trail is exactly `compact, note, rename, uid.assign` with no other actor on any of them.++### Verification++- **v51**: 64 pass, 0 fail.+- **The whole standing battery**, green on the source tree and on the installed build.+  The client is untouched this round, so the battery is running as a compatibility proof:+  every browser suite drives the 0.50.0 client against the 0.51.0 server.+- **A/B against 0.50.0** (`g51/abrun.sh`), the 0.50.0 recording script and the 0.50.0+  fixture, unchanged, on two copies and two ports: **BYTE IDENTICAL**, same md5.+- **`mssel` + `msphase`**, the two fixture-coupled legacy suites, against 0.50.0 and+  0.51.0 on separate fixtures and ports (`g51/abms51.sh`): **BYTE IDENTICAL**, same md5.++### The stale SKILL.md description, cleared++0.50.0 shipped a tarball whose SKILL.md frontmatter still carried 0.49.0's sentence about+the critical path, recorded under "Known drift, 0.50.0" below. Releases are immutable, so+it could only be cleared by a version bump. 0.51.0 carries the corrected sentence, and a+fresh `pkg install` now delivers it.++---+ ## Build notes, 2026-08-20 (0.50.0, no resize edges in dependency mode, batch delete, Critical is a toggle)  Three changes from Drew, all client-side. `gantt.html` only. `server.js` did not move:
package.json+1−1
@@ -2,7 +2,7 @@   "slug": "adom-gantt",   "title": "Adom Gantt",   "type": "app",-  "version": "0.50.0",+  "version": "0.51.0",   "description": "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, and one more lights up the critical path, meaning the longest run of work inside every section at once. Toggle to the table for every task and milestone as a row with owner, deadline and status, filtered per owner and edited in place, or to analytics for total, remaining and average hours, one row per person carrying both their hours and their status counts, per-section totals, milestones, dependency stats and what is overdue. 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.50.0",+  "version": "0.51.0",   "description": "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, and one more lights up the critical path, meaning the longest run of work inside every section at once. Toggle to the table for every task and milestone as a row with owner, deadline and status, filtered per owner and edited in place, or to analytics for total, remaining and average hours, one row per person carrying both their hours and their status counts, per-section totals, milestones, dependency stats and what is overdue. 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+113−13
@@ -83,6 +83,75 @@ function setCookie(req, res, sid) {   res.setHeader('Set-Cookie', bits.join('; ')); } +// ── The agent token ──+//+// Every mutation sits behind Sign in with Adom, which is right for a person and+// useless for an agent: there is no browser for it to approve an intent in. So the+// agent did the only thing left to it and wrote the data file and the changelog+// directly, which is exactly what the 0.35.0 hash chain exists to catch. An agent+// edit was therefore either an integrity failure or a change with no history at all.+//+// This is the door an agent is meant to come through instead. One random 32-byte+// token in a 0600 file beside the plan, generated once and reused for the life of+// the plan, granting exactly the mutations a signed-in person gets and nothing more.+// It creates no session and sets no cookie, and it does not change who may load the+// app page: it is an API credential, not a seat in the app.+//+// Everything it writes goes through appendLog like any other write, so the chain+// stays valid and /health keeps saying integrity ok. Every event it produces is+// stamped "adom-gantt agent" whatever the request body claims, so an agent's edit+// can never be read back as a person's.+//+// It is not a secret from anything already running in this container: a local+// process can read the plan and the token alike. It exists so that agent edits are+// AUDITABLE, arriving through the same gate, in the same order, in the same chain.+const AGENT_TOKEN_FILE = DATA_FILE.replace(/\.json$/, '-agent-token');+const AGENT_ACTOR = 'adom-gantt agent';+let AGENT_TOKEN = null;++function ensureAgentToken() {+  try {+    if (fs.existsSync(AGENT_TOKEN_FILE)) {+      const raw = fs.readFileSync(AGENT_TOKEN_FILE, 'utf8').trim();+      // Reused as-is when it is a real token. A short or malformed file is a broken+      // credential rather than a weak one, and quietly accepting it would mean an+      // agent could be locked out by a stray echo into the file with no way to tell.+      if (/^[0-9a-f]{64,}$/.test(raw)) {+        AGENT_TOKEN = raw;+        try { fs.chmodSync(AGENT_TOKEN_FILE, 0o600); } catch (e) {}+        return;+      }+      console.error('adom-gantt: the agent token file held no usable token, so a new one was written over it');+    }+    const tok = crypto.randomBytes(32).toString('hex');+    fs.writeFileSync(AGENT_TOKEN_FILE, tok + '\n', { encoding: 'utf8', mode: 0o600 });+    fs.chmodSync(AGENT_TOKEN_FILE, 0o600);+    AGENT_TOKEN = tok;+  } catch (e) {+    // Never fatal. A plan that could not get a token still serves every person on+    // it; what is lost is the agent's door, and the startup log says so.+    AGENT_TOKEN = null;+    console.error('adom-gantt: could not create the agent token file:', e.message);+  }+}++function bearerOf(req) {+  const raw = String(req.headers.authorization || '').trim();+  const m = /^Bearer\s+(.*)$/i.exec(raw);+  return m ? m[1].trim() : null;+}++// Present-but-wrong has to cost the same as present-and-right, so the comparison is+// constant time. timingSafeEqual throws on a length mismatch, and throwing IS a+// length oracle, so both sides are hashed to a fixed 32 bytes first and the two+// digests are what get compared.+function agentTokenOk(presented) {+  if (!AGENT_TOKEN || !presented) return false;+  const a = crypto.createHash('sha256').update(String(presented), 'utf8').digest();+  const b = crypto.createHash('sha256').update(AGENT_TOKEN, 'utf8').digest();+  return crypto.timingSafeEqual(a, b);+}+ // Is this the container talking to itself, or is it someone out on the internet? // The socket alone cannot answer it: the workspace proxy runs inside this same // container and forwards to 127.0.0.1, so a browser on the public URL arrives from@@ -110,6 +179,16 @@ function isDirectLoopback(req) { const LOOPBACK_READS = new Set(['/data', '/state', '/changelog',   '/export/json', '/export/csv', '/export/svg', '/export/html']); function authFor(req, urlPath) {+  // An Authorization header is an explicit claim to be the agent, so it decides the+  // request on its own: the right token, or a 401 exactly as if nothing had been+  // sent. It never falls through to the cookie or to the loopback reads, because a+  // wrong token quietly demoting itself to an anonymous read is how a broken agent+  // goes on looking like a working one.+  const bearer = bearerOf(req);+  if (bearer !== null) {+    if (!agentTokenOk(bearer)) return { ok: false };+    return { ok: true, user: null, agent: true, mode: 'agent' };+  }   if (AUTH_OFF) return { ok: true, user: null, mode: 'off' };   const sess = sessionOf(req);   if (sess) return { ok: true, user: sess.user, sid: sess.sid, mode: 'session' };@@ -510,9 +589,9 @@ function assertDates(items) { // // The changelog is append-only history and is NOT touched: earlier events refer // to the plan as it was, which is exactly what history means.-function logCompact(info) {+function logCompact(info, actor) {   try {-    appendLog([{ action: 'compact', items: info.items, adds: info.adds, deletes: info.deletes, via: 'compact' }], 'user');+    appendLog([{ action: 'compact', items: info.items, adds: info.adds, deletes: info.deletes, via: 'compact' }], actor || 'user');   } catch (e) {     console.error('adom-gantt: could not record the compact in the changelog:', e.message);   }@@ -522,9 +601,9 @@ function logCompact(info) { // single most destructive thing the app can do, and it used to leave no trace at // all: an agent reading the changelog could not tell that every event above it // referred to a plan that no longer exists. Never let a failed log kill the import.-function logImport(format, result) {+function logImport(format, result, actor) {   try {-    appendLog([{ action: 'import', format, items: result.items, phases: result.phases, via: 'import' }], 'user');+    appendLog([{ action: 'import', format, items: result.items, phases: result.phases, via: 'import' }], actor || 'user');   } catch (e) {     console.error('adom-gantt: could not record the import in the changelog:', e.message);   }@@ -944,7 +1023,7 @@ function stampUids(data, opts) { // Never fatal. A plan that could not be stamped still serves; the copy button // falls back to the old ids and the log says so. A migration that can take the // server down with it is a worse bug than the one it fixes.-function ensureUids(why) {+function ensureUids(why, actor) {   let data, state;   try { data = loadData(); state = loadState(); } catch (e) {     console.error('adom-gantt: could not read the plan to assign uids:', e.message);@@ -995,7 +1074,7 @@ function ensureUids(why) {                  stateRev: addedTouched ? state.stateRev : undefined };   try {     appendLog([{ action: 'uid.assign', count: minted + reminted, minted, reminted,-                 live: live.length, retired: dead.length, nextUid: next, via: why || 'startup' }], 'adom-gantt');+                 live: live.length, retired: dead.length, nextUid: next, via: why || 'startup' }], actor || 'adom-gantt');   } catch (e) {     console.error('adom-gantt: could not record the uid assignment in the changelog:', e.message);   }@@ -2083,7 +2162,11 @@ const server = http.createServer(async (req, res) => {         // The actor is taken from the session, never from the body. A client that         // says it is someone else is simply wrong, and an append-only log that can         // be told who to blame is worse than one with no names in it at all.-        const who = auth.user ? (auth.user.displayName || auth.user.username) : null;+        // A token-authed request is the agent and says so, full stop: an `actor` in+        // the body is ignored exactly the way a session's would be, and appendLog+        // takes this name over any the events carry.+        const who = auth.agent ? AGENT_ACTOR+          : (auth.user ? (auth.user.displayName || auth.user.username) : null);         const out = appendLog(events, who || (payload && payload.actor));         jsonResp(res, 200, { ok: true, added: out.added, rev: out.rev, file: path.basename(LOG_FILE) });       } catch (e) {@@ -2180,7 +2263,7 @@ const server = http.createServer(async (req, res) => {     // appends rows straight into the data file under a running server. Does     // nothing when every row already has a uid, so it is safe to spam.     if (req.method === 'POST' && urlPath === '/uid/backfill') {-      const out = ensureUids('backfill');+      const out = ensureUids('backfill', auth.agent ? AGENT_ACTOR : null);       jsonResp(res, out.ok ? 200 : 500, out);       return;     }@@ -2198,7 +2281,7 @@ const server = http.createServer(async (req, res) => {         };         fs.writeFileSync(DATA_FILE, JSON.stringify(merged, null, 2) + '\n', 'utf8');         if (fs.existsSync(STATE_FILE)) fs.unlinkSync(STATE_FILE);-        logCompact(info);+        logCompact(info, auth.agent ? AGENT_ACTOR : null);         jsonResp(res, 200, Object.assign({ ok: true, file: path.basename(DATA_FILE) }, info));       } catch (e) {         jsonResp(res, 400, { ok: false, error: e.message });@@ -2210,7 +2293,7 @@ const server = http.createServer(async (req, res) => {       const body = await readBody(req);       try {         const result = importJSON(body);-        logImport('json', result);+        logImport('json', result, auth.agent ? AGENT_ACTOR : null);         jsonResp(res, 200, result);       } catch (e) {         jsonResp(res, 400, { ok: false, error: e.message });@@ -2222,7 +2305,7 @@ const server = http.createServer(async (req, res) => {       const body = await readBody(req);       try {         const result = importCSV(body, query);-        logImport('csv', result);+        logImport('csv', result, auth.agent ? AGENT_ACTOR : null);         jsonResp(res, 200, result);       } catch (e) {         jsonResp(res, 400, { ok: false, error: e.message });@@ -2234,7 +2317,7 @@ const server = http.createServer(async (req, res) => {       const body = await readBody(req);       try {         const result = importMSProjectXML(body);-        logImport('msproject-xml', result);+        logImport('msproject-xml', result, auth.agent ? AGENT_ACTOR : null);         jsonResp(res, 200, result);       } catch (e) {         jsonResp(res, 400, { ok: false, error: e.message });@@ -2246,7 +2329,7 @@ const server = http.createServer(async (req, res) => {       const body = await readBody(req);       try {         const result = importJiraCSV(body);-        logImport('jira-csv', result);+        logImport('jira-csv', result, auth.agent ? AGENT_ACTOR : null);         jsonResp(res, 200, result);       } catch (e) {         jsonResp(res, 400, { ok: false, error: e.message });@@ -2321,6 +2404,15 @@ const server = http.createServer(async (req, res) => {     // ── Static file serving ──      if (urlPath === '/' || urlPath === '/gantt.html') {+      // The token is an API credential, not a seat in the app. Who may open the page+      // is exactly what it was before the token existed, so an agent asking for it+      // gets the same sign-in page a stranger does rather than a session it has no+      // use for and no way to hold.+      if (auth.mode === 'agent') {+        res.writeHead(401, { 'Content-Type': 'text/html', 'Cache-Control': 'no-store' });+        res.end(signInPage());+        return;+      }       res.writeHead(200, { 'Content-Type': 'text/html', 'Cache-Control': 'no-store' });       res.end(serveApp(auth.user));       return;@@ -2349,6 +2441,9 @@ process.on('uncaughtException', err => {  if (SESSION_FILE) loadSessions(); +// Before the door opens, so the first request bearing a token is already answerable.+ensureAgentToken();+ // 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@@ -2377,4 +2472,9 @@ server.listen(PORT, '127.0.0.1', () => {   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.');+  // The path, never the token. A startup log is read over shoulders and pasted into+  // issues; what an agent needs from it is where to look, not the credential itself.+  console.log(AGENT_TOKEN+    ? `Agent token: ${AGENT_TOKEN_FILE} (0600). Send it as "Authorization: Bearer <token>" to write through the gate as "${AGENT_ACTOR}".`+    : 'Agent token: unavailable (could not be created). Agent writes through the API are off.'); });