@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 +13 -0
- package/dist/claude-observation.mjs +1 -1
- package/dist/cli.mjs +17 -3
- package/dist/engineering-init.mjs +613 -0
- package/dist/engineering-pack.mjs +8 -1
- package/dist/package.json +1 -1
- package/package.json +1 -1
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.
|
|
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.
|
|
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
|
-
|
|
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
package/package.json
CHANGED