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/commands/task.js CHANGED
@@ -47,6 +47,7 @@ const {
47
47
  } = require('../lib/task-explanation');
48
48
  const { extractReceiptEvidence, RECEIPT_PATH_PATTERN } = require('../lib/receipt-evidence');
49
49
  const escapeRegExp = require('../lib/escape-regexp');
50
+ const { writeFileAtomic } = require('../lib/json-file');
50
51
  const reviewIntegrity = require('../lib/review-integrity');
51
52
  const { gateForHuman, isRetiredFillerReason, landingWhyClause, numberWord, plainLandingReason } = require('../lib/voice-gate');
52
53
  const {
@@ -184,14 +185,15 @@ function writeDefaultProjection(taskDb, db, options = {}) {
184
185
  }));
185
186
  const outPath = path.resolve(root || process.cwd(), '.atris', 'state', 'tasks.projection.json');
186
187
  const output = JSON.stringify(projection, null, 2) + '\n';
187
- fs.mkdirSync(path.dirname(outPath), { recursive: true });
188
188
  let shouldWrite = true;
189
189
  try {
190
190
  shouldWrite = fs.readFileSync(outPath, 'utf8') !== output;
191
191
  } catch {
192
192
  shouldWrite = true;
193
193
  }
194
- if (shouldWrite) fs.writeFileSync(outPath, output, 'utf8');
194
+ // Temp file and rename: a reader never sees half a projection. This is the one writer of the
195
+ // projection's shape; lib/mission-core.js writes through it too.
196
+ if (shouldWrite) writeFileAtomic(outPath, output);
195
197
  return { projection, outPath };
196
198
  }
197
199
 
@@ -4787,16 +4789,13 @@ function cmdReviews(args) {
4787
4789
  }
4788
4790
 
4789
4791
  function stampAcceptGroupMetadata(taskDb, db, taskId, { actor, group, verified, sampleVerifiedIds }) {
4790
- const row = taskDb.getTask(db, taskId);
4791
- if (!row) return;
4792
- const metadata = row.metadata && typeof row.metadata === 'object' ? { ...row.metadata } : {};
4793
- metadata.accept_group = group;
4794
- metadata.accept_group_by = actor;
4795
- metadata.accept_group_at = new Date().toISOString();
4796
- metadata.accepted_by_sampling = !verified;
4797
- if (!verified) metadata.sample_verified_ids = sampleVerifiedIds;
4798
- db.prepare('UPDATE tasks SET metadata = ?, updated_at = ? WHERE id = ?')
4799
- .run(JSON.stringify(metadata), Date.now(), taskId);
4792
+ taskDb.updateTaskMetadata(db, taskId, (metadata) => {
4793
+ metadata.accept_group = group;
4794
+ metadata.accept_group_by = actor;
4795
+ metadata.accept_group_at = new Date().toISOString();
4796
+ metadata.accepted_by_sampling = !verified;
4797
+ if (!verified) metadata.sample_verified_ids = sampleVerifiedIds;
4798
+ });
4800
4799
  }
4801
4800
 
4802
4801
  // Accept a whole certified cluster honestly: the human spot-checks K rows (real career XP), the rest
@@ -4902,16 +4901,7 @@ function cmdAcceptGroup(args) {
4902
4901
  accepted.push({ id: task.id, ok: false, reason: MISSION_XP_END_TO_END_REASON, detail: missionXpIssue });
4903
4902
  continue;
4904
4903
  }
