atris 3.35.0 → 3.37.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 +37 -0
- package/README.md +5 -3
- package/atris/GETTING_STARTED.md +1 -1
- package/atris/atris.md +3 -0
- package/atris/policies/day-loop-voice.md +102 -0
- package/atris/policies/outbound-artifact-gate.md +2 -0
- package/atris/skills/design/SKILL.md +56 -32
- package/atris/skills/endgame/SKILL.md +12 -6
- package/atris/skills/engines/SKILL.md +22 -4
- package/atris/skills/fable-method/SKILL.md +66 -0
- package/atris/skills/improve/SKILL.md +65 -45
- package/atris/skills/youtube/SKILL.md +10 -1
- package/atris.md +2 -0
- package/ax +147 -19
- package/bin/atris.js +576 -267
- package/commands/activate.js +194 -88
- package/commands/agents.js +166 -0
- package/commands/autoland.js +459 -107
- package/commands/autopilot-front.js +20 -2
- package/commands/autopilot.js +118 -2
- package/commands/avail.js +407 -0
- package/commands/bench.js +188 -0
- package/commands/brain.js +3 -0
- package/commands/brief.js +651 -0
- package/commands/business-sync.js +192 -6
- package/commands/clean.js +50 -24
- package/commands/close.js +1083 -0
- package/commands/cloud.js +245 -0
- package/commands/compile.js +292 -1
- package/commands/computer.js +150 -3
- package/commands/dream.js +365 -0
- package/commands/drill.js +371 -0
- package/commands/engine.js +993 -32
- package/commands/experiments.js +28 -0
- package/commands/feedback.js +34 -12
- package/commands/fleet-report.js +206 -0
- package/commands/gm.js +23 -0
- package/commands/goal.js +247 -0
- package/commands/improve.js +642 -26
- package/commands/init.js +72 -44
- package/commands/interview.js +67 -1
- package/commands/land.js +152 -52
- package/commands/lifecycle.js +39 -3
- package/commands/log.js +84 -1
- package/commands/loops.js +220 -16
- package/commands/meet.js +220 -0
- package/commands/member.js +511 -34
- package/commands/mission.js +3144 -338
- package/commands/next.js +137 -0
- package/commands/now.js +220 -25
- package/commands/one-lap.js +776 -0
- package/commands/orb.js +314 -0
- package/commands/pack-craft.js +179 -0
- package/commands/pack.js +823 -0
- package/commands/play.js +3 -2
- package/commands/probe.js +30 -3
- package/commands/pulse.js +241 -46
- package/commands/push.js +260 -82
- package/commands/rainmaker.js +49 -0
- package/commands/report.js +415 -0
- package/commands/scout.js +147 -0
- package/commands/search.js +363 -0
- package/commands/skill.js +47 -3
- package/commands/slop.js +50 -2
- package/commands/soul.js +1 -1
- package/commands/stream.js +861 -0
- package/commands/study.js +693 -0
- package/commands/sync.js +67 -54
- package/commands/task.js +1346 -117
- package/commands/team.js +73 -0
- package/commands/verify.js +96 -0
- package/commands/watch.js +303 -0
- package/commands/wish.js +500 -0
- package/commands/workflow.js +11 -5
- package/commands/worktree.js +234 -13
- package/commands/xp.js +29 -11
- package/lib/auto-accept-certified.js +331 -34
- package/lib/autoland.js +319 -54
- package/lib/ax-auto-lane.js +96 -0
- package/lib/bench/context.js +147 -0
- package/lib/bench/engines.js +141 -0
- package/lib/bench/report.js +140 -0
- package/lib/bench/runner.js +512 -0
- package/lib/brief-ledger.js +350 -0
- package/lib/cloud-mission.js +259 -0
- package/lib/codex-flight.js +154 -0
- package/lib/default-runner.js +45 -0
- package/lib/default-verifier.js +70 -0
- package/lib/engine-registry.js +232 -0
- package/lib/experiments/daily.js +640 -0
- package/lib/fleet.js +2219 -67
- package/lib/improve-vitals-html.js +171 -0
- package/lib/known-commands.js +58 -0
- package/lib/loop-doctor.js +416 -0
- package/lib/member-switches.js +144 -0
- package/lib/mission-room.js +1 -0
- package/lib/mission-root.js +52 -0
- package/lib/next-moves.js +327 -10
- package/lib/one-lap-validator.js +60 -0
- package/lib/orb-context.js +477 -0
- package/lib/orb-scorecard.js +224 -0
- package/lib/policy-lessons.js +52 -1
- package/lib/pulse.js +277 -3
- package/lib/receipt-block.js +168 -0
- package/lib/receipt-evidence.js +65 -4
- package/lib/router-brain.js +352 -0
- package/lib/runner-command.js +10 -0
- package/lib/self-drive.js +258 -0
- package/lib/short-name.js +103 -0
- package/lib/spawn-env.js +18 -0
- package/lib/state-detection.js +56 -1
- package/lib/sync-status.js +59 -0
- package/lib/task-db.js +108 -29
- package/lib/task-proof.js +23 -1
- package/lib/team-presence.js +260 -0
- package/lib/tool-result-encode.js +7 -0
- package/lib/trust-tiers.js +90 -0
- package/lib/usage.js +107 -0
- package/lib/voice-gate.js +163 -0
- package/lib/wish-audit.js +1368 -0
- package/lib/wish-delegate.js +1840 -0
- package/lib/wish-design.js +110 -0
- package/lib/wish-stats.js +183 -0
- package/lib/wish-store.js +354 -0
- package/lib/zip.js +221 -0
- package/package.json +3 -1
- package/templates/loops/atris/loops/LOOPS.md +55 -0
- package/templates/loops/atris/loops/TICK.md +24 -0
- package/templates/loops/atris/loops/feedback.md +22 -0
- package/templates/loops/atris/loops/quality.md +22 -0
- package/templates/loops/atris/wiki/systems/loops.md +41 -0
- package/utils/api.js +5 -1
- package/utils/auth.js +57 -21
|
@@ -0,0 +1,1083 @@
|
|
|
1
|
+
const crypto = require('crypto');
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const { knownCommands } = require('../lib/known-commands');
|
|
5
|
+
const { readUsage, usagePath } = require('../lib/usage');
|
|
6
|
+
const pulse = require('../lib/pulse');
|
|
7
|
+
const { parseLessons } = require('./autopilot');
|
|
8
|
+
|
|
9
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
10
|
+
const VALID_LANES = new Set(['business', 'life', 'code']);
|
|
11
|
+
const LEDGER_RELATIVE_PATH = path.join('.atris', 'state', 'closure.jsonl');
|
|
12
|
+
const TASK_PROJECTION_RELATIVE_PATH = path.join('.atris', 'state', 'tasks.projection.json');
|
|
13
|
+
const MISSIONS_RELATIVE_PATH = path.join('.atris', 'state', 'missions.jsonl');
|
|
14
|
+
const WATCH_CONFIG_RELATIVE_PATH = path.join('.atris', 'close-watch.json');
|
|
15
|
+
const EXPERIMENTS_RELATIVE_PATH = path.join('.atris', 'state', 'experiments.jsonl');
|
|
16
|
+
const EXPERIMENTS_DIR_RELATIVE_PATH = path.join('atris', 'experiments');
|
|
17
|
+
const RESOLVED_IN_SOURCE_PROOF = 'resolved in source store';
|
|
18
|
+
const FAILED_TASK_BATCH_PREFIX = 'tasks:failed:';
|
|
19
|
+
|
|
20
|
+
function ledgerPath(cwd = process.cwd()) {
|
|
21
|
+
return path.join(cwd, LEDGER_RELATIVE_PATH);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function ensureLedgerDir(cwd = process.cwd()) {
|
|
25
|
+
fs.mkdirSync(path.dirname(ledgerPath(cwd)), { recursive: true });
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function normalizeSpaces(text) {
|
|
29
|
+
return String(text || '').replace(/\s+/g, ' ').trim();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function canonicalWhat(text) {
|
|
33
|
+
return normalizeSpaces(text).toLowerCase();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function slugPart(text) {
|
|
37
|
+
const slug = canonicalWhat(text)
|
|
38
|
+
.normalize('NFKD')
|
|
39
|
+
.replace(/[\u0300-\u036f]/g, '')
|
|
40
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
41
|
+
.replace(/^-+|-+$/g, '')
|
|
42
|
+
.replace(/-{2,}/g, '-')
|
|
43
|
+
.slice(0, 48)
|
|
44
|
+
.replace(/-+$/g, '');
|
|
45
|
+
return slug || 'loop';
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function closeIdForWhat(what) {
|
|
49
|
+
const canonical = canonicalWhat(what);
|
|
50
|
+
const hash = crypto.createHash('sha1').update(canonical).digest('hex').slice(0, 7);
|
|
51
|
+
return `close-${slugPart(canonical)}-${hash}`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function closeIdForSource(source) {
|
|
55
|
+
const canonical = canonicalWhat(source);
|
|
56
|
+
const hash = crypto.createHash('sha1').update(canonical).digest('hex').slice(0, 7);
|
|
57
|
+
return `close-${slugPart(canonical)}-${hash}`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function readEvents(cwd = process.cwd()) {
|
|
61
|
+
const file = ledgerPath(cwd);
|
|
62
|
+
if (!fs.existsSync(file)) return [];
|
|
63
|
+
const content = fs.readFileSync(file, 'utf8');
|
|
64
|
+
const lines = content.split(/\n/).filter((line) => line.trim());
|
|
65
|
+
return lines.map((line, index) => {
|
|
66
|
+
try {
|
|
67
|
+
return JSON.parse(line);
|
|
68
|
+
} catch (error) {
|
|
69
|
+
throw new Error(`invalid closure ledger json on line ${index + 1}`);
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function appendEvent(event, cwd = process.cwd()) {
|
|
75
|
+
ensureLedgerDir(cwd);
|
|
76
|
+
fs.appendFileSync(ledgerPath(cwd), `${JSON.stringify(event)}\n`);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function isoDateOnly(date) {
|
|
80
|
+
return date.toISOString().slice(0, 10);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function parseDate(value) {
|
|
84
|
+
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
|
85
|
+
if (typeof value === 'string' && /^\d+$/.test(value.trim())) {
|
|
86
|
+
const numeric = Number(value.trim());
|
|
87
|
+
if (Number.isFinite(numeric)) return numeric;
|
|
88
|
+
}
|
|
89
|
+
const ms = Date.parse(value);
|
|
90
|
+
return Number.isFinite(ms) ? ms : null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function eventTime(event) {
|
|
94
|
+
return event.at || event.opened_at || null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function foldEvents(events, options = {}) {
|
|
98
|
+
const now = options.now ? new Date(options.now) : new Date();
|
|
99
|
+
const flags = new Map();
|
|
100
|
+
|
|
101
|
+
for (const event of events) {
|
|
102
|
+
if (!event || !event.kind || !event.id) continue;
|
|
103
|
+
|
|
104
|
+
if (event.kind === 'opened') {
|
|
105
|
+
flags.set(event.id, {
|
|
106
|
+
id: event.id,
|
|
107
|
+
what: normalizeSpaces(event.what),
|
|
108
|
+
owner: normalizeSpaces(event.owner || 'operator').toLowerCase(),
|
|
109
|
+
lane: VALID_LANES.has(event.lane) ? event.lane : 'business',
|
|
110
|
+
opened_at: event.opened_at || event.at,
|
|
111
|
+
ttl_days: Number(event.ttl_days) || 7,
|
|
112
|
+
close_condition: normalizeSpaces(event.close_condition || 'the loop is resolved'),
|
|
113
|
+
source: normalizeSpaces(event.source || 'manual'),
|
|
114
|
+
status: 'open',
|
|
115
|
+
closed_at: null,
|
|
116
|
+
dissolved_at: null,
|
|
117
|
+
snoozed_until: null,
|
|
118
|
+
escalated_days: [],
|
|
119
|
+
events: [event],
|
|
120
|
+
});
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const flag = flags.get(event.id);
|
|
125
|
+
if (!flag) continue;
|
|
126
|
+
flag.events.push(event);
|
|
127
|
+
|
|
128
|
+
if (event.kind === 'closed') {
|
|
129
|
+
flag.status = 'closed';
|
|
130
|
+
flag.closed_at = eventTime(event);
|
|
131
|
+
flag.proof = event.proof || '';
|
|
132
|
+
} else if (event.kind === 'dissolved') {
|
|
133
|
+
flag.status = 'dissolved';
|
|
134
|
+
flag.dissolved_at = eventTime(event);
|
|
135
|
+
flag.why = event.why || '';
|
|
136
|
+
} else if (event.kind === 'snoozed') {
|
|
137
|
+
flag.snoozed_until = event.until || null;
|
|
138
|
+
} else if (event.kind === 'escalated') {
|
|
139
|
+
const day = event.day || (event.at ? String(event.at).slice(0, 10) : null);
|
|
140
|
+
if (day && !flag.escalated_days.includes(day)) flag.escalated_days.push(day);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return Array.from(flags.values()).map((flag) => withComputedState(flag, now));
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function withComputedState(flag, now = new Date()) {
|
|
148
|
+
const openedMs = parseDate(flag.opened_at) || now.getTime();
|
|
149
|
+
const ttlDueMs = openedMs + (Number(flag.ttl_days) || 7) * DAY_MS;
|
|
150
|
+
const snoozedMs = flag.snoozed_until ? parseDate(flag.snoozed_until) : null;
|
|
151
|
+
const dueMs = Math.max(ttlDueMs, snoozedMs || 0);
|
|
152
|
+
const nowMs = now.getTime();
|
|
153
|
+
const isOpen = flag.status === 'open';
|
|
154
|
+
const overdue = isOpen && nowMs >= dueMs;
|
|
155
|
+
const daysOld = Math.max(0, Math.floor((nowMs - openedMs) / DAY_MS));
|
|
156
|
+
const daysPastTtl = overdue ? Math.max(0, Math.floor((nowMs - dueMs) / DAY_MS)) : 0;
|
|
157
|
+
|
|
158
|
+
return {
|
|
159
|
+
...flag,
|
|
160
|
+
due_at: new Date(dueMs).toISOString(),
|
|
161
|
+
days_old: daysOld,
|
|
162
|
+
days_past_ttl: daysPastTtl,
|
|
163
|
+
overdue,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function openFlags(cwd = process.cwd(), options = {}) {
|
|
168
|
+
return foldEvents(readEvents(cwd), options).filter((flag) => flag.status === 'open');
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function ownerWaitRank(flag) {
|
|
172
|
+
return /^(you|me|operator|human|keshav)$/.test(String(flag.owner || '').toLowerCase()) ? 0 : 1;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function compareOpenFlags(a, b) {
|
|
176
|
+
if (a.overdue !== b.overdue) return a.overdue ? -1 : 1;
|
|
177
|
+
if (a.overdue && b.overdue && b.days_past_ttl !== a.days_past_ttl) {
|
|
178
|
+
return b.days_past_ttl - a.days_past_ttl;
|
|
179
|
+
}
|
|
180
|
+
const aDue = parseDate(a.due_at) || 0;
|
|
181
|
+
const bDue = parseDate(b.due_at) || 0;
|
|
182
|
+
if (aDue !== bDue) return aDue - bDue;
|
|
183
|
+
return a.what.localeCompare(b.what);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function compareSweepFlags(a, b) {
|
|
187
|
+
const ownerRank = ownerWaitRank(a) - ownerWaitRank(b);
|
|
188
|
+
if (ownerRank !== 0) return ownerRank;
|
|
189
|
+
return compareOpenFlags(a, b);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function formatDays(count) {
|
|
193
|
+
return `${count} ${count === 1 ? 'day' : 'days'}`;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function trimSentenceEnd(text) {
|
|
197
|
+
return String(text || '').replace(/[.?!]+$/g, '');
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function listLine(flag) {
|
|
201
|
+
const parts = [
|
|
202
|
+
flag.what.toLowerCase(),
|
|
203
|
+
`${formatDays(flag.days_old)} old`,
|
|
204
|
+
];
|
|
205
|
+
if (flag.overdue) {
|
|
206
|
+
parts.push(`${formatDays(flag.days_past_ttl)} past ttl`);
|
|
207
|
+
}
|
|
208
|
+
return parts.join(', ');
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function sweepLine(flag) {
|
|
212
|
+
const what = trimSentenceEnd(flag.what.toLowerCase());
|
|
213
|
+
const closeCondition = trimSentenceEnd(flag.close_condition.toLowerCase());
|
|
214
|
+
const late = `${formatDays(flag.days_past_ttl)} late`;
|
|
215
|
+
if (ownerWaitRank(flag) === 0) {
|
|
216
|
+
return `${what} is waiting on you, ${late}, close it when ${closeCondition}.`;
|
|
217
|
+
}
|
|
218
|
+
return `${flag.owner} owns ${what}, ${late}, close it when ${closeCondition}.`;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function parseArgs(args) {
|
|
222
|
+
const options = {};
|
|
223
|
+
const positional = [];
|
|
224
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
225
|
+
const arg = args[index];
|
|
226
|
+
if (!arg.startsWith('--')) {
|
|
227
|
+
positional.push(arg);
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const equalsIndex = arg.indexOf('=');
|
|
232
|
+
const key = equalsIndex >= 0 ? arg.slice(2, equalsIndex) : arg.slice(2);
|
|
233
|
+
if (key === 'json' || key === 'help') {
|
|
234
|
+
options[key] = true;
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (equalsIndex >= 0) {
|
|
239
|
+
options[key] = arg.slice(equalsIndex + 1);
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const next = args[index + 1];
|
|
244
|
+
if (next === undefined || next.startsWith('--')) {
|
|
245
|
+
options[key] = true;
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
options[key] = next;
|
|
249
|
+
index += 1;
|
|
250
|
+
}
|
|
251
|
+
return { positional, options };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function positiveNumber(value, label) {
|
|
255
|
+
const number = Number(value);
|
|
256
|
+
if (!Number.isFinite(number) || number <= 0) {
|
|
257
|
+
throw new Error(`${label} must be a positive number`);
|
|
258
|
+
}
|
|
259
|
+
return number;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function printJson(payload) {
|
|
263
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function publicFlag(flag) {
|
|
267
|
+
const { events, ...rest } = flag;
|
|
268
|
+
return rest;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function printHelp() {
|
|
272
|
+
console.log('usage: atris close <add|list|done|dissolve|snooze|sweep|scan>');
|
|
273
|
+
console.log('add "<what>" [--owner x] [--lane life|business|code] [--ttl 7] [--when "<condition>"] [--source y]');
|
|
274
|
+
console.log('list [--json] [--lane x]');
|
|
275
|
+
console.log('done <id> [--proof "..."]');
|
|
276
|
+
console.log('dissolve <id> --why "..."');
|
|
277
|
+
console.log('snooze <id> --days n');
|
|
278
|
+
console.log('sweep [--json]');
|
|
279
|
+
console.log('scan [--json]');
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function commandAdd(args, context = {}) {
|
|
283
|
+
const { positional, options } = parseArgs(args);
|
|
284
|
+
const what = normalizeSpaces(positional.join(' '));
|
|
285
|
+
if (!what) throw new Error('what is required');
|
|
286
|
+
|
|
287
|
+
const lane = String(options.lane || 'business').toLowerCase();
|
|
288
|
+
if (!VALID_LANES.has(lane)) throw new Error('lane must be business, life, or code');
|
|
289
|
+
|
|
290
|
+
const ttlDays = options.ttl === undefined ? 7 : positiveNumber(options.ttl, 'ttl');
|
|
291
|
+
const now = context.now ? new Date(context.now) : new Date();
|
|
292
|
+
const existing = openFlags(context.cwd, { now }).find((flag) => canonicalWhat(flag.what) === canonicalWhat(what));
|
|
293
|
+
if (existing) {
|
|
294
|
+
console.log(`already open: ${existing.id}`);
|
|
295
|
+
return 0;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const openedAt = now.toISOString();
|
|
299
|
+
const event = {
|
|
300
|
+
kind: 'opened',
|
|
301
|
+
at: openedAt,
|
|
302
|
+
id: closeIdForWhat(what),
|
|
303
|
+
what,
|
|
304
|
+
owner: normalizeSpaces(options.owner || 'operator').toLowerCase(),
|
|
305
|
+
lane,
|
|
306
|
+
opened_at: openedAt,
|
|
307
|
+
ttl_days: ttlDays,
|
|
308
|
+
close_condition: normalizeSpaces(options.when || 'the loop is resolved'),
|
|
309
|
+
source: normalizeSpaces(options.source || 'manual'),
|
|
310
|
+
};
|
|
311
|
+
appendEvent(event, context.cwd);
|
|
312
|
+
console.log(`opened ${event.id}`);
|
|
313
|
+
return 0;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function commandList(args, context = {}) {
|
|
317
|
+
const { options } = parseArgs(args);
|
|
318
|
+
const lane = options.lane ? String(options.lane).toLowerCase() : null;
|
|
319
|
+
if (lane && !VALID_LANES.has(lane)) throw new Error('lane must be business, life, or code');
|
|
320
|
+
|
|
321
|
+
let flags = openFlags(context.cwd, { now: context.now });
|
|
322
|
+
if (lane) flags = flags.filter((flag) => flag.lane === lane);
|
|
323
|
+
flags.sort(compareOpenFlags);
|
|
324
|
+
|
|
325
|
+
if (options.json) {
|
|
326
|
+
printJson({ open: flags.map(publicFlag) });
|
|
327
|
+
return 0;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
if (flags.length === 0) {
|
|
331
|
+
console.log('nothing open.');
|
|
332
|
+
return 0;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
flags.forEach((flag) => console.log(listLine(flag)));
|
|
336
|
+
return 0;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function requireOpenFlag(id, context = {}) {
|
|
340
|
+
const flag = openFlags(context.cwd, { now: context.now }).find((item) => item.id === id);
|
|
341
|
+
if (!flag) throw new Error(`${id} is not open`);
|
|
342
|
+
return flag;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function commandDone(args, context = {}) {
|
|
346
|
+
const { positional, options } = parseArgs(args);
|
|
347
|
+
const id = positional[0];
|
|
348
|
+
if (!id) throw new Error('id is required');
|
|
349
|
+
requireOpenFlag(id, context);
|
|
350
|
+
const at = (context.now ? new Date(context.now) : new Date()).toISOString();
|
|
351
|
+
appendEvent({
|
|
352
|
+
kind: 'closed',
|
|
353
|
+
at,
|
|
354
|
+
id,
|
|
355
|
+
proof: normalizeSpaces(options.proof || ''),
|
|
356
|
+
}, context.cwd);
|
|
357
|
+
console.log(`closed ${id}`);
|
|
358
|
+
return 0;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function commandDissolve(args, context = {}) {
|
|
362
|
+
const { positional, options } = parseArgs(args);
|
|
363
|
+
const id = positional[0];
|
|
364
|
+
if (!id) throw new Error('id is required');
|
|
365
|
+
const why = normalizeSpaces(options.why || '');
|
|
366
|
+
if (!why) throw new Error('why is required');
|
|
367
|
+
requireOpenFlag(id, context);
|
|
368
|
+
const at = (context.now ? new Date(context.now) : new Date()).toISOString();
|
|
369
|
+
appendEvent({
|
|
370
|
+
kind: 'dissolved',
|
|
371
|
+
at,
|
|
372
|
+
id,
|
|
373
|
+
why,
|
|
374
|
+
}, context.cwd);
|
|
375
|
+
console.log(`dissolved ${id}`);
|
|
376
|
+
return 0;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function commandSnooze(args, context = {}) {
|
|
380
|
+
const { positional, options } = parseArgs(args);
|
|
381
|
+
const id = positional[0];
|
|
382
|
+
if (!id) throw new Error('id is required');
|
|
383
|
+
const days = positiveNumber(options.days, 'days');
|
|
384
|
+
requireOpenFlag(id, context);
|
|
385
|
+
const now = context.now ? new Date(context.now) : new Date();
|
|
386
|
+
const until = new Date(now.getTime() + days * DAY_MS).toISOString();
|
|
387
|
+
appendEvent({
|
|
388
|
+
kind: 'snoozed',
|
|
389
|
+
at: now.toISOString(),
|
|
390
|
+
id,
|
|
391
|
+
days,
|
|
392
|
+
until,
|
|
393
|
+
}, context.cwd);
|
|
394
|
+
console.log(`snoozed ${id} for ${formatDays(days)}`);
|
|
395
|
+
return 0;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// Escalation core shared by the CLI verb and the hourly pulse tick: append
|
|
399
|
+
// one escalated event per overdue flag per day, return the resulting state.
|
|
400
|
+
function sweepState(cwd = process.cwd(), now = new Date(), options = {}) {
|
|
401
|
+
const today = isoDateOnly(now);
|
|
402
|
+
let flags = openFlags(cwd, { now });
|
|
403
|
+
const overdue = flags.filter((flag) => flag.overdue).sort(compareSweepFlags);
|
|
404
|
+
const escalatedToday = [];
|
|
405
|
+
const shouldWrite = options.dryRun !== true && options.write !== false && options.readOnly !== true;
|
|
406
|
+
|
|
407
|
+
for (const flag of overdue) {
|
|
408
|
+
if (flag.escalated_days.includes(today)) continue;
|
|
409
|
+
if (shouldWrite) {
|
|
410
|
+
appendEvent({
|
|
411
|
+
kind: 'escalated',
|
|
412
|
+
at: now.toISOString(),
|
|
413
|
+
id: flag.id,
|
|
414
|
+
day: today,
|
|
415
|
+
}, cwd);
|
|
416
|
+
}
|
|
417
|
+
escalatedToday.push(flag.id);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
flags = openFlags(cwd, { now });
|
|
421
|
+
return { open: flags.length, overdue, escalated_today: escalatedToday };
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function commandSweep(args, context = {}) {
|
|
425
|
+
const { options } = parseArgs(args);
|
|
426
|
+
const now = context.now ? new Date(context.now) : new Date();
|
|
427
|
+
const { open, overdue, escalated_today: escalatedToday } = sweepState(context.cwd, now);
|
|
428
|
+
|
|
429
|
+
if (options.json) {
|
|
430
|
+
printJson({
|
|
431
|
+
open,
|
|
432
|
+
overdue: overdue.length,
|
|
433
|
+
escalated_today: escalatedToday,
|
|
434
|
+
});
|
|
435
|
+
return 0;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
if (overdue.length === 0) {
|
|
439
|
+
console.log('nothing is overdue.');
|
|
440
|
+
return 0;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
overdue.forEach((flag) => console.log(sweepLine(flag)));
|
|
444
|
+
return 0;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function readJsonFile(filePath) {
|
|
448
|
+
try {
|
|
449
|
+
if (!fs.existsSync(filePath)) return null;
|
|
450
|
+
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
451
|
+
} catch {
|
|
452
|
+
return null;
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function readJsonlFile(filePath) {
|
|
457
|
+
try {
|
|
458
|
+
if (!fs.existsSync(filePath)) return [];
|
|
459
|
+
return fs.readFileSync(filePath, 'utf8')
|
|
460
|
+
.split(/\n/)
|
|
461
|
+
.filter((line) => line.trim())
|
|
462
|
+
.map((line) => {
|
|
463
|
+
try {
|
|
464
|
+
return JSON.parse(line);
|
|
465
|
+
} catch {
|
|
466
|
+
return null;
|
|
467
|
+
}
|
|
468
|
+
})
|
|
469
|
+
.filter(Boolean);
|
|
470
|
+
} catch {
|
|
471
|
+
return [];
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function readTaskProjection(cwd) {
|
|
476
|
+
const payload = readJsonFile(path.join(cwd, TASK_PROJECTION_RELATIVE_PATH));
|
|
477
|
+
return payload && Array.isArray(payload.tasks) ? payload.tasks : [];
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function readMissions(cwd) {
|
|
481
|
+
return readJsonlFile(path.join(cwd, MISSIONS_RELATIVE_PATH));
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function usageHistoryReady(cwd, now) {
|
|
485
|
+
if (!fs.existsSync(usagePath(cwd))) return false;
|
|
486
|
+
let earliest = null;
|
|
487
|
+
for (const entry of readUsage(cwd)) {
|
|
488
|
+
const at = parseDate(entry.at);
|
|
489
|
+
if (at === null) continue;
|
|
490
|
+
if (earliest === null || at < earliest) earliest = at;
|
|
491
|
+
}
|
|
492
|
+
return earliest !== null && now.getTime() - earliest >= 30 * DAY_MS;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function usedCommandsSince(cwd, now, days) {
|
|
496
|
+
return new Set(readUsage(cwd, { sinceDays: days, now }).map((entry) => entry.cmd));
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function pulseLivenessState(cwd, now) {
|
|
500
|
+
const receipts = pulse.readJsonl(pulse.pulseReceiptsPath(cwd));
|
|
501
|
+
let newest = null;
|
|
502
|
+
for (const receipt of receipts) {
|
|
503
|
+
if (!receipt || receipt.phase !== 'finished') continue;
|
|
504
|
+
const at = parseDate(receipt.ts || receipt.at);
|
|
505
|
+
if (at === null) continue;
|
|
506
|
+
if (newest === null || at > newest) newest = at;
|
|
507
|
+
}
|
|
508
|
+
if (newest === null) return { healthy: false, unhealthy: false };
|
|
509
|
+
const ageMs = now.getTime() - newest;
|
|
510
|
+
return {
|
|
511
|
+
healthy: ageMs <= DAY_MS,
|
|
512
|
+
unhealthy: ageMs > DAY_MS,
|
|
513
|
+
};
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// Degradation sensor: the loop can be alive (ticking) yet losing ground. The
|
|
517
|
+
// pulse liveness check only sees silence; this sees a run of negative reward.
|
|
518
|
+
// Sums reward over the last N finished ticks; unhealthy when the trend is net
|
|
519
|
+
// negative, healthy once it climbs back to zero or better.
|
|
520
|
+
const REWARD_REGRESSION_WINDOW = 6;
|
|
521
|
+
|
|
522
|
+
function rewardRegressionState(cwd, now) {
|
|
523
|
+
const receipts = pulse.readJsonl(pulse.pulseReceiptsPath(cwd));
|
|
524
|
+
const finished = receipts
|
|
525
|
+
.filter((r) => r && r.phase === 'finished' && parseDate(r.ts || r.at) !== null)
|
|
526
|
+
.sort((a, b) => parseDate(a.ts || a.at) - parseDate(b.ts || b.at));
|
|
527
|
+
if (finished.length < REWARD_REGRESSION_WINDOW) return { healthy: false, unhealthy: false };
|
|
528
|
+
const window = finished.slice(-REWARD_REGRESSION_WINDOW);
|
|
529
|
+
const sum = window.reduce((acc, r) => acc + (Number(r.reward) || 0), 0);
|
|
530
|
+
return { healthy: sum >= 0, unhealthy: sum < 0, sum };
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
function newestExperimentTimestamp(row) {
|
|
534
|
+
const fields = ['ts', 'at', 'updated_at', 'finished_at', 'started_at', 'reviewed_at', 'created_at'];
|
|
535
|
+
for (const field of fields) {
|
|
536
|
+
const at = parseDate(row && row[field]);
|
|
537
|
+
if (at !== null) return at;
|
|
538
|
+
}
|
|
539
|
+
return null;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
function newestFileMtime(dir) {
|
|
543
|
+
let newest = null;
|
|
544
|
+
const visit = (target) => {
|
|
545
|
+
let stat;
|
|
546
|
+
try {
|
|
547
|
+
stat = fs.statSync(target);
|
|
548
|
+
} catch {
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
if (stat.isFile()) {
|
|
552
|
+
newest = newest === null ? stat.mtimeMs : Math.max(newest, stat.mtimeMs);
|
|
553
|
+
return;
|
|
554
|
+
}
|
|
555
|
+
if (!stat.isDirectory()) return;
|
|
556
|
+
let entries;
|
|
557
|
+
try {
|
|
558
|
+
entries = fs.readdirSync(target, { withFileTypes: true });
|
|
559
|
+
} catch {
|
|
560
|
+
return;
|
|
561
|
+
}
|
|
562
|
+
if (entries.length === 0) {
|
|
563
|
+
newest = newest === null ? stat.mtimeMs : Math.max(newest, stat.mtimeMs);
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
566
|
+
for (const entry of entries) {
|
|
567
|
+
visit(path.join(target, entry.name));
|
|
568
|
+
}
|
|
569
|
+
};
|
|
570
|
+
visit(dir);
|
|
571
|
+
return newest;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
function experimentLivenessState(cwd, now) {
|
|
575
|
+
const stateFile = path.join(cwd, EXPERIMENTS_RELATIVE_PATH);
|
|
576
|
+
const experimentsDir = path.join(cwd, EXPERIMENTS_DIR_RELATIVE_PATH);
|
|
577
|
+
const exists = fs.existsSync(stateFile) || fs.existsSync(experimentsDir);
|
|
578
|
+
if (!exists) return { healthy: false, unhealthy: false };
|
|
579
|
+
|
|
580
|
+
let newest = null;
|
|
581
|
+
if (fs.existsSync(stateFile)) {
|
|
582
|
+
for (const row of readJsonlFile(stateFile)) {
|
|
583
|
+
const at = newestExperimentTimestamp(row);
|
|
584
|
+
if (at !== null) newest = newest === null ? at : Math.max(newest, at);
|
|
585
|
+
}
|
|
586
|
+
if (newest === null) {
|
|
587
|
+
try {
|
|
588
|
+
newest = fs.statSync(stateFile).mtimeMs;
|
|
589
|
+
} catch {}
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
if (fs.existsSync(experimentsDir)) {
|
|
594
|
+
const mtime = newestFileMtime(experimentsDir);
|
|
595
|
+
if (mtime !== null) newest = newest === null ? mtime : Math.max(newest, mtime);
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
if (newest === null) return { healthy: false, unhealthy: false };
|
|
599
|
+
const ageMs = now.getTime() - newest;
|
|
600
|
+
return {
|
|
601
|
+
healthy: ageMs <= 3 * DAY_MS,
|
|
602
|
+
unhealthy: ageMs > 3 * DAY_MS,
|
|
603
|
+
};
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
function latestById(rows) {
|
|
607
|
+
const map = new Map();
|
|
608
|
+
for (const row of rows || []) {
|
|
609
|
+
const id = normalizeSpaces(row && row.id);
|
|
610
|
+
if (!id) continue;
|
|
611
|
+
map.set(id, row);
|
|
612
|
+
}
|
|
613
|
+
return map;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
function isOlderThan(value, days, now) {
|
|
617
|
+
const ms = parseDate(value);
|
|
618
|
+
return ms !== null && now.getTime() - ms > days * DAY_MS;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
function lessonFlagsState(cwd, now) {
|
|
622
|
+
const candidates = [];
|
|
623
|
+
const candidateSources = new Set();
|
|
624
|
+
|
|
625
|
+
for (const lesson of parseLessons(cwd)) {
|
|
626
|
+
const source = `lesson:${lesson.id}`;
|
|
627
|
+
const status = normalizeSpaces(lesson.meta && lesson.meta.status).toLowerCase();
|
|
628
|
+
const detector = normalizeSpaces(lesson.meta && lesson.meta.detector);
|
|
629
|
+
const needsDetector = lesson.verdict === 'fail'
|
|
630
|
+
&& !lesson.resolvedTag
|
|
631
|
+
&& status !== 'resolved'
|
|
632
|
+
&& !detector
|
|
633
|
+
&& isOlderThan(lesson.date, 7, now);
|
|
634
|
+
if (!needsDetector) continue;
|
|
635
|
+
|
|
636
|
+
candidateSources.add(source);
|
|
637
|
+
candidates.push({
|
|
638
|
+
what: `lesson ${lesson.id} has no detector, type one so it can self-heal`,
|
|
639
|
+
owner: normalizeSpaces(lesson.meta && lesson.meta.scope) || 'operator',
|
|
640
|
+
lane: 'code',
|
|
641
|
+
ttlDays: 7,
|
|
642
|
+
source,
|
|
643
|
+
});
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
return { candidates, candidateSources };
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
function taskRef(task) {
|
|
650
|
+
return normalizeSpaces(task && (task.display_id || task.id));
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
function taskSource(task) {
|
|
654
|
+
const id = normalizeSpaces(task && task.id);
|
|
655
|
+
return id ? `task:${id}` : '';
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
function taskResolved(task) {
|
|
659
|
+
const status = normalizeSpaces(task && task.status).toLowerCase();
|
|
660
|
+
return status === 'done' || status === 'accepted';
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
function reviewObject(task) {
|
|
664
|
+
const review = task && task.review;
|
|
665
|
+
return review && typeof review === 'object' && !Array.isArray(review) ? review : {};
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
function taskWaitingOnHumanAccept(task) {
|
|
669
|
+
if (!task || taskResolved(task)) return false;
|
|
670
|
+
const status = normalizeSpaces(task.status).toLowerCase();
|
|
671
|
+
const review = task.review;
|
|
672
|
+
if (!review) return false;
|
|
673
|
+
|
|
674
|
+
const reviewData = reviewObject(task);
|
|
675
|
+
const metadata = task.metadata && typeof task.metadata === 'object' ? task.metadata : {};
|
|
676
|
+
const approval = normalizeSpaces(reviewData.approval_status || metadata.approval_status).toLowerCase();
|
|
677
|
+
if (['accepted', 'approved', 'done', 'closed'].includes(approval)) return false;
|
|
678
|
+
if (status === 'review') return true;
|
|
679
|
+
if (['pending', 'waiting', 'needs_accept', 'needs_acceptance', 'needs-human', 'needs_human'].includes(approval)) return true;
|
|
680
|
+
if (reviewData.agent_certified === true || metadata.agent_certified === true) return true;
|
|
681
|
+
|
|
682
|
+
const text = normalizeSpaces([
|
|
683
|
+
typeof review === 'string' ? review : '',
|
|
684
|
+
reviewData.status,
|
|
685
|
+
reviewData.waiting_on,
|
|
686
|
+
reviewData.next_action,
|
|
687
|
+
reviewData.summary,
|
|
688
|
+
].join(' ')).toLowerCase();
|
|
689
|
+
return /\bhuman\b/.test(text) && /\baccept|approval\b/.test(text);
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
function failedTaskNeedsAttention(task, now) {
|
|
693
|
+
// Recency window: a failure nobody touched in 30 days is archaeology, not an
|
|
694
|
+
// open loop. Flag only failures old enough to be ignored but fresh enough to
|
|
695
|
+
// still matter.
|
|
696
|
+
return normalizeSpaces(task && task.status).toLowerCase() === 'failed'
|
|
697
|
+
&& isOlderThan(task.updated_at, 2, now)
|
|
698
|
+
&& !isOlderThan(task.updated_at, 30, now);
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
function failedTaskBatchSource(tasks) {
|
|
702
|
+
const ids = tasks.map((task) => normalizeSpaces(task && task.id)).filter(Boolean).sort();
|
|
703
|
+
if (ids.length === 0) return '';
|
|
704
|
+
const hash = crypto.createHash('sha1').update(ids.join('\n')).digest('hex').slice(0, 12);
|
|
705
|
+
return `${FAILED_TASK_BATCH_PREFIX}${hash}`;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
function failedTaskBatchWhat(tasks) {
|
|
709
|
+
const refs = tasks.map(taskRef).filter(Boolean).sort();
|
|
710
|
+
const shown = refs.slice(0, 5).map((ref) => ref.toLowerCase());
|
|
711
|
+
const more = refs.length > shown.length ? ` and ${refs.length - shown.length} more` : '';
|
|
712
|
+
const subject = refs.length === 1 ? '1 failed task needs' : `${refs.length} failed tasks need`;
|
|
713
|
+
const targets = refs.length === 1 ? shown[0] : `${shown.join(', ')}${more}`;
|
|
714
|
+
const action = refs.length === 1 ? `retry ${targets} or dissolve it` : `retry the useful ones or dissolve the rest (${targets})`;
|
|
715
|
+
return `${subject} one decision: ${action}`;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
function resolveWatchPath(cwd, watchPath) {
|
|
719
|
+
return path.isAbsolute(watchPath) ? watchPath : path.join(cwd, watchPath);
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
function readWatchConfig(cwd) {
|
|
723
|
+
const payload = readJsonFile(path.join(cwd, WATCH_CONFIG_RELATIVE_PATH));
|
|
724
|
+
if (!Array.isArray(payload)) return [];
|
|
725
|
+
return payload.map((entry) => {
|
|
726
|
+
const watchPath = normalizeSpaces(entry && entry.path);
|
|
727
|
+
const what = normalizeSpaces(entry && entry.what);
|
|
728
|
+
const pattern = entry && entry.pattern !== undefined ? String(entry.pattern) : '';
|
|
729
|
+
if (!watchPath || !what || !pattern) return null;
|
|
730
|
+
let regex;
|
|
731
|
+
try {
|
|
732
|
+
regex = new RegExp(pattern);
|
|
733
|
+
} catch {
|
|
734
|
+
return null;
|
|
735
|
+
}
|
|
736
|
+
const lane = normalizeSpaces(entry.lane || 'code').toLowerCase();
|
|
737
|
+
const ttlDays = Number(entry.ttl_days) > 0 ? Number(entry.ttl_days) : 3;
|
|
738
|
+
return {
|
|
739
|
+
path: watchPath,
|
|
740
|
+
file_path: resolveWatchPath(cwd, watchPath),
|
|
741
|
+
source: `watch:${watchPath}`,
|
|
742
|
+
regex,
|
|
743
|
+
lane: VALID_LANES.has(lane) ? lane : 'code',
|
|
744
|
+
ttl_days: ttlDays,
|
|
745
|
+
what,
|
|
746
|
+
};
|
|
747
|
+
}).filter(Boolean);
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
function watchMatch(entry) {
|
|
751
|
+
try {
|
|
752
|
+
if (!fs.existsSync(entry.file_path)) return { readable: false, matches: false };
|
|
753
|
+
const content = fs.readFileSync(entry.file_path, 'utf8');
|
|
754
|
+
entry.regex.lastIndex = 0;
|
|
755
|
+
return { readable: true, matches: entry.regex.test(content) };
|
|
756
|
+
} catch {
|
|
757
|
+
return { readable: false, matches: false };
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
function shortText(text, maxLength = 70) {
|
|
762
|
+
const value = normalizeSpaces(text);
|
|
763
|
+
if (value.length <= maxLength) return value;
|
|
764
|
+
const clipped = value.slice(0, maxLength).replace(/\s+\S*$/g, '').trim();
|
|
765
|
+
return clipped || value.slice(0, maxLength).trim();
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
function appendOpenedFromSource({ what, owner = 'operator', lane = 'code', ttlDays = 3, source }, cwd, now) {
|
|
769
|
+
const event = {
|
|
770
|
+
kind: 'opened',
|
|
771
|
+
at: now.toISOString(),
|
|
772
|
+
id: closeIdForSource(source),
|
|
773
|
+
what,
|
|
774
|
+
owner: normalizeSpaces(owner) || 'operator',
|
|
775
|
+
lane: VALID_LANES.has(lane) ? lane : 'code',
|
|
776
|
+
opened_at: now.toISOString(),
|
|
777
|
+
ttl_days: Number(ttlDays) || 3,
|
|
778
|
+
close_condition: 'the source store resolves it',
|
|
779
|
+
source,
|
|
780
|
+
};
|
|
781
|
+
appendEvent(event, cwd);
|
|
782
|
+
return event;
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
function appendClosedFromSource(flag, cwd, now) {
|
|
786
|
+
const event = {
|
|
787
|
+
kind: 'closed',
|
|
788
|
+
at: now.toISOString(),
|
|
789
|
+
id: flag.id,
|
|
790
|
+
proof: RESOLVED_IN_SOURCE_PROOF,
|
|
791
|
+
};
|
|
792
|
+
appendEvent(event, cwd);
|
|
793
|
+
return event;
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
function scanState(cwd = process.cwd(), options = {}) {
|
|
797
|
+
const now = options.now ? new Date(options.now) : new Date();
|
|
798
|
+
const tasks = readTaskProjection(cwd);
|
|
799
|
+
const tasksById = latestById(tasks);
|
|
800
|
+
const missions = Array.from(latestById(readMissions(cwd)).values());
|
|
801
|
+
const missionsById = latestById(missions);
|
|
802
|
+
const watchEntries = readWatchConfig(cwd);
|
|
803
|
+
const watchBySource = new Map(watchEntries.map((entry) => [entry.source, entry]));
|
|
804
|
+
const usedCommands = usedCommandsSince(cwd, now, 30);
|
|
805
|
+
const usageReady = usageHistoryReady(cwd, now);
|
|
806
|
+
const pulseLiveness = pulseLivenessState(cwd, now);
|
|
807
|
+
const experimentLiveness = experimentLivenessState(cwd, now);
|
|
808
|
+
const rewardRegression = rewardRegressionState(cwd, now);
|
|
809
|
+
const lessonFlags = lessonFlagsState(cwd, now);
|
|
810
|
+
const openBySource = new Map();
|
|
811
|
+
const dissolvedSources = new Set();
|
|
812
|
+
const autoClosed = [];
|
|
813
|
+
|
|
814
|
+
// Dissolve is a durable verdict: a source the operator declared dead on
|
|
815
|
+
// purpose must never re-open on a later scan.
|
|
816
|
+
for (const flag of foldEvents(readEvents(cwd), { now })) {
|
|
817
|
+
if (flag.status === 'dissolved' && flag.source) dissolvedSources.add(flag.source);
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
const failedTasks = Array.from(tasksById.values())
|
|
821
|
+
.filter((task) => failedTaskNeedsAttention(task, now))
|
|
822
|
+
.filter((task) => !dissolvedSources.has(taskSource(task)))
|
|
823
|
+
.sort((a, b) => taskRef(a).localeCompare(taskRef(b)));
|
|
824
|
+
const failedBatchSource = failedTaskBatchSource(failedTasks);
|
|
825
|
+
|
|
826
|
+
for (const flag of openFlags(cwd, { now })) {
|
|
827
|
+
if (flag.source) openBySource.set(flag.source, flag);
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
const closeFlag = (flag) => {
|
|
831
|
+
const event = appendClosedFromSource(flag, cwd, now);
|
|
832
|
+
autoClosed.push({ flag, event });
|
|
833
|
+
openBySource.delete(flag.source);
|
|
834
|
+
};
|
|
835
|
+
|
|
836
|
+
for (const flag of Array.from(openBySource.values())) {
|
|
837
|
+
if (flag.source.startsWith('task:')) {
|
|
838
|
+
const task = tasksById.get(flag.source.slice('task:'.length));
|
|
839
|
+
const status = normalizeSpaces(task && task.status).toLowerCase();
|
|
840
|
+
if (task && (taskResolved(task) || status === 'failed')) closeFlag(flag);
|
|
841
|
+
continue;
|
|
842
|
+
}
|
|
843
|
+
if (flag.source.startsWith(FAILED_TASK_BATCH_PREFIX)) {
|
|
844
|
+
if (flag.source !== failedBatchSource) closeFlag(flag);
|
|
845
|
+
continue;
|
|
846
|
+
}
|
|
847
|
+
if (flag.source.startsWith('mission:')) {
|
|
848
|
+
const mission = missionsById.get(flag.source.slice('mission:'.length));
|
|
849
|
+
const status = normalizeSpaces(mission && mission.status).toLowerCase();
|
|
850
|
+
if (status === 'complete' || status === 'completed' || status === 'stopped') closeFlag(flag);
|
|
851
|
+
continue;
|
|
852
|
+
}
|
|
853
|
+
if (flag.source.startsWith('watch:')) {
|
|
854
|
+
const entry = watchBySource.get(flag.source);
|
|
855
|
+
if (!entry) continue;
|
|
856
|
+
const matched = watchMatch(entry);
|
|
857
|
+
if (matched.readable && !matched.matches) closeFlag(flag);
|
|
858
|
+
continue;
|
|
859
|
+
}
|
|
860
|
+
if (flag.source.startsWith('usage:')) {
|
|
861
|
+
const commandName = flag.source.slice('usage:'.length);
|
|
862
|
+
if (usedCommands.has(commandName)) closeFlag(flag);
|
|
863
|
+
continue;
|
|
864
|
+
}
|
|
865
|
+
if (flag.source.startsWith('lesson:')) {
|
|
866
|
+
if (!lessonFlags.candidateSources.has(flag.source)) closeFlag(flag);
|
|
867
|
+
continue;
|
|
868
|
+
}
|
|
869
|
+
if (flag.source === 'liveness:pulse') {
|
|
870
|
+
if (pulseLiveness.healthy) closeFlag(flag);
|
|
871
|
+
continue;
|
|
872
|
+
}
|
|
873
|
+
if (flag.source === 'liveness:experiments') {
|
|
874
|
+
if (experimentLiveness.healthy) closeFlag(flag);
|
|
875
|
+
continue;
|
|
876
|
+
}
|
|
877
|
+
if (flag.source === 'liveness:reward') {
|
|
878
|
+
if (rewardRegression.healthy) closeFlag(flag);
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
const opened = [];
|
|
883
|
+
const openCandidate = (candidate) => {
|
|
884
|
+
if (!candidate.source || openBySource.has(candidate.source) || dissolvedSources.has(candidate.source)) return null;
|
|
885
|
+
const event = appendOpenedFromSource(candidate, cwd, now);
|
|
886
|
+
opened.push(event);
|
|
887
|
+
openBySource.set(candidate.source, { ...event, status: 'open' });
|
|
888
|
+
return event;
|
|
889
|
+
};
|
|
890
|
+
|
|
891
|
+
for (const task of tasks) {
|
|
892
|
+
const source = taskSource(task);
|
|
893
|
+
const ref = taskRef(task);
|
|
894
|
+
if (!source || !ref) continue;
|
|
895
|
+
if (taskWaitingOnHumanAccept(task) && isOlderThan(task.updated_at, 5, now)) {
|
|
896
|
+
openCandidate({
|
|
897
|
+
what: `task ${ref} has waited in review too long, accept it or send it back`,
|
|
898
|
+
lane: 'code',
|
|
899
|
+
ttlDays: 3,
|
|
900
|
+
source,
|
|
901
|
+
});
|
|
902
|
+
continue;
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
if (failedTasks.length > 0) {
|
|
907
|
+
openCandidate({
|
|
908
|
+
what: failedTaskBatchWhat(failedTasks),
|
|
909
|
+
lane: 'code',
|
|
910
|
+
ttlDays: 3,
|
|
911
|
+
source: failedBatchSource,
|
|
912
|
+
});
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
for (const candidate of lessonFlags.candidates) {
|
|
916
|
+
openCandidate(candidate);
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
const staleMissions = missions
|
|
920
|
+
.filter((mission) => ['running', 'planning'].includes(normalizeSpaces(mission.status).toLowerCase()))
|
|
921
|
+
.filter((mission) => isOlderThan(mission.updated_at, 5, now))
|
|
922
|
+
.sort((a, b) => (parseDate(a.updated_at) || 0) - (parseDate(b.updated_at) || 0));
|
|
923
|
+
const missionCap = 10;
|
|
924
|
+
const missionSkipped = Math.max(0, staleMissions.length - missionCap);
|
|
925
|
+
for (const mission of staleMissions.slice(0, missionCap)) {
|
|
926
|
+
const id = normalizeSpaces(mission.id);
|
|
927
|
+
if (!id) continue;
|
|
928
|
+
openCandidate({
|
|
929
|
+
what: `mission ${shortText(mission.objective || id)} has not moved in days, resume it or stop it`,
|
|
930
|
+
lane: 'code',
|
|
931
|
+
ttlDays: 3,
|
|
932
|
+
source: `mission:${id}`,
|
|
933
|
+
});
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
for (const entry of watchEntries) {
|
|
937
|
+
const matched = watchMatch(entry);
|
|
938
|
+
if (!matched.readable || !matched.matches) continue;
|
|
939
|
+
openCandidate({
|
|
940
|
+
what: entry.what,
|
|
941
|
+
lane: entry.lane,
|
|
942
|
+
ttlDays: entry.ttl_days,
|
|
943
|
+
source: entry.source,
|
|
944
|
+
});
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
if (usageReady) {
|
|
948
|
+
for (const commandName of knownCommands) {
|
|
949
|
+
if (usedCommands.has(commandName)) continue;
|
|
950
|
+
openCandidate({
|
|
951
|
+
what: `command ${commandName} has zero uses in 30 days, kill it or defend it`,
|
|
952
|
+
lane: 'code',
|
|
953
|
+
ttlDays: 7,
|
|
954
|
+
source: `usage:${commandName}`,
|
|
955
|
+
});
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
if (pulseLiveness.unhealthy) {
|
|
960
|
+
openCandidate({
|
|
961
|
+
what: 'the heartbeat has not ticked green in a day, the loop may be dead',
|
|
962
|
+
lane: 'code',
|
|
963
|
+
ttlDays: 1,
|
|
964
|
+
source: 'liveness:pulse',
|
|
965
|
+
});
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
if (experimentLiveness.unhealthy) {
|
|
969
|
+
openCandidate({
|
|
970
|
+
what: 'no experiment has run in three days, evolution is coasting',
|
|
971
|
+
lane: 'code',
|
|
972
|
+
ttlDays: 2,
|
|
973
|
+
source: 'liveness:experiments',
|
|
974
|
+
});
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
if (rewardRegression.unhealthy) {
|
|
978
|
+
openCandidate({
|
|
979
|
+
what: 'the last several heartbeats lost more than they gained, find what is failing and fix the loop',
|
|
980
|
+
lane: 'code',
|
|
981
|
+
ttlDays: 1,
|
|
982
|
+
source: 'liveness:reward',
|
|
983
|
+
});
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
return {
|
|
987
|
+
opened,
|
|
988
|
+
auto_closed: autoClosed,
|
|
989
|
+
skipped: {
|
|
990
|
+
missions: missionSkipped,
|
|
991
|
+
},
|
|
992
|
+
};
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
function publicScanOpened(event) {
|
|
996
|
+
return {
|
|
997
|
+
id: event.id,
|
|
998
|
+
what: event.what,
|
|
999
|
+
source: event.source,
|
|
1000
|
+
lane: event.lane,
|
|
1001
|
+
ttl_days: event.ttl_days,
|
|
1002
|
+
};
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
function publicScanClosed(item) {
|
|
1006
|
+
return {
|
|
1007
|
+
id: item.event.id,
|
|
1008
|
+
what: item.flag.what,
|
|
1009
|
+
source: item.flag.source,
|
|
1010
|
+
proof: item.event.proof,
|
|
1011
|
+
};
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
function commandScan(args, context = {}) {
|
|
1015
|
+
const { options } = parseArgs(args);
|
|
1016
|
+
const result = scanState(context.cwd, { now: context.now });
|
|
1017
|
+
|
|
1018
|
+
if (options.json) {
|
|
1019
|
+
printJson({
|
|
1020
|
+
opened: result.opened.map(publicScanOpened),
|
|
1021
|
+
auto_closed: result.auto_closed.map(publicScanClosed),
|
|
1022
|
+
skipped: result.skipped,
|
|
1023
|
+
counts: {
|
|
1024
|
+
opened: result.opened.length,
|
|
1025
|
+
auto_closed: result.auto_closed.length,
|
|
1026
|
+
},
|
|
1027
|
+
});
|
|
1028
|
+
return 0;
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
const lines = [];
|
|
1032
|
+
for (const event of result.opened) {
|
|
1033
|
+
lines.push(`opened ${trimSentenceEnd(event.what).toLowerCase()}.`);
|
|
1034
|
+
}
|
|
1035
|
+
for (const item of result.auto_closed) {
|
|
1036
|
+
lines.push(`auto-closed ${trimSentenceEnd(item.flag.what).toLowerCase()}.`);
|
|
1037
|
+
}
|
|
1038
|
+
if (result.skipped.missions > 0) {
|
|
1039
|
+
lines.push(`skipped ${result.skipped.missions} stale missions due to cap.`);
|
|
1040
|
+
}
|
|
1041
|
+
if (lines.length === 0) {
|
|
1042
|
+
console.log('nothing new to open, nothing resolved.');
|
|
1043
|
+
return 0;
|
|
1044
|
+
}
|
|
1045
|
+
lines.forEach((line) => console.log(line));
|
|
1046
|
+
return 0;
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
function run(args = [], context = {}) {
|
|
1050
|
+
const subcommand = args[0];
|
|
1051
|
+
const rest = args.slice(1);
|
|
1052
|
+
try {
|
|
1053
|
+
if (!subcommand || subcommand === 'help' || subcommand === '--help' || subcommand === '-h') {
|
|
1054
|
+
printHelp();
|
|
1055
|
+
return 0;
|
|
1056
|
+
}
|
|
1057
|
+
if (subcommand === 'add') return commandAdd(rest, context);
|
|
1058
|
+
if (subcommand === 'list') return commandList(rest, context);
|
|
1059
|
+
if (subcommand === 'done') return commandDone(rest, context);
|
|
1060
|
+
if (subcommand === 'dissolve') return commandDissolve(rest, context);
|
|
1061
|
+
if (subcommand === 'snooze') return commandSnooze(rest, context);
|
|
1062
|
+
if (subcommand === 'sweep') return commandSweep(rest, context);
|
|
1063
|
+
if (subcommand === 'scan') return commandScan(rest, context);
|
|
1064
|
+
console.error(`unknown close command: ${subcommand}`);
|
|
1065
|
+
return 2;
|
|
1066
|
+
} catch (error) {
|
|
1067
|
+
console.error(`error: ${error.message || error}`);
|
|
1068
|
+
return 2;
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
module.exports = {
|
|
1073
|
+
run,
|
|
1074
|
+
sweepState,
|
|
1075
|
+
scanState,
|
|
1076
|
+
closeIdForWhat,
|
|
1077
|
+
foldEvents,
|
|
1078
|
+
openFlags,
|
|
1079
|
+
ledgerPath,
|
|
1080
|
+
readEvents,
|
|
1081
|
+
listLine,
|
|
1082
|
+
sweepLine,
|
|
1083
|
+
};
|