@antoneeo/kb-agentic-skill 1.4.8 → 1.5.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 +54 -0
- package/gemini-extension.json +1 -1
- package/package.json +1 -1
- package/scripts/init.js +123 -6
- package/scripts/lib.js +409 -202
- package/skills/kb-agentic-skill/ENFORCEMENT.md +33 -2
- package/skills/kb-agentic-skill/SKILL.md +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,60 @@
|
|
|
2
2
|
|
|
3
3
|
Every significant change to this skill is recorded here.
|
|
4
4
|
|
|
5
|
+
## [1.5.0] - 2026-08-25
|
|
6
|
+
|
|
7
|
+
F-036 — the orientation hook installs itself.
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
- **`ENFORCEMENT.md` 4 said "wire it on every project" and nothing ever did.** The
|
|
11
|
+
SessionStart orientation hook was real, tested and fail-open, but installing it was a
|
|
12
|
+
manual step, so it was skipped — and a session that never enters Phase 1 explicitly
|
|
13
|
+
then never meets the guide router at all. Field result: an agent worked a governed
|
|
14
|
+
project without invoking the process. `init` now wires the hook itself.
|
|
15
|
+
- **The invariant that looked like it guarded this guarded only prose** — it asserted
|
|
16
|
+
the documentation section exists (`assertIn("## 4. SessionStart hook", t)`) and that
|
|
17
|
+
`orient` runs. It gains a companion asserting the shipped installer actually CALLS the
|
|
18
|
+
writer. Mutation-tested: the first draft asserted the bare symbol and passed on a file
|
|
19
|
+
that imports the writer without invoking it, which is exactly the disabled-installer
|
|
20
|
+
case it exists to catch.
|
|
21
|
+
|
|
22
|
+
### Fixed (also)
|
|
23
|
+
- **ENFORCEMENT 4/2's own worked hook examples named a skill directory that two of
|
|
24
|
+
the three lenses do not install.** kb and mkt both printed
|
|
25
|
+
`.claude/skills/agentic-sdlc/scripts/...`; kb installs under `kb-agentic` and mkt
|
|
26
|
+
under `mkt-agentic-sdlc`, so anyone who copied the snippet got a hook that runs,
|
|
27
|
+
prints `can't open file`, and emits nothing — the wired-and-dead defect this release
|
|
28
|
+
detects, sitting in the instructions that produce it. Four paths corrected, and a new
|
|
29
|
+
invariant derives the expected directory and validator from what the distribution
|
|
30
|
+
actually ships and fails if an example names anything else. Mutation-tested.
|
|
31
|
+
|
|
32
|
+
### Added
|
|
33
|
+
- **A portability rule the doctrine did not have.** The hook command names a validator,
|
|
34
|
+
and where that validator lives decides which settings file may carry it: a repo that
|
|
35
|
+
vendors the validator gets a repo-relative command in the shared, committed
|
|
36
|
+
`.claude/settings.json`; a normal project gets an absolute path, which goes to the
|
|
37
|
+
git-ignored `.claude/settings.local.json` instead — committing it would hand every
|
|
38
|
+
teammate a hook naming a directory they do not have. `init` picks the file and adds
|
|
39
|
+
the local one to `.gitignore`. ENFORCEMENT 4's own worked example showed an absolute
|
|
40
|
+
path and did not mention the distinction; it now does.
|
|
41
|
+
- **A wired-but-dead hook is detected and reported instead of counted as done.** Found
|
|
42
|
+
in the field: a repository whose hook named a sibling lens's path, so it ran every
|
|
43
|
+
session, printed `can't open file`, and emitted nothing. A bare "is a hook present?"
|
|
44
|
+
check answers "already wired" to that and makes the silence permanent, so the existing
|
|
45
|
+
command's validator path is checked on disk. Never rewritten — it may be hand-tuned —
|
|
46
|
+
but never passed off as working either.
|
|
47
|
+
|
|
48
|
+
### Notes
|
|
49
|
+
- `init` declines rather than guessing: no Python, skill not installed, a settings file
|
|
50
|
+
that is not valid JSON (never rewritten — a merge would discard what is in it), or a
|
|
51
|
+
skill path containing a double quote. Each case prints the snippet to paste.
|
|
52
|
+
- Only Claude Code's hook shape is wired. Codex and Gemini keep the manual snippet: this
|
|
53
|
+
repository has no fixture pinning their schema, and writing a hook file in a shape
|
|
54
|
+
nobody has verified is how the wired-but-dead defect above was born.
|
|
55
|
+
- A blocking `PreToolUse` gate would be real enforcement rather than a nudge, and it is
|
|
56
|
+
refused by the Vision's no-ceremony-ratchet Non-Goal. Recorded in the analysis so the
|
|
57
|
+
next person to have the idea finds the ruling instead of re-deriving it.
|
|
58
|
+
|
|
5
59
|
## [1.4.8] - 2026-08-25
|
|
6
60
|
|
|
7
61
|
F-035 — second field report from the F-029 practitioner. Three defects in the corpus
|
package/gemini-extension.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kb-agentic-skill",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.0",
|
|
4
4
|
"description": "Knowledge-Base & Document-First protocol with risk triage, Vision governance, signal distillation and optional devPNT integration.",
|
|
5
5
|
"author": "Antonio Pinto (https://github.com/Antoneeo)"
|
|
6
6
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@antoneeo/kb-agentic-skill",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.0",
|
|
4
4
|
"description": "Knowledge-Base & Document-First protocol for Claude Code, Gemini CLI, Google Antigravity and Codex with risk triage, Vision governance, signal distillation and optional devPNT integration.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"claude-code",
|
package/scripts/init.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
const fs = require('fs');
|
|
4
4
|
const path = require('path');
|
|
5
5
|
const { execSync } = require('child_process');
|
|
6
|
-
const { SKILL_SOURCE, INSTALLED_SKILL_NAME, SELF_LENS, SIBLING_LENSES, CLIENTS, clientDetected, skillTarget, loadTemplates, templateFor } = require('./lib');
|
|
6
|
+
const { SKILL_SOURCE, INSTALLED_SKILL_NAME, SELF_LENS, SIBLING_LENSES, CLIENTS, clientDetected, skillTarget, wireOrientHook, loadTemplates, templateFor } = require('./lib');
|
|
7
7
|
|
|
8
8
|
const cwd = process.cwd();
|
|
9
9
|
|
|
@@ -149,8 +149,22 @@ const protocolFiles = {
|
|
|
149
149
|
// written aside and merged by hand.
|
|
150
150
|
let protocolPreexisting = false;
|
|
151
151
|
|
|
152
|
-
|
|
153
|
-
|
|
152
|
+
// Probed ONCE and reused by section 6c: re-filtering CLIENTS there would
|
|
153
|
+
// run every client's detection a second time.
|
|
154
|
+
const detectedClients = CLIENTS.filter(clientDetected);
|
|
155
|
+
const PYTHON_CANDIDATES = ['python', 'python3', 'py'];
|
|
156
|
+
const detectedPython = (() => {
|
|
157
|
+
for (const py of PYTHON_CANDIDATES) {
|
|
158
|
+
try {
|
|
159
|
+
execSync(`${py} --version`, { stdio: 'ignore' });
|
|
160
|
+
return py;
|
|
161
|
+
} catch (e) { /* try the next interpreter */ }
|
|
162
|
+
}
|
|
163
|
+
return null;
|
|
164
|
+
})();
|
|
165
|
+
|
|
166
|
+
for (const client of detectedClients) {
|
|
167
|
+
{
|
|
154
168
|
console.log(`✅ ${client.label} detected.`);
|
|
155
169
|
const created = writeIfNotExists(protocolFiles[client.key], protocolContent, `${client.label} protocol pointer`);
|
|
156
170
|
if (!created) protocolPreexisting = true;
|
|
@@ -214,15 +228,118 @@ Delete it once the merge is done.
|
|
|
214
228
|
}
|
|
215
229
|
}
|
|
216
230
|
|
|
231
|
+
|
|
232
|
+
// 6c. SessionStart orientation hook (F-036).
|
|
233
|
+
// ENFORCEMENT.md §4 asks for this on every project with a docs root and Python.
|
|
234
|
+
// It was a manual step until now, so it was skipped -- and a session that never
|
|
235
|
+
// enters Phase 1 explicitly then never meets the guide router at all.
|
|
236
|
+
|
|
237
|
+
// devPNT projects want `--hybrid`: without it the hook reports audit-plan
|
|
238
|
+
// staleness that devPNT/KL owns, and noise at every session start is how a
|
|
239
|
+
// session-start message stops being read.
|
|
240
|
+
const hybridProject = fs.existsSync(path.join(cwd, '.devpnt'));
|
|
241
|
+
|
|
242
|
+
// Only touch .gitignore when the wiring landed in the machine-specific file:
|
|
243
|
+
// if that file is not ignored, the absolute path reaches teammates anyway and
|
|
244
|
+
// the whole reason for choosing it is undone. Append-only and marker-guarded,
|
|
245
|
+
// exactly like gitattributes() above. Wrapped, because the installer has already
|
|
246
|
+
// written the seed files and must not die here.
|
|
247
|
+
const ignoreLocalSettings = () => {
|
|
248
|
+
const marker = '.claude/settings.local.json';
|
|
249
|
+
if (!fs.existsSync(path.join(cwd, '.git'))) return;
|
|
250
|
+
const filePath = path.join(cwd, '.gitignore');
|
|
251
|
+
try {
|
|
252
|
+
const current = fs.existsSync(filePath) ? fs.readFileSync(filePath, 'utf8') : '';
|
|
253
|
+
// Line-wise, not substring: a `!.claude/settings.local.json` un-ignore would
|
|
254
|
+
// satisfy `includes()` and leave the machine-specific path committed.
|
|
255
|
+
if (current.split(/\r?\n/).some((l) => l.trim() === marker)) return;
|
|
256
|
+
fs.writeFileSync(filePath, current + [
|
|
257
|
+
'',
|
|
258
|
+
'# agentic-sdlc: the orientation hook here names a path on THIS machine.',
|
|
259
|
+
marker,
|
|
260
|
+
'',
|
|
261
|
+
].join(EOL), 'utf8');
|
|
262
|
+
console.log(`OK .gitignore: ${marker} ignored (it names a machine-specific path).`);
|
|
263
|
+
console.log(' If it is already tracked, run: git rm --cached ' + marker);
|
|
264
|
+
} catch (e) {
|
|
265
|
+
console.log(`⚠️ Could not update .gitignore (${e.message}). Add "${marker}" by hand.`);
|
|
266
|
+
}
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
const claudeClient = detectedClients.find((c) => c.key === 'claude');
|
|
270
|
+
const otherClients = detectedClients.filter((c) => c.key !== 'claude');
|
|
271
|
+
if (!claudeClient) {
|
|
272
|
+
console.log('ℹ️ Claude Code not detected: SessionStart orientation hook not wired.');
|
|
273
|
+
} else {
|
|
274
|
+
const r = wireOrientHook({
|
|
275
|
+
cwd, client: claudeClient, python: detectedPython,
|
|
276
|
+
hybrid: hybridProject, docsLabel: 'ai_docs',
|
|
277
|
+
});
|
|
278
|
+
switch (r.code) {
|
|
279
|
+
case 'wired':
|
|
280
|
+
console.log(`🪝 Wired the SessionStart orientation hook in .claude/${r.file}.`);
|
|
281
|
+
if (r.local) {
|
|
282
|
+
console.log(' It names a path on THIS machine, so it went to the git-ignored');
|
|
283
|
+
console.log(' file: each teammate runs init once to get their own.');
|
|
284
|
+
ignoreLocalSettings();
|
|
285
|
+
}
|
|
286
|
+
break;
|
|
287
|
+
case 'already':
|
|
288
|
+
console.log(`⏭️ SessionStart orientation hook already wired (.claude/${r.file}).`);
|
|
289
|
+
if (r.file === 'settings.local.json') ignoreLocalSettings();
|
|
290
|
+
break;
|
|
291
|
+
case 'broken':
|
|
292
|
+
console.log(`⚠️ The SessionStart orientation hook in .claude/${r.file} is BROKEN —`);
|
|
293
|
+
console.log(' its validator does not resolve, so it has been emitting nothing:');
|
|
294
|
+
console.log(` ${r.existing}`);
|
|
295
|
+
console.log(' Not overwritten (it may be hand-tuned). Correct it to:');
|
|
296
|
+
console.log(` ${r.command}`);
|
|
297
|
+
break;
|
|
298
|
+
case 'unverifiable':
|
|
299
|
+
console.log(`ℹ️ A SessionStart orientation hook exists in .claude/${r.file} but names`);
|
|
300
|
+
console.log(' no validator this installer recognises, so it was left alone:');
|
|
301
|
+
console.log(` ${r.existing}`);
|
|
302
|
+
break;
|
|
303
|
+
case 'malformed':
|
|
304
|
+
console.log(`⚠️ .claude/${r.file} has a shape this installer will not rewrite`);
|
|
305
|
+
console.log(` (${r.why}): left untouched. Add the hook by hand — command:`);
|
|
306
|
+
console.log(` ${JSON.stringify(r.command)}`);
|
|
307
|
+
break;
|
|
308
|
+
case 'write-failed':
|
|
309
|
+
console.log(`⚠️ Could not write .claude/${r.file} (${r.error}). Add by hand:`);
|
|
310
|
+
console.log(` ${JSON.stringify(r.command)}`);
|
|
311
|
+
break;
|
|
312
|
+
case 'no-python':
|
|
313
|
+
console.log('ℹ️ Python not found: SessionStart orientation hook not wired.');
|
|
314
|
+
break;
|
|
315
|
+
case 'no-validator':
|
|
316
|
+
console.log('ℹ️ Skill not installed yet: orientation hook not wired. Install it');
|
|
317
|
+
console.log(' (below), then re-run init.');
|
|
318
|
+
break;
|
|
319
|
+
case 'unsafe-path':
|
|
320
|
+
console.log('⚠️ The validator path contains a character that cannot be placed in a');
|
|
321
|
+
console.log(` hook command safely: ${r.validator}`);
|
|
322
|
+
console.log(' Refusing to build one. Wire it by hand (ENFORCEMENT.md §4).');
|
|
323
|
+
break;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
for (const c of otherClients) {
|
|
327
|
+
// Said out loud rather than skipped in silence: ENFORCEMENT.md §4's manual
|
|
328
|
+
// snippet is these clients' only route, and a silent skip is what let the
|
|
329
|
+
// "documented default nobody installs" defect live in the first place.
|
|
330
|
+
console.log(`ℹ️ ${c.label}: hook not wired (only Claude Code's shape is verified) —`);
|
|
331
|
+
console.log(' wire it by hand from ENFORCEMENT.md §4 if that client supports it.');
|
|
332
|
+
}
|
|
333
|
+
|
|
217
334
|
// 7. Generate ai_docs/INDEX.md
|
|
218
335
|
const validator = path.join(SKILL_SOURCE, 'scripts', 'sdlc_check.py');
|
|
219
336
|
let indexed = false;
|
|
220
|
-
|
|
337
|
+
// detectedPython is probed once, above: one answer for the hook and for this.
|
|
338
|
+
if (detectedPython) {
|
|
221
339
|
try {
|
|
222
|
-
execSync(`${
|
|
340
|
+
execSync(`${detectedPython} "${validator}" index --root "${cwd}"`, { stdio: 'ignore' });
|
|
223
341
|
console.log('📇 Generated ai_docs/INDEX.md (document manifest).');
|
|
224
342
|
indexed = true;
|
|
225
|
-
break;
|
|
226
343
|
} catch (e) { /* try next */ }
|
|
227
344
|
}
|
|
228
345
|
if (!indexed) {
|
package/scripts/lib.js
CHANGED
|
@@ -1,202 +1,409 @@
|
|
|
1
|
-
// Shared helpers for the KB Agentic npm scripts (init / postinstall / preuninstall).
|
|
2
|
-
// Single source for client detection and skill-target paths.
|
|
3
|
-
|
|
4
|
-
const fs = require('fs');
|
|
5
|
-
const path = require('path');
|
|
6
|
-
const os = require('os');
|
|
7
|
-
const { execSync } = require('child_process');
|
|
8
|
-
|
|
9
|
-
const PACKAGE_ROOT = path.resolve(__dirname, '..');
|
|
10
|
-
const SKILL_SOURCE = path.join(PACKAGE_ROOT, 'skills', 'kb-agentic-skill');
|
|
11
|
-
const TEMPLATES_PATH = path.join(SKILL_SOURCE, 'templates.md');
|
|
12
|
-
// The directory name each client loads the skill from. Derived from the manifest,
|
|
13
|
-
// never hard-coded by a consumer: three distributions share these scripts, and a
|
|
14
|
-
// literal here is how a copy-fork starts installing under its sibling's name.
|
|
15
|
-
const INSTALLED_SKILL_NAME = (() => {
|
|
16
|
-
const m = fs.readFileSync(path.join(SKILL_SOURCE, 'SKILL.md'), 'utf8').match(/^name:\s*(\S+)/m);
|
|
17
|
-
if (!m) throw new Error(`SKILL.md carries no 'name:' field: ${SKILL_SOURCE}`);
|
|
18
|
-
return m[1];
|
|
19
|
-
})();
|
|
20
|
-
|
|
21
|
-
// The family's lens table, read from the shared `routing.md` rather than restated
|
|
22
|
-
// here. Same reason as INSTALLED_SKILL_NAME above, and the same failure it prevents:
|
|
23
|
-
// BOTH the row for this lens and the rows for its siblings used to be literals copied
|
|
24
|
-
// between distributions, so kb and mkt wrote a multi-lens note announcing themselves
|
|
25
|
-
// as the code lens. routing.md is one of the byte-identical shared files, so this
|
|
26
|
-
// lookup cannot drift between distributions.
|
|
27
|
-
// Lazy on purpose: preuninstall.js needs INSTALLED_SKILL_NAME and nothing else, and
|
|
28
|
-
// must keep working on an installation damaged badly enough to have lost routing.md.
|
|
29
|
-
function lensTable() {
|
|
30
|
-
const routing = path.join(SKILL_SOURCE, 'routing.md');
|
|
31
|
-
if (!fs.existsSync(routing)) throw new Error(`routing.md missing from the skill: ${SKILL_SOURCE}`);
|
|
32
|
-
// Parsed as a table, not matched with a regex: a mis-escaped pattern matches the
|
|
33
|
-
// empty string and yields undefined instead of throwing, which is how this lookup
|
|
34
|
-
// failed the first time it was written.
|
|
35
|
-
const table = new Map();
|
|
36
|
-
for (const line of fs.readFileSync(routing, 'utf8').split(/\r?\n/)) {
|
|
37
|
-
const cells = line.split('|').map((cell) => cell.trim());
|
|
38
|
-
if (cells.length < 4) continue;
|
|
39
|
-
const [, lens, skill] = cells;
|
|
40
|
-
if (!/^[a-z]+$/.test(lens) || !/^`[a-z0-9-]+`$/.test(skill)) continue;
|
|
41
|
-
const name = skill.slice(1, -1);
|
|
42
|
-
// A duplicate row would otherwise be won silently by whichever came first, and a
|
|
43
|
-
// wrong routing.md propagates byte-identically to every distribution.
|
|
44
|
-
if (table.has(name)) throw new Error(`routing.md lists '${name}' more than once`);
|
|
45
|
-
table.set(name, lens);
|
|
46
|
-
}
|
|
47
|
-
if (!table.size) throw new Error(`routing.md carries no lens table: ${routing}`);
|
|
48
|
-
return table;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
function selfLens() {
|
|
52
|
-
const lens = lensTable().get(INSTALLED_SKILL_NAME);
|
|
53
|
-
if (!lens) throw new Error(`routing.md has no lens row for '${INSTALLED_SKILL_NAME}'`);
|
|
54
|
-
return lens;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
// Sibling lenses of the same family: one shared core, one docs tree, a different
|
|
58
|
-
// fidelity discipline each. Keyed by the installed skill directory name.
|
|
59
|
-
function siblingLenses() {
|
|
60
|
-
const table = lensTable();
|
|
61
|
-
selfLens(); // this lens must be in the table too
|
|
62
|
-
table.delete(INSTALLED_SKILL_NAME);
|
|
63
|
-
return Object.fromEntries(table);
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
// One entry per supported AI client. `home` may be overridden by an env var
|
|
67
|
-
// (Claude Desktop / portable installs); presence of the home dir counts as
|
|
68
|
-
// detection even when the CLI is not on PATH.
|
|
69
|
-
|
|
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 "kb-agentic".',
|
|
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 "$kb-agentic" or by asking for KB Agentic.',
|
|
94
|
-
},
|
|
95
|
-
{
|
|
96
|
-
key: 'antigravity',
|
|
97
|
-
label: 'Google Antigravity',
|
|
98
|
-
cmd: 'agy',
|
|
99
|
-
home: process.env.ANTIGRAVITY_HOME || path.join(os.homedir(), '.gemini'),
|
|
100
|
-
envVar: 'ANTIGRAVITY_HOME',
|
|
101
|
-
skillsSubdir: 'config/skills',
|
|
102
|
-
homeMarker: path.join(
|
|
103
|
-
process.env.ANTIGRAVITY_HOME || path.join(os.homedir(), '.gemini'),
|
|
104
|
-
'config',
|
|
105
|
-
'skills',
|
|
106
|
-
),
|
|
107
|
-
reload: 'Restart Antigravity, or run "agy skills reload", to load it. Invoke by asking for KB Agentic.',
|
|
108
|
-
},
|
|
109
|
-
];
|
|
110
|
-
|
|
111
|
-
function commandExists(cmd) {
|
|
112
|
-
try {
|
|
113
|
-
execSync(`${cmd} --version`, { stdio: 'ignore' });
|
|
114
|
-
return true;
|
|
115
|
-
} catch (e) {
|
|
116
|
-
return false;
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
function clientDetected(client) {
|
|
121
|
-
const homePathToCheck = client.homeMarker || client.home;
|
|
122
|
-
return commandExists(client.cmd)
|
|
123
|
-
|| Boolean(process.env[client.envVar])
|
|
124
|
-
|| fs.existsSync(homePathToCheck);
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
function skillTarget(client) {
|
|
128
|
-
const subdir = client.skillsSubdir ? client.skillsSubdir.split('/') : ['skills'];
|
|
129
|
-
return path.join(client.home, ...subdir, INSTALLED_SKILL_NAME);
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
function copyRecursive(src, dest) {
|
|
133
|
-
if (typeof fs.cpSync === 'function') {
|
|
134
|
-
fs.cpSync(src, dest, { recursive: true, force: true });
|
|
135
|
-
return;
|
|
136
|
-
}
|
|
137
|
-
if (!fs.existsSync(dest)) fs.mkdirSync(dest, { recursive: true });
|
|
138
|
-
for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
|
|
139
|
-
const s = path.join(src, entry.name);
|
|
140
|
-
const d = path.join(dest, entry.name);
|
|
141
|
-
if (entry.isDirectory()) copyRecursive(s, d);
|
|
142
|
-
else fs.copyFileSync(s, d);
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
function loadTemplates() {
|
|
147
|
-
const text = fs.readFileSync(TEMPLATES_PATH, 'utf8');
|
|
148
|
-
const lines = text.split(/\r?\n/);
|
|
149
|
-
const sections = {};
|
|
150
|
-
let heading = null;
|
|
151
|
-
let block = null;
|
|
152
|
-
for (const line of lines) {
|
|
153
|
-
const h = line.match(/^##\s+(.*)$/);
|
|
154
|
-
if (h && block === null) {
|
|
155
|
-
heading = h[1].trim();
|
|
156
|
-
sections[heading] = sections[heading] || [];
|
|
157
|
-
continue;
|
|
158
|
-
}
|
|
159
|
-
if (/^```/.test(line)) {
|
|
160
|
-
if (block === null) {
|
|
161
|
-
block = [];
|
|
162
|
-
} else {
|
|
163
|
-
if (heading) sections[heading].push(block.join('\n') + '\n');
|
|
164
|
-
block = null;
|
|
165
|
-
}
|
|
166
|
-
continue;
|
|
167
|
-
}
|
|
168
|
-
if (block !== null) block.push(line);
|
|
169
|
-
}
|
|
170
|
-
return sections;
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
function templateFor(sections, needle, index = 0) {
|
|
174
|
-
const heading = Object.keys(sections).find((h) => h.includes(needle));
|
|
175
|
-
const blocks = heading ? sections[heading] : undefined;
|
|
176
|
-
if (!blocks || !blocks[index]) {
|
|
177
|
-
throw new Error(
|
|
178
|
-
`Template section containing "${needle}" (block ${index}) not found in ${TEMPLATES_PATH}. ` +
|
|
179
|
-
'The package is corrupted or templates.md was restructured: fix templates.md, do not improvise content.'
|
|
180
|
-
);
|
|
181
|
-
}
|
|
182
|
-
return blocks[index];
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
1
|
+
// Shared helpers for the KB Agentic npm scripts (init / postinstall / preuninstall).
|
|
2
|
+
// Single source for client detection and skill-target paths.
|
|
3
|
+
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const os = require('os');
|
|
7
|
+
const { execSync } = require('child_process');
|
|
8
|
+
|
|
9
|
+
const PACKAGE_ROOT = path.resolve(__dirname, '..');
|
|
10
|
+
const SKILL_SOURCE = path.join(PACKAGE_ROOT, 'skills', 'kb-agentic-skill');
|
|
11
|
+
const TEMPLATES_PATH = path.join(SKILL_SOURCE, 'templates.md');
|
|
12
|
+
// The directory name each client loads the skill from. Derived from the manifest,
|
|
13
|
+
// never hard-coded by a consumer: three distributions share these scripts, and a
|
|
14
|
+
// literal here is how a copy-fork starts installing under its sibling's name.
|
|
15
|
+
const INSTALLED_SKILL_NAME = (() => {
|
|
16
|
+
const m = fs.readFileSync(path.join(SKILL_SOURCE, 'SKILL.md'), 'utf8').match(/^name:\s*(\S+)/m);
|
|
17
|
+
if (!m) throw new Error(`SKILL.md carries no 'name:' field: ${SKILL_SOURCE}`);
|
|
18
|
+
return m[1];
|
|
19
|
+
})();
|
|
20
|
+
|
|
21
|
+
// The family's lens table, read from the shared `routing.md` rather than restated
|
|
22
|
+
// here. Same reason as INSTALLED_SKILL_NAME above, and the same failure it prevents:
|
|
23
|
+
// BOTH the row for this lens and the rows for its siblings used to be literals copied
|
|
24
|
+
// between distributions, so kb and mkt wrote a multi-lens note announcing themselves
|
|
25
|
+
// as the code lens. routing.md is one of the byte-identical shared files, so this
|
|
26
|
+
// lookup cannot drift between distributions.
|
|
27
|
+
// Lazy on purpose: preuninstall.js needs INSTALLED_SKILL_NAME and nothing else, and
|
|
28
|
+
// must keep working on an installation damaged badly enough to have lost routing.md.
|
|
29
|
+
function lensTable() {
|
|
30
|
+
const routing = path.join(SKILL_SOURCE, 'routing.md');
|
|
31
|
+
if (!fs.existsSync(routing)) throw new Error(`routing.md missing from the skill: ${SKILL_SOURCE}`);
|
|
32
|
+
// Parsed as a table, not matched with a regex: a mis-escaped pattern matches the
|
|
33
|
+
// empty string and yields undefined instead of throwing, which is how this lookup
|
|
34
|
+
// failed the first time it was written.
|
|
35
|
+
const table = new Map();
|
|
36
|
+
for (const line of fs.readFileSync(routing, 'utf8').split(/\r?\n/)) {
|
|
37
|
+
const cells = line.split('|').map((cell) => cell.trim());
|
|
38
|
+
if (cells.length < 4) continue;
|
|
39
|
+
const [, lens, skill] = cells;
|
|
40
|
+
if (!/^[a-z]+$/.test(lens) || !/^`[a-z0-9-]+`$/.test(skill)) continue;
|
|
41
|
+
const name = skill.slice(1, -1);
|
|
42
|
+
// A duplicate row would otherwise be won silently by whichever came first, and a
|
|
43
|
+
// wrong routing.md propagates byte-identically to every distribution.
|
|
44
|
+
if (table.has(name)) throw new Error(`routing.md lists '${name}' more than once`);
|
|
45
|
+
table.set(name, lens);
|
|
46
|
+
}
|
|
47
|
+
if (!table.size) throw new Error(`routing.md carries no lens table: ${routing}`);
|
|
48
|
+
return table;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function selfLens() {
|
|
52
|
+
const lens = lensTable().get(INSTALLED_SKILL_NAME);
|
|
53
|
+
if (!lens) throw new Error(`routing.md has no lens row for '${INSTALLED_SKILL_NAME}'`);
|
|
54
|
+
return lens;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Sibling lenses of the same family: one shared core, one docs tree, a different
|
|
58
|
+
// fidelity discipline each. Keyed by the installed skill directory name.
|
|
59
|
+
function siblingLenses() {
|
|
60
|
+
const table = lensTable();
|
|
61
|
+
selfLens(); // this lens must be in the table too
|
|
62
|
+
table.delete(INSTALLED_SKILL_NAME);
|
|
63
|
+
return Object.fromEntries(table);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// One entry per supported AI client. `home` may be overridden by an env var
|
|
67
|
+
// (Claude Desktop / portable installs); presence of the home dir counts as
|
|
68
|
+
// detection even when the CLI is not on PATH.
|
|
69
|
+
|
|
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 "kb-agentic".',
|
|
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 "$kb-agentic" or by asking for KB Agentic.',
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
key: 'antigravity',
|
|
97
|
+
label: 'Google Antigravity',
|
|
98
|
+
cmd: 'agy',
|
|
99
|
+
home: process.env.ANTIGRAVITY_HOME || path.join(os.homedir(), '.gemini'),
|
|
100
|
+
envVar: 'ANTIGRAVITY_HOME',
|
|
101
|
+
skillsSubdir: 'config/skills',
|
|
102
|
+
homeMarker: path.join(
|
|
103
|
+
process.env.ANTIGRAVITY_HOME || path.join(os.homedir(), '.gemini'),
|
|
104
|
+
'config',
|
|
105
|
+
'skills',
|
|
106
|
+
),
|
|
107
|
+
reload: 'Restart Antigravity, or run "agy skills reload", to load it. Invoke by asking for KB Agentic.',
|
|
108
|
+
},
|
|
109
|
+
];
|
|
110
|
+
|
|
111
|
+
function commandExists(cmd) {
|
|
112
|
+
try {
|
|
113
|
+
execSync(`${cmd} --version`, { stdio: 'ignore' });
|
|
114
|
+
return true;
|
|
115
|
+
} catch (e) {
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function clientDetected(client) {
|
|
121
|
+
const homePathToCheck = client.homeMarker || client.home;
|
|
122
|
+
return commandExists(client.cmd)
|
|
123
|
+
|| Boolean(process.env[client.envVar])
|
|
124
|
+
|| fs.existsSync(homePathToCheck);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function skillTarget(client) {
|
|
128
|
+
const subdir = client.skillsSubdir ? client.skillsSubdir.split('/') : ['skills'];
|
|
129
|
+
return path.join(client.home, ...subdir, INSTALLED_SKILL_NAME);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function copyRecursive(src, dest) {
|
|
133
|
+
if (typeof fs.cpSync === 'function') {
|
|
134
|
+
fs.cpSync(src, dest, { recursive: true, force: true });
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
if (!fs.existsSync(dest)) fs.mkdirSync(dest, { recursive: true });
|
|
138
|
+
for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
|
|
139
|
+
const s = path.join(src, entry.name);
|
|
140
|
+
const d = path.join(dest, entry.name);
|
|
141
|
+
if (entry.isDirectory()) copyRecursive(s, d);
|
|
142
|
+
else fs.copyFileSync(s, d);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function loadTemplates() {
|
|
147
|
+
const text = fs.readFileSync(TEMPLATES_PATH, 'utf8');
|
|
148
|
+
const lines = text.split(/\r?\n/);
|
|
149
|
+
const sections = {};
|
|
150
|
+
let heading = null;
|
|
151
|
+
let block = null;
|
|
152
|
+
for (const line of lines) {
|
|
153
|
+
const h = line.match(/^##\s+(.*)$/);
|
|
154
|
+
if (h && block === null) {
|
|
155
|
+
heading = h[1].trim();
|
|
156
|
+
sections[heading] = sections[heading] || [];
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
if (/^```/.test(line)) {
|
|
160
|
+
if (block === null) {
|
|
161
|
+
block = [];
|
|
162
|
+
} else {
|
|
163
|
+
if (heading) sections[heading].push(block.join('\n') + '\n');
|
|
164
|
+
block = null;
|
|
165
|
+
}
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
if (block !== null) block.push(line);
|
|
169
|
+
}
|
|
170
|
+
return sections;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function templateFor(sections, needle, index = 0) {
|
|
174
|
+
const heading = Object.keys(sections).find((h) => h.includes(needle));
|
|
175
|
+
const blocks = heading ? sections[heading] : undefined;
|
|
176
|
+
if (!blocks || !blocks[index]) {
|
|
177
|
+
throw new Error(
|
|
178
|
+
`Template section containing "${needle}" (block ${index}) not found in ${TEMPLATES_PATH}. ` +
|
|
179
|
+
'The package is corrupted or templates.md was restructured: fix templates.md, do not improvise content.'
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
return blocks[index];
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// --- SessionStart orientation hook (F-036) ---------------------------------
|
|
186
|
+
// ENFORCEMENT.md §4 says to wire this on every project that has a docs root and
|
|
187
|
+
// a Python interpreter. Until F-036 nothing did: it was a manual step, so it was
|
|
188
|
+
// skipped, and the guide router reached no agent that did not already know to
|
|
189
|
+
// look for it. The hook only INJECTS orientation -- it cannot force a skill
|
|
190
|
+
// invocation, and the mechanism that could (a blocking PreToolUse gate) is
|
|
191
|
+
// refused by the Vision's no-ceremony-ratchet Non-Goal.
|
|
192
|
+
|
|
193
|
+
const ORIENT_HOOK_TIMEOUT = 10;
|
|
194
|
+
|
|
195
|
+
// The validator's filename is DERIVED, never written here: the marketing lens
|
|
196
|
+
// ships `mkt_check.py` where the others ship `sdlc_check.py`, and this block is
|
|
197
|
+
// copied verbatim into all three distributions. A literal would make two of the
|
|
198
|
+
// three look for an entry point that does not exist.
|
|
199
|
+
const ORIENT_ENTRY_POINTS = ['sdlc_check.py', 'mkt_check.py'];
|
|
200
|
+
const ORIENT_ENTRY_POINT = (() => {
|
|
201
|
+
for (const name of ORIENT_ENTRY_POINTS) {
|
|
202
|
+
if (fs.existsSync(path.join(SKILL_SOURCE, 'scripts', name))) return name;
|
|
203
|
+
}
|
|
204
|
+
return ORIENT_ENTRY_POINTS[0];
|
|
205
|
+
})();
|
|
206
|
+
|
|
207
|
+
// Both settings layers are ALWAYS inspected for an existing hook, whichever one
|
|
208
|
+
// we would write to. Scanning only the target file left two holes: a project
|
|
209
|
+
// that starts un-vendored and later vendors flips the target from the local file
|
|
210
|
+
// to the shared one, finds nothing there, and appends a second hook; and a dead
|
|
211
|
+
// hook in the layer we are not writing stays invisible.
|
|
212
|
+
const ORIENT_SETTINGS_FILES = ['settings.json', 'settings.local.json'];
|
|
213
|
+
|
|
214
|
+
// A vendored path is built from a directory name read out of the TARGET
|
|
215
|
+
// repository, and the result is executed by the client as a shell command. That
|
|
216
|
+
// is repo-controlled input reaching a command line, so each segment must match
|
|
217
|
+
// this exactly -- `$(...)`, backticks and newlines are not "unusual names", they
|
|
218
|
+
// are the payload. The absolute branch cannot reach this input (it is built from
|
|
219
|
+
// os.homedir() and the client roster) but is filtered too, below.
|
|
220
|
+
const ORIENT_SAFE_SEGMENT = /^[A-Za-z0-9._-]+$/;
|
|
221
|
+
// Refused anywhere in a command path. NOT backslash: a Windows path is full of
|
|
222
|
+
// them and the shape this project already commits uses them.
|
|
223
|
+
const ORIENT_UNSAFE_IN_PATH = /["$`\r\n]/;
|
|
224
|
+
|
|
225
|
+
// Where a repo may legitimately vendor the validator. ENFORCEMENT §2 tells users
|
|
226
|
+
// to copy it next to their CI config (`tools/`), and a skill-authoring repo
|
|
227
|
+
// carries it under `skills/<lens>/scripts/`. Both are checked; the lens matching
|
|
228
|
+
// this distribution wins, so a monorepo vendoring several lenses cannot wire a
|
|
229
|
+
// sibling's validator against this lens's docs root.
|
|
230
|
+
function vendoredValidator(cwd) {
|
|
231
|
+
const direct = [];
|
|
232
|
+
for (const dir of ['tools', 'scripts']) {
|
|
233
|
+
direct.push([dir, ORIENT_ENTRY_POINT].join('/'));
|
|
234
|
+
}
|
|
235
|
+
const skillDirs = (() => {
|
|
236
|
+
let entries;
|
|
237
|
+
try {
|
|
238
|
+
entries = fs.readdirSync(path.join(cwd, 'skills'), { withFileTypes: true });
|
|
239
|
+
} catch (e) {
|
|
240
|
+
return []; // no skills/ here, or it is not a directory
|
|
241
|
+
}
|
|
242
|
+
const names = entries.filter((e) => e.isDirectory()).map((e) => e.name)
|
|
243
|
+
.filter((n) => ORIENT_SAFE_SEGMENT.test(n));
|
|
244
|
+
// This lens first: readdir order must not decide which lens we orient.
|
|
245
|
+
names.sort((a, b) => (a === INSTALLED_SKILL_NAME ? -1 : 0)
|
|
246
|
+
- (b === INSTALLED_SKILL_NAME ? -1 : 0));
|
|
247
|
+
return names.map((n) => ['skills', n, 'scripts', ORIENT_ENTRY_POINT].join('/'));
|
|
248
|
+
})();
|
|
249
|
+
for (const rel of direct.concat(skillDirs)) {
|
|
250
|
+
if (fs.existsSync(path.join(cwd, rel))) return rel;
|
|
251
|
+
}
|
|
252
|
+
return null;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function orientHookCommand(python, validator, hybrid) {
|
|
256
|
+
return python + ' "' + validator + '" orient' + (hybrid ? ' --hybrid' : '');
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// The token in an EXISTING command that names a validator -- quoted or bare, and
|
|
260
|
+
// NOT simply "the first quoted thing". That naive rule failed both ways: a
|
|
261
|
+
// command that quotes the interpreter (`"C:\Py\python.exe" "...sdlc_check.py"`)
|
|
262
|
+
// returned the interpreter, which exists, so a DEAD hook reported as healthy;
|
|
263
|
+
// and a command with no quotes at all returned nothing, so a WORKING hook was
|
|
264
|
+
// reported broken. Returns null when no token names a validator: the caller must
|
|
265
|
+
// then say it cannot tell, never that the hook is broken.
|
|
266
|
+
function orientHookValidator(command) {
|
|
267
|
+
const cmd = String(command || '');
|
|
268
|
+
const re = /"([^"]*)"|(\S+)/g;
|
|
269
|
+
let m;
|
|
270
|
+
while ((m = re.exec(cmd)) !== null) {
|
|
271
|
+
const token = m[1] !== undefined ? m[1] : m[2];
|
|
272
|
+
if (ORIENT_ENTRY_POINTS.some((n) => token.endsWith(n))) return token;
|
|
273
|
+
}
|
|
274
|
+
return null;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Accepts both shapes seen in the wild: the documented groups
|
|
278
|
+
// (`SessionStart: [{ hooks: [...] }]`) and hook objects placed directly in the
|
|
279
|
+
// array. Anything else is reported by the caller rather than silently replaced.
|
|
280
|
+
function findOrientHook(settings) {
|
|
281
|
+
const groups = settings && settings.hooks && settings.hooks.SessionStart;
|
|
282
|
+
if (!Array.isArray(groups)) return null;
|
|
283
|
+
for (const group of groups) {
|
|
284
|
+
if (!group || typeof group !== 'object') continue;
|
|
285
|
+
const inner = Array.isArray(group.hooks) ? group.hooks : [group];
|
|
286
|
+
for (const h of inner) {
|
|
287
|
+
const cmd = h && typeof h.command === 'string' ? h.command : '';
|
|
288
|
+
if (ORIENT_ENTRY_POINTS.some((n) => cmd.includes(n)) && / orient(\s|$)/.test(cmd)) {
|
|
289
|
+
return cmd;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
return null;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// A settings object we can safely merge into: absent, or carrying the shapes we
|
|
297
|
+
// know. "I do not recognise this" must mean "write nothing", never "treat it as
|
|
298
|
+
// empty" -- the second is how a hand-written hook disappears.
|
|
299
|
+
function orientSettingsState(target) {
|
|
300
|
+
if (!fs.existsSync(target)) return { ok: true, settings: {} };
|
|
301
|
+
let settings;
|
|
302
|
+
try {
|
|
303
|
+
settings = JSON.parse(fs.readFileSync(target, 'utf8'));
|
|
304
|
+
} catch (e) {
|
|
305
|
+
return { ok: false, why: 'unreadable' };
|
|
306
|
+
}
|
|
307
|
+
if (!settings || typeof settings !== 'object' || Array.isArray(settings)) {
|
|
308
|
+
return { ok: false, why: 'not-an-object' };
|
|
309
|
+
}
|
|
310
|
+
const hooks = settings.hooks;
|
|
311
|
+
if (hooks !== undefined
|
|
312
|
+
&& (!hooks || typeof hooks !== 'object' || Array.isArray(hooks))) {
|
|
313
|
+
return { ok: false, why: 'unexpected-hooks' };
|
|
314
|
+
}
|
|
315
|
+
if (hooks && hooks.SessionStart !== undefined && !Array.isArray(hooks.SessionStart)) {
|
|
316
|
+
return { ok: false, why: 'unexpected-hooks' };
|
|
317
|
+
}
|
|
318
|
+
return { ok: true, settings };
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// Returns a RESULT CODE, never printed text: the caller owns the wording and the
|
|
322
|
+
// battery asserts on the code. Codes:
|
|
323
|
+
// wired | already | broken | unverifiable | malformed
|
|
324
|
+
// no-validator | no-python | unsafe-path | write-failed
|
|
325
|
+
function wireOrientHook(options) {
|
|
326
|
+
const cwd = options.cwd;
|
|
327
|
+
const client = options.client;
|
|
328
|
+
const python = options.python;
|
|
329
|
+
const hybrid = !!options.hybrid;
|
|
330
|
+
const docsLabel = options.docsLabel || 'project';
|
|
331
|
+
|
|
332
|
+
const vendored = vendoredValidator(cwd);
|
|
333
|
+
const absolute = path.join(skillTarget(client), 'scripts', ORIENT_ENTRY_POINT);
|
|
334
|
+
const validator = vendored || absolute;
|
|
335
|
+
if (!vendored && !fs.existsSync(absolute)) return { code: 'no-validator', validator };
|
|
336
|
+
if (!python) return { code: 'no-python', validator };
|
|
337
|
+
if (ORIENT_UNSAFE_IN_PATH.test(validator)) return { code: 'unsafe-path', validator };
|
|
338
|
+
|
|
339
|
+
const command = orientHookCommand(python, validator, hybrid);
|
|
340
|
+
|
|
341
|
+
// Inspect BOTH layers before deciding to write anything.
|
|
342
|
+
for (const name of ORIENT_SETTINGS_FILES) {
|
|
343
|
+
const p = path.join(cwd, '.claude', name);
|
|
344
|
+
const state = orientSettingsState(p);
|
|
345
|
+
if (!state.ok) {
|
|
346
|
+
if (fs.existsSync(p)) return { code: 'malformed', file: name, target: p, why: state.why, command };
|
|
347
|
+
continue;
|
|
348
|
+
}
|
|
349
|
+
const existing = findOrientHook(state.settings);
|
|
350
|
+
if (!existing) continue;
|
|
351
|
+
const found = orientHookValidator(existing);
|
|
352
|
+
if (found === null) {
|
|
353
|
+
return { code: 'unverifiable', file: name, target: p, existing, command };
|
|
354
|
+
}
|
|
355
|
+
const resolves = fs.existsSync(path.isAbsolute(found) ? found : path.join(cwd, found));
|
|
356
|
+
return resolves
|
|
357
|
+
? { code: 'already', file: name, target: p, command: existing }
|
|
358
|
+
: { code: 'broken', file: name, target: p, existing, command };
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// A machine-specific command must not reach the shared, committed file.
|
|
362
|
+
const file = vendored ? 'settings.json' : 'settings.local.json';
|
|
363
|
+
const target = path.join(cwd, '.claude', file);
|
|
364
|
+
const state = orientSettingsState(target);
|
|
365
|
+
if (!state.ok) return { code: 'malformed', file, target, why: state.why, command };
|
|
366
|
+
const settings = state.settings;
|
|
367
|
+
|
|
368
|
+
if (!settings.hooks) settings.hooks = {};
|
|
369
|
+
if (!Array.isArray(settings.hooks.SessionStart)) settings.hooks.SessionStart = [];
|
|
370
|
+
settings.hooks.SessionStart.push({
|
|
371
|
+
hooks: [{
|
|
372
|
+
type: 'command',
|
|
373
|
+
command,
|
|
374
|
+
timeout: ORIENT_HOOK_TIMEOUT,
|
|
375
|
+
statusMessage: 'Loading ' + docsLabel + ' orientation...',
|
|
376
|
+
}],
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
// The installer has already written seed files by this point; an unwritable
|
|
380
|
+
// settings file must not take the whole run down with a stack trace.
|
|
381
|
+
try {
|
|
382
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
383
|
+
fs.writeFileSync(target, JSON.stringify(settings, null, 2) + String.fromCharCode(10), 'utf8');
|
|
384
|
+
} catch (e) {
|
|
385
|
+
return { code: 'write-failed', file, target, command, error: e.message };
|
|
386
|
+
}
|
|
387
|
+
return { code: 'wired', file, target, command, local: !vendored };
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
module.exports = {
|
|
391
|
+
PACKAGE_ROOT,
|
|
392
|
+
SKILL_SOURCE,
|
|
393
|
+
INSTALLED_SKILL_NAME,
|
|
394
|
+
TEMPLATES_PATH,
|
|
395
|
+
CLIENTS,
|
|
396
|
+
commandExists,
|
|
397
|
+
clientDetected,
|
|
398
|
+
skillTarget,
|
|
399
|
+
wireOrientHook,
|
|
400
|
+
orientHookCommand,
|
|
401
|
+
copyRecursive,
|
|
402
|
+
loadTemplates,
|
|
403
|
+
templateFor,
|
|
404
|
+
};
|
|
405
|
+
|
|
406
|
+
// Lazy: reading routing.md is deferred to the consumer that actually asks.
|
|
407
|
+
Object.defineProperty(module.exports, 'SELF_LENS', { enumerable: true, get: selfLens });
|
|
408
|
+
Object.defineProperty(module.exports, 'SIBLING_LENSES', { enumerable: true, get: siblingLenses });
|
|
409
|
+
|
|
@@ -48,7 +48,7 @@ Blocks Edit/Write on protected paths when no `ANALYSIS_*.md` is `IN_PROGRESS`. I
|
|
|
48
48
|
"hooks": [
|
|
49
49
|
{
|
|
50
50
|
"type": "command",
|
|
51
|
-
"command": "python \"C:\\Users\\<user>\\.claude\\skills\\agentic
|
|
51
|
+
"command": "python \"C:\\Users\\<user>\\.claude\\skills\\kb-agentic\\scripts\\sdlc_check.py\" gate --hook --protected \"src/auth;src/crypto\""
|
|
52
52
|
}
|
|
53
53
|
]
|
|
54
54
|
}
|
|
@@ -72,6 +72,37 @@ Emits the `ai_docs/` orientation — reading guide (`README.md`), manifest (`IND
|
|
|
72
72
|
|
|
73
73
|
**Wire it on every project that has `ai_docs/` and a Python interpreter.** It was opt-in until v1.16.0 and the field result was the defect this level exists to prevent: the guide router stayed unread unless the user asked for it by hand, so guides were written and never consulted. Prompt-level placement (Rule Zero declares the router verdict; Phase 1 reads the router) carries the process on its own — this hook is the backstop that survives long contexts, compaction and a session that never enters Phase 1 explicitly. Skip it only where Python is unavailable, and know what you are trading.
|
|
74
74
|
|
|
75
|
+
**`init` wires it for you (F-036).** Running `init` on a project that has a docs root
|
|
76
|
+
and a Python interpreter now installs this hook itself — it was a manual step until
|
|
77
|
+
2026-08-25, and the field result was exactly the defect this level exists to prevent: an
|
|
78
|
+
agent worked a governed project without ever meeting the router. The snippet below is
|
|
79
|
+
the fallback for the cases init declines, and the list is exhaustive: **any client
|
|
80
|
+
other than Claude Code** (Codex and Gemini keep the manual snippet — no fixture in the
|
|
81
|
+
repository pins their hook schema, and writing one in an unverified shape is how a
|
|
82
|
+
wired-but-dead hook is born), no Python, the skill not installed yet, a settings file
|
|
83
|
+
that is not valid JSON or carries a `hooks`/`SessionStart` shape the writer does not
|
|
84
|
+
recognise (it never rewrites one it cannot read), a validator path holding a character
|
|
85
|
+
that cannot be placed in a command safely, or a settings file it cannot write. `init`
|
|
86
|
+
prints which case applied, per client — a silent skip is what let "documented default
|
|
87
|
+
that nobody installs" survive in the first place.
|
|
88
|
+
|
|
89
|
+
**Which settings file, and why it is not always the shared one.** The command names a
|
|
90
|
+
validator, and where that validator lives decides where the hook may be written:
|
|
91
|
+
|
|
92
|
+
| Case | Command | File |
|
|
93
|
+
|---|---|---|
|
|
94
|
+
| The repo vendors the validator (§2) | repo-relative | `.claude/settings.json` — portable, commit it |
|
|
95
|
+
| The validator is only in your skills directory (the normal case) | absolute | `.claude/settings.local.json` — machine-specific, git-ignored; each teammate runs `init` once |
|
|
96
|
+
|
|
97
|
+
Committing an absolute `C:\Users\<you>\...` path into the shared file hands every teammate a hook naming
|
|
98
|
+
a directory they do not have. `init` picks the file for you, and adds
|
|
99
|
+
`.claude/settings.local.json` to `.gitignore` when it uses the local one.
|
|
100
|
+
|
|
101
|
+
**A hook that is wired and DEAD is the worst of the three states**: it emits nothing at
|
|
102
|
+
every session AND it looks installed. `init` checks that an existing hook's validator
|
|
103
|
+
still resolves and reports it as BROKEN with the corrected command, rather than
|
|
104
|
+
reporting "already wired". It never rewrites the entry — it may be hand-tuned.
|
|
105
|
+
|
|
75
106
|
Wire it via each client's SessionStart mechanism — the same command everywhere (add `--hybrid` on devPNT/Hybrid projects):
|
|
76
107
|
|
|
77
108
|
Claude Code — in the project's `.claude/settings.json`:
|
|
@@ -84,7 +115,7 @@ Claude Code — in the project's `.claude/settings.json`:
|
|
|
84
115
|
"hooks": [
|
|
85
116
|
{
|
|
86
117
|
"type": "command",
|
|
87
|
-
"command": "python \"C:\\Users\\<user>\\.claude\\skills\\agentic
|
|
118
|
+
"command": "python \"C:\\Users\\<user>\\.claude\\skills\\kb-agentic\\scripts\\sdlc_check.py\" orient"
|
|
88
119
|
}
|
|
89
120
|
]
|
|
90
121
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: kb-agentic
|
|
3
|
-
version: 1.
|
|
3
|
+
version: 1.5.0
|
|
4
4
|
description: Knowledge-Base & Document-First protocol with risk-proportional triage, Vision as a guide, Signal Distillation, a complete Standalone mode and optional symbiosis with devPNT. Use for user documentation, knowledge extraction, SOPs, research notes, decision logs and knowledge management.
|
|
5
5
|
author: Antonio Pinto (https://github.com/Antoneeo)
|
|
6
6
|
copyright: (c) 2026 Antonio Pinto
|