@brunobracaioli/aibrain 0.4.0-beta.2 → 0.4.0-beta.4
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 +19 -0
- package/dist/assets/codex-context-compaction.mjs +98 -1
- package/dist/claude-observation.mjs +1 -1
- package/dist/cli.mjs +20 -2
- package/dist/codex-engineering.mjs +165 -0
- package/dist/engineering-pack.mjs +139 -16
- package/dist/extensions-catalog.mjs +140 -0
- package/dist/extensions-cli.mjs +214 -0
- package/dist/extensions-preferences.mjs +122 -0
- package/dist/guardrails/continuity.mjs +239 -0
- package/dist/guardrails/format.mjs +111 -0
- package/dist/guardrails/guards.mjs +397 -0
- package/dist/guardrails/run.mjs +211 -0
- package/dist/guardrails/shell-words.mjs +206 -0
- package/dist/guardrails-plugin.mjs +149 -0
- package/dist/launch.mjs +41 -2
- package/dist/monitor.mjs +21 -0
- package/dist/package.json +1 -1
- package/package.json +1 -1
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* AISAC Engineering pack for Claude Code (ADR-0047 wave E4b
|
|
4
|
+
* AISAC Engineering pack for Claude Code (ADR-0047 wave E4b) and Codex (E4c),
|
|
5
|
+
* spec FR4/FR8.
|
|
5
6
|
*
|
|
6
7
|
* One request per launch fetches the account's pack; it doubles as the live
|
|
7
8
|
* license check. The pack is trusted instruction for the user's agent, so it
|
|
8
9
|
* is accepted only when it is signed by a pinned key, matches the requested
|
|
9
10
|
* locale and passes the FR3 rules again here. It is written into the launch's
|
|
10
|
-
* private directory and handed to Claude Code as a second `--plugin-dir
|
|
11
|
+
* private directory and handed to Claude Code as a second `--plugin-dir`, or
|
|
12
|
+
* to the Codex app-server this launch owns (see codex-engineering.mjs).
|
|
11
13
|
* Any failure starts the session without the pack; nothing here can block a
|
|
12
14
|
* launch or treat a failure as licensed.
|
|
13
15
|
*/
|
|
@@ -35,6 +37,13 @@ import {
|
|
|
35
37
|
unavailableReasonFor,
|
|
36
38
|
} from './engineering.mjs';
|
|
37
39
|
import { atomicJson } from './state.mjs';
|
|
40
|
+
import {
|
|
41
|
+
AGENT_NAME,
|
|
42
|
+
CODEX_AGENTS_DIRECTORY,
|
|
43
|
+
MAX_AGENT_DESCRIPTION_LENGTH,
|
|
44
|
+
tomlBasicString,
|
|
45
|
+
validateCodexEngineering,
|
|
46
|
+
} from './codex-engineering.mjs';
|
|
38
47
|
|
|
39
48
|
const PACK_PATH = '/engineering/v1/pack';
|
|
40
49
|
export const PACK_TIMEOUT_MS = 10_000;
|
|
@@ -86,6 +95,10 @@ const PLUGIN_NAME = 'aisac-engineering';
|
|
|
86
95
|
const PLUGIN_LICENSE = 'LicenseRef-AISAC-Engineering';
|
|
87
96
|
const PACK_DIRECTORY = 'engineering';
|
|
88
97
|
const OUTPUT_STYLES_PREFIX = 'output-styles/';
|
|
98
|
+
const SKILLS_DIRECTORY = 'skills';
|
|
99
|
+
const AGENT_FILE = /^agents\/([a-z][a-z0-9-]{0,63})\.md$/u;
|
|
100
|
+
const WATERMARK_KEY = 'aisac_watermark';
|
|
101
|
+
const WATERMARK_VALUE = /^[0-9a-f]{16}$/u;
|
|
89
102
|
export const PUBLIC_KEY_FILE = 'engineering-pack-public-key.json';
|
|
90
103
|
const LOCALE_FILE = 'engineering-locale.json';
|
|
91
104
|
const LAST_LAUNCH_FILE = 'engineering-last-launch.json';
|
|
@@ -113,11 +126,12 @@ const SAFE_REASON = /^[a-z][a-z0-9_]{0,63}$/u;
|
|
|
113
126
|
* | { outcome: 'unavailable', reason: string }
|
|
114
127
|
* | { outcome: 'rejected', reason: string }} PackOutcome
|
|
115
128
|
*/
|
|
129
|
+
/** @typedef {'claude' | 'codex'} PackClient */
|
|
116
130
|
/**
|
|
117
131
|
* @typedef {{
|
|
118
132
|
* schemaVersion: 1,
|
|
119
133
|
* at: string,
|
|
120
|
-
* client:
|
|
134
|
+
* client: PackClient,
|
|
121
135
|
* state: PackOutcome['outcome'],
|
|
122
136
|
* reason: string | null,
|
|
123
137
|
* packVersion: string | null,
|
|
@@ -198,7 +212,7 @@ export async function readLastLaunch(root) {
|
|
|
198
212
|
value.schemaVersion !== 1 ||
|
|
199
213
|
typeof value.at !== 'string' ||
|
|
200
214
|
!ISO_INSTANT.test(value.at) ||
|
|
201
|
-
value.client !== 'claude' ||
|
|
215
|
+
(value.client !== 'claude' && value.client !== 'codex') ||
|
|
202
216
|
!['loaded', 'inactive', 'not_offered', 'unavailable', 'rejected'].includes(
|
|
203
217
|
/** @type {string} */ (value.state),
|
|
204
218
|
) ||
|
|
@@ -519,12 +533,13 @@ const PLUGIN_DESCRIPTION = {
|
|
|
519
533
|
};
|
|
520
534
|
|
|
521
535
|
/**
|
|
522
|
-
* Write
|
|
523
|
-
*
|
|
536
|
+
* Write the pack files into `engineering/` of the launch's private directory:
|
|
537
|
+
* `0700` directories, `0600` files, exclusive creation, contained paths.
|
|
524
538
|
* @param {VerifiedPack} pack
|
|
525
539
|
* @param {string} launchDirectory
|
|
540
|
+
* @returns {Promise<string>} the pack root
|
|
526
541
|
*/
|
|
527
|
-
|
|
542
|
+
async function writePackFiles(pack, launchDirectory) {
|
|
528
543
|
const base = resolve(launchDirectory, PACK_DIRECTORY);
|
|
529
544
|
await mkdir(base, { mode: 0o700 });
|
|
530
545
|
for (const file of pack.files) {
|
|
@@ -534,6 +549,17 @@ export async function materializePack(pack, launchDirectory) {
|
|
|
534
549
|
await mkdir(dirname(target), { recursive: true, mode: 0o700 });
|
|
535
550
|
await writeFile(target, file.content, { mode: 0o600, flag: 'wx' });
|
|
536
551
|
}
|
|
552
|
+
return base;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/**
|
|
556
|
+
* Write a verified pack as a Claude Code plugin inside the launch's private
|
|
557
|
+
* directory and return the plugin root. The manifest is the client's own.
|
|
558
|
+
* @param {VerifiedPack} pack
|
|
559
|
+
* @param {string} launchDirectory
|
|
560
|
+
*/
|
|
561
|
+
export async function materializePack(pack, launchDirectory) {
|
|
562
|
+
const base = await writePackFiles(pack, launchDirectory);
|
|
537
563
|
const manifest = {
|
|
538
564
|
name: PLUGIN_NAME,
|
|
539
565
|
version: `0.0.0-${pack.packVersion}`,
|
|
@@ -553,6 +579,92 @@ export async function materializePack(pack, launchDirectory) {
|
|
|
553
579
|
return base;
|
|
554
580
|
}
|
|
555
581
|
|
|
582
|
+
/**
|
|
583
|
+
* Front-matter values (one `key: value` per line, lower-cased keys, matching
|
|
584
|
+
* outer quotes removed) and the body after it; null without front matter.
|
|
585
|
+
* @param {string} content
|
|
586
|
+
* @returns {{ values: Map<string, string>, body: string } | null}
|
|
587
|
+
*/
|
|
588
|
+
export function splitFrontMatter(content) {
|
|
589
|
+
if (!content.startsWith(FRONT_MATTER_FENCE)) return null;
|
|
590
|
+
const end = content.indexOf(FRONT_MATTER_CLOSE, FRONT_MATTER_FENCE.length - 1);
|
|
591
|
+
if (end === -1) return null;
|
|
592
|
+
/** @type {Map<string, string>} */
|
|
593
|
+
const values = new Map();
|
|
594
|
+
for (const line of content.slice(FRONT_MATTER_FENCE.length, end + 1).split('\n')) {
|
|
595
|
+
const key = FRONT_MATTER_KEY.exec(line)?.[1];
|
|
596
|
+
if (key === undefined) continue;
|
|
597
|
+
let value = line.slice(line.indexOf(':') + 1).trim();
|
|
598
|
+
if (value.length >= 2 && (value[0] === '"' || value[0] === "'") && value.at(-1) === value[0])
|
|
599
|
+
value = value.slice(1, -1);
|
|
600
|
+
values.set(key.toLowerCase(), value);
|
|
601
|
+
}
|
|
602
|
+
return { values, body: content.slice(end + FRONT_MATTER_CLOSE.length).replace(/^\n+/u, '') };
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/**
|
|
606
|
+
* A Codex agent role file from a pack agent (`agents/<name>.md`): the front
|
|
607
|
+
* matter's name and description, the body as developer instructions, and the
|
|
608
|
+
* origin mark kept as a comment. Null when the agent cannot be a role.
|
|
609
|
+
* @param {PackFile} file
|
|
610
|
+
* @returns {{ name: string, description: string, toml: string } | null}
|
|
611
|
+
*/
|
|
612
|
+
export function codexRoleOf(file) {
|
|
613
|
+
const fileName = AGENT_FILE.exec(file.path)?.[1];
|
|
614
|
+
const parsed = splitFrontMatter(file.content);
|
|
615
|
+
if (fileName === undefined || parsed === null) return null;
|
|
616
|
+
const name = parsed.values.get('name');
|
|
617
|
+
const description = parsed.values.get('description');
|
|
618
|
+
if (name !== fileName || !AGENT_NAME.test(name)) return null;
|
|
619
|
+
if (
|
|
620
|
+
description === undefined ||
|
|
621
|
+
description.length === 0 ||
|
|
622
|
+
description.length > MAX_AGENT_DESCRIPTION_LENGTH ||
|
|
623
|
+
description === '>' ||
|
|
624
|
+
description === '|' ||
|
|
625
|
+
parsed.body.trim().length === 0
|
|
626
|
+
)
|
|
627
|
+
return null;
|
|
628
|
+
const watermark = parsed.values.get(WATERMARK_KEY);
|
|
629
|
+
const lines = [
|
|
630
|
+
'# AISAC Engineering. Licensed to your account; do not redistribute.',
|
|
631
|
+
...(watermark !== undefined && WATERMARK_VALUE.test(watermark)
|
|
632
|
+
? [`# ${WATERMARK_KEY}: ${watermark}`]
|
|
633
|
+
: []),
|
|
634
|
+
`name = ${tomlBasicString(name)}`,
|
|
635
|
+
`description = ${tomlBasicString(description)}`,
|
|
636
|
+
`developer_instructions = ${tomlBasicString(parsed.body)}`,
|
|
637
|
+
];
|
|
638
|
+
return { name, description, toml: `${lines.join('\n')}\n` };
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
/**
|
|
642
|
+
* Write a verified pack for the Codex app-server this launch owns: the files
|
|
643
|
+
* as for Claude (without the Claude manifest), one role file per agent in
|
|
644
|
+
* `codex-agents/`, and the description the monitor turns into session
|
|
645
|
+
* arguments. Output styles have no Codex equivalent and stay unused.
|
|
646
|
+
* @param {VerifiedPack} pack
|
|
647
|
+
* @param {string} launchDirectory
|
|
648
|
+
* @returns {Promise<import('./codex-engineering.mjs').CodexEngineering>}
|
|
649
|
+
*/
|
|
650
|
+
export async function materializeCodexPack(pack, launchDirectory) {
|
|
651
|
+
const base = await writePackFiles(pack, launchDirectory);
|
|
652
|
+
const skillsRoot = join(base, SKILLS_DIRECTORY);
|
|
653
|
+
await mkdir(skillsRoot, { recursive: true, mode: 0o700 });
|
|
654
|
+
const rolesDirectory = join(base, CODEX_AGENTS_DIRECTORY);
|
|
655
|
+
await mkdir(rolesDirectory, { mode: 0o700 });
|
|
656
|
+
/** @type {import('./codex-engineering.mjs').CodexAgentRole[]} */
|
|
657
|
+
const agents = [];
|
|
658
|
+
for (const file of pack.files) {
|
|
659
|
+
const role = codexRoleOf(file);
|
|
660
|
+
if (role === null) continue;
|
|
661
|
+
const configFile = join(rolesDirectory, `${role.name}.toml`);
|
|
662
|
+
await writeFile(configFile, role.toml, { mode: 0o600, flag: 'wx' });
|
|
663
|
+
agents.push({ name: role.name, description: role.description, configFile });
|
|
664
|
+
}
|
|
665
|
+
return validateCodexEngineering({ schemaVersion: 1, skillsRoot, agents });
|
|
666
|
+
}
|
|
667
|
+
|
|
556
668
|
/**
|
|
557
669
|
* One line for the launch, in the pack locale; null when the capability is
|
|
558
670
|
* not offered, so memory-only users see no change.
|
|
@@ -594,13 +706,14 @@ export function packNotice(outcome, locale) {
|
|
|
594
706
|
/**
|
|
595
707
|
* @param {PackOutcome} outcome
|
|
596
708
|
* @param {PackLocale} locale
|
|
709
|
+
* @param {PackClient} client
|
|
597
710
|
* @returns {LastLaunch}
|
|
598
711
|
*/
|
|
599
|
-
function lastLaunchOf(outcome, locale) {
|
|
712
|
+
function lastLaunchOf(outcome, locale, client) {
|
|
600
713
|
return {
|
|
601
714
|
schemaVersion: 1,
|
|
602
715
|
at: new Date().toISOString(),
|
|
603
|
-
client
|
|
716
|
+
client,
|
|
604
717
|
state: outcome.outcome,
|
|
605
718
|
reason:
|
|
606
719
|
outcome.outcome === 'unavailable' || outcome.outcome === 'rejected' ? outcome.reason : null,
|
|
@@ -610,13 +723,17 @@ function lastLaunchOf(outcome, locale) {
|
|
|
610
723
|
}
|
|
611
724
|
|
|
612
725
|
/**
|
|
613
|
-
* Fetch, verify and write the pack for one Claude Code launch. Never
|
|
726
|
+
* Fetch, verify and write the pack for one Claude Code or Codex launch. Never
|
|
727
|
+
* throws. `hostSupported: false` (a Codex release older than the verified
|
|
728
|
+
* mechanism) still checks the license but loads nothing.
|
|
614
729
|
* @param {{ endpoint: string, token: string, root: string, directory: string,
|
|
615
|
-
* packageRoot: string,
|
|
616
|
-
* timeoutMs?: number }} input
|
|
617
|
-
* @returns {Promise<{ pluginRoot: string | null,
|
|
730
|
+
* packageRoot: string, client?: PackClient, hostSupported?: boolean,
|
|
731
|
+
* env?: NodeJS.ProcessEnv, fetchImpl?: typeof fetch, timeoutMs?: number }} input
|
|
732
|
+
* @returns {Promise<{ pluginRoot: string | null,
|
|
733
|
+
* codex: import('./codex-engineering.mjs').CodexEngineering | null, line: string | null }>}
|
|
618
734
|
*/
|
|
619
735
|
export async function prepareEngineeringPack(input) {
|
|
736
|
+
const client = input.client ?? 'claude';
|
|
620
737
|
const locale = resolvePackLocale(await readStoredLocale(input.root), input.env);
|
|
621
738
|
/** @type {PackOutcome} */
|
|
622
739
|
let outcome;
|
|
@@ -632,21 +749,27 @@ export async function prepareEngineeringPack(input) {
|
|
|
632
749
|
} catch {
|
|
633
750
|
outcome = { outcome: 'unavailable', reason: 'client_error' };
|
|
634
751
|
}
|
|
752
|
+
if (outcome.outcome === 'loaded' && input.hostSupported === false)
|
|
753
|
+
outcome = { outcome: 'unavailable', reason: `${client}_outdated` };
|
|
635
754
|
let pluginRoot = null;
|
|
755
|
+
/** @type {import('./codex-engineering.mjs').CodexEngineering | null} */
|
|
756
|
+
let codex = null;
|
|
636
757
|
if (outcome.outcome === 'loaded') {
|
|
637
758
|
try {
|
|
638
|
-
|
|
759
|
+
if (client === 'codex') codex = await materializeCodexPack(outcome.pack, input.directory);
|
|
760
|
+
else pluginRoot = await materializePack(outcome.pack, input.directory);
|
|
639
761
|
} catch {
|
|
762
|
+
codex = null;
|
|
640
763
|
await rm(join(input.directory, PACK_DIRECTORY), { recursive: true, force: true }).catch(
|
|
641
764
|
() => undefined,
|
|
642
765
|
);
|
|
643
766
|
outcome = { outcome: 'rejected', reason: 'materialize_failed' };
|
|
644
767
|
}
|
|
645
768
|
}
|
|
646
|
-
await atomicJson(join(input.root, LAST_LAUNCH_FILE), lastLaunchOf(outcome, locale)).catch(
|
|
769
|
+
await atomicJson(join(input.root, LAST_LAUNCH_FILE), lastLaunchOf(outcome, locale, client)).catch(
|
|
647
770
|
() => undefined,
|
|
648
771
|
);
|
|
649
|
-
return { pluginRoot, line: packNotice(outcome, locale) };
|
|
772
|
+
return { pluginRoot, codex, line: packNotice(outcome, locale) };
|
|
650
773
|
}
|
|
651
774
|
|
|
652
775
|
/**
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* The AISAC Engineering extension catalog (spec aisac-engineering-extensions,
|
|
4
|
+
* FR1, FR2). It ships with the client and is the only source of hooks: the
|
|
5
|
+
* server and the licensed pack can neither add nor change entries (ADR-0048).
|
|
6
|
+
* The MCP catalog arrives in E6c.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** @typedef {'pt-BR' | 'en'} CatalogLocale */
|
|
10
|
+
/** @typedef {'PreToolUse' | 'PostToolUse' | 'UserPromptSubmit' | 'SessionStart' | 'PreCompact' | 'Stop'} HookEvent */
|
|
11
|
+
/** @typedef {'block' | 'advise' | 'context'} HookMode */
|
|
12
|
+
/**
|
|
13
|
+
* @typedef {object} HookBinding
|
|
14
|
+
* @property {HookEvent} event
|
|
15
|
+
* @property {string} [matcher] Tool names (PreToolUse, PostToolUse) or SessionStart sources.
|
|
16
|
+
*/
|
|
17
|
+
/**
|
|
18
|
+
* @typedef {object} HookEntry
|
|
19
|
+
* @property {string} id
|
|
20
|
+
* @property {HookMode} mode
|
|
21
|
+
* @property {boolean} defaultOn
|
|
22
|
+
* @property {readonly ('claude' | 'codex')[]} clients
|
|
23
|
+
* @property {readonly HookBinding[]} bindings
|
|
24
|
+
* @property {Readonly<Record<CatalogLocale, string>>} description
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
export const CATALOG_ID = /^[a-z][a-z0-9-]{0,31}$/u;
|
|
28
|
+
|
|
29
|
+
const EDIT_TOOLS = 'Edit|MultiEdit|Write';
|
|
30
|
+
|
|
31
|
+
/** @type {readonly HookEntry[]} */
|
|
32
|
+
export const HOOK_CATALOG = Object.freeze([
|
|
33
|
+
{
|
|
34
|
+
id: 'secrets-guard',
|
|
35
|
+
mode: 'block',
|
|
36
|
+
defaultOn: true,
|
|
37
|
+
clients: ['claude'],
|
|
38
|
+
bindings: [
|
|
39
|
+
{ event: 'PreToolUse', matcher: 'Read|Edit|MultiEdit|Write|NotebookEdit|Grep|Bash' },
|
|
40
|
+
],
|
|
41
|
+
description: {
|
|
42
|
+
en: 'Refuses reading or writing secret files (.env, keys, credentials); decides from names only.',
|
|
43
|
+
'pt-BR':
|
|
44
|
+
'Recusa ler ou gravar arquivos de segredo (.env, chaves, credenciais); decide só pelo nome.',
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
id: 'git-guard',
|
|
49
|
+
mode: 'block',
|
|
50
|
+
defaultOn: false,
|
|
51
|
+
clients: ['claude'],
|
|
52
|
+
bindings: [{ event: 'PreToolUse', matcher: 'Bash' }],
|
|
53
|
+
description: {
|
|
54
|
+
en: 'Refuses git push to main or master and force pushes (--force-with-lease passes).',
|
|
55
|
+
'pt-BR': 'Recusa git push na main ou master e push forçado (--force-with-lease passa).',
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
id: 'destructive-guard',
|
|
60
|
+
mode: 'block',
|
|
61
|
+
defaultOn: false,
|
|
62
|
+
clients: ['claude'],
|
|
63
|
+
bindings: [{ event: 'PreToolUse', matcher: 'Bash' }],
|
|
64
|
+
description: {
|
|
65
|
+
en: 'Refuses rm -rf on /, ~, . or .. and DROP DATABASE, DROP TABLE or TRUNCATE.',
|
|
66
|
+
'pt-BR': 'Recusa rm -rf em /, ~, . ou .. e DROP DATABASE, DROP TABLE ou TRUNCATE.',
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
id: 'format-on-edit',
|
|
71
|
+
mode: 'advise',
|
|
72
|
+
defaultOn: false,
|
|
73
|
+
clients: ['claude'],
|
|
74
|
+
bindings: [{ event: 'PostToolUse', matcher: EDIT_TOOLS }],
|
|
75
|
+
description: {
|
|
76
|
+
en: "Formats the edited file with the project's own formatter, when one is installed.",
|
|
77
|
+
'pt-BR': 'Formata o arquivo editado com o formatador do próprio projeto, quando houver.',
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
id: 'docs-reminder',
|
|
82
|
+
mode: 'advise',
|
|
83
|
+
defaultOn: true,
|
|
84
|
+
clients: ['claude'],
|
|
85
|
+
bindings: [{ event: 'PostToolUse', matcher: EDIT_TOOLS }, { event: 'UserPromptSubmit' }],
|
|
86
|
+
description: {
|
|
87
|
+
en: 'After a source file changes in a project with docs/, reminds once per turn to update the docs.',
|
|
88
|
+
'pt-BR':
|
|
89
|
+
'Quando um arquivo de código muda num projeto com docs/, lembra uma vez por turno de atualizar a documentação.',
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
id: 'session-context',
|
|
94
|
+
mode: 'context',
|
|
95
|
+
defaultOn: true,
|
|
96
|
+
clients: ['claude'],
|
|
97
|
+
bindings: [{ event: 'SessionStart', matcher: 'startup|resume|clear' }],
|
|
98
|
+
description: {
|
|
99
|
+
en: 'Starts the session with a short repository snapshot (branch, status, recent commits).',
|
|
100
|
+
'pt-BR': 'Abre a sessão com um resumo do repositório (branch, status, commits recentes).',
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
id: 'compact-continuity',
|
|
105
|
+
mode: 'context',
|
|
106
|
+
defaultOn: true,
|
|
107
|
+
clients: ['claude'],
|
|
108
|
+
bindings: [{ event: 'PreCompact' }, { event: 'SessionStart', matcher: 'compact' }],
|
|
109
|
+
description: {
|
|
110
|
+
en: 'Saves a repository snapshot before a compaction and restores it right after.',
|
|
111
|
+
'pt-BR': 'Salva um resumo do repositório antes do compact e o devolve logo depois.',
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
id: 'continuity-reminder',
|
|
116
|
+
mode: 'block',
|
|
117
|
+
defaultOn: true,
|
|
118
|
+
clients: ['claude'],
|
|
119
|
+
bindings: [
|
|
120
|
+
{ event: 'PostToolUse', matcher: EDIT_TOOLS },
|
|
121
|
+
{ event: 'UserPromptSubmit' },
|
|
122
|
+
{ event: 'Stop' },
|
|
123
|
+
],
|
|
124
|
+
description: {
|
|
125
|
+
en: 'In a project with NOTES.md, asks once per turn to update NOTES.md, the spec or the CHANGELOG after code changes.',
|
|
126
|
+
'pt-BR':
|
|
127
|
+
'Num projeto com NOTES.md, pede uma vez por turno para atualizar NOTES.md, a spec ou o CHANGELOG depois de mudanças no código.',
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
]);
|
|
131
|
+
|
|
132
|
+
/** @param {string} id @returns {HookEntry | undefined} */
|
|
133
|
+
export function hookById(id) {
|
|
134
|
+
return HOOK_CATALOG.find((entry) => entry.id === id);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** @returns {string[]} */
|
|
138
|
+
export function hookIds() {
|
|
139
|
+
return HOOK_CATALOG.map((entry) => entry.id);
|
|
140
|
+
}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* `aisac hooks` (spec aisac-engineering-extensions FR4): list, enable,
|
|
4
|
+
* disable and reset guardrail hooks, offline. On a TTY without arguments it
|
|
5
|
+
* opens a checklist. Choices apply to the next session; running sessions
|
|
6
|
+
* keep their set.
|
|
7
|
+
*/
|
|
8
|
+
import { emitKeypressEvents } from 'node:readline';
|
|
9
|
+
import { HOOK_CATALOG, hookById, hookIds } from './extensions-catalog.mjs';
|
|
10
|
+
import {
|
|
11
|
+
readPreferences,
|
|
12
|
+
resolveHooks,
|
|
13
|
+
unknownHookIds,
|
|
14
|
+
writePreferences,
|
|
15
|
+
} from './extensions-preferences.mjs';
|
|
16
|
+
import { readLastLaunch, readStoredLocale, resolvePackLocale } from './engineering-pack.mjs';
|
|
17
|
+
import { stateRoot } from './state.mjs';
|
|
18
|
+
|
|
19
|
+
const USAGE_EXIT_CODE = 2;
|
|
20
|
+
const ID_WIDTH = Math.max(...HOOK_CATALOG.map((entry) => entry.id.length));
|
|
21
|
+
const NEXT_SESSION =
|
|
22
|
+
'Applies to the next `aisac claude` session; running sessions keep their current set.';
|
|
23
|
+
const LICENSE_NOTE = 'Hooks load only with an active AISAC Engineering license.';
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @typedef {object} ChecklistState
|
|
27
|
+
* @property {number} cursor
|
|
28
|
+
* @property {boolean[]} enabled
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* One key press in the checklist (pure, tested without a TTY).
|
|
33
|
+
* @param {ChecklistState} state
|
|
34
|
+
* @param {{ name?: string, ctrl?: boolean, sequence?: string }} key
|
|
35
|
+
* @returns {{ state: ChecklistState, done: 'save' | 'cancel' | null }}
|
|
36
|
+
*/
|
|
37
|
+
export function checklistStep(state, key) {
|
|
38
|
+
const last = state.enabled.length - 1;
|
|
39
|
+
if ((key.ctrl === true && key.name === 'c') || key.name === 'escape' || key.name === 'q')
|
|
40
|
+
return { state, done: 'cancel' };
|
|
41
|
+
if (key.name === 'return' || key.name === 'enter') return { state, done: 'save' };
|
|
42
|
+
if (key.name === 'up' || key.name === 'k')
|
|
43
|
+
return { state: { ...state, cursor: Math.max(0, state.cursor - 1) }, done: null };
|
|
44
|
+
if (key.name === 'down' || key.name === 'j')
|
|
45
|
+
return { state: { ...state, cursor: Math.min(last, state.cursor + 1) }, done: null };
|
|
46
|
+
if (key.name === 'space' || key.sequence === ' ') {
|
|
47
|
+
const enabled = state.enabled.map((value, index) => (index === state.cursor ? !value : value));
|
|
48
|
+
return { state: { ...state, enabled }, done: null };
|
|
49
|
+
}
|
|
50
|
+
return { state, done: null };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** @param {'pt-BR' | 'en'} locale @param {ReturnType<typeof resolveHooks>} hooks @param {ChecklistState} [state] */
|
|
54
|
+
function renderLines(locale, hooks, state) {
|
|
55
|
+
return hooks.map((hook, index) => {
|
|
56
|
+
const enabled = state === undefined ? hook.enabled : state.enabled[index];
|
|
57
|
+
const pointer = state === undefined ? ' ' : index === state.cursor ? '>' : ' ';
|
|
58
|
+
const source = state === undefined ? ` ${hook.source.padEnd(7)}` : '';
|
|
59
|
+
return `${pointer} [${enabled ? 'x' : ' '}] ${hook.entry.id.padEnd(ID_WIDTH)} ${hook.entry.mode.padEnd(8)}${source} ${hook.entry.description[locale]}`;
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** @param {string} root */
|
|
64
|
+
async function licensedLastLaunch(root) {
|
|
65
|
+
const last = await readLastLaunch(root);
|
|
66
|
+
return last?.state === 'loaded';
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Store explicit choices, keeping MCP choices as they are.
|
|
71
|
+
* @param {Record<string, boolean>} hooks
|
|
72
|
+
*/
|
|
73
|
+
async function saveHooks(hooks) {
|
|
74
|
+
const current = await readPreferences(stateRoot());
|
|
75
|
+
await writePreferences({ hooks, mcp: current.status === 'valid' ? current.mcp : {} });
|
|
76
|
+
if (current.status === 'invalid')
|
|
77
|
+
console.log('The previous extensions file was invalid and was replaced.');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** @param {string[]} ids */
|
|
81
|
+
function unknownIds(ids) {
|
|
82
|
+
return ids.filter((id) => hookById(id) === undefined);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** @param {boolean} licensed */
|
|
86
|
+
function afterChange(licensed) {
|
|
87
|
+
console.log(NEXT_SESSION);
|
|
88
|
+
if (!licensed) console.log(LICENSE_NOTE);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Interactive checklist. Resolves to the new enabled flags, or null on cancel.
|
|
93
|
+
* @param {'pt-BR' | 'en'} locale @param {ReturnType<typeof resolveHooks>} hooks
|
|
94
|
+
* @returns {Promise<boolean[] | null>}
|
|
95
|
+
*/
|
|
96
|
+
function runChecklist(locale, hooks) {
|
|
97
|
+
return new Promise((resolve) => {
|
|
98
|
+
let state = { cursor: 0, enabled: hooks.map((hook) => hook.enabled) };
|
|
99
|
+
const header = 'AISAC Engineering hooks: space toggles, enter saves, q cancels.';
|
|
100
|
+
const draw = (/** @type {boolean} */ first) => {
|
|
101
|
+
if (!first) process.stdout.write(`\u001b[${hooks.length}A\u001b[J`);
|
|
102
|
+
process.stdout.write(`${renderLines(locale, hooks, state).join('\n')}\n`);
|
|
103
|
+
};
|
|
104
|
+
console.log(header);
|
|
105
|
+
draw(true);
|
|
106
|
+
emitKeypressEvents(process.stdin);
|
|
107
|
+
process.stdin.setRawMode(true);
|
|
108
|
+
process.stdin.resume();
|
|
109
|
+
/** @param {string} _text @param {{ name?: string, ctrl?: boolean, sequence?: string }} key */
|
|
110
|
+
const onKey = (_text, key) => {
|
|
111
|
+
const step = checklistStep(state, key ?? {});
|
|
112
|
+
state = step.state;
|
|
113
|
+
if (step.done === null) return draw(false);
|
|
114
|
+
process.stdin.off('keypress', onKey);
|
|
115
|
+
process.stdin.setRawMode(false);
|
|
116
|
+
process.stdin.pause();
|
|
117
|
+
resolve(step.done === 'save' ? state.enabled : null);
|
|
118
|
+
};
|
|
119
|
+
process.stdin.on('keypress', onKey);
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* `aisac hooks [list|enable|disable|reset] [ids...] [--json]`.
|
|
125
|
+
* @param {string[]} args
|
|
126
|
+
*/
|
|
127
|
+
export async function hooksCommand(args) {
|
|
128
|
+
const json = args.includes('--json');
|
|
129
|
+
const words = args.filter((arg) => arg !== '--json');
|
|
130
|
+
const [action = 'list', ...ids] = words;
|
|
131
|
+
const root = stateRoot();
|
|
132
|
+
const locale = resolvePackLocale(await readStoredLocale(root));
|
|
133
|
+
const preferences = await readPreferences(root);
|
|
134
|
+
const hooks = resolveHooks(preferences);
|
|
135
|
+
const licensed = await licensedLastLaunch(root);
|
|
136
|
+
|
|
137
|
+
if (action === 'list' && ids.length === 0) {
|
|
138
|
+
if (json) {
|
|
139
|
+
console.log(
|
|
140
|
+
JSON.stringify({
|
|
141
|
+
preferences: preferences.status,
|
|
142
|
+
licensedLastLaunch: licensed,
|
|
143
|
+
hooks: hooks.map((hook) => ({
|
|
144
|
+
id: hook.entry.id,
|
|
145
|
+
enabled: hook.enabled,
|
|
146
|
+
source: hook.source,
|
|
147
|
+
mode: hook.entry.mode,
|
|
148
|
+
clients: hook.entry.clients,
|
|
149
|
+
description: hook.entry.description[locale],
|
|
150
|
+
})),
|
|
151
|
+
unknown: unknownHookIds(preferences),
|
|
152
|
+
}),
|
|
153
|
+
);
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
if (args.length === 0 && process.stdin.isTTY && process.stdout.isTTY) {
|
|
157
|
+
const chosen = await runChecklist(locale, hooks);
|
|
158
|
+
if (chosen === null) return console.log('No change.');
|
|
159
|
+
/** @type {Record<string, boolean>} */
|
|
160
|
+
const choices = preferences.status === 'valid' ? { ...preferences.hooks } : {};
|
|
161
|
+
hooks.forEach((hook, index) => {
|
|
162
|
+
if (chosen[index] !== hook.enabled) choices[hook.entry.id] = chosen[index] === true;
|
|
163
|
+
});
|
|
164
|
+
await saveHooks(choices);
|
|
165
|
+
return afterChange(licensed);
|
|
166
|
+
}
|
|
167
|
+
console.log('AISAC Engineering hooks (Claude Code):');
|
|
168
|
+
console.log(renderLines(locale, hooks).join('\n'));
|
|
169
|
+
if (preferences.status === 'invalid')
|
|
170
|
+
console.log('The extensions file is invalid: the defaults apply. Run `aisac hooks reset`.');
|
|
171
|
+
console.log(
|
|
172
|
+
'Change with `aisac hooks enable <id>`, `aisac hooks disable <id>` or `aisac hooks reset`.',
|
|
173
|
+
);
|
|
174
|
+
if (!licensed) console.log(LICENSE_NOTE);
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
if (action === 'reset' && ids.length === 0) {
|
|
178
|
+
await saveHooks({});
|
|
179
|
+
console.log('Hooks reset to the defaults.');
|
|
180
|
+
return afterChange(licensed);
|
|
181
|
+
}
|
|
182
|
+
if ((action === 'enable' || action === 'disable') && ids.length > 0) {
|
|
183
|
+
const unknown = unknownIds(ids);
|
|
184
|
+
if (unknown.length > 0) {
|
|
185
|
+
console.error(
|
|
186
|
+
`aisac: unknown hook id: ${unknown.join(', ')}. Valid: ${hookIds().join(', ')}.`,
|
|
187
|
+
);
|
|
188
|
+
process.exitCode = USAGE_EXIT_CODE;
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
/** @type {Record<string, boolean>} */
|
|
192
|
+
const choices = preferences.status === 'valid' ? { ...preferences.hooks } : {};
|
|
193
|
+
for (const id of ids) choices[id] = action === 'enable';
|
|
194
|
+
await saveHooks(choices);
|
|
195
|
+
console.log(`${action === 'enable' ? 'Enabled' : 'Disabled'}: ${ids.join(', ')}.`);
|
|
196
|
+
return afterChange(licensed);
|
|
197
|
+
}
|
|
198
|
+
console.error(
|
|
199
|
+
'aisac: usage: aisac hooks [--json] | aisac hooks enable <id>... | aisac hooks disable <id>... | aisac hooks reset',
|
|
200
|
+
);
|
|
201
|
+
process.exitCode = USAGE_EXIT_CODE;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Offline extension summary for `status` and `doctor`. @param {string} root */
|
|
205
|
+
export async function extensionsSummary(root) {
|
|
206
|
+
const preferences = await readPreferences(root);
|
|
207
|
+
return {
|
|
208
|
+
preferences: preferences.status,
|
|
209
|
+
hooks: resolveHooks(preferences)
|
|
210
|
+
.filter((hook) => hook.enabled)
|
|
211
|
+
.map((hook) => hook.entry.id),
|
|
212
|
+
unknown: unknownHookIds(preferences),
|
|
213
|
+
};
|
|
214
|
+
}
|