atris 3.80.1 → 3.82.2

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 (70) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +11 -1
  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 +112 -14
  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 +150 -137
  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 +107 -1610
  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/first-minute.js +37 -4
  46. package/lib/fleet.js +1 -1
  47. package/lib/integrations-core.js +1695 -0
  48. package/lib/intents.js +7 -0
  49. package/lib/json-file.js +22 -1
  50. package/lib/loop-doctor.js +36 -2
  51. package/lib/map-refs.js +68 -8
  52. package/lib/member-context.js +20 -1
  53. package/lib/member-engine.js +1 -1
  54. package/lib/member-map-fold.js +49 -11
  55. package/lib/member-map-inbox.js +2 -1
  56. package/lib/mission-core.js +9 -2
  57. package/lib/next-work-context.js +190 -0
  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/task-db.js +107 -38
  62. package/lib/taste-notes.js +35 -0
  63. package/lib/team-lineup.js +3 -3
  64. package/lib/todo.js +2 -0
  65. package/lib/wiki.js +11 -3
  66. package/lib/worktree-core.js +2032 -0
  67. package/lib/xp-core.js +2172 -0
  68. package/lib/youtube-core.js +3178 -0
  69. package/lib/zen-apply.js +3 -1
  70. package/package.json +2 -2
package/commands/task.js CHANGED
@@ -10,6 +10,7 @@ const { execSync } = require('node:child_process');
10
10
  const path = require('path');
11
11
  const os = require('os');
12
12
  const { hasFlag } = require('../lib/arg-parser');
13
+ const { buildNextWorkContext, reconcileTaskRows } = require('../lib/next-work-context');
13
14
  const { argsWantHelp } = require('../lib/noninteractive');
14
15
  const {
15
16
  compactErrorPayload,
@@ -47,6 +48,7 @@ const {
47
48
  } = require('../lib/task-explanation');
48
49
  const { extractReceiptEvidence, RECEIPT_PATH_PATTERN } = require('../lib/receipt-evidence');
49
50
  const escapeRegExp = require('../lib/escape-regexp');
51
+ const { writeFileAtomic } = require('../lib/json-file');
50
52
  const reviewIntegrity = require('../lib/review-integrity');
51
53
  const { gateForHuman, isRetiredFillerReason, landingWhyClause, numberWord, plainLandingReason } = require('../lib/voice-gate');
52
54
  const {
@@ -73,6 +75,7 @@ const {
73
75
  } = require('../lib/task-decision');
74
76
  const {
75
77
  buildFirstMinute,
78
+ loadLocalTaskState,
76
79
  deskNextCommand,
77
80
  firstTalkCommand,
78
81
  folderName,
@@ -184,14 +187,15 @@ function writeDefaultProjection(taskDb, db, options = {}) {
184
187
  }));
185
188
  const outPath = path.resolve(root || process.cwd(), '.atris', 'state', 'tasks.projection.json');
186
189
  const output = JSON.stringify(projection, null, 2) + '\n';
187
- fs.mkdirSync(path.dirname(outPath), { recursive: true });
188
190
  let shouldWrite = true;
189
191
  try {
190
192
  shouldWrite = fs.readFileSync(outPath, 'utf8') !== output;
191
193
  } catch {
192
194
  shouldWrite = true;
193
195
  }
194
- if (shouldWrite) fs.writeFileSync(outPath, output, 'utf8');
196
+ // Temp file and rename: a reader never sees half a projection. This is the one writer of the
197
+ // projection's shape; lib/mission-core.js writes through it too.
198
+ if (shouldWrite) writeFileAtomic(outPath, output);
195
199
  return { projection, outPath };
196
200
  }
197
201
 
@@ -4787,16 +4791,13 @@ function cmdReviews(args) {
4787
4791
  }
4788
4792
 
4789
4793
  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);
4794
+ taskDb.updateTaskMetadata(db, taskId, (metadata) => {
4795
+ metadata.accept_group = group;
4796
+ metadata.accept_group_by = actor;
4797
+ metadata.accept_group_at = new Date().toISOString();
4798
+ metadata.accepted_by_sampling = !verified;
4799
+ if (!verified) metadata.sample_verified_ids = sampleVerifiedIds;
4800
+ });
4800
4801
  }
4801
4802
 
4802
4803
  // Accept a whole certified cluster honestly: the human spot-checks K rows (real career XP), the rest
