atris 3.80.1 → 3.82.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +10 -0
  3. package/atris/atris.md +14 -0
  4. package/atris/features/README.md +15 -0
  5. package/atris/policies/README.md +24 -0
  6. package/atris/skills/README.md +61 -45
  7. package/atris/skills/design/SKILL.md +6 -5
  8. package/atris/skills/taste/SKILL.md +39 -0
  9. package/atris/skills/taste/notes.md +29 -0
  10. package/atris/skills/taste/principles.md +23 -0
  11. package/atris/skills/wiki/SKILL.md +14 -2
  12. package/atris/skills/wiki/index-lines.md +30 -0
  13. package/atris/skills/wiki/measure.md +20 -0
  14. package/atris/skills/wiki/note-shape.md +16 -0
  15. package/atris/skills/wiki/upkeep.md +16 -0
  16. package/atris/team/README.md +38 -0
  17. package/atris.md +14 -0
  18. package/bin/atris.js +1 -1
  19. package/commands/activate.js +1 -1
  20. package/commands/autoland.js +25 -26
  21. package/commands/autopilot-front.js +46 -1
  22. package/commands/autopilot.js +55 -25
  23. package/commands/clean.js +22 -11
  24. package/commands/computer.js +1 -1
  25. package/commands/doc-health.js +48 -1
  26. package/commands/improve.js +16 -3
  27. package/commands/init.js +4 -0
  28. package/commands/integrations.js +69 -1616
  29. package/commands/mission.js +109 -13
  30. package/commands/release.js +41 -10
  31. package/commands/status.js +1 -1
  32. package/commands/sync.js +12 -8
  33. package/commands/task.js +144 -118
  34. package/commands/unknowns.js +9 -5
  35. package/commands/visualize.js +1 -12
  36. package/commands/workflow.js +4 -3
  37. package/commands/worktree.js +68 -1602
  38. package/commands/xp.js +23 -2113
  39. package/commands/youtube.js +192 -3110
  40. package/lib/business-core.js +3 -4
  41. package/lib/campaign-run-lock.js +12 -1
  42. package/lib/engine-ask.js +1 -1
  43. package/lib/engine-receipt-sweep.js +64 -0
  44. package/lib/file-lock.js +175 -0
  45. package/lib/fleet.js +1 -1
  46. package/lib/integrations-core.js +1695 -0
  47. package/lib/json-file.js +22 -1
  48. package/lib/loop-doctor.js +36 -2
  49. package/lib/map-refs.js +68 -8
  50. package/lib/member-engine.js +1 -1
  51. package/lib/member-map-fold.js +49 -11
  52. package/lib/member-map-inbox.js +2 -1
  53. package/lib/mission-core.js +9 -2
  54. package/lib/recall-exam.js +171 -0
  55. package/lib/roster-markdown.js +2 -2
  56. package/lib/runner-command.js +5 -5
  57. package/lib/task-db.js +105 -37
  58. package/lib/taste-notes.js +35 -0
  59. package/lib/team-lineup.js +3 -3
  60. package/lib/todo.js +2 -0
  61. package/lib/wiki.js +11 -3
  62. package/lib/worktree-core.js +1517 -0
  63. package/lib/xp-core.js +2172 -0
  64. package/lib/youtube-core.js +3178 -0
  65. package/lib/zen-apply.js +3 -1
  66. package/package.json +2 -2
@@ -900,10 +900,9 @@ function seedBusinessStarterTask(cwd, todoPath, starterAction) {
900
900
  verify: 'atris business record atris/reports/<recap>.md --outcome mixed --metric "operator speed"',
901
901
  },
902
902
  });
903
- const projection = taskDb.taskProjection(db, { workspaceRoot, limit: 500 });
904
- const projectionPath = path.join(cwd, '.atris', 'state', 'tasks.projection.json');
905
- fs.mkdirSync(path.dirname(projectionPath), { recursive: true });
906
- fs.writeFileSync(projectionPath, `${JSON.stringify(projection, null, 2)}\n`, 'utf8');
903
+ // The one projection writer (same shape as `atris task`, temp file and rename).
904
+ const { writeDefaultProjection } = require('../commands/task');
905
+ const projectionPath = writeDefaultProjection(taskDb, db, { root: cwd }).outPath;
907
906
  return { ok: true, inserted: Boolean(result.inserted), taskId: result.id, projectionPath };
