atris 3.74.0 → 3.74.1

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.
@@ -0,0 +1,412 @@
1
+ // What your ai coding agents did in this folder today. Reads Claude Code
2
+ // and Codex session logs plus git, all local. Sends nothing anywhere and
3
+ // needs no atris/ folder.
4
+ const fs = require('fs');
5
+ const os = require('os');
6
+ const path = require('path');
7
+ const { spawnSync } = require('child_process');
8
+
9
+ const CLAUDE_EDIT_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit']);
10
+ const PATCH_FILE_RE = /\*\*\* (?:Add|Update|Delete) File: ([^\n\\"]+)/g;
11
+
12
+ function startOfToday(now = new Date()) {
13
+ const d = new Date(now);
14
+ d.setHours(0, 0, 0, 0);
15
+ return d;
16
+ }
17
+
18
+ function readJsonl(file) {
19
+ let text = '';
20
+ try {
21
+ text = fs.readFileSync(file, 'utf8');
22
+ } catch {
23
+ return [];
24
+ }
25
+ const rows = [];
26
+ for (const line of text.split('\n')) {
27
+ if (!line.trim()) continue;
28
+ try {
29
+ rows.push(JSON.parse(line));
30
+ } catch {
31
+ // half-written last line while the agent is still running
32
+ }
33
+ }
34
+ return rows;
35
+ }
36
+
37
+ // Only the first line, so a busy day of logs from other folders costs a
38
+ // small read each instead of the whole file.
39
+ function readFirstJsonLine(file, maxBytes = 1024 * 1024) {
40
+ let fd;
41
+ try {
42
+ fd = fs.openSync(file, 'r');
43
+ const chunks = [];
44
+ const buf = Buffer.alloc(64 * 1024);
45
+ let total = 0;
46
+ while (total < maxBytes) {
47
+ const n = fs.readSync(fd, buf, 0, buf.length, total);
48
+ if (n <= 0) break;
49
+ const nl = buf.subarray(0, n).indexOf(10);
50
+ chunks.push(Buffer.from(buf.subarray(0, nl === -1 ? n : nl)));
51
+ total += n;
52
+ if (nl !== -1) break;
53
+ }
54
+ return JSON.parse(Buffer.concat(chunks).toString('utf8'));
55
+ } catch {
56
+ return null;
57
+ } finally {
58
+ if (fd !== undefined) fs.closeSync(fd);
59
+ }
60
+ }
61
+
62
+ function touchedSince(file, since) {
63
+ try {
64
+ return fs.statSync(file).mtimeMs >= since.getTime();
65
+ } catch {
66
+ return false;
67
+ }
68
+ }
69
+
70
+ function isToday(row, since) {
71
+ const t = Date.parse(row && row.timestamp);
72
+ return Number.isFinite(t) && t >= since.getTime();
73
+ }
74
+
75
+ function realPrompt(text) {
76
+ const s = String(text || '').trim();
77
+ if (!s) return false;
78
+ // harness wrappers and the project notes codex injects, not something
79
+ // the person typed
80
+ return !s.startsWith('<') && !s.startsWith('Caveat:') && !s.startsWith('# AGENTS.md instructions')
81
+ && !s.startsWith('[Request interrupted');
82
+ }
83
+
84
+ // Claude Code saves a typed ask as plain text, or as a list when it has a
85
+ // picture. A list carrying tool results is the tool talking back, not an ask.
86
+ function claudeAskText(content) {
87
+ if (typeof content === 'string') return content;
88
+ if (!Array.isArray(content) || content.some((c) => c && c.type === 'tool_result')) return '';
89
+ return content.filter((c) => c && c.type === 'text').map((c) => c.text || '').join('\n');
90
+ }
91
+
92
+ // Claude Code marks hook feedback, image notes, and the summary of a
93
+ // condensed chat on the row itself. None of those are asks.
94
+ function claudeHarnessRow(row) {
95
+ return Boolean(row.isMeta || row.isCompactSummary || row.isVisibleInTranscriptOnly);
96
+ }
97
+
98
+ // A program started this chat (claude -p, the agent sdk), not a person
99
+ // typing in the app.
100
+ function claudeStartedByProgram(row) {
101
+ return row.promptSource === 'sdk' || /^sdk/.test(String(row.entrypoint || ''));
102
+ }
103
+
104
+ // Codex helpers carry a parent link and belong to another session.
105
+ // codex exec is a program run; the desktop app and terminal app are chats.
106
+ function codexOrigin(meta) {
107
+ const src = meta && meta.source;
108
+ if (src && typeof src === 'object' && src.subagent) return 'helper';
109
+ if ((meta && meta.originator) === 'codex_exec' || src === 'exec') return 'auto';
110
+ return 'you';
111
+ }
112
+
113
+ // Claude Code keys each folder by its path with every non-alphanumeric
114
+ // character turned into a dash.
115
+ function claudeProjectDir(home, cwd, env = {}) {
116
+ return path.join(claudeRoot(home, env), 'projects', cwd.replace(/[^a-zA-Z0-9]/g, '-'));
117
+ }
118
+
119
+ // Past 200 characters Claude Code keeps the first 200 and adds a short
120
+ // code, so a deep folder is found by that prefix. Two deep folders can
121
+ // share it, so those logs are confirmed by the folder they record.
122
+ const CLAUDE_KEY_MAX = 200;
123
+
124
+ function claudeProjectDirs(home, cwd, env = {}) {
125
+ const exact = claudeProjectDir(home, cwd, env);
126
+ const key = path.basename(exact);
127
+ if (key.length <= CLAUDE_KEY_MAX) return { dirs: [exact], shortened: false };
128
+ const root = path.dirname(exact);
129
+ const prefix = `${key.slice(0, CLAUDE_KEY_MAX)}-`;
130
+ let names = [];
131
+ try {
132
+ names = fs.readdirSync(root).filter((n) => n.startsWith(prefix));
133
+ } catch {}
134
+ return { dirs: [exact, ...names.map((n) => path.join(root, n))], shortened: true };
135
+ }
136
+
137
+ function sameFolder(recorded, aliases) {
138
+ return aliases.includes(recorded) || realOr(recorded) === aliases[0];
139
+ }
140
+
141
+ // Both tools let people move their log folder; look where they look.
142
+ function claudeRoot(home, env = {}) {
143
+ return env.CLAUDE_CONFIG_DIR || path.join(home, '.claude');
144
+ }
145
+
146
+ function codexRoot(home, env = {}) {
147
+ return env.CODEX_HOME || path.join(home, '.codex');
148
+ }
149
+
150
+ function readClaude({ home, env, aliases, since }) {
151
+ const files = new Map();
152
+ for (const alias of aliases) {
153
+ const { dirs, shortened } = claudeProjectDirs(home, alias, env);
154
+ for (const dir of dirs) {
155
+ try {
156
+ for (const n of fs.readdirSync(dir)) {
157
+ const file = path.join(dir, n);
158
+ if (n.endsWith('.jsonl') && !files.has(file)) files.set(file, shortened);
159
+ }
160
+ } catch {}
161
+ }
162
+ }
163
+ const sessions = [];
164
+ for (const [file, shortened] of files) {
165
+ if (!touchedSince(file, since)) continue;
166
+ const rows = readJsonl(file);
167
+ // Folders like my-app and my_app share one log folder, so trust the
168
+ // folder each log records. A deep folder's log must record one.
169
+ const recorded = rows.find((r) => r && r.cwd);
170
+ if (recorded ? !sameFolder(recorded.cwd, aliases) : shortened) continue;
171
+ const s = { tool: 'claude', origin: null, prompts: 0, files: new Set(), lastPrompt: '' };
172
+ for (const row of rows) {
173
+ if (!isToday(row, since) || row.isSidechain) continue;
174
+ const content = row.message && row.message.content;
175
+ const ask = row.type === 'user' ? claudeAskText(content) : '';
176
+ if (ask && !claudeHarnessRow(row) && realPrompt(ask)) {
177
+ if (!s.origin) s.origin = claudeStartedByProgram(row) ? 'auto' : 'you';
178
+ s.prompts += 1;
179
+ s.lastPrompt = ask.trim();
180
+ }
181
+ if (row.type === 'assistant' && Array.isArray(content)) {
182
+ for (const c of content) {
183
+ if (c && c.type === 'tool_use' && CLAUDE_EDIT_TOOLS.has(c.name)) {
184
+ const p = c.input && (c.input.file_path || c.input.notebook_path);
185
+ if (p) s.files.add(p);
186
+ }
187
+ }
188
+ }
189
+ }
190
+ if (!s.origin) s.origin = 'you';
191
+ if (s.prompts || s.files.size) sessions.push(s);
192
+ }
193
+ return sessions;
194
+ }
195
+
196
+ // Codex files a session under the day it started, and a session picked
197
+ // back up later keeps writing there. So every day folder is listed, and
198
+ // only logs changed since midnight are opened.
199
+ function codexDayDirs(home, env) {
200
+ const root = path.join(codexRoot(home, env), 'sessions');
201
+ const dirs = [];
202
+ const list = (p) => {
203
+ try {
204
+ return fs.readdirSync(p, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => path.join(p, e.name));
205
+ } catch {
206
+ return [];
207
+ }
208
+ };
209
+ for (const y of list(root)) for (const m of list(y)) dirs.push(...list(m));
210
+ return dirs;
211
+ }
212
+
213
+ function insideFolder(child, folder) {
214
+ if (!child) return false;
215
+ const rel = path.relative(folder, child);
216
+ return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
217
+ }
218
+
219
+ function readCodex({ home, env, aliases, since }) {
220
+ // Codex records the folder as typed, shortcut and all, so a session
221
+ // started through a shortcut is resolved before it is compared.
222
+ const here = (child) => aliases.some((a) => insideFolder(child, a))
223
+ || insideFolder(realOr(child), aliases[0]);
224
+ const sessions = [];
225
+ for (const dir of codexDayDirs(home, env)) {
226
+ let names = [];
227
+ try {
228
+ names = fs.readdirSync(dir).filter((n) => n.endsWith('.jsonl'));
229
+ } catch {
230
+ continue;
231
+ }
232
+ for (const name of names) {
233
+ const file = path.join(dir, name);
234
+ if (!touchedSince(file, since)) continue;
235
+ const first = readFirstJsonLine(file);
236
+ if (first && first.type === 'session_meta' && !here(first.payload && first.payload.cwd)) continue;
237
+ const rows = readJsonl(file);
238
+ const meta = rows.find((r) => r.type === 'session_meta');
239
+ if (!meta || !here(meta.payload && meta.payload.cwd)) continue;
240
+ const s = { tool: 'codex', origin: codexOrigin(meta.payload), prompts: 0, files: new Set(), lastPrompt: '' };
241
+ const started = meta.payload.cwd;
242
+ const resolved = realOr(started) || started;
243
+ // edits recorded under the shortcut are moved onto the real folder
244
+ const onReal = (p) => (path.isAbsolute(p) && insideFolder(p, started) ? path.join(resolved, path.relative(started, p)) : p);
245
+ for (const row of rows) {
246
+ if (!isToday(row, since) || row.type !== 'response_item') continue;
247
+ const p = row.payload || {};
248
+ if (p.type === 'message' && p.role === 'user') {
249
+ const text = (p.content || []).map((c) => (c && c.text) || '').join('\n');
250
+ if (realPrompt(text)) {
251
+ s.prompts += 1;
252
+ s.lastPrompt = text.trim();
253
+ }
254
+ }
255
+ if (p.type === 'custom_tool_call' || p.type === 'function_call') {
256
+ const raw = String(p.input || p.arguments || '');
257
+ for (const m of raw.matchAll(PATCH_FILE_RE)) s.files.add(onReal(m[1].trim()));
258
+ }
259
+ }
260
+ if (s.prompts || s.files.size) sessions.push(s);
261
+ }
262
+ }
263
+ return sessions;
264
+ }
265
+
266
+ // Read only: git status would otherwise refresh its own index file.
267
+ function git(cwd, args) {
268
+ const r = spawnSync('git', ['--no-optional-locks', ...args], { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
269
+ return r.status === 0 ? r.stdout : null;
270
+ }
271
+
272
+ function readGit({ cwd, since }) {
273
+ if (git(cwd, ['rev-parse', '--is-inside-work-tree']) === null) return null;
274
+ // From a subfolder of a bigger repo, count only that subfolder, so it is
275
+ // not credited with its neighbors' work. At the top, no limit, which
276
+ // would also hide merges. Every new file counts, not just its folder.
277
+ const sub = (git(cwd, ['rev-parse', '--show-prefix']) || '').trim();
278
+ const log = git(cwd, ['log', `--since=${since.toISOString()}`, '--pretty=%s', ...(sub ? ['--', '.'] : [])]) || '';
279
+ const commits = log.split('\n').map((s) => s.trim()).filter(Boolean);
280
+ const status = git(cwd, ['status', '--porcelain', '--untracked-files=all', '--', '.']) || '';
281
+ const unsaved = status.split('\n').filter((l) => l.trim()).length;
282
+ return { commits, unsaved };
283
+ }
284
+
285
+ function relativeTo(cwd, file) {
286
+ const abs = path.isAbsolute(file) ? file : path.join(cwd, file);
287
+ const rel = path.relative(cwd, abs);
288
+ return rel.startsWith('..') ? null : rel;
289
+ }
290
+
291
+ function topAreas(files, limit = 2) {
292
+ const counts = new Map();
293
+ for (const f of files) {
294
+ const parts = f.split(path.sep);
295
+ const area = parts.length > 1 ? parts[0] : 'the top folder';
296
+ counts.set(area, (counts.get(area) || 0) + 1);
297
+ }
298
+ return [...counts.entries()].sort((a, b) => b[1] - a[1]).slice(0, limit).map(([a]) => a);
299
+ }
300
+
301
+ function realOr(p) {
302
+ try {
303
+ return fs.realpathSync(p);
304
+ } catch {
305
+ return null;
306
+ }
307
+ }
308
+
309
+ // Every name this folder goes by. The tools record the path you started
310
+ // in, which can run through a symlink, and the shell's PWD keeps that
311
+ // path even after node resolves it.
312
+ function folderAliases(cwd, env = {}) {
313
+ const real = realOr(cwd) || cwd;
314
+ const aliases = [real];
315
+ for (const p of [cwd, env.PWD]) {
316
+ if (p && path.isAbsolute(p) && !aliases.includes(p) && realOr(p) === real) aliases.push(p);
317
+ }
318
+ return aliases;
319
+ }
320
+
321
+ function buildAgentToday({ cwd = process.cwd(), home = os.homedir(), env = process.env, now = new Date() } = {}) {
322
+ const aliases = folderAliases(cwd, env);
323
+ const folder = aliases[0];
324
+ const since = startOfToday(now);
325
+ const sessions = [
326
+ ...readClaude({ home, env, aliases, since }),
327
+ ...readCodex({ home, env, aliases, since }),
328
+ ];
329
+ const files = new Set();
330
+ for (const s of sessions) {
331
+ for (const f of s.files) {
332
+ const rel = aliases.map((a) => relativeTo(a, f)).find(Boolean);
333
+ if (rel) files.add(rel);
334
+ }
335
+ }
336
+ const gitInfo = readGit({ cwd: folder, since });
337
+ const yours = sessions.filter((s) => s.origin === 'you');
338
+ return {
339
+ folder: path.basename(folder),
340
+ since: since.toISOString(),
341
+ sessions: {
342
+ claude: yours.filter((s) => s.tool === 'claude').length,
343
+ codex: yours.filter((s) => s.tool === 'codex').length,
344
+ },
345
+ prompts: yours.reduce((n, s) => n + s.prompts, 0),
346
+ autoRuns: sessions.filter((s) => s.origin === 'auto').length,
347
+ files: [...files].sort(),
348
+ areas: topAreas([...files]),
349
+ commits: gitInfo ? gitInfo.commits : [],
350
+ unsaved: gitInfo ? gitInfo.unsaved : 0,
351
+ isGit: Boolean(gitInfo),
352
+ };
353
+ }
354
+
355
+ function hasActivity(t) {
356
+ return Boolean(t && (ranAgent(t) || t.commits.length > 0));
357
+ }
358
+
359
+ function ranAgent(t) {
360
+ return t.sessions.claude + t.sessions.codex + (t.autoRuns || 0) > 0;
361
+ }
362
+
363
+ function plural(n, one, many = `${one}s`) {
364
+ return `${n} ${n === 1 ? one : many}`;
365
+ }
366
+
367
+ function quote(text, width = 60) {
368
+ const s = String(text).replace(/\s+/g, ' ').trim();
369
+ return s.length > width ? `${s.slice(0, width - 3).trimEnd()}...` : s;
370
+ }
371
+
372
+ // Five lines at most, plain words, one idea each.
373
+ function renderAgentToday(t) {
374
+ const lines = [`today in ${t.folder}`];
375
+ const total = t.sessions.claude + t.sessions.codex;
376
+ if (!hasActivity(t)) {
377
+ lines.push('no ai sessions or saved changes here yet today.');
378
+ lines.push('run this again after your agent does some work.');
379
+ return lines.join('\n');
380
+ }
381
+ if (total) {
382
+ const who = [
383
+ t.sessions.claude ? `${plural(t.sessions.claude, 'session')} in claude code` : '',
384
+ t.sessions.codex ? `${plural(t.sessions.codex, 'session')} in codex` : '',
385
+ ].filter(Boolean).join(' and ');
386
+ lines.push(`you asked your ai agent for ${plural(t.prompts, 'thing')}, across ${who}.`);
387
+ }
388
+ if (t.autoRuns) {
389
+ lines.push(`your agents ${total ? 'also ' : ''}ran ${plural(t.autoRuns, 'job')} on their own.`);
390
+ }
391
+ if (t.files.length) {
392
+ const where = t.files.length === 1 ? ` in ${t.areas[0]}` : `, mostly in ${t.areas.join(' and ')}`;
393
+ lines.push(`it changed ${plural(t.files.length, 'file')}${where}.`);
394
+ } else if (ranAgent(t)) {
395
+ lines.push('it read and ran things but changed no files.');
396
+ }
397
+ const saved = t.commits.length
398
+ ? `${plural(t.commits.length, 'change')} saved to git. latest: "${quote(t.commits[0])}"`
399
+ : '';
400
+ const unsaved = t.unsaved ? `${plural(t.unsaved, 'file')} changed but not saved to git yet.` : '';
401
+ if (saved && unsaved && lines.length + 2 > 5) {
402
+ // keep five lines: one git line says both
403
+ lines.push(`${plural(t.commits.length, 'change')} saved to git, ${plural(t.unsaved, 'file')} not saved yet.`);
404
+ } else {
405
+ if (saved) lines.push(saved);
406
+ if (unsaved) lines.push(unsaved);
407
+ else if (t.isGit && t.files.length) lines.push('everything it changed is saved to git.');
408
+ }
409
+ return lines.slice(0, 5).join('\n');
410
+ }
411
+
412
+ module.exports = { buildAgentToday, renderAgentToday, hasActivity, ranAgent, claudeProjectDir, startOfToday, readFirstJsonLine, folderAliases };
package/lib/autoland.js CHANGED
@@ -492,14 +492,6 @@ function unexplainedSummaryRows(items, label) {
492
492
  return [`${plur(count, label)} stayed hidden because ${count === 1 ? 'its meaning was' : 'their meanings were'} unclear`];
493
493
  }
494
494
 
495
- function missionDigestLine(mission, max = 160) {
496
- const visibleGoal = mission?.visible_goal?.desired_objective
497
- || mission?.goal?.visible_goal?.desired_objective
498
- || (typeof mission?.visible_goal === 'string' ? mission.visible_goal : '');
499
- const objective = visibleGoal || mission?.objective || '';
500
- return finishThought(clarify(objective, max), max);
501
- }
502
-
503
495
  // Some older surfaces still need a readable fallback for rough text, but the
504
496
  // digest paths above now hide agent-speak until a result sentence exists.
505
497
  function dejargon(line, options = {}) {
@@ -30,10 +30,6 @@ function packDir(repoRoot = repoRootFromHere(), pack = DEFAULT_PACK) {
30
30
  return path.join(benchmarksRoot(repoRoot), pack || DEFAULT_PACK);
31
31
  }
32
32
 
33
- function defaultTasksDir(repoRoot = repoRootFromHere()) {
34
- return packDir(repoRoot, DEFAULT_PACK);
35
- }
36
-
37
33
  function validateTaskSpec(spec, filePath) {
38
34
  if (!spec || typeof spec !== 'object' || Array.isArray(spec)) {
39
35
  throw new BenchInfraError(`${filePath}: task spec must export an object`);
@@ -341,34 +341,6 @@ function writeLatestScan(root, report) {
341
341
  return latestPath;
342
342
  }
343
343
 
344
- function readLatestScan(root) {
345
- const latestPath = path.join(root, '.atris', 'state', 'chat_scan.latest.json');
346
- try {
347
- if (!fs.existsSync(latestPath)) return null;
348
- return JSON.parse(fs.readFileSync(latestPath, 'utf8'));
349
- } catch {
350
- return null;
351
- }
352
- }
353
-
354
- function ensureChatScan(root, options = {}) {
355
- const maxAgeMs = Number(options.maxAgeMs ?? 3600000);
356
- const existing = readLatestScan(root);
357
- if (existing && existing.scanned_at && !options.force) {
358
- const age = Date.now() - new Date(existing.scanned_at).getTime();
359
- if (Number.isFinite(age) && age >= 0 && age <= maxAgeMs) {
360
- return existing;
361
- }
362
- }
363
- const report = scanChatLogs({ cwd: root, hours: options.hours, limit: options.limit });
364
- if (options.write !== false) writeLatestScan(root, report);
365
- return report;
366
- }
367
-
368
- function topChatFindings(report, limit = 3) {
369
- return (report && Array.isArray(report.findings) ? report.findings : []).slice(0, limit);
370
- }
371
-
372
344
  module.exports = {
373
345
  SCHEMA,
374
346
  scanChatLogs,
package/lib/cli-scope.js CHANGED
@@ -16,8 +16,10 @@ function pathUnderRoot(candidate, root) {
16
16
  if (!candidate || !root) return false;
17
17
  const resolvedCandidate = path.resolve(String(candidate));
18
18
  const resolvedRoot = path.resolve(String(root));
19
- return resolvedCandidate === resolvedRoot
20
- || resolvedCandidate.startsWith(`${resolvedRoot}${path.sep}`);
19
+ if (resolvedCandidate === resolvedRoot) return true;
20
+ // A filesystem root already ends in a separator ('/' or 'C:\\').
21
+ const prefix = resolvedRoot.endsWith(path.sep) ? resolvedRoot : `${resolvedRoot}${path.sep}`;
22
+ return resolvedCandidate.startsWith(prefix);
21
23
  }
22
24
 
23
25
  module.exports = {
@@ -1748,10 +1748,6 @@ function engineRegistryView(root = process.cwd()) {
1748
1748
  return readEngineRegistry(root).engines;
1749
1749
  }
1750
1750
 
1751
- function registeredEngineIds(root = process.cwd()) {
1752
- return engineRegistryView(root).map((engine) => engine.id);
1753
- }
1754
-
1755
1751
  function resolveRegisteredEngine(name, root = process.cwd()) {
1756
1752
  const requested = String(name || '').trim();
1757
1753
  const id = canonicalEngineName(requested);
package/lib/file-ops.js CHANGED
@@ -9,7 +9,12 @@ const escapeRegExp = require('./escape-regexp');
9
9
  */
10
10
  function getLogPath(dateStr) {
11
11
  const targetDir = path.join(process.cwd(), 'atris');
12
- const date = dateStr ? new Date(dateStr) : new Date();
12
+ // A bare YYYY-MM-DD is that calendar day here. new Date('2026-10-22') is
13
+ // UTC midnight, which is the evening before anywhere west of Greenwich.
14
+ const dayOnly = typeof dateStr === 'string' && dateStr.match(/^(\d{4})-(\d{2})-(\d{2})$/);
15
+ const date = dayOnly
16
+ ? new Date(Number(dayOnly[1]), Number(dayOnly[2]) - 1, Number(dayOnly[3]))
17
+ : (dateStr ? new Date(dateStr) : new Date());
13
18
  const year = date.getFullYear();
14
19
  const month = String(date.getMonth() + 1).padStart(2, '0');
15
20
  const day = String(date.getDate()).padStart(2, '0');
@@ -178,6 +178,8 @@ function loadLocalTasks(root = process.cwd()) {
178
178
  let dbRows = [];
179
179
  try {
180
180
  const taskDb = require('./task-db');
181
+ // Reading never creates the db, so a first look writes nothing.
182
+ if (!fs.existsSync(taskDb.getDbPath())) throw new Error('no task db yet');
181
183
  const db = taskDb.open();
182
184
  const workspaceRoot = taskDb.workspaceRoot(root);
183
185
  const rows = taskDb.listTasks(db, { workspaceRoot, limit: 200 });
package/lib/gm-bet.js CHANGED
@@ -152,6 +152,8 @@ function readScoreboard(file, workspaceRoot) {
152
152
  if (!row || row.workspace !== workspaceRoot) continue;
153
153
  if (row.kind === 'pick' && row.name) board = { name: row.name, up: row.up !== false, source: row.source === 'installs' ? 'installs' : 'reported', at: row.at, reports: [] };
154
154
  else if (row.kind === 'report' && board && Number.isFinite(row.value)) board.reports.push({ value: row.value, at: row.at });
155
+ // the owner stopped tracking it: no number until one is picked again
156
+ else if (row.kind === 'clear') board = null;
155
157
  }
156
158
  return board;
157
159
  }
@@ -348,6 +350,8 @@ function buildQuestionRewrite(previous, reasons) {
348
350
 
349
351
  const SAY_READ_MS = 15000;
350
352
  const SAY_KINDS = new Set(['question', 'answer', 'instruction', 'unclear']);
353
+ // on the weekly number card, what an instruction wants done with the number
354
+ const NUMBER_INTENTS = new Set(['stop', 'change', 'later', 'other']);
351
355
  const SAY_FALLBACK = 'say that another way, or tap a button.';
352
356
  // the last turns of a card's conversation the reader sees
353
357
  const THREAD_TURNS = 6;
@@ -380,6 +384,7 @@ async function readSaid({ text, card, thread = [], hint = null, workspaceRoot, b
380
384
  '- unclear: you cannot tell. reply: one short question back.',
381
385
  '- a message that ends with "?" or asks why, what, or how is a question back, never an answer.',
382
386
  '- you may add "suggestions": 0 to 3 short replies he could tap next, each under 40 characters.',
387
+ card.kind === 'number' ? '- on this number card an instruction also has "intent": "stop" when he wants to stop tracking this number, "change" when he wants a different number, "later" when he wants to be asked another time, else "other". for "other", reply says what this card can do.' : '',
383
388
  `- plain lowercase-friendly words, no em dashes, and never these words: ${OWNER_BANNED_WORDS.join(', ')}.`,
384
389
  '',
385
390
  `## the card on screen (${card.kind})`,
@@ -404,7 +409,8 @@ async function readSaid({ text, card, thread = [], hint = null, workspaceRoot, b
404
409
  const v = parsed.bet;
405
410
  if (!v || !SAY_KINDS.has(v.kind) || typeof v.reply !== 'string') return { ok: false, reason: 'the read was not the shape asked for' };
406
411
  const suggestions = Array.isArray(v.suggestions) ? v.suggestions.filter(x => typeof x === 'string' && x.trim()).map(x => x.trim()) : [];
407
- return { ok: true, kind: v.kind, reply: clip(v.reply, 280), ...(suggestions.length ? { suggestions } : {}) };
412
+ const intent = card.kind === 'number' && NUMBER_INTENTS.has(v.intent) ? v.intent : null;
413
+ return { ok: true, kind: v.kind, reply: clip(v.reply, 280), ...(intent ? { intent } : {}), ...(suggestions.length ? { suggestions } : {}) };
408
414
  }
409
415
 
410
416
  // Two or three numbers that would tell the owner this project is going well,
@@ -2709,10 +2715,11 @@ function createBetDesk(deps) {
2709
2715
  question: { key: 'scoreboard', ask: SCOREBOARD_ASK, line: null, choices: [...list.map((c, i) => ({ id: `s${i + 1}`, label: c.name })), { id: 'other', label: 'something else', other: true }], numbers: list },
2710
2716
  };
2711
2717
  }
2712
- if (board.source !== 'reported' || state.passedQuestions.has('report')) return null;
2718
+ const today = dayKey(now());
2719
+ // passed over today (not now, or a tap past it): asked again tomorrow
2720
+ if (board.source !== 'reported' || state.passedQuestions.has(`report:${today}`)) return null;
2713
2721
  const last = board.reports[board.reports.length - 1] || null;
2714
2722
  const weekly = !last || now() - (Date.parse(last.at) || 0) >= WEEK_MS;
2715
- const today = dayKey(now());
2716
2723
  const waiting = readLedger(deps.ledgerFile, deps.workspaceRoot).some(r => r.status === 'doing' && r.bet && r.bet.target && r.bet.target.metric === 'scoreboard'
2717
2724
  && String(r.bet.check_date || '') < today && !board.reports.some(x => String(x.at).slice(0, 10) >= r.bet.check_date));
2718
2725
  if (!weekly && !waiting) return null;
@@ -2860,7 +2867,7 @@ function createBetDesk(deps) {
2860
2867
  // a re-tap on a job card moves on: a yes waits for a later session, a done is seen
2861
2868
  function passJob(item) {
2862
2869
  if (item.kind === 'wait') return;
2863
- if (item.kind === 'report') { state.passedQuestions.add('report'); return; }
2870
+ if (item.kind === 'report') { state.passedQuestions.add(item.id); return; }
2864
2871
  if (item.kind === 'choice') {
2865
2872
  for (const i of item.items) record(i.id, { status: 'passed', note: 'not picked' });
2866
2873
  log({ card: 'bet', choice: 'pass', command: null, ran: 'in process', exit_code: 0, kind: 'choice' });
@@ -3279,7 +3286,7 @@ function createBetDesk(deps) {
3279
3286
  if (v.kind === 'question') return { kind: 'question', title: v.question.ask, detail: v.question.line, buttons: v.question.choices.map(c => c.label) };
3280
3287
  if (v.kind === 'bet') return { kind: 'idea', title: v.bet.title, detail: [v.bet.why_now, v.bet.expect].join(' '), buttons: ['do it', 'sharper idea', 'not now'] };
3281
3288
  if (v.kind === 'choice') return { kind: 'choice', title: 'which one next?', detail: v.choice.options.map(o => o.title).join(' / '), buttons: [] };
3282
- if (v.kind === 'report') return { kind: 'number', title: v.report.what, detail: v.report.why, buttons: [] };
3289
+ if (v.kind === 'report') return { kind: 'number', title: v.report.what, detail: v.report.why, buttons: ['save', 'not now'] };
3283
3290
  return { kind: v.kind, title: v.job ? v.job.what : '', detail: v.job ? v.job.why : '', buttons: [] };
3284
3291
  }
3285
3292
 
@@ -3349,6 +3356,35 @@ function createBetDesk(deps) {
3349
3356
  pump();
3350
3357
  return { ok: true, ack: plainReply(read.reply) || 'got it. the next idea will fit that.' };
3351
3358
  }
3359
+ // Words on the weekly number card are about that number, and the
3360
+ // reader says what they want. A plain number is this week's value. Stop
3361
+ // tracking this, or pick a different number, stops tracking it, and the
3362
+ // next card asks which number, if any. Ask me tomorrow is not now. Any
3363
+ // other answer or instruction gets a reply on the card and changes nothing.
3364
+ if (kind === 'report') {
3365
+ const stay = (reply) => {
3366
+ addTurns(t, { who: 'owner', text: said }, { who: 'orb', text: reply });
3367
+ return { ok: true, stay: true, kind: 'unclear', reply, thread: t.turns.slice() };
3368
+ };
3369
+ if (read.kind === 'answer') {
3370
+ const saved = report({ value: said });
3371
+ if (saved.ok) { endThread(); return saved; }
3372
+ return stay('type this week\'s number in the box, or say stop tracking this.');
3373
+ }
3374
+ if (read.intent === 'later') {
3375
+ passJob(cur);
3376
+ state.status = 'cleared';
3377
+ endThread();
3378
+ return { ok: true, ack: 'got it. i will ask tomorrow.' };
3379
+ }
3380
+ if (read.intent !== 'stop' && read.intent !== 'change') return stay(plainReply(read.reply) || 'this card keeps one number a week. say stop tracking this, or ask me tomorrow.');
3381
+ const name = cur.board.name;
3382
+ appendLedger(scoreboardFile, { workspace: deps.workspaceRoot, kind: 'clear', name, at: iso() });
3383
+ log({ card: 'bet', choice: 'scoreboard_clear', command: null, ran: 'in process', exit_code: 0 });
3384
+ state.status = 'cleared';
3385
+ endThread();
3386
+ return { ok: true, ack: `got it. i stopped tracking ${name}.` };
3387
+ }
3352
3388
  if (kind === 'yes' || kind === 'done') {
3353
3389
  const out = await steer({ member: cur.job.owner, text: said, confirm, note });
3354
3390
  if (out.ok) endThread();
@@ -752,15 +752,18 @@ body.has-card .foot { position: static; margin-top: 28px; }
752
752
  b.addEventListener('click', function () {
753
753
  if (bet.busy) return;
754
754
  buzz();
755
- if (c.other) { typeHere(b, c.label, 60, function (text, field) { answer(field, { choice: 'other', text: text }); }); return; }
755
+ // typed words are read first, so a question back is explained and nothing is saved
756
+ if (c.other) { typeHere(b, c.label, 60, function (text, field) { say(field, { text: text }); }); return; }
756
757
  answer(b, { choice: c.id });
757
758
  });
759
+ if (c.other) b.classList.add('other');
758
760
  act.appendChild(b);
759
761
  });
760
762
  // a quiet line under the answers, empty until an answer does not go through
761
763
  act.appendChild(el('div', 'links'));
762
764
  box.appendChild(act);
763
- box.appendChild(sayBox());
765
+ // its own something else box is the one place to type
766
+ if (!q.choices.some(function (c) { return c.other; })) box.appendChild(sayBox());
764
767
  }
765
768
 
766
769
  // ---- answering in place: the eyes stay in one spot ----------------------------
@@ -948,7 +951,7 @@ body.has-card .foot { position: static; margin-top: 28px; }
948
951
  act.appendChild(sayChoice('keep it as a note instead', { text: said, note: true }));
949
952
  }
950
953
  box.appendChild(act);
951
- box.appendChild(sayBox());
954
+ if (!own || !own.querySelector('.other')) box.appendChild(sayBox());
952
955
  }
953
956
 
954
957
  // the owner's own number, once a week: one box and one tap to save
@@ -978,7 +981,15 @@ body.has-card .foot { position: static; margin-top: 28px; }
978
981
  input.addEventListener('keydown', function (e) { if (e.key === 'Enter') { e.preventDefault(); go(); } });
979
982
  act.appendChild(input);
980
983
  act.appendChild(save);
984
+ // a quiet way out: moves on, saves nothing, and is not asked again today
985
+ var links = el('div', 'links');
986
+ var later = el('button', null, 'not now');
987
+ later.type = 'button';
988
+ later.addEventListener('click', function () { if (bet.busy) return; buzz(); think(false, 0); });
989
+ links.appendChild(later);
990
+ act.appendChild(links);
981
991
  box.appendChild(act);
992
+ box.appendChild(sayBox());
982
993
  }
983
994
 
984
995
  // A conversation in one box. While the words are read the reply spot shows