@@ -4902,16 +4903,7 @@ function cmdAcceptGroup(args) {
4902
4903
  accepted.push({ id: task.id, ok: false, reason: MISSION_XP_END_TO_END_REASON, detail: missionXpIssue });
4903
4904
  continue;
4904
4905
  }
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, {
4906
+ const done = taskDb.acceptTask(db, {
4915
4907
  id: task.id,
4916
4908
  actor,
4917
4909
  reward: 1,
@@ -4920,6 +4912,8 @@ function cmdAcceptGroup(args) {
4920
4912
  proof,
4921
4913
  careerXpEligible: isVerified, // honest: career XP ONLY for rows the human actually verified
4922
4914
  });
4915
+ if (!done.updated) { accepted.push({ id: task.id, ok: false, reason: 'not_review' }); continue; }
4916
+ const reviewed = done.reviewed;
4923
4917
  refreshCareerXpAfterReview(reviewed);
4924
4918
  stampAcceptGroupMetadata(taskDb, db, task.id, { actor, group: groupLabel, verified: isVerified, sampleVerifiedIds: [...verifiedIds] });
4925
4919
  accepted.push({ id: task.id, ok: true, verified: isVerified, career_xp: isVerified });
@@ -5888,27 +5882,11 @@ function cmdNextTruth(args) {
5888
5882
  const taskDb = getTaskDb();
5889
5883
  const db = taskDb.open();
5890
5884
  const workspaceRoot = taskDb.workspaceRoot();
5891
- const rows = taskDb.listTasks(db, { workspaceRoot, limit: 500 });
5892
- const existingProj = readProjectionFile(workspaceRoot);
5893
- const dbHasActionable = rows.some((task) => {
5894
- const status = task && task.status;
5895
- return status === 'open' || status === 'claimed' || status === 'review';
5896
- });
5897
- const projHasActionable = Boolean(
5898
- existingProj
5899
- && Array.isArray(existingProj.tasks)
5900
- && existingProj.tasks.some((task) => {
5901
- const status = task && task.status;
5902
- return status === 'open' || status === 'claimed' || status === 'review';
5903
- }),
5904
- );
5885
+ const snapshot = loadLocalTaskState(workspaceRoot);
5905
5886
  let projection;
5906
5887
  let outPath;
5907
- if (!dbHasActionable && projHasActionable) {
5908
- projection = existingProj;
5909
- outPath = path.resolve(path.join(workspaceRoot || '.', '.atris', 'state', 'tasks.projection.json'));
5910
- } else if (rows.length === 0 && existingProj && Array.isArray(existingProj.tasks) && existingProj.tasks.length > 0) {
5911
- projection = existingProj;
5888
+ if (snapshot.source !== 'database') {
5889
+ projection = { tasks: snapshot.tasks };
5912
5890
  outPath = path.resolve(path.join(workspaceRoot || '.', '.atris', 'state', 'tasks.projection.json'));
5913
5891
  } else {
5914
5892
  const written = writeDefaultProjection(taskDb, db);
@@ -5936,6 +5914,7 @@ function cmdNextTruth(args) {
5936
5914
  scope: normalizeTaskQueueScope(scope),
5937
5915
  projection_path: outPath,
5938
5916
  task: task ? compactTaskFromProjection(projection, task.id) : null,
5917
+ context: buildNextWorkContext({ root: workspaceRoot, tasks: reconcileTaskRows(snapshot.tasks, projection.tasks || []), selected: task, owner: String(owner), taskSource: snapshot.source, generatedAt: snapshot.generated_at }),
5939
5918
  });
5940
5919
  return;
5941
5920
  }
@@ -9390,19 +9369,8 @@ async function cmdAccept(args) {
9390
9369
  console.error('atris task accept: reward must be a positive number');
9391
9370
  process.exit(2);
9392
9371
  }
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, {
9372
+ // One transaction: done and accepted land together or not at all (lib/task-db.js acceptTask).
9373
+ const done = taskDb.acceptTask(db, {
9406
9374
  id: taskId,
9407
9375
  actor,
9408
9376
  reward: parsedReward.value,
@@ -9412,6 +9380,11 @@ async function cmdAccept(args) {
9412
9380
  careerXpEligible: true,
9413
9381
  clearedFields,
9414
9382
  });
9383
+ if (!done.updated) {
9384
+ console.error(`accept failed: ${taskId} not open|claimed|review`);
9385
+ process.exit(1);
9386
+ }
9387
+ const reviewed = done.reviewed;
9415
9388
  const xpProjection = refreshCareerXpAfterReview(reviewed);
9416
9389
  const { projection, outPath } = writeDefaultProjection(taskDb, db);
9417
9390
  const workspaceRoot = projection.workspace_root || process.cwd();
@@ -9485,17 +9458,13 @@ function stampAutoAcceptMetadata(taskDb, db, task, actor, policy) {
9485
9458
  } catch (err) {
9486
9459
  landing = { status: 'unverified', reason: 'landing_proof_failed', detail: String((err && err.message) || err).slice(0, 200) };
9487
9460
  }
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);
9461
+ // The landing proof runs git, so it is worked out first; only the write holds the lock.
9462
+ taskDb.updateTaskMetadata(db, taskId, (metadata) => {
9463
+ metadata.auto_accepted_at = new Date().toISOString();
9464
+ metadata.auto_accepted_by = actor;
9465
+ metadata.auto_accept_policy = policy;
9466
+ metadata.landing = { ...landing, checked_at: metadata.auto_accepted_at };
9467
+ });
9499
9468
  return landing;
9500
9469
  }
9501
9470
 
@@ -9512,19 +9481,8 @@ function acceptReviewTask(taskDb, db, taskId, { actor, proof, reward, lesson = '
9512
9481
  if (missionXpIssue) {
9513
9482
  return { ok: false, reason: MISSION_XP_END_TO_END_REASON, detail: missionXpIssue };
9514
9483
  }
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, {
9484
+ // One transaction: done and accepted land together or not at all (lib/task-db.js acceptTask).
9485
+ const accepted = taskDb.acceptTask(db, {
9528
9486
  id: taskId,
9529
9487
  actor,
9530
9488
  reward,
@@ -9534,6 +9492,10 @@ function acceptReviewTask(taskDb, db, taskId, { actor, proof, reward, lesson = '
9534
9492
  careerXpEligible: true,
9535
9493
  autoAccepted,
9536
9494
  });
9495
+ if (!accepted.updated) {
9496
+ return { ok: false, reason: 'not_open_claimed_or_review' };
9497
+ }
9498
+ const reviewed = accepted.reviewed;
9537
9499
  const acceptedRow = taskDb.getTask(db, taskId);
9538
9500
  refreshExistingTodoMarkdown(taskDb, db, acceptedRow && acceptedRow.workspace_root);
9539
9501
  return { ok: true, reviewed };
@@ -9580,19 +9542,12 @@ function verifyIsOnlyFileChecks(verify) {
9580
9542
  }
9581
9543
 
9582
9544
  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);
9545
+ taskDb.updateTaskMetadata(db, taskId, (metadata) => {
9546
+ metadata.verify = metadata.verify || verify;
9547
+ metadata.certified_verified_at = new Date().toISOString();
9548
+ metadata.certified_verified_by = actor;
9549
+ metadata.machine_verified = true;
9550
+ });
9596
9551
  }
9597
9552
 
9598
9553
  function currentHeadSha(workspaceRoot) {
@@ -9613,27 +9568,23 @@ function currentHeadSha(workspaceRoot) {
9613
9568
  // certify-verified and the autoland landing re-check, persist the outcome
9614
9569
  // here for the read path to consult.
9615
9570
  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 = {
9571
+ if (!taskDb.getTask(db, taskId)) return;
9572
+ const verifyCache = {
9620
9573
  command: verify,
9621
9574
  ok: result && result.ok === true,
9622
9575
  reason: (result && result.reason) || null,
9623
9576
  ran_at: new Date().toISOString(),
9624
9577
  ran_by: actor,
9625
9578
  // The commit the check ran on: the job's own code when it has some,
9626
- // else the folder the job lives in.
9579
+ // else the folder the job lives in. Read before the write lock is taken.
9627
9580
  code_sha: (result && result.code_sha) || currentHeadSha(workspaceRoot),
9628
9581
  code_source: (result && result.code_source) || 'checkout',
9629
9582
  };
9630
9583
  // A check is not a touch. Bumping updated_at here put every failed job at
9631
9584
  // 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);
9585
+ taskDb.updateTaskMetadata(db, taskId, (metadata) => {
9586
+ metadata.verify_cache = verifyCache;
9587
+ }, { touch: false });
9637
9588
  }
9638
9589
 
9639
9590
  // When a review job was last given a second check; never checked reads 0.
@@ -9656,23 +9607,16 @@ function failedOnSameCode(task, verify, codeSha) {
9656
9607
  // metadata.verify is what lets sweep --auto-accept (and certify-verified)
9657
9608
  // re-run it later against the current checkout instead of re-parsing prose.
9658
9609
  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);
9610
+ taskDb.updateTaskMetadata(db, taskId, (metadata) => {
9611
+ metadata.verify = verify;
9612
+ // A real verify stamped at ready means the creation-time degraded flag is
9613
+ // stale: clear it so `task show` stops warning about a missing check. A
9614
+ // diff-only verify keeps the flag; it still cannot fail on real breakage.
9615
+ if (verify.toLowerCase() !== 'git diff --check') {
9616
+ delete metadata.verification_status;
9617
+ delete metadata.verification_degraded_reason;
9618
+ }
9619
+ });
9676
9620
  }
9677
9621
 
9678
9622
  function landingVerifyFailureNote(verify, result) {
@@ -11095,13 +11039,29 @@ function renderedTaskRefSet(taskDb, rows, refRows) {
11095
11039
  return refs;
11096
11040
  }
11097
11041
 
11098
- function markdownRowsForRender(taskDb, existingTodoPath, rows, refRows) {
11042
+ // A bullet the renderer wrote starts with its job's ref in bold brackets: `- **[WSX-1]** title`.
11043
+ const RENDERED_TODO_BULLET_REF = /^\s*- (?:\[[ x]\]\s+)?\*\*\[([^\]]+)\]\*\*/;
11044
+
11045
+ // Rows in TODO.md that the database does not know. In a hand-written TODO they are rendered
11046
+ // from the markdown, as before. In a rendered TODO (it has the "Regenerated" marker) a bullet
11047
+ // the renderer wrote whose job is gone stays gone, but a bullet a person or a command added by
11048
+ // hand (no bracketed ref, like `atris business` writing `- **Onboard:** ...`) is imported into
11049
+ // the database when `db` is given, so the next render keeps it instead of dropping it. Imported
11050
+ // rows are pushed onto `rows` and `refRows`, so the caller renders them with the rest.
11051
+ function markdownRowsForRender(taskDb, existingTodoPath, rows, refRows, { db = null, workspaceRoot: wsOption = null } = {}) {
11099
11052
  if (!existingTodoPath || !fs.existsSync(existingTodoPath)) return [];
11100
11053
  const { parseTodoFile } = require('../lib/todo-fallback');
11101
11054
  const existingTodo = fs.readFileSync(existingTodoPath, 'utf8');
11102
11055
  const generatedTodo = existingTodo.includes('Regenerated from durable Atris task state');
11103
11056
  const parsed = parseTodoFile(existingTodoPath);
11104
- const ws = taskDb.workspaceRoot();
11057
+ const ws = wsOption || taskDb.workspaceRoot();
11058
+ const renderedBulletRefs = new Set();
11059
+ if (generatedTodo) {
11060
+ for (const line of existingTodo.split('\n')) {
11061
+ const match = line.match(RENDERED_TODO_BULLET_REF);
11062
+ if (match) renderedBulletRefs.add(normalizeRenderedTaskRef(match[1]));
11063
+ }
11064
+ }
11105
11065
  const existingRefs = renderedTaskRefSet(taskDb, rows, refRows);
11106
11066
  const existingSourceKeys = new Set(
11107
11067
  (Array.isArray(refRows) ? refRows : [])
@@ -11130,9 +11090,38 @@ function markdownRowsForRender(taskDb, existingTodoPath, rows, refRows) {
11130
11090
  if (
11131
11091
  (renderedRef && existingRefs.has(renderedRef)) ||
11132
11092
  (sk && existingSourceKeys.has(sk)) ||
11133
- existingTitles.has(normalizedTitle) ||
11134
- generatedTodo
11093
+ existingTitles.has(normalizedTitle)
11135
11094
  ) continue;
11095
+ if (generatedTodo) {
11096
+ // Written by the renderer and no longer in the database: leave it out.
11097
+ // Only open and in-progress bullets are imported: a hand-written line under Review or
11098
+ // Completed has no history to stand on.
11099
+ if (!db || (renderedRef && renderedBulletRefs.has(renderedRef)) || (status !== 'open' && status !== 'claimed')) continue;
11100
+ const claimer = status === 'claimed' ? (task.claimed || null) : null;
11101
+ const added = taskDb.addTask(db, {
11102
+ title: task.title,
11103
+ tag: task.tag || null,
11104
+ workspaceRoot: ws,
11105
+ sourceKey: sk,
11106
+ status: status === 'claimed' && !claimer ? 'open' : status,
11107
+ claimedBy: claimer,
11108
+ metadata: {
11109
+ source: 'todo_markdown_import',
11110
+ todo_id: task.id || null,
11111
+ todo_tags: task.tags || [],
11112
+ ...(task.verify ? { verify: task.verify } : {}),
11113
+ },
11114
+ });
11115
+ const imported = added && added.id ? taskDb.getTask(db, added.id) : null;
11116
+ if (imported) {
11117
+ if (Array.isArray(rows)) rows.push(imported);
11118
+ if (Array.isArray(refRows) && refRows !== rows) refRows.push(imported);
11119
+ existingRefs.add(normalizeRenderedTaskRef(imported.id));
11120
+ }
11121
+ if (sk) existingSourceKeys.add(sk);
11122
+ existingTitles.add(normalizedTitle);
11123
+ continue;
11124
+ }
11136
11125
  out.push({
11137
11126
  id: `markdown:${status}:${task.id || index}:${sk ? sk.slice(0, 10) : index}`,
11138
11127
  title: task.title,
@@ -11204,12 +11193,12 @@ function refreshExistingTodoMarkdown(taskDb, db, workspaceRoot) {
11204
11193
  const preservedSections = [];
11205
11194
  const endgameSection = extractTodoSectionMarkdown(existingTodo, 'Endgame');
11206
11195
  if (endgameSection) preservedSections.push(endgameSection);
11207
- const markdownRows = markdownRowsForRender(taskDb, outPath, rows, refRows);
11196
+ const markdownRows = markdownRowsForRender(taskDb, outPath, rows, refRows, { db, workspaceRoot: ws });
11208
11197
  const markdown = taskDb.renderTodoMarkdown([...rows, ...markdownRows], {
11209
11198
  refRows,
11210
11199
  preservedSections,
11211
11200
  });
11212
- fs.writeFileSync(outPath, markdown, 'utf8');
11201
+ writeFileAtomic(outPath, markdown);
11213
11202
  return outPath;
11214
11203
  }
11215
11204
 
@@ -11225,13 +11214,43 @@ function autoRenderTodoFromDb(cwd = process.cwd()) {
11225
11214
  if (fs.existsSync(outPath)) return refreshExistingTodoMarkdown(taskDb, db, cwd);
11226
11215
  const rows = taskDb.listTasks(db, { workspaceRoot: cwd, limit: 500 });
11227
11216
  const refRows = taskDb.listTasks(db, { workspaceRoot: cwd });
11228
- fs.writeFileSync(outPath, taskDb.renderTodoMarkdown(rows, { refRows }), 'utf8');
11217
+ writeFileAtomic(outPath, taskDb.renderTodoMarkdown(rows, { refRows }));
11229
11218
  return outPath;
11230
11219
  } catch {
11231
11220
  return null;
11232
11221
  }
11233
11222
  }
11234
11223
 
11224
+ // The task behind a TODO bullet, finished the way `atris task done` finishes one: doneTask, then the
11225
+ // projection and the rendered TODO. A rendered TODO.md is a view of the database, so marking its bullet
11226
+ // means nothing until the job itself is done. `matches(title)` says whether a job's title is the one the
11227
+ // caller named; only an open or claimed job in this workspace counts, an exact title wins over a partial
11228
+ // one, and two partial matches are a refusal, not a guess. Returns { completed, id, reason }.
11229
+ function completeTodoTaskByTitle(cwd, title, { matches, actor, proof } = {}) {
11230
+ const taskDb = getTaskDb();
11231
+ const db = taskDb.open();
11232
+ const ws = taskDb.workspaceRoot(cwd);
11233
+ const wanted = taskDb.normalizeTitle(title);
11234
+ const candidates = taskDb.listTasks(db, { workspaceRoot: ws, limit: null })
11235
+ .filter((row) => row.status === 'open' || row.status === 'claimed');
11236
+ const exact = candidates.filter((row) => wanted && taskDb.normalizeTitle(row.title) === wanted);
11237
+ const partial = typeof matches === 'function' ? candidates.filter((row) => matches(row.title)) : [];
11238
+ const picked = exact.length === 1 ? exact : (exact.length ? [] : partial);
11239
+ if (picked.length !== 1) return { completed: false, id: null, reason: picked.length ? 'ambiguous' : 'no_match' };
11240
+ const id = picked[0].id;
11241
+ const done = taskDb.doneTask(db, {
11242
+ id,
11243
+ status: 'done',
11244
+ actor: actor || process.env.ATRIS_AGENT_ID || DEFAULT_OWNER,
11245
+ action: 'done',
11246
+ proof,
11247
+ });
11248
+ if (!done.updated) return { completed: false, id, reason: 'not_open_or_claimed' };
11249
+ try { writeDefaultProjection(taskDb, db, { root: cwd }); } catch { /* the view is rebuilt on the next write */ }
11250
+ autoRenderTodoFromDb(cwd);
11251
+ return { completed: true, id, reason: null };
11252
+ }
11253
+
11235
11254
  function cmdRender(args) {
11236
11255
  const out = flag(args, '--out') || path.join('atris', 'TODO.md');
11237
11256
  const all = hasFlag(args, '--all');
@@ -11255,12 +11274,12 @@ function cmdRender(args) {
11255
11274
  const preservedSections = [];
11256
11275
  const endgameSection = extractTodoSectionMarkdown(existingTodo, 'Endgame');
11257
11276
  if (endgameSection) preservedSections.push(endgameSection);
11258
- const markdownRows = markdownRowsForRender(taskDb, outPath, rows, refRows);
11277
+ const markdownRows = markdownRowsForRender(taskDb, outPath, rows, refRows, workspaceRoot ? { db, workspaceRoot } : {});
11259
11278
  const rowsToRender = [...rows, ...markdownRows];
11260
11279
  const statusCounts = taskRenderStatusCounts(rowsToRender);
11261
11280
  const markdown = taskDb.renderTodoMarkdown(rowsToRender, { doneLimit, failedLimit, refRows, preservedSections });
11262
11281
  fs.mkdirSync(path.dirname(outPath), { recursive: true });
11263
- fs.writeFileSync(outPath, markdown, 'utf8');
11282
+ writeFileAtomic(outPath, markdown);
11264
11283
  if (wantsJson(args)) {
11265
11284
  printJson({
11266
11285
  ok: true,
@@ -11839,16 +11858,7 @@ function postTaskApiAccept({ res, taskDb, db, taskId, body }) {
11839
11858
  if (hasExplicitNext && !nextTask.trim()) clearedFields.push('next_task');
11840
11859
  const parsedReward = parseAcceptReward(body.reward);
11841
11860
  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, {
11861
+ const done = taskDb.acceptTask(db, {
11852
11862
  id: taskId,
11853
11863
  actor: String(body.actor || DEFAULT_OWNER),
11854
11864
  reward: parsedReward.value,
@@ -11858,6 +11868,8 @@ function postTaskApiAccept({ res, taskDb, db, taskId, body }) {
11858
11868
  careerXpEligible: true,
11859
11869
  clearedFields,
11860
11870
  });
11871
+ if (!done.updated) return sendJson(res, 409, { ok: false, reason: 'not_open_claimed_or_review' });
11872
+ const reviewed = done.reviewed;
11861
11873
  const nextCreated = body.createNext ? createNextTaskIfRequested(taskDb, db, ['--create-next'], currentTask, reviewed.episode.next_task_suggestion) : null;
11862
11874
  const xpProjection = refreshCareerXpAfterReview(reviewed);
11863
11875
  const { projection, outPath } = writeDefaultProjection(taskDb, db);
@@ -12260,6 +12272,7 @@ module.exports = {
12260
12272
  delegateTask,
12261
12273
  AGENT_ENV_MARKERS,
12262
12274
  autoRenderTodoFromDb,
12275
+ completeTodoTaskByTitle,
12263
12276
  cmdCertifyVerified,
12264
12277
  refreshKeptTaskList,
12265
12278
  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 = {