atris 3.15.11 → 3.15.13

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.
package/AGENTS.md CHANGED
@@ -55,11 +55,22 @@ CHECK → atris review (verify + cleanup)
55
55
 
56
56
  This workspace has a compiled agent brain.
57
57
 
58
+ On session start, activate it first:
59
+ `atris brain activate --root /Users/keshavrao/arena/atris-cli --verify`
60
+
58
61
  Load these first:
62
+ - `atris/now.md`
59
63
  - `atris/brain/STATUS.md`
60
64
  - `atris/brain/self_improvement_ledger.md`
65
+ - `atris/wiki/concepts/sync-language.md`
66
+ - `atris/skills/activation/SKILL.md`
61
67
  - `atris/MAP.md`
62
- - `atris task list`
68
+ - `atris/TODO.md`
69
+
70
+ First-message rule: follow the sync-language contract before writing to the operator.
71
+ Purpose: optimize for decision-speed; lead with the move, then use descriptions only when they help the operator act.
72
+ Shape: `<operator>, today is about <move>` -> `I picked this because <why now>` -> `Ready: <draft/proof/context>` -> `Go deeper: <paths>`.
73
+ Definitions: operator = current person or agent; move = one concrete high-leverage workflow; why now = business reason; ready = prepared action or proof; paths = 2-4 optional deeper views.
63
74
 
64
75
  Re-run after meaningful work:
65
76
  `atris brain compile --root /Users/keshavrao/arena/atris-cli`
@@ -207,6 +207,27 @@ Validated behavior:
207
207
 
208
208
  ## Still Needed
209
209
 
210
+ - Publish `atris@3.15.12`.
211
+ - Deploy the backend workspace snapshot patch from `atrisos-backend`.
212
+ - After deploy, run a Pallet smoke:
213
+
214
+ ```bash
215
+ npm i -g atris@3.15.12
216
+ cd ~/arena/atris-business/pallet
217
+ atris pull pallet --keep-local --only atris/wiki --timeout 120
218
+ atris push pallet --only atris/wiki --dry-run
219
+ ```
220
+
221
+ - DoorDash post-publish smoke:
222
+
223
+ ```bash
224
+ cd ~/arena/atris-business/doordash
225
+ atris wiki verify
226
+ atris push doordash --dry-run
227
+ ```
228
+
229
+ - Packaged CLI smoke already passed locally for `3.15.12`: tarball install exposes `atris pull --no-manifest` and `atris wiki verify`.
230
+
210
231
  This feature is safer and usable, but the "perfect sync" bar still requires more command-path fixtures and model-assisted semantic merge support.
211
232
 
212
233
  Missing validation:
@@ -326,6 +326,7 @@ function imessageDoctor() {
326
326
  chat_db_readable: false,
327
327
  sqlite3_available: false,
328
328
  osascript_available: false,
329
+ messages_automation: false,
329
330
  };
330
331
  const issues = [];
331
332
 
