atris 3.56.0 → 3.56.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -15,6 +15,12 @@ function isHelpArg(arg) {
15
15
  return arg === '--help' || arg === '-h' || arg === 'help';
16
16
  }
17
17
 
18
+ function looksLikeBusinessSlug(token) {
19
+ const value = String(token || '');
20
+ if (!value || value.startsWith('-')) return false;
21
+ return /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/i.test(value);
22
+ }
23
+
18
24
  /**
19
25
  * Account-scoped verbs dump live CRM/cloud state. Outside a bound business
20
26
  * workspace they must opt in with --account. Help always passes.
@@ -42,6 +48,7 @@ function refuseAccountGlobal(write = console.error) {
42
48
 
43
49
  module.exports = {
44
50
  ACCOUNT_GLOBAL_MESSAGE,
51
+ looksLikeBusinessSlug,
45
52
  requireAccountBound,
46
53
  refuseAccountGlobal,
47
54
  };
@@ -0,0 +1,94 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+
6
+ function dateStamp(now) {
7
+ if (typeof now === 'string' && /^\d{4}-\d{2}-\d{2}/.test(now)) {
8
+ return now.slice(0, 10);
9
+ }
10
+ const value = now instanceof Date ? now : new Date(now || Date.now());
11
+ if (Number.isNaN(value.getTime())) return new Date().toISOString().slice(0, 10);
12
+ return value.toISOString().slice(0, 10);
13
+ }
14
+
15
+ function parseApplyFields(text) {
16
+ const change = String(text || '').match(/^change:\s*(.+)$/im);
17
+ const receipt = String(text || '').match(/^receipt:\s*(.+)$/im);
18
+ return {
19
+ change: change ? change[1].trim() : '',
20
+ receipt: receipt ? receipt[1].trim() : '',
21
+ };
22
+ }
23
+
24
+ function isFilledApply(fields) {
25
+ const empty = (value) => !value || /^fill this$/i.test(value);
26
+ return !empty(fields.change) && !empty(fields.receipt);
27
+ }
28
+
29
+ function applySlug(text) {
30
+ const slug = String(text || '')
31
+ .toLowerCase()
32
+ .replace(/[^a-z0-9]+/g, '-')
33
+ .replace(/^-+|-+$/g, '')
34
+ .slice(0, 48);
35
+ return slug || 'query';
36
+ }
37
+
38
+ function applySidecarRel(kind, id) {
39
+ return `atris/wiki/briefs/${kind}-${id}.apply.md`;
40
+ }
41
+
42
+ function readApplyReceipt({ cwd, rel } = {}) {
43
+ if (!rel || !cwd) return null;
44
+ const abs = path.join(cwd, rel);
45
+ if (!fs.existsSync(abs)) return null;
46
+ return { rel, text: fs.readFileSync(abs, 'utf8') };
47
+ }
48
+
49
+ function writeApplyStub({ cwd, source, rel, now } = {}) {
50
+ try {
51
+ if (!rel || !cwd) return null;
52
+ const wikiDir = path.join(cwd, 'atris', 'wiki');
53
+ if (!fs.existsSync(wikiDir)) return null;
54
+
55
+ const abs = path.join(cwd, rel);
56
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
57
+ if (!fs.existsSync(abs)) {
58
+ fs.writeFileSync(abs, [
59
+ `source: ${source}`,
60
+ 'change: fill this',
61
+ 'receipt: fill this',
62
+ ].join('\n') + '\n');
63
+ }
64
+
65
+ const date = dateStamp(now);
66
+ const journalPath = path.join(cwd, 'atris', 'logs', date.slice(0, 4), `${date}.md`);
67
+ fs.mkdirSync(path.dirname(journalPath), { recursive: true });
68
+ let existing = '';
69
+ if (fs.existsSync(journalPath)) existing = fs.readFileSync(journalPath, 'utf8');
70
+ const line = `- [claimable] apply: fill this -> ${rel}`;
71
+ if (!existing.includes(line)) {
72
+ const prefix = existing && !existing.endsWith('\n') ? '\n' : '';
73
+ fs.writeFileSync(journalPath, `${existing}${prefix}${line}\n`);
74
+ }
75
+ return rel;
76
+ } catch {
77
+ return null;
78
+ }
79
+ }
80
+
81
+ function ensureApply({ cwd, source, rel, now, output, incompleteMessage, required = true } = {}) {
82
+ const print = typeof output === 'function' ? output : (line = '') => console.error(line);
83
+ const existing = readApplyReceipt({ cwd, rel });
84
+ if (existing && isFilledApply(parseApplyFields(existing.text))) return 0;
85
+ if (!(required && existing)) writeApplyStub({ cwd, source, rel, now });
86
+ print(incompleteMessage);
87
+ return required ? 2 : 0;
88
+ }
89
+
90
+ module.exports = {
91
+ applySlug,
92
+ applySidecarRel,
93
+ ensureApply,
94
+ };
@@ -54,9 +54,11 @@ function isFlagLikeAnswer(answer) {
54
54
  return tokens.every((token) => token.startsWith('-') || token === 'help');
55
55
  }
56
56
 
57
- function starterTaskTitle(answer) {
58
- const summary = compactText(answer, 80) || 'first useful path';
59
- return `First useful step: ${summary}`;
57
+ function starterTaskTitle(answer, folder = 'this folder') {
58
+ const room = folder || 'this folder';
59
+ const { isFirstTalkLine } = require('./first-minute');
60
+ if (isFirstTalkLine(answer)) return room;
61
+ return compactText(answer, 80) || room;
60
62
  }
61
63
 
62
64
  function saveContextProfile(root, answer, { source = 'first_contact' } = {}) {
@@ -77,29 +79,71 @@ function saveContextProfile(root, answer, { source = 'first_contact' } = {}) {
77
79
  return profile;
78
80
  }
79
81
 
82
+ function runSilentMinimalInit(root = process.cwd()) {
83
+ const { spawnSync } = require('child_process');
84
+ const script = process.argv[1] || path.join(__dirname, '..', 'bin', 'atris.js');
85
+ const result = spawnSync(process.execPath, [script, 'init', '--minimal', '--yes'], {
86
+ cwd: root,
87
+ env: process.env,
88
+ encoding: 'utf8',
89
+ stdio: ['ignore', 'pipe', 'pipe'],
90
+ });
91
+ return Number.isInteger(result.status) ? result.status : 1;
92
+ }
93
+
94
+ function startFirstTalk(root, answer, { asJson = false, log = console.log } = {}) {
95
+ const {
96
+ firstTalkJson,
97
+ folderName,
98
+ personName,
99
+ renderFirstTalk,
100
+ } = require('./first-minute');
101
+ const initStatus = runSilentMinimalInit(root);
102
+ if (initStatus !== 0) return initStatus;
103
+ saveContextProfile(root, answer, { source: 'first_talk' });
104
+ const starter = createStarterTask(root, answer);
105
+ const room = folderName(root);
106
+ const who = personName();
107
+ if (asJson) {
108
+ log(JSON.stringify(firstTalkJson({ starter, person: who, folder: room }), null, 2));
109
+ return 0;
110
+ }
111
+ log('');
112
+ log(renderFirstTalk({ person: who, folder: room, starter }));
113
+ return 0;
114
+ }
115
+
80
116
  function createStarterTask(root, answer) {
81
117
  if (isFlagLikeAnswer(answer)) return null;
82
118
  const atrisDir = path.join(root, 'atris');
83
119
  if (!fs.existsSync(atrisDir)) return null;
120
+ const { folderName, personName } = require('./first-minute');
121
+ const title = starterTaskTitle(answer, folderName(root));
122
+ const who = personName() || 'operator';
84
123
  try {
85
124
  const taskDb = require('./task-db');
86
125
  const db = taskDb.open();
87
126
  const workspaceRoot = taskDb.workspaceRoot(root);
88
- const title = starterTaskTitle(answer);
89
127
  const sourceKey = taskDb.sourceKey('context-gatherer:first-task', title);
90
128
  const added = taskDb.addTask(db, {
91
129
  title,
92
130
  tag: 'onboarding',
93
131
  workspaceRoot,
94
132
  sourceKey,
133
+ status: 'claimed',
134
+ claimedBy: who,
95
135
  metadata: {
96
136
  source: 'context_gatherer',
97
137
  first_answer: compactText(answer, 500),
98
138
  },
99
139
  });
100
- const rows = taskDb.listTasks(db, { workspaceRoot });
101
- const displayRows = taskDb.withTaskDisplayRefs(rows);
102
- const task = displayRows.find(row => row.id === added.id) || null;
140
+ let rows = taskDb.listTasks(db, { workspaceRoot });
141
+ let task = taskDb.withTaskDisplayRefs(rows).find(row => row.id === added.id) || null;
142
+ if (task && task.status === 'open') {
143
+ taskDb.claimTask(db, { id: added.id, claimedBy: who });
144
+ rows = taskDb.listTasks(db, { workspaceRoot });
145
+ task = taskDb.withTaskDisplayRefs(rows).find(row => row.id === added.id) || task;
146
+ }
103
147
  try {
104
148
  const todoPath = path.join(root, 'atris', 'TODO.md');
105
149
  fs.writeFileSync(todoPath, taskDb.renderTodoMarkdown(rows, { title: 'TODO.md' }), 'utf8');
@@ -109,11 +153,13 @@ function createStarterTask(root, answer) {
109
153
  inserted: added.inserted,
110
154
  display_id: task && task.display_id || null,
111
155
  title,
156
+ status: task && task.status || 'claimed',
157
+ claimed_by: (task && task.claimed_by) || who,
112
158
  };
113
159
  } catch (error) {
114
160
  return {
115
161
  error: error && error.message ? error.message : String(error),
116
- title: starterTaskTitle(answer),
162
+ title,
117
163
  };
118
164
  }
119
165
  }
@@ -185,6 +231,7 @@ module.exports = {
185
231
  hasContextProfile,
186
232
  saveContextProfile,
187
233
  createStarterTask,
234
+ startFirstTalk,
188
235
  shouldGatherContext,
189
236
  renderPrompt,
190
237
  starterTaskTitle,
package/lib/engine-ask.js CHANGED
@@ -25,6 +25,7 @@ const DEFAULT_ASK_TIMEOUT_MS = 120000;
25
25
  const MAX_ASK_CONCURRENCY = 4;
26
26
  const MAX_ASK_JOBS = 8;
27
27
  const MAX_ASK_TIMEOUT_MS = 10 * 60 * 1000;
28
+ const DEFAULT_FABLE_ASK_TIMEOUT_MS = MAX_ASK_TIMEOUT_MS;
28
29
  const MAX_ASK_PROMPT_BYTES = 16 * 1024;
29
30
  const MAX_ASK_TOTAL_PROMPT_BYTES = 64 * 1024;
30
31
  const MAX_ASK_OUTPUT_BYTES = 1024 * 1024;
@@ -45,7 +46,7 @@ function askUsage() {
45
46
  'options:',
46
47
  ' --model <name> exact model for every selected engine',
47
48
  ` --concurrency <n> parallel runs, 1-${MAX_ASK_CONCURRENCY} (default ${DEFAULT_ASK_CONCURRENCY})`,
48
- ` --timeout <sec> per-engine timeout, 1-${MAX_ASK_TIMEOUT_MS / 1000} (default ${DEFAULT_ASK_TIMEOUT_MS / 1000})`,
49
+ ` --timeout <sec> per-engine timeout, 1-${MAX_ASK_TIMEOUT_MS / 1000} (default ${DEFAULT_ASK_TIMEOUT_MS / 1000}; Fable ${DEFAULT_FABLE_ASK_TIMEOUT_MS / 1000})`,
49
50
  ' --json print the receipt as json',
50
51
  '',
51
52
  `jobs files contain up to ${MAX_ASK_JOBS} objects: [{"engine":"codex","model":"optional","prompt":"question","label":"optional"}]`,
@@ -74,7 +75,7 @@ function normalizeAskJob(job, index) {
74
75
  if (Buffer.byteLength(prompt) > MAX_ASK_PROMPT_BYTES) {
75
76
  throw new Error(`job ${index + 1} prompt must be ${MAX_ASK_PROMPT_BYTES} bytes or fewer`);
76
77
  }
77
- const model = String(job.model || '').trim();
78
+ const model = resolveAskModel(engine, job.model);
78
79
  const label = String(job.label || '').trim();
79
80
  if (label.length > 80) throw new Error(`job ${index + 1} label must be 80 characters or fewer`);
80
81
  return { engine, model, prompt, label };
@@ -99,7 +100,7 @@ function parseEngineAskArgs(args, { root = process.cwd(), readFile = fs.readFile
99
100
  let requestedModel = '';
100
101
  let modelFlagPresent = false;
101
102
  let concurrency = DEFAULT_ASK_CONCURRENCY;
102
- let timeoutMs = DEFAULT_ASK_TIMEOUT_MS;
103
+ let timeoutMs = null;
103
104
  let json = false;
104
105
  let help = false;
105
106
 
@@ -146,7 +147,7 @@ function parseEngineAskArgs(args, { root = process.cwd(), readFile = fs.readFile
146
147
  promptParts.push(arg);
147
148
  }
148
149
 
149
- if (help) return { help: true, json, jobs: [], concurrency, timeoutMs };
150
+ if (help) return { help: true, json, jobs: [], concurrency, timeoutMs: timeoutMs || DEFAULT_ASK_TIMEOUT_MS };
150
151
  if (modelFlagPresent && !requestedModel) throw new Error('--model needs a name');
151
152
  const commonPrompt = promptParts.join(' ').trim();
152
153
  let jobs;
@@ -178,7 +179,9 @@ function parseEngineAskArgs(args, { root = process.cwd(), readFile = fs.readFile
178
179
  if (totalPromptBytes > MAX_ASK_TOTAL_PROMPT_BYTES) {
179
180
  throw new Error(`engine ask accepts at most ${MAX_ASK_TOTAL_PROMPT_BYTES} prompt bytes per run`);
180
181
  }
181
- return { help: false, json, jobs: labelAskJobs(jobs), concurrency, timeoutMs };
182
+ const resolvedTimeoutMs = timeoutMs
183
+ || (jobs.some((job) => job.engine === 'fable') ? DEFAULT_FABLE_ASK_TIMEOUT_MS : DEFAULT_ASK_TIMEOUT_MS);
184
+ return { help: false, json, jobs: labelAskJobs(jobs), concurrency, timeoutMs: resolvedTimeoutMs };
182
185
  }
183
186
 
184
187
  function guardedPrompt(prompt) {
@@ -192,11 +195,18 @@ function assertAskModelSupported(engine, model) {
192
195
  throw error;
193
196
  }
194
197
 
198
+ function resolveAskModel(engine, modelName = '') {
199
+ const requested = String(modelName || '').trim();
200
+ if (requested) return requested;
201
+ if (engine !== 'claude' && engine !== 'fable' && engine !== 'haiku') return '';
202
+ return RUNNER_PROFILE_DEFS[engine]?.model || DEFAULT_CLAUDE_RUNNER_MODEL;
203
+ }
204
+
195
205
  function buildReadOnlyEngineInvocation(engineName, prompt, modelName = '') {
196
206
  const engine = canonicalEngineName(engineName);
197
207
  const profile = RUNNER_PROFILE_DEFS[engine];
198
208
  if (!profile) throw new Error(`unknown engine "${engineName}"`);
199
- const model = String(modelName || '').trim();
209
+ const model = resolveAskModel(engine, modelName);
200
210
  assertAskModelSupported(engine, model);
201
211
  const request = guardedPrompt(prompt);
202
212