4905
- const done = taskDb.doneTask(db, {
4906
- id: task.id,
4907
- status: 'done',
4908
- actor,
4909
- allowReview: true,
4910
- action: 'accepted',
4911
- proof,
4912
- });
4913
- if (!done.updated) { accepted.push({ id: task.id, ok: false, reason: 'not_review' }); continue; }
4914
- const reviewed = taskDb.reviewTask(db, {
4904
+ const done = taskDb.acceptTask(db, {
4915
4905
  id: task.id,
4916
4906
  actor,
4917
4907
  reward: 1,
@@ -4920,6 +4910,8 @@ function cmdAcceptGroup(args) {
4920
4910
  proof,
4921
4911
  careerXpEligible: isVerified, // honest: career XP ONLY for rows the human actually verified
4922
4912
  });
4913
+ if (!done.updated) { accepted.push({ id: task.id, ok: false, reason: 'not_review' }); continue; }
4914
+ const reviewed = done.reviewed;
4923
4915
  refreshCareerXpAfterReview(reviewed);
4924
4916
  stampAcceptGroupMetadata(taskDb, db, task.id, { actor, group: groupLabel, verified: isVerified, sampleVerifiedIds: [...verifiedIds] });
4925
4917
  accepted.push({ id: task.id, ok: true, verified: isVerified, career_xp: isVerified });
@@ -9390,19 +9382,8 @@ async function cmdAccept(args) {
9390
9382
  console.error('atris task accept: reward must be a positive number');
9391
9383
  process.exit(2);
9392
9384
  }
9393
- const done = taskDb.doneTask(db, {
9394
- id: taskId,
9395
- status: 'done',
9396
- actor,
9397
- allowReview: true,
9398
- action: 'accepted',
9399
- proof,
9400
- });
9401
- if (!done.updated) {
9402
- console.error(`accept failed: ${taskId} not open|claimed|review`);
9403
- process.exit(1);
9404
- }
9405
- const reviewed = taskDb.reviewTask(db, {
9385
+ // One transaction: done and accepted land together or not at all (lib/task-db.js acceptTask).
9386
+ const done = taskDb.acceptTask(db, {
9406
9387
  id: taskId,
9407
9388
  actor,
9408
9389
  reward: parsedReward.value,
@@ -9412,6 +9393,11 @@ async function cmdAccept(args) {
9412
9393
  careerXpEligible: true,
9413
9394
  clearedFields,
9414
9395
  });
9396
+ if (!done.updated) {
9397
+ console.error(`accept failed: ${taskId} not open|claimed|review`);
9398
+ process.exit(1);
9399
+ }
9400
+ const reviewed = done.reviewed;
9415
9401
  const xpProjection = refreshCareerXpAfterReview(reviewed);
9416
9402
  const { projection, outPath } = writeDefaultProjection(taskDb, db);
9417
9403
  const workspaceRoot = projection.workspace_root || process.cwd();
@@ -9485,17 +9471,13 @@ function stampAutoAcceptMetadata(taskDb, db, task, actor, policy) {
9485
9471
  } catch (err) {
9486
9472
  landing = { status: 'unverified', reason: 'landing_proof_failed', detail: String((err && err.message) || err).slice(0, 200) };
9487
9473
  }
9488
- const metadata = row.metadata && typeof row.metadata === 'object' ? { ...row.metadata } : {};
9489
- metadata.auto_accepted_at = new Date().toISOString();
9490
- metadata.auto_accepted_by = actor;
9491
- metadata.auto_accept_policy = policy;
9492
- metadata.landing = { ...landing, checked_at: metadata.auto_accepted_at };
9493
- db.prepare(`
9494
- UPDATE tasks
9495
- SET metadata = ?,
9496
- updated_at = ?
9497
- WHERE id = ?
9498
- `).run(JSON.stringify(metadata), Date.now(), taskId);
9474
+ // The landing proof runs git, so it is worked out first; only the write holds the lock.
9475
+ taskDb.updateTaskMetadata(db, taskId, (metadata) => {
9476
+ metadata.auto_accepted_at = new Date().toISOString();
9477
+ metadata.auto_accepted_by = actor;
9478
+ metadata.auto_accept_policy = policy;
9479
+ metadata.landing = { ...landing, checked_at: metadata.auto_accepted_at };
9480
+ });
9499
9481
  return landing;
9500
9482
  }
9501
9483
 
@@ -9512,19 +9494,8 @@ function acceptReviewTask(taskDb, db, taskId, { actor, proof, reward, lesson = '
9512
9494
  if (missionXpIssue) {
9513
9495
  return { ok: false, reason: MISSION_XP_END_TO_END_REASON, detail: missionXpIssue };
9514
9496
  }
9515
- const done = taskDb.doneTask(db, {
9516
- id: taskId,
9517
- status: 'done',
9518
- actor,
9519
- allowReview: true,
9520
- action: 'accepted',
9521
- proof,
9522
- autoAccepted,
9523
- });
9524
- if (!done.updated) {
9525
- return { ok: false, reason: 'not_open_claimed_or_review' };
9526
- }
9527
- const reviewed = taskDb.reviewTask(db, {
9497
+ // One transaction: done and accepted land together or not at all (lib/task-db.js acceptTask).
9498
+ const accepted = taskDb.acceptTask(db, {
9528
9499
  id: taskId,
9529
9500
  actor,
9530
9501
  reward,
@@ -9534,6 +9505,10 @@ function acceptReviewTask(taskDb, db, taskId, { actor, proof, reward, lesson = '
9534
9505
  careerXpEligible: true,
9535
9506
  autoAccepted,
9536
9507
  });
9508
+ if (!accepted.updated) {
9509
+ return { ok: false, reason: 'not_open_claimed_or_review' };
9510
+ }
9511
+ const reviewed = accepted.reviewed;
9537
9512
  const acceptedRow = taskDb.getTask(db, taskId);
9538
9513
  refreshExistingTodoMarkdown(taskDb, db, acceptedRow && acceptedRow.workspace_root);
9539
9514
  return { ok: true, reviewed };
@@ -9580,19 +9555,12 @@ function verifyIsOnlyFileChecks(verify) {
9580
9555
  }
9581
9556
 
9582
9557
  function stampCertifyVerifyMetadata(taskDb, db, taskId, actor, verify) {
9583
- const row = taskDb.getTask(db, taskId);
9584
- if (!row) return;
9585
- const metadata = row.metadata && typeof row.metadata === 'object' ? { ...row.metadata } : {};
9586
- metadata.verify = metadata.verify || verify;
9587
- metadata.certified_verified_at = new Date().toISOString();
9588
- metadata.certified_verified_by = actor;
9589
- metadata.machine_verified = true;
9590
- db.prepare(`
9591
- UPDATE tasks
9592
- SET metadata = ?,
9593
- updated_at = ?
9594
- WHERE id = ?
9595
- `).run(JSON.stringify(metadata), Date.now(), taskId);
9558
+ taskDb.updateTaskMetadata(db, taskId, (metadata) => {
9559
+ metadata.verify = metadata.verify || verify;
9560
+ metadata.certified_verified_at = new Date().toISOString();
9561
+ metadata.certified_verified_by = actor;
9562
+ metadata.machine_verified = true;
9563
+ });
9596
9564
  }
9597
9565
 
9598
9566
  function currentHeadSha(workspaceRoot) {
@@ -9613,27 +9581,23 @@ function currentHeadSha(workspaceRoot) {
9613
9581
  // certify-verified and the autoland landing re-check, persist the outcome
9614
9582
  // here for the read path to consult.
9615
9583
  function stampVerifyCacheMetadata(taskDb, db, taskId, { verify, result, actor, workspaceRoot }) {
9616
- const row = taskDb.getTask(db, taskId);
9617
- if (!row) return;
9618
- const metadata = row.metadata && typeof row.metadata === 'object' ? { ...row.metadata } : {};
9619
- metadata.verify_cache = {
9584
+ if (!taskDb.getTask(db, taskId)) return;
9585
+ const verifyCache = {
9620
9586
  command: verify,
9621
9587
  ok: result && result.ok === true,
9622
9588
  reason: (result && result.reason) || null,
9623
9589
  ran_at: new Date().toISOString(),
9624
9590
  ran_by: actor,
9625
9591
  // The commit the check ran on: the job's own code when it has some,
9626
- // else the folder the job lives in.
9592
+ // else the folder the job lives in. Read before the write lock is taken.
9627
9593
  code_sha: (result && result.code_sha) || currentHeadSha(workspaceRoot),
9628
9594
  code_source: (result && result.code_source) || 'checkout',
9629
9595
  };
9630
9596
  // A check is not a touch. Bumping updated_at here put every failed job at
9631
9597
  // the front of the next pass, so the same six failing jobs won every hour.
9632
- db.prepare(`
9633
- UPDATE tasks
9634
- SET metadata = ?
9635
- WHERE id = ?
9636
- `).run(JSON.stringify(metadata), taskId);
9598
+ taskDb.updateTaskMetadata(db, taskId, (metadata) => {
9599
+ metadata.verify_cache = verifyCache;
9600
+ }, { touch: false });
9637
9601
  }
9638
9602
 
9639
9603
  // When a review job was last given a second check; never checked reads 0.
@@ -9656,23 +9620,16 @@ function failedOnSameCode(task, verify, codeSha) {
9656
9620
  // metadata.verify is what lets sweep --auto-accept (and certify-verified)
9657
9621
  // re-run it later against the current checkout instead of re-parsing prose.
9658
9622
  function stampReadyVerifyMetadata(taskDb, db, taskId, verify) {
9659
- const row = taskDb.getTask(db, taskId);
9660
- if (!row) return;
9661
- const metadata = row.metadata && typeof row.metadata === 'object' ? { ...row.metadata } : {};
9662
- metadata.verify = verify;
9663
- // A real verify stamped at ready means the creation-time degraded flag is
9664
- // stale: clear it so `task show` stops warning about a missing check. A
9665
- // diff-only verify keeps the flag; it still cannot fail on real breakage.
9666
- if (verify.toLowerCase() !== 'git diff --check') {
9667
- delete metadata.verification_status;
9668
- delete metadata.verification_degraded_reason;
9669
- }
9670
- db.prepare(`
9671
- UPDATE tasks
9672
- SET metadata = ?,
9673
- updated_at = ?
9674
- WHERE id = ?
9675
- `).run(JSON.stringify(metadata), Date.now(), taskId);
9623
+ taskDb.updateTaskMetadata(db, taskId, (metadata) => {
9624
+ metadata.verify = verify;
9625
+ // A real verify stamped at ready means the creation-time degraded flag is
9626
+ // stale: clear it so `task show` stops warning about a missing check. A
9627
+ // diff-only verify keeps the flag; it still cannot fail on real breakage.
9628
+ if (verify.toLowerCase() !== 'git diff --check') {
9629
+ delete metadata.verification_status;
9630
+ delete metadata.verification_degraded_reason;
9631
+ }
9632
+ });
9676
9633
  }
9677
9634
 
9678
9635
  function landingVerifyFailureNote(verify, result) {
@@ -11095,13 +11052,29 @@ function renderedTaskRefSet(taskDb, rows, refRows) {
11095
11052
  return refs;
11096
11053
  }
11097
11054
 
11098
- function markdownRowsForRender(taskDb, existingTodoPath, rows, refRows) {
11055
+ // A bullet the renderer wrote starts with its job's ref in bold brackets: `- **[WSX-1]** title`.
11056
+ const RENDERED_TODO_BULLET_REF = /^\s*- (?:\[[ x]\]\s+)?\*\*\[([^\]]+)\]\*\*/;
11057
+
11058
+ // Rows in TODO.md that the database does not know. In a hand-written TODO they are rendered
11059
+ // from the markdown, as before. In a rendered TODO (it has the "Regenerated" marker) a bullet
11060
+ // the renderer wrote whose job is gone stays gone, but a bullet a person or a command added by
11061
+ // hand (no bracketed ref, like `atris business` writing `- **Onboard:** ...`) is imported into
11062
+ // the database when `db` is given, so the next render keeps it instead of dropping it. Imported
11063
+ // rows are pushed onto `rows` and `refRows`, so the caller renders them with the rest.
11064
+ function markdownRowsForRender(taskDb, existingTodoPath, rows, refRows, { db = null, workspaceRoot: wsOption = null } = {}) {
11099
11065
  if (!existingTodoPath || !fs.existsSync(existingTodoPath)) return [];
11100
11066
  const { parseTodoFile } = require('../lib/todo-fallback');
11101
11067
  const existingTodo = fs.readFileSync(existingTodoPath, 'utf8');
11102
11068
  const generatedTodo = existingTodo.includes('Regenerated from durable Atris task state');
11103
11069
  const parsed = parseTodoFile(existingTodoPath);
11104
- const ws = taskDb.workspaceRoot();
11070
+ const ws = wsOption || taskDb.workspaceRoot();
11071
+ const renderedBulletRefs = new Set();
11072
+ if (generatedTodo) {
11073
+ for (const line of existingTodo.split('\n')) {
11074
+ const match = line.match(RENDERED_TODO_BULLET_REF);
11075
+ if (match) renderedBulletRefs.add(normalizeRenderedTaskRef(match[1]));
11076
+ }
11077
+ }
11105
11078
  const existingRefs = renderedTaskRefSet(taskDb, rows, refRows);
11106
11079
  const existingSourceKeys = new Set(
11107
11080
  (Array.isArray(refRows) ? refRows : [])
@@ -11130,9 +11103,38 @@ function markdownRowsForRender(taskDb, existingTodoPath, rows, refRows) {
11130
11103
  if (
11131
11104
  (renderedRef && existingRefs.has(renderedRef)) ||
11132
11105
  (sk && existingSourceKeys.has(sk)) ||
11133
- existingTitles.has(normalizedTitle) ||
11134
- generatedTodo
11106
+ existingTitles.has(normalizedTitle)
11135
11107
  ) continue;
11108
+ if (generatedTodo) {
11109
+ // Written by the renderer and no longer in the database: leave it out.
11110
+ // Only open and in-progress bullets are imported: a hand-written line under Review or
11111
+ // Completed has no history to stand on.
11112
+ if (!db || (renderedRef && renderedBulletRefs.has(renderedRef)) || (status !== 'open' && status !== 'claimed')) continue;
11113
+ const claimer = status === 'claimed' ? (task.claimed || null) : null;
11114
+ const added = taskDb.addTask(db, {
11115
+ title: task.title,
11116
+ tag: task.tag || null,
11117
+ workspaceRoot: ws,
11118
+ sourceKey: sk,
11119
+ status: status === 'claimed' && !claimer ? 'open' : status,
11120
+ claimedBy: claimer,
11121
+ metadata: {
11122
+ source: 'todo_markdown_import',
11123
+ todo_id: task.id || null,
11124
+ todo_tags: task.tags || [],
11125
+ ...(task.verify ? { verify: task.verify } : {}),
11126
+ },
11127
+ });
11128
+ const imported = added && added.id ? taskDb.getTask(db, added.id) : null;
11129
+ if (imported) {
11130
+ if (Array.isArray(rows)) rows.push(imported);
11131
+ if (Array.isArray(refRows) && refRows !== rows) refRows.push(imported);
11132
+ existingRefs.add(normalizeRenderedTaskRef(imported.id));
11133
+ }
11134
+ if (sk) existingSourceKeys.add(sk);
11135
+ existingTitles.add(normalizedTitle);
11136
+ continue;
11137
+ }
11136
11138
  out.push({
11137
11139
  id: `markdown:${status}:${task.id || index}:${sk ? sk.slice(0, 10) : index}`,
11138
11140
  title: task.title,
@@ -11204,12 +11206,12 @@ function refreshExistingTodoMarkdown(taskDb, db, workspaceRoot) {
11204
11206
  const preservedSections = [];
11205
11207
  const endgameSection = extractTodoSectionMarkdown(existingTodo, 'Endgame');
11206
11208
  if (endgameSection) preservedSections.push(endgameSection);
11207
- const markdownRows = markdownRowsForRender(taskDb, outPath, rows, refRows);
11209
+ const markdownRows = markdownRowsForRender(taskDb, outPath, rows, refRows, { db, workspaceRoot: ws });
11208
11210
  const markdown = taskDb.renderTodoMarkdown([...rows, ...markdownRows], {
11209
11211
  refRows,
11210
11212
  preservedSections,
11211
11213
  });
11212
- fs.writeFileSync(outPath, markdown, 'utf8');
11214
+ writeFileAtomic(outPath, markdown);
11213
11215
  return outPath;
11214
11216
  }
11215
11217
 
@@ -11225,13 +11227,43 @@ function autoRenderTodoFromDb(cwd = process.cwd()) {
11225
11227
  if (fs.existsSync(outPath)) return refreshExistingTodoMarkdown(taskDb, db, cwd);
11226
11228
  const rows = taskDb.listTasks(db, { workspaceRoot: cwd, limit: 500 });
11227
11229
  const refRows = taskDb.listTasks(db, { workspaceRoot: cwd });
11228
- fs.writeFileSync(outPath, taskDb.renderTodoMarkdown(rows, { refRows }), 'utf8');
11230
+ writeFileAtomic(outPath, taskDb.renderTodoMarkdown(rows, { refRows }));
11229
11231
  return outPath;
11230
11232
  } catch {
11231
11233
  return null;
11232
11234
  }
11233
11235
  }
11234
11236
 
11237
+ // The task behind a TODO bullet, finished the way `atris task done` finishes one: doneTask, then the
11238
+ // projection and the rendered TODO. A rendered TODO.md is a view of the database, so marking its bullet
11239
+ // means nothing until the job itself is done. `matches(title)` says whether a job's title is the one the
11240
+ // caller named; only an open or claimed job in this workspace counts, an exact title wins over a partial
11241
+ // one, and two partial matches are a refusal, not a guess. Returns { completed, id, reason }.
11242
+ function completeTodoTaskByTitle(cwd, title, { matches, actor, proof } = {}) {
11243
+ const taskDb = getTaskDb();
11244
+ const db = taskDb.open();
11245
+ const ws = taskDb.workspaceRoot(cwd);
11246
+ const wanted = taskDb.normalizeTitle(title);
11247
+ const candidates = taskDb.listTasks(db, { workspaceRoot: ws, limit: null })
11248
+ .filter((row) => row.status === 'open' || row.status === 'claimed');
11249
+ const exact = candidates.filter((row) => wanted && taskDb.normalizeTitle(row.title) === wanted);
11250
+ const partial = typeof matches === 'function' ? candidates.filter((row) => matches(row.title)) : [];
11251
+ const picked = exact.length === 1 ? exact : (exact.length ? [] : partial);
11252
+ if (picked.length !== 1) return { completed: false, id: null, reason: picked.length ? 'ambiguous' : 'no_match' };
11253
+ const id = picked[0].id;
11254
+ const done = taskDb.doneTask(db, {
11255
+ id,
11256
+ status: 'done',
11257
+ actor: actor || process.env.ATRIS_AGENT_ID || DEFAULT_OWNER,
11258
+ action: 'done',
11259
+ proof,
11260
+ });
11261
+ if (!done.updated) return { completed: false, id, reason: 'not_open_or_claimed' };
11262
+ try { writeDefaultProjection(taskDb, db, { root: cwd }); } catch { /* the view is rebuilt on the next write */ }
11263
+ autoRenderTodoFromDb(cwd);
11264
+ return { completed: true, id, reason: null };
11265
+ }
11266
+
11235
11267
  function cmdRender(args) {
11236
11268
  const out = flag(args, '--out') || path.join('atris', 'TODO.md');
11237
11269
  const all = hasFlag(args, '--all');
@@ -11255,12 +11287,12 @@ function cmdRender(args) {
11255
11287
  const preservedSections = [];
11256
11288
  const endgameSection = extractTodoSectionMarkdown(existingTodo, 'Endgame');
11257
11289
  if (endgameSection) preservedSections.push(endgameSection);
11258
- const markdownRows = markdownRowsForRender(taskDb, outPath, rows, refRows);
11290
+ const markdownRows = markdownRowsForRender(taskDb, outPath, rows, refRows, workspaceRoot ? { db, workspaceRoot } : {});
11259
11291
  const rowsToRender = [...rows, ...markdownRows];
11260
11292
  const statusCounts = taskRenderStatusCounts(rowsToRender);
11261
11293
  const markdown = taskDb.renderTodoMarkdown(rowsToRender, { doneLimit, failedLimit, refRows, preservedSections });
11262
11294
  fs.mkdirSync(path.dirname(outPath), { recursive: true });
11263
- fs.writeFileSync(outPath, markdown, 'utf8');
11295
+ writeFileAtomic(outPath, markdown);
11264
11296
  if (wantsJson(args)) {
11265
11297
  printJson({
11266
11298
  ok: true,
@@ -11839,16 +11871,7 @@ function postTaskApiAccept({ res, taskDb, db, taskId, body }) {
11839
11871
  if (hasExplicitNext && !nextTask.trim()) clearedFields.push('next_task');
11840
11872
  const parsedReward = parseAcceptReward(body.reward);
11841
11873
  if (!parsedReward.ok) return sendJson(res, 400, { ok: false, reason: 'invalid_reward', detail: 'reward must be a positive number' });
11842
- const done = taskDb.doneTask(db, {
11843
- id: taskId,
11844
- status: 'done',
11845
- actor: String(body.actor || DEFAULT_OWNER),
11846
- allowReview: true,
11847
- action: 'accepted',
11848
- proof,
11849
- });
11850
- if (!done.updated) return sendJson(res, 409, { ok: false, reason: 'not_open_claimed_or_review' });
11851
- const reviewed = taskDb.reviewTask(db, {
11874
+ const done = taskDb.acceptTask(db, {
11852
11875
  id: taskId,
11853
11876
  actor: String(body.actor || DEFAULT_OWNER),
11854
11877
  reward: parsedReward.value,
@@ -11858,6 +11881,8 @@ function postTaskApiAccept({ res, taskDb, db, taskId, body }) {
11858
11881
  careerXpEligible: true,
11859
11882
  clearedFields,
11860
11883
  });
11884
+ if (!done.updated) return sendJson(res, 409, { ok: false, reason: 'not_open_claimed_or_review' });
11885
+ const reviewed = done.reviewed;
11861
11886
  const nextCreated = body.createNext ? createNextTaskIfRequested(taskDb, db, ['--create-next'], currentTask, reviewed.episode.next_task_suggestion) : null;
11862
11887
  const xpProjection = refreshCareerXpAfterReview(reviewed);
11863
11888
  const { projection, outPath } = writeDefaultProjection(taskDb, db);
@@ -12260,6 +12285,7 @@ module.exports = {
12260
12285
  delegateTask,
12261
12286
  AGENT_ENV_MARKERS,
12262
12287
  autoRenderTodoFromDb,
12288
+ completeTodoTaskByTitle,
12263
12289
  cmdCertifyVerified,
12264
12290
  refreshKeptTaskList,
12265
12291
  keptCount,
@@ -2,7 +2,7 @@
2
2
 
3
3
  const fs = require('fs');
4
4
  const path = require('path');
5
- const { mapNotesPath } = require('../lib/map-refs');
5
+ const { mapNotesPath, mapNotesExcerpt } = require('../lib/map-refs');
6
6
  const { spawnSync } = require('child_process');
7
7
  const taskDb = require('../lib/task-db');
8
8
  const {
@@ -166,10 +166,14 @@ function formatTaskEvents(rows) {
166
166
  }).join('\n');
167
167
  }
168
168
 
169
- function gatherTerritoryContext(root, db) {
169
+ function gatherTerritoryContext(root, db, problem = '') {
170
170
  const map = readFileBestEffort(path.join(root, 'atris', 'MAP.md'), { lines: 100 });
171
171
  const notesPath = mapNotesPath(root);
172
- const notes = notesPath ? readFileBestEffort(path.join(root, notesPath), { lines: 100 }) : '';
172
+ // Follow the notes index to the area notes that match the problem; with no
173
+ // match, fall back to the top of the notes file.
174
+ const matched = notesPath ? mapNotesExcerpt(root, problem, READ_LIMIT) : '';
175
+ const notes = matched || (notesPath ? readFileBestEffort(path.join(root, notesPath), { lines: 100 }) : '');
176
+ const notesTitle = matched ? 'map notes lines that match the problem' : `${notesPath} first 100 lines`;
173
177
  const lessonsMd = readFileBestEffort(path.join(root, 'atris', 'lessons.md'), { maxChars: READ_LIMIT });
174
178
  const lessonsJson = readFileBestEffort(path.join(root, 'atris', 'lessons.json'), { maxChars: READ_LIMIT });
175
179
  const journals = readRecentJournals(root);
@@ -179,7 +183,7 @@ function gatherTerritoryContext(root, db) {
179
183
  return [
180
184
  section('workspace_root', root),
181
185
  section('atris/MAP.md first 100 lines', map),
182
- ...(notesPath ? [section(`${notesPath} first 100 lines`, notes)] : []),
186
+ ...(notesPath ? [section(notesTitle, notes)] : []),
183
187
  section('atris/lessons.md', lessonsMd),
184
188
  section('atris/lessons.json', lessonsJson),
185
189
  section('last 3 daily journals', journals),
@@ -617,7 +621,7 @@ async function unknownsCommand(args = []) {
617
621
  return 1;
618
622
  }
619
623
 
620
- const territoryContext = gatherTerritoryContext(root, db);
624
+ const territoryContext = gatherTerritoryContext(root, db, problem);
621
625
  const analysis = analyzeWithModel(root, problem, territoryContext);
622
626
  const unknowns = normalizeUnknowns(analysis);
623
627
  const questions = normalizeQuestions(analysis, problem);
@@ -1,6 +1,6 @@
1
1
  const fs = require('fs');
2
2
  const path = require('path');
3
- const { mapNotesPath } = require('../lib/map-refs');
3
+ const { mapNotesExcerpt } = require('../lib/map-refs');
4
4
  const { spawnSync } = require('child_process');
5
5
  const { getLogPath } = require('../lib/journal');
6
6
  const { ensureValidCredentials } = require('../utils/auth');
@@ -195,17 +195,6 @@ function findRelevantContextFiles(cwd, prompt) {
195
195
  .slice(0, 4);
196
196
  }
197
197
 
198
- // The deep map is large: keep only the lines that share a word with the prompt.
199
- function mapNotesExcerpt(cwd, prompt, maxChars) {
200
- const notesPath = mapNotesPath(cwd);
201
- if (!notesPath) return '';
202
- const words = promptWords(prompt);
203
- if (!words.size) return '';
204
- const lines = fs.readFileSync(path.join(cwd, notesPath), 'utf8').split(/\r?\n/)
205
- .filter(line => [...words].some(word => line.toLowerCase().includes(word)));
206
- return lines.join('\n').slice(0, maxChars);
207
- }
208
-
209
198
  function collectWorkspaceContext(prompt, cwd = process.cwd()) {
210
199
  const business = readBusinessMeta(cwd);
211
200
  const chunks = [];
@@ -19,6 +19,7 @@ const {
19
19
  const { startFirstTalk } = require('../lib/context-gatherer');
20
20
  const { isNonInteractive } = require('../lib/noninteractive');
21
21
  const { loadContext } = require('../lib/state-detection');
22
+ const { resolveClaudeRunnerModel } = require('../lib/runner-command');
22
23
  const { buildToolResultBody } = require('../lib/tool-result-encode');
23
24
  const { commitReviewLearning } = require('./learn');
24
25
 
@@ -502,7 +503,7 @@ function mapPromptSection(targetDir, cwd = process.cwd()) {
502
503
  if (!notes) return `## MAP.md\nRead this file for file:line references: ${mapRef}\n\n`;
503
504
  const notesRef = path.relative(cwd, path.join(path.dirname(targetDir), notes));
504
505
  return `## MAP.md\nRead this file to find where work lives: ${mapRef}\n\n`
505
- + `## MAP-NOTES.md\nRead this file for exact file:line references: ${notesRef}\n\n`;
506
+ + `## MAP-NOTES.md\nRead this file for exact file:line references, or the area note it points to: ${notesRef}\n\n`;
506
507
  }
507
508
 
508
509
  async function planAtris(userInput = null) {
@@ -1741,7 +1742,7 @@ async function reviewAtris() {
1741
1742
  // Handoff prompt: suggest writing handoff if completions exist today
1742
1743
  if (fs.existsSync(logFile)) {
1743
1744
  const journalContent = fs.readFileSync(logFile, 'utf8');
1744
- const hasCompletions = /## Completed ✅[\s\S]*?- \*\*C\d+:/.test(journalContent);
1745
+ const hasCompletions = /## Completed(?: ✅)?[^\S\n]*\n[\s\S]*?- \*\*C\d+:/.test(journalContent);
1745
1746
  const hasHandoff = /## Handoff[\s\S]*?\*\*Context:\*\*/.test(journalContent);
1746
1747
 
1747
1748
  if (hasCompletions && !hasHandoff) {
@@ -1834,7 +1835,7 @@ async function executeAgentSDKFast(userInput) {
1834
1835
  const postData = JSON.stringify({
1835
1836
  message: userInput,
1836
1837
  workspace_path: process.cwd(),
1837
- model: 'claude-sonnet-4-6'
1838
+ model: resolveClaudeRunnerModel()
1838
1839
  });
1839
1840
 
1840
1841
  const options = {