atris 3.72.4 → 3.72.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -367,7 +367,7 @@ atris business record atris/reports/2026-04-12-operator-recap.md --outcome mixed
367
367
  - `atris activate` loads the current wiki status so the next session starts with project memory, not just tasks
368
368
  - `atris member` keeps team-member identity and learning local-first: `MEMBER.md` is the role contract, `goals.json` is the machine-readable goal/experiment state, `goals.md` is the human readout, and `logs/YYYY-MM-DD.md` records what happened. Use `atris member goal`, `tick`, `status`, `block`, and `review <name> <experiment-id> --accept --proof "..." --value 1..5` to test whether a member is making useful progress or needs the operator/orchestrator.
369
369
  - `atris codex-goal` is the read-only bridge for native Codex `/goal`: `status` inspects the live goal database, while the legacy `reset` command now refuses every mutation. Active tasks continue in place; completed tasks retain their final goal, and new work or recurring monitors use a new dedicated Codex task.
370
- - `atris task` keeps durable local task state and append-only events for agents; `atris/TODO.md` is just a regenerated readable board. Run the loop with `atris task new`, `delegate "..." --to <owner>`, `next`, `say`, and `ready <id> --proof "..."`; human approval is `atris task accept <id>` (moves to Done, awards Career XP) or `revise <id> --note "..."`. Final task transitions also append the general daily log and, when a real `atris/team/<member>/MEMBER.md` matches the task owner, that member's daily log. Add `--json` for headless agents, `atris task serve` for the local board, and `atris task show <ref>` / `events --all` for the full ledger. Commands accept semantic refs (`OBL-18`), full IDs, or any unique prefix. In cloud business workspaces, Supabase `tasks` is the source of truth and Swarlo the live claim layer.
370
+ - `atris task` keeps durable local task state and append-only events for agents; `atris/TODO.md` is just a regenerated readable board. Run the loop with `atris task new`, `delegate "..." --to <owner>`, `next`, `say`, and `ready <id> --proof "..."`; human approval is `atris task accept <id>` (moves to Done, awards Career XP) or `revise <id> --note "..."`. Final task transitions also append the general daily log and, when a real `atris/team/<member>/MEMBER.md` matches the task owner, that member's daily log. Add `--json` for headless agents, `atris task serve` for the local board, and `atris task show <ref>` / `events --all` for the full ledger. The board prints a private link with a fresh token on each run and listens only on 127.0.0.1. Open that full link in the browser; API clients send its token in `x-atris-token` and use `application/json` for changes. Browser changes must come from the board's own origin. Commands accept semantic refs (`OBL-18`), full IDs, or any unique prefix. In cloud business workspaces, Supabase `tasks` is the source of truth and Swarlo the live claim layer.
371
371
  - `atris mission` is the durable autonomy layer: start with an owner, verifier, runner, and stop condition; record bounded work with `mission tick`; close with `mission complete` only after proof. Runners include `manual`, `claude`, `atris2`, and `codex_goal`.
372
372
  - `atris pulse` is the OS-cron heartbeat for overnight self-improvement. Use `atris pulse status`, `tick`, `run`, `install --cadence "<cron>" --days 7 --verify "npm test" --model claude-opus-5-5`, and `uninstall`.
373
373
  - `atris experiments` runs small test packs in `atris/experiments/`
