clckernel 1.2.19 → 2.0.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.
Files changed (78) hide show
  1. package/.agents/skills/clckernel_cli/SKILL.md +23 -6
  2. package/.agents/skills/clckernel_cli/reference/audit.md +13 -3
  3. package/.agents/skills/clckernel_cli/reference/commit.md +1 -1
  4. package/.agents/skills/clckernel_cli/reference/doctor.md +5 -3
  5. package/.agents/skills/clckernel_cli/reference/feature.md +3 -3
  6. package/.agents/skills/clckernel_cli/reference/guard.md +48 -12
  7. package/.agents/skills/clckernel_cli/reference/phase.md +6 -3
  8. package/.agents/skills/clckernel_cli/reference/start.md +14 -4
  9. package/.agents/skills/clckernel_cli/reference/test.md +1 -1
  10. package/.agents/skills/clckernel_cli/reference/update.md +19 -19
  11. package/CHANGELOG.md +244 -0
  12. package/LICENSE +21 -0
  13. package/README.es.md +280 -51
  14. package/README.md +274 -50
  15. package/bin/cli.js +8 -0
  16. package/package.json +12 -2
  17. package/src/adapters/agent-os.js +161 -84
  18. package/src/adapters/astro.js +8 -6
  19. package/src/adapters/base.js +23 -38
  20. package/src/adapters/django.js +14 -13
  21. package/src/adapters/fastapi.js +16 -15
  22. package/src/adapters/go.js +11 -9
  23. package/src/adapters/laravel.js +12 -10
  24. package/src/adapters/nextjs.js +7 -5
  25. package/src/adapters/rails.js +13 -11
  26. package/src/adapters/rust.js +11 -9
  27. package/src/catalog.js +58 -49
  28. package/src/config.js +38 -240
  29. package/src/detector.js +243 -44
  30. package/src/doctor.js +18 -9
  31. package/src/generator.js +734 -302
  32. package/src/gitignore.js +60 -14
  33. package/src/index.js +257 -18
  34. package/src/io.js +409 -0
  35. package/src/known-hashes.json +309 -0
  36. package/src/onboarding.js +15 -0
  37. package/src/technologies/detector.js +1 -1
  38. package/src/technologies/guards/_shared.js +1 -1
  39. package/src/ui.js +233 -40
  40. package/src/updater.js +271 -38
  41. package/tools/_changes.js +279 -0
  42. package/tools/_changes.py +233 -0
  43. package/tools/_config.js +294 -0
  44. package/tools/_config.py +271 -0
  45. package/tools/_yaml.py +489 -0
  46. package/tools/audit.js +511 -111
  47. package/tools/audit.py +506 -128
  48. package/tools/celery_guard.py +13 -35
  49. package/tools/check_a11y.js +8 -28
  50. package/tools/check_api_contracts.js +13 -39
  51. package/tools/check_architecture.js +35 -44
  52. package/tools/check_architecture.py +172 -105
  53. package/tools/check_custom.js +108 -61
  54. package/tools/check_custom.py +108 -271
  55. package/tools/check_data_files.js +88 -0
  56. package/tools/check_db_efficiency.py +80 -65
  57. package/tools/check_hitl.js +112 -69
  58. package/tools/check_links.js +138 -0
  59. package/tools/check_migrations.py +143 -94
  60. package/tools/check_performance.js +35 -44
  61. package/tools/check_placeholders.js +104 -0
  62. package/tools/check_responsive.js +8 -28
  63. package/tools/check_scope.py +40 -31
  64. package/tools/check_seo.js +8 -28
  65. package/tools/check_skills.js +158 -0
  66. package/tools/check_storybook.js +8 -21
  67. package/tools/check_symlinks.js +40 -22
  68. package/tools/check_ui_reuse.js +17 -29
  69. package/tools/docker_guard.py +19 -41
  70. package/tools/postgres_guard.py +15 -36
  71. package/tools/redis_guard.py +13 -35
  72. package/tools/scan_secrets.js +134 -67
  73. package/tools/scan_secrets.py +163 -72
  74. package/tools/verify_tdd.js +867 -0
  75. package/tools/verify_tdd.py +407 -165
  76. package/tools/yaml.js +404 -132
  77. package/tools/check_domain_data.js +0 -100
  78. package/tools/check_test_calendar_sync.js +0 -111
package/src/generator.js CHANGED
@@ -1,106 +1,507 @@
1
1
  /**
2
- * CLC Forge — Universal Provisioning Engine
2
+ * CLC Kernel — Universal Provisioning Engine
3
3
  * Manifest-driven tool installation and dynamic pre-commit hook generation.
4
4
  */
5
5
 
6
6
  const fs = require('fs');
7
7
  const path = require('path');
8
- const { execSync } = require('child_process');
9
- const { resolveConfig } = require('./config.js');
8
+ const { spawnSync } = require('child_process');
9
+ const { readProjectConfig, globMatcher } = require('./config.js');
10
+ const { createIO, runWithIO, activeIO, currentIO, registeredPaths, MANAGED_REGISTRY } = require('./io');
11
+ const { FIRST_SESSION_SECTION } = require('./onboarding');
10
12
 
11
13
  /**
12
- * Detects whether a Node CLI required by a hook command is actually installed
13
- * in the target project. Resolving through node_modules/.bin is what `npx` does
14
- * first, so a missing entry there means `npx` would download it on the fly (or
15
- * fail inside a commit hook, where the network is not available).
14
+ * Shell condition that decides at commit time whether an external tool runs.
15
+ * Adapters declare it as `availableIf`; a tool without one falls back to
16
+ * `command -v <extCLI or first word of the command>`. A compound condition
17
+ * (&&, ||, ;, |) is grouped in braces so the redirect silences all of it —
18
+ * `{ ...; }` is valid in both POSIX sh (husky hooks) and bash.
19
+ * @param {object} tool - External tool descriptor
20
+ * @returns {string}
21
+ */
22
+ function availabilityCondition(tool) {
23
+ const cond = (tool.availableIf || `command -v ${tool.extCLI || tool.command.split(/\s+/)[0]}`).trim();
24
+ return /&&|\|\||;|\|/.test(cond) ? `{ ${cond}; }` : cond;
25
+ }
26
+
27
+ /**
28
+ * Hook line for an external tool: run it when available (a failure aborts
29
+ * the commit under `set -e`), otherwise say it was skipped instead of
30
+ * omitting it silently.
31
+ * @param {object} tool - External tool descriptor
32
+ * @returns {string}
33
+ */
34
+ function guardedCommand(tool, managed = []) {
35
+ return `if ${availabilityCondition(tool)} >/dev/null 2>&1; then ${withManagedExcludes(tool, managed)}; ` +
36
+ `else echo "clckernel: ${tool.name} not available, skipped"; fi`;
37
+ }
38
+
39
+ /**
40
+ * Appends the harness's own files to a whole-project linter's exclusions.
41
+ * A tool opts in with `excludeManaged: { flag, extensions }` (e.g. ruff's
42
+ * --extend-exclude for .py); without managed files of those extensions the
43
+ * command is unchanged.
44
+ * @param {object} tool - External tool descriptor
45
+ * @param {string[]} managed - Paths listed in the registry
46
+ * @returns {string}
47
+ */
48
+ function withManagedExcludes(tool, managed) {
49
+ if (!tool.excludeManaged) return tool.command;
50
+ const { flag, extensions } = tool.excludeManaged;
51
+ const files = managed.filter(f => extensions.some(ext => f.endsWith(ext)));
52
+ return files.length > 0 ? `${tool.command} ${flag} ${files.join(',')}` : tool.command;
53
+ }
54
+
55
+ /**
56
+ * Reads the registry written by the last install/update (either format); []
57
+ * when absent or malformed.
16
58
  * @param {string} targetDir - Target project root
17
- * @param {string} pkg - Package name to look for
18
- * @returns {boolean}
59
+ * @returns {string[]}
60
+ */
61
+ function readManagedRegistry(targetDir) {
62
+ return registeredPaths(targetDir);
63
+ }
64
+
65
+ /**
66
+ * The sorted paths of every file this run installs into tools/, the registry
67
+ * included: whole-project linters in the hook exclude them.
68
+ * @param {string[]} toolFiles - File names installed into tools/
69
+ * @returns {string[]}
70
+ */
71
+ function managedToolPaths(toolFiles) {
72
+ return [...new Set([...toolFiles.map(f => `tools/${f}`), MANAGED_REGISTRY])].sort();
73
+ }
74
+
75
+ /**
76
+ * Writes the registry (sdds/safe-update, decision 2): every kernel-owned file
77
+ * whose bytes are the kernel's after this run (tools/*, the hook, the skill),
78
+ * with its sha256. Protected files are left out: their content is the
79
+ * project's. The snapshot helpers (_changes.js/.py) drop these paths, and the
80
+ * registry itself, from the files a guard evaluates.
81
+ * @param {object} io - Active io session
82
+ * @param {string} targetDir - Target project root
83
+ */
84
+ function writeManagedRegistry(io, targetDir) {
85
+ const { version } = require('../package.json');
86
+ const registry = { kernelVersion: version, files: io.managedEntries() };
87
+ io.writeFile(path.join(targetDir, MANAGED_REGISTRY), JSON.stringify(registry, null, 2) + '\n');
88
+ }
89
+
90
+ /**
91
+ * Builds the single tool manifest used both to copy guards into tools/ and to
92
+ * generate the pre-commit hook, so every installed guard is also executed.
93
+ * Technology tools (Celery, Redis, PostgreSQL, Docker) are inserted right
94
+ * before check_custom, keeping the user-defined rules last; when the adapter
95
+ * has no check_custom they are appended.
96
+ * The adapter receives the project root, so an adapter may add guards whose
97
+ * structure is present.
98
+ * @param {object} adapter - Adapter with getTools(targetDir?)
99
+ * @param {Array} [techTools] - Tools for detected technologies
100
+ * @param {string} [targetDir] - Project root
101
+ * @returns {Array} Merged tool manifest
102
+ */
103
+ function buildToolManifest(adapter, techTools = [], targetDir) {
104
+ const frameworkTools = adapter.getTools(targetDir);
105
+ const checkCustomIdx = frameworkTools.findIndex(t => t.name === 'check_custom');
106
+ if (checkCustomIdx < 0) return [...frameworkTools, ...techTools];
107
+ return [
108
+ ...frameworkTools.slice(0, checkCustomIdx),
109
+ ...techTools,
110
+ ...frameworkTools.slice(checkCustomIdx),
111
+ ];
112
+ }
113
+
114
+ /** A guard is a manifest entry the kernel copies into tools/ (not an external CLI). */
115
+ function isGuard(tool) {
116
+ return tool.language !== 'external' && !!tool.path;
117
+ }
118
+
119
+ /**
120
+ * The manifest an install or update works with (sdds/update-no-additions,
121
+ * decision 1). A fresh install keeps every guard. An update keeps only the
122
+ * guards the project has: those in the hashed registry plus those present in
123
+ * tools/ (a protected or project-owned file with a kernel guard's name is
124
+ * still installed); an old path-list registry, or none, means present files
125
+ * only. `addGuards` (names, or 'all') opts more in. Every other guard is
126
+ * recorded as available. Non-guard entries (external CLIs) are kept.
127
+ * @param {object} io - Active io session
128
+ * @param {Array} tools - Tool manifest
129
+ * @param {string[]|'all'} [addGuards] - Guards to add on update
130
+ * @returns {Array} The manifest to install and run in the hook
19
131
  */
