@antoneeo/agentic-sdlc-skill 1.26.0 → 1.27.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/CHANGELOG.md +101 -0
- package/gemini-extension.json +6 -6
- package/package.json +50 -50
- package/scripts/init.js +123 -6
- package/scripts/lib.js +434 -227
- package/skills/agentic-sdlc-skill/ENFORCEMENT.md +31 -0
- package/skills/agentic-sdlc-skill/SKILL.md +352 -352
- package/skills/agentic-sdlc-skill/dispatch.md +9 -0
- package/skills/agentic-sdlc-skill/review.md +20 -5
- package/skills/agentic-sdlc-skill/templates.md +9 -2
package/scripts/lib.js
CHANGED
|
@@ -1,227 +1,434 @@
|
|
|
1
|
-
// Shared helpers for the Agentic SDLC npm scripts (init / postinstall / preuninstall).
|
|
2
|
-
// Single source for client detection and skill-target paths: init and postinstall
|
|
3
|
-
// must never disagree on what "Claude Code is installed" means.
|
|
4
|
-
|
|
5
|
-
const fs = require('fs');
|
|
6
|
-
const path = require('path');
|
|
7
|
-
const os = require('os');
|
|
8
|
-
const { execSync } = require('child_process');
|
|
9
|
-
|
|
10
|
-
const PACKAGE_ROOT = path.resolve(__dirname, '..');
|
|
11
|
-
const SKILL_SOURCE = path.join(PACKAGE_ROOT, 'skills', 'agentic-sdlc-skill');
|
|
12
|
-
const TEMPLATES_PATH = path.join(SKILL_SOURCE, 'templates.md');
|
|
13
|
-
// The directory name each client loads the skill from. Derived from the manifest,
|
|
14
|
-
// never hard-coded by a consumer: three distributions share these scripts, and a
|
|
15
|
-
// literal here is how a copy-fork starts installing under its sibling's name.
|
|
16
|
-
const INSTALLED_SKILL_NAME = (() => {
|
|
17
|
-
const m = fs.readFileSync(path.join(SKILL_SOURCE, 'SKILL.md'), 'utf8').match(/^name:\s*(\S+)/m);
|
|
18
|
-
if (!m) throw new Error(`SKILL.md carries no 'name:' field: ${SKILL_SOURCE}`);
|
|
19
|
-
return m[1];
|
|
20
|
-
})();
|
|
21
|
-
|
|
22
|
-
// The family's lens table, read from the shared `routing.md` rather than restated
|
|
23
|
-
// here. Same reason as INSTALLED_SKILL_NAME above, and the same failure it prevents:
|
|
24
|
-
// BOTH the row for this lens and the rows for its siblings used to be literals copied
|
|
25
|
-
// between distributions, so kb and mkt wrote a multi-lens note announcing themselves
|
|
26
|
-
// as the code lens. routing.md is one of the byte-identical shared files, so this
|
|
27
|
-
// lookup cannot drift between distributions.
|
|
28
|
-
// Lazy on purpose: preuninstall.js needs INSTALLED_SKILL_NAME and nothing else, and
|
|
29
|
-
// must keep working on an installation damaged badly enough to have lost routing.md.
|
|
30
|
-
function lensTable() {
|
|
31
|
-
const routing = path.join(SKILL_SOURCE, 'routing.md');
|
|
32
|
-
if (!fs.existsSync(routing)) throw new Error(`routing.md missing from the skill: ${SKILL_SOURCE}`);
|
|
33
|
-
// Parsed as a table, not matched with a regex: a mis-escaped pattern matches the
|
|
34
|
-
// empty string and yields undefined instead of throwing, which is how this lookup
|
|
35
|
-
// failed the first time it was written.
|
|
36
|
-
const table = new Map();
|
|
37
|
-
for (const line of fs.readFileSync(routing, 'utf8').split(/\r?\n/)) {
|
|
38
|
-
const cells = line.split('|').map((cell) => cell.trim());
|
|
39
|
-
if (cells.length < 4) continue;
|
|
40
|
-
const [, lens, skill] = cells;
|
|
41
|
-
if (!/^[a-z]+$/.test(lens) || !/^`[a-z0-9-]+`$/.test(skill)) continue;
|
|
42
|
-
const name = skill.slice(1, -1);
|
|
43
|
-
// A duplicate row would otherwise be won silently by whichever came first, and a
|
|
44
|
-
// wrong routing.md propagates byte-identically to every distribution.
|
|
45
|
-
if (table.has(name)) throw new Error(`routing.md lists '${name}' more than once`);
|
|
46
|
-
table.set(name, lens);
|
|
47
|
-
}
|
|
48
|
-
if (!table.size) throw new Error(`routing.md carries no lens table: ${routing}`);
|
|
49
|
-
return table;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
function selfLens() {
|
|
53
|
-
const lens = lensTable().get(INSTALLED_SKILL_NAME);
|
|
54
|
-
if (!lens) throw new Error(`routing.md has no lens row for '${INSTALLED_SKILL_NAME}'`);
|
|
55
|
-
return lens;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
// Sibling lenses of the same family: one shared core, one docs tree, a different
|
|
59
|
-
// fidelity discipline each. Keyed by the installed skill directory name.
|
|
60
|
-
function siblingLenses() {
|
|
61
|
-
const table = lensTable();
|
|
62
|
-
selfLens(); // this lens must be in the table too
|
|
63
|
-
table.delete(INSTALLED_SKILL_NAME);
|
|
64
|
-
return Object.fromEntries(table);
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
// One entry per supported AI client. `home` may be overridden by an env var
|
|
68
|
-
// (Claude Desktop / portable installs); presence of the home dir counts as
|
|
69
|
-
// detection even when the CLI is not on PATH.
|
|
70
|
-
const CLIENTS = [
|
|
71
|
-
{
|
|
72
|
-
key: 'claude',
|
|
73
|
-
label: 'Claude Code',
|
|
74
|
-
cmd: 'claude',
|
|
75
|
-
home: process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude'),
|
|
76
|
-
envVar: 'CLAUDE_CONFIG_DIR',
|
|
77
|
-
reload: 'Restart Claude Code to load it. Invoke via Skill tool as "agentic-sdlc".',
|
|
78
|
-
},
|
|
79
|
-
{
|
|
80
|
-
key: 'gemini',
|
|
81
|
-
label: 'Gemini CLI',
|
|
82
|
-
cmd: 'gemini',
|
|
83
|
-
home: process.env.GEMINI_HOME || path.join(os.homedir(), '.gemini'),
|
|
84
|
-
envVar: 'GEMINI_HOME',
|
|
85
|
-
reload: 'Run "gemini skills reload" or restart Gemini CLI to load it.',
|
|
86
|
-
},
|
|
87
|
-
{
|
|
88
|
-
key: 'codex',
|
|
89
|
-
label: 'Codex AI',
|
|
90
|
-
cmd: 'codex',
|
|
91
|
-
home: process.env.CODEX_HOME || path.join(os.homedir(), '.codex'),
|
|
92
|
-
envVar: 'CODEX_HOME',
|
|
93
|
-
reload: 'Restart Codex to load it. Invoke it as "$agentic-sdlc" or by asking for Agentic SDLC.',
|
|
94
|
-
},
|
|
95
|
-
{
|
|
96
|
-
// Google Antigravity 2.0 discovers global agent skills under
|
|
97
|
-
// ~/.gemini/config/skills/ -- the SAME home the legacy Gemini CLI claims.
|
|
98
|
-
// To avoid a shared-home double-install (P-TM T1), this entry sets:
|
|
99
|
-
// - skillsSubdir 'config/skills': distinct target from gemini's ~/.gemini/skills
|
|
100
|
-
// - homeMarker on ~/.gemini/config/skills: detection never fires on bare
|
|
101
|
-
// ~/.gemini (which every Antigravity user has); only the Antigravity skills
|
|
102
|
-
// dir, the `agy` CLI, or ANTIGRAVITY_HOME count as "Antigravity installed".
|
|
103
|
-
key: 'antigravity',
|
|
104
|
-
label: 'Google Antigravity',
|
|
105
|
-
cmd: 'agy',
|
|
106
|
-
home: process.env.ANTIGRAVITY_HOME || path.join(os.homedir(), '.gemini'),
|
|
107
|
-
envVar: 'ANTIGRAVITY_HOME',
|
|
108
|
-
skillsSubdir: 'config/skills',
|
|
109
|
-
homeMarker: path.join(
|
|
110
|
-
process.env.ANTIGRAVITY_HOME || path.join(os.homedir(), '.gemini'),
|
|
111
|
-
'config',
|
|
112
|
-
'skills',
|
|
113
|
-
),
|
|
114
|
-
reload: 'Restart Antigravity, or run "agy skills reload", to load it. Invoke by asking for Agentic SDLC.',
|
|
115
|
-
},
|
|
116
|
-
];
|
|
117
|
-
|
|
118
|
-
function commandExists(cmd) {
|
|
119
|
-
try {
|
|
120
|
-
execSync(`${cmd} --version`, { stdio: 'ignore' });
|
|
121
|
-
return true;
|
|
122
|
-
} catch (e) {
|
|
123
|
-
return false;
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
function clientDetected(client) {
|
|
128
|
-
// An entry may override the fs-existence probe with a `homeMarker` (a more
|
|
129
|
-
// specific path than the bare home) so two clients sharing a home dir do not
|
|
130
|
-
// both fire on its mere existence. Entries without a marker check `home`
|
|
131
|
-
// exactly as before (backward-compatible).
|
|
132
|
-
const homePathToCheck = client.homeMarker || client.home;
|
|
133
|
-
return commandExists(client.cmd)
|
|
134
|
-
|| Boolean(process.env[client.envVar])
|
|
135
|
-
|| fs.existsSync(homePathToCheck);
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
function skillTarget(client) {
|
|
139
|
-
// An entry may override the default `skills` sub-path with `skillsSubdir`
|
|
140
|
-
// (split on '/' to keep cross-platform path.join correctness). Entries
|
|
141
|
-
// without it resolve to <home>/skills/agentic-sdlc exactly as before.
|
|
142
|
-
const subdir = client.skillsSubdir ? client.skillsSubdir.split('/') : ['skills'];
|
|
143
|
-
return path.join(client.home, ...subdir, INSTALLED_SKILL_NAME);
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
function copyRecursive(src, dest) {
|
|
147
|
-
if (typeof fs.cpSync === 'function') {
|
|
148
|
-
fs.cpSync(src, dest, { recursive: true, force: true });
|
|
149
|
-
return;
|
|
150
|
-
}
|
|
151
|
-
// Fallback for Node < 16.7
|
|
152
|
-
if (!fs.existsSync(dest)) fs.mkdirSync(dest, { recursive: true });
|
|
153
|
-
for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
|
|
154
|
-
const s = path.join(src, entry.name);
|
|
155
|
-
const d = path.join(dest, entry.name);
|
|
156
|
-
if (entry.isDirectory()) copyRecursive(s, d);
|
|
157
|
-
else fs.copyFileSync(s, d);
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
/**
|
|
162
|
-
* Parse templates.md into { headingText: [fencedBlock, ...] }.
|
|
163
|
-
* Templates are single-sourced there: the init script must extract them
|
|
164
|
-
* instead of carrying its own inline copies (which historically drifted).
|
|
165
|
-
*/
|
|
166
|
-
function loadTemplates() {
|
|
167
|
-
const text = fs.readFileSync(TEMPLATES_PATH, 'utf8');
|
|
168
|
-
const lines = text.split(/\r?\n/);
|
|
169
|
-
const sections = {};
|
|
170
|
-
let heading = null;
|
|
171
|
-
let block = null;
|
|
172
|
-
for (const line of lines) {
|
|
173
|
-
const h = line.match(/^##\s+(.*)$/);
|
|
174
|
-
if (h && block === null) {
|
|
175
|
-
heading = h[1].trim();
|
|
176
|
-
sections[heading] = sections[heading] || [];
|
|
177
|
-
continue;
|
|
178
|
-
}
|
|
179
|
-
if (/^```/.test(line)) {
|
|
180
|
-
if (block === null) {
|
|
181
|
-
block = [];
|
|
182
|
-
} else {
|
|
183
|
-
if (heading) sections[heading].push(block.join('\n') + '\n');
|
|
184
|
-
block = null;
|
|
185
|
-
}
|
|
186
|
-
continue;
|
|
187
|
-
}
|
|
188
|
-
if (block !== null) block.push(line);
|
|
189
|
-
}
|
|
190
|
-
return sections;
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
/**
|
|
194
|
-
* Return the Nth fenced block of the section whose heading contains `needle`.
|
|
195
|
-
* Throws with a clear message when missing: writing a wrong or empty
|
|
196
|
-
* boilerplate silently would be worse than failing the init.
|
|
197
|
-
*/
|
|
198
|
-
function templateFor(sections, needle, index = 0) {
|
|
199
|
-
const heading = Object.keys(sections).find((h) => h.includes(needle));
|
|
200
|
-
const blocks = heading ? sections[heading] : undefined;
|
|
201
|
-
if (!blocks || !blocks[index]) {
|
|
202
|
-
throw new Error(
|
|
203
|
-
`Template section containing "${needle}" (block ${index}) not found in ${TEMPLATES_PATH}. ` +
|
|
204
|
-
'The package is corrupted or templates.md was restructured: fix templates.md, do not improvise content.'
|
|
205
|
-
);
|
|
206
|
-
}
|
|
207
|
-
return blocks[index];
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
1
|
+
// Shared helpers for the Agentic SDLC npm scripts (init / postinstall / preuninstall).
|
|
2
|
+
// Single source for client detection and skill-target paths: init and postinstall
|
|
3
|
+
// must never disagree on what "Claude Code is installed" means.
|
|
4
|
+
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const path = require('path');
|
|
7
|
+
const os = require('os');
|
|
8
|
+
const { execSync } = require('child_process');
|
|
9
|
+
|
|
10
|
+
const PACKAGE_ROOT = path.resolve(__dirname, '..');
|
|
11
|
+
const SKILL_SOURCE = path.join(PACKAGE_ROOT, 'skills', 'agentic-sdlc-skill');
|
|
12
|
+
const TEMPLATES_PATH = path.join(SKILL_SOURCE, 'templates.md');
|
|
13
|
+
// The directory name each client loads the skill from. Derived from the manifest,
|
|
14
|
+
// never hard-coded by a consumer: three distributions share these scripts, and a
|
|
15
|
+
// literal here is how a copy-fork starts installing under its sibling's name.
|
|
16
|
+
const INSTALLED_SKILL_NAME = (() => {
|
|
17
|
+
const m = fs.readFileSync(path.join(SKILL_SOURCE, 'SKILL.md'), 'utf8').match(/^name:\s*(\S+)/m);
|
|
18
|
+
if (!m) throw new Error(`SKILL.md carries no 'name:' field: ${SKILL_SOURCE}`);
|
|
19
|
+
return m[1];
|
|
20
|
+
})();
|
|
21
|
+
|
|
22
|
+
// The family's lens table, read from the shared `routing.md` rather than restated
|
|
23
|
+
// here. Same reason as INSTALLED_SKILL_NAME above, and the same failure it prevents:
|
|
24
|
+
// BOTH the row for this lens and the rows for its siblings used to be literals copied
|
|
25
|
+
// between distributions, so kb and mkt wrote a multi-lens note announcing themselves
|
|
26
|
+
// as the code lens. routing.md is one of the byte-identical shared files, so this
|
|
27
|
+
// lookup cannot drift between distributions.
|
|
28
|
+
// Lazy on purpose: preuninstall.js needs INSTALLED_SKILL_NAME and nothing else, and
|
|
29
|
+
// must keep working on an installation damaged badly enough to have lost routing.md.
|
|
30
|
+
function lensTable() {
|
|
31
|
+
const routing = path.join(SKILL_SOURCE, 'routing.md');
|
|
32
|
+
if (!fs.existsSync(routing)) throw new Error(`routing.md missing from the skill: ${SKILL_SOURCE}`);
|
|
33
|
+
// Parsed as a table, not matched with a regex: a mis-escaped pattern matches the
|
|
34
|
+
// empty string and yields undefined instead of throwing, which is how this lookup
|
|
35
|
+
// failed the first time it was written.
|
|
36
|
+
const table = new Map();
|
|
37
|
+
for (const line of fs.readFileSync(routing, 'utf8').split(/\r?\n/)) {
|
|
38
|
+
const cells = line.split('|').map((cell) => cell.trim());
|
|
39
|
+
if (cells.length < 4) continue;
|
|
40
|
+
const [, lens, skill] = cells;
|
|
41
|
+
if (!/^[a-z]+$/.test(lens) || !/^`[a-z0-9-]+`$/.test(skill)) continue;
|
|
42
|
+
const name = skill.slice(1, -1);
|
|
43
|
+
// A duplicate row would otherwise be won silently by whichever came first, and a
|
|
44
|
+
// wrong routing.md propagates byte-identically to every distribution.
|
|
45
|
+
if (table.has(name)) throw new Error(`routing.md lists '${name}' more than once`);
|
|
46
|
+
table.set(name, lens);
|
|
47
|
+
}
|
|
48
|
+
if (!table.size) throw new Error(`routing.md carries no lens table: ${routing}`);
|
|
49
|
+
return table;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function selfLens() {
|
|
53
|
+
const lens = lensTable().get(INSTALLED_SKILL_NAME);
|
|
54
|
+
if (!lens) throw new Error(`routing.md has no lens row for '${INSTALLED_SKILL_NAME}'`);
|
|
55
|
+
return lens;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Sibling lenses of the same family: one shared core, one docs tree, a different
|
|
59
|
+
// fidelity discipline each. Keyed by the installed skill directory name.
|
|
60
|
+
function siblingLenses() {
|
|
61
|
+
const table = lensTable();
|
|
62
|
+
selfLens(); // this lens must be in the table too
|
|
63
|
+
table.delete(INSTALLED_SKILL_NAME);
|
|
64
|
+
return Object.fromEntries(table);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// One entry per supported AI client. `home` may be overridden by an env var
|
|
68
|
+
// (Claude Desktop / portable installs); presence of the home dir counts as
|
|
69
|
+
// detection even when the CLI is not on PATH.
|
|
70
|
+
const CLIENTS = [
|
|
71
|
+
{
|
|
72
|
+
key: 'claude',
|
|
73
|
+
label: 'Claude Code',
|
|
74
|
+
cmd: 'claude',
|
|
75
|
+
home: process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude'),
|
|
76
|
+
envVar: 'CLAUDE_CONFIG_DIR',
|
|
77
|
+
reload: 'Restart Claude Code to load it. Invoke via Skill tool as "agentic-sdlc".',
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
key: 'gemini',
|
|
81
|
+
label: 'Gemini CLI',
|
|
82
|
+
cmd: 'gemini',
|
|
83
|
+
home: process.env.GEMINI_HOME || path.join(os.homedir(), '.gemini'),
|
|
84
|
+
envVar: 'GEMINI_HOME',
|
|
85
|
+
reload: 'Run "gemini skills reload" or restart Gemini CLI to load it.',
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
key: 'codex',
|
|
89
|
+
label: 'Codex AI',
|
|
90
|
+
cmd: 'codex',
|
|
91
|
+
home: process.env.CODEX_HOME || path.join(os.homedir(), '.codex'),
|
|
92
|
+
envVar: 'CODEX_HOME',
|
|
93
|
+
reload: 'Restart Codex to load it. Invoke it as "$agentic-sdlc" or by asking for Agentic SDLC.',
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
// Google Antigravity 2.0 discovers global agent skills under
|
|
97
|
+
// ~/.gemini/config/skills/ -- the SAME home the legacy Gemini CLI claims.
|
|
98
|
+
// To avoid a shared-home double-install (P-TM T1), this entry sets:
|
|
99
|
+
// - skillsSubdir 'config/skills': distinct target from gemini's ~/.gemini/skills
|
|
100
|
+
// - homeMarker on ~/.gemini/config/skills: detection never fires on bare
|
|
101
|
+
// ~/.gemini (which every Antigravity user has); only the Antigravity skills
|
|
102
|
+
// dir, the `agy` CLI, or ANTIGRAVITY_HOME count as "Antigravity installed".
|
|
103
|
+
key: 'antigravity',
|
|
104
|
+
label: 'Google Antigravity',
|
|
105
|
+
cmd: 'agy',
|
|
106
|
+
home: process.env.ANTIGRAVITY_HOME || path.join(os.homedir(), '.gemini'),
|
|
107
|
+
envVar: 'ANTIGRAVITY_HOME',
|
|
108
|
+
skillsSubdir: 'config/skills',
|
|
109
|
+
homeMarker: path.join(
|
|
110
|
+
process.env.ANTIGRAVITY_HOME || path.join(os.homedir(), '.gemini'),
|
|
111
|
+
'config',
|
|
112
|
+
'skills',
|
|
113
|
+
),
|
|
114
|
+
reload: 'Restart Antigravity, or run "agy skills reload", to load it. Invoke by asking for Agentic SDLC.',
|
|
115
|
+
},
|
|
116
|
+
];
|
|
117
|
+
|
|
118
|
+
function commandExists(cmd) {
|
|
119
|
+
try {
|
|
120
|
+
execSync(`${cmd} --version`, { stdio: 'ignore' });
|
|
121
|
+
return true;
|
|
122
|
+
} catch (e) {
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function clientDetected(client) {
|
|
128
|
+
// An entry may override the fs-existence probe with a `homeMarker` (a more
|
|
129
|
+
// specific path than the bare home) so two clients sharing a home dir do not
|
|
130
|
+
// both fire on its mere existence. Entries without a marker check `home`
|
|
131
|
+
// exactly as before (backward-compatible).
|
|
132
|
+
const homePathToCheck = client.homeMarker || client.home;
|
|
133
|
+
return commandExists(client.cmd)
|
|
134
|
+
|| Boolean(process.env[client.envVar])
|
|
135
|
+
|| fs.existsSync(homePathToCheck);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function skillTarget(client) {
|
|
139
|
+
// An entry may override the default `skills` sub-path with `skillsSubdir`
|
|
140
|
+
// (split on '/' to keep cross-platform path.join correctness). Entries
|
|
141
|
+
// without it resolve to <home>/skills/agentic-sdlc exactly as before.
|
|
142
|
+
const subdir = client.skillsSubdir ? client.skillsSubdir.split('/') : ['skills'];
|
|
143
|
+
return path.join(client.home, ...subdir, INSTALLED_SKILL_NAME);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function copyRecursive(src, dest) {
|
|
147
|
+
if (typeof fs.cpSync === 'function') {
|
|
148
|
+
fs.cpSync(src, dest, { recursive: true, force: true });
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
// Fallback for Node < 16.7
|
|
152
|
+
if (!fs.existsSync(dest)) fs.mkdirSync(dest, { recursive: true });
|
|
153
|
+
for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
|
|
154
|
+
const s = path.join(src, entry.name);
|
|
155
|
+
const d = path.join(dest, entry.name);
|
|
156
|
+
if (entry.isDirectory()) copyRecursive(s, d);
|
|
157
|
+
else fs.copyFileSync(s, d);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Parse templates.md into { headingText: [fencedBlock, ...] }.
|
|
163
|
+
* Templates are single-sourced there: the init script must extract them
|
|
164
|
+
* instead of carrying its own inline copies (which historically drifted).
|
|
165
|
+
*/
|
|
166
|
+
function loadTemplates() {
|
|
167
|
+
const text = fs.readFileSync(TEMPLATES_PATH, 'utf8');
|
|
168
|
+
const lines = text.split(/\r?\n/);
|
|
169
|
+
const sections = {};
|
|
170
|
+
let heading = null;
|
|
171
|
+
let block = null;
|
|
172
|
+
for (const line of lines) {
|
|
173
|
+
const h = line.match(/^##\s+(.*)$/);
|
|
174
|
+
if (h && block === null) {
|
|
175
|
+
heading = h[1].trim();
|
|
176
|
+
sections[heading] = sections[heading] || [];
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
if (/^```/.test(line)) {
|
|
180
|
+
if (block === null) {
|
|
181
|
+
block = [];
|
|
182
|
+
} else {
|
|
183
|
+
if (heading) sections[heading].push(block.join('\n') + '\n');
|
|
184
|
+
block = null;
|
|
185
|
+
}
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
if (block !== null) block.push(line);
|
|
189
|
+
}
|
|
190
|
+
return sections;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Return the Nth fenced block of the section whose heading contains `needle`.
|
|
195
|
+
* Throws with a clear message when missing: writing a wrong or empty
|
|
196
|
+
* boilerplate silently would be worse than failing the init.
|
|
197
|
+
*/
|
|
198
|
+
function templateFor(sections, needle, index = 0) {
|
|
199
|
+
const heading = Object.keys(sections).find((h) => h.includes(needle));
|
|
200
|
+
const blocks = heading ? sections[heading] : undefined;
|
|
201
|
+
if (!blocks || !blocks[index]) {
|
|
202
|
+
throw new Error(
|
|
203
|
+
`Template section containing "${needle}" (block ${index}) not found in ${TEMPLATES_PATH}. ` +
|
|
204
|
+
'The package is corrupted or templates.md was restructured: fix templates.md, do not improvise content.'
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
return blocks[index];
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// --- SessionStart orientation hook (F-036) ---------------------------------
|
|
211
|
+
// ENFORCEMENT.md §4 says to wire this on every project that has a docs root and
|
|
212
|
+
// a Python interpreter. Until F-036 nothing did: it was a manual step, so it was
|
|
213
|
+
// skipped, and the guide router reached no agent that did not already know to
|
|
214
|
+
// look for it. The hook only INJECTS orientation -- it cannot force a skill
|
|
215
|
+
// invocation, and the mechanism that could (a blocking PreToolUse gate) is
|
|
216
|
+
// refused by the Vision's no-ceremony-ratchet Non-Goal.
|
|
217
|
+
|
|
218
|
+
const ORIENT_HOOK_TIMEOUT = 10;
|
|
219
|
+
|
|
220
|
+
// The validator's filename is DERIVED, never written here: the marketing lens
|
|
221
|
+
// ships `mkt_check.py` where the others ship `sdlc_check.py`, and this block is
|
|
222
|
+
// copied verbatim into all three distributions. A literal would make two of the
|
|
223
|
+
// three look for an entry point that does not exist.
|
|
224
|
+
const ORIENT_ENTRY_POINTS = ['sdlc_check.py', 'mkt_check.py'];
|
|
225
|
+
const ORIENT_ENTRY_POINT = (() => {
|
|
226
|
+
for (const name of ORIENT_ENTRY_POINTS) {
|
|
227
|
+
if (fs.existsSync(path.join(SKILL_SOURCE, 'scripts', name))) return name;
|
|
228
|
+
}
|
|
229
|
+
return ORIENT_ENTRY_POINTS[0];
|
|
230
|
+
})();
|
|
231
|
+
|
|
232
|
+
// Both settings layers are ALWAYS inspected for an existing hook, whichever one
|
|
233
|
+
// we would write to. Scanning only the target file left two holes: a project
|
|
234
|
+
// that starts un-vendored and later vendors flips the target from the local file
|
|
235
|
+
// to the shared one, finds nothing there, and appends a second hook; and a dead
|
|
236
|
+
// hook in the layer we are not writing stays invisible.
|
|
237
|
+
const ORIENT_SETTINGS_FILES = ['settings.json', 'settings.local.json'];
|
|
238
|
+
|
|
239
|
+
// A vendored path is built from a directory name read out of the TARGET
|
|
240
|
+
// repository, and the result is executed by the client as a shell command. That
|
|
241
|
+
// is repo-controlled input reaching a command line, so each segment must match
|
|
242
|
+
// this exactly -- `$(...)`, backticks and newlines are not "unusual names", they
|
|
243
|
+
// are the payload. The absolute branch cannot reach this input (it is built from
|
|
244
|
+
// os.homedir() and the client roster) but is filtered too, below.
|
|
245
|
+
const ORIENT_SAFE_SEGMENT = /^[A-Za-z0-9._-]+$/;
|
|
246
|
+
// Refused anywhere in a command path. NOT backslash: a Windows path is full of
|
|
247
|
+
// them and the shape this project already commits uses them.
|
|
248
|
+
const ORIENT_UNSAFE_IN_PATH = /["$`\r\n]/;
|
|
249
|
+
|
|
250
|
+
// Where a repo may legitimately vendor the validator. ENFORCEMENT §2 tells users
|
|
251
|
+
// to copy it next to their CI config (`tools/`), and a skill-authoring repo
|
|
252
|
+
// carries it under `skills/<lens>/scripts/`. Both are checked; the lens matching
|
|
253
|
+
// this distribution wins, so a monorepo vendoring several lenses cannot wire a
|
|
254
|
+
// sibling's validator against this lens's docs root.
|
|
255
|
+
function vendoredValidator(cwd) {
|
|
256
|
+
const direct = [];
|
|
257
|
+
for (const dir of ['tools', 'scripts']) {
|
|
258
|
+
direct.push([dir, ORIENT_ENTRY_POINT].join('/'));
|
|
259
|
+
}
|
|
260
|
+
const skillDirs = (() => {
|
|
261
|
+
let entries;
|
|
262
|
+
try {
|
|
263
|
+
entries = fs.readdirSync(path.join(cwd, 'skills'), { withFileTypes: true });
|
|
264
|
+
} catch (e) {
|
|
265
|
+
return []; // no skills/ here, or it is not a directory
|
|
266
|
+
}
|
|
267
|
+
const names = entries.filter((e) => e.isDirectory()).map((e) => e.name)
|
|
268
|
+
.filter((n) => ORIENT_SAFE_SEGMENT.test(n));
|
|
269
|
+
// This lens first: readdir order must not decide which lens we orient.
|
|
270
|
+
names.sort((a, b) => (a === INSTALLED_SKILL_NAME ? -1 : 0)
|
|
271
|
+
- (b === INSTALLED_SKILL_NAME ? -1 : 0));
|
|
272
|
+
return names.map((n) => ['skills', n, 'scripts', ORIENT_ENTRY_POINT].join('/'));
|
|
273
|
+
})();
|
|
274
|
+
for (const rel of direct.concat(skillDirs)) {
|
|
275
|
+
if (fs.existsSync(path.join(cwd, rel))) return rel;
|
|
276
|
+
}
|
|
277
|
+
return null;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function orientHookCommand(python, validator, hybrid) {
|
|
281
|
+
return python + ' "' + validator + '" orient' + (hybrid ? ' --hybrid' : '');
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// The token in an EXISTING command that names a validator -- quoted or bare, and
|
|
285
|
+
// NOT simply "the first quoted thing". That naive rule failed both ways: a
|
|
286
|
+
// command that quotes the interpreter (`"C:\Py\python.exe" "...sdlc_check.py"`)
|
|
287
|
+
// returned the interpreter, which exists, so a DEAD hook reported as healthy;
|
|
288
|
+
// and a command with no quotes at all returned nothing, so a WORKING hook was
|
|
289
|
+
// reported broken. Returns null when no token names a validator: the caller must
|
|
290
|
+
// then say it cannot tell, never that the hook is broken.
|
|
291
|
+
function orientHookValidator(command) {
|
|
292
|
+
const cmd = String(command || '');
|
|
293
|
+
const re = /"([^"]*)"|(\S+)/g;
|
|
294
|
+
let m;
|
|
295
|
+
while ((m = re.exec(cmd)) !== null) {
|
|
296
|
+
const token = m[1] !== undefined ? m[1] : m[2];
|
|
297
|
+
if (ORIENT_ENTRY_POINTS.some((n) => token.endsWith(n))) return token;
|
|
298
|
+
}
|
|
299
|
+
return null;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// Accepts both shapes seen in the wild: the documented groups
|
|
303
|
+
// (`SessionStart: [{ hooks: [...] }]`) and hook objects placed directly in the
|
|
304
|
+
// array. Anything else is reported by the caller rather than silently replaced.
|
|
305
|
+
function findOrientHook(settings) {
|
|
306
|
+
const groups = settings && settings.hooks && settings.hooks.SessionStart;
|
|
307
|
+
if (!Array.isArray(groups)) return null;
|
|
308
|
+
for (const group of groups) {
|
|
309
|
+
if (!group || typeof group !== 'object') continue;
|
|
310
|
+
const inner = Array.isArray(group.hooks) ? group.hooks : [group];
|
|
311
|
+
for (const h of inner) {
|
|
312
|
+
const cmd = h && typeof h.command === 'string' ? h.command : '';
|
|
313
|
+
if (ORIENT_ENTRY_POINTS.some((n) => cmd.includes(n)) && / orient(\s|$)/.test(cmd)) {
|
|
314
|
+
return cmd;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
return null;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// A settings object we can safely merge into: absent, or carrying the shapes we
|
|
322
|
+
// know. "I do not recognise this" must mean "write nothing", never "treat it as
|
|
323
|
+
// empty" -- the second is how a hand-written hook disappears.
|
|
324
|
+
function orientSettingsState(target) {
|
|
325
|
+
if (!fs.existsSync(target)) return { ok: true, settings: {} };
|
|
326
|
+
let settings;
|
|
327
|
+
try {
|
|
328
|
+
settings = JSON.parse(fs.readFileSync(target, 'utf8'));
|
|
329
|
+
} catch (e) {
|
|
330
|
+
return { ok: false, why: 'unreadable' };
|
|
331
|
+
}
|
|
332
|
+
if (!settings || typeof settings !== 'object' || Array.isArray(settings)) {
|
|
333
|
+
return { ok: false, why: 'not-an-object' };
|
|
334
|
+
}
|
|
335
|
+
const hooks = settings.hooks;
|
|
336
|
+
if (hooks !== undefined
|
|
337
|
+
&& (!hooks || typeof hooks !== 'object' || Array.isArray(hooks))) {
|
|
338
|
+
return { ok: false, why: 'unexpected-hooks' };
|
|
339
|
+
}
|
|
340
|
+
if (hooks && hooks.SessionStart !== undefined && !Array.isArray(hooks.SessionStart)) {
|
|
341
|
+
return { ok: false, why: 'unexpected-hooks' };
|
|
342
|
+
}
|
|
343
|
+
return { ok: true, settings };
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// Returns a RESULT CODE, never printed text: the caller owns the wording and the
|
|
347
|
+
// battery asserts on the code. Codes:
|
|
348
|
+
// wired | already | broken | unverifiable | malformed
|
|
349
|
+
// no-validator | no-python | unsafe-path | write-failed
|
|
350
|
+
function wireOrientHook(options) {
|
|
351
|
+
const cwd = options.cwd;
|
|
352
|
+
const client = options.client;
|
|
353
|
+
const python = options.python;
|
|
354
|
+
const hybrid = !!options.hybrid;
|
|
355
|
+
const docsLabel = options.docsLabel || 'project';
|
|
356
|
+
|
|
357
|
+
const vendored = vendoredValidator(cwd);
|
|
358
|
+
const absolute = path.join(skillTarget(client), 'scripts', ORIENT_ENTRY_POINT);
|
|
359
|
+
const validator = vendored || absolute;
|
|
360
|
+
if (!vendored && !fs.existsSync(absolute)) return { code: 'no-validator', validator };
|
|
361
|
+
if (!python) return { code: 'no-python', validator };
|
|
362
|
+
if (ORIENT_UNSAFE_IN_PATH.test(validator)) return { code: 'unsafe-path', validator };
|
|
363
|
+
|
|
364
|
+
const command = orientHookCommand(python, validator, hybrid);
|
|
365
|
+
|
|
366
|
+
// Inspect BOTH layers before deciding to write anything.
|
|
367
|
+
for (const name of ORIENT_SETTINGS_FILES) {
|
|
368
|
+
const p = path.join(cwd, '.claude', name);
|
|
369
|
+
const state = orientSettingsState(p);
|
|
370
|
+
if (!state.ok) {
|
|
371
|
+
if (fs.existsSync(p)) return { code: 'malformed', file: name, target: p, why: state.why, command };
|
|
372
|
+
continue;
|
|
373
|
+
}
|
|
374
|
+
const existing = findOrientHook(state.settings);
|
|
375
|
+
if (!existing) continue;
|
|
376
|
+
const found = orientHookValidator(existing);
|
|
377
|
+
if (found === null) {
|
|
378
|
+
return { code: 'unverifiable', file: name, target: p, existing, command };
|
|
379
|
+
}
|
|
380
|
+
const resolves = fs.existsSync(path.isAbsolute(found) ? found : path.join(cwd, found));
|
|
381
|
+
return resolves
|
|
382
|
+
? { code: 'already', file: name, target: p, command: existing }
|
|
383
|
+
: { code: 'broken', file: name, target: p, existing, command };
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// A machine-specific command must not reach the shared, committed file.
|
|
387
|
+
const file = vendored ? 'settings.json' : 'settings.local.json';
|
|
388
|
+
const target = path.join(cwd, '.claude', file);
|
|
389
|
+
const state = orientSettingsState(target);
|
|
390
|
+
if (!state.ok) return { code: 'malformed', file, target, why: state.why, command };
|
|
391
|
+
const settings = state.settings;
|
|
392
|
+
|
|
393
|
+
if (!settings.hooks) settings.hooks = {};
|
|
394
|
+
if (!Array.isArray(settings.hooks.SessionStart)) settings.hooks.SessionStart = [];
|
|
395
|
+
settings.hooks.SessionStart.push({
|
|
396
|
+
hooks: [{
|
|
397
|
+
type: 'command',
|
|
398
|
+
command,
|
|
399
|
+
timeout: ORIENT_HOOK_TIMEOUT,
|
|
400
|
+
statusMessage: 'Loading ' + docsLabel + ' orientation...',
|
|
401
|
+
}],
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
// The installer has already written seed files by this point; an unwritable
|
|
405
|
+
// settings file must not take the whole run down with a stack trace.
|
|
406
|
+
try {
|
|
407
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
408
|
+
fs.writeFileSync(target, JSON.stringify(settings, null, 2) + String.fromCharCode(10), 'utf8');
|
|
409
|
+
} catch (e) {
|
|
410
|
+
return { code: 'write-failed', file, target, command, error: e.message };
|
|
411
|
+
}
|
|
412
|
+
return { code: 'wired', file, target, command, local: !vendored };
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
module.exports = {
|
|
416
|
+
PACKAGE_ROOT,
|
|
417
|
+
SKILL_SOURCE,
|
|
418
|
+
INSTALLED_SKILL_NAME,
|
|
419
|
+
TEMPLATES_PATH,
|
|
420
|
+
CLIENTS,
|
|
421
|
+
commandExists,
|
|
422
|
+
clientDetected,
|
|
423
|
+
skillTarget,
|
|
424
|
+
wireOrientHook,
|
|
425
|
+
orientHookCommand,
|
|
426
|
+
copyRecursive,
|
|
427
|
+
loadTemplates,
|
|
428
|
+
templateFor,
|
|
429
|
+
};
|
|
430
|
+
|
|
431
|
+
// Lazy: reading routing.md is deferred to the consumer that actually asks.
|
|
432
|
+
Object.defineProperty(module.exports, 'SELF_LENS', { enumerable: true, get: selfLens });
|
|
433
|
+
Object.defineProperty(module.exports, 'SIBLING_LENSES', { enumerable: true, get: siblingLenses });
|
|
434
|
+
|