atris 3.0.1 → 3.2.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.
@@ -0,0 +1,301 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const { parseTodo } = require('./todo');
4
+
5
+ const PRIVATE_MEMORY_ROOT = '.atris/presidio';
6
+
7
+ function ensurePrivateMemoryDir(atrisDir) {
8
+ const privateDir = path.join(path.dirname(atrisDir), PRIVATE_MEMORY_ROOT);
9
+ fs.mkdirSync(privateDir, { recursive: true });
10
+ return privateDir;
11
+ }
12
+
13
+ function getScorecardsPath(atrisDir) {
14
+ return path.join(ensurePrivateMemoryDir(atrisDir), 'scorecards.md');
15
+ }
16
+
17
+ function parsePickedAt(value) {
18
+ if (!value) return null;
19
+ const match = String(value).trim().match(/^(\d{4}-\d{2}-\d{2})(?:\s+(\d{2}:\d{2}))?/);
20
+ if (!match) return null;
21
+
22
+ const [, datePart, timePart = '00:00'] = match;
23
+ const parsed = new Date(`${datePart}T${timePart}:00`);
24
+ return Number.isNaN(parsed.getTime()) ? null : parsed;
25
+ }
26
+
27
+ function parseTickDate(dateStr, timeLabel) {
28
+ const match = String(timeLabel || '').trim().toLowerCase().match(/^(\d{1,2}):(\d{2})(?:\s*(am|pm))?$/);
29
+ if (!match) return null;
30
+
31
+ let hours = parseInt(match[1], 10);
32
+ const minutes = parseInt(match[2], 10);
33
+ const meridiem = match[3] || null;
34
+
35
+ if (meridiem === 'pm' && hours !== 12) hours += 12;
36
+ if (meridiem === 'am' && hours === 12) hours = 0;
37
+
38
+ const parsed = new Date(`${dateStr}T00:00:00`);
39
+ parsed.setHours(hours, minutes, 0, 0);
40
+ return Number.isNaN(parsed.getTime()) ? null : parsed;
41
+ }
42
+
43
+ function listLogFiles(atrisDir, startDate, endDate = new Date()) {
44
+ const logsDir = path.join(atrisDir, 'logs');
45
+ if (!fs.existsSync(logsDir)) return [];
46
+
47
+ const startKey = startDate.toISOString().slice(0, 10);
48
+ const endKey = endDate.toISOString().slice(0, 10);
49
+ const files = [];
50
+
51
+ for (const year of fs.readdirSync(logsDir)) {
52
+ const yearDir = path.join(logsDir, year);
53
+ let stat;
54
+ try {
55
+ stat = fs.statSync(yearDir);
56
+ } catch {
57
+ continue;
58
+ }
59
+ if (!stat.isDirectory()) continue;
60
+
61
+ for (const entry of fs.readdirSync(yearDir)) {
62
+ if (!entry.endsWith('.md')) continue;
63
+ const dateKey = entry.replace(/\.md$/, '');
64
+ if (dateKey < startKey || dateKey > endKey) continue;
65
+ files.push({ dateKey, file: path.join(yearDir, entry) });
66
+ }
67
+ }
68
+
69
+ files.sort((a, b) => a.dateKey.localeCompare(b.dateKey));
70
+ return files;
71
+ }
72
+
73
+ function readNotesSection(content) {
74
+ const match = String(content || '').match(/## Notes\n([\s\S]*?)(?=\n##\s|$)/);
75
+ return match ? match[1] : '';
76
+ }
77
+
78
+ function collectRewardStats(atrisDir, pickedAt) {
79
+ const startAt = parsePickedAt(pickedAt);
80
+ if (!startAt) {
81
+ return { totalReward: 0, totalTicks: 0, haltedTicks: 0 };
82
+ }
83
+
84
+ let totalReward = 0;
85
+ let totalTicks = 0;
86
+ let haltedTicks = 0;
87
+
88
+ for (const { dateKey, file } of listLogFiles(atrisDir, startAt)) {
89
+ const notes = readNotesSection(fs.readFileSync(file, 'utf8'));
90
+ if (!notes) continue;
91
+
92
+ const lines = notes.split('\n');
93
+ for (let i = 0; i < lines.length; i++) {
94
+ const headerMatch = lines[i].match(/^- (\d{1,2}:\d{2}(?:\s*[ap]m)?)$/i);
95
+ if (!headerMatch) continue;
96
+
97
+ const tickAt = parseTickDate(dateKey, headerMatch[1]);
98
+ if (!tickAt || tickAt < startAt) continue;
99
+
100
+ let reward = null;
101
+ let halted = false;
102
+ let j = i + 1;
103
+ for (; j < lines.length; j++) {
104
+ const current = lines[j];
105
+ if (j > i + 1 && /^- (\d{1,2}:\d{2}(?:\s*[ap]m)?)$/i.test(current)) break;
106
+ if (current && !current.startsWith(' ')) break;
107
+
108
+ const trimmed = current.trim();
109
+ if (!trimmed) continue;
110
+ if (/^Reward:\s*-?\d+$/i.test(trimmed)) {
111
+ reward = parseInt(trimmed.replace(/^Reward:\s*/i, ''), 10);
112
+ }
113
+ if (/review flagged issues|verify failed|hit an error|stopped for a manual check/i.test(trimmed)) {
114
+ halted = true;
115
+ }
116
+ }
117
+
118
+ if (reward !== null) {
119
+ totalReward += reward;
120
+ totalTicks += 1;
121
+ if (halted) haltedTicks += 1;
122
+ }
123
+
124
+ i = j - 1;
125
+ }
126
+ }
127
+
128
+ return { totalReward, totalTicks, haltedTicks };
129
+ }
130
+
131
+ function countLessonsGenerated(atrisDir, pickedAt) {
132
+ const startAt = parsePickedAt(pickedAt);
133
+ if (!startAt) return 0;
134
+
135
+ const lessonsPath = path.join(atrisDir, 'lessons.md');
136
+ if (!fs.existsSync(lessonsPath)) return 0;
137
+
138
+ const startDateKey = startAt.toISOString().slice(0, 10);
139
+ return fs.readFileSync(lessonsPath, 'utf8')
140
+ .split('\n')
141
+ .reduce((count, line) => {
142
+ const match = line.match(/^- \*\*\[(\d{4}-\d{2}-\d{2})\]/);
143
+ return match && match[1] >= startDateKey ? count + 1 : count;
144
+ }, 0);
145
+ }
146
+
147
+ function buildScorecardData(atrisDir, { slug, pickedAt } = {}) {
148
+ if (!slug) {
149
+ throw new Error('Scorecard: slug is required');
150
+ }
151
+
152
+ const todoPath = path.join(atrisDir, 'TODO.md');
153
+ const todo = parseTodo(todoPath);
154
+ const startAt = parsePickedAt(pickedAt) || new Date();
155
+ const rewardStats = collectRewardStats(atrisDir, pickedAt);
156
+ const completedEndgame = todo.completed.filter(t => t.tag === 'endgame').length;
157
+ const activeEndgame = todo.backlog.filter(t => t.tag === 'endgame').length
158
+ + todo.inProgress.filter(t => t.tag === 'endgame').length;
159
+
160
+ return {
161
+ slug,
162
+ startDate: startAt.toISOString().slice(0, 10),
163
+ endDate: new Date().toISOString().slice(0, 10),
164
+ tasksShipped: completedEndgame,
165
+ tasksAttempted: completedEndgame + activeEndgame,
166
+ wallClockHours: Math.max(0, (Date.now() - startAt.getTime()) / (1000 * 60 * 60)),
167
+ haltRatio: rewardStats.totalTicks > 0 ? rewardStats.haltedTicks / rewardStats.totalTicks : 0,
168
+ totalReward: rewardStats.totalReward,
169
+ lessonsGenerated: countLessonsGenerated(atrisDir, pickedAt),
170
+ };
171
+ }
172
+
173
+ /**
174
+ * Write a scorecard when an endgame closes.
175
+ *
176
+ * @param {string} atrisDir - Path to atris/ directory
177
+ * @param {object} data - Scorecard data
178
+ * - slug: endgame slug (e.g., "loop-self-seeds-horizons")
179
+ * - startDate: ISO date when endgame started
180
+ * - endDate: ISO date when endgame ended (default: today)
181
+ * - tasksShipped: number of tasks completed
182
+ * - tasksAttempted: number of tasks started
183
+ * - wallClockHours: total hours (float)
184
+ * - haltRatio: fraction of ticks that halted (e.g., 0.1)
185
+ * - totalReward: sum of per-tick reward scores
186
+ * - lessonsGenerated: number of lessons appended to lessons.md
187
+ */
188
+ function writeScorecard(atrisDir, data) {
189
+ const {
190
+ slug,
191
+ startDate,
192
+ endDate = new Date().toISOString().split('T')[0],
193
+ tasksShipped = 0,
194
+ tasksAttempted = 0,
195
+ wallClockHours = 0,
196
+ haltRatio = 0,
197
+ totalReward = 0,
198
+ lessonsGenerated = 0,
199
+ } = data;
200
+
201
+ // Validate required fields
202
+ if (!slug) {
203
+ throw new Error('Scorecard: slug is required');
204
+ }
205
+
206
+ const scorecardsPath = getScorecardsPath(atrisDir);
207
+
208
+ // Ensure scorecards.md exists
209
+ if (!fs.existsSync(scorecardsPath)) {
210
+ 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`;
211
+ fs.writeFileSync(scorecardsPath, template, 'utf8');
212
+ }
213
+
214
+ // Format: - **[date] slug** — shipped: X/Y — wall-clock: Nh — halt: Z% — reward: total — lessons: N
215
+ const haltPercent = Math.round(haltRatio * 100);
216
+ const wallClockStr = wallClockHours < 1 ? `${Math.round(wallClockHours * 60)}m` : `${wallClockHours.toFixed(1)}h`;
217
+ const line = `- **[${endDate}] ${slug}** — shipped: ${tasksShipped}/${tasksAttempted} — wall-clock: ${wallClockStr} — halt: ${haltPercent}% — reward: ${totalReward} — lessons: ${lessonsGenerated}\n`;
218
+
219
+ // Append to file
220
+ fs.appendFileSync(scorecardsPath, line, 'utf8');
221
+ }
222
+
223
+ /**
224
+ * Detect if the current endgame in TODO.md is complete (all endgame tasks in Completed).
225
+ * Returns { complete: boolean, endgameSlug: string | null }
226
+ */
227
+ function detectEndgameCompletion(atrisDir) {
228
+ const todoPath = path.join(atrisDir, 'TODO.md');
229
+ if (!fs.existsSync(todoPath)) {
230
+ return { complete: false, endgameSlug: null };
231
+ }
232
+
233
+ const todo = parseTodo(todoPath);
234
+
235
+ // Find the current endgame section
236
+ const endgameSectionMatch = fs.readFileSync(todoPath, 'utf8')
237
+ .match(/## Endgame\n\n\*\*Slug:\*\*\s*(\S+)/);
238
+
239
+ if (!endgameSectionMatch) {
240
+ return { complete: false, endgameSlug: null };
241
+ }
242
+
243
+ const slug = endgameSectionMatch[1];
244
+
245
+ // Check if there are any endgame-tagged tasks in backlog or in-progress
246
+ const hasActiveEndgame = todo.backlog.some(t => t.tag === 'endgame')
247
+ || todo.inProgress.some(t => t.tag === 'endgame');
248
+
249
+ return {
250
+ complete: !hasActiveEndgame,
251
+ endgameSlug: slug,
252
+ };
253
+ }
254
+
255
+ /**
256
+ * Parse scorecards.md and return array of scorecard objects.
257
+ */
258
+ function readScorecards(atrisDir) {
259
+ const scorecardsPath = getScorecardsPath(atrisDir);
260
+ if (!fs.existsSync(scorecardsPath)) return [];
261
+
262
+ const content = fs.readFileSync(scorecardsPath, 'utf8');
263
+ const scorecards = [];
264
+
265
+ 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*(\d+)\s*—\s*lessons:\s*(\d+)$/);
267
+ if (!match) continue;
268
+
269
+ const [, endDate, slug, shipped, attempted, wallClockStr, haltPercent, reward, lessons] = match;
270
+
271
+ // Parse wall-clock back to hours
272
+ let wallClockHours = 0;
273
+ if (wallClockStr.endsWith('h')) {
274
+ wallClockHours = parseFloat(wallClockStr);
275
+ } else if (wallClockStr.endsWith('m')) {
276
+ wallClockHours = parseInt(wallClockStr) / 60;
277
+ }
278
+
279
+ scorecards.push({
280
+ endDate,
281
+ slug,
282
+ tasksShipped: parseInt(shipped),
283
+ tasksAttempted: parseInt(attempted),
284
+ wallClockHours,
285
+ haltRatio: parseInt(haltPercent) / 100,
286
+ totalReward: parseInt(reward),
287
+ lessonsGenerated: parseInt(lessons),
288
+ });
289
+ }
290
+
291
+ return scorecards;
292
+ }
293
+
294
+ module.exports = {
295
+ PRIVATE_MEMORY_ROOT,
296
+ getScorecardsPath,
297
+ buildScorecardData,
298
+ writeScorecard,
299
+ readScorecards,
300
+ detectEndgameCompletion,
301
+ };
package/lib/todo.js CHANGED
@@ -33,8 +33,8 @@ function parseSection(content, sectionName) {
33
33
  const line = rawLine.trimEnd();
34
34
 
35
35
  // New task line: - **T1:** Description or - **T1a:** Description [tag] [tag]
36
- // Accepts task IDs like T1, W3b, M12c single uppercase letter, digits, optional trailing lowercase letter.
37
- const taskMatch = line.match(/^- \*\*([A-Z]\d+[a-z]?):\*\*\s*(.+)$/);
36
+ // Accepts task IDs like T1, W3b, M12c, R1, T#1 letter(s), optional symbols, digits, optional trailing letter.
37
+ const taskMatch = line.match(/^- \*\*([A-Za-z][A-Za-z0-9#]*\d[a-z]?):\*\*\s*(.+)$/);
38
38
  if (taskMatch) {
39
39
  if (current) tasks.push(current);
40
40
  // Capture ALL bracketed tags in the line, not just the last one. Endgame is priority.
@@ -47,6 +47,7 @@ function parseSection(content, sectionName) {
47
47
  tags: allTags,
48
48
  claimed: null,
49
49
  stage: null,
50
+ verify: null,
50
51
  };
51
52
  continue;
52
53
  }
@@ -60,6 +61,7 @@ function parseSection(content, sectionName) {
60
61
  tag: null,
61
62
  claimed: null,
62
63
  stage: null,
64
+ verify: null,
63
65
  });
64
66
  continue;
65
67
  }
@@ -73,6 +75,7 @@ function parseSection(content, sectionName) {
73
75
  tag: null,
74
76
  claimed: null,
75
77
  stage: null,
78
+ verify: null,
76
79
  });
77
80
  continue;
78
81
  }
@@ -92,6 +95,13 @@ function parseSection(content, sectionName) {
92
95
  current.stage = stageMatch[1].trim();
93
96
  continue;
94
97
  }
98
+
99
+ // Verify line
100
+ const verifyMatch = line.match(/\*\*Verify:\*\*\s*(.+)$/) || line.match(/Verify:\s*(.+)$/);
101
+ if (verifyMatch) {
102
+ current.verify = verifyMatch[1].trim();
103
+ continue;
104
+ }
95
105
  }
96
106
 
97
107
  if (current) tasks.push(current);
package/lib/wiki.js CHANGED
@@ -2,6 +2,7 @@ const fs = require('fs');
2
2
  const path = require('path');
3
3
 
4
4
  const WIKI_ROOT = 'atris/wiki';
5
+ const PRIVATE_WIKI_ROOT = '.atris/presidio';
5
6
  const LEGACY_WIKI_ROOT = 'wiki';
6
7
  const WIKI_BRIEFS_SUBDIR = 'briefs';
7
8
  const LEGACY_WIKI_BRIEFS_SUBDIR = 'syntheses';
@@ -9,6 +10,14 @@ const WIKI_SUBDIRS = ['people', 'systems', 'concepts', WIKI_BRIEFS_SUBDIR];
9
10
  const WIKI_STATUS_FILE = 'STATUS.md';
10
11
  const WIKI_CONTENT_SUBDIRS = WIKI_SUBDIRS.map((subdir) => path.join(WIKI_ROOT, subdir));
11
12
 
13
+ function getWikiRoot(mode = 'public') {
14
+ return mode === 'private' ? PRIVATE_WIKI_ROOT : WIKI_ROOT;
15
+ }
16
+
17
+ function getWikiLinkRoot(mode = 'public') {
18
+ return mode === 'private' ? PRIVATE_WIKI_ROOT : 'atris/wiki';
19
+ }
20
+
12
21
  function today() {
13
22
  return new Date().toISOString().slice(0, 10);
14
23
  }
@@ -17,10 +26,12 @@ function nowTime() {
17
26
  return new Date().toTimeString().slice(0, 5);
18
27
  }
19
28
 
20
- function protocolMarkdown() {
29
+ function protocolMarkdown(mode = 'public') {
30
+ const wikiRoot = getWikiRoot(mode);
31
+ const wikiLinkRoot = getWikiLinkRoot(mode);
21
32
  return `# Atris Wiki Protocol
22
33
 
23
- This wiki lives in \`${WIKI_ROOT}/\`.
34
+ This wiki lives in \`${wikiRoot}/\`.
24
35
 
25
36
  ## Purpose
26
37
 
@@ -28,20 +39,20 @@ Turn raw project context into a living memory the next agent can pick up cold.
28
39
 
29
40
  ## Shape
30
41
 
31
- - \`${WIKI_ROOT}/wiki.md\` - this protocol
32
- - \`${WIKI_ROOT}/index.md\` - catalog grouped by page type
33
- - \`${WIKI_ROOT}/log.md\` - append-only ingest and lint history
34
- - \`${WIKI_ROOT}/STATUS.md\` - plain-English health summary
35
- - \`${WIKI_ROOT}/people/\` - humans (employees, contacts, stakeholders)
36
- - \`${WIKI_ROOT}/systems/\` - tools, tables, dashboards, services, products
37
- - \`${WIKI_ROOT}/concepts/\` - patterns, frameworks, recurring ideas
38
- - \`${WIKI_ROOT}/${WIKI_BRIEFS_SUBDIR}/\` - multi-page briefs and cross-cutting analysis
42
+ - \`${wikiRoot}/wiki.md\` - this protocol
43
+ - \`${wikiRoot}/index.md\` - catalog grouped by page type
44
+ - \`${wikiRoot}/log.md\` - append-only ingest and lint history
45
+ - \`${wikiRoot}/STATUS.md\` - plain-English health summary
46
+ - \`${wikiRoot}/people/\` - humans (employees, contacts, stakeholders)
47
+ - \`${wikiRoot}/systems/\` - tools, tables, dashboards, services, products
48
+ - \`${wikiRoot}/concepts/\` - patterns, frameworks, recurring ideas
49
+ - \`${wikiRoot}/${WIKI_BRIEFS_SUBDIR}/\` - multi-page briefs and cross-cutting analysis
39
50
 
40
51
  ## Rules
41
52
 
42
53
  - Read the full source before writing.
43
54
  - Merge new facts into existing pages. Do not overwrite history blindly.
44
- - Add cross-references with \`[[atris/wiki/...]]\` links.
55
+ - Add cross-references with \`[[${wikiLinkRoot}/...]]\` links.
45
56
  - Keep \`index.md\`, \`log.md\`, and \`STATUS.md\` in sync with page changes.
46
57
  - If something is unclear or contradictory, say so directly.
47
58
  `;
@@ -155,15 +166,18 @@ function migrateLegacyBriefsDir(wikiDir) {
155
166
  rewriteLegacyWikiReferences(wikiDir);
156
167
  }
157
168
 
158
- function ensureWikiScaffold(projectRoot = process.cwd()) {
159
- const wikiDir = path.join(projectRoot, WIKI_ROOT);
169
+ function ensureWikiScaffold(projectRoot = process.cwd(), mode = 'public') {
170
+ const wikiRoot = getWikiRoot(mode);
171
+ const wikiDir = path.join(projectRoot, wikiRoot);
160
172
  fs.mkdirSync(wikiDir, { recursive: true });
161
- migrateLegacyBriefsDir(wikiDir);
173
+ if (mode === 'public') {
174
+ migrateLegacyBriefsDir(wikiDir);
175
+ }
162
176
  for (const subdir of WIKI_SUBDIRS) {
163
177
  fs.mkdirSync(path.join(wikiDir, subdir), { recursive: true });
164
178
  }
165
179
 
166
- ensureFile(path.join(wikiDir, 'wiki.md'), protocolMarkdown());
180
+ ensureFile(path.join(wikiDir, 'wiki.md'), protocolMarkdown(mode));
167
181
  ensureFile(path.join(wikiDir, 'index.md'), indexMarkdown());
168
182
  ensureFile(path.join(wikiDir, 'log.md'), logMarkdown());
169
183
  ensureFile(path.join(wikiDir, WIKI_STATUS_FILE), statusMarkdown());
@@ -171,7 +185,12 @@ function ensureWikiScaffold(projectRoot = process.cwd()) {
171
185
  return wikiDir;
172
186
  }
173
187
 
174
- function findLocalWikiDir(projectRoot = process.cwd(), slug = null) {
188
+ function findLocalWikiDir(projectRoot = process.cwd(), slug = null, mode = 'public') {
189
+ if (mode === 'private') {
190
+ const privateDir = path.join(projectRoot, PRIVATE_WIKI_ROOT);
191
+ return fs.existsSync(privateDir) ? privateDir : null;
192
+ }
193
+
175
194
  const tries = [
176
195
  path.join(projectRoot, WIKI_ROOT),
177
196
  path.join(projectRoot, LEGACY_WIKI_ROOT),
@@ -190,8 +209,8 @@ function normalizeWikiOnlyPrefix(prefix) {
190
209
  return null;
191
210
  }
192
211
 
193
- function readWikiStatus(projectRoot = process.cwd(), slug = null) {
194
- const wikiDir = findLocalWikiDir(projectRoot, slug);
212
+ function readWikiStatus(projectRoot = process.cwd(), slug = null, mode = 'public') {
213
+ const wikiDir = findLocalWikiDir(projectRoot, slug, mode);
195
214
  if (!wikiDir) return null;
196
215
 
197
216
  const statusPath = path.join(wikiDir, WIKI_STATUS_FILE);
@@ -275,8 +294,9 @@ function parseFrontmatter(content) {
275
294
  return frontmatter;
276
295
  }
277
296
 
278
- function readWikiPages(projectRoot = process.cwd()) {
279
- const wikiDir = path.join(projectRoot, WIKI_ROOT);
297
+ function readWikiPages(projectRoot = process.cwd(), mode = 'public') {
298
+ const wikiRoot = getWikiRoot(mode);
299
+ const wikiDir = path.join(projectRoot, wikiRoot);
280
300
  const pages = [];
281
301
 
282
302
  for (const subdir of WIKI_SUBDIRS) {
@@ -302,8 +322,8 @@ function normalizeSourcePath(projectRoot, source) {
302
322
  return path.normalize(path.join(projectRoot, source));
303
323
  }
304
324
 
305
- function findStaleWikiPages(projectRoot = process.cwd()) {
306
- return readWikiPages(projectRoot)
325
+ function findStaleWikiPages(projectRoot = process.cwd(), mode = 'public') {
326
+ return readWikiPages(projectRoot, mode)
307
327
  .map((page) => {
308
328
  const sources = Array.isArray(page.frontmatter.sources) ? page.frontmatter.sources : [];
309
329
  if (sources.length === 0) return null;
@@ -345,13 +365,14 @@ function findStaleWikiPages(projectRoot = process.cwd()) {
345
365
  }
346
366
 
347
367
  function extractWikiLinks(content) {
348
- const matches = content.match(/\[\[(atris\/wiki\/[^\]]+?)\]\]/g) || [];
368
+ const matches = content.match(/\[\[((?:atris\/wiki|\.atris\/presidio)\/[^\]]+?)\]\]/g) || [];
349
369
  return matches.map((match) => match.slice(2, -2));
350
370
  }
351
371
 
352
- function findWikiOrphans(projectRoot = process.cwd()) {
353
- const pages = readWikiPages(projectRoot);
354
- const indexPath = path.join(projectRoot, WIKI_ROOT, 'index.md');
372
+ function findWikiOrphans(projectRoot = process.cwd(), mode = 'public') {
373
+ const wikiRoot = getWikiRoot(mode);
374
+ const pages = readWikiPages(projectRoot, mode);
375
+ const indexPath = path.join(projectRoot, wikiRoot, 'index.md');
355
376
  const indexContent = fs.existsSync(indexPath) ? fs.readFileSync(indexPath, 'utf8') : '';
356
377
 
357
378
  const inboundLinks = new Map();
@@ -423,8 +444,8 @@ function parseStatusBullets(content) {
423
444
  return bullets;
424
445
  }
425
446
 
426
- function writeWikiStatus(projectRoot = process.cwd(), report) {
427
- const wikiDir = ensureWikiScaffold(projectRoot);
447
+ function writeWikiStatus(projectRoot = process.cwd(), report, mode = 'public') {
448
+ const wikiDir = ensureWikiScaffold(projectRoot, mode);
428
449
  const statusPath = path.join(wikiDir, WIKI_STATUS_FILE);
429
450
  const existing = fs.existsSync(statusPath) ? fs.readFileSync(statusPath, 'utf8') : '';
430
451
  const bullets = parseStatusBullets(existing);
@@ -444,8 +465,8 @@ function writeWikiStatus(projectRoot = process.cwd(), report) {
444
465
  return statusPath;
445
466
  }
446
467
 
447
- function appendWikiLog(projectRoot = process.cwd(), summary, details = []) {
448
- const wikiDir = ensureWikiScaffold(projectRoot);
468
+ function appendWikiLog(projectRoot = process.cwd(), summary, details = [], mode = 'public') {
469
+ const wikiDir = ensureWikiScaffold(projectRoot, mode);
449
470
  const logPath = path.join(wikiDir, 'log.md');
450
471
  let content = fs.existsSync(logPath) ? fs.readFileSync(logPath, 'utf8') : '# Atris Wiki Log\n';
451
472
  const dateHeader = `## ${today()}`;
@@ -471,17 +492,20 @@ function formatSourceList(sourceValue) {
471
492
  .join(', ');
472
493
  }
473
494
 
474
- const WIKI_SCHEMA = `The wiki lives in ${WIKI_ROOT}/.
495
+ function buildWikiSchema(mode = 'public') {
496
+ const wikiRoot = getWikiRoot(mode);
497
+ const wikiLinkRoot = getWikiLinkRoot(mode);
498
+ return `The wiki lives in ${wikiRoot}/.
475
499
 
476
500
  Structure:
477
- - ${WIKI_ROOT}/wiki.md - protocol for future agents
478
- - ${WIKI_ROOT}/index.md - catalog grouped by type
479
- - ${WIKI_ROOT}/log.md - append-only activity log
480
- - ${WIKI_ROOT}/STATUS.md - plain-English health summary
481
- - ${WIKI_ROOT}/people/ - one page per human
482
- - ${WIKI_ROOT}/systems/ - one page per tool, table, dashboard, service, or product
483
- - ${WIKI_ROOT}/concepts/ - pattern and framework pages
484
- - ${WIKI_ROOT}/${WIKI_BRIEFS_SUBDIR}/ - cross-cutting briefs referencing 3+ pages
501
+ - ${wikiRoot}/wiki.md - protocol for future agents
502
+ - ${wikiRoot}/index.md - catalog grouped by type
503
+ - ${wikiRoot}/log.md - append-only activity log
504
+ - ${wikiRoot}/STATUS.md - plain-English health summary
505
+ - ${wikiRoot}/people/ - one page per human
506
+ - ${wikiRoot}/systems/ - one page per tool, table, dashboard, service, or product
507
+ - ${wikiRoot}/concepts/ - pattern and framework pages
508
+ - ${wikiRoot}/${WIKI_BRIEFS_SUBDIR}/ - cross-cutting briefs referencing 3+ pages
485
509
 
486
510
  Page format:
487
511
  ---
@@ -497,7 +521,7 @@ tags: [tag1, tag2]
497
521
  # Title
498
522
  Body in markdown.
499
523
  ## Cross-References
500
- - [[atris/wiki/people/related.md]] - why related
524
+ - [[${wikiLinkRoot}/people/related.md]] - why related
501
525
 
502
526
  Rules:
503
527
  - Read every listed source fully before writing
@@ -505,20 +529,23 @@ Rules:
505
529
  - Keep index.md, log.md, and STATUS.md current
506
530
  - Flag contradictions directly instead of smoothing them over
507
531
  - Never modify the raw source documents you ingested`;
532
+ }
508
533
 
509
- function buildIngestPrompt(sourceValue) {
534
+ function buildIngestPrompt(sourceValue, mode = 'public') {
535
+ const wikiRoot = getWikiRoot(mode);
536
+ const wikiLinkRoot = getWikiLinkRoot(mode);
510
537
  return `Atris wiki ingest: ${formatSourceList(sourceValue)}
511
- ${WIKI_SCHEMA}
538
+ ${buildWikiSchema(mode)}
512
539
 
513
540
  Workflow:
514
541
  1. Read every source in: ${sourceValue}
515
- 2. Ensure ${WIKI_ROOT}/ exists with wiki.md, index.md, log.md, STATUS.md, and the 3 page subfolders
542
+ 2. Ensure ${wikiRoot}/ exists with wiki.md, index.md, log.md, STATUS.md, and the 3 page subfolders
516
543
  3. Extract people, systems, and concepts worth preserving
517
- 4. Create or update pages under ${WIKI_ROOT}/, merging with existing facts instead of replacing them
518
- 5. Add cross-references using [[atris/wiki/...]] links
519
- 6. Update ${WIKI_ROOT}/index.md with one-line descriptions of touched pages
520
- 7. Append an INGEST entry to ${WIKI_ROOT}/log.md under today's date
521
- 8. Refresh ${WIKI_ROOT}/STATUS.md in plain English for a non-technical reader
544
+ 4. Create or update pages under ${wikiRoot}/, merging with existing facts instead of replacing them
545
+ 5. Add cross-references using [[${wikiLinkRoot}/...]] links
546
+ 6. Update ${wikiRoot}/index.md with one-line descriptions of touched pages
547
+ 7. Append an INGEST entry to ${wikiRoot}/log.md under today's date
548
+ 8. Refresh ${wikiRoot}/STATUS.md in plain English for a non-technical reader
522
549
 
523
550
  Quality bar:
524
551
  - Ask clarifying questions if the source is ambiguous
@@ -527,18 +554,20 @@ Quality bar:
527
554
  - Leave the wiki sharper than you found it`;
528
555
  }
529
556
 
530
- function buildQueryPrompt(question) {
557
+ function buildQueryPrompt(question, mode = 'public') {
558
+ const wikiRoot = getWikiRoot(mode);
531
559
  return `Atris wiki query: ${question}
532
560
 
533
- Read ${WIKI_ROOT}/index.md first, then the most relevant pages.
534
- Answer from the wiki with direct references to page paths under ${WIKI_ROOT}/.
561
+ Read ${wikiRoot}/index.md first, then the most relevant pages.
562
+ Answer from the wiki with direct references to page paths under ${wikiRoot}/.
535
563
  If the answer reveals a reusable insight, offer to save it as a brief page.`;
536
564
  }
537
565
 
538
- function buildLintPrompt() {
566
+ function buildLintPrompt(mode = 'public') {
567
+ const wikiRoot = getWikiRoot(mode);
539
568
  return `Atris wiki lint pass
540
569
 
541
- Read ${WIKI_ROOT}/index.md, crawl the referenced pages, and inspect the local wiki.
570
+ Read ${wikiRoot}/index.md, crawl the referenced pages, and inspect the local wiki.
542
571
 
543
572
  Checks:
544
573
  1. Every page referenced by index.md exists
@@ -546,8 +575,8 @@ Checks:
546
575
  3. Orphan pages are listed
547
576
  4. Contradictions are called out plainly
548
577
  5. Gaps worth ingesting next are listed concretely
549
- 6. ${WIKI_ROOT}/STATUS.md is rewritten in plain English
550
- 7. ${WIKI_ROOT}/log.md gets a LINT entry under today's date
578
+ 6. ${wikiRoot}/STATUS.md is rewritten in plain English
579
+ 7. ${wikiRoot}/log.md gets a LINT entry under today's date
551
580
 
552
581
  Output:
553
582
  - Clear summary for a non-technical reader
@@ -557,11 +586,13 @@ Output:
557
586
 
558
587
  module.exports = {
559
588
  WIKI_ROOT,
589
+ PRIVATE_WIKI_ROOT,
560
590
  LEGACY_WIKI_ROOT,
561
591
  WIKI_SUBDIRS,
562
592
  WIKI_CONTENT_SUBDIRS,
563
- WIKI_SCHEMA,
593
+ WIKI_SCHEMA: buildWikiSchema(),
564
594
  WIKI_STATUS_FILE,
595
+ getWikiRoot,
565
596
  ensureWikiScaffold,
566
597
  findLocalWikiDir,
567
598
  normalizeWikiOnlyPrefix,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "atris",
3
- "version": "3.0.1",
3
+ "version": "3.2.0",
4
4
  "description": "Atris — an operating system for intelligence. Integrates with any agent.",
5
5
  "main": "bin/atris.js",
6
6
  "bin": {
@@ -32,7 +32,8 @@
32
32
  "atris/skills/"
33
33
  ],
34
34
  "scripts": {
35
- "test": "node --test"
35
+ "test": "node --test",
36
+ "prepare": "cp scripts/pre-commit .git/hooks/pre-commit 2>/dev/null && chmod +x .git/hooks/pre-commit || true"
36
37
  },
37
38
  "keywords": [
38
39
  "atrisdev",