atris 3.2.0 → 3.5.0
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/GETTING_STARTED.md +65 -131
- package/README.md +18 -2
- package/atris/GETTING_STARTED.md +65 -131
- package/atris/PERSONA.md +5 -1
- package/atris/atris.md +122 -153
- package/atris/skills/aeo/SKILL.md +117 -0
- package/atris/skills/atris/SKILL.md +49 -25
- package/atris/skills/create-member/SKILL.md +29 -9
- package/atris/skills/endgame/SKILL.md +9 -0
- package/atris/skills/research-search/SKILL.md +167 -0
- package/atris/skills/research-search/arxiv_search.py +157 -0
- package/atris/skills/research-search/program.md +48 -0
- package/atris/skills/research-search/results.tsv +6 -0
- package/atris/skills/research-search/scholar_search.py +154 -0
- package/atris/skills/tidy/SKILL.md +36 -21
- package/atris/team/_template/MEMBER.md +2 -0
- package/atris/team/validator/MEMBER.md +35 -1
- package/atris.md +118 -178
- package/bin/atris.js +30 -5
- package/cli/__pycache__/atris_code.cpython-314.pyc +0 -0
- package/cli/__pycache__/runtime_guard.cpython-312.pyc +0 -0
- package/cli/__pycache__/runtime_guard.cpython-314.pyc +0 -0
- package/cli/atris_code.py +889 -0
- package/cli/runtime_guard.py +693 -0
- package/commands/align.js +15 -0
- package/commands/app.js +316 -0
- package/commands/autopilot.js +390 -7
- package/commands/business.js +677 -2
- package/commands/computer.js +1979 -43
- package/commands/context-sync.js +5 -0
- package/commands/lifecycle.js +12 -0
- package/commands/plugin.js +24 -0
- package/commands/pull.js +40 -1
- package/commands/push.js +44 -0
- package/commands/serve.js +1 -0
- package/commands/sync.js +272 -76
- package/commands/verify.js +50 -1
- package/commands/wiki.js +27 -2
- package/lib/file-ops.js +13 -1
- package/lib/journal.js +23 -0
- package/lib/scorecard.js +42 -4
- package/lib/sync-telemetry.js +59 -0
- package/lib/todo.js +6 -0
- package/lib/wiki.js +150 -6
- package/package.json +2 -1
- package/utils/api.js +19 -0
- package/utils/auth.js +25 -1
- package/utils/config.js +24 -0
- package/utils/update-check.js +16 -0
package/commands/sync.js
CHANGED
|
@@ -48,6 +48,79 @@ function _substituteParams(content, params) {
|
|
|
48
48
|
.replace(/\{\{workspace_template\}\}/g, params.workspace_template || 'business');
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
/**
|
|
52
|
+
* Sync the canonical skill set from atris-cli/atris/skills/* into a
|
|
53
|
+
* workspace's atris/skills/* (plus ensure .claude/skills/ symlinks).
|
|
54
|
+
*
|
|
55
|
+
* Shared by business mode (via syncWorkspaceTemplate) and legacy/dev mode
|
|
56
|
+
* (via syncAtris). Single source of truth = atris-cli/atris/skills/.
|
|
57
|
+
*
|
|
58
|
+
* Returns the number of files updated (0 if everything was up to date).
|
|
59
|
+
*/
|
|
60
|
+
function syncPackageSkills(targetAtrisDir, opts = {}) {
|
|
61
|
+
const packageSkillsDir = path.join(__dirname, '..', 'atris', 'skills');
|
|
62
|
+
const userSkillsDir = path.join(targetAtrisDir, 'skills');
|
|
63
|
+
const claudeSkillsBaseDir = path.join(path.dirname(targetAtrisDir), '.claude', 'skills');
|
|
64
|
+
const verbose = opts.verbose !== false;
|
|
65
|
+
let updated = 0;
|
|
66
|
+
|
|
67
|
+
if (!fs.existsSync(packageSkillsDir)) return 0;
|
|
68
|
+
|
|
69
|
+
if (!fs.existsSync(userSkillsDir)) fs.mkdirSync(userSkillsDir, { recursive: true });
|
|
70
|
+
if (!fs.existsSync(claudeSkillsBaseDir)) fs.mkdirSync(claudeSkillsBaseDir, { recursive: true });
|
|
71
|
+
|
|
72
|
+
const skillFolders = fs.readdirSync(packageSkillsDir).filter(f => {
|
|
73
|
+
try { return fs.statSync(path.join(packageSkillsDir, f)).isDirectory(); }
|
|
74
|
+
catch { return false; }
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
for (const skill of skillFolders) {
|
|
78
|
+
const srcSkillDir = path.join(packageSkillsDir, skill);
|
|
79
|
+
const destSkillDir = path.join(userSkillsDir, skill);
|
|
80
|
+
const symlinkPath = path.join(claudeSkillsBaseDir, skill);
|
|
81
|
+
|
|
82
|
+
const syncRecursive = (src, dest, skillName, basePath = '') => {
|
|
83
|
+
if (!fs.existsSync(dest)) fs.mkdirSync(dest, { recursive: true });
|
|
84
|
+
for (const entry of fs.readdirSync(src)) {
|
|
85
|
+
const srcPath = path.join(src, entry);
|
|
86
|
+
const destPath = path.join(dest, entry);
|
|
87
|
+
const relPath = basePath ? `${basePath}/${entry}` : entry;
|
|
88
|
+
if (fs.statSync(srcPath).isDirectory()) {
|
|
89
|
+
syncRecursive(srcPath, destPath, skillName, relPath);
|
|
90
|
+
} else {
|
|
91
|
+
const srcContent = fs.readFileSync(srcPath, 'utf8');
|
|
92
|
+
const destContent = fs.existsSync(destPath) ? fs.readFileSync(destPath, 'utf8') : '';
|
|
93
|
+
if (srcContent !== destContent) {
|
|
94
|
+
fs.writeFileSync(destPath, srcContent);
|
|
95
|
+
if (entry.endsWith('.sh')) fs.chmodSync(destPath, 0o755);
|
|
96
|
+
if (verbose) console.log(`✓ Updated atris/skills/${skillName}/${relPath}`);
|
|
97
|
+
updated++;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
syncRecursive(srcSkillDir, destSkillDir, skill);
|
|
104
|
+
|
|
105
|
+
if (!fs.existsSync(symlinkPath)) {
|
|
106
|
+
const relativePath = path.relative(claudeSkillsBaseDir, destSkillDir);
|
|
107
|
+
try {
|
|
108
|
+
fs.symlinkSync(relativePath, symlinkPath);
|
|
109
|
+
if (verbose) console.log(`✓ Linked .claude/skills/${skill}`);
|
|
110
|
+
} catch (e) {
|
|
111
|
+
fs.mkdirSync(symlinkPath, { recursive: true });
|
|
112
|
+
const skillFile = path.join(destSkillDir, 'SKILL.md');
|
|
113
|
+
if (fs.existsSync(skillFile)) {
|
|
114
|
+
fs.copyFileSync(skillFile, path.join(symlinkPath, 'SKILL.md'));
|
|
115
|
+
}
|
|
116
|
+
if (verbose) console.log(`✓ Copied .claude/skills/${skill} (symlink failed)`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return updated;
|
|
122
|
+
}
|
|
123
|
+
|
|
51
124
|
function resolveWorkspaceTemplate(templateName = 'business') {
|
|
52
125
|
const normalized = String(templateName || 'business').toLowerCase();
|
|
53
126
|
if (normalized === 'research-lab' || normalized === 'researchlab' || normalized === 'lab') {
|
|
@@ -58,7 +131,7 @@ function resolveWorkspaceTemplate(templateName = 'business') {
|
|
|
58
131
|
return { key: normalized, ...template };
|
|
59
132
|
}
|
|
60
133
|
|
|
61
|
-
function
|
|
134
|
+
function ensureWorkspaceStateFiles(targetRoot, params, options = {}) {
|
|
62
135
|
const dryRun = options.dryRun === true;
|
|
63
136
|
const metaDir = path.join(targetRoot, '.atris');
|
|
64
137
|
const stateDir = path.join(metaDir, 'state');
|
|
@@ -164,12 +237,22 @@ function syncWorkspaceTemplate(targetRoot, bizMeta, options = {}) {
|
|
|
164
237
|
}
|
|
165
238
|
}
|
|
166
239
|
|
|
167
|
-
const stateAddedList =
|
|
240
|
+
const stateAddedList = ensureWorkspaceStateFiles(targetRoot, params, { dryRun });
|
|
241
|
+
|
|
242
|
+
// Skills: sync the canonical skill set from atris-cli package into the
|
|
243
|
+
// customer workspace. Business-starter template ships skill infra (README,
|
|
244
|
+
// folders) but skill files live in atris-cli/atris/skills/ — single source
|
|
245
|
+
// of truth. Any new skill (e.g. AEO) auto-propagates to every customer.
|
|
246
|
+
let skillsUpdated = 0;
|
|
247
|
+
if (!dryRun) {
|
|
248
|
+
skillsUpdated = syncPackageSkills(targetAtrisDir, { verbose: false });
|
|
249
|
+
}
|
|
168
250
|
|
|
169
251
|
console.log(` Added: ${added}`);
|
|
170
252
|
console.log(` Updated: ${updated} ${force ? '' : '(--force to enable)'}`);
|
|
171
253
|
console.log(` Preserved: ${preserved} (existing customizations kept)`);
|
|
172
254
|
console.log(` Skipped: ${skipped} (already match template)`);
|
|
255
|
+
console.log(` Skills: ${skillsUpdated} updated from atris-cli/atris/skills/`);
|
|
173
256
|
console.log('');
|
|
174
257
|
|
|
175
258
|
if (addedList.length > 0) {
|
|
@@ -324,80 +407,8 @@ function syncAtris() {
|
|
|
324
407
|
console.log('✓ Migrated TASK_CONTEXTS.md to TODO.md');
|
|
325
408
|
}
|
|
326
409
|
|
|
327
|
-
// Sync all skills from package to user's project
|
|
328
|
-
|
|
329
|
-
const userSkillsDir = path.join(targetDir, 'skills');
|
|
330
|
-
const claudeSkillsBaseDir = path.join(process.cwd(), '.claude', 'skills');
|
|
331
|
-
|
|
332
|
-
if (fs.existsSync(packageSkillsDir)) {
|
|
333
|
-
// Ensure directories exist
|
|
334
|
-
if (!fs.existsSync(userSkillsDir)) {
|
|
335
|
-
fs.mkdirSync(userSkillsDir, { recursive: true });
|
|
336
|
-
}
|
|
337
|
-
if (!fs.existsSync(claudeSkillsBaseDir)) {
|
|
338
|
-
fs.mkdirSync(claudeSkillsBaseDir, { recursive: true });
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
// Get all skill folders from package
|
|
342
|
-
const skillFolders = fs.readdirSync(packageSkillsDir).filter(f => {
|
|
343
|
-
const skillPath = path.join(packageSkillsDir, f);
|
|
344
|
-
return fs.statSync(skillPath).isDirectory();
|
|
345
|
-
});
|
|
346
|
-
|
|
347
|
-
for (const skill of skillFolders) {
|
|
348
|
-
const srcSkillDir = path.join(packageSkillsDir, skill);
|
|
349
|
-
const destSkillDir = path.join(userSkillsDir, skill);
|
|
350
|
-
const symlinkPath = path.join(claudeSkillsBaseDir, skill);
|
|
351
|
-
|
|
352
|
-
// Recursive sync function for skills (handles subdirs like hooks/)
|
|
353
|
-
const syncRecursive = (src, dest, skillName, basePath = '') => {
|
|
354
|
-
if (!fs.existsSync(dest)) {
|
|
355
|
-
fs.mkdirSync(dest, { recursive: true });
|
|
356
|
-
}
|
|
357
|
-
const entries = fs.readdirSync(src);
|
|
358
|
-
for (const entry of entries) {
|
|
359
|
-
const srcPath = path.join(src, entry);
|
|
360
|
-
const destPath = path.join(dest, entry);
|
|
361
|
-
const relPath = basePath ? `${basePath}/${entry}` : entry;
|
|
362
|
-
|
|
363
|
-
if (fs.statSync(srcPath).isDirectory()) {
|
|
364
|
-
syncRecursive(srcPath, destPath, skillName, relPath);
|
|
365
|
-
} else {
|
|
366
|
-
const srcContent = fs.readFileSync(srcPath, 'utf8');
|
|
367
|
-
const destContent = fs.existsSync(destPath) ? fs.readFileSync(destPath, 'utf8') : '';
|
|
368
|
-
if (srcContent !== destContent) {
|
|
369
|
-
fs.writeFileSync(destPath, srcContent);
|
|
370
|
-
// Preserve executable permission for shell scripts
|
|
371
|
-
if (entry.endsWith('.sh')) {
|
|
372
|
-
fs.chmodSync(destPath, 0o755);
|
|
373
|
-
}
|
|
374
|
-
console.log(`✓ Updated atris/skills/${skillName}/${relPath}`);
|
|
375
|
-
updated++;
|
|
376
|
-
}
|
|
377
|
-
}
|
|
378
|
-
}
|
|
379
|
-
};
|
|
380
|
-
|
|
381
|
-
syncRecursive(srcSkillDir, destSkillDir, skill);
|
|
382
|
-
|
|
383
|
-
// Create symlink if doesn't exist
|
|
384
|
-
if (!fs.existsSync(symlinkPath)) {
|
|
385
|
-
const relativePath = path.join('..', '..', 'atris', 'skills', skill);
|
|
386
|
-
try {
|
|
387
|
-
fs.symlinkSync(relativePath, symlinkPath);
|
|
388
|
-
console.log(`✓ Linked .claude/skills/${skill}`);
|
|
389
|
-
} catch (e) {
|
|
390
|
-
// Fallback: copy instead of symlink
|
|
391
|
-
fs.mkdirSync(symlinkPath, { recursive: true });
|
|
392
|
-
const skillFile = path.join(destSkillDir, 'SKILL.md');
|
|
393
|
-
if (fs.existsSync(skillFile)) {
|
|
394
|
-
fs.copyFileSync(skillFile, path.join(symlinkPath, 'SKILL.md'));
|
|
395
|
-
}
|
|
396
|
-
console.log(`✓ Copied .claude/skills/${skill} (symlink failed)`);
|
|
397
|
-
}
|
|
398
|
-
}
|
|
399
|
-
}
|
|
400
|
-
}
|
|
410
|
+
// Sync all skills from package to user's project via shared helper.
|
|
411
|
+
updated += syncPackageSkills(targetDir, { verbose: true });
|
|
401
412
|
|
|
402
413
|
// Update .claude/skills/atris/SKILL.md (legacy - now handled above, keeping for compatibility)
|
|
403
414
|
const claudeSkillsDir = path.join(process.cwd(), '.claude', 'skills', 'atris');
|
|
@@ -654,10 +665,195 @@ function syncSkills({ silent = false } = {}) {
|
|
|
654
665
|
return updated;
|
|
655
666
|
}
|
|
656
667
|
|
|
668
|
+
/**
|
|
669
|
+
* Discover atris-managed projects under a root directory.
|
|
670
|
+
* A project is any directory whose immediate child `atris/` folder contains `atris.md`.
|
|
671
|
+
* Skips noise: node_modules, .git, .claude, dist, build, _archive, worktrees.
|
|
672
|
+
*/
|
|
673
|
+
function _findAtrisProjects(rootDir, maxDepth = 8) {
|
|
674
|
+
const skip = new Set([
|
|
675
|
+
'node_modules', '.git', '.claude', '.next', 'dist', 'build',
|
|
676
|
+
'_archive', 'worktrees', '.codex', '.venv', 'venv', '__pycache__',
|
|
677
|
+
]);
|
|
678
|
+
const found = [];
|
|
679
|
+
function walk(dir, depth) {
|
|
680
|
+
if (depth > maxDepth) return;
|
|
681
|
+
const atrisMd = path.join(dir, 'atris', 'atris.md');
|
|
682
|
+
if (fs.existsSync(atrisMd)) found.push(dir);
|
|
683
|
+
let entries;
|
|
684
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
|
|
685
|
+
catch { return; }
|
|
686
|
+
for (const e of entries) {
|
|
687
|
+
if (!e.isDirectory()) continue;
|
|
688
|
+
if (skip.has(e.name)) continue;
|
|
689
|
+
if (e.name.startsWith('.')) continue;
|
|
690
|
+
walk(path.join(dir, e.name), depth + 1);
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
walk(path.resolve(rootDir), 0);
|
|
694
|
+
return found;
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
/**
|
|
698
|
+
* Sync canonical atris.md (and core docs) across every atris project under cwd.
|
|
699
|
+
* Walks the subtree, finds every `atris/atris.md`, copies the package source.
|
|
700
|
+
*
|
|
701
|
+
* Flags: --dry-run (preview only), --yes/--force (skip confirm).
|
|
702
|
+
* Skips business workspaces (they use syncWorkspaceTemplate via syncAtris).
|
|
703
|
+
*/
|
|
704
|
+
// Canonical files shipped from the package root. Must match syncAtris's filesToSync.
|
|
705
|
+
const SYNC_ALL_FILES = [
|
|
706
|
+
{ source: 'atris.md', target: 'atris.md' },
|
|
707
|
+
{ source: 'atris/atrisDev.md', target: 'atrisDev.md' },
|
|
708
|
+
{ source: 'PERSONA.md', target: 'PERSONA.md' },
|
|
709
|
+
{ source: 'GETTING_STARTED.md', target: 'GETTING_STARTED.md' },
|
|
710
|
+
{ source: 'atris/CLAUDE.md', target: 'CLAUDE.md' },
|
|
711
|
+
];
|
|
712
|
+
|
|
713
|
+
/**
|
|
714
|
+
* Pure function: build the sync plan for every atris project under root.
|
|
715
|
+
* No console output, no writes. Returns { projects, plan } for inspection.
|
|
716
|
+
* Plan entries: { projectRoot, isBusiness, isCustomized, changes }.
|
|
717
|
+
*
|
|
718
|
+
* Exported for tests — the production syncAtrisAll wraps this.
|
|
719
|
+
*/
|
|
720
|
+
function buildSyncAllPlan({ root, pkgRoot, filesToSync = SYNC_ALL_FILES } = {}) {
|
|
721
|
+
const projects = _findAtrisProjects(root);
|
|
722
|
+
const plan = [];
|
|
723
|
+
for (const projectRoot of projects) {
|
|
724
|
+
const atrisDir = path.join(projectRoot, 'atris');
|
|
725
|
+
const bizFile = path.join(projectRoot, '.atris', 'business.json');
|
|
726
|
+
const isSelf = path.resolve(projectRoot) === path.resolve(pkgRoot);
|
|
727
|
+
const isInBusinessDir = projectRoot.split(path.sep).includes('atris-business');
|
|
728
|
+
let isBusiness = isInBusinessDir || (fs.existsSync(bizFile) && !isSelf);
|
|
729
|
+
if (isBusiness && !isInBusinessDir) {
|
|
730
|
+
try {
|
|
731
|
+
const head = fs.readFileSync(path.join(atrisDir, 'atris.md'), 'utf8').slice(0, 300);
|
|
732
|
+
if (!/^#\s+Atris Boot Protocol/i.test(head)) isBusiness = false;
|
|
733
|
+
} catch {}
|
|
734
|
+
}
|
|
735
|
+
let isCustomized = false;
|
|
736
|
+
if (!isSelf && !isBusiness) {
|
|
737
|
+
try {
|
|
738
|
+
const head = fs.readFileSync(path.join(atrisDir, 'atris.md'), 'utf8').slice(0, 500);
|
|
739
|
+
const isNewCanonical = /^#\s+atris\s*\n\nAtris exists because/m.test(head);
|
|
740
|
+
const isOldGeneric = /^#\s+atris\.md\s*\n\n>\s+Drop this file anywhere/m.test(head);
|
|
741
|
+
if (!isNewCanonical && !isOldGeneric) isCustomized = true;
|
|
742
|
+
} catch {}
|
|
743
|
+
}
|
|
744
|
+
const changes = [];
|
|
745
|
+
for (const { source, target } of filesToSync) {
|
|
746
|
+
const sourceFile = path.join(pkgRoot, source);
|
|
747
|
+
const targetFile = path.join(atrisDir, target);
|
|
748
|
+
if (!fs.existsSync(sourceFile)) continue;
|
|
749
|
+
const newContent = fs.readFileSync(sourceFile, 'utf8');
|
|
750
|
+
const currentContent = fs.existsSync(targetFile) ? fs.readFileSync(targetFile, 'utf8') : '';
|
|
751
|
+
if (currentContent !== newContent) changes.push(target);
|
|
752
|
+
}
|
|
753
|
+
plan.push({ projectRoot, isBusiness, isCustomized, changes });
|
|
754
|
+
}
|
|
755
|
+
return { projects, plan };
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
function syncAtrisAll({ dryRun = false, force = false } = {}) {
|
|
759
|
+
const root = process.cwd();
|
|
760
|
+
const pkgRoot = path.join(__dirname, '..');
|
|
761
|
+
|
|
762
|
+
console.log('');
|
|
763
|
+
console.log(`Scanning ${root} for atris projects...`);
|
|
764
|
+
|
|
765
|
+
const filesToSync = SYNC_ALL_FILES;
|
|
766
|
+
const { projects, plan: initialPlan } = buildSyncAllPlan({ root, pkgRoot, filesToSync });
|
|
767
|
+
|
|
768
|
+
if (projects.length === 0) {
|
|
769
|
+
console.log('No atris projects found under current directory.');
|
|
770
|
+
return;
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
const plan = initialPlan;
|
|
774
|
+
|
|
775
|
+
// Report.
|
|
776
|
+
console.log(`Found ${projects.length} project(s).`);
|
|
777
|
+
console.log('');
|
|
778
|
+
let wouldUpdate = 0, unchanged = 0, skipped = 0;
|
|
779
|
+
for (const p of plan) {
|
|
780
|
+
const rel = path.relative(root, p.projectRoot) || '.';
|
|
781
|
+
if (p.isBusiness) {
|
|
782
|
+
console.log(` ⏭ ${rel} (business workspace — run "atris update" in that dir)`);
|
|
783
|
+
skipped++;
|
|
784
|
+
} else if (p.isCustomized) {
|
|
785
|
+
console.log(` ⏭ ${rel} (customized atris.md — review manually)`);
|
|
786
|
+
skipped++;
|
|
787
|
+
} else if (p.changes.length === 0) {
|
|
788
|
+
console.log(` · ${rel} (up to date)`);
|
|
789
|
+
unchanged++;
|
|
790
|
+
} else {
|
|
791
|
+
console.log(` → ${rel} — ${p.changes.length} file(s): ${p.changes.join(', ')}`);
|
|
792
|
+
wouldUpdate++;
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
console.log('');
|
|
796
|
+
|
|
797
|
+
if (dryRun) {
|
|
798
|
+
console.log(`Dry run: ${wouldUpdate} project(s) would update, ${unchanged} unchanged, ${skipped} skipped.`);
|
|
799
|
+
return;
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
if (wouldUpdate === 0) {
|
|
803
|
+
console.log('Nothing to sync.');
|
|
804
|
+
return;
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
// Confirm unless forced.
|
|
808
|
+
if (!force) {
|
|
809
|
+
const readline = require('readline');
|
|
810
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
811
|
+
return new Promise((resolve) => {
|
|
812
|
+
rl.question(`Sync ${wouldUpdate} project(s)? (y/N) `, (answer) => {
|
|
813
|
+
rl.close();
|
|
814
|
+
if (!/^y(es)?$/i.test(answer.trim())) {
|
|
815
|
+
console.log('Cancelled.');
|
|
816
|
+
resolve();
|
|
817
|
+
return;
|
|
818
|
+
}
|
|
819
|
+
_executeSyncAll(plan, pkgRoot, filesToSync, root);
|
|
820
|
+
resolve();
|
|
821
|
+
});
|
|
822
|
+
});
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
_executeSyncAll(plan, pkgRoot, filesToSync, root);
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
function _executeSyncAll(plan, pkgRoot, filesToSync, root) {
|
|
829
|
+
let updated = 0;
|
|
830
|
+
for (const p of plan) {
|
|
831
|
+
if (p.isBusiness || p.isCustomized || p.changes.length === 0) continue;
|
|
832
|
+
const atrisDir = path.join(p.projectRoot, 'atris');
|
|
833
|
+
for (const { source, target } of filesToSync) {
|
|
834
|
+
const sourceFile = path.join(pkgRoot, source);
|
|
835
|
+
const targetFile = path.join(atrisDir, target);
|
|
836
|
+
if (!fs.existsSync(sourceFile)) continue;
|
|
837
|
+
const newContent = fs.readFileSync(sourceFile, 'utf8');
|
|
838
|
+
const currentContent = fs.existsSync(targetFile) ? fs.readFileSync(targetFile, 'utf8') : '';
|
|
839
|
+
if (currentContent === newContent) continue;
|
|
840
|
+
fs.mkdirSync(path.dirname(targetFile), { recursive: true });
|
|
841
|
+
fs.copyFileSync(sourceFile, targetFile);
|
|
842
|
+
}
|
|
843
|
+
updated++;
|
|
844
|
+
}
|
|
845
|
+
console.log('');
|
|
846
|
+
console.log(`✓ Synced ${updated} project(s).`);
|
|
847
|
+
}
|
|
848
|
+
|
|
657
849
|
module.exports = {
|
|
658
850
|
syncAtris,
|
|
851
|
+
syncAtrisAll,
|
|
852
|
+
buildSyncAllPlan,
|
|
853
|
+
SYNC_ALL_FILES,
|
|
659
854
|
syncSkills,
|
|
660
855
|
syncBusinessCanonical,
|
|
661
856
|
syncWorkspaceTemplate,
|
|
662
857
|
resolveWorkspaceTemplate,
|
|
858
|
+
ensureWorkspaceStateFiles,
|
|
663
859
|
};
|
package/commands/verify.js
CHANGED
|
@@ -479,6 +479,55 @@ function checkMapForFiles(atrisDir, changes) {
|
|
|
479
479
|
return { documented: documented >= fileChanges.length / 2 };
|
|
480
480
|
}
|
|
481
481
|
|
|
482
|
+
/**
|
|
483
|
+
* atris verify <slug> --section <name>
|
|
484
|
+
*
|
|
485
|
+
* Extract the first fenced bash block under "## <name>" in
|
|
486
|
+
* atris/features/<slug>/validate.md and execute it. Returns the exit code
|
|
487
|
+
* from bash. Used as the machine-checkable Verify command in TODO.md tasks.
|
|
488
|
+
*
|
|
489
|
+
* Contract (per atris.md): the rubric must be read-only, deterministic, and
|
|
490
|
+
* reference only the working tree. The command fails loudly when the rubric
|
|
491
|
+
* or section is missing — that prevents silent "trivial Verify" regressions.
|
|
492
|
+
*/
|
|
493
|
+
function verifyRubric(slug, section, opts = {}) {
|
|
494
|
+
const cwd = opts.cwd || process.cwd();
|
|
495
|
+
if (!slug || !section) {
|
|
496
|
+
console.error('Usage: atris verify <feature-slug> --section <name>');
|
|
497
|
+
return 2;
|
|
498
|
+
}
|
|
499
|
+
const validateFile = path.join(cwd, 'atris', 'features', slug, 'validate.md');
|
|
500
|
+
if (!fs.existsSync(validateFile)) {
|
|
501
|
+
console.error(`✗ No rubric at ${path.relative(cwd, validateFile)}`);
|
|
502
|
+
return 2;
|
|
503
|
+
}
|
|
504
|
+
const content = fs.readFileSync(validateFile, 'utf8');
|
|
505
|
+
// Match "## <section>" (case-insensitive, anchored), skipping optional
|
|
506
|
+
// prose until the first ```bash or ```sh fence. Extract until the closing ```.
|
|
507
|
+
const escaped = section.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
508
|
+
const pattern = new RegExp(
|
|
509
|
+
`^##\\s+${escaped}\\s*$[\\s\\S]*?\\n\`\`\`(?:bash|sh)?\\s*\\n([\\s\\S]*?)\\n\`\`\``,
|
|
510
|
+
'mi'
|
|
511
|
+
);
|
|
512
|
+
const match = content.match(pattern);
|
|
513
|
+
if (!match) {
|
|
514
|
+
console.error(`✗ No fenced bash block under "## ${section}" in ${path.relative(cwd, validateFile)}`);
|
|
515
|
+
return 2;
|
|
516
|
+
}
|
|
517
|
+
const script = match[1];
|
|
518
|
+
const os = require('os');
|
|
519
|
+
const tmpFile = path.join(os.tmpdir(), `atris-verify-${Date.now()}-${Math.floor(Math.random() * 1e6)}.sh`);
|
|
520
|
+
fs.writeFileSync(tmpFile, `#!/usr/bin/env bash\nset -e\n${script}\n`);
|
|
521
|
+
fs.chmodSync(tmpFile, 0o755);
|
|
522
|
+
try {
|
|
523
|
+
const proc = spawnSync('bash', [tmpFile], { cwd, stdio: opts.silent ? 'pipe' : 'inherit' });
|
|
524
|
+
return typeof proc.status === 'number' ? proc.status : 1;
|
|
525
|
+
} finally {
|
|
526
|
+
try { fs.unlinkSync(tmpFile); } catch {}
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
482
530
|
module.exports = {
|
|
483
|
-
verifyAtris
|
|
531
|
+
verifyAtris,
|
|
532
|
+
verifyRubric
|
|
484
533
|
};
|
package/commands/wiki.js
CHANGED
|
@@ -8,10 +8,14 @@ const {
|
|
|
8
8
|
PRIVATE_WIKI_ROOT,
|
|
9
9
|
getWikiRoot,
|
|
10
10
|
ensureWikiScaffold,
|
|
11
|
+
ensureContextScaffold,
|
|
11
12
|
findLocalWikiDir,
|
|
13
|
+
stageWikiIngest,
|
|
12
14
|
buildIngestPrompt,
|
|
13
15
|
buildQueryPrompt,
|
|
14
16
|
buildLintPrompt,
|
|
17
|
+
writeWikiStatus,
|
|
18
|
+
appendWikiLog,
|
|
15
19
|
} = require('../lib/wiki');
|
|
16
20
|
|
|
17
21
|
function autoDetectSlug() {
|
|
@@ -170,9 +174,30 @@ async function wikiIngest(mode, slug, sourceValue) {
|
|
|
170
174
|
if (mode === 'local' || mode === 'private') {
|
|
171
175
|
const wikiMode = mode === 'private' ? 'private' : 'public';
|
|
172
176
|
const wikiDir = ensureWikiScaffold(process.cwd(), wikiMode);
|
|
173
|
-
|
|
177
|
+
const contextDir = ensureContextScaffold(process.cwd(), wikiMode);
|
|
178
|
+
const staged = stageWikiIngest(process.cwd(), sourceValue, wikiMode);
|
|
179
|
+
writeWikiStatus(process.cwd(), {
|
|
180
|
+
health: `ingest staged from ${staged.packPath}`,
|
|
181
|
+
nextMove: `compile ${staged.promptSource} into ${getWikiRoot(wikiMode)}`,
|
|
182
|
+
}, wikiMode, { lastIngest: staged.manifest.ingested_at });
|
|
183
|
+
appendWikiLog(
|
|
184
|
+
process.cwd(),
|
|
185
|
+
`${staged.manifest.entries.length} source item(s) staged from ${sourceValue}`,
|
|
186
|
+
[
|
|
187
|
+
`context ${contextDir}`,
|
|
188
|
+
`pack ${staged.packPath}`,
|
|
189
|
+
`manifest ${staged.manifestPath}`,
|
|
190
|
+
...staged.manifest.entries.map((entry) => `${entry.kind} ${entry.staged}`),
|
|
191
|
+
],
|
|
192
|
+
wikiMode,
|
|
193
|
+
'INGEST'
|
|
194
|
+
);
|
|
195
|
+
printLocalPrompt(mode === 'private' ? 'Private wiki ingest' : 'Local wiki ingest', buildIngestPrompt(staged.promptSource, wikiMode), getWikiRoot(wikiMode), [
|
|
174
196
|
`Wiki dir: ${wikiDir}`,
|
|
175
|
-
`
|
|
197
|
+
`Context dir: ${contextDir}`,
|
|
198
|
+
`Pack: ${staged.packPath}`,
|
|
199
|
+
`Manifest: ${staged.manifestPath}`,
|
|
200
|
+
`Sources: ${staged.promptSource}`,
|
|
176
201
|
]);
|
|
177
202
|
return;
|
|
178
203
|
}
|
package/lib/file-ops.js
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
const fs = require('fs');
|
|
2
2
|
const path = require('path');
|
|
3
3
|
|
|
4
|
-
|
|
4
|
+
/**
|
|
5
|
+
* Get the path components for a journal log file.
|
|
6
|
+
* @param {string} [dateStr] - Optional date string (defaults to today)
|
|
7
|
+
* @returns {Object} Object with logsDir, yearDir, logFile, dateFormatted
|
|
8
|
+
*/
|
|
5
9
|
function getLogPath(dateStr) {
|
|
6
10
|
const targetDir = path.join(process.cwd(), 'atris');
|
|
7
11
|
const date = dateStr ? new Date(dateStr) : new Date();
|
|
@@ -17,6 +21,9 @@ function getLogPath(dateStr) {
|
|
|
17
21
|
return { logsDir, yearDir, logFile, dateFormatted };
|
|
18
22
|
}
|
|
19
23
|
|
|
24
|
+
/**
|
|
25
|
+
* Ensure the logs directory structure exists.
|
|
26
|
+
*/
|
|
20
27
|
function ensureLogDirectory() {
|
|
21
28
|
const { logsDir, yearDir } = getLogPath();
|
|
22
29
|
|
|
@@ -29,6 +36,11 @@ function ensureLogDirectory() {
|
|
|
29
36
|
}
|
|
30
37
|
}
|
|
31
38
|
|
|
39
|
+
/**
|
|
40
|
+
* Create a new daily log file, carrying forward unfinished items from yesterday.
|
|
41
|
+
* @param {string} logFile - Path to the log file to create
|
|
42
|
+
* @param {string} dateFormatted - Date string in YYYY-MM-DD format
|
|
43
|
+
*/
|
|
32
44
|
function createLogFile(logFile, dateFormatted) {
|
|
33
45
|
let carryInProgress = '';
|
|
34
46
|
let carryBacklog = '';
|
package/lib/journal.js
CHANGED
|
@@ -4,6 +4,12 @@ const path = require('path');
|
|
|
4
4
|
const os = require('os');
|
|
5
5
|
const { spawnSync } = require('child_process');
|
|
6
6
|
|
|
7
|
+
/**
|
|
8
|
+
* Check if two timestamps are effectively the same (within 5ms).
|
|
9
|
+
* @param {string} a - First timestamp
|
|
10
|
+
* @param {string} b - Second timestamp
|
|
11
|
+
* @returns {boolean} True if timestamps are within 5ms of each other
|
|
12
|
+
*/
|
|
7
13
|
function isSameTimestamp(a, b) {
|
|
8
14
|
if (!a || !b) return false;
|
|
9
15
|
const ta = new Date(a).getTime();
|
|
@@ -12,6 +18,11 @@ function isSameTimestamp(a, b) {
|
|
|
12
18
|
return Math.abs(ta - tb) < 5;
|
|
13
19
|
}
|
|
14
20
|
|
|
21
|
+
/**
|
|
22
|
+
* Compute a SHA-256 hash of content with normalized line endings.
|
|
23
|
+
* @param {string} content - Content to hash
|
|
24
|
+
* @returns {string|null} Hex hash or null if invalid input
|
|
25
|
+
*/
|
|
15
26
|
function computeContentHash(content) {
|
|
16
27
|
if (typeof content !== 'string') {
|
|
17
28
|
return null;
|
|
@@ -20,6 +31,11 @@ function computeContentHash(content) {
|
|
|
20
31
|
return crypto.createHash('sha256').update(normalized).digest('hex');
|
|
21
32
|
}
|
|
22
33
|
|
|
34
|
+
/**
|
|
35
|
+
* Parse journal content into named sections (by ## headers).
|
|
36
|
+
* @param {string} content - Journal markdown content
|
|
37
|
+
* @returns {Object} Map of section name to content
|
|
38
|
+
*/
|
|
23
39
|
function parseJournalSections(content) {
|
|
24
40
|
const sections = {};
|
|
25
41
|
const lines = content.split('\n');
|
|
@@ -48,6 +64,13 @@ function parseJournalSections(content) {
|
|
|
48
64
|
return sections;
|
|
49
65
|
}
|
|
50
66
|
|
|
67
|
+
/**
|
|
68
|
+
* Merge local and remote journal sections, detecting conflicts.
|
|
69
|
+
* @param {Object} localSections - Local section map
|
|
70
|
+
* @param {Object} remoteSections - Remote section map
|
|
71
|
+
* @param {string} knownRemoteHash - Hash of remote at last sync
|
|
72
|
+
* @returns {{merged: Object, conflicts: Array}} Merged sections and conflicts
|
|
73
|
+
*/
|
|
51
74
|
function mergeSections(localSections, remoteSections, knownRemoteHash) {
|
|
52
75
|
const merged = {};
|
|
53
76
|
const conflicts = [];
|
package/lib/scorecard.js
CHANGED
|
@@ -4,16 +4,31 @@ const { parseTodo } = require('./todo');
|
|
|
4
4
|
|
|
5
5
|
const PRIVATE_MEMORY_ROOT = '.atris/presidio';
|
|
6
6
|
|
|
7
|
+
/**
|
|
8
|
+
* Ensure the private memory directory exists.
|
|
9
|
+
* @param {string} atrisDir - Path to the atris directory
|
|
10
|
+
* @returns {string} Path to the private memory directory
|
|
11
|
+
*/
|
|
7
12
|
function ensurePrivateMemoryDir(atrisDir) {
|
|
8
13
|
const privateDir = path.join(path.dirname(atrisDir), PRIVATE_MEMORY_ROOT);
|
|
9
14
|
fs.mkdirSync(privateDir, { recursive: true });
|
|
10
15
|
return privateDir;
|
|
11
16
|
}
|
|
12
17
|
|
|
18
|
+
/**
|
|
19
|
+
* Get the path to the scorecards file.
|
|
20
|
+
* @param {string} atrisDir - Path to the atris directory
|
|
21
|
+
* @returns {string} Path to scorecards.md
|
|
22
|
+
*/
|
|
13
23
|
function getScorecardsPath(atrisDir) {
|
|
14
24
|
return path.join(ensurePrivateMemoryDir(atrisDir), 'scorecards.md');
|
|
15
25
|
}
|
|
16
26
|
|
|
27
|
+
/**
|
|
28
|
+
* Parse a "picked at" timestamp from scorecard data.
|
|
29
|
+
* @param {string} value - Timestamp string in YYYY-MM-DD [HH:MM] format
|
|
30
|
+
* @returns {Date|null} Parsed date or null if invalid
|
|
31
|
+
*/
|
|
17
32
|
function parsePickedAt(value) {
|
|
18
33
|
if (!value) return null;
|
|
19
34
|
const match = String(value).trim().match(/^(\d{4}-\d{2}-\d{2})(?:\s+(\d{2}:\d{2}))?/);
|
|
@@ -24,6 +39,12 @@ function parsePickedAt(value) {
|
|
|
24
39
|
return Number.isNaN(parsed.getTime()) ? null : parsed;
|
|
25
40
|
}
|
|
26
41
|
|
|
42
|
+
/**
|
|
43
|
+
* Parse a tick timestamp combining date and time label.
|
|
44
|
+
* @param {string} dateStr - Date in YYYY-MM-DD format
|
|
45
|
+
* @param {string} timeLabel - Time label like "2:30 PM"
|
|
46
|
+
* @returns {Date|null} Parsed date or null if invalid
|
|
47
|
+
*/
|
|
27
48
|
function parseTickDate(dateStr, timeLabel) {
|
|
28
49
|
const match = String(timeLabel || '').trim().toLowerCase().match(/^(\d{1,2}):(\d{2})(?:\s*(am|pm))?$/);
|
|
29
50
|
if (!match) return null;
|
|
@@ -40,6 +61,13 @@ function parseTickDate(dateStr, timeLabel) {
|
|
|
40
61
|
return Number.isNaN(parsed.getTime()) ? null : parsed;
|
|
41
62
|
}
|
|
42
63
|
|
|
64
|
+
/**
|
|
65
|
+
* List journal log files within a date range.
|
|
66
|
+
* @param {string} atrisDir - Path to the atris directory
|
|
67
|
+
* @param {Date} startDate - Start of date range
|
|
68
|
+
* @param {Date} [endDate] - End of date range (defaults to today)
|
|
69
|
+
* @returns {Array} Array of log file paths
|
|
70
|
+
*/
|
|
43
71
|
function listLogFiles(atrisDir, startDate, endDate = new Date()) {
|
|
44
72
|
const logsDir = path.join(atrisDir, 'logs');
|
|
45
73
|
if (!fs.existsSync(logsDir)) return [];
|
|
@@ -153,16 +181,20 @@ function buildScorecardData(atrisDir, { slug, pickedAt } = {}) {
|
|
|
153
181
|
const todo = parseTodo(todoPath);
|
|
154
182
|
const startAt = parsePickedAt(pickedAt) || new Date();
|
|
155
183
|
const rewardStats = collectRewardStats(atrisDir, pickedAt);
|
|
156
|
-
|
|
184
|
+
// Count shipped tasks from journal completions (tasks get deleted from TODO.md after completion)
|
|
185
|
+
const completedFromTodo = todo.completed.filter(t => t.tag === 'endgame').length;
|
|
157
186
|
const activeEndgame = todo.backlog.filter(t => t.tag === 'endgame').length
|
|
158
187
|
+ todo.inProgress.filter(t => t.tag === 'endgame').length;
|
|
188
|
+
// Fall back to reward tick count if TODO completions were already pruned
|
|
189
|
+
const shipped = completedFromTodo > 0 ? completedFromTodo : rewardStats.totalTicks - rewardStats.haltedTicks;
|
|
190
|
+
const attempted = shipped + activeEndgame + rewardStats.haltedTicks;
|
|
159
191
|
|
|
160
192
|
return {
|
|
161
193
|
slug,
|
|
162
194
|
startDate: startAt.toISOString().slice(0, 10),
|
|
163
195
|
endDate: new Date().toISOString().slice(0, 10),
|
|
164
|
-
tasksShipped:
|
|
165
|
-
tasksAttempted:
|
|
196
|
+
tasksShipped: Math.max(shipped, 0),
|
|
197
|
+
tasksAttempted: Math.max(attempted, shipped),
|
|
166
198
|
wallClockHours: Math.max(0, (Date.now() - startAt.getTime()) / (1000 * 60 * 60)),
|
|
167
199
|
haltRatio: rewardStats.totalTicks > 0 ? rewardStats.haltedTicks / rewardStats.totalTicks : 0,
|
|
168
200
|
totalReward: rewardStats.totalReward,
|
|
@@ -205,6 +237,12 @@ function writeScorecard(atrisDir, data) {
|
|
|
205
237
|
|
|
206
238
|
const scorecardsPath = getScorecardsPath(atrisDir);
|
|
207
239
|
|
|
240
|
+
// Dedupe guard: don't write the same slug twice
|
|
241
|
+
const existing = readScorecards(atrisDir);
|
|
242
|
+
if (existing.some(sc => sc.slug === slug)) {
|
|
243
|
+
return; // already written
|
|
244
|
+
}
|
|
245
|
+
|
|
208
246
|
// Ensure scorecards.md exists
|
|
209
247
|
if (!fs.existsSync(scorecardsPath)) {
|
|
210
248
|
const template = `# scorecards.md — Endgame Results\n\n> Append-only. One line per closed endgame. Records outcome metrics from the horizon.\n\n---\n\n`;
|
|
@@ -263,7 +301,7 @@ function readScorecards(atrisDir) {
|
|
|
263
301
|
const scorecards = [];
|
|
264
302
|
|
|
265
303
|
for (const line of content.split('\n')) {
|
|
266
|
-
const match = line.match(/^- \*\*\[(.+?)\]\s+(.+?)\*\*\s*—\s*shipped:\s*(\d+)\/(\d+)\s*—\s*wall-clock:\s*(.+?)\s*—\s*halt:\s*(\d+)%\s*—\s*reward:\s*(
|
|
304
|
+
const match = line.match(/^- \*\*\[(.+?)\]\s+(.+?)\*\*\s*—\s*shipped:\s*(\d+)\/(\d+)\s*—\s*wall-clock:\s*(.+?)\s*—\s*halt:\s*(\d+)%\s*—\s*reward:\s*(-?\d+)\s*—\s*lessons:\s*(\d+)$/);
|
|
267
305
|
if (!match) continue;
|
|
268
306
|
|
|
269
307
|
const [, endDate, slug, shipped, attempted, wallClockStr, haltPercent, reward, lessons] = match;
|