atris 3.80.0 → 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 (73) 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/auth.js +2 -2
  21. package/commands/autoland.js +25 -26
  22. package/commands/autopilot-front.js +46 -1
  23. package/commands/autopilot.js +55 -25
  24. package/commands/clean.js +22 -11
  25. package/commands/computer.js +166 -2413
  26. package/commands/doc-health.js +48 -1
  27. package/commands/improve.js +16 -3
  28. package/commands/init.js +4 -0
  29. package/commands/integrations.js +69 -1616
  30. package/commands/mission.js +109 -13
  31. package/commands/release.js +41 -10
  32. package/commands/security-review.js +5 -1
  33. package/commands/site-deploy.js +5 -1
  34. package/commands/status.js +1 -1
  35. package/commands/sync.js +12 -8
  36. package/commands/task.js +144 -118
  37. package/commands/unknowns.js +9 -5
  38. package/commands/visualize.js +1 -12
  39. package/commands/workflow.js +4 -3
  40. package/commands/worktree.js +68 -1602
  41. package/commands/xp.js +23 -2113
  42. package/commands/youtube.js +192 -3110
  43. package/lib/business-core.js +3 -4
  44. package/lib/campaign-run-lock.js +12 -1
  45. package/lib/computer-core.js +2447 -0
  46. package/lib/engine-ask.js +1 -1
  47. package/lib/engine-receipt-sweep.js +64 -0
  48. package/lib/file-lock.js +175 -0
  49. package/lib/fleet.js +1 -1
  50. package/lib/integrations-core.js +1695 -0
  51. package/lib/json-file.js +22 -1
  52. package/lib/loop-doctor.js +36 -2
  53. package/lib/map-refs.js +68 -8
  54. package/lib/member-engine.js +1 -1
  55. package/lib/member-map-fold.js +49 -11
  56. package/lib/member-map-inbox.js +2 -1
  57. package/lib/mission-core.js +9 -2
  58. package/lib/recall-exam.js +171 -0
  59. package/lib/roster-markdown.js +2 -2
  60. package/lib/runner-command.js +5 -5
  61. package/lib/security-audit-classes.js +20 -2
  62. package/lib/security-audit.js +31 -1
  63. package/lib/task-db.js +105 -37
  64. package/lib/taste-notes.js +35 -0
  65. package/lib/team-lineup.js +3 -3
  66. package/lib/todo.js +2 -0
  67. package/lib/wiki.js +11 -3
  68. package/lib/worktree-core.js +1517 -0
  69. package/lib/xp-core.js +2172 -0
  70. package/lib/youtube-core.js +3178 -0
  71. package/lib/zen-apply.js +3 -1
  72. package/package.json +10 -3
  73. package/utils/auth.js +18 -4
@@ -8,7 +8,7 @@
8
8
  // control that should stop them, and the concrete result across the boundary.
9
9
 
10
10
  const CODE_EXTS = new Set(['.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx', '.py', '.rb', '.go', '.java', '.php', '.rs', '.sh', '.bash', '.zsh']);
11
- const WEB_EXTS = new Set(['.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx', '.html', '.vue', '.svelte']);
11
+ const WEB_EXTS = new Set(['.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx', '.html', '.vue', '.svelte', '.erb', '.rb']);
12
12
  const WORKFLOW_RE = /(?:^|\/)\.github\/workflows\/[^/]+\.ya?ml$/;
13
13
 
