@enki-dev-expert/global-engineering-standards 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/AGENTS.md +303 -0
  2. package/CHANGELOG.md +28 -0
  3. package/CITATION.cff +21 -0
  4. package/CODE_OF_CONDUCT.md +13 -0
  5. package/CONTRIBUTING.md +27 -0
  6. package/LICENSE +21 -0
  7. package/README.md +159 -2
  8. package/SECURITY.md +25 -0
  9. package/dist/cli.js +40 -0
  10. package/dist/content.js +71 -0
  11. package/dist/filesystem.js +83 -0
  12. package/dist/installer.js +243 -0
  13. package/dist/layout.js +42 -0
  14. package/dist/transaction.js +143 -0
  15. package/docs/ai-and-llm.md +1312 -0
  16. package/docs/architecture.md +1007 -0
  17. package/docs/cost-engineering.md +1322 -0
  18. package/docs/data.md +993 -0
  19. package/docs/governance.md +1744 -0
  20. package/docs/mobile-and-web.md +1362 -0
  21. package/docs/observability.md +1345 -0
  22. package/docs/platforms-and-applications.md +1581 -0
  23. package/docs/privacy.md +566 -0
  24. package/docs/quality-and-testing.md +1278 -0
  25. package/docs/scalability.md +1012 -0
  26. package/docs/security.md +373 -0
  27. package/handbook/claude-plugin.md +31 -0
  28. package/handbook/compatibility.md +27 -0
  29. package/handbook/recovery.md +29 -0
  30. package/handbook/releasing.md +50 -0
  31. package/handbook/security-review.md +41 -0
  32. package/handbook/signing.md +18 -0
  33. package/handbook/validation.md +50 -0
  34. package/package.json +65 -4
  35. package/scripts/content-baseline.json +25 -0
  36. package/skills/engineering-architecture-review/SKILL.md +1531 -0
  37. package/skills/engineering-cost-estimate/SKILL.md +1599 -0
  38. package/skills/engineering-database-review/SKILL.md +1364 -0
  39. package/skills/engineering-privacy-check/SKILL.md +1295 -0
  40. package/skills/engineering-safe-implementation/SKILL.md +1502 -0
  41. package/skills/engineering-security-standards-check/SKILL.md +1112 -0
  42. package/src/cli.ts +45 -0
  43. package/src/content.ts +82 -0
  44. package/src/filesystem.ts +94 -0
  45. package/src/installer.ts +280 -0
  46. package/src/layout.ts +70 -0
  47. package/src/transaction.ts +167 -0
  48. package/templates/ADR.md +1302 -0
  49. package/templates/AUDIT_REPORT.md +1693 -0
  50. package/templates/EXCEPTIONS.md +647 -0
  51. package/templates/PROJECT_AGENTS.md +868 -0
