atris 3.34.0 → 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.
- package/AGENTS.md +0 -2
- package/FOR_AGENTS.md +5 -3
- package/atris/skills/engines/SKILL.md +16 -7
- package/atris/skills/render-cli/SKILL.md +88 -0
- package/atris.md +4 -2
- package/ax +475 -17
- package/bin/atris.js +197 -44
- package/commands/aeo.js +52 -0
- package/commands/autoland.js +313 -19
- package/commands/business.js +91 -8
- package/commands/codex-goal.js +26 -2
- package/commands/drive.js +187 -0
- package/commands/engine.js +73 -2
- package/commands/feed.js +202 -0
- package/commands/github.js +38 -0
- package/commands/gm.js +262 -3
- package/commands/init.js +13 -1
- package/commands/integrations.js +39 -11
- package/commands/interview.js +143 -0
- package/commands/land.js +114 -13
- package/commands/lesson.js +112 -1
- package/commands/linear.js +38 -0
- package/commands/member.js +398 -42
- package/commands/mission.js +554 -64
- package/commands/now.js +25 -1
- package/commands/radar.js +259 -14
- package/commands/serve.js +54 -0
- package/commands/status.js +50 -5
- package/commands/stripe.js +38 -0
- package/commands/supabase.js +39 -0
- package/commands/task.js +935 -71
- package/commands/truth.js +29 -3
- package/commands/unknowns.js +627 -0
- package/commands/update.js +44 -0
- package/commands/vercel.js +38 -0
- package/commands/worktree.js +68 -10
- package/commands/write.js +399 -0
- package/lib/auto-accept-certified.js +70 -19
- package/lib/autoland.js +39 -3
- package/lib/fleet.js +250 -9
- package/lib/memory-view.js +14 -5
- package/lib/mission-runtime-loop.js +7 -0
- package/lib/official-cli-integration.js +174 -0
- package/lib/outbound-send-gate.js +165 -0
- package/lib/permission-grants.js +293 -0
- package/lib/review-integrity.js +147 -0
- package/lib/runner-command.js +23 -0
- package/lib/task-db.js +220 -7
- package/lib/task-proof.js +20 -0
- package/lib/task-receipt.js +93 -0
- package/package.json +1 -1
- package/utils/update-check.js +27 -6
- 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
|
|
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
|
+
};
|