atris 3.33.3 → 3.35.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/AGENTS.md +0 -2
  2. package/FOR_AGENTS.md +5 -3
  3. package/atris/skills/engines/SKILL.md +16 -7
  4. package/atris/skills/render-cli/SKILL.md +88 -0
  5. package/atris.md +19 -0
  6. package/ax +475 -17
  7. package/bin/atris.js +206 -44
  8. package/commands/aeo.js +52 -0
  9. package/commands/autoland.js +313 -19
  10. package/commands/business.js +91 -8
  11. package/commands/codex-goal.js +26 -2
  12. package/commands/drive.js +187 -0
  13. package/commands/engine.js +73 -2
  14. package/commands/feed.js +202 -0
  15. package/commands/github.js +38 -0
  16. package/commands/gm.js +262 -3
  17. package/commands/init.js +13 -1
  18. package/commands/integrations.js +39 -11
  19. package/commands/interview.js +143 -0
  20. package/commands/land.js +114 -13
  21. package/commands/lesson.js +112 -1
  22. package/commands/linear.js +38 -0
  23. package/commands/loops.js +212 -0
  24. package/commands/member.js +398 -42
  25. package/commands/mission.js +598 -64
  26. package/commands/now.js +25 -1
  27. package/commands/radar.js +259 -14
  28. package/commands/serve.js +54 -0
  29. package/commands/status.js +50 -5
  30. package/commands/stripe.js +38 -0
  31. package/commands/supabase.js +39 -0
  32. package/commands/task.js +935 -71
  33. package/commands/truth.js +29 -3
  34. package/commands/unknowns.js +627 -0
  35. package/commands/update.js +44 -0
  36. package/commands/vercel.js +38 -0
  37. package/commands/worktree.js +68 -10
  38. package/commands/write.js +399 -0
  39. package/lib/auto-accept-certified.js +70 -19
  40. package/lib/autoland.js +39 -3
  41. package/lib/fleet.js +256 -13
  42. package/lib/memory-view.js +14 -5
  43. package/lib/mission-runtime-loop.js +7 -0
  44. package/lib/official-cli-integration.js +174 -0
  45. package/lib/outbound-send-gate.js +165 -0
  46. package/lib/permission-grants.js +293 -0
  47. package/lib/review-integrity.js +147 -0
  48. package/lib/runner-command.js +23 -0
  49. package/lib/task-db.js +220 -7
  50. package/lib/task-proof.js +20 -0
  51. package/lib/task-receipt.js +93 -0
  52. package/package.json +1 -1
  53. package/utils/update-check.js +27 -6
  54. package/atris/learnings.jsonl +0 -1
package/commands/truth.js CHANGED
@@ -107,9 +107,34 @@ function taskScopeLine(scope) {
107
107
  return `${taskScopeLabel(scope)} (${scope.workspace_root})`;
108
108
  }
109
109
 