14
14
  const CLASSES = [
@@ -23,6 +23,9 @@ const CLASSES = [
23
23
  /\bsubprocess\.(?:run|call|Popen|check_output|check_call)\s*\(/,
24
24
  /\bos\.(?:system|popen)\s*\(/,
25
25
  /\bshell\s*:\s*true\b/,
26
+ // Ruby, Go, Java
27
+ /\b(?:system|spawn|exec)\s*\(?\s*["'][^"'\n]*#\{|%x[{(\[]|\bIO\.popen\b|\bOpen3\.\w+|`[^`\n]*#\{/,
28
+ /\bexec\.Command(?:Context)?\s*\(|\bRuntime\.getRuntime\(\)\.exec\b|\bnew\s+ProcessBuilder\b/,
26
29
  ],
27
30
  guide: [
28
31
  'Invariant: a lower-trust value never becomes shell syntax, a program name, or an option the caller did not intend.',
@@ -44,6 +47,7 @@ const CLASSES = [
44
47
  /\brequire\s*\(\s*(?!['"`])[^)]/,
45
48
  /\bimport\s*\(\s*(?!['"`])[^)]/,
46
49
  /\b(?:exec|compile)\s*\(\s*(?!['"])/,
50
+ /\b(?:instance_eval|class_eval|module_eval|instance_exec)\b|\.(?:constantize|safe_constantize)\b|\bconst_get\s*\(|\.(?:public_)?send\s*\(\s*(?![:'\"])/,
47
51
  ],
48
52
  guide: [
49
53
  'Invariant: text from a lower-trust source is never run as code, and a module path is never chosen by it.',
@@ -61,6 +65,7 @@ const CLASSES = [
61
65
  /\b(?:readFileSync|writeFileSync|readFile|writeFile|appendFileSync|createReadStream|createWriteStream|unlinkSync|rmSync|rmdirSync|renameSync|copyFileSync|symlinkSync|cpSync)\s*\(\s*(?!['"`])/,
62
66
  /\bpath\.(?:join|resolve)\s*\([^)]*\b(?:req|params|query|body|name|slug|id|input|args?|file|target)\b/,
63
67
  /\bextract(?:All)?\s*\(|\btar\b.*\bx\b|\bunzip\b/,
68
+ /\bFile\.(?:read|write|open|binread|binwrite|delete|unlink|rename|symlink)\s*\(\s*(?!['\"])|\bFileUtils\.\w+|\bsend_file\b|\bos\.(?:Open|OpenFile|Create|ReadFile|WriteFile|Remove(?:All)?)\s*\(|\bfilepath\.Join\s*\(/,
64
69
  ],
65
70
  guide: [
66
71
  'Invariant: a path built from a lower-trust value stays inside the directory the code means, after normalization and symlink resolution.',
@@ -81,6 +86,7 @@ const CLASSES = [
81
86
  /\baxios(?:\.\w+)?\s*\(/,
82
87
  /\brequests\.(?:get|post|put|patch|delete|request)\s*\(/,
83
88
  /\burllib\.request\.urlopen\s*\(/,
89
+ /\bNet::HTTP\b|\bHTTParty\b|\bFaraday\b|\bURI\.open\b|\bRestClient\b|\bhttp\.(?:Get|Post|Head|NewRequest(?:WithContext)?)\s*\(/,
84
90
  ],
85
91
  guide: [
86
92
  'Invariant: a lower-trust value cannot pick the host, scheme, or port of a request made with this process\'s network position or credentials.',
@@ -100,6 +106,10 @@ const CLASSES = [
100
106
  /\bcreateServer\s*\(/,
101
107
  /\.listen\s*\(\s*(?:\d|port|PORT)/,
102
108
  /\b(?:req|request)\.(?:headers|query|params|body)\b/,
109
+ /\b(?:before_action|skip_before_action|authorize!?|current_user|policy_scope|load_and_authorize_resource)\b|\bparams\[/,
110
+ /\bdef\s+(?:self\.)?(?:authorized\?|visible\?|scope_items\b|resolve\b)|\bfield\s+:\w+|\bargument\s+:\w+/,
111
+ /^\s*(?:get|post|put|patch|delete|match|resources?)\s+['\":]/,
112
+ /\bhttp\.HandleFunc\s*\(|\b\w+\.(?:GET|POST|PUT|PATCH|DELETE|Handle|HandleFunc)\s*\(\s*\"\//,
103
113
  ],
104
114
  guide: [
105
115
  'Invariant: every action that reads or changes another principal\'s data, or spends shared resources, checks who is asking and whether they may.',
@@ -119,6 +129,7 @@ const CLASSES = [
119
129
  /os\.environ(?:\.get)?\s*[[(]\s*['"][A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD)/,
120
130
  /\b(?:authorization|x-api-key|api[_-]?key|access[_-]?token|refresh[_-]?token)\b/i,
121
131
  /\bcredentials?\.json\b|\bkeychain\b|\.netrc\b/i,
132
+ /\bENV(?:\.fetch)?\s*[\[(]\s*['\"][A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD)|\bos\.Getenv\s*\(\s*\"[A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD)/,
122
133
  ],
123
134
  guide: [
124
135
  'Invariant: a credential reaches only the party it was issued for, and never lands in logs, errors, receipts, prompts, child process environments, or files others can read.',
@@ -155,6 +166,7 @@ const CLASSES = [
155
166
  /\b(?:SELECT|INSERT|UPDATE|DELETE)\b[^;\n]*(?:\$\{|['"`]\s*\+|%s|\.format\(|\bf["'])/i,
156
167
  /\.(?:query|execute|raw|exec)\s*\(\s*(?:`[^`]*\$\{|f["']|['"][^'"]*['"]\s*\+)/,
157
168
  /\$where\b|\.find\s*\(\s*(?:req|request)\./,
169
+ /\.(?:where|order|group|having|joins|select|pluck|find_by_sql|exec_query|execute)\s*\(\s*\"[^\"\n]*#\{|\.order\s*\(\s*params\b|\.(?:Query|QueryRow|Exec)(?:Context)?\s*\([^)\n]*fmt\.Sprintf/,
158
170
  ],
159
171
  guide: [
160
172
  'Invariant: lower-trust values reach a query only as bound parameters, never as query text, identifiers, or operators.',
@@ -175,6 +187,7 @@ const CLASSES = [
175
187
  /\bv-html\b|\{@html\b/,
176
188
  /\bpostMessage\s*\(|addEventListener\s*\(\s*['"]message['"]/,
177
189
  /\bhref\s*=\s*\{?\s*(?!['"](?:https?:|\/|#))/,
190
+ /\.html_safe\b|\braw\s*\(|<%==/,
178
191
  ],
179
192
  guide: [
180
193
  'Invariant: text from another user or an outside source renders as text, never as markup or script, and cross-window messages are accepted only from expected origins.',
@@ -194,6 +207,7 @@ const CLASSES = [
194
207
  /\bunserialize\s*\(|\bMarshal\.load\b/,
195
208
  /\b__proto__\b|\bconstructor\s*\[\s*['"]prototype/,
196
209
  /\bObject\.assign\s*\(\s*\{\}\s*,\s*(?:req|body|input|data|JSON\.parse)/,
210
+ /\bYAML\.(?:load|unsafe_load)\s*\(|\bPsych\.(?:load|unsafe_load)\b|\bOj\.load\b|\bnew\s+ObjectInputStream\b|\bgob\.NewDecoder\b/,
197
211
  ],
198
212
  guide: [
199
213
  'Invariant: parsing lower-trust input never runs code, never changes shared object prototypes, and never produces an object the next step trusts more than the input.',
@@ -212,6 +226,8 @@ const CLASSES = [
212
226
  /createHash\s*\(\s*['"](?:md5|sha1)['"]/,
213
227
  /\b\w*(?:token|secret|signature|hmac|digest)\w*\s*(?:===|!==|==|!=)\s*\w*(?:token|secret|signature|hmac|digest|expected)/i,
214
228
  /\bjwt\.(?:decode|verify)\s*\(|algorithms?\s*:\s*\[?\s*['"]none['"]/,
229
+ /\bOpenSSL::(?:Cipher|PKey|HMAC|Digest|KDF)\b|\bDigest::(?:MD5|SHA1)\b|\bSecureRandom\b|\bsecure_compare\b|\bJWT\.decode\b/,
230
+ /\bcrypto\.(?:createCipheriv|createDecipheriv|createSign|createVerify|timingSafeEqual)\b|\b(?:decrypt|encrypt|verify_signature|verify_raw|sign_raw)\s*[(]|\bcrypto\/(?:aes|cipher|rsa|ecdsa|ed25519|hmac|subtle)\b/,
215
231
  ],
216
232
  guide: [
217
233
  'Invariant: secrets, session ids, and one-time codes are unguessable, and checks of them do not leak timing or accept forged values.',
@@ -225,7 +241,7 @@ const CLASSES = [
225
241
  boundary: 'install, build, and release',
226
242
  weight: 8,
227
243
  exts: null,
228
- files: (rel) => WORKFLOW_RE.test(rel) || /(?:^|\/)package\.json$/.test(rel) || /(?:^|\/)(?:Dockerfile|Makefile|install\.sh|setup\.py|pyproject\.toml)$/.test(rel),
244
+ files: (rel) => WORKFLOW_RE.test(rel) || /(?:^|\/)package\.json$/.test(rel) || /(?:^|\/)(?:Dockerfile|Makefile|install\.sh|setup\.py|pyproject\.toml|Gemfile|Rakefile|go\.mod)$/.test(rel) || /\.gemspec$/.test(rel),
229
245
  detect: [
230
246
  /"(?:preinstall|install|postinstall|prepare|prepublish)"\s*:/,
231
247
  /\bpull_request_target\b|\bworkflow_run\b/,
@@ -233,6 +249,7 @@ const CLASSES = [
233
249
  /\bcurl\b[^|\n]*\|\s*(?:ba|z)?sh\b|\bwget\b[^|\n]*\|\s*(?:ba|z)?sh\b/,
234
250
  /\$\{\{\s*github\.event\.(?:issue|pull_request|comment|head_commit|review)\b/,
235
251
  /\bsecrets\.[A-Z0-9_]+/,
252
+ /^\s*(?:gem|source)\s+['\"][^'\"]+['\"]\s*,\s*(?:git|github|path):|\.add_(?:runtime_)?dependency\b|\bspec\.(?:files|executables)\b/,
236
253
  ],
237
254
  guide: [
238
255
  'Invariant: code from a lower-trust contributor or dependency cannot run with release credentials, change what ships, or execute on a user\'s machine at install time.',
@@ -277,6 +294,7 @@ const CANDIDATE_GATE = [
277
294
  'Propose "confirmed" only when the trace is complete, the impact crosses a stated boundary, every condition is listed, and no layer in source prevents it.',
278
295
  'Do not strengthen a crash into code execution, ordinary work into an outage, or a same-person action into privilege gain.',
279
296
  'If a decisive fact is outside source (deployment, proxy, provider setting, who can write a file), propose "needs_validation" with the exact missing fact. It gets no severity.',
297
+ 'Before calling a library version or behavior unknown, read what is installed in the target: lockfiles, venv or .venv site-packages, node_modules, vendor folders. A fact on disk is not outside source.',
280
298
  'A missing best practice with no affected principal or resource goes in "hardening", not in candidates. A candidate disproved by source is dropped.',
281
299
  'The fingerprint names the root cause, like "lib/foo.js:runHook/shell-from-task-title". No line numbers, no severity, no verdict in it.',
282
300
  'Return an empty candidates array when nothing survives these rules. Most units should end "covered".',
@@ -761,6 +761,7 @@ function verifierPrompt({ candidate, cls }) {
761
761
  'Ask who controls the input. If it is the same person who runs the tool, it is self-impact, so reject it.',
762
762
  '"confirmed": the full trace holds, the impact crosses the stated boundary, and nothing in source prevents it. Fix the severity if it is overstated; overall can never exceed impact.',
763
763
  '"needs_validation": the trace holds in source, but a decisive fact is outside it. Name that fact exactly and give a safe check the owner can run.',
764
+ 'A library version or behavior is not outside source when the target has it installed. Read the lockfile, the venv or .venv site-packages, or node_modules before sending it to the owner.',
764
765
  '"rejected": the source disproves it. Name the line that does.',
765
766
  'Use "corrected" only for fields you would change. Read-only: do not run the target or touch the network.',
766
767
  ]),
@@ -1163,6 +1164,17 @@ function resolveOutDir(root, { out, repo }) {
1163
1164
  return { dir: path.join(outRoot, `run-${next}`), outRoot };
1164
1165
  }
1165
1166
 
1167
+ // Same function in the fingerprint and sinks within a few lines of each other:
1168
+ // one defect, even when two hunters filed it under different classes.
1169
+ function sameRootCause(a, b) {
1170
+ const scope = (fp) => String(fp || '').split('/').slice(0, -1).join('/');
1171
+ const sink = (c) => (Array.isArray(c.trace) && c.trace.length ? c.trace[c.trace.length - 1] : null);
1172
+ const sa = sink(a);
1173
+ const sb = sink(b);
1174
+ if (!sa || !sb || !scope(a.fingerprint) || scope(a.fingerprint) !== scope(b.fingerprint)) return false;
1175
+ return sa.file === sb.file && Math.abs(Number(sa.line) - Number(sb.line)) <= 3;
1176
+ }
1177
+
1166
1178
  function budgetPlan(profile, total) {
1167
1179
  const critics = profile === 'quick' ? 1 : 2;
1168
1180
  const verifyReserve = Math.max(1, Math.ceil(0.3 * (total - critics)));
@@ -1256,7 +1268,11 @@ async function runAudit(opts = {}) {
1256
1268
 
1257
1269
  const plan = budgetPlan(profile, total);
1258
1270
  const plannedCount = ledger.filter((u) => u.status === 'planned').length;
1259
- meta.plan = { ...plan, planned_units: plannedCount, units_per_hunter: UNITS_PER_HUNTER[profile], hunters_needed: Math.ceil(plannedCount / UNITS_PER_HUNTER[profile]) };
1271
+ // Hunters take one class in one subsystem at a time, so count batches per group.
1272
+ const groupSizes = new Map();
1273
+ for (const u of ledger) if (u.status === 'planned') { const k = `${u.attack_class}\0${u.subsystem}`; groupSizes.set(k, (groupSizes.get(k) || 0) + 1); }
1274
+ const huntersNeeded = [...groupSizes.values()].reduce((n, size) => n + Math.ceil(size / UNITS_PER_HUNTER[profile]), 0);
1275
+ meta.plan = { ...plan, planned_units: plannedCount, units_per_hunter: UNITS_PER_HUNTER[profile], hunters_needed: huntersNeeded };
1260
1276
 
1261
1277
  const pendingRecords = [];
1262
1278
  const finish = (status, reason, records = [], carriedPending = []) => {
@@ -1378,9 +1394,23 @@ async function runAudit(opts = {}) {
1378
1394
  const pending = [];
1379
1395
  const toVerify = [];
1380
1396
  const seenFp = new Set();
1397
+ const kept = [];
1381
1398
  for (const cand of queue) {
1382
1399
  if (seenFp.has(cand.fingerprint)) continue;
1383
1400
  seenFp.add(cand.fingerprint);
1401
+ const twin = !cand._carried && kept.find((k) => !k._carried && sameRootCause(k, cand));
1402
+ if (twin) {
1403
+ // Two hunters named one root cause from different classes. Check it once
1404
+ // and point the twin's areas at the surviving fingerprint.
1405
+ twin.coverage_ids = [...new Set([...(twin.coverage_ids || []), ...(cand.coverage_ids || [])])];
1406
+ for (const u of ledger) {
1407
+ if (Array.isArray(u.result_fingerprints) && u.result_fingerprints.includes(cand.fingerprint)) {
1408
+ u.result_fingerprints = [...new Set(u.result_fingerprints.map((fp) => (fp === cand.fingerprint ? twin.fingerprint : fp)))];
1409
+ }
1410
+ }
1411
+ continue;
1412
+ }
1413
+ kept.push(cand);
1384
1414
  if (!cand.attack_class || !CLASS_BY_ID.has(cand.attack_class)) cand.attack_class = cand._unit_class || 'command-injection';
1385
1415
  const errs = citationErrors(root, cand);
1386
1416
  if (errs.length) {
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 = {