@ankhorage/devtools 1.19.19 → 1.21.0
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 +4 -2
- package/dist/tools/agents/index.d.ts +6 -0
- package/dist/tools/agents/index.js +29 -0
- package/dist/tools/shared/managedFiles.d.ts +4 -0
- package/dist/tools/shared/managedFiles.js +88 -34
- package/dist/tools/skills/assets/ankhorage-project-structure/SKILL.md +51 -65
- package/dist/tools/skills/assets/ankhorage-project-structure/references/architecture-profiles.md +141 -0
- package/dist/tools/skills/assets/ankhorage-project-structure/references/hexagonal-invariants.md +80 -0
- package/dist/tools/skills/assets/hexagonal-architecture/SKILL.md +9 -0
- package/dist/tools/workflows/files/ci.yml +13 -0
- package/dist/tools/workflows/files/release.yml +37 -0
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
# @ankhorage/devtools
|
|
5
5
|
|
|
6
|
-
         
|
|
7
7
|
|
|
8
8
|
Shared tooling, repository automation, runtime policies, and agent standards for Ankhorage TypeScript projects
|
|
9
9
|
|
|
@@ -199,10 +199,12 @@ The canonical workflow, VS Code, and skill files are packaged with `@ankhorage/d
|
|
|
199
199
|
|
|
200
200
|
## Managed agent instructions
|
|
201
201
|
|
|
202
|
-
`ankh devtools agents sync` owns the repository-root `AGENTS.md
|
|
202
|
+
`ankh devtools agents sync` owns the repository-root `AGENTS.md` plus `CLAUDE.md` and `GEMINI.md` as symbolic links to that canonical file. The shared instructions are intentionally small and stable. The target repository's package name and description are rendered from `package.json`; the remaining content defines the unconditional current-architecture policy, directs structural work to the managed project-structure skill, and requires the canonical build, type, lint, Knip, Changeset, and formatting commands before pull request creation.
|
|
203
203
|
|
|
204
204
|
Only the current Ankhorage architecture is supported. Managed instructions reject deprecated APIs, compatibility aliases, shims, dual old/new paths, historical-state fallbacks, and migrations whose sole purpose is obsolete state. A canonical cross-package change requires affected repositories to update to the latest released public API.
|
|
205
205
|
|
|
206
|
+
Every Devtools-managed repository is standalone: its own checkout must be sufficient to install, build, test, and use it with declared dependencies and explicit configuration. Published packages are additionally consumer-agnostic and reusable outside Ankhorage. Sibling repositories/source imports, unpublished workspace/file/link coupling, hidden organization-local state, and hard assumptions about a consuming app, infrastructure, hosting provider, web server, container runtime, or deployment topology are architecture debt rather than supported exceptions.
|
|
207
|
+
|
|
206
208
|
## Managed repository skills
|
|
207
209
|
|
|
208
210
|
`ankh devtools skills sync` owns the complete `.agents/skills/ankhorage-coding-rules/`, `.agents/skills/hexagonal-architecture/`, and `.agents/skills/ankhorage-project-structure/` trees from the immutable copies shipped in the Devtools release. It creates `.agents/` when missing, replaces stale files in those managed skills, and preserves every unrelated skill directory. The project-structure skill requires both the coding-rules and hexagonal-architecture skills before structural work can continue.
|
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
export declare const agentsManagedFiles: readonly [{
|
|
2
2
|
readonly relativePath: "AGENTS.md";
|
|
3
3
|
readonly render: typeof renderAgentsFile;
|
|
4
|
+
}, {
|
|
5
|
+
readonly relativePath: "CLAUDE.md";
|
|
6
|
+
readonly symlinkTarget: "AGENTS.md";
|
|
7
|
+
}, {
|
|
8
|
+
readonly relativePath: "GEMINI.md";
|
|
9
|
+
readonly symlinkTarget: "AGENTS.md";
|
|
4
10
|
}];
|
|
5
11
|
/*** Render mandatory repository instructions from the target package identity. */
|
|
6
12
|
declare function renderAgentsFile(targetDirectory: string): Promise<string>;
|
|
@@ -5,6 +5,14 @@ export const agentsManagedFiles = [
|
|
|
5
5
|
relativePath: 'AGENTS.md',
|
|
6
6
|
render: renderAgentsFile,
|
|
7
7
|
},
|
|
8
|
+
{
|
|
9
|
+
relativePath: 'CLAUDE.md',
|
|
10
|
+
symlinkTarget: 'AGENTS.md',
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
relativePath: 'GEMINI.md',
|
|
14
|
+
symlinkTarget: 'AGENTS.md',
|
|
15
|
+
},
|
|
8
16
|
];
|
|
9
17
|
/*** Render mandatory repository instructions from the target package identity. */
|
|
10
18
|
async function renderAgentsFile(targetDirectory) {
|
|
@@ -13,6 +21,7 @@ async function renderAgentsFile(targetDirectory) {
|
|
|
13
21
|
const description = readNonEmptyString(manifest.description) ?? 'No package description is declared.';
|
|
14
22
|
const sections = [
|
|
15
23
|
renderCurrentArchitectureInstructions(),
|
|
24
|
+
renderStandaloneRepositoryInstructions(),
|
|
16
25
|
renderRequiredRepositoryInstructions(),
|
|
17
26
|
renderDocumentationInstructions(),
|
|
18
27
|
renderPullRequestInstructions(),
|
|
@@ -45,6 +54,26 @@ published public APIs and declared dependencies, never sibling source files.
|
|
|
45
54
|
Current-runtime error handling and canonical database or infrastructure migrations remain valid
|
|
46
55
|
when they support states that the current architecture can intentionally produce.
|
|
47
56
|
|
|
57
|
+
`;
|
|
58
|
+
}
|
|
59
|
+
/*** Render the mandatory standalone repository and package contract. */
|
|
60
|
+
function renderStandaloneRepositoryInstructions() {
|
|
61
|
+
return `## Standalone contract
|
|
62
|
+
|
|
63
|
+
Every repository managed by \`@ankhorage/devtools\` is standalone. It must be independently
|
|
64
|
+
installable, buildable, testable, and usable from its own checkout using only declared dependencies
|
|
65
|
+
and explicit configuration. It must not require sibling repositories, sibling source imports,
|
|
66
|
+
\`workspace:\`, \`file:\`, or \`link:\` dependencies to unpublished packages, hidden
|
|
67
|
+
organization-local state, or assumptions about a specific consuming application, infrastructure,
|
|
68
|
+
hosting provider, web server, container runtime, or deployment topology.
|
|
69
|
+
|
|
70
|
+
Published packages are additionally consumer-agnostic and reusable outside Ankhorage.
|
|
71
|
+
Environment- or provider-specific behavior belongs behind explicit configuration and adapters,
|
|
72
|
+
never in the package core.
|
|
73
|
+
|
|
74
|
+
If an existing repository violates this contract, treat that as architecture debt to remove, not
|
|
75
|
+
as an exception to preserve.
|
|
76
|
+
|
|
48
77
|
`;
|
|
49
78
|
}
|
|
50
79
|
/*** Render contextual repository skill-selection requirements. */
|
|
@@ -5,6 +5,7 @@ export interface ManagedFileDefinition {
|
|
|
5
5
|
readonly sourceUrl?: URL;
|
|
6
6
|
readonly contents?: string;
|
|
7
7
|
readonly render?: ManagedFileRenderer;
|
|
8
|
+
readonly symlinkTarget?: string;
|
|
8
9
|
readonly mode?: ManagedFileMode;
|
|
9
10
|
readonly isApplicable?: (targetDirectory: string) => Promise<boolean> | boolean;
|
|
10
11
|
}
|
|
@@ -18,8 +19,11 @@ export interface ManagedFileSyncResult {
|
|
|
18
19
|
readonly relativePath: string;
|
|
19
20
|
readonly action: ManagedFileSyncAction;
|
|
20
21
|
}
|
|
22
|
+
/*** Resolve and validate the repository directory targeted by a managed-files operation. */
|
|
21
23
|
export declare function resolveManagedTargetDirectory(cwd: string, requestedPath: string | undefined): Promise<string>;
|
|
24
|
+
/*** Inspect managed files and symbolic links without mutating repository state. */
|
|
22
25
|
export declare function inspectManagedFiles(targetDirectory: string, definitions: readonly ManagedFileDefinition[]): Promise<readonly ManagedFileStatus[]>;
|
|
26
|
+
/*** Synchronize managed files and symbolic links to their canonical definitions. */
|
|
23
27
|
export declare function syncManagedFiles(targetDirectory: string, definitions: readonly ManagedFileDefinition[], options: {
|
|
24
28
|
readonly dryRun: boolean;
|
|
25
29
|
}): Promise<readonly ManagedFileSyncResult[]>;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises';
|
|
1
|
+
import { lstat, mkdir, readFile, readlink, rm, stat, symlink, writeFile } from 'node:fs/promises';
|
|
2
2
|
import { dirname, resolve } from 'node:path';
|
|
3
|
+
/*** Resolve and validate the repository directory targeted by a managed-files operation. */
|
|
3
4
|
export async function resolveManagedTargetDirectory(cwd, requestedPath) {
|
|
4
5
|
const targetDirectory = resolve(cwd, requestedPath ?? '.');
|
|
5
6
|
let targetStats;
|
|
@@ -14,46 +15,62 @@ export async function resolveManagedTargetDirectory(cwd, requestedPath) {
|
|
|
14
15
|
}
|
|
15
16
|
return targetDirectory;
|
|
16
17
|
}
|
|
18
|
+
/*** Inspect managed files and symbolic links without mutating repository state. */
|
|
17
19
|
export async function inspectManagedFiles(targetDirectory, definitions) {
|
|
18
|
-
const statuses = await Promise.all(definitions.map(
|
|
19
|
-
const targetPath = resolve(targetDirectory, definition.relativePath);
|
|
20
|
-
const isApplicable = await (definition.isApplicable?.(targetDirectory) ?? true);
|
|
21
|
-
try {
|
|
22
|
-
const targetContents = await readFile(targetPath, 'utf8');
|
|
23
|
-
if (!isApplicable) {
|
|
24
|
-
return { relativePath: definition.relativePath, state: 'obsolete' };
|
|
25
|
-
}
|
|
26
|
-
if ((definition.mode ?? 'replace') === 'create-only') {
|
|
27
|
-
return { relativePath: definition.relativePath, state: 'current' };
|
|
28
|
-
}
|
|
29
|
-
const canonicalContents = await readCanonicalContents(definition, targetDirectory);
|
|
30
|
-
return {
|
|
31
|
-
relativePath: definition.relativePath,
|
|
32
|
-
state: targetContents === canonicalContents ? 'current' : 'outdated',
|
|
33
|
-
};
|
|
34
|
-
}
|
|
35
|
-
catch (error) {
|
|
36
|
-
if (isMissingFileError(error)) {
|
|
37
|
-
return isApplicable
|
|
38
|
-
? { relativePath: definition.relativePath, state: 'missing' }
|
|
39
|
-
: undefined;
|
|
40
|
-
}
|
|
41
|
-
throw new Error(`Failed to inspect managed file: ${targetPath}`, { cause: error });
|
|
42
|
-
}
|
|
43
|
-
}));
|
|
20
|
+
const statuses = await Promise.all(definitions.map((definition) => inspectManagedFileAsync(targetDirectory, definition)));
|
|
44
21
|
return statuses.filter((status) => status !== undefined);
|
|
45
22
|
}
|
|
23
|
+
/*** Synchronize managed files and symbolic links to their canonical definitions. */
|
|
46
24
|
export async function syncManagedFiles(targetDirectory, definitions, options) {
|
|
47
25
|
const statuses = await inspectManagedFiles(targetDirectory, definitions);
|
|
48
26
|
const definitionsByPath = new Map(definitions.map((definition) => [definition.relativePath, definition]));
|
|
49
27
|
const results = [];
|
|
50
28
|
for (const status of statuses) {
|
|
51
|
-
|
|
52
|
-
results.push(result);
|
|
29
|
+
results.push(await syncManagedFileAsync(targetDirectory, status, definitionsByPath, options));
|
|
53
30
|
}
|
|
54
31
|
return results;
|
|
55
32
|
}
|
|
56
|
-
|
|
33
|
+
/*** Inspect one managed artifact against its file or symbolic-link definition. */
|
|
34
|
+
async function inspectManagedFileAsync(targetDirectory, definition) {
|
|
35
|
+
assertSingleContentSource(definition);
|
|
36
|
+
const targetPath = resolve(targetDirectory, definition.relativePath);
|
|
37
|
+
const isApplicable = await (definition.isApplicable?.(targetDirectory) ?? true);
|
|
38
|
+
try {
|
|
39
|
+
const targetStats = await lstat(targetPath);
|
|
40
|
+
if (!isApplicable) {
|
|
41
|
+
return { relativePath: definition.relativePath, state: 'obsolete' };
|
|
42
|
+
}
|
|
43
|
+
if ((definition.mode ?? 'replace') === 'create-only') {
|
|
44
|
+
return { relativePath: definition.relativePath, state: 'current' };
|
|
45
|
+
}
|
|
46
|
+
const current = definition.symlinkTarget === undefined
|
|
47
|
+
? await isCurrentFileAsync(targetPath, targetStats.isSymbolicLink(), definition, targetDirectory)
|
|
48
|
+
: await isCurrentSymlinkAsync(targetPath, targetStats.isSymbolicLink(), definition.symlinkTarget);
|
|
49
|
+
return {
|
|
50
|
+
relativePath: definition.relativePath,
|
|
51
|
+
state: current ? 'current' : 'outdated',
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
catch (error) {
|
|
55
|
+
if (isMissingFileError(error)) {
|
|
56
|
+
return isApplicable ? { relativePath: definition.relativePath, state: 'missing' } : undefined;
|
|
57
|
+
}
|
|
58
|
+
throw new Error(`Failed to inspect managed file: ${targetPath}`, { cause: error });
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
/*** Compare one regular managed file with its canonical rendered contents. */
|
|
62
|
+
async function isCurrentFileAsync(targetPath, isSymbolicLink, definition, targetDirectory) {
|
|
63
|
+
if (isSymbolicLink)
|
|
64
|
+
return false;
|
|
65
|
+
return ((await readFile(targetPath, 'utf8')) ===
|
|
66
|
+
(await readCanonicalContents(definition, targetDirectory)));
|
|
67
|
+
}
|
|
68
|
+
/*** Compare one managed symbolic link with its canonical relative target. */
|
|
69
|
+
async function isCurrentSymlinkAsync(targetPath, isSymbolicLink, symlinkTarget) {
|
|
70
|
+
return isSymbolicLink && (await readlink(targetPath)) === symlinkTarget;
|
|
71
|
+
}
|
|
72
|
+
/*** Apply one managed artifact status to the target repository. */
|
|
73
|
+
async function syncManagedFileAsync(targetDirectory, status, definitionsByPath, options) {
|
|
57
74
|
if (status.state === 'current') {
|
|
58
75
|
return { relativePath: status.relativePath, action: 'unchanged' };
|
|
59
76
|
}
|
|
@@ -63,7 +80,7 @@ async function syncManagedFile(targetDirectory, status, definitionsByPath, optio
|
|
|
63
80
|
}
|
|
64
81
|
if (status.state === 'obsolete') {
|
|
65
82
|
if (!options.dryRun) {
|
|
66
|
-
await rm(resolve(targetDirectory, definition.relativePath));
|
|
83
|
+
await rm(resolve(targetDirectory, definition.relativePath), { force: true, recursive: true });
|
|
67
84
|
}
|
|
68
85
|
return {
|
|
69
86
|
relativePath: status.relativePath,
|
|
@@ -78,12 +95,41 @@ async function syncManagedFile(targetDirectory, status, definitionsByPath, optio
|
|
|
78
95
|
}
|
|
79
96
|
const targetPath = resolve(targetDirectory, definition.relativePath);
|
|
80
97
|
await mkdir(dirname(targetPath), { recursive: true });
|
|
81
|
-
|
|
98
|
+
const canonicalContents = definition.symlinkTarget === undefined
|
|
99
|
+
? await readCanonicalContents(definition, targetDirectory)
|
|
100
|
+
: undefined;
|
|
101
|
+
await prepareManagedTargetAsync(targetPath, definition);
|
|
102
|
+
await writeManagedArtifactAsync(targetPath, definition, canonicalContents);
|
|
82
103
|
return {
|
|
83
104
|
relativePath: status.relativePath,
|
|
84
105
|
action: status.state === 'missing' ? 'created' : 'updated',
|
|
85
106
|
};
|
|
86
107
|
}
|
|
108
|
+
/*** Remove an existing artifact only when writing through it would violate the target type. */
|
|
109
|
+
async function prepareManagedTargetAsync(targetPath, definition) {
|
|
110
|
+
try {
|
|
111
|
+
const targetStats = await lstat(targetPath);
|
|
112
|
+
if (definition.symlinkTarget !== undefined || targetStats.isSymbolicLink()) {
|
|
113
|
+
await rm(targetPath, { force: true, recursive: true });
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
catch (error) {
|
|
117
|
+
if (!isMissingFileError(error))
|
|
118
|
+
throw error;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
/*** Write either a canonical regular file or a canonical symbolic link. */
|
|
122
|
+
async function writeManagedArtifactAsync(targetPath, definition, canonicalContents) {
|
|
123
|
+
if (definition.symlinkTarget !== undefined) {
|
|
124
|
+
await symlink(definition.symlinkTarget, targetPath);
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
if (canonicalContents === undefined) {
|
|
128
|
+
throw new Error(`Missing canonical file contents for ${definition.relativePath}.`);
|
|
129
|
+
}
|
|
130
|
+
await writeFile(targetPath, canonicalContents, 'utf8');
|
|
131
|
+
}
|
|
132
|
+
/*** Resolve canonical contents for one regular managed file definition. */
|
|
87
133
|
async function readCanonicalContents(definition, targetDirectory) {
|
|
88
134
|
assertSingleContentSource(definition);
|
|
89
135
|
if (definition.sourceUrl !== undefined) {
|
|
@@ -95,17 +141,25 @@ async function readCanonicalContents(definition, targetDirectory) {
|
|
|
95
141
|
if (definition.render !== undefined) {
|
|
96
142
|
return await definition.render(targetDirectory);
|
|
97
143
|
}
|
|
98
|
-
throw new Error(`Managed file
|
|
144
|
+
throw new Error(`Managed file does not contain regular-file content: ${definition.relativePath}`);
|
|
99
145
|
}
|
|
146
|
+
/*** Require every managed artifact to define exactly one canonical source. */
|
|
100
147
|
function assertSingleContentSource(definition) {
|
|
101
|
-
const sourceCount = [
|
|
148
|
+
const sourceCount = [
|
|
149
|
+
definition.sourceUrl,
|
|
150
|
+
definition.contents,
|
|
151
|
+
definition.render,
|
|
152
|
+
definition.symlinkTarget,
|
|
153
|
+
].filter((value) => value !== undefined).length;
|
|
102
154
|
if (sourceCount !== 1) {
|
|
103
155
|
throw new Error(`Managed file must define exactly one content source: ${definition.relativePath}`);
|
|
104
156
|
}
|
|
105
157
|
}
|
|
158
|
+
/*** Check whether an unknown failure means the target path does not exist. */
|
|
106
159
|
function isMissingFileError(error) {
|
|
107
160
|
return isNodeError(error) && error.code === 'ENOENT';
|
|
108
161
|
}
|
|
162
|
+
/*** Check whether an unknown failure carries a Node.js error code. */
|
|
109
163
|
function isNodeError(error) {
|
|
110
164
|
return error instanceof Error && 'code' in error;
|
|
111
165
|
}
|
|
@@ -36,71 +36,57 @@ repository root. Do not resolve it relative to this skill's own installation loc
|
|
|
36
36
|
|
|
37
37
|
1. `<repo-root>/.agents/skills/hexagonal-architecture/SKILL.md`
|
|
38
38
|
|
|
39
|
-
##
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
- `
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
outbound/
|
|
91
|
-
composition/
|
|
92
|
-
constants/
|
|
93
|
-
<topic>.ts
|
|
94
|
-
utils/
|
|
95
|
-
types/
|
|
96
|
-
<topic>.ts
|
|
97
|
-
constants/
|
|
98
|
-
<topic>.ts
|
|
99
|
-
utils/
|
|
100
|
-
```
|
|
101
|
-
|
|
102
|
-
Keep only deliberate package facades directly under `src/`. Public package subpaths must name their
|
|
103
|
-
explicit module in `package.json`; generic `index.ts` barrels are not public API exceptions.
|
|
39
|
+
## Architecture profiles and source layout
|
|
40
|
+
|
|
41
|
+
Do not impose one folder tree on every repository. Select the smallest profile that matches the
|
|
42
|
+
repository's real responsibility, then enforce that profile's vocabulary and dependency direction.
|
|
43
|
+
Read `references/architecture-profiles.md` and `references/hexagonal-invariants.md` before
|
|
44
|
+
creating or moving architectural directories.
|
|
45
|
+
|
|
46
|
+
Valid profiles include:
|
|
47
|
+
|
|
48
|
+
- simple/value/contracts library;
|
|
49
|
+
- reusable UI or design-system library;
|
|
50
|
+
- application, engine, or hybrid package;
|
|
51
|
+
- provider or platform adapter package;
|
|
52
|
+
- tooling package;
|
|
53
|
+
- generated standalone application;
|
|
54
|
+
- an explicitly documented repository-specific profile such as Studio.
|
|
55
|
+
|
|
56
|
+
A package may start flat. Introduce `domain/`, `application/`, `ports/`, `adapters/`,
|
|
57
|
+
`composition/`, `features/`, or `core/` only when those names communicate a real architectural
|
|
58
|
+
role. Once a vocabulary is introduced, its combinations must be coherent:
|
|
59
|
+
|
|
60
|
+
- `domain/` may stand alone and must remain independent from outer mechanisms;
|
|
61
|
+
- `application/` coordinates use cases and may depend inward on domain policy and required ports;
|
|
62
|
+
- `ports/` define capabilities required by inner policy; they do not implement provider technology;
|
|
63
|
+
- `adapters/` translate or implement a port at an external edge and therefore require an inward
|
|
64
|
+
capability boundary to adapt to;
|
|
65
|
+
- `composition/` is outer wiring and exists only when concrete implementations need selection;
|
|
66
|
+
- `features/` is feature-first organization, not a generic bucket. Each feature owns a coherent
|
|
67
|
+
slice and may introduce only the role directories it actually needs;
|
|
68
|
+
- `core/` is allowed only when the repository defines it narrowly as stable inner policy. It must
|
|
69
|
+
never become a miscellaneous dumping ground.
|
|
70
|
+
|
|
71
|
+
Dependency direction is the invariant. Inner policy must not import outer mechanisms. A domain or
|
|
72
|
+
core module must not depend on application orchestration, adapters, composition, CLI, host,
|
|
73
|
+
platform, framework, database, or provider implementation details. Application/use-case code must
|
|
74
|
+
not import concrete adapters or composition roots. Adapters may depend inward on ports/application/
|
|
75
|
+
domain contracts. Composition may depend on all pieces it wires.
|
|
76
|
+
|
|
77
|
+
Do not create empty layers for symmetry. A small package with no domain orchestration does not need
|
|
78
|
+
hexagonal ceremony. UI libraries use component/foundation dependency direction rather than fake
|
|
79
|
+
application ports. Contracts libraries remain portable and side-effect free.
|
|
80
|
+
|
|
81
|
+
Repository-root `examples/` contains complete user-facing examples. Each example lives in a named
|
|
82
|
+
subdirectory. Test-only fixtures remain test-owned.
|
|
83
|
+
|
|
84
|
+
Package-level delivery edges such as `src/cli/`, `src/host/`, `src/app/`, or `src/platform/`
|
|
85
|
+
remain thin adapters/composition boundaries. The filesystem below `src/cli/commands/` mirrors the
|
|
86
|
+
public Ankh command path, and command modules parse input, invoke package behavior, and render output.
|
|
87
|
+
|
|
88
|
+
Keep only deliberate public facades directly under `src/`. Public package subpaths must map to
|
|
89
|
+
explicit package exports; generic barrels are not an excuse to bypass ownership.
|
|
104
90
|
|
|
105
91
|
## General Taxonomy
|
|
106
92
|
|
package/dist/tools/skills/assets/ankhorage-project-structure/references/architecture-profiles.md
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# Architecture Profiles
|
|
2
|
+
|
|
3
|
+
Choose the profile from actual ownership and consumers. Profiles define allowed vocabulary and
|
|
4
|
+
dependency direction; they are not templates that require every listed directory.
|
|
5
|
+
|
|
6
|
+
## Simple, value, or contracts library
|
|
7
|
+
|
|
8
|
+
Use for portable types, deterministic values, parsers, constants, algorithms, and small libraries
|
|
9
|
+
without application orchestration.
|
|
10
|
+
|
|
11
|
+
Typical forms:
|
|
12
|
+
|
|
13
|
+
```text
|
|
14
|
+
src/
|
|
15
|
+
index.ts
|
|
16
|
+
<domain-or-topic>/
|
|
17
|
+
types/
|
|
18
|
+
constants/
|
|
19
|
+
utils/
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Do not invent ports, adapters, application, or composition layers when there is no external edge to
|
|
23
|
+
abstract. Contracts packages additionally keep public declarations serializable and free of runtime
|
|
24
|
+
implementation.
|
|
25
|
+
|
|
26
|
+
## Reusable UI or design-system library
|
|
27
|
+
|
|
28
|
+
Use semantic UI ownership and stable foundation layers rather than fake use cases:
|
|
29
|
+
|
|
30
|
+
```text
|
|
31
|
+
src/
|
|
32
|
+
foundation/
|
|
33
|
+
theme/
|
|
34
|
+
layout/
|
|
35
|
+
primitives/
|
|
36
|
+
components/
|
|
37
|
+
patterns/
|
|
38
|
+
registry/
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Higher-level UI may depend on lower-level foundations; foundations must not depend upward on composed
|
|
42
|
+
components or registries. Provider execution belongs outside reusable presentation components.
|
|
43
|
+
|
|
44
|
+
## Application, engine, or hybrid package
|
|
45
|
+
|
|
46
|
+
Use when the package owns use cases, state transitions, external systems, or several delivery edges.
|
|
47
|
+
Domain-first and feature-first organization are both valid when coherent.
|
|
48
|
+
|
|
49
|
+
Domain-first example:
|
|
50
|
+
|
|
51
|
+
```text
|
|
52
|
+
src/
|
|
53
|
+
<domain>/
|
|
54
|
+
domain/
|
|
55
|
+
application/
|
|
56
|
+
ports/
|
|
57
|
+
adapters/
|
|
58
|
+
composition/
|
|
59
|
+
cli/
|
|
60
|
+
host/
|
|
61
|
+
app/
|
|
62
|
+
platform/
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Feature-first example:
|
|
66
|
+
|
|
67
|
+
```text
|
|
68
|
+
src/
|
|
69
|
+
features/
|
|
70
|
+
<feature>/
|
|
71
|
+
domain/
|
|
72
|
+
application/
|
|
73
|
+
ports/
|
|
74
|
+
adapters/
|
|
75
|
+
composition/
|
|
76
|
+
cli/
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Only create the role directories that the capability actually needs. A pure domain feature can stop
|
|
80
|
+
at `domain/`; an in-memory use case need not invent an outbound adapter.
|
|
81
|
+
|
|
82
|
+
## Provider or platform adapter package
|
|
83
|
+
|
|
84
|
+
Use when the package deliberately implements an external technology boundary:
|
|
85
|
+
|
|
86
|
+
```text
|
|
87
|
+
src/
|
|
88
|
+
contracts/
|
|
89
|
+
planning/
|
|
90
|
+
adapters/
|
|
91
|
+
composition/
|
|
92
|
+
cli/
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Portable configuration and planning stay independent from SDK/runtime values. Concrete provider code
|
|
96
|
+
stays in adapters.
|
|
97
|
+
|
|
98
|
+
## Tooling package
|
|
99
|
+
|
|
100
|
+
Command-centric tooling may use:
|
|
101
|
+
|
|
102
|
+
```text
|
|
103
|
+
src/
|
|
104
|
+
cli/
|
|
105
|
+
policy/
|
|
106
|
+
application/
|
|
107
|
+
adapters/
|
|
108
|
+
composition/
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Policy remains deterministic. Filesystem, process, registry, network, and GitHub behavior stay at
|
|
112
|
+
the edge.
|
|
113
|
+
|
|
114
|
+
## Generated standalone application
|
|
115
|
+
|
|
116
|
+
A generated app owns its manifest, lockfile, installation, validation, build, and deployment inputs.
|
|
117
|
+
A parent tool may invoke it with the app as `cwd`, but it must not depend on a hidden parent
|
|
118
|
+
workspace, sibling source, or installation state.
|
|
119
|
+
|
|
120
|
+
## Repository-specific profile
|
|
121
|
+
|
|
122
|
+
A repository may define a narrower profile when its domain genuinely needs one. That profile must be
|
|
123
|
+
documented in the managed project-structure skill or an explicit repository reference and must still
|
|
124
|
+
respect the shared standalone and dependency-direction invariants. Studio is the canonical example:
|
|
125
|
+
its `features/` taxonomy is intentional and each substantial feature may layer internally.
|
|
126
|
+
|
|
127
|
+
## Combination rules
|
|
128
|
+
|
|
129
|
+
Folder names create obligations:
|
|
130
|
+
|
|
131
|
+
- `domain/`: inner policy; no outward mechanism dependencies.
|
|
132
|
+
- `application/`: use-case orchestration; no concrete adapter/composition dependency.
|
|
133
|
+
- `ports/`: capability contracts required by inner policy.
|
|
134
|
+
- `adapters/`: concrete edge implementations; there must be an inward capability/policy to adapt.
|
|
135
|
+
- `composition/`: selects and wires concrete implementations; do not create it without pieces to wire.
|
|
136
|
+
- `features/`: siblings are product capabilities, not technical categories.
|
|
137
|
+
- `core/`: only a narrowly defined inner-policy layer; never a generic dumping ground.
|
|
138
|
+
- `common/`, `shared/`, and `helpers/`: not architectural ownership categories.
|
|
139
|
+
|
|
140
|
+
Doctor should validate these combinations and dependency directions rather than require every
|
|
141
|
+
repository to match one tree.
|
package/dist/tools/skills/assets/ankhorage-project-structure/references/hexagonal-invariants.md
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# Hexagonal Architecture Invariants
|
|
2
|
+
|
|
3
|
+
The canonical rule is isolation of inner policy from external mechanisms, not the visual shape or a
|
|
4
|
+
fixed number of directories.
|
|
5
|
+
|
|
6
|
+
## Research basis
|
|
7
|
+
|
|
8
|
+
- Alistair Cockburn's original Ports & Adapters article defines an application on the inside
|
|
9
|
+
communicating through purposeful ports with replaceable technology-specific adapters. It
|
|
10
|
+
explicitly notes that the number of ports is not fixed:
|
|
11
|
+
https://alistair.cockburn.us/hexagonal-architecture
|
|
12
|
+
- Robert C. Martin's Clean Architecture states the Dependency Rule: source dependencies point
|
|
13
|
+
inward, while outer mechanisms must not leak names or formats into inner policy:
|
|
14
|
+
https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html
|
|
15
|
+
- Martin Fowler describes layering as a logical separation that can exist at different granularities
|
|
16
|
+
and notes that larger systems often modularize primarily by domain, layering inside those modules:
|
|
17
|
+
https://martinfowler.com/bliki/PresentationDomainDataLayering.html
|
|
18
|
+
- DDD-oriented layered architecture keeps domain rules independent from infrastructure while the
|
|
19
|
+
application layer coordinates use cases and infrastructure implements technical details:
|
|
20
|
+
https://learn.microsoft.com/dotnet/architecture/microservices/microservice-ddd-cqrs-patterns/ddd-oriented-microservice
|
|
21
|
+
|
|
22
|
+
## Invariants
|
|
23
|
+
|
|
24
|
+
```text
|
|
25
|
+
outer adapter/composition -> application/use case -> domain/core policy
|
|
26
|
+
|
|
|
27
|
+
v
|
|
28
|
+
required port contract
|
|
29
|
+
|
|
30
|
+
concrete adapter ----------------> required port contract
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
The exact filesystem can vary, but source dependencies must preserve this direction.
|
|
34
|
+
|
|
35
|
+
### Inner policy
|
|
36
|
+
|
|
37
|
+
Domain/core policy owns deterministic rules, values, invariants, and transformations. It must not
|
|
38
|
+
import:
|
|
39
|
+
|
|
40
|
+
- CLI, HTTP, UI, worker, or framework entrypoints;
|
|
41
|
+
- filesystem/process/network/database/provider SDK implementations;
|
|
42
|
+
- adapters or composition roots;
|
|
43
|
+
- application orchestration that sits outside that policy.
|
|
44
|
+
|
|
45
|
+
### Application/use cases
|
|
46
|
+
|
|
47
|
+
Application code coordinates domain policy and required capabilities. It may define or consume port
|
|
48
|
+
contracts, but it must not import concrete adapter implementations or composition roots.
|
|
49
|
+
|
|
50
|
+
### Ports
|
|
51
|
+
|
|
52
|
+
A port names a capability or conversation at a boundary. Create one when an external side effect,
|
|
53
|
+
provider, process, platform, storage mechanism, or multiple delivery mechanisms justify substitution
|
|
54
|
+
or deterministic testing. A port is not required for an ordinary pure function call.
|
|
55
|
+
|
|
56
|
+
### Adapters
|
|
57
|
+
|
|
58
|
+
Adapters translate at the edge. Inbound adapters map CLI/HTTP/UI/worker input into an application
|
|
59
|
+
operation. Outbound adapters implement required capabilities using concrete technology.
|
|
60
|
+
|
|
61
|
+
An `adapters/` directory without any identifiable inward policy/capability boundary is structurally
|
|
62
|
+
suspicious: technology has become the architecture instead of adapting it.
|
|
63
|
+
|
|
64
|
+
### Composition
|
|
65
|
+
|
|
66
|
+
Composition selects implementations and wires dependencies. It is intentionally outermost and may
|
|
67
|
+
know concrete adapters. Inner policy must never import it.
|
|
68
|
+
|
|
69
|
+
## Verification strategy
|
|
70
|
+
|
|
71
|
+
Doctor should validate what can be proven statically:
|
|
72
|
+
|
|
73
|
+
- local dependency protocols and sibling-source coupling;
|
|
74
|
+
- folder-role combinations after a vocabulary is introduced;
|
|
75
|
+
- relative import direction between recognized roles;
|
|
76
|
+
- generic catch-all architecture directories;
|
|
77
|
+
- public-package standalone scripts and packed artifact boundaries.
|
|
78
|
+
|
|
79
|
+
Semantic independence that cannot be inferred statically belongs in the package-owned
|
|
80
|
+
`test:standalone` suite. Release must execute both Doctor and that owner test.
|
|
@@ -35,6 +35,15 @@ Dependency direction is always inward:
|
|
|
35
35
|
- Domain -> domain-only abstractions (no framework or infrastructure dependencies)
|
|
36
36
|
- Domain -> nothing external
|
|
37
37
|
|
|
38
|
+
## Structural rule
|
|
39
|
+
|
|
40
|
+
Hexagonal architecture does not prescribe one mandatory directory tree or a fixed number of ports.
|
|
41
|
+
The enforceable contract is dependency direction and replaceability: inner policy must remain
|
|
42
|
+
independent from outer technology, and adapters translate external mechanisms at explicit
|
|
43
|
+
boundaries. Use the repository's managed `ankhorage-project-structure` skill to select a concrete
|
|
44
|
+
profile and validate folder-role combinations. Do not create empty layers or ports merely to match
|
|
45
|
+
a diagram.
|
|
46
|
+
|
|
38
47
|
## How It Works
|
|
39
48
|
|
|
40
49
|
### Step 1: Model a use case boundary
|
|
@@ -83,6 +83,19 @@ jobs:
|
|
|
83
83
|
echo "No test script found; skipping."
|
|
84
84
|
fi
|
|
85
85
|
|
|
86
|
+
- name: Run standalone contract
|
|
87
|
+
run: |
|
|
88
|
+
if node -e "const p=require('./package.json'); const required=p.private!==true&&p.publishConfig?.access==='public'; process.exit(required||p.scripts?.['test:standalone']?0:1)"; then
|
|
89
|
+
if node -e "const p=require('./package.json'); process.exit(p.scripts?.['test:standalone']?0:1)"; then
|
|
90
|
+
bun run test:standalone
|
|
91
|
+
else
|
|
92
|
+
echo "::error title=Standalone contract test required::Public packages must define test:standalone."
|
|
93
|
+
exit 1
|
|
94
|
+
fi
|
|
95
|
+
else
|
|
96
|
+
echo "No standalone owner test required; skipping."
|
|
97
|
+
fi
|
|
98
|
+
|
|
86
99
|
- name: Run typecheck
|
|
87
100
|
run: |
|
|
88
101
|
if node -e "const p=require('./package.json'); process.exit(p.scripts?.typecheck ? 0 : 1)"; then
|
|
@@ -76,6 +76,18 @@ jobs:
|
|
|
76
76
|
echo "No build script found; skipping."
|
|
77
77
|
fi
|
|
78
78
|
|
|
79
|
+
- name: Validate standalone release contract
|
|
80
|
+
run: |
|
|
81
|
+
BUN_INSTALL_CACHE_DIR="${RUNNER_TEMP}/doctor-cache" bunx @ankhorage/doctor@__ANKH_DOCTOR_VERSION__ validate .
|
|
82
|
+
if node -e "const p=require('./package.json'); const required=p.private!==true&&p.publishConfig?.access==='public'; process.exit(required||p.scripts?.['test:standalone']?0:1)"; then
|
|
83
|
+
if node -e "const p=require('./package.json'); process.exit(p.scripts?.['test:standalone']?0:1)"; then
|
|
84
|
+
bun run test:standalone
|
|
85
|
+
else
|
|
86
|
+
echo "::error title=Standalone contract test required::Public packages must define test:standalone."
|
|
87
|
+
exit 1
|
|
88
|
+
fi
|
|
89
|
+
fi
|
|
90
|
+
|
|
79
91
|
- name: Version packages directly on main
|
|
80
92
|
id: release
|
|
81
93
|
if: hashFiles('.changeset/config.json') != ''
|
|
@@ -148,6 +160,31 @@ jobs:
|
|
|
148
160
|
echo "release_sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
|
149
161
|
fi
|
|
150
162
|
|
|
163
|
+
- name: Verify standalone packed install
|
|
164
|
+
if: steps.release.outputs.versioned == 'true'
|
|
165
|
+
run: |
|
|
166
|
+
set -euo pipefail
|
|
167
|
+
bun install --frozen-lockfile --ignore-scripts
|
|
168
|
+
if node -e "const p=require('./package.json'); process.exit(p.scripts?.build?0:1)"; then
|
|
169
|
+
bun run build
|
|
170
|
+
fi
|
|
171
|
+
if node -e "const p=require('./package.json'); process.exit(p.scripts?.['test:standalone']?0:1)"; then
|
|
172
|
+
bun run test:standalone
|
|
173
|
+
fi
|
|
174
|
+
pack_dir="$(mktemp -d "${RUNNER_TEMP}/standalone-pack.XXXXXX")"
|
|
175
|
+
install_dir="$(mktemp -d "${RUNNER_TEMP}/standalone-install.XXXXXX")"
|
|
176
|
+
npm pack --ignore-scripts --pack-destination "$pack_dir" >/dev/null
|
|
177
|
+
artifact="$(find "$pack_dir" -maxdepth 1 -type f -name '*.tgz' -print -quit)"
|
|
178
|
+
if [ -z "$artifact" ]; then
|
|
179
|
+
echo "::error title=Standalone pack failed::npm pack did not produce a package artifact."
|
|
180
|
+
exit 1
|
|
181
|
+
fi
|
|
182
|
+
printf '{"name":"standalone-consumer","private":true,"version":"0.0.0"}\n' > "$install_dir/package.json"
|
|
183
|
+
(
|
|
184
|
+
cd "$install_dir"
|
|
185
|
+
bun add "$artifact" --ignore-scripts
|
|
186
|
+
)
|
|
187
|
+
|
|
151
188
|
- name: Publish unpublished packages
|
|
152
189
|
if: hashFiles('.changeset/config.json') != ''
|
|
153
190
|
run: |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ankhorage/devtools",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.21.0",
|
|
4
4
|
"description": "Shared tooling, repository automation, runtime policies, and agent standards for Ankhorage TypeScript projects",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"homepage": "https://github.com/ankhorage/devtools#readme",
|
|
@@ -121,7 +121,8 @@
|
|
|
121
121
|
"changeset": "bun src/cli/bin/changeset.ts",
|
|
122
122
|
"changeset:status": "bun src/cli/bin/changeset.ts status --since=origin/main",
|
|
123
123
|
"version-packages": "bun src/cli/bin/changeset.ts version",
|
|
124
|
-
"check-types": "bun x tsc --noEmit -p tsconfig.test.json"
|
|
124
|
+
"check-types": "bun x tsc --noEmit -p tsconfig.test.json",
|
|
125
|
+
"test:standalone": "bun test src/tools/agents/index.test.ts src/tools/shared/managedFiles.test.ts"
|
|
125
126
|
},
|
|
126
127
|
"dependencies": {
|
|
127
128
|
"@ankhorage/apm": "^0.8.10",
|