908
907
  } catch (error) {
909
908
  return { ok: false, error: error && error.message ? error.message : String(error) };
@@ -9,6 +9,11 @@
9
9
  const fs = require('fs');
10
10
  const os = require('os');
11
11
  const path = require('path');
12
+ const { stealStaleLock } = require('./file-lock');
13
+
14
+ // A lock is created empty and its holder record lands a moment later, so an
15
+ // unreadable lock this young is still someone's, not stale.
16
+ const UNREADABLE_GRACE_MS = 5000;
12
17
 
13
18
  function lockPath(home, campaignId) {
14
19
  return path.join(home, 'campaigns', campaignId, 'run.lock');
@@ -48,10 +53,16 @@ function acquireRunLock(home, campaignId, { pid = process.pid, host = os.hostnam
48
53
  } catch (error) {
49
54
  if (error.code !== 'EEXIST') throw error;
50
55
  }
56
+ let judged;
57
+ try { judged = fs.statSync(file); } catch { continue; }
58
+ let raw = null;
59
+ try { raw = fs.readFileSync(file, 'utf8'); } catch {}
51
60
  const holder = readHolder(file);
52
61
  if (holder && holder.host === host && isAlive(holder.pid)) return { ok: false, holder };
53
62
  if (holder && holder.host !== host) return { ok: false, holder };
54
- fs.rmSync(file, { force: true });
63
+ if (!holder && Date.now() - judged.mtimeMs < UNREADABLE_GRACE_MS) return { ok: false, holder: null };
64
+ // Rename-then-verify, so a second waiter never deletes a fresh lock.
65
+ stealStaleLock(file, judged, raw);
55
66
  }
56
67
  return { ok: false, holder: readHolder(file) };
57
68
  }
package/lib/engine-ask.js CHANGED
@@ -246,7 +246,7 @@ function assertAskModelSupported(engine, model) {
246
246
  function resolveAskModel(engine, modelName = '') {
247
247
  const requested = String(modelName || '').trim();
248
248
  if (requested) {
249
- // A display name ("opus 5.5") becomes an id only for the claude cli; every
249
+ // A spaced display name becomes an id only for the claude cli; every
250
250
  // other engine gets its model name as typed.
251
251
  return engine === 'claude' || engine === 'fable' || engine === 'haiku'
252
252
  ? claudeModelId(requested)
@@ -93,6 +93,70 @@ function sweepEngineAskReceipts(root, deps = {}) {
93
93
  return summary;
94
94
  }
95
95
 
96
+ // Fleet and dispatch flights write a running receipt with the driver's pid
97
+ // and rewrite it when they finish. A crashed flight never rewrites it, so the
98
+ // receipt read as running forever. Flights run long, so the age bar is an hour
99
+ // and a live pid always keeps the receipt as it is.
100
+ const DISPATCH_RECEIPT_SCHEMA = 'atris.dispatch_receipt.v1';
101
+ const FLEET_GRACE_MS = 60 * 60 * 1000;
102
+ const FLEET_SWEEP_NOTE = 'stale receipt sweep marked this flight presumed dead: its process is gone and it never finished.';
103
+
104
+ function isFleetReceipt(name, receipt) {
105
+ if (receipt.schema === DISPATCH_RECEIPT_SCHEMA) return name.startsWith('dispatch-');
106
+ return !receipt.schema && name.startsWith('fleet-') && Array.isArray(receipt.results);
107
+ }
108
+
109
+ function sweepFleetReceipts(root, deps = {}) {
110
+ const fsModule = deps.fs || fs;
111
+ const kill = deps.kill || process.kill;
112
+ const now = deps.now ? deps.now() : new Date();
113
+ const nowMs = now.getTime();
114
+ const graceMs = deps.graceMs === undefined ? FLEET_GRACE_MS : Number(deps.graceMs);
115
+ const summary = { scanned: 0, finalized: 0, skipped_alive: 0, skipped_young: 0 };
116
+ const runsDir = path.join(root, 'atris', 'runs');
117
+ let names;
118
+ try {
119
+ names = fsModule.readdirSync(runsDir)
120
+ .filter((name) => name.endsWith('.json') && (name.startsWith('fleet-') || name.startsWith('dispatch-')))
121
+ .sort();
122
+ } catch {
123
+ return summary;
124
+ }
125
+ for (const name of names) {
126
+ const receiptPath = path.join(runsDir, name);
127
+ let receipt;
128
+ let stat;
129
+ try {
130
+ receipt = JSON.parse(fsModule.readFileSync(receiptPath, 'utf8'));
131
+ stat = fsModule.statSync(receiptPath);
132
+ } catch {
133
+ continue;
134
+ }
135
+ if (!receipt || typeof receipt !== 'object' || receipt.status !== 'running' || !isFleetReceipt(name, receipt)) continue;
136
+ summary.scanned += 1;
137
+ // Age from the last write, not the start: a long flight rewrites its
138
+ // receipt as tasks land.
139
+ const lastWriteMs = Math.max(receiptStartedMs(receipt, stat), stat.mtimeMs);
140
+ if (nowMs - lastWriteMs < graceMs) {
141
+ summary.skipped_young += 1;
142
+ continue;
143
+ }
144
+ if (processIsAlive(Number(receipt.pid), kill)) {
145
+ summary.skipped_alive += 1;
146
+ continue;
147
+ }
148
+ atomicWriteReceipt(receiptPath, {
149
+ ...receipt,
150
+ status: 'presumed_dead',
151
+ swept_at: now.toISOString(),
152
+ note: FLEET_SWEEP_NOTE,
153
+ }, fsModule);
154
+ summary.finalized += 1;
155
+ }
156
+ return summary;
157
+ }
158
+
96
159
  module.exports = {
97
160
  sweepEngineAskReceipts,
161
+ sweepFleetReceipts,
98
162
  };
@@ -0,0 +1,175 @@
1
+ 'use strict';
2
+
3
+ // One exclusive lock file per resource, shared by the loops that must not run
4
+ // twice at once (autoland tick, autopilot, mission, campaign run).
5
+ //
6
+ // - Create is exclusive (`wx`), so two takers never both win.
7
+ // - The file records { pid, host, token, at }. Release removes it only while
8
+ // the token (or the open file) is still ours, so a tick that lost its lock
9
+ // to a stale-steal never deletes the next owner's lock.
10
+ // - A stale lock is stolen by renaming it to a unique tombstone first. If the
11
+ // renamed file turns out not to be the one judged stale (someone replaced
12
+ // it in between), it is linked back without clobbering. Two waiters that
13
+ // both judged the same lock stale can no longer delete each other's fresh
14
+ // lock.
15
+
16
+ const crypto = require('crypto');
17
+ const fs = require('fs');
18
+ const os = require('os');
19
+ const path = require('path');
20
+
21
+ function pidAlive(pid) {
22
+ const n = Number(pid);
23
+ if (!Number.isInteger(n) || n <= 0) return false;
24
+ try {
25
+ process.kill(n, 0);
26
+ return true;
27
+ } catch (error) {
28
+ return Boolean(error) && error.code === 'EPERM';
29
+ }
30
+ }
31
+
32
+ function readRaw(file) {
33
+ try { return fs.readFileSync(file, 'utf8'); } catch { return null; }
34
+ }
35
+
36
+ function parseHolder(raw) {
37
+ if (raw == null) return null;
38
+ try {
39
+ const value = JSON.parse(raw);
40
+ return value && typeof value === 'object' ? value : null;
41
+ } catch {
42
+ return null;
43
+ }
44
+ }
45
+
46
+ function sameFile(a, b) {
47
+ return Boolean(a && b) && a.ino === b.ino && a.dev === b.dev;
48
+ }
49
+
50
+ // Remove `file` only if it is still the file described by `judged` (stat) and
51
+ // `judgedRaw` (contents). Returns true when the caller should retry create.
52
+ function stealStaleLock(file, judged, judgedRaw) {
53
+ const tomb = `${file}.stale-${process.pid}-${crypto.randomBytes(4).toString('hex')}`;
54
+ try {
55
+ fs.renameSync(file, tomb);
56
+ } catch (error) {
57
+ if (error && error.code === 'ENOENT') return true;
58
+ throw error;
59
+ }
60
+ let moved = null;
61
+ try { moved = fs.statSync(tomb); } catch {}
62
+ const movedRaw = readRaw(tomb);
63
+ if (!sameFile(moved, judged) || (judgedRaw != null && movedRaw !== judgedRaw)) {
64
+ // We grabbed a lock that was replaced after we judged it. Put it back
65
+ // without overwriting anything created since.
66
+ try { fs.linkSync(tomb, file); } catch {}
67
+ }
68
+ try { fs.unlinkSync(tomb); } catch {}
69
+ return true;
70
+ }
71
+
72
+ // Decide whether a lock someone else holds may be taken over.
73
+ function judgeHolder(holder, ageMs, { host, isAlive, staleAfterMs, unreadableGraceMs }) {
74
+ if (!holder) return ageMs > unreadableGraceMs ? 'stale' : 'held';
75
+ const holderPid = Number(holder.pid);
76
+ const atMs = typeof holder.at === 'number' ? holder.at : Date.parse(holder.at || holder.started_at || '');
77
+ const heldFor = Number.isFinite(atMs) ? Date.now() - atMs : ageMs;
78
+ if (Number.isFinite(staleAfterMs) && heldFor > staleAfterMs) return 'stale';
79
+ if (holder.host && holder.host !== host) return 'held';
80
+ if (!Number.isInteger(holderPid) || holderPid <= 0) return ageMs > unreadableGraceMs ? 'stale' : 'held';
81
+ return isAlive(holderPid) ? 'held' : 'stale';
82
+ }
83
+
84
+ // Returns { ok: true, file, token, fd?, release() } or { ok: false, file, holder }.
85
+ // Options:
86
+ // meta extra fields written into the lock record
87
+ // pid, host identity written into the record (defaults: this process)
88
+ // staleAfterMs a lock older than this is stale even with a live pid
89
+ // unreadableGraceMs an empty/unreadable lock younger than this is held
90
+ // (its owner may still be writing the record)
91
+ // keepOpen return the open fd (release then checks the inode)
92
+ // isAlive pid liveness probe (tests)
93
+ function acquireFileLock(file, options = {}) {
94
+ const {
95
+ meta = {},
96
+ pid = process.pid,
97
+ host = os.hostname(),
98
+ staleAfterMs = Infinity,
99
+ unreadableGraceMs = 5000,
100
+ keepOpen = false,
101
+ isAlive = pidAlive,
102
+ attempts = 5,
103
+ } = options;
104
+ fs.mkdirSync(path.dirname(file), { recursive: true });
105
+ const token = crypto.randomBytes(8).toString('hex');
106
+ const record = JSON.stringify({ ...meta, pid, host, token, at: new Date().toISOString() });
107
+ let lastHolder = null;
108
+ for (let attempt = 0; attempt < attempts; attempt += 1) {
109
+ let fd;
110
+ try {
111
+ fd = fs.openSync(file, 'wx');
112
+ } catch (error) {
113
+ if (!error || error.code !== 'EEXIST') throw error;
114
+ }
115
+ if (fd !== undefined) {
116
+ try {
117
+ fs.writeSync(fd, record);
118
+ } catch (error) {
119
+ try { fs.closeSync(fd); } catch {}
120
+ try { fs.unlinkSync(file); } catch {}
121
+ throw error;
122
+ }
123
+ let ino = null;
124
+ try { ino = fs.fstatSync(fd); } catch {}
125
+ if (!keepOpen) {
126
+ try { fs.closeSync(fd); } catch {}
127
+ fd = undefined;
128
+ }
129
+ const lock = { ok: true, file, token, fd, stat: ino, record };
130
+ lock.release = () => releaseFileLock(lock);
131
+ return lock;
132
+ }
133
+ let judged;
134
+ try {
135
+ judged = fs.statSync(file);
136
+ } catch (error) {
137
+ if (error && error.code === 'ENOENT') continue;
138
+ throw error;
139
+ }
140
+ const raw = readRaw(file);
141
+ const holder = parseHolder(raw);
142
+ lastHolder = holder;
143
+ const verdict = judgeHolder(holder, Date.now() - judged.mtimeMs, { host, isAlive, staleAfterMs, unreadableGraceMs });
144
+ if (verdict === 'held') return { ok: false, file, holder };
145
+ stealStaleLock(file, judged, raw);
146
+ }
147
+ return { ok: false, file, holder: lastHolder || parseHolder(readRaw(file)) };
148
+ }
149
+
150
+ // Remove the lock only while it is still ours.
151
+ function releaseFileLock(lock) {
152
+ if (!lock || !lock.ok || lock.released) return false;
153
+ lock.released = true;
154
+ let ours = false;
155
+ if (lock.fd != null) {
156
+ try {
157
+ ours = sameFile(fs.fstatSync(lock.fd), fs.statSync(lock.file));
158
+ } catch {
159
+ ours = false;
160
+ }
161
+ try { fs.closeSync(lock.fd); } catch {}
162
+ } else {
163
+ const holder = parseHolder(readRaw(lock.file));
164
+ ours = Boolean(holder) && holder.token === lock.token;
165
+ }
166
+ if (!ours) return false;
167
+ try { fs.unlinkSync(lock.file); } catch { return false; }
168
+ return true;
169
+ }
170
+
171
+ module.exports = {
172
+ acquireFileLock,
173
+ stealStaleLock,
174
+ pidAlive,
175
+ };
package/lib/fleet.js CHANGED
@@ -179,7 +179,7 @@ function buildFleetPrompt(task, { worktreePath, yolo = false } = {}) {
179
179
  lines.push(
180
180
  'Rules:',
181
181
  mapNotesPath(worktreePath || process.cwd())
182
- ? '- Read atris/MAP.md first to locate the code, then atris/refs/MAP-NOTES.md for exact file:line refs; never guess file locations.'
182
+ ? '- Read atris/MAP.md first to locate the code, then atris/refs/MAP-NOTES.md (or the area note it points to) for exact file:line refs; never guess file locations.'
183
183
  : '- Read atris/MAP.md first to locate the code; never guess file locations.',
184
184
  ...SHARED_BRIEF_RULES,
185
185
  '- Run git status first. Stage ONLY files you changed. Never revert or touch files another agent modified.',