package/src/cli.ts ADDED
@@ -0,0 +1,45 @@
1
+ #!/usr/bin/env node
2
+ import { type Command, run } from './installer.js';
3
+ import { type Agent, defaults } from './layout.js';
4
+
5
+ const help = `Global Engineering Standards\n\nUsage: engineering-standards <command> [options]\n\nCommands: init, init-project, update, uninstall, doctor, rollback\nOptions: --global | --project, --claude, --codex, --dry-run, --help\n\nDefault: current project, both agents. init-project implies --project.\nUpdate uses the content bundled with the CLI version you are running; it does not download.\nUninstall and rollback apply to the entire selected scope.\nExisting user instructions and unrelated skills are preserved.\n`;
6
+ export function main(args: string[]): void {
7
+ if (!args.length || args.includes('--help')) {
8
+ process.stdout.write(help);
9
+ return;
10
+ }
11
+ const command = args[0] as Command;
12
+ if (!['init', 'init-project', 'update', 'uninstall', 'doctor', 'rollback'].includes(command))
13
+ throw new Error(`Unknown command: ${command}`);
14
+ const flags = args.slice(1);
15
+ for (const flag of flags)
16
+ if (!['--global', '--project', '--claude', '--codex', '--dry-run'].includes(flag))
17
+ throw new Error(`Unknown option: ${flag}`);
18
+ if (new Set(flags).size !== flags.length) throw new Error('Repeated option');
19
+ if (
20
+ (flags.includes('--global') && flags.includes('--project')) ||
21
+ (command === 'init-project' && flags.includes('--global'))
22
+ )
23
+ throw new Error('Conflicting installation scopes');
24
+ if (
25
+ ['uninstall', 'rollback', 'doctor'].includes(command) &&
26
+ (flags.includes('--claude') || flags.includes('--codex'))
27
+ )
28
+ throw new Error(`${command} applies to the whole scope; omit agent filters.`);
29
+ const selected = (['claude', 'codex'] as Agent[]).filter((x) => flags.includes(`--${x}`));
30
+ const result = run(command, {
31
+ ...defaults(),
32
+ scope: flags.includes('--global') ? 'global' : 'project',
33
+ agents: selected.length ? selected : command === 'update' ? [] : ['claude', 'codex'],
34
+ dryRun: flags.includes('--dry-run'),
35
+ });
36
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
37
+ }
38
+ try {
39
+ main(process.argv.slice(2));
40
+ } catch (error) {
41
+ process.stderr.write(
42
+ `engineering-standards: ${error instanceof Error ? error.message : String(error)}\n`,
43
+ );
44
+ process.exitCode = 1;
45
+ }
package/src/content.ts ADDED
@@ -0,0 +1,82 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { readdirSync, readFileSync } from 'node:fs';
3
+ import path from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+
6
+ export const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
7
+ export const digest = (value: string | Buffer) => createHash('sha256').update(value).digest('hex');
8
+ export const skills = [
9
+ 'engineering-architecture-review',
10
+ 'engineering-cost-estimate',
11
+ 'engineering-database-review',
12
+ 'engineering-privacy-check',
13
+ 'engineering-safe-implementation',
14
+ 'engineering-security-standards-check',
15
+ ] as const;
16
+ export const documents = [
17
+ 'ai-and-llm',
18
+ 'architecture',
19
+ 'cost-engineering',
20
+ 'data',
21
+ 'governance',
22
+ 'mobile-and-web',
23
+ 'observability',
24
+ 'platforms-and-applications',
25
+ 'privacy',
26
+ 'quality-and-testing',
27
+ 'scalability',
28
+ 'security',
29
+ ];
30
+ export const templates = ['ADR', 'AUDIT_REPORT', 'EXCEPTIONS', 'PROJECT_AGENTS'];
31
+ export const contentFiles = [
32
+ 'AGENTS.md',
33
+ ...documents.map((x) => `docs/${x}.md`),
34
+ ...templates.map((x) => `templates/${x}.md`),
35
+ ...skills.map((x) => `skills/${x}/SKILL.md`),
36
+ ];
37
+
38
+ /** Validate the complete payload before planning any destination writes. */
39
+ export function readContent(root = packageRoot): Map<string, string> {
40
+ const baseline = JSON.parse(
41
+ readFileSync(path.join(root, 'scripts/content-baseline.json'), 'utf8'),
42
+ ) as Record<string, string>;
43
+ const result = new Map<string, string>();
44
+ for (const name of contentFiles) {
45
+ const data = readFileSync(path.join(root, name));
46
+ if (digest(data) !== baseline[name]) throw new Error(`Content baseline mismatch: ${name}`);
47
+ const value = new TextDecoder('utf-8', { fatal: true }).decode(data);
48
+ for (const match of value.matchAll(/(?:docs|templates)\/[A-Za-z0-9_-]+\.md/g)) {
49
+ if (!contentFiles.includes(match[0]))
50
+ throw new Error(`Broken reference in ${name}: ${match[0]}`);
51
+ }
52
+ result.set(name, value);
53
+ }
54
+ for (const skill of skills) {
55
+ const value = result.get(`skills/${skill}/SKILL.md`) ?? '';
56
+ if (
57
+ !value.startsWith(`---\nname: ${skill}\ndescription: `) ||
58
+ !/\n---\n/.test(value.slice(4))
59
+ ) {
60
+ throw new Error(`Invalid skill metadata: ${skill}`);
61
+ }
62
+ const description = value.split('\n')[2]?.slice('description: '.length) ?? '';
63
+ if (!description.trim() || description.length > 1024)
64
+ throw new Error(`Invalid description: ${skill}`);
65
+ }
66
+ if (
67
+ readdirSync(path.join(root, 'docs')).filter((x) => x.endsWith('.md')).length !== 12 ||
68
+ readdirSync(path.join(root, 'skills')).length !== 6
69
+ )
70
+ throw new Error('Unexpected content inventory');
71
+ return result;
72
+ }
73
+
74
+ /** Only installation references change; the canonical policy remains byte-for-byte intact. */
75
+ export function render(value: string, reference: string): string {
76
+ return value
77
+ .replaceAll('~/engineering-standards/', () => `${reference}/`)
78
+ .replace(
79
+ /(?<![\w/])((?:docs|templates)\/[A-Za-z0-9_-]+\.md)/g,
80
+ (_match, relative: string) => `${reference}/${relative}`,
81
+ );
82
+ }
@@ -0,0 +1,94 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import {
3
+ chmodSync,
4
+ closeSync,
5
+ existsSync,
6
+ fsyncSync,
7
+ lstatSync,
8
+ mkdirSync,
9
+ openSync,
10
+ readFileSync,
11
+ renameSync,
12
+ unlinkSync,
13
+ writeFileSync,
14
+ } from 'node:fs';
15
+ import path from 'node:path';
16
+
17
+ export interface Snapshot {
18
+ data: string;
19
+ mode: number;
20
+ }
21
+ export const equal = (a: Snapshot | null, b: Snapshot | null) =>
22
+ a?.data === b?.data && a?.mode === b?.mode;
23
+
24
+ /** Refuse links and special files at every existing component, including dangling links. */
25
+ export function assertSafe(file: string): void {
26
+ const absolute = path.resolve(file);
27
+ const parsed = path.parse(absolute);
28
+ let cursor = parsed.root;
29
+ const parts = absolute.slice(parsed.root.length).split(path.sep).filter(Boolean);
30
+ for (let index = 0; index < parts.length; index++) {
31
+ cursor = path.join(cursor, parts[index] ?? '');
32
+ let stat: ReturnType<typeof lstatSync>;
33
+ try {
34
+ stat = lstatSync(cursor);
35
+ } catch (error) {
36
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return;
37
+ throw error;
38
+ }
39
+ if (stat.isSymbolicLink()) throw new Error(`Refusing symbolic link: ${cursor}`);
40
+ if (index < parts.length - 1 && !stat.isDirectory())
41
+ throw new Error(`Not a directory: ${cursor}`);
42
+ if (index === parts.length - 1 && !stat.isDirectory() && (!stat.isFile() || stat.nlink !== 1)) {
43
+ throw new Error(`Refusing special file or hard link: ${cursor}`);
44
+ }
45
+ }
46
+ }
47
+ export function snapshot(file: string): Snapshot | null {
48
+ assertSafe(file);
49
+ if (!existsSync(file)) return null;
50
+ const stat = lstatSync(file);
51
+ if (!stat.isFile()) throw new Error(`Expected regular file: ${file}`);
52
+ if (stat.size > 8 * 1024 * 1024) throw new Error(`File exceeds 8 MiB safety limit: ${file}`);
53
+ return { data: readFileSync(file).toString('base64'), mode: stat.mode & 0o777 };
54
+ }
55
+ export function textOf(value: Snapshot | null): string {
56
+ return value
57
+ ? new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(
58
+ Buffer.from(value.data, 'base64'),
59
+ )
60
+ : '';
61
+ }
62
+ export function asSnapshot(value: string, previous: Snapshot | null = null): Snapshot {
63
+ return {
64
+ data: Buffer.from(value).toString('base64'),
65
+ mode: previous?.mode ?? (process.platform === 'win32' ? 0o666 : 0o600),
66
+ };
67
+ }
68
+ export function ensureDirectory(directory: string): void {
69
+ assertSafe(directory);
70
+ mkdirSync(directory, { recursive: true, mode: 0o700 });
71
+ }
72
+ export function replace(file: string, value: Snapshot | null): void {
73
+ assertSafe(file);
74
+ if (value === null) {
75
+ if (existsSync(file)) unlinkSync(file);
76
+ return;
77
+ }
78
+ ensureDirectory(path.dirname(file));
79
+ const temporary = path.join(path.dirname(file), `.ges-${randomUUID()}.tmp`);
80
+ let fd: number | undefined;
81
+ try {
82
+ fd = openSync(temporary, 'wx', value.mode);
83
+ writeFileSync(fd, Buffer.from(value.data, 'base64'));
84
+ fsyncSync(fd);
85
+ closeSync(fd);
86
+ fd = undefined;
87
+ chmodSync(temporary, value.mode);
88
+ assertSafe(file);
89
+ renameSync(temporary, file);
90
+ } finally {
91
+ if (fd !== undefined) closeSync(fd);
92
+ if (existsSync(temporary)) unlinkSync(temporary);
93
+ }
94
+ }
@@ -0,0 +1,280 @@
1
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { contentFiles, digest, packageRoot, readContent, render, skills } from './content.js';
4
+ import { asSnapshot, assertSafe, equal, snapshot, textOf } from './filesystem.js';
5
+ import { type Agent, type Layout, layout, type Options } from './layout.js';
6
+ import { type Change, commit, locked, pending, rollback } from './transaction.js';
7
+
8
+ const start = '<!-- global-engineering-standards:start -->';
9
+ const end = '<!-- global-engineering-standards:end -->';
10
+ interface Entry {
11
+ hash: string;
12
+ block?: string;
13
+ created?: boolean;
14
+ }
15
+ interface Manifest {
16
+ schema: number;
17
+ version: string;
18
+ scope: Options['scope'];
19
+ agents: Agent[];
20
+ entries: Record<string, Entry>;
21
+ }
22
+ export interface Result {
23
+ command: string;
24
+ dryRun: boolean;
25
+ changes: string[];
26
+ transaction: string | null;
27
+ messages: string[];
28
+ }
29
+
30
+ function loadManifest(context: Layout): Manifest | null {
31
+ const data = snapshot(context.targets.get('manifest') as string);
32
+ if (!data) return null;
33
+ const manifest = JSON.parse(textOf(data)) as Manifest;
34
+ if (
35
+ manifest.schema !== 1 ||
36
+ manifest.scope !== context.options.scope ||
37
+ typeof manifest.version !== 'string' ||
38
+ !manifest.entries ||
39
+ !Array.isArray(manifest.agents) ||
40
+ !manifest.agents.length ||
41
+ manifest.agents.some((x) => x !== 'claude' && x !== 'codex')
42
+ )
43
+ throw new Error('Invalid manifest');
44
+ for (const [key, entry] of Object.entries(manifest.entries)) {
45
+ if (!context.targets.has(key) || key === 'manifest' || !/^[a-f0-9]{64}$/.test(entry.hash))
46
+ throw new Error(`Invalid manifest entry: ${key}`);
47
+ if (key.endsWith('-instructions')) {
48
+ if (
49
+ typeof entry.block !== 'string' ||
50
+ !entry.block.startsWith(`\n\n${start}\n`) ||
51
+ !entry.block.endsWith(`${end}\n`) ||
52
+ digest(entry.block) !== entry.hash ||
53
+ typeof entry.created !== 'boolean'
54
+ ) {
55
+ throw new Error(`Invalid instruction ownership: ${key}`);
56
+ }
57
+ } else if (entry.block !== undefined) throw new Error(`Invalid file ownership: ${key}`);
58
+ }
59
+ for (const name of contentFiles)
60
+ if (!manifest.entries[`framework/${name}`]) throw new Error(`Incomplete manifest: ${name}`);
61
+ for (const agent of manifest.agents) {
62
+ if (!manifest.entries[`${agent}-instructions`])
63
+ throw new Error(`Incomplete manifest: ${agent}`);
64
+ for (const skill of skills)
65
+ if (!manifest.entries[`${agent}-skill/${skill}`])
66
+ throw new Error(`Incomplete manifest: ${skill}`);
67
+ }
68
+ return manifest;
69
+ }
70
+ function verify(context: Layout, manifest: Manifest): void {
71
+ for (const [key, entry] of Object.entries(manifest.entries)) {
72
+ const value = snapshot(context.targets.get(key) as string);
73
+ if (!value) throw new Error(`Missing installed file: ${key}`);
74
+ const text = textOf(value);
75
+ if (entry.block) {
76
+ if (
77
+ text.split(entry.block).length !== 2 ||
78
+ text.split(start).length !== 2 ||
79
+ text.split(end).length !== 2
80
+ ) {
81
+ throw new Error(`Managed instruction block changed: ${key}`);
82
+ }
83
+ } else if (digest(Buffer.from(value.data, 'base64')) !== entry.hash)
84
+ throw new Error(`Installed file changed: ${key}`);
85
+ }
86
+ }
87
+ function checkOverrides(context: Layout, agents: Agent[]): void {
88
+ if (!agents.includes('codex')) return;
89
+ const instruction = context.targets.get('codex-instructions') as string;
90
+ const override = snapshot(path.join(path.dirname(instruction), 'AGENTS.override.md'));
91
+ if (textOf(override).trim())
92
+ throw new Error(
93
+ 'AGENTS.override.md shadows AGENTS.md; resolve this instruction conflict first.',
94
+ );
95
+ }
96
+ function reference(context: Layout, target: string): string {
97
+ return (
98
+ (context.options.scope === 'global'
99
+ ? context.framework
100
+ : path.relative(path.dirname(target), context.framework)
101
+ )
102
+ .split(path.sep)
103
+ .join('/') || '.'
104
+ );
105
+ }
106
+ function block(context: Layout, target: string, policy: string): string {
107
+ const root = reference(context, target);
108
+ return (
109
+ `\n\n${start}\n# Installed Global Engineering Standards\n\n` +
110
+ `Framework root: ${JSON.stringify(root)}. Resolve relative framework paths against the directory containing THIS file, never the current working directory.\n\n` +
111
+ `${render(policy, root).trim()}\n\n${end}\n`
112
+ );
113
+ }
114
+ function planInstall(context: Layout, previous: Manifest | null, source: string): Change[] {
115
+ const content = readContent(source);
116
+ const agents = [
117
+ ...new Set([...(previous?.agents ?? []), ...context.options.agents]),
118
+ ].sort() as Agent[];
119
+ checkOverrides(context, agents);
120
+ const desired = new Map<string, string>();
121
+ for (const [name, value] of content) {
122
+ const target = context.targets.get(`framework/${name}`) as string;
123
+ const root = reference(context, target);
124
+ desired.set(
125
+ `framework/${name}`,
126
+ name.endsWith('SKILL.md') ? renderSkill(value, root) : render(value, root),
127
+ );
128
+ }
129
+ for (const agent of agents) {
130
+ const key = `${agent}-instructions`;
131
+ desired.set(
132
+ key,
133
+ block(context, context.targets.get(key) as string, content.get('AGENTS.md') as string),
134
+ );
135
+ for (const skill of skills) {
136
+ const key = `${agent}-skill/${skill}`;
137
+ const target = context.targets.get(key) as string;
138
+ const directory = path.dirname(target);
139
+ assertSafe(directory);
140
+ if (!previous?.entries[key] && existsSync(directory) && readdirSync(directory).length)
141
+ throw new Error(`Existing skill directory preserved: ${directory}`);
142
+ desired.set(
143
+ key,
144
+ renderSkill(content.get(`skills/${skill}/SKILL.md`) as string, reference(context, target)),
145
+ );
146
+ }
147
+ }
148
+ const changes: Change[] = [];
149
+ const entries: Record<string, Entry> = {};
150
+ for (const [key, value] of desired) {
151
+ const before = snapshot(context.targets.get(key) as string);
152
+ const old = previous?.entries[key];
153
+ let next = value;
154
+ if (key.endsWith('-instructions')) {
155
+ const original = textOf(before);
156
+ if (!old && (original.includes(start) || original.includes(end)))
157
+ throw new Error(`Unowned managed block: ${key}`);
158
+ next = old?.block ? original.replace(old.block, () => value) : original + value;
159
+ entries[key] = {
160
+ hash: digest(value),
161
+ block: value,
162
+ created: old?.created ?? before === null,
163
+ };
164
+ if (key === 'codex-instructions' && Buffer.byteLength(next) > 32768)
165
+ throw new Error(
166
+ 'Codex instruction file exceeds the default 32 KiB limit; reduce existing instructions manually.',
167
+ );
168
+ } else {
169
+ if (before && !old) throw new Error(`Existing file preserved: ${key}`);
170
+ entries[key] = { hash: digest(value) };
171
+ }
172
+ const after = asSnapshot(next, before);
173
+ if (!equal(before, after)) changes.push({ key, before, after });
174
+ }
175
+ const version = (
176
+ JSON.parse(readFileSync(path.join(source, 'package.json'), 'utf8')) as { version: string }
177
+ ).version;
178
+ const manifest: Manifest = { schema: 1, version, scope: context.options.scope, agents, entries };
179
+ const before = snapshot(context.targets.get('manifest') as string);
180
+ const after = asSnapshot(`${JSON.stringify(manifest, null, 2)}\n`, before);
181
+ if (!equal(before, after)) changes.push({ key: 'manifest', before, after });
182
+ return changes;
183
+ }
184
+ export function renderSkill(value: string, root: string): string {
185
+ const delimiter = value.indexOf('\n---\n', 4) + 5;
186
+ return (
187
+ value.slice(0, delimiter) +
188
+ `\nInstallation path resolution: resolve all relative framework paths in this skill against the directory containing this SKILL.md, never the current working directory. Framework root: ${JSON.stringify(root)}.\n` +
189
+ render(value.slice(delimiter), root)
190
+ );
191
+ }
192
+ function planUninstall(context: Layout, manifest: Manifest): Change[] {
193
+ const changes: Change[] = [];
194
+ for (const [key, entry] of Object.entries(manifest.entries)) {
195
+ const before = snapshot(context.targets.get(key) as string);
196
+ let after = null;
197
+ if (entry.block) {
198
+ const remaining = textOf(before).replace(entry.block, '');
199
+ after = remaining === '' && entry.created ? null : asSnapshot(remaining, before);
200
+ }
201
+ changes.push({ key, before, after });
202
+ }
203
+ changes.push({
204
+ key: 'manifest',
205
+ before: snapshot(context.targets.get('manifest') as string),
206
+ after: null,
207
+ });
208
+ return changes;
209
+ }
210
+ export type Command = 'init' | 'init-project' | 'update' | 'uninstall' | 'doctor' | 'rollback';
211
+ export function run(
212
+ command: Command,
213
+ options: Options,
214
+ source = packageRoot,
215
+ failAfter?: number,
216
+ ): Result {
217
+ const context = layout(options);
218
+ assertSafe(context.state);
219
+ // Fail safely before even creating a lock or state directory.
220
+ if (command !== 'rollback' && pending(context))
221
+ throw new Error('Interrupted transaction requires rollback.');
222
+ const initial = command === 'rollback' ? null : loadManifest(context);
223
+ if (command === 'doctor') {
224
+ readContent(source);
225
+ if (!initial) throw new Error('No installation manifest found for this scope.');
226
+ if (existsSync(path.join(context.state, 'operation.lock')))
227
+ throw new Error('Installation lock exists; check for an active or interrupted process.');
228
+ if (pending(context)) throw new Error('Interrupted transaction requires rollback.');
229
+ verify(context, initial);
230
+ checkOverrides(context, initial.agents);
231
+ const messages = [
232
+ 'Manifest, content hashes, instruction blocks and six skill locations per installed agent verified.',
233
+ 'Agent runtime discovery and semantic compliance require an isolated agent session.',
234
+ ];
235
+ return { command, dryRun: options.dryRun, changes: [], transaction: null, messages };
236
+ }
237
+ const work = (): Result => {
238
+ if (command === 'rollback') {
239
+ const result = rollback(context);
240
+ return {
241
+ command,
242
+ dryRun: options.dryRun,
243
+ changes: [`${result.changes} files`],
244
+ transaction: result.id,
245
+ messages: ['Backups retained.'],
246
+ };
247
+ }
248
+ if (pending(context)) throw new Error('Interrupted transaction requires rollback.');
249
+ const previous = loadManifest(context);
250
+ if (previous) verify(context, previous);
251
+ if (command === 'update' && !previous)
252
+ throw new Error('No installation to update. Run init first.');
253
+ const changes =
254
+ command === 'uninstall'
255
+ ? previous
256
+ ? planUninstall(context, previous)
257
+ : []
258
+ : planInstall(context, previous, source);
259
+ const transaction = options.dryRun ? null : commit(context, changes, failAfter);
260
+ return {
261
+ command,
262
+ dryRun: options.dryRun,
263
+ changes: changes.map((x) => x.key),
264
+ transaction,
265
+ messages:
266
+ command === 'uninstall'
267
+ ? ['Only owned, unchanged content removed. Backups and empty directories retained.']
268
+ : ['Restart your agent and verify instruction and skill discovery.'],
269
+ };
270
+ };
271
+ if (options.dryRun) return work();
272
+ // Plan first so ordinary conflicts never write even a lock or a backup.
273
+ if (command !== 'rollback') {
274
+ if (initial) verify(context, initial);
275
+ if (command === 'update' && !initial) throw new Error('No installation to update.');
276
+ if (command !== 'uninstall') planInstall(context, initial, source);
277
+ }
278
+ if (command === 'uninstall' && !initial) return work();
279
+ return locked(context, work);
280
+ }
package/src/layout.ts ADDED
@@ -0,0 +1,70 @@
1
+ import os from 'node:os';
2
+ import path from 'node:path';
3
+ import { contentFiles, skills } from './content.js';
4
+
5
+ export type Agent = 'claude' | 'codex';
6
+ export interface Options {
7
+ scope: 'global' | 'project';
8
+ agents: Agent[];
9
+ home: string;
10
+ project: string;
11
+ codexHome?: string;
12
+ claudeHome?: string;
13
+ dryRun: boolean;
14
+ }
15
+ export interface Layout {
16
+ state: string;
17
+ framework: string;
18
+ targets: Map<string, string>;
19
+ options: Options;
20
+ }
21
+ export function layout(options: Options, paths = path): Layout {
22
+ const base = paths.resolve(options.scope === 'global' ? options.home : options.project);
23
+ if (options.scope === 'project' && base === paths.resolve(options.home)) {
24
+ throw new Error(
25
+ 'Project installation in the home directory is ambiguous; choose a project directory.',
26
+ );
27
+ }
28
+ const state = paths.join(base, '.engineering-standards');
29
+ const framework = paths.join(state, 'framework');
30
+ const targets = new Map(
31
+ contentFiles.map((name) => [`framework/${name}`, paths.join(framework, ...name.split('/'))]),
32
+ );
33
+ const claude =
34
+ options.scope === 'global'
35
+ ? paths.resolve(options.claudeHome ?? paths.join(base, '.claude'))
36
+ : paths.join(base, '.claude');
37
+ const codex =
38
+ options.scope === 'global'
39
+ ? paths.resolve(options.codexHome ?? paths.join(base, '.codex'))
40
+ : base;
41
+ targets.set(
42
+ 'claude-instructions',
43
+ paths.join(options.scope === 'global' ? claude : base, 'CLAUDE.md'),
44
+ );
45
+ targets.set('codex-instructions', paths.join(codex, 'AGENTS.md'));
46
+ for (const skill of skills) {
47
+ targets.set(`claude-skill/${skill}`, paths.join(claude, 'skills', skill, 'SKILL.md'));
48
+ targets.set(`codex-skill/${skill}`, paths.join(base, '.agents', 'skills', skill, 'SKILL.md'));
49
+ }
50
+ targets.set('manifest', paths.join(state, 'manifest.json'));
51
+ const values = [...targets.values()].map((x) => x.toLowerCase());
52
+ if (
53
+ values.some((x) =>
54
+ [...x].some((character) => character.charCodeAt(0) < 32 || character.charCodeAt(0) === 127),
55
+ )
56
+ )
57
+ throw new Error('Control characters in destination paths are unsupported');
58
+ if (new Set(values).size !== values.length) throw new Error('Overlapping destination paths');
59
+ return { state, framework, targets, options };
60
+ }
61
+ export function defaults(): Omit<Options, 'scope' | 'agents' | 'dryRun'> {
62
+ const options: Omit<Options, 'scope' | 'agents' | 'dryRun'> = {
63
+ home: os.homedir(),
64
+ project: process.cwd(),
65
+ };
66
+ if (process.env.CODEX_HOME) options.codexHome = path.resolve(process.env.CODEX_HOME);
67
+ if (process.env.CLAUDE_CONFIG_DIR)
68
+ options.claudeHome = path.resolve(process.env.CLAUDE_CONFIG_DIR);
69
+ return options;
70
+ }