20
- function isCliInstalled(targetDir, pkg) {
21
- if (!pkg) return false;
22
- try {
23
- return fs.existsSync(path.join(targetDir, 'node_modules', '.bin', pkg));
24
- } catch (e) {
132
+ function selectGuards(io, tools, addGuards) {
133
+ if (io.mode !== 'update') return tools;
134
+ const addAll = addGuards === 'all';
135
+ const add = new Set(Array.isArray(addGuards) ? addGuards : []);
136
+ const registered = new Set(io.registry.format === 'hashed' ? io.registry.paths : []);
137
+ return tools.filter(tool => {
138
+ if (!isGuard(tool)) return true;
139
+ const rel = `tools/${tool.path}`;
140
+ if (addAll || add.has(tool.name) || registered.has(rel) || io.exists(rel)) return true;
141
+ io.noteAvailable(tool.name);
25
142
  return false;
143
+ });
144
+ }
145
+
146
+ /** Fallback files as guard descriptors (audit orchestrators excluded). */
147
+ function fallbackGuards(projectType) {
148
+ const files = projectType === 'unknown' ? GENERIC_FALLBACK_FILES : LEGACY_FALLBACK_FILES;
149
+ return files
150
+ .filter(file => !/^audit\./.test(file))
151
+ .map(file => ({ name: path.basename(file, path.extname(file)), language: path.extname(file).slice(1), path: file }));
152
+ }
153
+
154
+ /**
155
+ * Names of the guards the stack's manifest offers (what --add-guards
156
+ * accepts), in manifest order, once each.
157
+ * @param {string} targetDir - Project root
158
+ * @param {object} config - Detected config
159
+ * @returns {string[]}
160
+ */
161
+ function manifestGuardNames(targetDir, config) {
162
+ const cfg = config || {};
163
+ let guards;
164
+ if (Array.isArray(cfg.parts) && cfg.parts.length > 0) {
165
+ guards = buildPartsManifest(cfg.parts, cfg.techTools || [], targetDir).guards.filter(isGuard);
166
+ } else if (cfg.adapter) {
167
+ guards = buildToolManifest(cfg.adapter, cfg.techTools || [], targetDir).filter(isGuard);
168
+ } else {
169
+ guards = fallbackGuards(cfg.projectType);
26
170
  }
171
+ return [...new Set(guards.map(tool => tool.name))];
27
172
  }
28
173
 
29
174
  /**
30
- * Generates a pre-commit hook dynamically from the adapter's tool manifest.
31
- * Frontend projects use .husky; backend projects use .githooks.
32
- *
33
- * External CLIs (language === 'external', e.g. tsc, lint-staged) are only emitted
34
- * when the target project already depends on them: the generator does not add
35
- * dependencies, so referencing an uninstalled binary produces a hook that fails
36
- * on every commit.
37
- *
38
- * @param {object} adapter - Adapter with getTools() and projectType
175
+ * Guard block every hook starts with (sdds/unified-discovery): the hook runs
176
+ * no guard itself. It calls the audit orchestrators with --hook, and they
177
+ * discover and run every guard once — the kernel guards installed for the
178
+ * project and the project guards (top-level tools/ guards the kernel does not
179
+ * own, and tools/guards/*) — exactly the set `clckernel audit` runs. Each
180
+ * orchestrator runs when it is installed (a Python stack needs no node); one
181
+ * that is missing hands its guards to the other, and with neither the commit
182
+ * fails. A failing guard or a crashing orchestrator aborts the commit under
183
+ * `set -e`. POSIX sh-safe.
184
+ */
185
+ const ORCHESTRATOR_BLOCK = [
186
+ '# Guards: the audit orchestrators run every guard in tools/ once (kernel and project guards).',
187
+ '[ -f tools/audit.js ] || [ -f tools/audit.py ] || { echo "clckernel: no audit orchestrator in tools/" >&2; exit 1; }',
188
+ 'if [ -f tools/audit.js ]; then node tools/audit.js --hook; fi',
189
+ 'if [ -f tools/audit.py ]; then python3 tools/audit.py --hook; fi',
190
+ ];
191
+
192
+ /** Value core.hooksPath takes when the harness owns the repo's hooks. */
193
+ const GITHOOKS_DIR = '.githooks';
194
+
195
+ function runGit(cwd, args) {
196
+ const res = spawnSync('git', args, { cwd, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] });
197
+ return { ok: !res.error && res.status === 0, out: (res.stdout || '').trim() };
198
+ }
199
+
200
+ /**
201
+ * Points core.hooksPath at .githooks only when it is safe to do so:
202
+ * (a) targetDir is the root of its git repository, and (b) the current value
203
+ * is empty or already .githooks. Anything else (another hook manager such as
204
+ * Husky, a monorepo subdirectory, no repository at all) is left untouched and
205
+ * the manual command is printed instead.
39
206
  * @param {string} targetDir - Target project root
207
+ * @returns {boolean} true when core.hooksPath is (now) .githooks
40
208
  */
41
- function generateHookFromManifest(adapter, targetDir) {
42
- const tools = adapter.getTools();
43
- const isFront = adapter.projectType === 'frontend';
209
+ function configureHooksPath(targetDir) {
210
+ return configureHooksPathWithCommand(targetDir).active;
211
+ }
44
212
 
45
- const hookDir = isFront
46
- ? path.join(targetDir, '.husky')
47
- : path.join(targetDir, '.githooks');
48
- const hookFile = path.join(hookDir, 'pre-commit');
213
+ /**
214
+ * configureHooksPath, also returning the command that activates the hook
215
+ * when it was left inactive (sdds/onboarding: the Next steps repeat it).
216
+ * @param {string} targetDir - Target project root
217
+ * @returns {{ active: boolean, activate: string|null }}
218
+ */
219
+ function configureHooksPathWithCommand(targetDir) {
220
+ const toplevel = runGit(targetDir, ['rev-parse', '--show-toplevel']);
221
+ if (!toplevel.ok || !toplevel.out) {
222
+ const activate = `git init && git config core.hooksPath ${GITHOOKS_DIR}`;
223
+ console.warn(
224
+ `Warning: ${targetDir} is not inside a git repository; core.hooksPath was not set.\n` +
225
+ ` After \`git init\`, enable the hooks with: git config core.hooksPath ${GITHOOKS_DIR}`
226
+ );
227
+ return { active: false, activate };
228
+ }
229
+
230
+ const root = fs.realpathSync(toplevel.out);
231
+ if (root !== fs.realpathSync(targetDir)) {
232
+ const rel = path.join(path.relative(root, fs.realpathSync(targetDir)), GITHOOKS_DIR);
233
+ const activate = `git -C "${root}" config core.hooksPath ${rel}`;
234
+ console.warn(
235
+ `Warning: ${targetDir} is not the root of its git repository (${root}); core.hooksPath was not set.\n` +
236
+ ` To use these hooks for the whole repository run: ${activate}`
237
+ );
238
+ return { active: false, activate };
239
+ }
240
+
241
+ // Only the repository's own value means another hook manager owns it; a
242
+ // global/system value (a user-wide hooks dir) is overridden by a local one.
243
+ const current = localHooksPath(targetDir);
244
+ if (current === GITHOOKS_DIR) return { active: true, activate: null };
245
+ if (current) {
246
+ const activate = `git config core.hooksPath ${GITHOOKS_DIR}`;
247
+ console.warn(
248
+ `Warning: core.hooksPath is already set to "${current}" (another hook manager?); it was left unchanged.\n` +
249
+ ` To run the CLC Kernel hooks instead run: ${activate}`
250
+ );
251
+ return { active: false, activate };
252
+ }
253
+
254
+ const inherited = runGit(targetDir, ['config', '--get', 'core.hooksPath']).out;
255
+ if (inherited && inherited !== GITHOOKS_DIR) {
256
+ console.warn(
257
+ `Note: a global core.hooksPath is configured ("${inherited}"). This repository gets its own local\n` +
258
+ ` core.hooksPath=${GITHOOKS_DIR}, which overrides it here; hooks in "${inherited}" do not run for this repository.`
259
+ );
260
+ }
261
+
262
+ const active = currentIO(targetDir).gitConfig(targetDir, 'core.hooksPath', GITHOOKS_DIR);
263
+ return { active, activate: active ? null : `git config core.hooksPath ${GITHOOKS_DIR}` };
264
+ }
265
+
266
+ /** The repository's own (local scope) core.hooksPath; '' when unset. */
267
+ function localHooksPath(targetDir) {
268
+ return runGit(targetDir, ['config', '--get', '--local', 'core.hooksPath']).out;
269
+ }
270
+
271
+ /** core.hooksPath values that mean Husky (v9: .husky/_, older: .husky) runs the hooks. */
272
+ const HUSKY_HOOKS_PATHS = new Set(['.husky', '.husky/_']);
49
273
 
50
- // The husky bootstrap only exists when the project ran `husky init`. Source it
51
- // only in that case, so a generated hook never points at a missing file.
52
- const huskyBootstrap = path.join(hookDir, '_', 'husky.sh');
53
- const useHuskyBootstrap = isFront && fs.existsSync(huskyBootstrap);
274
+ /** True when Husky is the active hook manager of the repository at targetDir. */
275
+ function huskyActive(targetDir) {
276
+ const current = localHooksPath(targetDir).replace(/\/+$/, '');
277
+ return HUSKY_HOOKS_PATHS.has(current);
278
+ }
279
+
280
+ /**
281
+ * Writes the pre-commit hook where git will run it: .husky/pre-commit only when
282
+ * Husky is active for a frontend project; otherwise .githooks/pre-commit, with
283
+ * core.hooksPath set through configureHooksPath (and its safety checks).
284
+ * The orchestrator block (every guard) comes first, then the external tools.
285
+ * @param {string} targetDir - Target project root
286
+ * @param {boolean} isFront - Frontend project (may use Husky)
287
+ * @param {string[]} commands - External tool lines
288
+ * @param {string} banner - Text echoed when the hook starts
289
+ * @returns {{ file: string, active: boolean, activate: string|null }} Hook path
290
+ * relative to targetDir, whether git runs it and, when not, the command that
291
+ * activates it
292
+ */
293
+ function writeHook(targetDir, isFront, commands, banner) {
294
+ const useHusky = isFront && huskyActive(targetDir);
295
+ const hookDir = path.join(targetDir, useHusky ? '.husky' : GITHOOKS_DIR);
296
+ const hookFile = path.join(hookDir, 'pre-commit');
54
297
 
55
298
  const lines = [];
56
- if (isFront) {
299
+ if (useHusky) {
57
300
  lines.push('#!/usr/bin/env sh');
58
- if (useHuskyBootstrap) {
301
+ // The husky bootstrap only exists with older Husky installs. Source it only
302
+ // then, so a generated hook never points at a missing file.
303
+ if (fs.existsSync(path.join(hookDir, '_', 'husky.sh'))) {
59
304
  lines.push('. "$(dirname -- "$0")/_/husky.sh"');
60
305
  }
61
- lines.push('');
62
306
  } else {
63
307
  lines.push('#!/usr/bin/env bash');
64
- lines.push(`echo "Running CLC Kernel ${adapter.name} Safeguards..."`);
65
- lines.push('');
66
308
  }
309
+ // Fail closed: any failing guard aborts the commit, not just the last one.
310
+ lines.push('set -e');
311
+ lines.push(`echo "${banner}"`);
312
+ lines.push('');
313
+ lines.push(...ORCHESTRATOR_BLOCK);
314
+ lines.push(...commands);
315
+
316
+ // Generated per project: overwritten only when it still matches the
317
+ // registry's hash (sdds/safe-update, decision 3).
318
+ const file = path.posix.join(useHusky ? '.husky' : GITHOOKS_DIR, 'pre-commit');
319
+ const status = currentIO(targetDir).writeManaged(file, lines.join('\n') + '\n', { mode: '755' });
320
+
321
+ const { active, activate } = useHusky ? { active: true, activate: null } : configureHooksPathWithCommand(targetDir);
322
+ return { file, active, status, activate };
323
+ }
324
+
325
+ /**
326
+ * Generates a pre-commit hook dynamically from the adapter's tool manifest.
327
+ * The hook goes to .husky/pre-commit only for a frontend project whose
328
+ * core.hooksPath already points at Husky; everything else uses .githooks.
329
+ *
330
+ * External CLIs (language === 'external', e.g. tsc, ruff, cargo) are wrapped in
331
+ * a runtime availability check (see guardedCommand): the hook runs them when
332
+ * the project has them at commit time and prints a "skipped" notice otherwise,
333
+ * so installing a tool later needs no `clckernel update`.
334
+ *
335
+ * @param {object} adapter - Adapter with getTools() and projectType
336
+ * @param {string} targetDir - Target project root
337
+ * @param {Array} [tools] - Resolved tool manifest (see buildToolManifest).
338
+ * Defaults to adapter.getTools() when omitted.
339
+ * @param {string[]} [managed] - Registered harness files (see writeManagedRegistry);
340
+ * defaults to the registry on disk
341
+ * @returns {{ file: string, active: boolean }} The hook written and whether git runs it
342
+ */
343
+ function generateHookFromManifest(adapter, targetDir, tools = adapter.getTools(), managed = readManagedRegistry(targetDir)) {
344
+ const commands = tools
345
+ .filter(tool => tool.command && tool.language === 'external')
346
+ .map(tool => guardedCommand(tool, managed));
347
+ return writeHook(targetDir, adapter.projectType === 'frontend', commands, `Running CLC Kernel ${adapter.name} Safeguards...`);
348
+ }
349
+
350
+ /** Single-quoted shell word (part dirs are already restricted to safe names). */
351
+ function shellQuote(value) {
352
+ return `'${String(value).replace(/'/g, "'\\''")}'`;
353
+ }
354
+
355
+ /**
356
+ * Hook line for an external tool of a part (sdds/fullstack-monorepo,
357
+ * decision 4): it runs inside the part's dir, in a subshell so the cd does
358
+ * not leak. A failing tool fails the subshell, and with it the hook under
359
+ * `set -e`; a missing one prints a skip notice naming the dir.
360
+ * @param {object} tool - External tool descriptor
361
+ * @param {string} dir - Part dir relative to the root ('.' for the root)
362
+ * @param {string[]} managed - Registered harness files (root-relative)
363
+ * @returns {string}
364
+ */
365
+ function partGuardedCommand(tool, dir, managed = []) {
366
+ // Exclusions are relative to where the tool runs: only harness files inside the part apply.
367
+ const local = dir === '.' ? managed : managed
368
+ .filter(f => f.startsWith(`${dir}/`))
369
+ .map(f => f.slice(dir.length + 1));
370
+ const skip = `else echo "clckernel: ${tool.name} (${dir}) not available, skipped"; fi`;
371
+ const bin = dir === '.' ? null : nodeBinOf(tool);
372
+ if (bin) return `( cd ${shellQuote(dir)} && ${hoistedNodeBody(tool, bin, dir, local)} ${skip} )`;
373
+ const body = `if ${availabilityCondition(tool)} >/dev/null 2>&1; then ${withManagedExcludes(tool, local)}; ${skip}`;
374
+ return dir === '.' ? body : `( cd ${shellQuote(dir)} && ${body} )`;
375
+ }
376
+
377
+ /**
378
+ * Name of the node_modules binary a Node-based external tool runs: its
379
+ * availableIf is `[ -x node_modules/.bin/<bin> ]` and its command `npx <bin> ...`.
380
+ * @param {object} tool - External tool descriptor
381
+ * @returns {string|null}
382
+ */
383
+ function nodeBinOf(tool) {
384
+ const match = /^\[ -x node_modules\/\.bin\/([\w.@-]+) \]$/.exec((tool.availableIf || '').trim());
385
+ if (!match) return null;
386
+ return new RegExp(`^npx ${match[1].replace(/[.]/g, '\\.')}(\\s|$)`).test(tool.command) ? match[1] : null;
387
+ }
388
+
389
+ /**
390
+ * `if/elif` branches of a part's Node tool (sdds/monorepo-gaps, gap 1): the
391
+ * part's own node_modules/.bin/<bin> first, then the one hoisted to the
392
+ * repository root by npm/yarn/pnpm workspaces. The branch runs the binary it
393
+ * found (not npx), with cwd = the part dir. The caller appends the else/fi.
394
+ * @param {object} tool - External tool descriptor (see nodeBinOf)
395
+ * @param {string} bin - Binary name
396
+ * @param {string} dir - Part dir relative to the root (not '.')
397
+ * @param {string[]} local - Managed files relative to the part dir
398
+ * @returns {string}
399
+ */
400
+ function hoistedNodeBody(tool, bin, dir, local) {
401
+ const args = withManagedExcludes(tool, local).slice(`npx ${bin}`.length);
402
+ const up = dir.split('/').filter(Boolean).map(() => '..').join('/');
403
+ return [`node_modules/.bin/${bin}`, `${up}/node_modules/.bin/${bin}`]
404
+ .map((exe, i) => `${i === 0 ? 'if' : 'elif'} [ -x ${exe} ]; then ${exe}${args};`)
405
+ .join(' ');
406
+ }
67
407
 
68
- const skipped = [];
69
- tools.forEach(tool => {
70
- if (!tool.command) return;
71
- if (tool.language === 'external') {
72
- if (!isCliInstalled(targetDir, tool.extCLI)) {
73
- skipped.push(tool.extCLI || tool.command);
74
- return;
408
+ /**
409
+ * Tool manifest of a multi-part repo (sdds/fullstack-monorepo, decision 3):
410
+ * the guards of every part, once each (by file), with the technology tools
411
+ * before the user-defined rules (check_custom), then each part's external
412
+ * tools tagged with the part dir.
413
+ * @param {Array<{dir: string, adapter: object}>} parts - config.parts
414
+ * @param {Array} techTools - Tools for detected technologies
415
+ * @param {string} targetDir - Project root
416
+ * @returns {{ guards: Array, externals: Array<{tool: object, dir: string}> }}
417
+ */
418
+ function buildPartsManifest(parts, techTools, targetDir) {
419
+ const guards = [];
420
+ const custom = [];
421
+ const externals = [];
422
+ const seen = new Set();
423
+ const add = (list, tool) => {
424
+ const key = tool.path || `${tool.name}:${tool.command}`;
425
+ if (seen.has(key)) return;
426
+ seen.add(key);
427
+ list.push(tool);
428
+ };
429
+ for (const part of parts) {
430
+ for (const tool of part.adapter.getTools(path.join(targetDir, part.dir))) {
431
+ if (tool.language === 'external') {
432
+ const key = `${part.dir}\0${tool.name}`;
433
+ if (!seen.has(key)) {
434
+ seen.add(key);
435
+ externals.push({ tool, dir: part.dir });
436
+ }
437
+ } else {
438
+ add(tool.name === 'check_custom' ? custom : guards, tool);
75
439
  }
76
440
  }
77
- lines.push(tool.command);
78
- });
79
-
80
- if (skipped.length > 0) {
81
- lines.push('');
82
- lines.push(`# Not run here (not a dependency of this project): ${skipped.join(', ')}`);
83
- lines.push('# Install it and re-run `clckernel update` to enable it.');
84
441
  }
442
+ for (const tool of techTools) add(guards, tool);
443
+ return { guards: [...guards, ...custom], externals };
444
+ }
445
+
446
+ /**
447
+ * Writes the hook of a multi-part repo: every guard once from the root, then
448
+ * each part's external tools in its dir. The hook goes to .husky only when a
449
+ * part is frontend and Husky is active (same rule as a single stack).
450
+ * @param {object} config - Multi-part config
451
+ * @param {string} targetDir - Project root
452
+ * @param {{ guards: Array, externals: Array }} manifest - From buildPartsManifest
453
+ * @param {string[]} managed - Registered harness files
454
+ * @returns {{ file: string, active: boolean }}
455
+ */
456
+ function generatePartsHook(config, targetDir, manifest, managed) {
457
+ const commands = manifest.externals.map(({ tool, dir }) => partGuardedCommand(tool, dir, managed));
458
+ const isFront = config.parts.some(p => p.adapter.projectType === 'frontend');
459
+ const names = [...new Set(config.parts.map(p => p.adapter.name))].join(' + ');
460
+ return writeHook(targetDir, isFront, commands, `Running CLC Kernel ${names} Safeguards...`);
461
+ }
85
462
 
86
- fs.mkdirSync(hookDir, { recursive: true });
87
- fs.writeFileSync(hookFile, lines.join('\n') + '\n', 'utf-8');
88
- try { fs.chmodSync(hookFile, '755'); } catch (e) {}
463
+ /** Audit orchestrator that discovers guards of each language. */
464
+ const ORCHESTRATORS = { '.js': 'audit.js', '.py': 'audit.py' };
89
465
 
90
- if (!isFront) {
91
- try {
92
- execSync('git config core.hooksPath .githooks', { cwd: targetDir, stdio: 'ignore' });
93
- } catch (e) {}
466
+ /**
467
+ * Copies one audit orchestrator per guard language installed in tools/:
468
+ * audit.js discovers only .js guards and audit.py only .py ones, so a mixed
469
+ * set (e.g. Next.js + docker_guard.py) gets both. With no guards at all,
470
+ * audit.js is installed: Node is the one runtime known to exist.
471
+ * @param {string} targetDir - Target project root
472
+ * @param {string[]} copiedFiles - tools/ file names copied for this project
473
+ * @returns {string[]} File names written into tools/
474
+ */
475
+ function installOrchestrators(targetDir, copiedFiles) {
476
+ const wanted = new Set(copiedFiles.map(f => ORCHESTRATORS[path.extname(f)]).filter(Boolean));
477
+ if (wanted.size === 0) wanted.add('audit.js');
478
+ const written = [];
479
+ for (const file of wanted) {
480
+ const src = path.join(__dirname, '..', 'tools', file);
481
+ if (!fs.existsSync(src)) continue;
482
+ currentIO(targetDir).copyManaged(src, `tools/${file}`, { mode: '755' });
483
+ written.push(file);
94
484
  }
485
+ return written;
95
486
  }
96
487
 
488
+ /**
489
+ * Shared modules the diff-based guards import from their own directory, by
490
+ * guard language: the snapshot helper and the config reader it uses
491
+ * (sdds/config-foundation), so a config added after install works without
492
+ * `clckernel update`.
493
+ */
494
+ const SNAPSHOT_HELPERS = { '.js': ['_changes.js', '_config.js', 'yaml.js'], '.py': ['_changes.py', '_config.py', '_yaml.py'] };
495
+
97
496
  /**
98
497
  * Installs tools from an adapter's manifest into the target tools/ directory.
99
498
  * Only copies tools with valid descriptors. External CLIs are skipped (no file to copy).
100
- * Always copies the audit orchestrator for the project type.
499
+ * Copies the audit orchestrator(s) matching the installed guards' languages.
101
500
  * @param {string} targetDir - Target project root
102
501
  * @param {Array} manifest - Tool manifest from adapter.getTools()
103
502
  * @param {object} config - Project config (projectType, framework, testRunner)
503
+ * @returns {{ installed: string[], written: string[] }} Names of the guards copied,
504
+ * and every file name written into tools/
104
505
  */
105
506
  function installToolsFromManifest(targetDir, manifest, config) {
106
507
  const sourceToolsDir = path.join(__dirname, '..', 'tools');
@@ -108,7 +509,7 @@ function installToolsFromManifest(targetDir, manifest, config) {
108
509
 
109
510
  // tools/ must exist before anything is copied into it. Provisioning a project
110
511
  // that has no tools/ yet used to throw ENOENT on the first copyFileSync.
111
- fs.mkdirSync(path.join(targetDir, 'tools'), { recursive: true });
512
+ currentIO(targetDir).mkdir(path.join(targetDir, 'tools'));
112
513
 
113
514
  // Validate descriptors: skip + warn tools missing required fields
114
515
  const validTools = manifest.filter(tool => {
@@ -119,34 +520,30 @@ function installToolsFromManifest(targetDir, manifest, config) {
119
520
  return true;
120
521
  });
121
522
 
122
- // Always copy the audit orchestrator for the right language
123
- const isNodeStack = config.projectType === 'frontend' || config.projectType === 'agent-os';
124
- const orchestrator = isNodeStack ? 'audit.js' : 'audit.py';
125
- const orchestratorSrc = path.join(sourceToolsDir, orchestrator);
126
- const orchestratorDest = path.join(targetDir, 'tools', orchestrator);
127
- if (fs.existsSync(orchestratorSrc)) {
128
- fs.copyFileSync(orchestratorSrc, orchestratorDest);
129
- try { fs.chmodSync(orchestratorDest, '755'); } catch (e) {}
130
- }
131
-
132
523
  // Copy only custom tools with valid paths (skip external CLIs and empty paths)
524
+ const installed = [];
525
+ const copiedPaths = [];
133
526
  validTools.forEach(tool => {
134
527
  if (tool.language === 'external' || !tool.path) return;
135
528
  const srcFile = path.join(sourceToolsDir, tool.path);
136
- const destFile = path.join(targetDir, 'tools', tool.path);
137
529
  if (fs.existsSync(srcFile)) {
138
- fs.copyFileSync(srcFile, destFile);
139
- try { fs.chmodSync(destFile, '755'); } catch (e) {}
530
+ currentIO(targetDir).copyManaged(srcFile, `tools/${tool.path}`, { mode: '755' });
531
+ installed.push(tool.name);
532
+ copiedPaths.push(tool.path);
140
533
  } else {
141
534
  console.warn(`Warning: tool "${tool.path}" declared by adapter but not found in bundled tools/. Skipping.`);
142
535
  }
143
536
  });
144
537
 
538
+ const written = [...copiedPaths, ...installOrchestrators(targetDir, copiedPaths)];
539
+
145
540
  // check_custom.js parses the project YAML config, so the generated project
146
541
  // must ship the parser alongside it.
147
542
  if (validTools.some(tool => tool.path === 'check_custom.js')) {
148
- installYamlModule(targetDir);
543
+ written.push(...installYamlModule(targetDir));
149
544
  }
545
+ written.push(...installSnapshotHelpers(targetDir, copiedPaths));
546
+ return { installed, written };
150
547
  }
151
548
 
152
549
  /**
@@ -158,192 +555,250 @@ function installToolsFromManifest(targetDir, manifest, config) {
158
555
  * harness provisions and owns.
159
556
  *
160
557
  * @param {string} targetDir - Target project root
558
+ * @returns {string[]} File names written into tools/
161
559
  */
162
560
  function installYamlModule(targetDir) {
163
561
  const yamlSrc = path.join(__dirname, '..', 'tools', 'yaml.js');
164
- if (!fs.existsSync(yamlSrc)) return;
165
- const toolsDest = path.join(targetDir, 'tools');
166
- fs.mkdirSync(toolsDest, { recursive: true });
167
- fs.copyFileSync(yamlSrc, path.join(toolsDest, 'yaml.js'));
562
+ if (!fs.existsSync(yamlSrc)) return [];
563
+ currentIO(targetDir).copyManaged(yamlSrc, 'tools/yaml.js');
564
+ return ['yaml.js'];
565
+ }
566
+
567
+ /**
568
+ * Copies the snapshot helper each installed guard imports as a sibling:
569
+ * _changes.js (with its config reader _config.js and yaml.js) when any JS file
570
+ * was copied into tools/, _changes.py (with _config.py and _yaml.py) when any
571
+ * Python file was (technology guards can bring Python guards to a Node
572
+ * stack). A stack without Python tools stays free of Python files. The `_`
573
+ * prefix keeps the helpers out of the audit orchestrators' guard discovery.
574
+ *
575
+ * @param {string} targetDir - Target project root
576
+ * @param {string[]} copiedFiles - tools/ file names copied for this project
577
+ * @returns {string[]} File names written into tools/
578
+ */
579
+ function installSnapshotHelpers(targetDir, copiedFiles) {
580
+ const helpers = new Set(copiedFiles.flatMap(f => SNAPSHOT_HELPERS[path.extname(f)] || []));
581
+ const written = [];
582
+ for (const file of helpers) {
583
+ const src = path.join(__dirname, '..', 'tools', file);
584
+ if (!fs.existsSync(src)) continue;
585
+ currentIO(targetDir).copyManaged(src, `tools/${file}`);
586
+ written.push(file);
587
+ }
588
+ return written;
168
589
  }
169
590
 
591
+ /** Generic harness for an unknown stack: Node is guaranteed (the user ran npx). */
592
+ const GENERIC_FALLBACK_FILES = ['audit.js', 'scan_secrets.js'];
593
+
594
+ /** Legacy fallback set, used when a stack was forced (frontend/backend) without an adapter. */
595
+ const LEGACY_FALLBACK_FILES = [
596
+ 'audit.js', 'audit.py',
597
+ 'scan_secrets.js', 'scan_secrets.py',
598
+ 'check_custom.js', 'check_custom.py',
599
+ 'check_responsive.js', 'check_seo.js',
600
+ 'check_db_efficiency.py'
601
+ ];
602
+
170
603
  /**
171
604
  * Installs a minimal fallback tool set when no adapter is provided.
172
- * Copies audit.js, audit.py, scan_secrets.js, scan_secrets.py.
605
+ * An unknown stack gets only the JS audit orchestrator and secret scan; a
606
+ * forced frontend/backend type keeps the legacy set.
173
607
  * @param {string} targetDir - Target project root
608
+ * @param {string} [projectType] - Detected or forced project type
609
+ * @param {(file: string) => boolean} [keep] - Files to install (update: the selected guards)
610
+ * @returns {{ installed: string[], written: string[] }} Names of the guards copied
611
+ * (orchestrators excluded), and every file name written into tools/
174
612
  */
175
- function installFallbackTools(targetDir) {
613
+ function installFallbackTools(targetDir, projectType, keep = () => true) {
176
614
  const sourceToolsDir = path.join(__dirname, '..', 'tools');
177
- const fallbackFiles = [
178
- 'audit.js', 'audit.py',
179
- 'scan_secrets.js', 'scan_secrets.py',
180
- 'check_custom.js', 'check_custom.py',
181
- 'check_responsive.js', 'check_seo.js',
182
- 'check_db_efficiency.py'
183
- ];
615
+ const fallbackFiles = (projectType === 'unknown' ? GENERIC_FALLBACK_FILES : LEGACY_FALLBACK_FILES).filter(keep);
184
616
 
617
+ const installed = new Set();
618
+ const written = [];
185
619
  fallbackFiles.forEach(file => {
186
620
  const srcFile = path.join(sourceToolsDir, file);
187
- const destFile = path.join(targetDir, 'tools', file);
188
621
  if (fs.existsSync(srcFile)) {
189
- fs.copyFileSync(srcFile, destFile);
190
- try { fs.chmodSync(destFile, '755'); } catch (e) {}
622
+ currentIO(targetDir).copyManaged(srcFile, `tools/${file}`, { mode: '755' });
623
+ written.push(file);
624
+ const name = path.basename(file, path.extname(file));
625
+ if (name !== 'audit') installed.add(name);
191
626
  }
192
627
  });
193
628
 
194
629
  if (fallbackFiles.includes('check_custom.js')) {
195
- installYamlModule(targetDir);
630
+ written.push(...installYamlModule(targetDir));
196
631
  }
632
+ written.push(...installSnapshotHelpers(targetDir, fallbackFiles));
633
+ return { installed: [...installed], written };
197
634
  }
198
635
 
199
636
  /**
200
- * Generates a minimal generic pre-commit hook when no adapter is available.
637
+ * Generates a minimal generic pre-commit hook when no adapter is available:
638
+ * the orchestrators run the installed fallback guards.
201
639
  * @param {string} targetDir - Target project root
202
- * @param {string} projectType - 'frontend' or 'backend'
640
+ * @param {string} projectType - 'frontend', 'backend' or 'unknown'
641
+ * @returns {{ file: string, active: boolean }} The hook written and whether git runs it
203
642
  */
204
643
  function generateFallbackHook(targetDir, projectType) {
205
- const isFront = projectType === 'frontend';
206
-
207
- if (isFront) {
208
- const hookDir = path.join(targetDir, '.husky');
209
- fs.mkdirSync(hookDir, { recursive: true });
210
- const hookFile = path.join(hookDir, 'pre-commit');
211
- // Only source the husky bootstrap when the project actually has it.
212
- const bootstrap = fs.existsSync(path.join(hookDir, '_', 'husky.sh'))
213
- ? '. "$(dirname -- "$0")/_/husky.sh"\n'
214
- : '';
215
- fs.writeFileSync(hookFile, `#!/usr/bin/env sh
216
- ${bootstrap}
217
- node tools/scan_secrets.js || true
218
- `, 'utf-8');
219
- try { fs.chmodSync(hookFile, '755'); } catch (e) {}
220
- } else {
221
- const hookDir = path.join(targetDir, '.githooks');
222
- fs.mkdirSync(hookDir, { recursive: true });
223
- const hookFile = path.join(hookDir, 'pre-commit');
224
- fs.writeFileSync(hookFile, `#!/usr/bin/env bash
225
- echo "Running CLC Kernel Safeguards..."
226
- python3 tools/scan_secrets.py || true
227
- `, 'utf-8');
228
- try { fs.chmodSync(hookFile, '755'); } catch (e) {}
229
- try {
230
- execSync('git config core.hooksPath .githooks', { cwd: targetDir, stdio: 'ignore' });
231
- } catch (e) {}
232
- }
644
+ return writeHook(targetDir, projectType === 'frontend', [], 'Running CLC Kernel Safeguards...');
233
645
  }
234
646
 
235
647
  /**
236
- * Extended-schema keys that route a config through the config-driven path
237
- * (design §1.4). `rules` intentionally excluded: a rules-only YAML keeps the
238
- * legacy adapter/fallback path (check_custom reads rules at audit time).
648
+ * AGENTS.md of a multi-part repo (sdds/fullstack-monorepo, decision 6): one
649
+ * law with a table of the parts and each part's stack rules grouped.
650
+ * @param {object} config - Multi-part config
651
+ * @returns {string}
239
652
  */
240
- const EXTENDED_CONFIG_KEYS = [
241
- 'active_guards',
242
- 'phases',
243
- 'gate_mode',
244
- 'severities',
245
- 'layers',
246
- 'scope',
247
- 'exclude_paths',
248
- ];
653
+ function partsAgentsMd(config) {
654
+ const testRunner = "the part's test runner (see Parts)";
655
+ const rows = config.parts.map(p => `| \`${p.dir}\` | ${p.adapter.name} | ${p.adapter.testRunner} |`).join('\n');
656
+ const sections = config.parts.map((p, i) => `### 2.${i + 1} Core AI Safeguards — \`${p.dir}\` (${p.adapter.name})
657
+ ${p.adapter.getSafeguards().map(s => `- ${s}`).join('\n')}`).join('\n\n');
658
+ return `# CLC Kernel ${config.projectType.toUpperCase()} (${config.parts.length} parts) — AGENTS
659
+
660
+ This document is the **authoritative law** for AI agents working in this repository.
661
+ Forged by **CLC Kernel: The AI Agent Governance Engine**.
662
+
663
+ > **RULE #0: MANDATORY EXECUTION OVERRIDE RULE (NO EXCEPTIONS)**
664
+ > Even when the user issues a direct or urgent fix request ("fix this bug", "fix this error", "quick fix"):
665
+ > YOU ARE STRICTLY FORBIDDEN from modifying source code directly without completing the full quality harness:
666
+ > 1. **Research & Root Cause Analysis:** Investigate tracebacks and inspect affected files before editing.
667
+ > 2. **TDD Verification (Red Phase):** Write a failing regression test first (${testRunner}).
668
+ > 3. **Clean Architecture Implementation (Green Phase):** Make the test pass maintaining layer isolation.
669
+ > 4. **Mandatory Educational Audit Gate:** Execute \`node tools/audit.js\` or \`python tools/audit.py\` and output the Educational Code Summary to stdout.
670
+ > NEVER declare success or skip verification commands for quick fixes.
249
671
 
250
- /** True when the object carries extended-schema keys (a config-path config). */
251
- function isExtendedConfig(config) {
252
- if (!config || typeof config !== 'object' || Array.isArray(config)) return false;
253
- return EXTENDED_CONFIG_KEYS.some((k) => Object.prototype.hasOwnProperty.call(config, k));
672
+ ## Parts
673
+ This repository has ${config.parts.length} code parts. The guards in \`tools/\` run once from the repository root; each part's external tools (linters, test runners) run inside the part's directory. A changed file's tests belong to the same part as the file.
674
+
675
+ | Directory | Stack | Test runner |
676
+ |---|---|---|
677
+ ${rows}
678
+
679
+ ## 1. The Loop (Every Task)
680
+ 1. **Research** — Inspect codebase / docs before writing code.
681
+ 2. **Plan** — Write an SDD under \`sdds/{change-name}/\`. SDDs live 100% locally and are gitignored.
682
+ 3. **Test** (TDD) — Write failing test first (${testRunner}).
683
+ 4. **Implement** — Make test pass.
684
+ 5. **Verify & Audit** — Run \`node tools/audit.js --change {change-name}\` or \`python tools/audit.py --change {change-name}\` (report + receipt in \`sdds/{change-name}/\`; without \`--change\` nothing is written).
685
+ 6. **DoD** — Lint, typecheck, tests, coverage, docs, memory.
686
+ 7. **Commit** — Pre-commit hook runs automated guards.
687
+ 8. **PR** — Generate PR body.
688
+
689
+ ## 2. Core AI Safeguards (per part)
690
+
691
+ ${sections}
692
+
693
+ ${commandTable(testRunner)}
694
+ ${FIRST_SESSION_SECTION}`;
254
695
  }
255
696
 
256
697
  /**
257
- * Config-driven path (Slice A, minimal A-03 integration): resolve the raw
258
- * config (or reuse an already-resolved one marked by index.js/A-05) and emit
259
- * the resolved harness metadata to `.clckernel.resolved.json` and
260
- * `.clc-forge.resolved.json` (deterministic: stable key order, 2-space JSON).
261
- * Full layout/tools/hook materialization is the A-04 work unit; here only
262
- * the metadata flows out.
263
- * @param {string} targetDir - Target project root
264
- * @param {object} configOrNull - Raw or resolved extended config
265
- * @returns {object} The resolved config (metadata written to disk)
698
+ * The "Universal Command Interface" section shared by the generated AGENTS.md
699
+ * files (fallback and multi-part). `clckernel update` looks for its rows.
700
+ * @param {string} testRunner - Shown in the `clckernel test` row
701
+ * @returns {string}
266
702
  */
267
- function generateConfigPathHarness(targetDir, configOrNull) {
268
- const alreadyResolved =
269
- configOrNull.__clcKernelConfigPath === true ||
270
- configOrNull.__clcForgeConfigPath === true ||
271
- Array.isArray(configOrNull.__catalog);
272
- const resolved = alreadyResolved ? configOrNull : resolveConfig(configOrNull, {});
273
-
274
- const metadata = {
275
- activeGuards: resolved.activeGuards,
276
- phases: resolved.phases,
277
- gateMode: resolved.gateMode,
278
- effectiveSeverities: resolved.effectiveSeverities,
279
- layers: resolved.layers,
280
- scope: resolved.scope,
281
- excludePaths: resolved.excludePaths,
282
- rules: resolved.rules || [],
283
- framework: resolved.framework,
284
- projectType: resolved.projectType,
285
- testRunner: resolved.testRunner,
286
- __catalog: resolved.__catalog,
287
- };
703
+ function commandTable(testRunner) {
704
+ return `## 3. Universal Command Interface (All LLMs & Agents)
705
+ When given a command matching \`clckernel <action>\` or \`/clckernel <action>\`, execute the deterministic state machine below:
288
706
 
289
- fs.mkdirSync(targetDir, { recursive: true });
290
- fs.writeFileSync(
291
- path.join(targetDir, '.clckernel.resolved.json'),
292
- JSON.stringify(metadata, null, 2) + '\n',
293
- 'utf-8'
294
- );
295
- fs.writeFileSync(
296
- path.join(targetDir, '.clc-forge.resolved.json'),
297
- JSON.stringify(metadata, null, 2) + '\n',
298
- 'utf-8'
299
- );
300
- return resolved;
707
+ | Command | Lifecycle Phase | Mandatory Execution Protocol |
708
+ |---|---|---|
709
+ | \`clckernel feature <name>\` | New Feature | 1. Research affected layers.<br>2. Author local spec in \`sdds/{name}/spec.md\`.<br>3. Present HIT review to user and wait for approval.<br>4. Write failing test (Red Phase).<br>5. Implement clean code (Green Phase).<br>6. Run \`node tools/audit.js --change {name}\` (or \`audit.py --change {name}\`). |
710
+ | \`clckernel fix <issue>\` | Bug Fix | 1. Investigate root cause (no code edits).<br>2. Reproduce with failing test (Red Phase).<br>3. Minimal surgical fix respecting Clean Arch (Green Phase).<br>4. Run audit gate without skipping. |
711
+ | \`clckernel test\` | Verification | Run test suite (\`${testRunner}\`) and verify clean pass (exit code 0). |
712
+ | \`clckernel audit\` | Verification | Run \`npx clckernel audit [dir] [--change <name>]\` (every installed orchestrator: \`tools/audit.js\` and/or \`tools/audit.py\`) and output the Educational Summary; only \`--change\` writes the report to \`sdds/<name>/\`. |
713
+ | \`clckernel doctor\` | Diagnostics | Run \`npx clckernel doctor [dir]\`: checks AGENTS.md, sdds/, tools/, the pre-commit hook and that the guard scripts load. |
714
+ | \`clckernel commit <message>\` | Version Control | Run full audit, verify tests pass, ensure no changes outside SDD scope, and commit with conventional commits (never add AI attribution). |
715
+ | \`clckernel guard create <name>\` | Safeguards | Scaffold a new guard script in the stack's native language in \`tools/guards/\`. |
716
+ | \`clckernel update\` | Maintenance | Non-destructive update: run \`npx clckernel update [dir]\` (\`--dry-run\` previews, \`--add-guards <a,b\\|all>\` adds guards); refreshes kernel tools, hook, playbooks & symlinks (preserves SDDs, project guards, edited files & \`.clckernel.yaml\`). |
717
+ | \`clckernel start\` | Setup | Scan codebase and propose a governance plan; if the harness is missing, install it with \`npx clckernel . --yes\`. \`.clckernel.yaml\` is optional: write it only for settings that differ from the defaults. |
718
+ `;
301
719
  }
302
720
 
303
721
  /**
304
- * Generate a harness for a target project.
305
- * When the optional `configOrNull` is null (or an adapter-detected config
306
- * without extended-schema keys) the legacy path runs UNCHANGED. A config
307
- * carrying extended-schema keys (or the `__clcKernelConfigPath` marker set
308
- * by index.js) routes to the config-driven path — which for A-03 emits the
309
- * resolved metadata; full materialization lands in A-04.
722
+ * Generate a harness for a target project from its detected stack
723
+ * (`configOrNull`; null falls back to generic provisioning).
724
+ * The project's .clckernel.yaml is read first (sdds/config-foundation): an
725
+ * invalid one throws its ConfigError before anything is written. A caller
726
+ * that already read it (update, the CLI) passes `options.projectConfig`.
727
+ * @param {string} targetDir - Project root
728
+ * @param {object|null} configOrNull - Detected stack config
729
+ * @param {{ dryRun?: boolean, knownHashes?: object, addGuards?: string[]|'all',
730
+ * projectConfig?: { file: string|null, config: object, warnings: string[] } }} [options]
310
731
  */
311
- function generateHarness(targetDir, configOrNull) {
312
- if (configOrNull && (configOrNull.__clcKernelConfigPath === true || configOrNull.__clcForgeConfigPath === true || isExtendedConfig(configOrNull))) {
313
- return generateConfigPathHarness(targetDir, configOrNull);
732
+ function generateHarness(targetDir, configOrNull, options = {}) {
733
+ let projectConfig = options.projectConfig;
734
+ if (!projectConfig) {
735
+ projectConfig = readProjectConfig(targetDir);
736
+ for (const warning of projectConfig.warnings) console.warn(`Warning: ${warning}`);
314
737
  }
315
- // Legacy path: a null/absent config falls back to generic provisioning
316
- // (byte-identical for every existing caller, which always passes a full
317
- // detected config — defaults only kick in for a bare null).
738
+ const withConfig = { ...options, projectConfig };
739
+ // One io session per install (sdds/safe-update): reuse the caller's (update)
740
+ // or open one, real or dry-run.
741
+ const active = activeIO();
742
+ if (active) return generateHarnessWith(active, targetDir, configOrNull, withConfig);
743
+ const io = createIO({
744
+ root: targetDir,
745
+ dryRun: !!options.dryRun,
746
+ knownHashes: options.knownHashes,
747
+ exclude: globMatcher(projectConfig.config.update.exclude),
748
+ });
749
+ const result = runWithIO(io, () => generateHarnessWith(io, targetDir, configOrNull, withConfig));
750
+ if (result && typeof result === 'object' && !Array.isArray(result) && 'installedGuards' in result) {
751
+ return { ...result, plan: io.plan() };
752
+ }
753
+ return result;
754
+ }
755
+
756
+ function generateHarnessWith(io, targetDir, configOrNull, options = {}) {
757
+ // A null/absent config falls back to generic provisioning.
318
758
  const config = configOrNull || {};
759
+ const { projectConfig } = options;
760
+
761
+ const multiPart = Array.isArray(config.parts) && config.parts.length > 0;
319
762
 
320
763
  // Step 1: Provision directories + AGENTS.md
321
- if (config.adapter) {
764
+ if (multiPart) {
765
+ // Directories and .gitignore as any adapter provisions them; the law file
766
+ // is the multi-part one, never written over a project's own AGENTS.md.
767
+ config.parts[0].adapter.provision(targetDir, { ...config, skipAgentsMd: true });
768
+ if (!io.exists(path.join(targetDir, 'AGENTS.md'))) {
769
+ io.writeFile(path.join(targetDir, 'AGENTS.md'), partsAgentsMd(config));
770
+ }
771
+ } else if (config.adapter) {
322
772
  config.adapter.provision(targetDir, config);
323
773
  } else {
324
774
  // Fallback: create directory structure + AGENTS.md
325
775
  const isFront = config.projectType === 'frontend';
776
+ // Unknown stack: a Generic AGENTS.md with no stack-specific rules.
777
+ const isGeneric = config.projectType === 'unknown';
778
+ const frameworkLabel = isGeneric ? 'Generic' : (config.framework || 'Generic');
779
+ const testRunner = isGeneric ? "the project's test runner" : (config.testRunner || 'npm test');
326
780
 
327
- fs.mkdirSync(path.join(targetDir, 'docs', 'Journal'), { recursive: true });
328
- fs.mkdirSync(path.join(targetDir, 'docs', 'Architecture'), { recursive: true });
329
- fs.mkdirSync(path.join(targetDir, 'docs', 'Development'), { recursive: true });
330
- fs.mkdirSync(path.join(targetDir, 'sdds'), { recursive: true });
331
- fs.mkdirSync(path.join(targetDir, 'tools'), { recursive: true });
781
+ io.mkdir(path.join(targetDir, 'docs', 'Journal'));
782
+ io.mkdir(path.join(targetDir, 'docs', 'Architecture'));
783
+ io.mkdir(path.join(targetDir, 'docs', 'Development'));
784
+ io.mkdir(path.join(targetDir, 'sdds'));
785
+ io.mkdir(path.join(targetDir, 'tools'));
332
786
 
333
787
  const { ensureGitignoreEntries } = require('./gitignore');
334
788
  ensureGitignoreEntries(targetDir);
335
- fs.writeFileSync(path.join(targetDir, 'sdds', '.gitkeep'), '', 'utf-8');
789
+ const gitkeep = path.join(targetDir, 'sdds', '.gitkeep');
790
+ if (!io.exists(gitkeep)) io.writeFile(gitkeep, '');
336
791
 
337
- const agentsContent = `# CLC Kernel ${config.framework || 'Generic'} (${(config.projectType || 'unknown').toUpperCase()}) — AGENTS
792
+ const agentsContent = `# CLC Kernel ${frameworkLabel} (${(config.projectType || 'unknown').toUpperCase()}) — AGENTS
338
793
 
339
794
  This document is the **authoritative law** for AI agents working in this repository.
340
795
  Forged by **CLC Kernel: The AI Agent Governance Engine**.
341
796
 
342
- > **RULE #0: MANDATORY EXECUTION OVERRIDE RULE (UNBYPASSABLE)**
797
+ > **RULE #0: MANDATORY EXECUTION OVERRIDE RULE (NO EXCEPTIONS)**
343
798
  > Even when the user issues a direct or urgent fix request ("fix this bug", "fix this error", "quick fix"):
344
799
  > YOU ARE STRICTLY FORBIDDEN from modifying source code directly without completing the full quality harness:
345
800
  > 1. **Research & Root Cause Analysis:** Investigate tracebacks and inspect affected files before editing.
346
- > 2. **TDD Verification (Red Phase):** Write a failing regression test first (${config.testRunner || 'npm test'}).
801
+ > 2. **TDD Verification (Red Phase):** Write a failing regression test first (${testRunner}).
347
802
  > 3. **Clean Architecture Implementation (Green Phase):** Make the test pass maintaining layer isolation.
348
803
  > 4. **Mandatory Educational Audit Gate:** Execute \`node tools/audit.js\` or \`python tools/audit.py\` and output the Educational Code Summary to stdout.
349
804
  > NEVER declare success or skip verification commands for quick fixes.
@@ -351,9 +806,9 @@ Forged by **CLC Kernel: The AI Agent Governance Engine**.
351
806
  ## 1. The Loop (Every Task)
352
807
  1. **Research** — Inspect codebase / docs before writing code.
353
808
  2. **Plan** — Write an SDD under \`sdds/{change-name}/\`. SDDs live 100% locally and are gitignored.
354
- 3. **Test** (TDD) — Write failing test first (${config.testRunner || 'npm test'}).
809
+ 3. **Test** (TDD) — Write failing test first (${testRunner}).
355
810
  4. **Implement** — Make test pass.
356
- 5. **Verify & Audit** — Run \`node tools/audit.js\` or \`python tools/audit.py\`.
811
+ 5. **Verify & Audit** — Run \`node tools/audit.js --change {change-name}\` or \`python tools/audit.py --change {change-name}\` (report + receipt in \`sdds/{change-name}/\`; without \`--change\` nothing is written).
357
812
  6. **DoD** — Lint, typecheck, tests, coverage, docs, memory.
358
813
  7. **Commit** — Pre-commit hook runs automated guards.
359
814
  8. **PR** — Generate PR body.
@@ -369,86 +824,88 @@ ${isFront ? `- UI Component Reuse First (components/ui/ & semantic tokens)
369
824
  - Next/Image & Tree-Shaking Guard
370
825
  - Storybook Coverage Guard
371
826
  - Educational Audit Gate
372
- - Memory Guard (Engram / Graphify)` : `- Wait for Audit Gate
373
- - Scope Guardrail
374
- - Real TDD Validation (Red -> Green)
827
+ - Memory protocol (agent-enforced; Engram / Graphify optional, not checked by a guard)` : isGeneric ? `- Wait for Audit Gate
828
+ - Secret Leak Guard (tools/scan_secrets.js, run by the pre-commit hook)
829
+ - Scope protocol (agent-enforced; not checked by a guard)
830
+ - TDD protocol (agent-enforced; no verifier installed for this stack)
831
+ - Memory protocol (agent-enforced; Engram / Graphify optional, not checked by a guard)` : `- Wait for Audit Gate
832
+ - Scope protocol (agent-enforced; not checked by a guard)
833
+ - TDD protocol (agent-enforced; no verifier installed for this stack)
375
834
  - Secret Leak Guard
376
- - Clean Architecture AST Guard
377
- - Database Efficiency & N+1 Performance Guard
378
- - Database Migration Idempotency Guard
379
- - Memory Guard (Engram / Graphify)`}
835
+ - Clean Architecture protocol (agent-enforced; no layer guard installed for this stack)
836
+ - N+1 Heuristic (ORM-like calls inside loops, Python AST)
837
+ - Migration Reversibility protocol (agent-enforced; no migration guard installed for this stack)
838
+ - Memory protocol (agent-enforced; Engram / Graphify optional, not checked by a guard)`}
380
839
 
381
- ## 3. Universal Command Interface (All LLMs & Agents)
382
- When given a command matching \`clckernel <action>\` or \`/clckernel <action>\`, execute the deterministic state machine below:
383
-
384
- | Command | Lifecycle Phase | Mandatory Execution Protocol |
385
- |---|---|---|
386
- | \`clckernel feature <name>\` | New Feature | 1. Research affected layers.<br>2. Author local spec in \`sdds/{name}/spec.md\`.<br>3. Present HIT review to user and wait for approval.<br>4. Write failing test (Red Phase).<br>5. Implement clean code (Green Phase).<br>6. Run \`node tools/audit.js\` (or \`audit.py\`). |
387
- | \`clckernel fix <issue>\` | Bug Fix | 1. Investigate root cause (no code edits).<br>2. Reproduce with failing test (Red Phase).<br>3. Minimal surgical fix respecting Clean Arch (Green Phase).<br>4. Run audit gate without skipping. |
388
- | \`clckernel test\` | Verification | Run test suite (\`${config.testRunner || 'npm test'}\`) and verify clean pass (exit code 0). |
389
- | \`clckernel audit\` | Verification | Run \`node tools/audit.js\` or \`python tools/audit.py\` and output the Educational Summary. |
390
- | \`clckernel doctor\` | Diagnostics | Check git pre-commit hooks, IDE symlinks, and AST engine integrity. |
391
- | \`clckernel commit <message>\` | Version Control | Run full audit, verify tests pass, ensure no changes outside SDD scope, and commit with conventional commits (never add AI attribution). |
392
- | \`clckernel guard create <name>\` | Safeguards | Scaffold a new AST guard in the stack's native language in \`tools/guards/\`. |
393
- | \`clckernel update\` | Maintenance | Non-destructive update: refresh core tools, playbooks & symlinks (preserves SDDs & custom guards). |
394
- | \`clckernel start\` | Setup | Scan codebase, propose governance plan, and materialize \`.clckernel.yaml\`. |
395
- `;
840
+ ${commandTable(testRunner)}
841
+ ${FIRST_SESSION_SECTION}`;
396
842
  // Guard: never overwrite an existing project-customized AGENTS.md.
397
- // This prevents clckernel update from replacing Laila MOS invariants
398
- // (carousel format, one-question-onboarding, dynamic calendar, immutability).
399
- if (!fs.existsSync(path.join(targetDir, 'AGENTS.md'))) {
400
- fs.writeFileSync(path.join(targetDir, 'AGENTS.md'), agentsContent, 'utf-8');
843
+ // This prevents clckernel update from replacing project-specific
844
+ // invariants the template cannot know about.
845
+ if (!io.exists(path.join(targetDir, 'AGENTS.md'))) {
846
+ io.writeFile(path.join(targetDir, 'AGENTS.md'), agentsContent);
401
847
  }
402
848
 
403
849
  }
404
850
 
405
- // Step 2: Install tools (manifest-driven or fallback)
406
- if (config.adapter) {
407
- const frameworkTools = config.adapter.getTools();
408
- const techTools = config.techTools || [];
409
-
410
- // Merge order: [frameworkTools prefix, ...techTools, check_custom + suffix]
411
- // Split around check_custom to insert tech tools before it
412
- const checkCustomIdx = frameworkTools.findIndex(t => t.name === 'check_custom');
413
- const mergedManifest = checkCustomIdx >= 0
414
- ? [
415
- ...frameworkTools.slice(0, checkCustomIdx),
416
- ...techTools,
417
- ...frameworkTools.slice(checkCustomIdx)
418
- ]
419
- : [
420
- ...frameworkTools,
421
- ...techTools
422
- ];
423
-
424
- installToolsFromManifest(targetDir, mergedManifest, config);
851
+ // Steps 2-3: Install tools and generate the pre-commit hook from the same
852
+ // manifest, so every guard copied into tools/ is also run by the hook.
853
+ // The registry of everything written into tools/ is recorded before the hook
854
+ // is generated, so whole-project linters can exclude those files.
855
+ let installedGuards;
856
+ let hook;
857
+ // On update only the guards the project has (plus --add-guards) are
858
+ // installed and run (sdds/update-no-additions, decisions 1 and 4).
859
+ const { addGuards } = options;
860
+ if (multiPart) {
861
+ const full = buildPartsManifest(config.parts, config.techTools || [], targetDir);
862
+ const manifest = { ...full, guards: selectGuards(io, full.guards, addGuards) };
863
+ const { installed, written } = installToolsFromManifest(targetDir, manifest.guards, config);
864
+ installedGuards = installed;
865
+ hook = generatePartsHook(config, targetDir, manifest, managedToolPaths(written));
866
+ } else if (config.adapter) {
867
+ const manifest = selectGuards(io, buildToolManifest(config.adapter, config.techTools || [], targetDir), addGuards);
868
+ const { installed, written } = installToolsFromManifest(targetDir, manifest, config);
869
+ installedGuards = installed;
870
+ hook = generateHookFromManifest(config.adapter, targetDir, manifest, managedToolPaths(written));
425
871
  } else {
426
- installFallbackTools(targetDir);
427
- }
428
-
429
- // Step 3: Generate pre-commit hook (manifest-driven or fallback)
430
- if (config.adapter) {
431
- generateHookFromManifest(config.adapter, targetDir);
432
- } else {
433
- generateFallbackHook(targetDir, config.projectType);
872
+ const kept = new Set(selectGuards(io, fallbackGuards(config.projectType), addGuards).map(tool => tool.path));
873
+ const keep = file => /^audit\./.test(file) || kept.has(file);
874
+ const { installed } = installFallbackTools(targetDir, config.projectType, keep);
875
+ installedGuards = installed;
876
+ hook = generateFallbackHook(targetDir, config.projectType);
434
877
  }
435
878
 
436
879
  // Step 4: Install LLM Conversational CLI & Agent Context
437
- installAgenticCLI(targetDir);
880
+ installAgenticCLI(targetDir, projectConfig.config.mirrors.list);
881
+
882
+ // Step 5: Record what the kernel owns now, with hashes.
883
+ writeManagedRegistry(io, targetDir);
884
+
885
+ return { installedGuards, hook };
438
886
  }
439
887
 
888
+ /** AI IDE mirrors (symlinks to AGENTS.md) install creates when the config lists none. */
889
+ const DEFAULT_MIRRORS = [
890
+ 'CLAUDE.md', // Cursor / Claude Desktop / Windsurf
891
+ 'GEMINI.md', // Gemini / Project IDX
892
+ '.cursorrules', // Cursor legacy
893
+ '.cursor/rules/clckernel_context.mdc', // Cursor modern (MDC)
894
+ '.github/copilot-instructions.md', // GitHub Copilot
895
+ '.antigravity/rules.md' // Antigravity (Local Agent)
896
+ ];
897
+
440
898
  /**
441
- * Instala la CLI Conversacional (SKILL.md) y genera los symlinks universales
442
- * para que todos los LLMs (Cursor, Claude, Gemini) absorban el AGENTS.md.
899
+ * Installs the conversational CLI skill (SKILL.md and its playbooks) and the
900
+ * AI IDE mirrors of AGENTS.md: the config's mirrors.list, or DEFAULT_MIRRORS.
901
+ * @param {string} targetDir - Project root
902
+ * @param {string[]|null} [mirrors] - mirrors.list of the project config
443
903
  */
444
- function installAgenticCLI(targetDir) {
904
+ function installAgenticCLI(targetDir, mirrors) {
905
+ const io = currentIO(targetDir);
445
906
  // 1. Crear el Directorio de Skills
446
- const skillDir = path.join(targetDir, '.agents', 'skills', 'clckernel_cli');
447
- const legacySkillDir = path.join(targetDir, '.agents', 'skills', 'clc_forge_cli');
448
- const skillRefDir = path.join(skillDir, 'reference');
449
- const legacySkillRefDir = path.join(legacySkillDir, 'reference');
450
- fs.mkdirSync(skillRefDir, { recursive: true });
451
- fs.mkdirSync(legacySkillRefDir, { recursive: true });
907
+ const skillRel = '.agents/skills/clckernel_cli';
908
+ io.mkdir(path.join(targetDir, skillRel, 'reference'));
452
909
 
453
910
  // 2. Inyectar el SKILL.md Maestro y Playbooks de Referencia
454
911
  const bundledSkillDir = path.join(__dirname, '..', '.agents', 'skills', 'clckernel_cli');
@@ -456,8 +913,7 @@ function installAgenticCLI(targetDir) {
456
913
  const bundledRefDir = path.join(bundledSkillDir, 'reference');
457
914
 
458
915
  if (fs.existsSync(bundledSkillFile)) {
459
- fs.copyFileSync(bundledSkillFile, path.join(skillDir, 'SKILL.md'));
460
- fs.copyFileSync(bundledSkillFile, path.join(legacySkillDir, 'SKILL.md'));
916
+ io.copyManaged(bundledSkillFile, `${skillRel}/SKILL.md`);
461
917
  }
462
918
 
463
919
  if (fs.existsSync(bundledRefDir)) {
@@ -465,53 +921,29 @@ function installAgenticCLI(targetDir) {
465
921
  for (const pb of playbooks) {
466
922
  const srcPb = path.join(bundledRefDir, pb);
467
923
  if (fs.statSync(srcPb).isFile()) {
468
- fs.copyFileSync(srcPb, path.join(skillRefDir, pb));
469
- fs.copyFileSync(srcPb, path.join(legacySkillRefDir, pb));
924
+ io.copyManaged(srcPb, `${skillRel}/reference/${pb}`);
470
925
  }
471
926
  }
472
927
  }
473
928
 
474
929
  // 3. Crear Symlinks Dinámicos al AGENTS.md (El Single Source of Truth)
475
930
  const sourceFile = 'AGENTS.md';
476
- const symlinks = [
477
- 'CLAUDE.md', // Cursor / Claude Desktop / Windsurf
478
- 'GEMINI.md', // Gemini / Project IDX
479
- '.cursorrules', // Cursor legacy
480
- '.cursor/rules/clckernel_context.mdc', // Cursor modern (MDC)
481
- '.cursor/rules/clc_forge_context.mdc', // Cursor backward compat
482
- '.github/copilot-instructions.md', // GitHub Copilot
483
- '.antigravity/rules.md' // Antigravity (Local Agent)
484
- ];
931
+ const symlinks = Array.isArray(mirrors) ? mirrors : DEFAULT_MIRRORS;
485
932
 
486
933
  symlinks.forEach(link => {
487
934
  const linkPath = path.join(targetDir, link);
488
- const linkDir = path.dirname(linkPath);
489
-
490
- if (!fs.existsSync(linkDir)) {
491
- fs.mkdirSync(linkDir, { recursive: true });
492
- }
493
-
494
- const relPath = path.relative(linkDir, path.join(targetDir, sourceFile));
935
+ const relPath = path.relative(path.dirname(linkPath), path.join(targetDir, sourceFile));
495
936
 
496
937
  try {
497
938
  // Only replace an existing symlink (a stale mirror needs refreshing).
498
939
  // A regular file belongs to the consumer: unlinking it would destroy
499
- // their own rules, so leave it in place instead.
500
- let existing = null;
501
- try {
502
- existing = fs.lstatSync(linkPath);
503
- } catch (err) {}
504
-
505
- if (existing) {
506
- if (!existing.isSymbolicLink()) return;
507
- fs.unlinkSync(linkPath);
508
- }
509
-
510
- fs.symlinkSync(relPath, linkPath, 'file');
940
+ // their own rules, so leave it in place instead. An update never
941
+ // creates a mirror the project does not have (sdds/update-no-additions).
942
+ io.mirror(relPath, linkPath);
511
943
  } catch (e) {
512
944
  // Ignorar fallos de permisos o symlinks existentes
513
945
  }
514
946
  });
515
947
  }
516
948
 
517
- module.exports = { generateHarness, generateHookFromManifest };
949
+ module.exports = { generateHarness, generateHookFromManifest, buildToolManifest, configureHooksPath, manifestGuardNames };