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
package/lib/task-db.js CHANGED
@@ -826,6 +826,13 @@ function pidAlive(pid) {
826
826
  try { process.kill(pid, 0); return true; } catch (err) { return err.code === 'EPERM'; }
827
827
  }
828
828
 
829
+ // A claim made by hand (`atris task claim` with no --lease-minutes) has no lease, so before this nothing ever let
830
+ // it go: a session that died holding one kept the job off the board for good. Such a claim is let go once its job
831
+ // has seen no activity (no event of any kind) for this many days, whatever --stale-days says. It is measured by
832
+ // activity, not by a clock set at claim time, so a claim someone is still working on is never taken away, and it
833
+ // is long enough that the narrower --stale-days rule (fleet claims, or one named claimer) still decides first.
834
+ const UNLEASED_CLAIM_STALE_DAYS = 30;
835
+
829
836
  function expireLeases(db, { now = Date.now(), staleDays, claimer, dryRun = false, isAlive = pidAlive } = {}) {
830
837
  const timestamp = now instanceof Date ? now.getTime() : typeof now === 'string' ? Date.parse(now) : Number(now);
831
838
  if (!Number.isFinite(timestamp)) throw new Error('invalid now');
@@ -846,6 +853,9 @@ function expireLeases(db, { now = Date.now(), staleDays, claimer, dryRun = false
846
853
  } else if (!lease && staleDays != null && row.last_seen_at != null && row.last_seen_at < timestamp - Number(staleDays) * 86400000 && (claimer ? row.claimed_by === claimer : String(row.claimed_by || '').startsWith('fleet-'))) {
847
854
  reason = `stranded with no activity for over ${staleDays} days`;
848
855
  leaseUntil = new Date(row.last_seen_at + 3600000).toISOString();
856
+ } else if (!lease && row.last_seen_at != null && row.last_seen_at < timestamp - UNLEASED_CLAIM_STALE_DAYS * 86400000) {
857
+ reason = `stranded with no activity for over ${UNLEASED_CLAIM_STALE_DAYS} days`;
858
+ leaseUntil = new Date(row.last_seen_at + 3600000).toISOString();
849
859
  } else continue;
850
860
  const lastSeen = lease && Number.isFinite(until) && Number.isFinite(Number(lease.minutes))
851
861
  ? until - Number(lease.minutes) * 60000 : row.last_seen_at;
@@ -1130,7 +1140,7 @@ function reopenTask(db, { id, actor, reason, metadata: metadataPatch, fromReview
1130
1140
  return { reopened: true, event, row: updated };
1131
1141
  }
1132
1142
 
1133
- function doneTask(db, { id, status, actor, allowReview = false, action, proof, autoAccepted = false } = {}) {
1143
+ function doneTask(db, { id, status, actor, allowReview = false, action, proof, autoAccepted = false, deferLogs = false } = {}) {
1134
1144
  if (!id) throw new Error('id required');
1135
1145
  const final = status || 'done';
1136
1146
  if (!['done', 'failed'].includes(final)) throw new Error('status must be done|failed');
@@ -1139,18 +1149,22 @@ function doneTask(db, { id, status, actor, allowReview = false, action, proof, a
1139
1149
  // read as a real failure. See archiveTask() below.
1140
1150
  const now = Date.now();
1141
1151
  const allowedStatuses = allowReview ? "'open', 'claimed', 'review'" : "'open', 'claimed'";
1142
- const result = withBusyRetry(() => db.prepare(`
1143
- UPDATE tasks
1144
- SET status = ?, done_at = ?, updated_at = ?
1145
- WHERE id = ?
1146
- AND status IN (${allowedStatuses})
1147
- `).run(final, now, now, id));
1148
- if (result.changes === 1) {
1149
- const row = getTask(db, id);
1152
+ const eventActor = actor || process.env.ATRIS_AGENT_ID || process.env.USER || null;
1153
+ // The status change and its event are one write: a crash between them used to leave a done job with no
1154
+ // completed event. The daily log is a file, written only after the write is saved.
1155
+ const row = inWriteTransaction(db, () => {
1156
+ const result = withBusyRetry(() => db.prepare(`
1157
+ UPDATE tasks
1158
+ SET status = ?, done_at = ?, updated_at = ?
1159
+ WHERE id = ?
1160
+ AND status IN (${allowedStatuses})
1161
+ `).run(final, now, now, id));
1162
+ if (result.changes !== 1) return null;
1163
+ const updated = getTask(db, id);
1150
1164
  appendTaskEvent(db, {
1151
1165
  taskId: id,
1152
- workspaceRoot: row.workspace_root,
1153
- actor: actor || process.env.ATRIS_AGENT_ID || process.env.USER || null,
1166
+ workspaceRoot: updated.workspace_root,
1167
+ actor: eventActor,
1154
1168
  eventType: final === 'done' ? 'completed' : 'blocked',
1155
1169
  payload: {
1156
1170
  status: final,
@@ -1158,17 +1172,40 @@ function doneTask(db, { id, status, actor, allowReview = false, action, proof, a
1158
1172
  ...(autoAccepted ? { auto_accepted: true } : {}),
1159
1173
  },
1160
1174
  });
1161
- const logs = appendTaskCompletionLogs(db, row, {
1162
- status: final,
1163
- actor: actor || process.env.ATRIS_AGENT_ID || process.env.USER || null,
1164
- action: action || final,
1165
- proof,
1166
- });
1175
+ return updated;
1176
+ });
1177
+ if (row) {
1178
+ const logInput = { status: final, actor: eventActor, action: action || final, proof };
1179
+ // A caller that holds its own transaction (acceptTask) writes the log once that is saved.
1180
+ if (deferLogs) return { updated: true, row, logInput };
1181
+ const logs = appendTaskCompletionLogs(db, row, logInput);
1167
1182
  return { updated: true, logs };
1168
1183
  }
1169
1184
  return { updated: false };
1170
1185
  }
1171
1186
 
1187
+ // Accepting a job is two writes, the job marked done and the review that records it as accepted with its
1188
+ // reward. They are one transaction: a crash between them used to leave a done job with approval still
1189
+ // pending, and nothing scans done jobs to finish the accept (autoland reads only review). The daily log
1190
+ // is a file and is written once the transaction is saved.
1191
+ function acceptTask(db, { id, actor, proof, reward, lesson = '', nextTask = '', autoAccepted = false, careerXpEligible = true, clearedFields = [] } = {}) {
1192
+ if (!id) throw new Error('id required');
1193
+ const out = inWriteTransaction(db, () => {
1194
+ const done = doneTask(db, { id, status: 'done', actor, allowReview: true, action: 'accepted', proof, autoAccepted, deferLogs: true });
1195
+ if (!done.updated) return { updated: false };
1196
+ const reviewed = reviewTask(db, { id, actor, reward, lesson, nextTask, proof, careerXpEligible, clearedFields, autoAccepted });
1197
+ return { updated: true, reviewed, row: done.row, logInput: done.logInput };
1198
+ });
1199
+ if (!out.updated) return { updated: false };
1200
+ let logs = {};
1201
+ try {
1202
+ logs = appendTaskCompletionLogs(db, out.row, out.logInput);
1203
+ } catch (err) {
1204
+ if (process.env.ATRIS_DEBUG) process.stderr.write(`accept log failed: ${String(err?.message ?? err).replace(/[\r\n]+/g, ' ')}\n`);
1205
+ }
1206
+ return { updated: true, reviewed: out.reviewed, logs };
1207
+ }
1208
+
1172
1209
  function reapMissionBlockerTasks(db, { workspaceRoot: ws, missions = [], actor, rows = null, skipLogs = false } = {}) {
1173
1210
  const missionById = new Map((Array.isArray(missions) ? missions : [])
1174
1211
  .filter(mission => mission && mission.id)
@@ -1825,32 +1862,55 @@ function releaseClosedPullRequest(db, { id, actor, workspaceRoot: ws, expected,
1825
1862
  return { released: true, row: out.row, event: out.event };
1826
1863
  }
1827
1864
 
1865
+ // Read a job's metadata, change it, and write it back with the write lock held, so two writers that each
1866
+ // read the whole JSON cannot drop each other's keys. The mutator gets a copy and changes it in place or
1867
+ // returns a new object; returning false writes nothing. Keep the mutator fast and free of I/O: it runs
1868
+ // inside BEGIN IMMEDIATE. touch: false leaves updated_at as it was.
1869
+ // Returns null when the job does not exist, else { row, metadata, changed }.
1870
+ function updateTaskMetadata(db, id, mutator, { touch = true, now = Date.now() } = {}) {
1871
+ if (!id) throw new Error('id required');
1872
+ if (typeof mutator !== 'function') throw new Error('mutator required');
1873
+ return inWriteTransaction(db, () => {
1874
+ const row = getTask(db, id);
1875
+ if (!row) return null;
1876
+ const metadata = row.metadata && typeof row.metadata === 'object' ? { ...row.metadata } : {};
1877
+ const out = mutator(metadata, row);
1878
+ if (out === false) return { row, metadata, changed: false };
1879
+ const next = out && typeof out === 'object' ? out : metadata;
1880
+ const text = JSON.stringify(next);
1881
+ withBusyRetry(() => (touch
1882
+ ? db.prepare('UPDATE tasks SET metadata = ?, updated_at = ? WHERE id = ?').run(text, now, id)
1883
+ : db.prepare('UPDATE tasks SET metadata = ? WHERE id = ?').run(text, id)));
1884
+ return { row, metadata: next, changed: true };
1885
+ });
1886
+ }
1887
+
1828
1888
  function setTaskResult(db, { id, actor, result }) {
1829
1889
  if (!id) throw new Error('id required');
1830
1890
  const text = cleanLandingText(result);
1831
1891
  if (!text) throw new Error('result required');
1832
- const row = getTask(db, id);
1833
- if (!row) return { saved: false, reason: 'not_found' };
1834
1892
  const now = Date.now();
1835
- const metadata = row.metadata && typeof row.metadata === 'object' ? { ...row.metadata } : {};
1836
- metadata.result = text;
1837
- metadata.result_at = new Date(now).toISOString();
1838
- metadata.result_by = actor || process.env.ATRIS_AGENT_ID || process.env.USER || null;
1839
- const updated = withBusyRetry(() => db.prepare(`
1840
- UPDATE tasks
1841
- SET metadata = ?,
1842
- updated_at = ?
1843
- WHERE id = ?
1844
- `).run(JSON.stringify(metadata), now, id));
1845
- if (updated.changes !== 1) return { saved: false, reason: 'stale_task_state' };
1846
- const latest = getTask(db, id);
1847
- const event = appendTaskEvent(db, {
1848
- taskId: id,
1849
- workspaceRoot: latest.workspace_root,
1850
- actor: metadata.result_by,
1851
- eventType: 'result_set',
1852
- payload: { result: text },
1893
+ const resultBy = actor || process.env.ATRIS_AGENT_ID || process.env.USER || null;
1894
+ // The metadata write and its event are one transaction, and the metadata is read under the lock.
1895
+ const out = inWriteTransaction(db, () => {
1896
+ const saved = updateTaskMetadata(db, id, (metadata) => {
1897
+ metadata.result = text;
1898
+ metadata.result_at = new Date(now).toISOString();
1899
+ metadata.result_by = resultBy;
1900
+ }, { now });
1901
+ if (!saved) return null;
1902
+ const row = getTask(db, id);
1903
+ const written = appendTaskEvent(db, {
1904
+ taskId: id,
1905
+ workspaceRoot: row.workspace_root,
1906
+ actor: resultBy,
1907
+ eventType: 'result_set',
1908
+ payload: { result: text },
1909
+ });
1910
+ return { latest: row, event: written };
1853
1911
  });
1912
+ if (!out) return { saved: false, reason: 'not_found' };
1913
+ const { latest, event } = out;
1854
1914
  return { saved: true, event, row: latest };
1855
1915
  }
1856
1916
 
@@ -3053,6 +3113,11 @@ function taskProjection(db, {
3053
3113
  };
3054
3114
  }
3055
3115
 
3116
+ // Status names: the database keeps `failed` (doneTask writes it, its event is `blocked`), and
3117
+ // TODO.md shows those rows under "## Blocked". The words differ on purpose and the stored value
3118
+ // is not renamed: every reader, filter, and old database already says `failed`, and a person
3119
+ // reads "blocked" as "needs someone", which is what a failed job is. `atris task list --status
3120
+ // failed` is how to list them.
3056
3121
  function renderTodoMarkdown(rows, { title = 'TODO.md', doneLimit = TODO_RENDER_DONE_LIMIT, failedLimit = TODO_RENDER_FAILED_LIMIT, refRows = rows, preservedSections = [] } = {}) {
3057
3122
  const displayRows = withTaskDisplayRefs(rows, refRows);
3058
3123
  const buckets = {
@@ -3192,6 +3257,7 @@ module.exports = {
3192
3257
  renewLease,
3193
3258
  clearLease,
3194
3259
  expireLeases,
3260
+ UNLEASED_CLAIM_STALE_DAYS,
3195
3261
  previewExpiredLeases,
3196
3262
  sweepExpiredLeases,
3197
3263
  releaseTask,
@@ -3201,6 +3267,7 @@ module.exports = {
3201
3267
  backlogTask,
3202
3268
  clearPlanTasks,
3203
3269
  doneTask,
3270
+ acceptTask,
3204
3271
  reapMissionBlockerTasks,
3205
3272
  archiveTask,
3206
3273
  archiveNotNeeded,
@@ -3212,6 +3279,7 @@ module.exports = {
3212
3279
  landHeldMerge,
3213
3280
  releaseClosedPullRequest,
3214
3281
  setTaskResult,
3282
+ updateTaskMetadata,
3215
3283
  reviseTask,
3216
3284
  stageTask,
3217
3285
  noteTask,
@@ -0,0 +1,35 @@
1
+ 'use strict';
2
+
3
+ // Taste is personal, so it lives outside the shipped skill: the project's
4
+ // atris/taste/TASTE.md and the person's ~/.atris/taste/TASTE.md. Sync replaces
5
+ // atris/skills/ on every update and never touches either file. Init and update
6
+ // only create the project starter when it is missing.
7
+
8
+ const fs = require('fs');
9
+ const path = require('path');
10
+
11
+ const PROJECT_TASTE_FILE = path.join('taste', 'TASTE.md');
12
+
13
+ const STARTER = `# Taste
14
+
15
+ How this project should look and read: its brand and its voice. Agents read this before making anything a person will see. The person's own taste in \`~/.atris/taste/TASTE.md\` comes second.
16
+
17
+ Each line gives the rule in plain words and links to a short note with the reason and real examples. \`atris/skills/taste/notes.md\` shows the shape.
18
+
19
+ ## The list
20
+
21
+ - (Nothing recorded yet. When someone corrects how something looks or reads, add the rule here.)
22
+ `;
23
+
24
+ // Returns true when the starter was written (or would be, in a dry run).
25
+ function ensureProjectTasteFile(atrisDir, { dryRun = false } = {}) {
26
+ const file = path.join(atrisDir, PROJECT_TASTE_FILE);
27
+ if (fs.existsSync(file)) return false;
28
+ if (!dryRun) {
29
+ fs.mkdirSync(path.dirname(file), { recursive: true });
30
+ fs.writeFileSync(file, STARTER, { flag: 'wx' });
31
+ }
32
+ return true;
33
+ }
34
+
35
+ module.exports = { ensureProjectTasteFile };
@@ -19,15 +19,15 @@ function orderJobs(rows) {
19
19
  return [...builtIn, ...list.filter((row) => !builtIn.includes(row))];
20
20
  }
21
21
 
22
- // "opus 5.5" for a claude model, "codex gpt-6-astra" for anything else, and
22
+ // Friendly label for a claude model, "<tool> <model>" for anything else, and
23
23
  // just the tool when it runs its own default.
24
24
  function shortRuns(view) {
25
25
  if (!view || !view.engine) return '';
26
26
  const tool = rosterToolLabel(view.engine);
27
27
  if (!view.model) return tool;
28
28
  const label = modelLabel(view.model);
29
- // "atris:fast" on atris fast says the tool twice; "grok 4.7 fast" on grok
30
- // already names it.
29
+ // "atris:fast" on atris fast says the tool twice; a label starting with the
30
+ // tool name already names it.
31
31
  const plain = (text) => String(text).toLowerCase().replace(/[^a-z0-9.]+/g, ' ').trim();
32
32
  if (plain(label) === plain(tool)) return tool;
33
33
  if (CLAUDE_FAMILY.includes(view.engine) || plain(label).startsWith(`${plain(tool)} `)) return label;
package/lib/todo.js CHANGED
@@ -61,6 +61,8 @@ function dbBuckets(workspaceRoot) {
61
61
  if (r.status === 'open') backlog.push(shaped);
62
62
  else if (r.status === 'claimed') inProgress.push(shaped);
63
63
  else if (r.status === 'review') review.push(shaped);
64
+ // `failed` is the stored name for a job TODO.md shows under "Blocked" (see renderTodoMarkdown
65
+ // in lib/task-db.js); this shim has no blocked bucket, so it files them with the finished rows.
64
66
  else if (r.status === 'done' || r.status === 'failed') completed.push(shaped);
65
67
  }
66
68
  return { backlog, inProgress, review, completed, sourceKeys, sourceRefs };
package/lib/wiki.js CHANGED
@@ -71,12 +71,17 @@ Turn raw project context into a living memory the next agent can pick up cold.
71
71
  - Add cross-references with \`[[${wikiLinkRoot}/...]]\` links.
72
72
  - Keep \`index.md\`, \`log.md\`, and \`STATUS.md\` in sync with page changes.
73
73
  - If something is unclear or contradictory, say so directly.
74
+ - One idea per page, under about 80 lines, with real examples right under the rule they prove.
75
+ - Each \`index.md\` line gives the rule or answer and says when to open the page. Never a bare topic name.
76
+ - Keep \`index.md\` to one screen. Move text instead of rewriting it, and list anything deleted in \`log.md\`.
74
77
  `;
75
78
  }
76
79
 
77
80
  function indexMarkdown() {
78
81
  return `# Atris Wiki Index
79
82
 
83
+ Each line gives the rule or answer and says when to open the page behind it.
84
+
80
85
  ## People
81
86
 
82
87
  ## Systems
@@ -874,7 +879,9 @@ Rules:
874
879
  - Merge new info into existing pages instead of replacing them
875
880
  - Keep index.md, log.md, and STATUS.md current
876
881
  - Flag contradictions directly instead of smoothing them over
877
- - Never modify the raw source documents you ingested`;
882
+ - Never modify the raw source documents you ingested
883
+ - One idea per page, under about 80 lines, with examples under the rule they prove
884
+ - An index line gives the rule or answer, then says when to open the page`;
878
885
  }
879
886
 
880
887
  function buildIngestPrompt(sourceValue, mode = 'public') {
@@ -889,7 +896,7 @@ Workflow:
889
896
  3. Extract people, systems, and concepts worth preserving
890
897
  4. Create or update pages under ${wikiRoot}/, merging with existing facts instead of replacing them
891
898
  5. Add cross-references using [[${wikiLinkRoot}/...]] links
892
- 6. Update ${wikiRoot}/index.md with one-line descriptions of touched pages
899
+ 6. Update ${wikiRoot}/index.md with one line per touched page: the rule or answer, then when to open it
893
900
  7. Append an INGEST entry to ${wikiRoot}/log.md under today's date
894
901
  8. Refresh ${wikiRoot}/STATUS.md in plain English for a non-technical reader
895
902
 
@@ -945,7 +952,8 @@ Keep rules:
945
952
  - Entries marked as user-stated conventions or explicit remember-this survive.
946
953
  - Every kept entry keeps its provenance as a source link or date.
947
954
 
948
- Consolidate overlapping entries, preserve the strongest source-backed version, and leave index.md and page links consistent.`;
955
+ Consolidate overlapping entries, preserve the strongest source-backed version, and leave index.md and page links consistent.
956
+ Split any page over about 80 lines or holding two ideas. Rewrite bare topic lines in index.md so each says when to open the page.`;
949
957
  }
950
958
 
951
959
  module.exports = {