atris 3.56.0 → 3.56.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/atris/skills/engines/SKILL.md +11 -3
  2. package/atris/skills/x-search/SKILL.md +20 -1
  3. package/atris/skills/youtube/SKILL.md +51 -43
  4. package/bin/atris.js +134 -90
  5. package/commands/auth.js +6 -1
  6. package/commands/autopilot-front.js +29 -13
  7. package/commands/brainstorm.js +77 -476
  8. package/commands/business.js +13 -1
  9. package/commands/engine.js +7 -0
  10. package/commands/experiments.js +178 -0
  11. package/commands/fleet-report.js +2 -2
  12. package/commands/founder.js +12 -0
  13. package/commands/human-missions.js +64 -2
  14. package/commands/init.js +2 -35
  15. package/commands/integrations.js +100 -0
  16. package/commands/land.js +53 -23
  17. package/commands/later.js +52 -0
  18. package/commands/launchpad.js +45 -10
  19. package/commands/log.js +79 -30
  20. package/commands/mission.js +117 -22
  21. package/commands/next.js +153 -63
  22. package/commands/now.js +115 -38
  23. package/commands/recap.js +97 -4
  24. package/commands/run-front.js +20 -5
  25. package/commands/spaceship.js +52 -12
  26. package/commands/status.js +38 -7
  27. package/commands/task.js +56 -19
  28. package/commands/terminal.js +5 -5
  29. package/commands/workflow.js +19 -5
  30. package/commands/x-search.js +123 -13
  31. package/commands/youtube.js +941 -36
  32. package/lib/account-bound.js +7 -0
  33. package/lib/apply-gate.js +102 -0
  34. package/lib/config-guard.js +106 -0
  35. package/lib/context-gatherer.js +55 -8
  36. package/lib/engine-ask.js +16 -6
  37. package/lib/engine-registry.js +14 -4
  38. package/lib/first-minute.js +571 -26
  39. package/lib/known-commands.js +1 -1
  40. package/lib/pack-capabilities.js +13 -3
  41. package/lib/runner-command.js +5 -3
  42. package/lib/scratch-root.js +44 -0
  43. package/lib/workspace-scaffold.js +3 -1
  44. package/package.json +1 -1
  45. package/utils/auth.js +84 -6