package/commands/gm.js CHANGED
@@ -1525,7 +1525,7 @@ async function gmPhone(args = []) {
1525
1525
  const opened = [];
1526
1526
  try {
1527
1527
  for (const address of addresses) {
1528
- const server = http.createServer((req, res) => { handler(req, res); });
1528
+ const server = http.createServer((req, res) => { handler(req, res).catch(() => res.destroy()); });
1529
1529
  await new Promise((resolve, reject) => {
1530
1530
  server.once('error', reject);
1531
1531
  server.listen(port, address, resolve);
package/commands/task.js CHANGED
@@ -5,6 +5,7 @@
5
5
 
6
6
  const fs = require('fs');
7
7
  const http = require('http');
8
+ const crypto = require('crypto');
8
9
  const { execSync } = require('node:child_process');
9
10
  const path = require('path');
10
11
  const os = require('os');
@@ -12482,7 +12483,7 @@ function taskBoardTemplate(model) {
12482
12483
  async function api(path, options = {}) {
12483
12484
  const res = await fetch(path, {
12484
12485
  ...options,
12485
- headers: { 'content-type': 'application/json', ...(options.headers || {}) }
12486
+ headers: { 'content-type': 'application/json', 'x-atris-token': new URLSearchParams(location.search).get('t') || '', ...(options.headers || {}) }
12486
12487
  });
12487
12488
  const data = await res.json();
12488
12489
  if (!res.ok || data.ok === false) throw new Error(data.detail || data.reason || 'request failed');
@@ -12798,18 +12799,30 @@ function taskBoardHtml() {
12798
12799
  }
12799
12800
 
12800
12801
  function readJsonBody(req) {
12802
+ if (String(req.headers['content-type'] || '').split(';')[0].trim().toLowerCase() !== 'application/json') {
12803
+ return Promise.reject(Object.assign(new Error('json required'), { statusCode: 415 }));
12804
+ }
12801
12805
  return new Promise((resolve, reject) => {
12802
- let body = '';
12806
+ let size = 0;
12807
+ const chunks = [];
12803
12808
  req.on('data', chunk => {
12804
- body += chunk;
12805
- if (body.length > 1_000_000) {
12806
- reject(new Error('body too large'));
12807
- req.destroy();
12809
+ if (size > 1_000_000) return;
12810
+ size += chunk.length;
12811
+ if (size > 1_000_000) {
12812
+ reject(Object.assign(new Error('body too large'), { statusCode: 413 }));
12813
+ return;
12808
12814
  }
12815
+ chunks.push(chunk);
12809
12816
  });
12810
12817
  req.on('end', () => {
12818
+ if (size > 1_000_000) return;
12819
+ const body = Buffer.concat(chunks).toString('utf8');
12811
12820
  if (!body.trim()) return resolve({});
12812
- try { resolve(JSON.parse(body)); } catch (e) { reject(e); }
12821
+ try {
12822
+ const value = JSON.parse(body);
12823
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('object required');
12824
+ resolve(value);
12825
+ } catch (e) { reject(Object.assign(e, { statusCode: 400 })); }
12813
12826
  });
12814
12827
  req.on('error', reject);
12815
12828
  });
@@ -12818,14 +12831,16 @@ function readJsonBody(req) {
12818
12831
  function sendJson(res, status, value, headers = {}) {
12819
12832
  res.writeHead(status, {
12820
12833
  'content-type': 'application/json; charset=utf-8',
12821
- 'access-control-allow-origin': 'http://localhost',
12834
+ 'cache-control': 'no-store',
12835
+ 'referrer-policy': 'no-referrer',
12836
+ 'x-content-type-options': 'nosniff',
12822
12837
  ...headers,
12823
12838
  });
12824
12839
  res.end(JSON.stringify(value, null, 2));
12825
12840
  }
12826
12841
 
12827
12842
  function sendHtml(res, value) {
12828
- res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
12843
+ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store', 'referrer-policy': 'no-referrer', 'x-content-type-options': 'nosniff' });
12829
12844
  res.end(value);
12830
12845
  }
12831
12846
 
@@ -13469,8 +13484,24 @@ function matchTaskApiRoute(req, url) {
13469
13484
  return null;
13470
13485
  }
13471
13486
 
13472
- async function handleTaskApi(req, res, taskDb, db) {
13473
- const url = new URL(req.url, 'http://127.0.0.1');
13487
+ async function handleTaskApi(req, res, taskDb, db, token) {
13488
+ let url;
13489
+ try {
13490
+ const target = req.url || '/';
13491
+ url = new URL(target, 'http://127.0.0.1');
13492
+ if (!target.startsWith('/') || target.startsWith('//') || /[\\#\0]/.test(target) || decodeURIComponent(url.pathname).includes('\0')) throw new Error('url');
13493
+ decodeURIComponent(target);
13494
+ } catch {
13495
+ return sendJson(res, 400, { ok: false, reason: 'bad_url' });
13496
+ }
13497
+ const host = String(req.headers.host || '').toLowerCase();
13498
+ const port = req.socket.localPort;
13499
+ if (![`127.0.0.1:${port}`, `localhost:${port}`].includes(host)) return sendJson(res, 403, { ok: false, reason: 'host' });
13500
+ const provided = Buffer.from(String(req.headers['x-atris-token'] || (req.method === 'GET' && url.searchParams.get('t')) || ''));
13501
+ const expected = Buffer.from(token);
13502
+ if (provided.length !== expected.length || !crypto.timingSafeEqual(provided, expected)) return sendJson(res, 403, { ok: false, reason: 'token' });
13503
+ if (req.method === 'GET' && (req.headers['transfer-encoding'] || Number(req.headers['content-length'] || 0) > 0)) return sendJson(res, 400, { ok: false, reason: 'body' });
13504
+ if (req.method !== 'GET' && req.headers.origin !== undefined && req.headers.origin !== `http://${host}`) return sendJson(res, 403, { ok: false, reason: 'origin' });
13474
13505
  const matchedRoute = matchTaskApiRoute(req, url);
13475
13506
  if (!matchedRoute) return sendJson(res, 404, { ok: false, reason: 'not_found' });
13476
13507
  return matchedRoute.route.serve({
@@ -13486,26 +13517,32 @@ async function handleTaskApi(req, res, taskDb, db) {
13486
13517
  function createTaskApiServer(taskApi = {}) {
13487
13518
  const taskDb = taskApi.taskDb || getTaskDb();
13488
13519
  const db = taskApi.db || taskDb.open();
13489
- return http.createServer((req, res) => {
13490
- handleTaskApi(req, res, taskDb, db).catch((error) => {
13491
- sendJson(res, 500, { ok: false, reason: 'server_error', detail: String(error && error.message || error) });
13492
- });
13520
+ const token = crypto.randomBytes(32).toString('base64url');
13521
+ const server = http.createServer((req, res) => {
13522
+ handleTaskApi(req, res, taskDb, db, token).catch((error) => {
13523
+ if (res.headersSent || res.destroyed) return res.destroy();
13524
+ const status = error.statusCode || 500;
13525
+ sendJson(res, status, { ok: false, reason: status < 500 ? 'bad_request' : 'server_error' });
13526
+ }).catch(() => res.destroy());
13493
13527
  });
13528
+ server.taskToken = token;
13529
+ return server;
13494
13530
  }
13495
13531
 
13496
13532
  function cmdServe(args) {
13497
13533
  const host = String(flag(args, '--host') || '127.0.0.1');
13534
+ if (!['127.0.0.1', 'localhost'].includes(host)) throw new Error('task board only listens on 127.0.0.1');
13498
13535
  const port = Number(flag(args, '--port') || process.env.PORT || 8787);
13499
13536
  const taskDb = getTaskDb();
13500
13537
  const db = taskDb.open();
13501
13538
  const server = createTaskApiServer({ taskDb, db });
13502
13539
  return new Promise((resolve, reject) => {
13503
13540
  server.on('error', reject);
13504
- server.listen(port, host, () => {
13541
+ server.listen(port, '127.0.0.1', () => {
13505
13542
  const addr = server.address();
13506
13543
  const actualPort = addr && addr.port || port;
13507
- console.log(`Task board: http://${host}:${actualPort}`);
13508
- console.log(`Workspace: ${taskDb.workspaceRoot()}`);
13544
+ console.log(`task board: http://${host}:${actualPort}/?t=${server.taskToken}`);
13545
+ console.log(`workspace: ${taskDb.workspaceRoot()}`);
13509
13546
  });
13510
13547
 
13511
13548
  const shutdown = () => {
package/lib/gm-phone.js CHANGED
@@ -303,11 +303,20 @@ function tokenMatches(given, token) {
303
303
  // look-alike hostname pointed at the Mac gets nothing.
304
304
  function checkRequest(req, { token, hosts = [] }) {
305
305
  const host = String(req.headers.host || '').toLowerCase();
306
- const url = new URL(req.url || '/', 'http://local');
306
+ let url;
307
+ try {
308
+ const target = req.url || '/';
309
+ url = new URL(target, 'http://local');
310
+ if (!target.startsWith('/') || target.startsWith('//') || /[\\#\0]/.test(target) || decodeURIComponent(url.pathname).includes('\0')) throw new Error('url');
311
+ decodeURIComponent(target);
312
+ } catch {
313
+ return { ok: false, status: 400, reason: 'url' };
314
+ }
307
315
  if (!hosts.includes(host)) return { ok: false, status: 403, reason: 'host' };
308
316
  const header = req.headers['x-gm-token'];
309
317
  if (req.method === 'GET') {
310
318
  if (!tokenMatches(url.searchParams.get('t') || header, token)) return { ok: false, status: 403, reason: 'token' };
319
+ if (req.headers['transfer-encoding'] || Number(req.headers['content-length'] || 0) > 0) return { ok: false, status: 400, reason: 'body' };
311
320
  return { ok: true, path: url.pathname };
312
321
  }
313
322
  if (req.method === 'POST') {
@@ -324,10 +333,10 @@ function readBody(req) {
324
333
  let size = 0;
325
334
  const chunks = [];
326
335
  req.on('data', (chunk) => {
336
+ if (size > BODY_MAX) return;
327
337
  size += chunk.length;
328
338
  if (size > BODY_MAX) {
329
339
  reject(new Error('too_large'));
330
- req.destroy();
331
340
  return;
332
341
  }
333
342
  chunks.push(chunk);
@@ -522,7 +531,7 @@ function createPhoneHandler(deps) {
522
531
 
523
532
  async function handle(req, res) {
524
533
  const check = checkRequest(req, { token: deps.token, hosts: deps.hosts });
525
- if (!check.ok) return send(res, check.status, check.status === 403 ? 'forbidden' : 'method not allowed', 'text/plain; charset=utf-8');
534
+ if (!check.ok) return send(res, check.status, check.status === 400 ? 'bad request' : check.status === 403 ? 'forbidden' : 'method not allowed', 'text/plain; charset=utf-8');
526
535
  try {
527
536
  if (req.method === 'GET' && check.path === '/') {
528
537
  const nonce = crypto.randomBytes(16).toString('base64');
@@ -533,12 +542,13 @@ function createPhoneHandler(deps) {
533
542
  if (req.method === 'GET' && check.path === '/state') return send(res, 200, state());
534
543
  if (req.method === 'GET' && check.path === '/bet') return bet ? send(res, 200, bet.warm()) : send(res, 404, { ok: false, reason: 'not_found' });
535
544
  if (req.method === 'POST' && (check.path === '/action' || check.path === '/undo' || (bet && check.path.startsWith('/bet/')))) {
536
- if (!String(req.headers['content-type'] || '').startsWith('application/json')) return send(res, 415, { ok: false, reason: 'json_only' });
545
+ if (String(req.headers['content-type'] || '').split(';')[0].trim().toLowerCase() !== 'application/json') return send(res, 415, { ok: false, reason: 'json_only' });
537
546
  let body;
538
547
  try {
539
548
  body = JSON.parse(await readBody(req) || '{}');
540
- } catch {
541
- return send(res, 400, { ok: false, reason: 'bad_body' });
549
+ if (!body || typeof body !== 'object' || Array.isArray(body)) throw new Error('bad_body');
550
+ } catch (err) {
551
+ return send(res, err.message === 'too_large' ? 413 : 400, { ok: false, reason: 'bad_body' });
542
552
  }
543
553
  if (check.path.startsWith('/bet/')) {
544
554
  const out = await betAction(check.path, body || {});
package/lib/site.js CHANGED
@@ -111,8 +111,16 @@ function serveSite(dir, port = 4321) {
111
111
  const port = server.address() && server.address().port;
112
112
  const host = String(req.headers.host || '').toLowerCase();
113
113
  if (![`127.0.0.1:${port}`, `localhost:${port}`, `[::1]:${port}`].includes(host)) { res.writeHead(403); res.end('forbidden'); return; }
114
+ if (!['GET', 'HEAD'].includes(req.method)) { res.writeHead(405); res.end('method not allowed'); return; }
115
+ if (req.headers['transfer-encoding'] || Number(req.headers['content-length'] || 0) > 0) { res.writeHead(400); res.end('bad request'); return; }
114
116
  let rel;
115
- try { rel = decodeURIComponent((req.url || '/').split('?')[0]); }
117
+ try {
118
+ const target = req.url || '/';
119
+ new URL(target, 'http://localhost');
120
+ if (!target.startsWith('/') || target.startsWith('//') || /[\\#\0]/.test(target)) throw new Error('url');
121
+ decodeURIComponent(target);
122
+ rel = decodeURIComponent(target.split('?')[0]);
123
+ }
116
124
  catch { res.writeHead(400); res.end('bad request'); return; }
117
125
  // A null byte makes the fs calls below throw synchronously and crash the server.
118
126
  if (rel.includes('\0')) { res.writeHead(400); res.end('bad request'); return; }
package/lib/zen-apply.js CHANGED
@@ -536,8 +536,12 @@ function applyZen({ cwd = process.cwd(), fetch = true, freshMinutes, abandonedDa
536
536
  const base = gitOut(['merge-base', 'HEAD', main], root);
537
537
  const moved = moveKeepingNotes(root, ['checkout', '-q', main], main, tag, base || 'HEAD');
538
538
  notes.push(...(moved.notes || []));
539
+ // A branch with nothing beyond origin is cleared as finished below, so
540
+ // only a branch with its own commits is called kept.
541
+ const own = branch && before.upstream ? gitOut(['rev-list', '--count', `${before.upstream}..${branch}`], root) : null;
539
542
  if (moved.ok) {
540
- done.push(`moved back to ${main}${branch ? ` (${branch} is kept)` : ''}`);
543
+ const why = !branch ? '' : own === '0' ? ` (${branch} had nothing new)` : ` (${branch} is kept)`;
544
+ done.push(`moved back to ${main}${why}`);
541
545
  branch = main;
542
546
  } else {
543
547
  left.push(`could not move to ${main}: ${moved.error}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "atris",
3
- "version": "3.72.4",
3
+ "version": "3.72.5",
4
4
  "description": "you say what you want in plain words. atris builds it, checks it, and shows you proof.",
5
5
  "main": "bin/atris.js",
6
6
  "bin": {
@@ -303,5 +303,6 @@ module.exports = {
303
303
  fixtureFetch,
304
304
  runOfflineCheck,
305
305
  saveFailedOutput,
306
+ startCountingServer,
306
307
  summary,
307
308
  };