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/io.js ADDED
@@ -0,0 +1,409 @@
1
+ /**
2
+ * CLC Kernel — Single write point for install and update (sdds/safe-update).
3
+ *
4
+ * Every write the harness makes in a project goes through an `io` session:
5
+ * files, copies, symlinks, moves, removals, .gitignore and git config. A real
6
+ * session applies each operation and records it; a dry-run session only
7
+ * records it, so the same code path yields the plan `--dry-run` prints.
8
+ *
9
+ * Files the kernel owns (tools/*, the pre-commit hook, the clckernel_cli
10
+ * skill) are written with `writeManaged`, which applies the ownership rule:
11
+ *
12
+ * missing -> write
13
+ * identical to the new content -> nothing
14
+ * sha256 equals the registry's hash, or any
15
+ * version the kernel published (known-hashes) -> overwrite
16
+ * anything else -> keep it, write <file>.clckernel-new
17
+ *
18
+ * The registry (tools/.clckernel-managed.json) records, per owned file, the
19
+ * sha256 of what the kernel last wrote: { kernelVersion, files: [{ path, sha256 }] }.
20
+ * The older format (a sorted array of paths) is still read; it carries no hashes.
21
+ *
22
+ * Paths the project config lists in `update.exclude` are never written,
23
+ * moved, linked or removed: each is recorded as excluded and left as it is.
24
+ */
25
+
26
+ const fs = require('fs');
27
+ const path = require('path');
28
+ const crypto = require('crypto');
29
+ const { spawnSync } = require('child_process');
30
+
31
+ const MANAGED_REGISTRY = 'tools/.clckernel-managed.json';
32
+ const NEW_SUFFIX = '.clckernel-new';
33
+
34
+ const sha256 = (bytes) => crypto.createHash('sha256').update(bytes).digest('hex');
35
+ const toPosix = (p) => p.split(path.sep).join('/');
36
+
37
+ /** sha256 of every published version of each kernel file (scripts/build-known-hashes.js). */
38
+ function loadKnownHashes() {
39
+ try {
40
+ return require('./known-hashes.json');
41
+ } catch {
42
+ return {};
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Reads the registry in either format.
48
+ * @param {string} root - Project root
49
+ * @returns {{ format: 'none'|'list'|'hashed', paths: string[], hashes: Map<string, string> }}
50
+ */
51
+ function readRegistry(root) {
52
+ let data;
53
+ try {
54
+ data = JSON.parse(fs.readFileSync(path.join(root, MANAGED_REGISTRY), 'utf-8'));
55
+ } catch {
56
+ return { format: 'none', paths: [], hashes: new Map() };
57
+ }
58
+ if (Array.isArray(data)) {
59
+ return { format: 'list', paths: data.filter((f) => typeof f === 'string'), hashes: new Map() };
60
+ }
61
+ const files = data && Array.isArray(data.files) ? data.files : [];
62
+ const hashes = new Map();
63
+ for (const f of files) {
64
+ if (f && typeof f.path === 'string' && typeof f.sha256 === 'string') hashes.set(f.path, f.sha256);
65
+ }
66
+ return { format: 'hashed', paths: [...hashes.keys()], hashes };
67
+ }
68
+
69
+ /**
70
+ * Registered paths in either format, the registry itself included (the
71
+ * snapshot helpers and whole-project linters exclude them).
72
+ * @param {string} root - Project root
73
+ * @returns {string[]}
74
+ */
75
+ function registeredPaths(root) {
76
+ const { format, paths } = readRegistry(root);
77
+ return format === 'none' ? [] : [...new Set([...paths, MANAGED_REGISTRY])].sort();
78
+ }
79
+
80
+ /**
81
+ * Creates an io session for one install/update of `root`.
82
+ * @param {object} options
83
+ * @param {string} options.root - Project root
84
+ * @param {boolean} [options.dryRun] - Record only, never write
85
+ * @param {object} [options.knownHashes] - { path: [sha256] } (defaults to src/known-hashes.json)
86
+ * @param {'install'|'update'} [options.mode] - An update never adds guards or
87
+ * mirrors the project does not have (sdds/update-no-additions)
88
+ * @param {(rel: string) => boolean} [options.exclude] - Root-relative POSIX
89
+ * paths never touched (the config's update.exclude)
90
+ */
91
+ function createIO({ root, dryRun = false, knownHashes = loadKnownHashes(), mode: sessionMode = 'install', exclude = () => false } = {}) {
92
+ const projectRoot = path.resolve(root);
93
+ const registry = readRegistry(projectRoot);
94
+ // Dry-run overlay: what the plan would have put on disk, so later reads in
95
+ // the same run see it (e.g. .gitignore entries added twice).
96
+ const overlay = new Map(); // abs -> { kind: 'file', bytes } | { kind: 'dir' } | { kind: 'link', target } | { kind: 'gone' }
97
+ const decisions = new Map(); // rel -> { status, sha }
98
+ const plan = {
99
+ written: [], unchanged: [], protected: [], moved: [], removed: [], gitignore: [], gitConfig: [],
100
+ // sdds/update-no-additions: what update left out on purpose.
101
+ available: [], mirrorsSkipped: [], trackedNotIgnored: [],
102
+ // sdds/config-foundation: paths the config's update.exclude keeps out.
103
+ excluded: [],
104
+ };
105
+
106
+ const rel = (abs) => toPosix(path.relative(projectRoot, abs));
107
+ const abs = (p) => (path.isAbsolute(p) ? p : path.join(projectRoot, p));
108
+ const note = (list, value) => {
109
+ if (!list.includes(value)) list.push(value);
110
+ };
111
+ /** True (and recorded) when the config excludes this absolute path. */
112
+ const excluded = (p) => {
113
+ const relPath = rel(p);
114
+ if (!exclude(relPath)) return false;
115
+ note(plan.excluded, relPath);
116
+ return true;
117
+ };
118
+
119
+ function lstat(p) {
120
+ const o = overlay.get(p);
121
+ if (o) return o.kind === 'gone' ? null : o;
122
+ try {
123
+ const st = fs.lstatSync(p);
124
+ if (st.isSymbolicLink()) return { kind: 'link', target: fs.readlinkSync(p) };
125
+ if (st.isDirectory()) return { kind: 'dir' };
126
+ if (st.isFile()) return { kind: 'file' };
127
+ return { kind: 'other' };
128
+ } catch {
129
+ return null;
130
+ }
131
+ }
132
+
133
+ function exists(p) {
134
+ const o = overlay.get(abs(p));
135
+ if (o) return o.kind !== 'gone';
136
+ return fs.existsSync(abs(p));
137
+ }
138
+
139
+ function read(p) {
140
+ const o = overlay.get(abs(p));
141
+ if (o && o.kind === 'file') return o.bytes;
142
+ if (o && o.kind === 'gone') return null;
143
+ try {
144
+ return fs.readFileSync(abs(p));
145
+ } catch {
146
+ return null;
147
+ }
148
+ }
149
+
150
+ function mkdir(p) {
151
+ if (excluded(abs(p))) return;
152
+ ensureDir(abs(p));
153
+ }
154
+
155
+ function ensureDir(dir) {
156
+ if (dryRun) {
157
+ for (let d = dir; d.startsWith(projectRoot) && !exists(d); d = path.dirname(d)) overlay.set(d, { kind: 'dir' });
158
+ return;
159
+ }
160
+ fs.mkdirSync(dir, { recursive: true });
161
+ }
162
+
163
+ function put(dest, bytes, mode) {
164
+ if (dryRun) {
165
+ overlay.set(dest, { kind: 'file', bytes });
166
+ return;
167
+ }
168
+ ensureDir(path.dirname(dest));
169
+ fs.writeFileSync(dest, bytes);
170
+ if (mode) {
171
+ try { fs.chmodSync(dest, mode); } catch {}
172
+ }
173
+ }
174
+
175
+ /**
176
+ * Writes a file the project owns the content of (AGENTS.md when absent,
177
+ * sdds/.gitkeep, the registry, .clckernel.resolved.json): no ownership rule.
178
+ * @returns {'written'|'unchanged'}
179
+ */
180
+ function writeFile(p, content, { mode } = {}) {
181
+ const dest = abs(p);
182
+ if (excluded(dest)) return 'excluded';
183
+ const bytes = Buffer.isBuffer(content) ? content : Buffer.from(String(content), 'utf-8');
184
+ const current = read(dest);
185
+ if (current && current.equals(bytes)) {
186
+ note(plan.unchanged, rel(dest));
187
+ return 'unchanged';
188
+ }
189
+ put(dest, bytes, mode);
190
+ note(plan.written, rel(dest));
191
+ return 'written';
192
+ }
193
+
194
+ /**
195
+ * Writes a kernel-owned file under the ownership rule.
196
+ * @param {string} relPath - Path relative to the project root
197
+ * @param {string|Buffer} content
198
+ * @param {{ mode?: string }} [options]
199
+ * @returns {'written'|'unchanged'|'protected'|'excluded'}
200
+ */
201
+ function writeManaged(relPath, content, { mode } = {}) {
202
+ const relKey = toPosix(relPath);
203
+ const dest = abs(relKey);
204
+ if (excluded(dest)) return 'excluded';
205
+ const bytes = Buffer.isBuffer(content) ? content : Buffer.from(String(content), 'utf-8');
206
+ const sha = sha256(bytes);
207
+ const prior = decisions.get(relKey);
208
+ if (prior && prior.sha === sha) return prior.status;
209
+
210
+ const stat = lstat(dest);
211
+ const current = stat && stat.kind === 'file' ? read(dest) : null;
212
+ let status;
213
+ let reason = null;
214
+ if (!stat) {
215
+ status = 'written';
216
+ } else if (current && current.equals(bytes)) {
217
+ status = 'unchanged';
218
+ } else if (current && prior && prior.status !== 'protected') {
219
+ status = 'written'; // written earlier in this same run
220
+ } else if (current && (registry.hashes.get(relKey) === sha256(current) ||
221
+ (knownHashes[relKey] || []).includes(sha256(current)))) {
222
+ status = 'written';
223
+ } else {
224
+ status = 'protected';
225
+ if (!current) reason = 'not a regular file';
226
+ else if (registry.paths.includes(relKey)) reason = 'modified locally';
227
+ else reason = 'unknown to the kernel: edited locally, or a project file with the same name';
228
+ }
229
+
230
+ if (status === 'written') {
231
+ put(dest, bytes, mode);
232
+ plan.unchanged = plan.unchanged.filter((p) => p !== relKey);
233
+ note(plan.written, relKey);
234
+ } else if (status === 'unchanged') {
235
+ if (!dryRun && mode) {
236
+ try { fs.chmodSync(dest, mode); } catch {}
237
+ }
238
+ if (!plan.written.includes(relKey)) note(plan.unchanged, relKey);
239
+ } else {
240
+ const newPath = relKey + NEW_SUFFIX;
241
+ put(abs(newPath), bytes, mode);
242
+ if (!plan.protected.some((x) => x.path === relKey)) plan.protected.push({ path: relKey, newPath, reason });
243
+ }
244
+ decisions.set(relKey, { status, sha });
245
+ return status;
246
+ }
247
+
248
+ /** writeManaged with the bytes of a bundled file. */
249
+ function copyManaged(src, relPath, options) {
250
+ return writeManaged(relPath, fs.readFileSync(src), options);
251
+ }
252
+
253
+ /** Creates a symlink at `p` pointing to `target` (the caller decided it is safe). */
254
+ function symlink(target, p) {
255
+ const dest = abs(p);
256
+ if (excluded(dest)) return 'excluded';
257
+ const stat = lstat(dest);
258
+ if (stat && stat.kind === 'link' && stat.target === target) {
259
+ note(plan.unchanged, rel(dest));
260
+ return 'unchanged';
261
+ }
262
+ if (stat && stat.kind !== 'link') return 'skipped';
263
+ if (dryRun) {
264
+ overlay.set(dest, { kind: 'link', target });
265
+ } else {
266
+ if (stat) fs.unlinkSync(dest);
267
+ fs.symlinkSync(target, dest, 'file');
268
+ }
269
+ note(plan.written, `${rel(dest)} -> ${target}`);
270
+ return 'written';
271
+ }
272
+
273
+ /**
274
+ * Creates or refreshes an AI IDE mirror (a symlink to AGENTS.md). An update
275
+ * only refreshes mirrors that exist: one the project deleted is reported as
276
+ * skipped and neither it nor its directory is created.
277
+ * @returns {'written'|'unchanged'|'skipped'}
278
+ */
279
+ function mirror(target, p) {
280
+ const dest = abs(p);
281
+ if (excluded(dest)) return 'excluded';
282
+ if (sessionMode === 'update' && !lstat(dest)) {
283
+ note(plan.mirrorsSkipped, rel(dest));
284
+ return 'skipped';
285
+ }
286
+ if (!exists(path.dirname(dest))) ensureDir(path.dirname(dest));
287
+ return symlink(target, dest);
288
+ }
289
+
290
+ /** Records a guard of the manifest the project has not installed. */
291
+ function noteAvailable(name) {
292
+ note(plan.available, name);
293
+ }
294
+
295
+ /** Records a .gitignore entry left out because the project tracks that path. */
296
+ function noteTrackedNotIgnored(entry) {
297
+ note(plan.trackedNotIgnored, entry);
298
+ }
299
+
300
+ /** Removes a file, symlink or directory tree. */
301
+ function remove(p) {
302
+ const dest = abs(p);
303
+ if (excluded(dest)) return 'excluded';
304
+ if (dryRun) overlay.set(dest, { kind: 'gone' });
305
+ else fs.rmSync(dest, { recursive: true, force: true });
306
+ note(plan.removed, rel(dest));
307
+ }
308
+
309
+ /** Moves `from` to `to` (both inside the project). */
310
+ function move(from, to) {
311
+ const src = abs(from);
312
+ const dest = abs(to);
313
+ if (excluded(src) || excluded(dest)) return 'excluded';
314
+ if (dryRun) {
315
+ overlay.set(dest, { kind: 'file', bytes: read(src) });
316
+ overlay.set(src, { kind: 'gone' });
317
+ } else {
318
+ ensureDir(path.dirname(dest));
319
+ fs.renameSync(src, dest);
320
+ }
321
+ plan.moved.push({ from: rel(src), to: rel(dest) });
322
+ }
323
+
324
+ /** Rewrites .gitignore with `content`, recording the entries it adds. */
325
+ function appendGitignore(p, content, entries) {
326
+ const dest = abs(p);
327
+ if (excluded(dest)) return 'excluded';
328
+ put(dest, Buffer.from(content, 'utf-8'));
329
+ for (const entry of entries) note(plan.gitignore, entry);
330
+ }
331
+
332
+ /** Sets a git config key in the repository at `cwd`. */
333
+ function gitConfig(cwd, key, value) {
334
+ plan.gitConfig.push({ key, value });
335
+ if (dryRun) return true;
336
+ const res = spawnSync('git', ['config', key, value], { cwd, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] });
337
+ return !res.error && res.status === 0;
338
+ }
339
+
340
+ /** Owned files whose current bytes are the kernel's (written or unchanged), for the registry. */
341
+ function managedEntries() {
342
+ return [...decisions.entries()]
343
+ .filter(([, d]) => d.status !== 'protected')
344
+ .map(([p, d]) => ({ path: p, sha256: d.sha }))
345
+ .sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
346
+ }
347
+
348
+ return {
349
+ root: projectRoot,
350
+ dryRun,
351
+ mode: sessionMode,
352
+ registry,
353
+ exists,
354
+ read,
355
+ mkdir,
356
+ writeFile,
357
+ writeManaged,
358
+ copyManaged,
359
+ symlink,
360
+ mirror,
361
+ noteAvailable,
362
+ noteTrackedNotIgnored,
363
+ remove,
364
+ move,
365
+ appendGitignore,
366
+ gitConfig,
367
+ managedEntries,
368
+ plan: () => plan,
369
+ };
370
+ }
371
+
372
+ // The active session. Install and update run synchronously, so a stack is
373
+ // enough to hand the session to the adapters without threading it through
374
+ // every provision() signature.
375
+ const stack = [];
376
+
377
+ /** Runs fn with `io` as the active session. */
378
+ function runWithIO(io, fn) {
379
+ stack.push(io);
380
+ try {
381
+ return fn();
382
+ } finally {
383
+ stack.pop();
384
+ }
385
+ }
386
+
387
+ /** The active session, or null. */
388
+ function activeIO() {
389
+ return stack.length > 0 ? stack[stack.length - 1] : null;
390
+ }
391
+
392
+ /** The active session, or a fresh real session for `root` when none is active. */
393
+ function currentIO(root) {
394
+ if (stack.length > 0) return stack[stack.length - 1];
395
+ return createIO({ root: root || process.cwd() });
396
+ }
397
+
398
+ module.exports = {
399
+ MANAGED_REGISTRY,
400
+ NEW_SUFFIX,
401
+ createIO,
402
+ runWithIO,
403
+ activeIO,
404
+ currentIO,
405
+ readRegistry,
406
+ registeredPaths,
407
+ loadKnownHashes,
408
+ sha256,
409
+ };