@@ -346,10 +347,17 @@ function imessageDoctor() {
346
347
 
347
348
  checks.osascript_available = spawnSync('osascript', ['-e', 'return "ok"'], { encoding: 'utf8' }).status === 0;
348
349
  if (!checks.osascript_available) issues.push('osascript is not available.');
350
+ if (checks.osascript_available) {
351
+ checks.messages_automation = spawnSync('osascript', ['-e', 'tell application "Messages" to count services'], {
352
+ encoding: 'utf8',
353
+ timeout: 4000,
354
+ }).status === 0;
355
+ }
356
+ if (!checks.messages_automation) issues.push('Messages automation permission is not available yet.');
349
357
 
350
358
  if (!checks.macos) issues.push('Local iMessage requires macOS.');
351
359
 
352
- const connected = checks.macos && checks.chat_db_exists && checks.chat_db_readable && checks.sqlite3_available && checks.osascript_available;
360
+ const connected = checks.macos && checks.chat_db_exists && checks.chat_db_readable && checks.sqlite3_available && checks.osascript_available && checks.messages_automation;
353
361
  return {
354
362
  connected,
355
363
  provider: 'local_imessage',
package/commands/pull.js CHANGED
@@ -76,7 +76,7 @@ async function pullAtris() {
76
76
  let arg = process.argv[3];
77
77
 
78
78
  if (arg === '--help') {
79
- console.log('Usage: atris pull [business] [--into <path>] [--only <prefix>] [--keep-local] [--timeout <seconds>] [--dry-run]');
79
+ console.log('Usage: atris pull [business] [--into <path>] [--only <prefix>] [--keep-local] [--timeout <seconds>] [--dry-run] [--no-manifest]');
80
80
  console.log('');
81
81
  console.log(' Pull is force-overwrite by default. Cloud is the source of truth.');
82
82
  console.log(' Local files that conflict with cloud are replaced by the cloud version.');
@@ -87,6 +87,7 @@ async function pullAtris() {
87
87
  console.log(' atris pull doordash --only atris/wiki/');
88
88
  console.log(' atris pull --keep-local Preserve conflicting local edits as .remote files (legacy)');
89
89
  console.log(' atris pull --dry-run Preview pull changes without writing local files');
90
+ console.log(' atris pull --no-manifest Pull for inspection without changing this machine\'s sync anchor');
90
91
  return;
91
92
  }
92
93
 
@@ -176,6 +177,7 @@ async function pullBusiness(slug) {
176
177
  const force = !process.argv.includes('--keep-local');
177
178
  const failOnConflict = process.argv.includes('--fail-on-conflict');
178
179
  const dryRun = process.argv.includes('--dry-run');
180
+ const noManifest = process.argv.includes('--no-manifest');
179
181
 
180
182
  // Parse --only flag: comma-separated directory prefixes to filter
181
183
  // Supports both --only=team/,context/ and --only team/,context/
@@ -485,6 +487,19 @@ async function pullBusiness(slug) {
485
487
  let files = result.data.files || [];
486
488
  if (files.length === 0) {
487
489
  console.log(' Workspace is empty.');
490
+ const inScopeLocalBeforePull = Object.keys(localFilesBeforePull).filter((p) => {
491
+ if (!onlyPrefixes) return true;
492
+ const rel = p.replace(/^\//, '');
493
+ return onlyPrefixes.some((pref) => rel.startsWith(pref));
494
+ }).length;
495
+ if (!force && inScopeLocalBeforePull > 0) {
496
+ console.error('');
497
+ console.error(' Pull stopped: cloud returned zero files while local has in-scope content.');
498
+ console.error(' This usually means the snapshot endpoint is unhealthy or still warming.');
499
+ console.error(' No local files or sync manifest were changed.');
500
+ await emit('status_unknown', { error_detail: 'empty snapshot with local in-scope content' });
501
+ process.exit(1);
502
+ }
488
503
  // Don't early-return in force mode: we still need to fall through to the
489
504
  // mirror sweep so a genuinely-emptied cloud can clear local files. The
490
505
  // sweep itself has a safety guard that refuses to wipe local content
@@ -551,11 +566,35 @@ async function pullBusiness(slug) {
551
566
  // Compute local file hashes
552
567
  const localFiles = localFilesBeforePull;
553
568
 
569
+ function isPullPathInScope(filePath) {
570
+ if (!onlyPrefixes) return true;
571
+ const rel = filePath.replace(/^\//, '');
572
+ return onlyPrefixes.some((pref) => rel.startsWith(pref));
573
+ }
574
+
575
+ function filterFilesToPullScope(filesMap = {}) {
576
+ if (!onlyPrefixes) return filesMap;
577
+ return Object.fromEntries(
578
+ Object.entries(filesMap).filter(([p]) => isPullPathInScope(p))
579
+ );
580
+ }
581
+
582
+ function filterManifestToPullScope(existingManifest) {
583
+ if (!onlyPrefixes || !existingManifest || !existingManifest.files) return existingManifest;
584
+ return {
585
+ ...existingManifest,
586
+ files: filterFilesToPullScope(existingManifest.files),
587
+ };
588
+ }
589
+
554
590
  // If output dir is empty (fresh clone) or --force, treat as first sync — pull everything
555
- const effectiveManifest = (Object.keys(localFiles).length === 0 || force) ? null : manifest;
591
+ const scopedLocalFiles = filterFilesToPullScope(localFiles);
592
+ const scopedRemoteFiles = filterFilesToPullScope(remoteFiles);
593
+ const scopedManifest = filterManifestToPullScope(manifest);
594
+ const effectiveManifest = (Object.keys(scopedLocalFiles).length === 0 || force) ? null : scopedManifest;
556
595
 
557
596
  // Three-way compare
558
- const diff = threeWayCompare(localFiles, remoteFiles, effectiveManifest);
597
+ const diff = threeWayCompare(scopedLocalFiles, scopedRemoteFiles, effectiveManifest);
559
598
 
560
599
  if (dryRun) {
561
600
  console.log('');
@@ -806,12 +845,16 @@ async function pullBusiness(slug) {
806
845
  }
807
846
  manifestFiles = merged;
808
847
  }
809
- const newManifest = buildManifest(manifestFiles, commitHash, { workspaceRoot: outputDir });
810
- saveManifest(resolvedSlug || slug, newManifest);
811
- writeBaseContents(outputDir, remoteContent);
812
- removeBaseContents(outputDir, diff.deletedRemote);
848
+ if (!noManifest) {
849
+ const newManifest = buildManifest(manifestFiles, commitHash, { workspaceRoot: outputDir });
850
+ saveManifest(resolvedSlug || slug, newManifest);
851
+ writeBaseContents(outputDir, remoteContent);
852
+ removeBaseContents(outputDir, diff.deletedRemote);
853
+ }
813
854
 
814
- // Save business config in the output dir so push/status work without args
855
+ // Save business config in the output dir so push/status work without args.
856
+ // Inspection pulls should not re-bind the global sync anchor, but the pulled
857
+ // folder still benefits from a local business marker for navigation.
815
858
  const atrisDir = path.join(outputDir, '.atris');
816
859
  fs.mkdirSync(atrisDir, { recursive: true });
817
860
  fs.writeFileSync(path.join(atrisDir, 'business.json'), JSON.stringify({
package/commands/push.js CHANGED
@@ -89,6 +89,21 @@ function buildPushChangePlan({
89
89
  return { filesToPush, deletedPaths, unchangedCount };
90
90
  }
91
91
 
92
+ function shouldRetrySyncIndividually(result, filesToPush) {
93
+ if (!result || result.ok) return false;
94
+ if (!Array.isArray(filesToPush) || filesToPush.length <= 1) return false;
95
+ return result.status !== 403 && result.status !== 409;
96
+ }
97
+
98
+ function isMassDeletePlan({ deletedPaths = [], filesToPush = [], unchangedCount = 0 } = {}) {
99
+ const deleteCount = deletedPaths.length;
100
+ if (deleteCount === 0) return false;
101
+ const survivingCount = filesToPush.length + unchangedCount;
102
+ if (deleteCount >= 10 && survivingCount === 0) return true;
103
+ if (deleteCount >= 25) return true;
104
+ return deleteCount >= 10 && deleteCount > survivingCount;
105
+ }
106
+
92
107
  async function pushAtris() {
93
108
  const elapsedMs = startTimer();
94
109
  let slug = process.argv[3];
@@ -107,7 +122,7 @@ async function pushAtris() {
107
122
  }
108
123
 
109
124
  if (!slug || slug === '--help') {
110
- console.log('Usage: atris push [business] [--from <path>] [--only <prefix>] [--force] [--delete]');
125
+ console.log('Usage: atris push [business] [--from <path>] [--only <prefix>] [--force] [--delete] [--delete-all]');
111
126
  console.log('');
112
127
  console.log(' Push requires a fresh pull. If cloud has changed since your last pull,');
113
128
  console.log(' the push will be blocked until you run `atris pull`. Use --force to override.');
@@ -116,13 +131,15 @@ async function pushAtris() {
116
131
  console.log(' atris push pallet Push pallet/ or atris/pallet/');
117
132
  console.log(' atris push pallet --only team/nate Only push files in team/nate/');
118
133
  console.log(' atris push --force Bypass freshness check (force-push, may overwrite cloud changes)');
119
- console.log(' atris push --delete Allow cloud deletes shown by --dry-run');
134
+ console.log(' atris push --delete Allow small cloud deletes shown by --dry-run');
135
+ console.log(' atris push --delete-all Extra confirmation for mass-delete recovery');
120
136
  process.exit(0);
121
137
  }
122
138
 
123
139
  const force = process.argv.includes('--force');
124
140
  const dryRun = process.argv.includes('--dry-run');
125
141
  const allowDelete = process.argv.includes('--delete');
142
+ const allowMassDelete = process.argv.includes('--delete-all');
126
143
  const allowCrossRootManifest = process.argv.includes('--allow-cross-root-manifest');
127
144
 
128
145
  // Parse --only
@@ -343,7 +360,7 @@ async function pushAtris() {
343
360
 
344
361
  if (filesToPush.length === 0 && deletedPaths.length === 0) {
345
362
  console.log('\n Already up to date.\n');
346
- await emit('success', { files_unchanged: filteredLocalCount });
363
+ await emit('success', { files_unchanged: unchangedCount });
347
364
  return;
348
365
  }
349
366
 
@@ -384,6 +401,24 @@ async function pushAtris() {
384
401
  process.exit(1);
385
402
  }
386
403
 
404
+ if (deletedPaths.length > 0 && allowDelete && !allowMassDelete && isMassDeletePlan({ deletedPaths, filesToPush, unchangedCount })) {
405
+ console.log('');
406
+ console.log(` ✗ Refusing mass delete of ${deletedPaths.length} cloud files with --delete alone.`);
407
+ console.log('');
408
+ console.log(' This looks like a missing local folder or wrong source root.');
409
+ console.log(' No cloud files were deleted.');
410
+ console.log('');
411
+ console.log(' First inspect the plan:');
412
+ console.log(` atris push ${resolvedSlug || slug} --dry-run`);
413
+ console.log('');
414
+ console.log(' If this is truly an intentional full cleanup, rerun with both flags:');
415
+ console.log(` atris push ${resolvedSlug || slug} --delete --delete-all`);
416
+ console.log('');
417
+ console.log(' If this is surprising, pull cloud truth first:');
418
+ console.log(` atris pull ${resolvedSlug || slug} --keep-local --timeout 120`);
419
+ process.exit(1);
420
+ }
421
+
387
422
  let pushed = 0;
388
423
  let deleted = 0;
389
424
  let skipped = [];
@@ -398,6 +433,7 @@ async function pushAtris() {
398
433
  let failedToLand = [];
399
434
  const landedPaths = new Set();
400
435
  let result = { ok: true };
436
+ let batchFailureDetail = null;
401
437
 
402
438
  // Server-canonical path format for the /sync endpoint: NO leading slash.
403
439
  // The warm runner's _safe_path rejects `/atris/...` with "Absolute path
@@ -410,6 +446,10 @@ async function pushAtris() {
410
446
  return s.startsWith('/') ? s : `/${s}`;
411
447
  };
412
448
  const wireFiles = (files) => files.map((f) => ({ path: toWirePath(f.path), content: f.content }));
449
+ const syncFiles = (files) => apiRequestJson(
450
+ `/business/${businessId}/workspaces/${workspaceId}/sync`,
451
+ { method: 'POST', token: creds.token, body: { files: wireFiles(files) }, headers: { 'X-Atris-Actor-Source': 'cli' } }
452
+ );
413
453
 
414
454
  // Inspect per-file results from a /sync response. Treat "written" and
415
455
  // "unchanged" as success; everything else (including missing-from-results,
@@ -451,10 +491,7 @@ async function pushAtris() {
451
491
 
452
492
  if (filesToPush.length > 0) {
453
493
  // Push files to server (strip leading slash — server requires workspace-relative paths)
454
- result = await apiRequestJson(
455
- `/business/${businessId}/workspaces/${workspaceId}/sync`,
456
- { method: 'POST', token: creds.token, body: { files: wireFiles(filesToPush) }, headers: { 'X-Atris-Actor-Source': 'cli' } }
457
- );
494
+ result = await syncFiles(filesToPush);
458
495
 
459
496
  if (!result.ok) {
460
497
  if (result.status === 403) {
@@ -469,10 +506,7 @@ async function pushAtris() {
469
506
  skipped = filesToPush.filter(f => !f.path.startsWith('/team/') && !f.path.startsWith('/journal/'));
470
507
 
471
508
  if (allowed.length > 0) {
472
- const retry = await apiRequestJson(
473
- `/business/${businessId}/workspaces/${workspaceId}/sync`,
474
- { method: 'POST', token: creds.token, body: { files: wireFiles(allowed) }, headers: { 'X-Atris-Actor-Source': 'cli' } }
475
- );
509
+ const retry = await syncFiles(allowed);
476
510
  if (retry.ok) {
477
511
  recordSyncResults(allowed, retry);
478
512
  pushed = landedPaths.size;
@@ -490,6 +524,23 @@ async function pushAtris() {
490
524
  console.error('\n Computer is sleeping. Wake it first.');
491
525
  await emit('cold_wake', { error_detail: 'computer sleeping (409)' });
492
526
  process.exit(1);
527
+ } else if (shouldRetrySyncIndividually(result, filesToPush)) {
528
+ batchFailureDetail = `${result.status || 'unknown'}: ${result.errorMessage || result.error || 'batch sync failed'}`;
529
+ console.log('');
530
+ console.log(` Batch push failed (${result.errorMessage || result.error || result.status}). Retrying one file at a time...`);
531
+ for (const f of filesToPush) {
532
+ const single = await syncFiles([f]);
533
+ if (single.ok) {
534
+ recordSyncResults([f], single);
535
+ } else {
536
+ failedToLand.push({
537
+ path: f.path,
538
+ status: single.status || 'error',
539
+ error: single.errorMessage || single.error || '',
540
+ });
541
+ }
542
+ }
543
+ pushed = landedPaths.size;
493
544
  } else {
494
545
  console.error(`\n Push failed: ${result.errorMessage || result.error || result.status}`);
495
546
  await emit('status_unknown', { error_detail: `sync status ${result.status}` });
@@ -631,6 +682,9 @@ async function pushAtris() {
631
682
  } else if (deleteFailed.length > 0) {
632
683
  finalOutcome = 'status_unknown';
633
684
  finalDetail = `${deleteFailed.length} delete(s) failed (statuses: ${[...new Set(deleteFailed.map(f => f.status))].join(',')})`;
685
+ } else if (batchFailureDetail) {
686
+ finalOutcome = 'status_unknown';
687
+ finalDetail = `batch sync failed but individual retry landed all files (${batchFailureDetail})`;
634
688
  } else if (_rateLimitedDeletes > 0) {
635
689
  finalOutcome = 'rate_limited';
636
690
  finalDetail = `${_rateLimitedDeletes} delete(s) hit 429 (recovered)`;
@@ -652,8 +706,10 @@ async function pushAtris() {
652
706
  module.exports = {
653
707
  pushAtris,
654
708
  buildPushChangePlan,
709
+ shouldRetrySyncIndividually,
655
710
  resolvePushSourceDir,
656
711
  canonicalWorkspaceRoot,
657
712
  basenameOfManifestPath,
658
713
  isBusinessWorkspaceRoot,
714
+ isMassDeletePlan,
659
715
  };
package/commands/wiki.js CHANGED
@@ -16,6 +16,7 @@ const {
16
16
  buildLintPrompt,
17
17
  writeWikiStatus,
18
18
  appendWikiLog,
19
+ validateAgentReadableWikiPages,
19
20
  } = require('../lib/wiki');
20
21
 
21
22
  function autoDetectSlug() {
@@ -331,6 +332,32 @@ function wikiLog(mode, slug, limit) {
331
332
  console.log('');
332
333
  }
333
334
 
335
+ function wikiVerify(mode, slug) {
336
+ const wikiMode = mode === 'private' ? 'private' : 'public';
337
+ const wikiDir = findLocalWikiDir(process.cwd(), slug, wikiMode);
338
+ if (!wikiDir) {
339
+ console.error(`No local wiki found at ${getWikiRoot(wikiMode)}.`);
340
+ process.exit(1);
341
+ }
342
+
343
+ const report = validateAgentReadableWikiPages(process.cwd(), wikiMode);
344
+ console.log('');
345
+ console.log(`Agent-readable wiki contract: ${report.ok ? 'pass' : 'fail'}`);
346
+ console.log(` pages: ${report.pageCount}`);
347
+ console.log(` findings: ${report.findingCount}`);
348
+ if (!report.ok) {
349
+ for (const finding of report.findings.slice(0, 20)) {
350
+ console.log(` - ${finding.page}: ${finding.code} - ${finding.message}`);
351
+ }
352
+ if (report.findings.length > 20) {
353
+ console.log(` ... +${report.findings.length - 20} more`);
354
+ }
355
+ console.log('');
356
+ process.exit(1);
357
+ }
358
+ console.log('');
359
+ }
360
+
334
361
  async function wikiCommand(subcommand, ...args) {
335
362
  const { mode, args: cleanArgs } = parseModeArgs(args);
336
363
 
@@ -392,8 +419,18 @@ async function wikiCommand(subcommand, ...args) {
392
419
  await loopAtris(cleanArgs);
393
420
  break;
394
421
  }
422
+ case 'verify':
423
+ case 'contract': {
424
+ const slug = mode === 'cloud' ? (cleanArgs[0] || autoDetectSlug()) : null;
425
+ if (mode === 'cloud') {
426
+ console.error('Cloud wiki verify is local-first. Run after `atris pull --only atris/wiki --no-manifest` or inside a workspace.');
427
+ process.exit(1);
428
+ }
429
+ wikiVerify(mode, slug);
430
+ break;
431
+ }
395
432
  default:
396
- console.log('Usage: atris wiki <ingest|query|lint|search|log|loop> [business] [args]');
433
+ console.log('Usage: atris wiki <ingest|query|lint|search|log|loop|verify> [business] [args]');
397
434
  console.log('');
398
435
  console.log(' ingest <path> Local-first ingest into atris/wiki/');
399
436
  console.log(' query "question" Local-first query against atris/wiki/');
@@ -401,6 +438,7 @@ async function wikiCommand(subcommand, ...args) {
401
438
  console.log(' search [business] <term> Search local atris/wiki/index.md');
402
439
  console.log(' log [business] [N] Show recent atris/wiki/log.md entries');
403
440
  console.log(' loop Run local wiki upkeep analysis and refresh STATUS/log');
441
+ console.log(' verify Check agent-readable source/verification metadata');
404
442
  console.log('');
405
443
  console.log('Flags:');
406
444
  console.log(' --cloud Route ingest/query/lint to the cloud workspace');
@@ -418,4 +456,5 @@ module.exports = {
418
456
  wikiLint,
419
457
  wikiSearch,
420
458
  wikiLog,
459
+ wikiVerify,
421
460
  };
@@ -19,9 +19,25 @@ function parseTodoFile(todoPath) {
19
19
  };
20
20
  }
21
21
 
22
+ function tagsFromText(text) {
23
+ const allTags = [...String(text || '').matchAll(/\[(\w+)\]/g)].map(m => m[1]);
24
+ return {
25
+ allTags,
26
+ tag: allTags.includes('endgame') ? 'endgame' : (allTags[0] || null),
27
+ };
28
+ }
29
+
30
+ function cleanTaskTitle(text) {
31
+ const raw = String(text || '').trim();
32
+ const withoutTags = raw.replace(/\s*\[\w+\]/g, '').trim();
33
+ const bold = withoutTags.match(/^\*\*(.+?)\*\*\s*(?:[—-]\s*)?(.*)$/);
34
+ if (!bold) return withoutTags;
35
+ return [bold[1], bold[2]].filter(Boolean).join(' — ').trim();
36
+ }
37
+
22
38
  function parseSection(content, sectionName) {
23
39
  const escaped = sectionName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
24
- const match = content.match(new RegExp(`##\\s+${escaped}\\n([\\s\\S]*?)(?=\\n##|$)`, 'i'));
40
+ const match = content.match(new RegExp(`(?:^|\\n)##\\s+${escaped}\\s*\\n([\\s\\S]*?)(?=\\n##(?!#)\\s+|$)`, 'i'));
25
41
  if (!match) return [];
26
42
 
27
43
  const body = (match[1] || '').trim();
@@ -34,14 +50,13 @@ function parseSection(content, sectionName) {
34
50
  for (const rawLine of lines) {
35
51
  const line = rawLine.trimEnd();
36
52
 
37
- const taskMatch = line.match(/^- \*\*([A-Za-z][A-Za-z0-9#]*\d[a-z]?):\*\*\s*(.+)$/);
53
+ const taskMatch = line.match(/^- \*\*([^*:\n]+):\*\*\s*(.+)$/);
38
54
  if (taskMatch) {
39
55
  if (current) tasks.push(current);
40
- const allTags = [...taskMatch[2].matchAll(/\[(\w+)\]/g)].map(m => m[1]);
41
- const tag = allTags.includes('endgame') ? 'endgame' : (allTags[0] || null);
56
+ const { allTags, tag } = tagsFromText(taskMatch[2]);
42
57
  current = {
43
58
  id: taskMatch[1],
44
- title: taskMatch[2].replace(/\s*\[\w+\]/g, '').trim(),
59
+ title: cleanTaskTitle(taskMatch[2]),
45
60
  tag,
46
61
  tags: allTags,
47
62
  claimed: null,
@@ -52,14 +67,24 @@ function parseSection(content, sectionName) {
52
67
  }
53
68
 
54
69
  const checkMatch = line.match(/^- \[[ x]\]\s+(.+)$/);
55
- if (checkMatch && !current) {
56
- tasks.push({ id: null, title: checkMatch[1].trim(), tag: null, claimed: null, stage: null, verify: null });
70
+ if (checkMatch) {
71
+ if (current) {
72
+ tasks.push(current);
73
+ current = null;
74
+ }
75
+ const { allTags, tag } = tagsFromText(checkMatch[1]);
76
+ tasks.push({ id: null, title: cleanTaskTitle(checkMatch[1]), tag, tags: allTags, claimed: null, stage: null, verify: null });
57
77
  continue;
58
78
  }
59
79
 
60
80
  const plainMatch = line.match(/^- (.+)$/);
61
- if (plainMatch && !current && !plainMatch[1].startsWith('**')) {
62
- tasks.push({ id: null, title: plainMatch[1].trim(), tag: null, claimed: null, stage: null, verify: null });
81
+ if (plainMatch && !plainMatch[1].startsWith('**')) {
82
+ if (current) {
83
+ tasks.push(current);
84
+ current = null;
85
+ }
86
+ const { allTags, tag } = tagsFromText(plainMatch[1]);
87
+ tasks.push({ id: null, title: cleanTaskTitle(plainMatch[1]), tag, tags: allTags, claimed: null, stage: null, verify: null });
63
88
  continue;
64
89
  }
65
90
 
package/lib/wiki.js CHANGED
@@ -457,7 +457,20 @@ function readWikiPages(projectRoot = process.cwd(), mode = 'public') {
457
457
 
458
458
  function normalizeSourcePath(projectRoot, source) {
459
459
  if (!source || /^https?:\/\//i.test(source)) return null;
460
+ // Source receipts may be external system labels such as "hubspot",
461
+ // "chorus", or "greenhouse". They are valid evidence labels but not local
462
+ // files, so stale-file checks should not mark them missing.
463
+ if (!/[/.]/.test(source)) return null;
460
464
  if (path.isAbsolute(source)) return path.normalize(source);
465
+ if (source.startsWith('context/')) {
466
+ const atrisContextPath = path.normalize(path.join(projectRoot, 'atris', source));
467
+ if (fs.existsSync(atrisContextPath)) return atrisContextPath;
468
+ }
469
+ if (source.startsWith('team/')) {
470
+ const atrisTeamPath = path.normalize(path.join(projectRoot, 'atris', source));
471
+ if (fs.existsSync(atrisTeamPath)) return atrisTeamPath;
472
+ }
473
+ if (/^(chorus|hubspot|greenhouse)\//.test(source)) return null;
461
474
  return path.normalize(path.join(projectRoot, source));
462
475
  }
463
476
 
@@ -503,9 +516,20 @@ function findStaleWikiPages(projectRoot = process.cwd(), mode = 'public') {
503
516
  .filter(Boolean);
504
517
  }
505
518
 
506
- function extractWikiLinks(content) {
519
+ function extractWikiLinks(content, wikiRoot = WIKI_ROOT) {
507
520
  const matches = content.match(/\[\[((?:atris\/wiki|\.atris\/presidio)\/[^\]]+?)\]\]/g) || [];
508
- return matches.map((match) => match.slice(2, -2));
521
+ const links = matches.map((match) => match.slice(2, -2));
522
+ const markdownMatches = content.matchAll(/\[[^\]]+\]\(([^)]+\.md)\)/g);
523
+ for (const match of markdownMatches) {
524
+ const raw = String(match[1] || '').trim();
525
+ if (!raw || /^https?:\/\//i.test(raw) || raw.startsWith('#')) continue;
526
+ if (raw.startsWith(`${wikiRoot}/`)) {
527
+ links.push(raw);
528
+ } else if (!raw.startsWith('/') && !raw.startsWith('..')) {
529
+ links.push(`${wikiRoot}/${raw}`.replace(/\/+/g, '/'));
530
+ }
531
+ }
532
+ return links;
509
533
  }
510
534
 
511
535
  function findWikiOrphans(projectRoot = process.cwd(), mode = 'public') {
@@ -519,8 +543,10 @@ function findWikiOrphans(projectRoot = process.cwd(), mode = 'public') {
519
543
  inboundLinks.set(page.relativePath, 0);
520
544
  }
521
545
 
546
+ const indexLinks = new Set(extractWikiLinks(indexContent, wikiRoot).map((link) => link.replace(/\\/g, '/')));
547
+
522
548
  for (const page of pages) {
523
- const links = extractWikiLinks(page.content);
549
+ const links = extractWikiLinks(page.content, wikiRoot);
524
550
  for (const link of links) {
525
551
  const normalized = link.replace(/\\/g, '/');
526
552
  if (normalized !== page.relativePath && inboundLinks.has(normalized)) {
@@ -531,13 +557,66 @@ function findWikiOrphans(projectRoot = process.cwd(), mode = 'public') {
531
557
 
532
558
  return pages
533
559
  .filter((page) => {
534
- const indexed = indexContent.includes(`[[${page.relativePath}]]`);
560
+ const indexed = indexContent.includes(`[[${page.relativePath}]]`) || indexLinks.has(page.relativePath);
535
561
  const inboundCount = inboundLinks.get(page.relativePath) || 0;
536
562
  return !indexed && inboundCount === 0;
537
563
  })
538
564
  .map((page) => page.relativePath);
539
565
  }
540
566
 
567
+ function hasFrontmatterKey(frontmatter, key) {
568
+ return Object.prototype.hasOwnProperty.call(frontmatter || {}, key);
569
+ }
570
+
571
+ function isIsoDate(value) {
572
+ return typeof value === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(value);
573
+ }
574
+
575
+ function isConfidence(value) {
576
+ const n = Number(value);
577
+ return Number.isFinite(n) && n >= 0 && n <= 1;
578
+ }
579
+
580
+ function validateAgentReadableWikiPages(projectRoot = process.cwd(), mode = 'public') {
581
+ const findings = [];
582
+ const pages = readWikiPages(projectRoot, mode);
583
+ const stalePages = new Set(findStaleWikiPages(projectRoot, mode).map((item) => item.page));
584
+
585
+ for (const page of pages) {
586
+ const fm = page.frontmatter || {};
587
+ const sources = Array.isArray(fm.sources) ? fm.sources.filter(Boolean) : [];
588
+
589
+ if (sources.length === 0) {
590
+ findings.push({ page: page.relativePath, code: 'missing-sources', message: 'missing source receipt in frontmatter sources' });
591
+ }
592
+ if (!isIsoDate(fm.last_compiled)) {
593
+ findings.push({ page: page.relativePath, code: 'missing-last-compiled', message: 'missing YYYY-MM-DD last_compiled' });
594
+ }
595
+ if (!isIsoDate(fm.last_verified)) {
596
+ findings.push({ page: page.relativePath, code: 'missing-last-verified', message: 'missing YYYY-MM-DD last_verified' });
597
+ }
598
+ if (!isConfidence(fm.confidence)) {
599
+ findings.push({ page: page.relativePath, code: 'missing-confidence', message: 'confidence must be a number from 0 to 1' });
600
+ }
601
+ if (!hasFrontmatterKey(fm, 'dependencies') || !Array.isArray(fm.dependencies)) {
602
+ findings.push({ page: page.relativePath, code: 'missing-dependencies', message: 'dependencies must be present as a list, even if empty' });
603
+ }
604
+ if (!String(fm.actionability || '').trim()) {
605
+ findings.push({ page: page.relativePath, code: 'missing-actionability', message: 'missing actionability summary' });
606
+ }
607
+ if (stalePages.has(page.relativePath)) {
608
+ findings.push({ page: page.relativePath, code: 'stale-source', message: 'source changed since last_compiled or source is missing' });
609
+ }
610
+ }
611
+
612
+ return {
613
+ ok: findings.length === 0,
614
+ pageCount: pages.length,
615
+ findingCount: findings.length,
616
+ findings,
617
+ };
618
+ }
619
+
541
620
  function findSuggestedSources(projectRoot = process.cwd(), limit = 3) {
542
621
  const candidates = [
543
622
  'README.md',
@@ -743,6 +822,7 @@ module.exports = {
743
822
  readWikiPages,
744
823
  findStaleWikiPages,
745
824
  findWikiOrphans,
825
+ validateAgentReadableWikiPages,
746
826
  findSuggestedSources,
747
827
  stageWikiIngest,
748
828
  writeWikiStatus,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "atris",
3
- "version": "3.15.11",
3
+ "version": "3.15.13",
4
4
  "description": "Atris — an operating system for intelligence. Integrates with any agent.",
5
5
  "main": "bin/atris.js",
6
6
  "bin": {