@brunobracaioli/aibrain 0.4.0-beta.13 → 0.4.0-beta.14

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/README.md CHANGED
@@ -30,6 +30,19 @@ roles. Nothing is written to `~/.codex`, `~/.agents` or the project, and the
30
30
  session's folder is deleted on exit. An older Codex starts without the pack and
31
31
  says `codex_outdated`.
32
32
 
33
+ `aisac init [dir]` (licensed accounts, client `0.4.0-beta.14` or later) writes the
34
+ AISAC skeleton into a new or existing project: `CLAUDE.md`, `AGENTS.md`,
35
+ `NOTES.example.md`, the `docs/` tree with its templates, a Claude Code rules file,
36
+ a CI workflow with pinned actions, Dependabot, `.gitignore` and the workloop
37
+ template. The templates travel inside the licensed pack, fetched and verified like
38
+ a launch; the command writes only files that are missing, never follows a symbolic
39
+ link, never touches `.git/` and prints every file it wrote or skipped. Options:
40
+ `--stack node|python|none` (otherwise detected from `package.json`, `pyproject.toml`
41
+ or `requirements.txt`), `--lang pt-BR|en`, `--dry-run`, `--json`. Exit codes: `0`
42
+ when every file exists or was written, `1` for a license, pack or write failure
43
+ (nothing is written before the pack is verified), `2` for a usage error. Until the
44
+ pack carries the templates, the command says so and writes nothing.
45
+
33
46
  Licensed `aisac claude` sessions also load guardrail hooks as the session plugin
34
47
  `aisac-guardrails`.
35
48
  - **Default on:** `secrets-guard` refuses secret files by name. Four more hooks
@@ -16,7 +16,7 @@ export const MAX_LOG_FILES = 3;
16
16
  const LOCK_WAIT_MS = 250;
17
17
  const LOCK_STALE_MS = 30_000;
18
18
  const ADMISSION_MS = 60 * 60 * 1000;
19
- export const ADAPTER_VERSION = '0.4.0-beta.13';
19
+ export const ADAPTER_VERSION = '0.4.0-beta.14';
20
20
  const MAX_DIAGNOSTIC_COUNT = 10_000_000;
21
21
  const MAX_DIAGNOSTIC_BYTES = 128 * 1024 * 1024;
22
22
  const CHECKPOINT_BACKENDS = new Set(['native_store', 'private_file']);
package/dist/cli.mjs CHANGED
@@ -37,13 +37,14 @@ import {
37
37
  resolvePackLocale,
38
38
  writeStoredLocale,
39
39
  } from './engineering-pack.mjs';
40
+ import { initCommand } from './engineering-init.mjs';
40
41
  import { extensionsSummary, hooksCommand, mcpCommand } from './extensions-cli.mjs';
41
42
  import { sessionServers } from './mcp-servers.mjs';
42
43
  import { anyFormatter } from './guardrails/format.mjs';
43
44
  import { readCodexGuardrails } from './guardrails-plugin.mjs';
44
45
  import { codexHooksStatus, supportsCodexHooks } from './codex-hooks.mjs';
45
46
 
46
- const VERSION = '0.4.0-beta.13';
47
+ const VERSION = '0.4.0-beta.14';
47
48
  // The command is `aisac`; `aibrain` is an alias installed from the same entry.
48
49
  const COMMAND = 'aisac';
49
50
  const ENDPOINT = 'https://mcp.ai-brain.aisac.cloud/mcp';
@@ -52,7 +53,7 @@ const LEGACY_DISCLOSURE_VERSION = 'aibrain-paid-context-v1-2026-10-09';
52
53
 