@@ -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,102 @@
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, change, receipt, journalLine } = {}) {
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
+ const changeText = change || 'fill this';
58
+ const receiptText = receipt || 'fill this';
59
+ let shouldWrite = !fs.existsSync(abs);
60
+ if (!shouldWrite && change && receipt) {
61
+ shouldWrite = !isFilledApply(parseApplyFields(fs.readFileSync(abs, 'utf8')));
62
+ }
63
+ if (shouldWrite) {
64
+ fs.writeFileSync(abs, [
65
+ `source: ${source}`,
66
+ `change: ${changeText}`,
67
+ `receipt: ${receiptText}`,
68
+ ].join('\n') + '\n');
69
+ }
70
+
71
+ const date = dateStamp(now);
72
+ const journalPath = path.join(cwd, 'atris', 'logs', date.slice(0, 4), `${date}.md`);
73
+ fs.mkdirSync(path.dirname(journalPath), { recursive: true });
74
+ let existing = '';
75
+ if (fs.existsSync(journalPath)) existing = fs.readFileSync(journalPath, 'utf8');
76
+ const line = journalLine || `- [claimable] apply: fill this -> ${rel}`;
77
+ if (!existing.includes(line)) {
78
+ const prefix = existing && !existing.endsWith('\n') ? '\n' : '';
79
+ fs.writeFileSync(journalPath, `${existing}${prefix}${line}\n`);
80
+ }
81
+ return rel;
82
+ } catch {
83
+ return null;
84
+ }
85
+ }
86
+
87
+ function ensureApply({
88
+ cwd, source, rel, now, output, incompleteMessage, required = true, change, receipt, journalLine,
89
+ } = {}) {
90
+ const print = typeof output === 'function' ? output : (line = '') => console.error(line);
91
+ const existing = readApplyReceipt({ cwd, rel });
92
+ if (existing && isFilledApply(parseApplyFields(existing.text))) return 0;
93
+ if (!(required && existing)) writeApplyStub({ cwd, source, rel, now, change, receipt, journalLine });
94
+ print(incompleteMessage);
95
+ return required ? 2 : 0;
96
+ }
97
+
98
+ module.exports = {
99
+ applySlug,
100
+ applySidecarRel,
101
+ ensureApply,
102
+ };
@@ -0,0 +1,106 @@
1
+ 'use strict';
2
+
3
+ const os = require('os');
4
+ const path = require('path');
5
+
6
+ const SETTINGS_NAME = 'settings.json';
7
+ const CLAUDE_DIR_NAME = '.claude';
8
+ const DENY_REASON = 'pack runs cannot change .claude/settings.json to add SessionStart or disable hooks';
9
+
10
+ function expandUserPath(value, home) {
11
+ const text = String(value);
12
+ if (text === '~') return home;
13
+ if (text.startsWith('~/') || text.startsWith('~\\')) return path.join(home, text.slice(2));
14
+ return text;
15
+ }
16
+
17
+ function asText(value) {
18
+ if (value == null) return '';
19
+ if (typeof value === 'string') return value;
20
+ try {
21
+ return JSON.stringify(value);
22
+ } catch {
23
+ return String(value);
24
+ }
25
+ }
26
+
27
+ function settingsPlantPersistence(value) {
28
+ if (!value || typeof value !== 'object') return false;
29
+ if (value.disableAllHooks === true) return true;
30
+ if (value.hooks && Object.prototype.hasOwnProperty.call(value.hooks, 'SessionStart')) return true;
31
+ return Object.values(value).some((entry) => settingsPlantPersistence(entry));
32
+ }
33
+
34
+ function plantsPersistence(text) {
35
+ const source = asText(text);
36
+ if (!source) return false;
37
+ if (/\bSessionStart\b/.test(source)) return true;
38
+ if (/["']?disableAllHooks["']?\s*:\s*true\b/.test(source)) return true;
39
+ try {
40
+ return settingsPlantPersistence(JSON.parse(source));
41
+ } catch {
42
+ return false;
43
+ }
44
+ }
45
+
46
+ function isClaudeSettingsPath(value, options = {}) {
47
+ if (typeof value !== 'string' || !value.trim()) return false;
48
+ const home = options.home || os.homedir();
49
+ const cwd = options.cwd || process.cwd();
50
+ const expanded = expandUserPath(value.trim(), home);
51
+ const resolved = path.resolve(cwd, expanded);
52
+ if (path.basename(resolved) === SETTINGS_NAME && path.basename(path.dirname(resolved)) === CLAUDE_DIR_NAME) {
53
+ return true;
54
+ }
55
+ const configDir = path.resolve(
56
+ options.configDir
57
+ || process.env.CLAUDE_CONFIG_DIR
58
+ || path.join(home, CLAUDE_DIR_NAME),
59
+ );
60
+ return resolved === path.join(configDir, SETTINGS_NAME);
61
+ }
62
+
63
+ function commandMentionsClaudeSettings(command) {
64
+ const text = String(command || '');
65
+ if (!text.trim()) return false;
66
+ return /(?:^|[^\w])(?:~\/|\.\/|(?:\.\.\/)+)?(?:\$\{?HOME\}?\/)?\.claude\/settings\.json\b/.test(text)
67
+ || /\$\{?CLAUDE_CONFIG_DIR\}?\/settings\.json/.test(text)
68
+ || /(?:^|[^\w])(?:\$HOME|\$\{HOME\})\/\.claude\/settings\.json\b/.test(text);
69
+ }
70
+
71
+ function fileToolPath(input) {
72
+ const toolInput = (input && input.tool_input) || {};
73
+ return toolInput.file_path || toolInput.path || null;
74
+ }
75
+
76
+ function bashCommand(input) {
77
+ const toolInput = (input && input.tool_input) || {};
78
+ return toolInput.command || toolInput.cmd || '';
79
+ }
80
+
81
+ function writeContents(input) {
82
+ const toolInput = (input && input.tool_input) || {};
83
+ return toolInput.contents ?? toolInput.content ?? toolInput.new_string ?? '';
84
+ }
85
+
86
+ function enforceConfigGuard(input, options = {}) {
87
+ const tool = input && input.tool_name;
88
+ if (tool === 'Write' || tool === 'Edit') {
89
+ if (!isClaudeSettingsPath(fileToolPath(input), options)) return { allowed: true };
90
+ if (!plantsPersistence(writeContents(input))) return { allowed: true };
91
+ return { allowed: false, reason: DENY_REASON };
92
+ }
93
+ if (tool === 'Bash') {
94
+ if (!commandMentionsClaudeSettings(bashCommand(input))) return { allowed: true };
95
+ return { allowed: false, reason: DENY_REASON };
96
+ }
97
+ return { allowed: true };
98
+ }
99
+
100
+ module.exports = {
101
+ DENY_REASON,
102
+ commandMentionsClaudeSettings,
103
+ enforceConfigGuard,
104
+ isClaudeSettingsPath,
105
+ plantsPersistence,
106
+ };
@@ -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
 
@@ -68,6 +68,14 @@ function engineRegistryFile(root = process.cwd()) {
68
68
  return path.join(root, '.atris', 'state', 'engines.json');
69
69
  }
70
70
 
71
+ // A scratch folder is not a room. First-touch seed must not mint .atris/.
72
+ // Updates stay allowed once the file or a real workspace already exists.
73
+ function canPersistEngineRegistry(root = process.cwd()) {
74
+ if (fs.existsSync(engineRegistryFile(root))) return true;
75
+ return fs.existsSync(path.join(root, 'atris'))
76
+ || fs.existsSync(path.join(root, '.atris', 'business.json'));
77
+ }
78
+
71
79
  // Machine probe. Routing never calls this on a settled registry: it runs once
72
80
  // when an engine first appears (seeding the policy file), at the execution
73
81
  // stage right before a spawn, and on the explicit `atris engine doctor`.
@@ -239,14 +247,16 @@ function setEngineOverrides(name, overrides = {}, root = process.cwd()) {
239
247
  }
240
248
 
241
249
  // A read only writes when normalization actually changed the saved engines
242
- // (first seed, schema drift). Settled registries stay untouched, so read
243
- // paths cannot stomp a mutation another process just landed. The comparison
244
- // uses the same raw snapshot the seed was built from: one read, one decision.
250
+ // (first seed, schema drift) and this folder is already a room or already
251
+ // has engines.json. Empty scratch stays empty. Settled registries stay
252
+ // untouched, so read paths cannot stomp a mutation another process just
253
+ // landed. The comparison uses the same raw snapshot the seed was built from:
254
+ // one read, one decision.
245
255
  function readEngineRegistry(root = process.cwd(), options = {}) {
246
256
  const raw = readRawRegistry(engineRegistryFile(root));
247
257
  const registry = seededRegistry(root, raw);
248
258
  const needsPersist = JSON.stringify(raw.engines || []) !== JSON.stringify(registry.engines);
249
- if (options.persist !== false && needsPersist) {
259
+ if (options.persist !== false && needsPersist && canPersistEngineRegistry(root)) {
250
260
  writeEngineRegistry(root, registry);
251
261
  }
252
262
  return registry;