110
+ const PARKED_DAYS = 30;
111
+
112
+ // One git call: every feature dir touched in the last PARKED_DAYS.
113
+ // An unproven feature nobody has edited in a month is a parked idea
114
+ // packet, not work-in-progress — counting it as "unproven" buries the
115
+ // handful of live lanes that actually need a proof receipt.
116
+ function recentlyTouchedFeatureDirs(cwd) {
117
+ try {
118
+ const out = execFileSync(
119
+ 'git',
120
+ ['log', `--since=${PARKED_DAYS}.days`, '--format=', '--name-only', '--', 'atris/features'],
121
+ { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] },
122
+ );
123
+ const dirs = new Set();
124
+ for (const line of out.split('\n')) {
125
+ const m = line.match(/^atris\/features\/([^/]+)\//);
126
+ if (m) dirs.add(m[1]);
127
+ }
128
+ return dirs;
129
+ } catch {
130
+ return null; // no git history to judge by: nothing reads as parked
131
+ }
132
+ }
133
+
110
134
  function loadFeatures(cwd) {
111
135
  const dir = path.join(cwd, 'atris', 'features');
112
136
  if (!fs.existsSync(dir)) return [];
137
+ const recent = recentlyTouchedFeatureDirs(cwd);
113
138
  const out = [];
114
139
  for (const name of fs.readdirSync(dir)) {
115
140
  if (name.startsWith('_')) continue;
@@ -134,6 +159,7 @@ function loadFeatures(cwd) {
134
159
  if (/blocked/i.test(status || '')) verdict = 'blocked';
135
160
  else if (proofAgeDays != null && proofAgeDays <= STALE_DAYS) verdict = 'proven';
136
161
  else if (proofAgeDays != null) verdict = 'stale';
162
+ else if (recent && !recent.has(name)) verdict = 'parked';
137
163
  else verdict = 'unproven';
138
164
  out.push({ lane: name, status: status || '-', verdict, proofAgeDays });
139
165
  }
@@ -199,7 +225,7 @@ function truthCommand(args = []) {
199
225
 
200
226
  const line = (s) => console.log(s);
201
227
  if (summary) {
202
- line(`truth [${taskScopeLabel(scope)}]: features ${featureTally.proven || 0} proven / ${featureTally.stale || 0} stale / ${featureTally.blocked || 0} blocked / ${featureTally.unproven || 0} unproven · loops ${loopTally.proven || 0} live / ${(loopTally.stale || 0) + (loopTally['never-ran'] || 0)} stale / ${loopTally.blocked || 0} failing · missions ${missions.length} active`);
228
+ line(`truth [${taskScopeLabel(scope)}]: features ${featureTally.proven || 0} proven / ${featureTally.stale || 0} stale / ${featureTally.blocked || 0} blocked / ${featureTally.unproven || 0} unproven / ${featureTally.parked || 0} parked · loops ${loopTally.proven || 0} live / ${(loopTally.stale || 0) + (loopTally['never-ran'] || 0)} stale / ${loopTally.blocked || 0} failing · missions ${missions.length} active`);
203
229
  return 0;
204
230
  }
205
231
 
@@ -214,7 +240,7 @@ function truthCommand(args = []) {
214
240
  }
215
241
 
216
242
  line(`\nFeature lanes (${features.length}):`);
217
- const order = { blocked: 0, stale: 1, unproven: 2, proven: 3 };
243
+ const order = { blocked: 0, stale: 1, unproven: 2, proven: 3, parked: 4 };
218
244
  for (const f of features.sort((a, b) => (order[a.verdict] ?? 9) - (order[b.verdict] ?? 9))) {
219
245
  line(` ${f.verdict.padEnd(9)} ${fmtAge(f.proofAgeDays).padStart(6)} ${f.lane}`);
220
246
  }
@@ -224,7 +250,7 @@ function truthCommand(args = []) {
224
250
  line(` ${h.verdict.padEnd(9)} ${fmtAge(h.lastRunAgeDays).padStart(6)} ${h.id}${h.fails ? ` fails=${h.fails}` : ''}`);
225
251
  }
226
252
 
227
- line(`\nVerdict key: proven = receipt within ${STALE_DAYS}d · stale = receipt older · unproven = no receipt ever · blocked = failing now`);
253
+ line(`\nVerdict key: proven = receipt within ${STALE_DAYS}d · stale = receipt older · unproven = no receipt, edited within ${PARKED_DAYS}d · parked = no receipt, untouched ${PARKED_DAYS}d+ · blocked = failing now`);
228
254
  return 0;
229
255
  }
230
256
 
@@ -0,0 +1,627 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const { spawnSync } = require('child_process');
6
+ const taskDb = require('../lib/task-db');
7
+ const {
8
+ buildRunnerAvailabilityCommand,
9
+ buildRunnerCommand,
10
+ runnerAvailabilityFailureMessage,
11
+ } = require('../lib/runner-command');
12
+
13
+ const UNKNOWN_KINDS = new Set(['known_unknown', 'unknown_known', 'unknown_unknown']);
14
+ const STAKES = new Set(['reversible', 'costly', 'burnable_once']);
15
+ const MODEL_TIMEOUT_MS = 180000;
16
+ const READ_LIMIT = 12000;
17
+
18
+ const UNKNOWN_SCHEMA = `
19
+ CREATE TABLE IF NOT EXISTS unknowns (
20
+ id TEXT PRIMARY KEY,
21
+ created_at TEXT NOT NULL,
22
+ workspace_root TEXT NOT NULL,
23
+ problem TEXT NOT NULL,
24
+ kind TEXT NOT NULL,
25
+ text TEXT NOT NULL,
26
+ stakes TEXT NOT NULL,
27
+ status TEXT NOT NULL DEFAULT 'open',
28
+ resolution TEXT,
29
+ resolved_at TEXT
30
+ );
31
+ CREATE INDEX IF NOT EXISTS idx_unknowns_workspace_status ON unknowns(workspace_root, status, created_at);
32
+ CREATE INDEX IF NOT EXISTS idx_unknowns_status ON unknowns(status, created_at);
33
+ `;
34
+
35
+ function showHelp() {
36
+ console.log('');
37
+ console.log('Usage: atris unknowns "<problem statement>"');
38
+ console.log(' atris unknowns list [--all]');
39
+ console.log(' atris unknowns resolve <id> "<what we learned>"');
40
+ console.log('');
41
+ console.log('Runs a blindspot pass, writes unknowns to the global task DB, and renders .atris/state/unknowns.md.');
42
+ console.log('');
43
+ }
44
+
45
+ function ensureUnknownsSchema(db) {
46
+ db.exec(UNKNOWN_SCHEMA);
47
+ }
48
+
49
+ function clip(value, max = READ_LIMIT) {
50
+ const text = String(value || '');
51
+ if (text.length <= max) return text;
52
+ return `${text.slice(0, Math.max(0, max - 24)).trim()}\n[...truncated]`;
53
+ }
54
+
55
+ function clipLine(value, max = 160) {
56
+ const text = String(value || '').replace(/\s+/g, ' ').trim();
57
+ if (text.length <= max) return text;
58
+ return `${text.slice(0, Math.max(0, max - 3)).trim()}...`;
59
+ }
60
+
61
+ function readFileBestEffort(file, options = {}) {
62
+ try {
63
+ const text = fs.readFileSync(file, 'utf8');
64
+ if (options.lines) return text.split(/\r?\n/).slice(0, options.lines).join('\n');
65
+ return clip(text, options.maxChars || READ_LIMIT);
66
+ } catch {
67
+ return '';
68
+ }
69
+ }
70
+
71
+ function section(title, content) {
72
+ const body = String(content || '').trim();
73
+ return `## ${title}\n${body || '(missing)'}`;
74
+ }
75
+
76
+ function walkFiles(dir, predicate, out = []) {
77
+ if (!fs.existsSync(dir)) return out;
78
+ let entries = [];
79
+ try {
80
+ entries = fs.readdirSync(dir, { withFileTypes: true });
81
+ } catch {
82
+ return out;
83
+ }
84
+ for (const entry of entries) {
85
+ const full = path.join(dir, entry.name);
86
+ if (entry.isDirectory()) {
87
+ walkFiles(full, predicate, out);
88
+ } else if (predicate(full, entry.name)) {
89
+ out.push(full);
90
+ }
91
+ }
92
+ return out;
93
+ }
94
+
95
+ function readRecentJournals(root) {
96
+ const logsDir = path.join(root, 'atris', 'logs');
97
+ const files = walkFiles(logsDir, (_full, name) => /^\d{4}-\d{2}-\d{2}\.md$/.test(name))
98
+ .map(file => {
99
+ let mtime = 0;
100
+ try { mtime = fs.statSync(file).mtimeMs; } catch {}
101
+ return { file, mtime, name: path.basename(file) };
102
+ })
103
+ .sort((a, b) => b.name.localeCompare(a.name) || b.mtime - a.mtime)
104
+ .slice(0, 3);
105
+
106
+ if (!files.length) return '';
107
+ return files.map(({ file }) => {
108
+ const rel = path.relative(root, file);
109
+ return `### ${rel}\n${readFileBestEffort(file, { maxChars: 6000 })}`;
110
+ }).join('\n\n');
111
+ }
112
+
113
+ function runGitLog(root) {
114
+ const result = spawnSync('git', ['log', '--oneline', '-15'], {
115
+ cwd: root,
116
+ encoding: 'utf8',
117
+ stdio: ['ignore', 'pipe', 'ignore'],
118
+ timeout: 15000,
119
+ });
120
+ if (result.status !== 0) return '';
121
+ return String(result.stdout || '').trim();
122
+ }
123
+
124
+ function parsePayload(payload) {
125
+ try { return JSON.parse(payload); } catch { return payload; }
126
+ }
127
+
128
+ function readTaskEvents(db, workspaceRoot) {
129
+ try {
130
+ const stmt = db.prepare(`
131
+ SELECT event_id, task_id, version, workspace_root, actor, event_type, payload, created_at
132
+ FROM task_events
133
+ WHERE workspace_root = ?
134
+ ORDER BY created_at DESC
135
+ LIMIT 20
136
+ `);
137
+ return stmt.all(workspaceRoot);
138
+ } catch {
139
+ // If the schema exists but the workspace filter cannot run for any reason,
140
+ // keep the context best-effort by falling back to the recent global ledger.
141
+ }
142
+
143
+ try {
144
+ return db.prepare(`
145
+ SELECT event_id, task_id, version, workspace_root, actor, event_type, payload, created_at
146
+ FROM task_events
147
+ ORDER BY created_at DESC
148
+ LIMIT 20
149
+ `).all();
150
+ } catch {
151
+ return '';
152
+ }
153
+ }
154
+
155
+ function formatTaskEvents(rows) {
156
+ if (!Array.isArray(rows) || !rows.length) return '';
157
+ return rows.map(row => {
158
+ const at = Number(row.created_at || 0) ? new Date(Number(row.created_at)).toISOString() : String(row.created_at || '');
159
+ const payload = parsePayload(row.payload || '');
160
+ const payloadText = typeof payload === 'string' ? payload : JSON.stringify(payload);
161
+ return [
162
+ `- ${at} ${row.event_type || 'event'} task=${row.task_id || '?'} actor=${row.actor || '?'}`,
163
+ ` ${clipLine(payloadText, 220)}`,
164
+ ].join('\n');
165
+ }).join('\n');
166
+ }
167
+
168
+ function gatherTerritoryContext(root, db) {
169
+ const map = readFileBestEffort(path.join(root, 'atris', 'MAP.md'), { lines: 100 });
170
+ const lessonsMd = readFileBestEffort(path.join(root, 'atris', 'lessons.md'), { maxChars: READ_LIMIT });
171
+ const lessonsJson = readFileBestEffort(path.join(root, 'atris', 'lessons.json'), { maxChars: READ_LIMIT });
172
+ const journals = readRecentJournals(root);
173
+ const gitLog = runGitLog(root);
174
+ const taskEvents = formatTaskEvents(readTaskEvents(db, root));
175
+
176
+ return [
177
+ section('workspace_root', root),
178
+ section('atris/MAP.md first 100 lines', map),
179
+ section('atris/lessons.md', lessonsMd),
180
+ section('atris/lessons.json', lessonsJson),
181
+ section('last 3 daily journals', journals),
182
+ section('git log --oneline -15', gitLog),
183
+ section('last 20 task_events', taskEvents),
184
+ ].join('\n\n');
185
+ }
186
+
187
+ function buildUnknownsPrompt(problem, territoryContext) {
188
+ return `You are the Atris blindspot-pass engine.
189
+
190
+ Problem statement:
191
+ ${problem}
192
+
193
+ Territory context:
194
+ ${territoryContext}
195
+
196
+ Enumerate the blindspots that matter before the operator plans work:
197
+ - known_unknown: something the operator already knows they do not know.
198
+ - unknown_known: something likely present in the territory that the operator would recognize on sight if named.
199
+ - unknown_unknown: a likely blindspot inferred from weak signals, missing evidence, or failure modes.
200
+
201
+ Then choose the TOP 3 highest-leverage questions whose answers would most change the plan. For each question, include one concrete confirm test and one concrete kill test.
202
+
203
+ Output STRICT JSON ONLY. No prose, no markdown, no code fences. Use exactly this shape:
204
+ {
205
+ "unknowns": [
206
+ {
207
+ "kind": "known_unknown|unknown_known|unknown_unknown",
208
+ "text": "specific unknown",
209
+ "stakes": "reversible|costly|burnable_once"
210
+ }
211
+ ],
212
+ "top_questions": [
213
+ {
214
+ "question": "highest leverage question",
215
+ "confirm_test": "concrete test that would confirm the risk/opportunity",
216
+ "kill_test": "concrete test that would kill or deprioritize it"
217
+ }
218
+ ]
219
+ }
220
+
221
+ Rules:
222
+ - Return at least one unknown when the context is thin.
223
+ - top_questions must contain exactly 3 items.
224
+ - stakes must be one of: reversible, costly, burnable_once.
225
+ - kind must be one of: known_unknown, unknown_known, unknown_unknown.
226
+ - Do not invent source facts. Mark uncertainty in the text when evidence is thin.`;
227
+ }
228
+
229
+ function runRunnerPrompt(root, prompt) {
230
+ let availabilityCommand;
231
+ try {
232
+ availabilityCommand = buildRunnerAvailabilityCommand();
233
+ } catch (err) {
234
+ return { ok: false, reason: runnerAvailabilityFailureMessage(err) };
235
+ }
236
+
237
+ const availability = spawnSync(availabilityCommand, [], {
238
+ cwd: root,
239
+ encoding: 'utf8',
240
+ shell: true,
241
+ stdio: ['ignore', 'pipe', 'pipe'],
242
+ timeout: 10000,
243
+ });
244
+ if (availability.error || availability.status !== 0) {
245
+ return {
246
+ ok: false,
247
+ reason: runnerAvailabilityFailureMessage(availability.error || new Error(availability.stderr || 'runner unavailable')),
248
+ };
249
+ }
250
+
251
+ const stateDir = path.join(root, '.atris', 'state');
252
+ fs.mkdirSync(stateDir, { recursive: true });
253
+ const promptFile = path.join(stateDir, 'unknowns-prompt.tmp');
254
+ fs.writeFileSync(promptFile, prompt, 'utf8');
255
+
256
+ try {
257
+ const cmd = buildRunnerCommand({ promptFile });
258
+ const env = { ...process.env };
259
+ delete env.CLAUDECODE;
260
+ const result = spawnSync(cmd, [], {
261
+ cwd: root,
262
+ encoding: 'utf8',
263
+ shell: true,
264
+ stdio: ['ignore', 'pipe', 'pipe'],
265
+ timeout: MODEL_TIMEOUT_MS,
266
+ maxBuffer: 10 * 1024 * 1024,
267
+ env,
268
+ });
269
+ if (result.error) return { ok: false, reason: result.error.message || 'runner failed' };
270
+ if (result.status !== 0) {
271
+ return { ok: false, reason: clipLine(result.stderr || result.stdout || `runner exited ${result.status}`, 300) };
272
+ }
273
+ return { ok: true, output: String(result.stdout || '').trim() };
274
+ } finally {
275
+ try { fs.unlinkSync(promptFile); } catch {}
276
+ }
277
+ }
278
+
279
+ function stripJsonFence(text) {
280
+ return String(text || '')
281
+ .replace(/^\s*```(?:json)?\s*/i, '')
282
+ .replace(/\s*```\s*$/i, '')
283
+ .trim();
284
+ }
285
+
286
+ function parseModelJson(output) {
287
+ const stripped = stripJsonFence(output);
288
+ try {
289
+ return JSON.parse(stripped);
290
+ } catch {}
291
+
292
+ const start = stripped.indexOf('{');
293
+ const end = stripped.lastIndexOf('}');
294
+ if (start !== -1 && end > start) {
295
+ return JSON.parse(stripped.slice(start, end + 1));
296
+ }
297
+ throw new Error('runner returned no parseable JSON object');
298
+ }
299
+
300
+ function normalizeKind(value, fallback) {
301
+ const normalized = String(value || '').toLowerCase().replace(/[\s-]+/g, '_');
302
+ return UNKNOWN_KINDS.has(normalized) ? normalized : fallback;
303
+ }
304
+
305
+ function normalizeStakes(value) {
306
+ const normalized = String(value || '').toLowerCase().replace(/[\s-]+/g, '_');
307
+ return STAKES.has(normalized) ? normalized : 'costly';
308
+ }
309
+
310
+ function collectCategorizedUnknowns(payload) {
311
+ const pairs = [
312
+ ['known_unknown', payload.known_unknowns],
313
+ ['known_unknown', payload.knownUnknowns],
314
+ ['unknown_known', payload.unknown_knowns],
315
+ ['unknown_known', payload.unknownKnowns],
316
+ ['unknown_unknown', payload.unknown_unknowns],
317
+ ['unknown_unknown', payload.unknownUnknowns],
318
+ ];
319
+ const rows = [];
320
+ for (const [kind, value] of pairs) {
321
+ if (!Array.isArray(value)) continue;
322
+ for (const item of value) {
323
+ if (typeof item === 'string') rows.push({ kind, text: item, stakes: 'costly' });
324
+ else if (item && typeof item === 'object') rows.push({ kind, ...item });
325
+ }
326
+ }
327
+ return rows;
328
+ }
329
+
330
+ function normalizeUnknowns(payload) {
331
+ const raw = Array.isArray(payload.unknowns) ? payload.unknowns : collectCategorizedUnknowns(payload);
332
+ return raw.map((item) => {
333
+ const value = typeof item === 'string' ? { text: item } : (item || {});
334
+ const text = String(value.text || value.unknown || value.question || '').replace(/\s+/g, ' ').trim();
335
+ if (!text) return null;
336
+ return {
337
+ kind: normalizeKind(value.kind, 'unknown_unknown'),
338
+ text,
339
+ stakes: normalizeStakes(value.stakes),
340
+ };
341
+ }).filter(Boolean);
342
+ }
343
+
344
+ function normalizeQuestions(payload, problem) {
345
+ const raw = payload.top_questions || payload.topQuestions || payload.questions || [];
346
+ const questions = Array.isArray(raw) ? raw.map(item => {
347
+ const value = typeof item === 'string' ? { question: item } : (item || {});
348
+ const question = String(value.question || value.text || '').replace(/\s+/g, ' ').trim();
349
+ if (!question) return null;
350
+ return {
351
+ question,
352
+ confirm_test: String(value.confirm_test || value.confirmTest || value.confirm || '').replace(/\s+/g, ' ').trim() || 'Find a concrete receipt that answers this before planning.',
353
+ kill_test: String(value.kill_test || value.killTest || value.kill || '').replace(/\s+/g, ' ').trim() || 'Fail to find the receipt in the current workspace evidence.',
354
+ };
355
+ }).filter(Boolean) : [];
356
+
357
+ const fallback = fallbackQuestions(problem);
358
+ while (questions.length < 3) questions.push(fallback[questions.length]);
359
+ return questions.slice(0, 3);
360
+ }
361
+
362
+ function fallbackQuestions(problem) {
363
+ const subject = (clipLine(problem, 90) || 'this problem').replace(/[?!.]+$/, '');
364
+ return [
365
+ {
366
+ question: `What existing code path already owns "${subject}"?`,
367
+ confirm_test: 'Find one current command, helper, or task event that already implements the closest version of the behavior.',
368
+ kill_test: 'No owner path appears in MAP.md, git history, or task events after a focused search.',
369
+ },
370
+ {
371
+ question: 'What would make this change costly to reverse after one use?',
372
+ confirm_test: 'Identify a persisted state write, schema change, external side effect, or user-facing contract that cannot be rolled back cleanly.',
373
+ kill_test: 'All effects are local, idempotent, or behind an existing reversible command path.',
374
+ },
375
+ {
376
+ question: 'What proof would change the plan before implementation starts?',
377
+ confirm_test: 'A dry run, fixture, or smoke command exposes a failing assumption in the proposed path.',
378
+ kill_test: 'The dry run exercises the main path and no assumption changes.',
379
+ },
380
+ ];
381
+ }
382
+
383
+ function fallbackPayload(problem, reason) {
384
+ return {
385
+ model_unavailable: true,
386
+ unavailable_reason: reason,
387
+ unknowns: [{
388
+ kind: 'unknown_unknown',
389
+ text: `Model unavailable for blindspot pass. Re-run after configuring the shared runner/API key. Reason: ${clipLine(reason || 'unknown', 220)}`,
390
+ stakes: 'costly',
391
+ }],
392
+ top_questions: fallbackQuestions(problem),
393
+ };
394
+ }
395
+
396
+ function analyzeWithModel(root, problem, territoryContext) {
397
+ const prompt = buildUnknownsPrompt(problem, territoryContext);
398
+ const model = runRunnerPrompt(root, prompt);
399
+ if (!model.ok) return fallbackPayload(problem, model.reason);
400
+
401
+ try {
402
+ const parsed = parseModelJson(model.output);
403
+ const unknowns = normalizeUnknowns(parsed);
404
+ if (!unknowns.length) return fallbackPayload(problem, 'runner returned no unknown rows');
405
+ return {
406
+ unknowns,
407
+ top_questions: normalizeQuestions(parsed, problem),
408
+ };
409
+ } catch (err) {
410
+ return fallbackPayload(problem, err.message || 'runner JSON parse failed');
411
+ }
412
+ }
413
+
414
+ function insertUnknowns(db, { workspaceRoot, problem, unknowns }) {
415
+ const now = new Date().toISOString();
416
+ const stmt = db.prepare(`
417
+ INSERT INTO unknowns (id, created_at, workspace_root, problem, kind, text, stakes, status, resolution, resolved_at)
418
+ VALUES (?, ?, ?, ?, ?, ?, ?, 'open', NULL, NULL)
419
+ `);
420
+ const rows = [];
421
+ for (const unknown of unknowns) {
422
+ const row = {
423
+ id: taskDb.newId(),
424
+ created_at: now,
425
+ workspace_root: workspaceRoot,
426
+ problem,
427
+ kind: normalizeKind(unknown.kind, 'unknown_unknown'),
428
+ text: String(unknown.text || '').replace(/\s+/g, ' ').trim(),
429
+ stakes: normalizeStakes(unknown.stakes),
430
+ status: 'open',
431
+ resolution: null,
432
+ resolved_at: null,
433
+ };
434
+ if (!row.text) continue;
435
+ stmt.run(row.id, row.created_at, row.workspace_root, row.problem, row.kind, row.text, row.stakes);
436
+ rows.push(row);
437
+ }
438
+ return rows;
439
+ }
440
+
441
+ function listUnknownRows(db, { workspaceRoot, all = false, status, limit = 500 } = {}) {
442
+ const where = [];
443
+ const args = [];
444
+ if (!all && workspaceRoot) {
445
+ where.push('workspace_root = ?');
446
+ args.push(workspaceRoot);
447
+ }
448
+ if (status) {
449
+ where.push('status = ?');
450
+ args.push(status);
451
+ }
452
+ args.push(Number(limit) || 500);
453
+ return db.prepare(`
454
+ SELECT id, created_at, workspace_root, problem, kind, text, stakes, status, resolution, resolved_at
455
+ FROM unknowns
456
+ ${where.length ? `WHERE ${where.join(' AND ')}` : ''}
457
+ ORDER BY CASE status WHEN 'open' THEN 0 ELSE 1 END, created_at DESC
458
+ LIMIT ?
459
+ `).all(...args);
460
+ }
461
+
462
+ function renderUnknownsMarkdown(db, workspaceRoot) {
463
+ const rows = listUnknownRows(db, { workspaceRoot, status: null, limit: 1000 });
464
+ const lines = [
465
+ '# Unknowns Ledger',
466
+ '',
467
+ '> Rendered from SQLite. Do not edit this file as source of truth.',
468
+ '',
469
+ `Generated: ${new Date().toISOString()}`,
470
+ `Workspace: ${workspaceRoot}`,
471
+ '',
472
+ ];
473
+
474
+ for (const status of ['open', 'resolved']) {
475
+ const group = rows.filter(row => row.status === status);
476
+ lines.push(`## ${status === 'open' ? 'Open' : 'Resolved'}`, '');
477
+ if (!group.length) {
478
+ lines.push('(none)', '');
479
+ continue;
480
+ }
481
+ for (const row of group) {
482
+ lines.push(`- **${row.id}** [${row.kind}/${row.stakes}] ${row.text}`);
483
+ lines.push(` Problem: ${clipLine(row.problem, 180)}`);
484
+ if (row.resolution) lines.push(` Resolution: ${clipLine(row.resolution, 220)}`);
485
+ lines.push('');
486
+ }
487
+ }
488
+
489
+ const out = path.join(workspaceRoot, '.atris', 'state', 'unknowns.md');
490
+ fs.mkdirSync(path.dirname(out), { recursive: true });
491
+ fs.writeFileSync(out, lines.join('\n').replace(/\n{3,}/g, '\n\n'), 'utf8');
492
+ return out;
493
+ }
494
+
495
+ function printSummary(questions, count) {
496
+ console.log('Top questions:');
497
+ questions.slice(0, 3).forEach((item, index) => {
498
+ console.log(`${index + 1}. ${item.question}`);
499
+ console.log(` Confirm: ${item.confirm_test}`);
500
+ console.log(` Kill: ${item.kill_test}`);
501
+ });
502
+ console.log('');
503
+ console.log(`${count} unknown${count === 1 ? '' : 's'} written to ledger`);
504
+ }
505
+
506
+ function printList(rows, { all = false } = {}) {
507
+ if (!rows.length) {
508
+ console.log('No open unknowns.');
509
+ return;
510
+ }
511
+ for (const row of rows) {
512
+ const ws = all ? ` ${row.workspace_root}` : '';
513
+ console.log(`${row.id} ${row.kind} ${row.stakes}${ws}`);
514
+ console.log(` ${row.text}`);
515
+ }
516
+ }
517
+
518
+ function findUnknownByRef(db, id, workspaceRoot) {
519
+ const ref = String(id || '').trim();
520
+ if (!ref) return [];
521
+ const scoped = db.prepare(`
522
+ SELECT id, created_at, workspace_root, problem, kind, text, stakes, status, resolution, resolved_at
523
+ FROM unknowns
524
+ WHERE workspace_root = ?
525
+ AND (id = ? OR id LIKE ?)
526
+ ORDER BY created_at DESC
527
+ `).all(workspaceRoot, ref, `${ref}%`);
528
+ if (scoped.length) return scoped;
529
+ return db.prepare(`
530
+ SELECT id, created_at, workspace_root, problem, kind, text, stakes, status, resolution, resolved_at
531
+ FROM unknowns
532
+ WHERE id = ? OR id LIKE ?
533
+ ORDER BY created_at DESC
534
+ `).all(ref, `${ref}%`);
535
+ }
536
+
537
+ function resolveUnknown(db, { id, resolution, workspaceRoot }) {
538
+ const rows = findUnknownByRef(db, id, workspaceRoot);
539
+ if (!rows.length) return { ok: false, reason: 'not_found' };
540
+ if (rows.length > 1) return { ok: false, reason: 'ambiguous', rows };
541
+
542
+ const row = rows[0];
543
+ const now = new Date().toISOString();
544
+ const text = String(resolution || '').trim();
545
+ const appended = row.resolution
546
+ ? `${row.resolution}\n\n${now} ${text}`
547
+ : `${now} ${text}`;
548
+ db.prepare(`
549
+ UPDATE unknowns
550
+ SET status = 'resolved',
551
+ resolution = ?,
552
+ resolved_at = ?
553
+ WHERE id = ?
554
+ `).run(appended, now, row.id);
555
+ renderUnknownsMarkdown(db, row.workspace_root);
556
+ return { ok: true, row: { ...row, status: 'resolved', resolution: appended, resolved_at: now } };
557
+ }
558
+
559
+ async function unknownsCommand(args = []) {
560
+ if (args.includes('--help') || args.includes('-h') || args[0] === 'help') {
561
+ showHelp();
562
+ return 0;
563
+ }
564
+
565
+ const root = taskDb.workspaceRoot(process.cwd());
566
+ const db = taskDb.open();
567
+ ensureUnknownsSchema(db);
568
+
569
+ const sub = args[0];
570
+ if (sub === 'list') {
571
+ const all = args.includes('--all');
572
+ const rows = listUnknownRows(db, { workspaceRoot: root, all, status: 'open' });
573
+ printList(rows, { all });
574
+ return 0;
575
+ }
576
+
577
+ if (sub === 'resolve') {
578
+ const id = args[1];
579
+ const resolution = args.slice(2).join(' ').trim();
580
+ if (!id || !resolution) {
581
+ console.error('Usage: atris unknowns resolve <id> "<what we learned>"');
582
+ return 1;
583
+ }
584
+ const result = resolveUnknown(db, { id, resolution, workspaceRoot: root });
585
+ if (!result.ok) {
586
+ if (result.reason === 'ambiguous') {
587
+ console.error(`Ambiguous unknown id "${id}". Matches: ${result.rows.map(row => row.id).join(', ')}`);
588
+ } else {
589
+ console.error(`Unknown not found: ${id}`);
590
+ }
591
+ return 1;
592
+ }
593
+ console.log(`resolved ${result.row.id}`);
594
+ return 0;
595
+ }
596
+
597
+ const problem = args.join(' ').trim();
598
+ if (!problem) {
599
+ showHelp();
600
+ return 1;
601
+ }
602
+
603
+ const territoryContext = gatherTerritoryContext(root, db);
604
+ const analysis = analyzeWithModel(root, problem, territoryContext);
605
+ const unknowns = normalizeUnknowns(analysis);
606
+ const questions = normalizeQuestions(analysis, problem);
607
+ const rows = insertUnknowns(db, { workspaceRoot: root, problem, unknowns });
608
+ renderUnknownsMarkdown(db, root);
609
+ printSummary(questions, rows.length);
610
+ return 0;
611
+ }
612
+
613
+ module.exports = {
614
+ unknownsCommand,
615
+ showHelp,
616
+ ensureUnknownsSchema,
617
+ gatherTerritoryContext,
618
+ buildUnknownsPrompt,
619
+ analyzeWithModel,
620
+ normalizeUnknowns,
621
+ normalizeQuestions,
622
+ fallbackPayload,
623
+ insertUnknowns,
624
+ listUnknownRows,
625
+ renderUnknownsMarkdown,
626
+ resolveUnknown,
627
+ };