53
54
  function usage() {
54
55
  console.log(
55
- `${COMMAND} ${VERSION}\n\nUsage:\n ${COMMAND} setup [--client claude|codex|both] [--context-profile upstream|conservative]\n [--compact-at 1..95] [--keep-threshold 0..1] [--preserve-recent 1..100]\n [--min-reduction 0..1] [--truncate-head-chars 0..4096]\n [--max-state-tokens 1000..25000] [--max-request-tokens 1000..30000]\n [--tool-policy compatible|read_only] [--goal TEXT]\n ${COMMAND} doctor [--json]\n ${COMMAND} status [--json]\n ${COMMAND} disable\n ${COMMAND} setup --undo\n ${COMMAND} claude [-- native-args...]\n ${COMMAND} codex [-- native-args...]\n\nPolicy notes:\n Automatic monitoring asks for compaction at compact-at percent; state/request\n limits are estimated upper bounds and do not override server quotas.\n min-reduction, truncate-head-chars and tool-policy apply only to Claude;\n Codex keeps native compaction and same-session recovery.`,
56
+ `${COMMAND} ${VERSION}\n\nUsage:\n ${COMMAND} setup [--client claude|codex|both] [--context-profile upstream|conservative]\n [--compact-at 1..95] [--keep-threshold 0..1] [--preserve-recent 1..100]\n [--min-reduction 0..1] [--truncate-head-chars 0..4096]\n [--max-state-tokens 1000..25000] [--max-request-tokens 1000..30000]\n [--tool-policy compatible|read_only] [--goal TEXT]\n ${COMMAND} doctor [--json]\n ${COMMAND} status [--json]\n ${COMMAND} disable\n ${COMMAND} setup --undo\n ${COMMAND} claude [-- native-args...]\n ${COMMAND} codex [-- native-args...]\n ${COMMAND} init [dir] [--lang pt-BR|en] [--stack node|python|none] [--dry-run] [--json]\n\nPolicy notes:\n Automatic monitoring asks for compaction at compact-at percent; state/request\n limits are estimated upper bounds and do not override server quotas.\n min-reduction, truncate-head-chars and tool-policy apply only to Claude;\n Codex keeps native compaction and same-session recovery.`,
56
57
  );
57
58
  console.log(
58
59
  'Clients: setup without --client configures the clients installed on PATH (Claude Code, Codex, or both).',
@@ -66,6 +67,9 @@ function usage() {
66
67
  console.log(
67
68
  'AISAC Engineering: `aisac claude` and `aisac codex` (Codex 0.164+) load the licensed method; setup --lang pt-BR|en picks its language (before the first setup it runs the full setup); setup and doctor report the license; status stays offline. `aibrain` is an alias of `aisac`.',
68
69
  );
70
+ console.log(
71
+ 'Project skeleton: `aisac init [dir]` (licensed accounts) writes the AISAC skeleton from the pack into a new or existing project, only the files that are missing; `--dry-run` lists them, `--stack` picks node|python|none when detection is not enough.',
72
+ );
69
73
  console.log(
70
74
  'Guardrails: `aisac hooks` lists them (a checklist on a terminal); `aisac hooks enable|disable <id>...` and `aisac hooks reset` change the next `aisac claude` or `aisac codex` session.',
71
75
  );
@@ -845,6 +849,14 @@ async function main(argv) {
845
849
  }
846
850
  if (command === 'hooks') return hooksCommand(rest);
847
851
  if (command === 'mcp') return mcpCommand(rest);
852
+ if (command === 'init')
853
+ return initCommand(rest, {
854
+ packageRoot: PACKAGE_ROOT,
855
+ readConfig,
856
+ stateRoot,
857
+ cwd: process.cwd(),
858
+ env: process.env,
859
+ });
848
860
  if (command === 'disable') {
849
861
  if (rest.length > 0) throw new Error('disable_does_not_accept_options');
850
862
  return removeOwnedSetup();
@@ -897,5 +909,7 @@ main(cliArgs).catch((error) => {
897
909
  const guide = /** @type {{ guide?: unknown }} */ (error ?? {}).guide;
898
910
  if (typeof guide === 'string') console.error(`\n${guide}`);
899
911
  }
900
- process.exitCode = 1;
912
+ // Usage errors of `aisac init` exit 2 (spec FR3); everything else exits 1.
913
+ const exitCode = /** @type {{ exitCode?: unknown }} */ (error ?? {}).exitCode;
914
+ process.exitCode = exitCode === 2 ? 2 : 1;
901
915
  });
@@ -0,0 +1,613 @@
1
+ // @ts-check
2
+
3
+ /**
4
+ * `aisac init` (spec aisac-engineering-init, ADR-0053, wave E5c): the AISAC
5
+ * skeleton written into the user's project from the licensed pack's `init/`
6
+ * templates.
7
+ *
8
+ * A template is one pack file under `init/` whose front matter names the
9
+ * target path and the stack, and whose body holds exactly one fenced code
10
+ * block: the fenced lines are the file. Only that body is written, so the
11
+ * per-account watermark the server adds to every front matter never reaches
12
+ * a repository. Only missing files are written, with exclusive creates, never
13
+ * through a symbolic link and never under `.git/`. The command fetches and
14
+ * verifies the pack with the same code path as a launch; nothing is written
15
+ * before the pack is verified, and nothing is written outside the directory
16
+ * the user named.
17
+ */
18
+
19
+ import { lstat, mkdir, open, realpath, stat } from 'node:fs/promises';
20
+ import { basename, join, resolve, sep } from 'node:path';
21
+ import {
22
+ INIT_PREFIX,
23
+ isPackLocale,
24
+ obtainPack,
25
+ packNotice,
26
+ readStoredLocale,
27
+ resolvePackLocale,
28
+ splitFrontMatter,
29
+ } from './engineering-pack.mjs';
30
+ import { isRecord } from './engineering.mjs';
31
+
32
+ export const INIT_STACKS = /** @type {const} */ (['node', 'python', 'none']);
33
+ const TEMPLATE_STACKS = new Set([...INIT_STACKS, 'all']);
34
+ const TEMPLATE_PATH = /^init\/([A-Za-z0-9][A-Za-z0-9._-]{0,127})\.md$/u;
35
+ const TEMPLATE_KEYS = new Set(['name', 'description', 'target', 'stack', 'aisac_watermark']);
36
+ const MAX_DESCRIPTION_LENGTH = 1024;
37
+ const MAX_TARGET_LENGTH = 200;
38
+ const TARGET_CHARACTERS = /^[A-Za-z0-9._/-]+$/u;
39
+ const FORBIDDEN_SEGMENTS = new Set(['.', '..', '.git']);
40
+ const OPENING_FENCE = /^(`{3,})([^`]*)$/u;
41
+ const CLOSING_FENCE = /^(`{3,})[ \t]*$/u;
42
+ const WATERMARK_KEY = 'aisac_watermark';
43
+ const PROJECT_NAME = /^[A-Za-z0-9._-]{1,64}$/u;
44
+ const PROJECT_NAME_TOKEN = '{{project_name}}';
45
+ const DEFAULT_PROJECT_NAME = 'project';
46
+ /** YAML and `.gitignore` templates are byte-identical across locales and carry no token (spec D7, D8). */
47
+ const CONFIGURATION_TARGET = /(?:\.ya?ml|\.gitignore)$/u;
48
+ const DIRECTORY_MODE = 0o755;
49
+ const FILE_MODE = 0o644;
50
+ const NODE_MANIFEST = 'package.json';
51
+ const PYTHON_MANIFESTS = ['pyproject.toml', 'requirements.txt'];
52
+ const CLAUDE_MEMORY_FILE = 'CLAUDE.md';
53
+ const USAGE_EXIT_CODE = 2;
54
+ const UPDATE_COMMAND = 'npm install -g @aisac/cli@beta';
55
+
56
+ /** @typedef {(typeof INIT_STACKS)[number]} InitStack */
57
+ /** @typedef {InitStack | 'all'} TemplateStack */
58
+ /** @typedef {import('./engineering-pack.mjs').PackLocale} PackLocale */
59
+ /**
60
+ * @typedef {{
61
+ * path: string,
62
+ * name: string,
63
+ * description: string,
64
+ * target: string,
65
+ * stack: TemplateStack,
66
+ * body: string
67
+ * }} InitTemplate
68
+ */
69
+ /** @typedef {'init_path' | 'init_front_matter' | 'init_target' | 'init_body'} InitTemplateReason */
70
+ /** @typedef {'wrote' | 'skipped' | 'failed'} InitOutcome */
71
+ /** @typedef {{ target: string, outcome: InitOutcome, reason: string | null }} InitEntry */
72
+ /**
73
+ * @typedef {{
74
+ * directory: string,
75
+ * entries: InitEntry[],
76
+ * wrote: string[],
77
+ * skipped: { target: string, reason: string }[],
78
+ * failed: { target: string, reason: string }[]
79
+ * }} InitResult
80
+ */
81
+
82
+ /** A template the server should never have served; the whole run stops. */
83
+ export class InitTemplateError extends Error {
84
+ /** @param {InitTemplateReason} reason @param {string} path */
85
+ constructor(reason, path) {
86
+ super(`init_template_invalid:${reason}:${path}`);
87
+ this.reason = reason;
88
+ this.path = path;
89
+ }
90
+ }
91
+
92
+ /** @param {string} code */
93
+ function usageError(code) {
94
+ const error = new Error(code);
95
+ Object.defineProperty(error, 'exitCode', { value: USAGE_EXIT_CODE });
96
+ return error;
97
+ }
98
+
99
+ /** @param {unknown} value @returns {value is InitStack} */
100
+ export function isInitStack(value) {
101
+ return typeof value === 'string' && INIT_STACKS.includes(/** @type {InitStack} */ (value));
102
+ }
103
+
104
+ /**
105
+ * Relative, `[A-Za-z0-9._/-]`, at most 200 characters, no empty, `.`, `..` or
106
+ * `.git` segment (spec FR2). A leading dot inside a segment is fine (`.github`).
107
+ * @param {unknown} target
108
+ * @returns {target is string}
109
+ */
110
+ export function isValidInitTarget(target) {
111
+ if (typeof target !== 'string' || target.length === 0 || target.length > MAX_TARGET_LENGTH)
112
+ return false;
113
+ if (!TARGET_CHARACTERS.test(target)) return false;
114
+ return target
115
+ .split('/')
116
+ .every((segment) => segment.length > 0 && !FORBIDDEN_SEGMENTS.has(segment.toLowerCase()));
117
+ }
118
+
119
+ /**
120
+ * The one fenced block of a template body, as file content: its lines, each
121
+ * ended by a newline; an empty block is an empty file. Anything outside the
122
+ * block is the author's note. A longer outer fence keeps inner fences as
123
+ * content, so a template can carry Markdown with code blocks.
124
+ * @param {string} markdown
125
+ * @param {string} path
126
+ */
127
+ function fencedBody(markdown, path) {
128
+ /** @type {number | null} */
129
+ let fence = null;
130
+ /** @type {string[] | null} */
131
+ let content = null;
132
+ let closed = false;
133
+ for (const line of markdown.split('\n')) {
134
+ if (fence === null) {
135
+ const opening = OPENING_FENCE.exec(line);
136
+ if (opening === null) continue;
137
+ if (closed) throw new InitTemplateError('init_body', path);
138
+ fence = opening[1]?.length ?? 0;
139
+ content = [];
140
+ continue;
141
+ }
142
+ const closing = CLOSING_FENCE.exec(line);
143
+ if (closing !== null && (closing[1]?.length ?? 0) >= fence) {
144
+ fence = null;
145
+ closed = true;
146
+ continue;
147
+ }
148
+ content?.push(line);
149
+ }
150
+ if (fence !== null || !closed || content === null) throw new InitTemplateError('init_body', path);
151
+ if (content.some((line) => line.includes(WATERMARK_KEY)))
152
+ throw new InitTemplateError('init_body', path);
153
+ return content.length === 0 ? '' : `${content.join('\n')}\n`;
154
+ }
155
+
156
+ /**
157
+ * Parse one `init/<name>.md` pack file (spec FR2). The server validates the
158
+ * same rules before serving; this is the second layer.
159
+ * @param {{ path: string, content: string }} file
160
+ * @returns {InitTemplate}
161
+ */
162
+ export function parseInitTemplate(file) {
163
+ const stem = TEMPLATE_PATH.exec(file.path)?.[1];
164
+ if (stem === undefined) throw new InitTemplateError('init_path', file.path);
165
+ const parsed = splitFrontMatter(file.content);
166
+ if (parsed === null) throw new InitTemplateError('init_front_matter', file.path);
167
+ const { values, body } = parsed;
168
+ for (const key of values.keys()) {
169
+ if (!TEMPLATE_KEYS.has(key)) throw new InitTemplateError('init_front_matter', file.path);
170
+ }
171
+ const name = values.get('name');
172
+ const description = values.get('description');
173
+ const stack = values.get('stack') ?? 'all';
174
+ if (
175
+ name !== stem ||
176
+ description === undefined ||
177
+ description.length === 0 ||
178
+ description.length > MAX_DESCRIPTION_LENGTH ||
179
+ description === '>' ||
180
+ description === '|' ||
181
+ !TEMPLATE_STACKS.has(stack)
182
+ )
183
+ throw new InitTemplateError('init_front_matter', file.path);
184
+ const target = values.get('target');
185
+ if (!isValidInitTarget(target)) throw new InitTemplateError('init_target', file.path);
186
+ return {
187
+ path: file.path,
188
+ name,
189
+ description,
190
+ target,
191
+ stack: /** @type {TemplateStack} */ (stack),
192
+ body: fencedBody(body, file.path),
193
+ };
194
+ }
195
+
196
+ /** @param {string} left @param {string} right */
197
+ function compareTargets(left, right) {
198
+ return left < right ? -1 : left > right ? 1 : 0;
199
+ }
200
+
201
+ /**
202
+ * Every `init/` template of a verified pack, in sorted target order. Two
203
+ * templates may share a target only with different stacks, neither `all`; no
204
+ * target may be a directory of another.
205
+ * @param {readonly { path: string, content: string }[]} files
206
+ * @returns {InitTemplate[]}
207
+ */
208
+ export function loadInitTemplates(files) {
209
+ const templates = files
210
+ .filter((file) => file.path.startsWith(INIT_PREFIX))
211
+ .map((file) => parseInitTemplate(file))
212
+ .sort(
213
+ (left, right) =>
214
+ compareTargets(left.target, right.target) || compareTargets(left.stack, right.stack),
215
+ );
216
+ /** @type {Map<string, InitTemplate[]>} */
217
+ const byTarget = new Map();
218
+ const directories = new Set();
219
+ for (const template of templates) {
220
+ const segments = template.target.split('/');
221
+ for (let depth = 1; depth < segments.length; depth += 1)
222
+ directories.add(segments.slice(0, depth).join('/'));
223
+ const sharing = byTarget.get(template.target) ?? [];
224
+ for (const other of sharing) {
225
+ if (other.stack === template.stack || other.stack === 'all' || template.stack === 'all')
226
+ throw new InitTemplateError('init_target', template.path);
227
+ }
228
+ sharing.push(template);
229
+ byTarget.set(template.target, sharing);
230
+ }
231
+ for (const template of templates) {
232
+ if (directories.has(template.target)) throw new InitTemplateError('init_target', template.path);
233
+ }
234
+ return templates;
235
+ }
236
+
237
+ /**
238
+ * The templates of one stack: its own plus the shared ones.
239
+ * @param {readonly InitTemplate[]} templates
240
+ * @param {InitStack} stack
241
+ */
242
+ export function selectTemplates(templates, stack) {
243
+ return templates.filter((template) => template.stack === 'all' || template.stack === stack);
244
+ }
245
+
246
+ /** @param {string} path */
247
+ async function isFile(path) {
248
+ try {
249
+ return (await stat(path)).isFile();
250
+ } catch {
251
+ return false;
252
+ }
253
+ }
254
+
255
+ /**
256
+ * `node` from `package.json`, `python` from `pyproject.toml` or
257
+ * `requirements.txt`, `none` without either, `ambiguous` with both (spec D6).
258
+ * A missing directory has no stack.
259
+ * @param {string} directory
260
+ * @returns {Promise<InitStack | 'ambiguous'>}
261
+ */
262
+ export async function detectStack(directory) {
263
+ const [node, ...python] = await Promise.all([
264
+ isFile(join(directory, NODE_MANIFEST)),
265
+ ...PYTHON_MANIFESTS.map((manifest) => isFile(join(directory, manifest))),
266
+ ]);
267
+ const hasPython = python.some(Boolean);
268
+ if (node && hasPython) return 'ambiguous';
269
+ if (node) return 'node';
270
+ return hasPython ? 'python' : 'none';
271
+ }
272
+
273
+ /**
274
+ * The directory's base name when it fits the token charset, else `project`
275
+ * (spec D7).
276
+ * @param {string} directory
277
+ */
278
+ export function projectNameOf(directory) {
279
+ const name = basename(directory);
280
+ return PROJECT_NAME.test(name) ? name : DEFAULT_PROJECT_NAME;
281
+ }
282
+
283
+ /**
284
+ * The file content for one template: the fenced body with `{{project_name}}`
285
+ * replaced in text templates; YAML and `.gitignore` are written as they are.
286
+ * @param {InitTemplate} template
287
+ * @param {string} projectName
288
+ */
289
+ export function renderTemplate(template, projectName) {
290
+ return CONFIGURATION_TARGET.test(template.target)
291
+ ? template.body
292
+ : template.body.replaceAll(PROJECT_NAME_TOKEN, projectName);
293
+ }
294
+
295
+ /** @param {unknown} error */
296
+ function errorCode(error) {
297
+ return isRecord(error) && typeof error.code === 'string' ? error.code : 'write_failed';
298
+ }
299
+
300
+ /**
301
+ * Create the missing parents of a target without following links. A
302
+ * symbolic link on the way skips the file; a non-directory fails it.
303
+ * @param {string} base
304
+ * @param {readonly string[]} segments
305
+ * @param {boolean} dryRun
306
+ * @returns {Promise<{ outcome: 'ok' } | { outcome: 'skipped' | 'failed', reason: string }>}
307
+ */
308
+ async function ensureParents(base, segments, dryRun) {
309
+ let current = base;
310
+ for (const segment of segments) {
311
+ current = join(current, segment);
312
+ let entry = await lstat(current).catch(() => null);
313
+ if (entry === null) {
314
+ if (dryRun) continue;
315
+ try {
316
+ await mkdir(current, { mode: DIRECTORY_MODE });
317
+ } catch (error) {
318
+ if (errorCode(error) !== 'EEXIST') return { outcome: 'failed', reason: errorCode(error) };
319
+ }
320
+ entry = await lstat(current).catch(() => null);
321
+ if (entry === null) return { outcome: 'failed', reason: 'parent_missing' };
322
+ }
323
+ if (entry.isSymbolicLink()) return { outcome: 'skipped', reason: 'symlink' };
324
+ if (!entry.isDirectory()) return { outcome: 'failed', reason: 'parent_not_directory' };
325
+ }
326
+ return { outcome: 'ok' };
327
+ }
328
+
329
+ /**
330
+ * Write the selected templates into the directory: missing files only,
331
+ * exclusive creates, `0644` files and `0755` directories, sorted target
332
+ * order, each file reported once. With `dryRun` nothing is created and the
333
+ * files that would be written are listed as written.
334
+ * @param {{ directory: string, templates: readonly InitTemplate[], projectName: string, dryRun: boolean }} input
335
+ * @returns {Promise<InitResult>}
336
+ */
337
+ export async function applySkeleton(input) {
338
+ const base = input.dryRun
339
+ ? await realpath(input.directory).catch(() => resolve(input.directory))
340
+ : await realpath(input.directory);
341
+ /** @type {InitEntry[]} */
342
+ const entries = [];
343
+ for (const template of input.templates) {
344
+ const segments = template.target.split('/');
345
+ const full = resolve(base, ...segments);
346
+ if (!full.startsWith(`${base}${sep}`)) {
347
+ entries.push({ target: template.target, outcome: 'failed', reason: 'target_escape' });
348
+ continue;
349
+ }
350
+ const parents = await ensureParents(base, segments.slice(0, -1), input.dryRun);
351
+ if (parents.outcome !== 'ok') {
352
+ entries.push({ target: template.target, outcome: parents.outcome, reason: parents.reason });
353
+ continue;
354
+ }
355
+ const existing = await lstat(full).catch(() => null);
356
+ if (existing !== null) {
357
+ entries.push({
358
+ target: template.target,
359
+ outcome: 'skipped',
360
+ reason: existing.isSymbolicLink() ? 'symlink' : 'exists',
361
+ });
362
+ continue;
363
+ }
364
+ if (input.dryRun) {
365
+ entries.push({ target: template.target, outcome: 'wrote', reason: null });
366
+ continue;
367
+ }
368
+ try {
369
+ const handle = await open(full, 'wx', FILE_MODE);
370
+ try {
371
+ await handle.writeFile(renderTemplate(template, input.projectName), 'utf8');
372
+ } finally {
373
+ await handle.close();
374
+ }
375
+ entries.push({ target: template.target, outcome: 'wrote', reason: null });
376
+ } catch (error) {
377
+ const code = errorCode(error);
378
+ entries.push(
379
+ code === 'EEXIST'
380
+ ? { target: template.target, outcome: 'skipped', reason: 'exists' }
381
+ : { target: template.target, outcome: 'failed', reason: code },
382
+ );
383
+ }
384
+ }
385
+ return {
386
+ directory: base,
387
+ entries,
388
+ wrote: entries.filter((entry) => entry.outcome === 'wrote').map((entry) => entry.target),
389
+ skipped: entries
390
+ .filter((entry) => entry.outcome === 'skipped')
391
+ .map((entry) => ({ target: entry.target, reason: entry.reason ?? 'exists' })),
392
+ failed: entries
393
+ .filter((entry) => entry.outcome === 'failed')
394
+ .map((entry) => ({ target: entry.target, reason: entry.reason ?? 'write_failed' })),
395
+ };
396
+ }
397
+
398
+ /**
399
+ * The next steps after a run, in the pack locale (spec FR3).
400
+ * @param {PackLocale} locale
401
+ * @param {InitResult} result
402
+ */
403
+ export function nextSteps(locale, result) {
404
+ const pt = locale === 'pt-BR';
405
+ const claudeKept = result.skipped.some(
406
+ (entry) => entry.target === CLAUDE_MEMORY_FILE && entry.reason === 'exists',
407
+ );
408
+ return [
409
+ pt
410
+ ? 'Copie NOTES.example.md para NOTES.md (o .gitignore já o exclui) e mantenha-o atualizado.'
411
+ : 'Copy NOTES.example.md to NOTES.md (already in .gitignore) and keep it current.',
412
+ pt
413
+ ? 'Preencha os marcadores [CUSTOMIZE] de CLAUDE.md, AGENTS.md e .claude/rules/ na primeira sessão; o seu agente pode fazer isso.'
414
+ : 'Fill the [CUSTOMIZE] markers in CLAUDE.md, AGENTS.md and .claude/rules/ in the first session; your agent can do it.',
415
+ pt
416
+ ? 'Rode aisac claude ou aisac codex nesta pasta: o método chega com a sessão.'
417
+ : 'Run aisac claude or aisac codex in this folder: the method arrives with the session.',
418
+ pt
419
+ ? 'Leia docs/README.md para o mapa da documentação.'
420
+ : 'Read docs/README.md for the documentation map.',
421
+ ...(claudeKept
422
+ ? [
423
+ pt
424
+ ? 'CLAUDE.md já existia e ficou como estava; peça à skill aisac-method para incorporar o método nele.'
425
+ : 'CLAUDE.md already existed and was left as is; ask the aisac-method skill to fold the method into it.',
426
+ ]
427
+ : []),
428
+ ];
429
+ }
430
+
431
+ /**
432
+ * The human report: one line per file in target order, a summary, the next steps.
433
+ * @param {{ result: InitResult, locale: PackLocale, stack: InitStack, packVersion: string, dryRun: boolean }} input
434
+ */
435
+ export function initReport(input) {
436
+ const pt = input.locale === 'pt-BR';
437
+ const lines = input.result.entries.map((entry) => {
438
+ switch (entry.outcome) {
439
+ case 'wrote':
440
+ return input.dryRun
441
+ ? `${pt ? 'gravaria' : 'would write'} ${entry.target}`
442
+ : `${pt ? 'gravado' : 'wrote'} ${entry.target}`;
443
+ case 'skipped':
444
+ return `${pt ? 'pulado' : 'skipped'} ${entry.target} (${entry.reason})`;
445
+ default:
446
+ return `${pt ? 'falhou' : 'failed'} ${entry.target} (${entry.reason})`;
447
+ }
448
+ });
449
+ const counts = pt
450
+ ? `${input.result.wrote.length} ${input.dryRun ? 'a gravar' : 'gravados'}, ${input.result.skipped.length} pulados, ${input.result.failed.length} com falha`
451
+ : `${input.result.wrote.length} ${input.dryRun ? 'to write' : 'written'}, ${input.result.skipped.length} skipped, ${input.result.failed.length} failed`;
452
+ lines.push(
453
+ `aisac init: ${counts} (stack ${input.stack}, ${input.locale}, pack ${input.packVersion}) ${pt ? 'em' : 'in'} ${input.result.directory}`,
454
+ );
455
+ if (!input.dryRun && input.result.failed.length === 0) {
456
+ lines.push(pt ? 'Próximos passos:' : 'Next steps:');
457
+ for (const [index, step] of nextSteps(input.locale, input.result).entries())
458
+ lines.push(` ${index + 1}. ${step}`);
459
+ }
460
+ return lines;
461
+ }
462
+
463
+ /**
464
+ * @typedef {{
465
+ * dir: string | undefined,
466
+ * lang: PackLocale | undefined,
467
+ * stack: InitStack | undefined,
468
+ * dryRun: boolean,
469
+ * json: boolean
470
+ * }} InitArguments
471
+ */
472
+
473
+ /**
474
+ * `aisac init [dir] [--lang pt-BR|en] [--stack node|python|none] [--dry-run] [--json]`.
475
+ * Usage errors exit 2.
476
+ * @param {readonly string[]} args
477
+ * @returns {InitArguments}
478
+ */
479
+ export function parseInitArguments(args) {
480
+ /** @type {InitArguments} */
481
+ const parsed = { dir: undefined, lang: undefined, stack: undefined, dryRun: false, json: false };
482
+ for (let index = 0; index < args.length; index += 1) {
483
+ const argument = /** @type {string} */ (args[index]);
484
+ if (argument === '--dry-run' || argument === '--json') {
485
+ const key = argument === '--json' ? 'json' : 'dryRun';
486
+ if (parsed[key]) throw usageError(`duplicate_option:${argument}`);
487
+ parsed[key] = true;
488
+ } else if (argument === '--lang' || argument === '--stack') {
489
+ const value = args[index + 1];
490
+ if (value === undefined || value.startsWith('-'))
491
+ throw usageError(`option_value_required:${argument}`);
492
+ const key = argument === '--lang' ? 'lang' : 'stack';
493
+ if (parsed[key] !== undefined) throw usageError(`duplicate_option:${argument}`);
494
+ if (key === 'lang') {
495
+ if (!isPackLocale(value)) throw usageError('invalid_lang');
496
+ parsed.lang = value;
497
+ } else {
498
+ if (!isInitStack(value)) throw usageError('invalid_stack');
499
+ parsed.stack = value;
500
+ }
501
+ index += 1;
502
+ } else if (argument.startsWith('-') || parsed.dir !== undefined) {
503
+ throw usageError('unknown_option');
504
+ } else {
505
+ parsed.dir = argument;
506
+ }
507
+ }
508
+ return parsed;
509
+ }
510
+
511
+ /**
512
+ * @typedef {{
513
+ * packageRoot: string,
514
+ * readConfig: () => Promise<unknown>,
515
+ * stateRoot: () => string,
516
+ * cwd?: string,
517
+ * env?: NodeJS.ProcessEnv,
518
+ * fetchImpl?: typeof fetch,
519
+ * log?: (line: string) => void,
520
+ * warn?: (line: string) => void
521
+ * }} InitDependencies
522
+ */
523
+
524
+ /** @param {unknown} config @returns {{ endpoint: string, token: string }} */
525
+ function accountOf(config) {
526
+ if (
527
+ !isRecord(config) ||
528
+ typeof config.endpoint !== 'string' ||
529
+ typeof config.token !== 'string' ||
530
+ config.token.length === 0
531
+ )
532
+ throw new Error('setup_required');
533
+ return { endpoint: config.endpoint, token: config.token };
534
+ }
535
+
536
+ /**
537
+ * The command. Throws with the error code as message; usage errors carry
538
+ * `exitCode` 2, everything else exits 1 through the CLI's handler.
539
+ * @param {readonly string[]} args
540
+ * @param {InitDependencies} deps
541
+ */
542
+ export async function initCommand(args, deps) {
543
+ const log = deps.log ?? ((line) => console.log(line));
544
+ const warn = deps.warn ?? ((line) => console.error(line));
545
+ const options = parseInitArguments(args);
546
+ const directory = resolve(deps.cwd ?? process.cwd(), options.dir ?? '.');
547
+ const existing = await stat(directory).catch(() => null);
548
+ if (existing !== null && !existing.isDirectory()) throw usageError('dir_not_directory');
549
+ const detected = options.stack ?? (existing === null ? 'none' : await detectStack(directory));
550
+ if (detected === 'ambiguous') throw usageError('stack_ambiguous');
551
+ const stack = detected;
552
+ const account = accountOf(await deps.readConfig());
553
+ const locale =
554
+ options.lang ?? resolvePackLocale(await readStoredLocale(deps.stateRoot()), deps.env);
555
+ const outcome = await obtainPack({
556
+ endpoint: account.endpoint,
557
+ token: account.token,
558
+ locale,
559
+ packageRoot: deps.packageRoot,
560
+ ...(deps.fetchImpl === undefined ? {} : { fetchImpl: deps.fetchImpl }),
561
+ });
562
+ if (outcome.outcome !== 'loaded') {
563
+ const notice = packNotice(outcome, locale);
564
+ if (notice !== null) warn(notice);
565
+ switch (outcome.outcome) {
566
+ case 'inactive':
567
+ throw new Error('license_inactive');
568
+ case 'not_offered':
569
+ throw new Error('engineering_not_offered');
570
+ case 'unavailable':
571
+ throw new Error(`pack_unavailable:${outcome.reason}`);
572
+ default:
573
+ throw new Error(`pack_rejected:${outcome.reason}`);
574
+ }
575
+ }
576
+ const templates = selectTemplates(loadInitTemplates(outcome.pack.files), stack);
577
+ if (templates.length === 0) {
578
+ warn(
579
+ locale === 'pt-BR'
580
+ ? `AISAC Engineering: o pacote ainda não traz os modelos do init; atualize o aisac (${UPDATE_COMMAND}) ou tente mais tarde.`
581
+ : `AISAC Engineering: the pack carries no init templates yet; update aisac (${UPDATE_COMMAND}) or try again later.`,
582
+ );
583
+ throw new Error('init_templates_missing');
584
+ }
585
+ if (existing === null && !options.dryRun)
586
+ await mkdir(directory, { recursive: true, mode: DIRECTORY_MODE });
587
+ const result = await applySkeleton({
588
+ directory,
589
+ templates,
590
+ projectName: projectNameOf(await realpath(directory).catch(() => directory)),
591
+ dryRun: options.dryRun,
592
+ });
593
+ const packVersion = outcome.pack.packVersion;
594
+ if (options.json) {
595
+ log(
596
+ JSON.stringify({
597
+ dir: result.directory,
598
+ locale,
599
+ stack,
600
+ packVersion,
601
+ dryRun: options.dryRun,
602
+ wrote: result.wrote,
603
+ skipped: result.skipped,
604
+ failed: result.failed,
605
+ nextSteps: nextSteps(locale, result),
606
+ }),
607
+ );
608
+ } else {
609
+ for (const line of initReport({ result, locale, stack, packVersion, dryRun: options.dryRun }))
610
+ log(line);
611
+ }
612
+ if (result.failed.length > 0) throw new Error('init_write_failed');
613
+ }
@@ -94,6 +94,11 @@ const FORBIDDEN_FRONT_MATTER_KEYS = new Set([
94
94
  const PLUGIN_NAME = 'aisac-engineering';
95
95
  const PLUGIN_LICENSE = 'LicenseRef-AISAC-Engineering';
96
96
  const PACK_DIRECTORY = 'engineering';
97
+ /**
98
+ * `aisac init` templates (spec aisac-engineering-init D2, E5c): pack files that
99
+ * never reach a session. Only `engineering-init.mjs` reads them.
100
+ */
101
+ export const INIT_PREFIX = 'init/';
97
102
  const OUTPUT_STYLES_PREFIX = 'output-styles/';
98
103
  const SKILLS_DIRECTORY = 'skills';
99
104
  const AGENT_FILE = /^agents\/([a-z][a-z0-9-]{0,63})\.md$/u;
@@ -534,7 +539,8 @@ const PLUGIN_DESCRIPTION = {
534
539
 
535
540
  /**
536
541
  * Write the pack files into `engineering/` of the launch's private directory:
537
- * `0700` directories, `0600` files, exclusive creation, contained paths.
542
+ * `0700` directories, `0600` files, exclusive creation, contained paths. The
543
+ * `init/` templates stay out: they are for `aisac init`, not for a session.
538
544
  * @param {VerifiedPack} pack
539
545
  * @param {string} launchDirectory
540
546
  * @returns {Promise<string>} the pack root
@@ -544,6 +550,7 @@ async function writePackFiles(pack, launchDirectory) {
544
550
  await mkdir(base, { mode: 0o700 });
545
551
  for (const file of pack.files) {
546
552
  if (!isValidPackPath(file.path)) throw new Error('pack_path_invalid');
553
+ if (file.path.startsWith(INIT_PREFIX)) continue;
547
554
  const target = resolve(base, ...file.path.split('/'));
548
555
  if (!target.startsWith(`${base}${sep}`)) throw new Error('pack_path_escape');
549
556
  await mkdir(dirname(target), { recursive: true, mode: 0o700 });
package/dist/package.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "@brunobracaioli/aibrain",
3
- "version": "0.4.0-beta.13",
3
+ "version": "0.4.0-beta.14",
4
4
  "type": "module"
5
5
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brunobracaioli/aibrain",
3
- "version": "0.4.0-beta.13",
3
+ "version": "0.4.0-beta.14",
4
4
  "private": false,
5
5
  "description": "AISAC client for Claude Code and Codex: AI Brain memory, context management and AISAC Engineering",
6
6
  "license": "UNLICENSED",