telo3-ai 5.0.7 → 6.2.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 (47) hide show
  1. package/README.md +32 -791
  2. package/SKILL.md +36 -657
  3. package/bootstrap/TELO3.md +17 -0
  4. package/cli.js +223 -42
  5. package/hooks/inject-contract.mjs +23 -0
  6. package/hooks/session-start.example.json +15 -0
  7. package/index.js +48 -14
  8. package/package.json +32 -69
  9. package/references/accessibility-quality.md +92 -272
  10. package/references/ai-behavior.md +70 -332
  11. package/references/anti-slop.md +71 -0
  12. package/references/document-specification.md +110 -419
  13. package/references/legal-compliance.md +90 -395
  14. package/references/performance-quality.md +100 -351
  15. package/references/privacy-compliance.md +88 -464
  16. package/references/project-audit.md +79 -440
  17. package/references/security-quality.md +68 -220
  18. package/references/seo-quality.md +83 -339
  19. package/references/smart-skill-delegation.md +73 -419
  20. package/references/token-optimization.md +83 -450
  21. package/references/ui-quality.md +106 -481
  22. package/references/workflow.md +91 -447
  23. package/scripts/postinstall.mjs +27 -0
  24. package/src/activate.js +159 -0
  25. package/src/context.js +72 -0
  26. package/src/find-context.js +41 -0
  27. package/src/init.js +38 -0
  28. package/src/install.js +128 -0
  29. package/src/paths.js +20 -0
  30. package/src/status.js +30 -0
  31. package/src/tokens.js +10 -0
  32. package/src/validate.js +220 -0
  33. package/src/version.js +12 -0
  34. package/templates/architecture.md +17 -398
  35. package/templates/design.md +22 -631
  36. package/templates/memory.md +13 -351
  37. package/templates/prd.md +15 -295
  38. package/templates/rules.md +20 -600
  39. package/templates/tasks.md +10 -210
  40. package/AI-CONTEXT.md +0 -272
  41. package/ONBOARDING-SKILL.md +0 -738
  42. package/SKILL-METADATA.json +0 -237
  43. package/scripts/init-project-context.sh +0 -139
  44. package/scripts/validate-project-context.sh +0 -164
  45. package/skills/telo3/SKILL.md +0 -315
  46. package/skills/telo3-context/SKILL.md +0 -272
  47. package/skills/telo3-onboarding/SKILL.md +0 -738
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Activation: install the always-on Telo3 contract into project instruction
3
+ * files so any AI auto-loads it every session — mention "telo3" once, stays on.
4
+ *
5
+ * Portable primary: AGENTS.md (auto-read by Cursor, Copilot, Codex, Gemini CLI,
6
+ * Windsurf, Aider, RooCode, ...). Tool-specific files are belt-and-suspenders.
7
+ * Idempotent, additive, marker-delimited; never clobbers unrelated content.
8
+ * @license MIT
9
+ */
10
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, rmSync, lstatSync, renameSync, mkdtempSync } from 'node:fs';
11
+ import { join, resolve, relative, dirname, isAbsolute } from 'node:path';
12
+ import { findContextDir } from './find-context.js';
13
+ import { assertSafePath } from './paths.js';
14
+ import { fileURLToPath } from 'node:url';
15
+ import { getVersion } from './version.js';
16
+
17
+ const SKILL_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
18
+ const BOOTSTRAP_PATH = join(SKILL_ROOT, 'bootstrap', 'TELO3.md');
19
+
20
+ const BEGIN_RE = /<!-- TELO3:BEGIN[\s\S]*?<!-- TELO3:END -->/g;
21
+
22
+ /** Shared instruction files: replace our marked block or append; keep everything else. */
23
+ const SHARED_TARGETS = [
24
+ 'AGENTS.md',
25
+ 'CLAUDE.md',
26
+ 'GEMINI.md',
27
+ join('.github', 'copilot-instructions.md'),
28
+ ];
29
+
30
+ /** Dedicated rule files: wholly Telo3-managed, with tool-specific frontmatter. */
31
+ const DEDICATED_TARGETS = {
32
+ [join('.cursor', 'rules', 'telo3.mdc')]: '---\ndescription: Telo3 always-on engineering contract\nalwaysApply: true\n---\n',
33
+ [join('.windsurf', 'rules', 'telo3.md')]: '---\ntrigger: always_on\n---\n',
34
+ [join('.clinerules', 'telo3.md')]: '',
35
+ };
36
+
37
+ const DEFAULT_EXTRA = [join('.cursor', 'rules', 'telo3.mdc')];
38
+ const ALL_EXTRA = Object.keys(DEDICATED_TARGETS);
39
+
40
+ export function renderContract(projectRoot, skillFile = join(SKILL_ROOT, 'SKILL.md')) {
41
+ const root = resolve(projectRoot);
42
+ if (!existsSync(skillFile) || !lstatSync(skillFile).isFile()) throw new Error('Skill entry is missing; install the skill first.');
43
+ const relSkill = relative(root, skillFile);
44
+ if (isAbsolute(relSkill)) throw new Error('Skill must be on the same drive; install it inside the project.');
45
+ const context = findContextDir(root) || join(root, 'project-context');
46
+ const contextPath = toPosix(relative(root, context)) || '.';
47
+ const raw = readFileSync(BOOTSTRAP_PATH, 'utf8');
48
+ const body = raw.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n/, '').trimEnd();
49
+ const skillPath = toPosix(relSkill) || 'SKILL.md';
50
+ const block = body.replace(/\{\{VERSION\}\}/g, getVersion()).replace(/\{\{SKILL_PATH\}\}/g, skillPath).replace(/\{\{CONTEXT_PATH\}\}/g, contextPath);
51
+ return { skillPath, contextPath, block };
52
+ }
53
+
54
+ function toPosix(p) {
55
+ return p.split('\\').join('/');
56
+ }
57
+
58
+ function updateInstruction(path, block, frontmatter = null) {
59
+ for (let attempt = 0; attempt < 3; attempt++) {
60
+ assertSafePath(dirname(path), path);
61
+ const existed = existsSync(path);
62
+ const current = existed ? readFileSync(path, 'utf8') : '';
63
+ const managed = BEGIN_RE.test(current);
64
+ BEGIN_RE.lastIndex = 0;
65
+ if (frontmatter !== null && existed && !managed) return 'skipped';
66
+ const next = managed ? current.replace(BEGIN_RE, block) : `${frontmatter || ''}${block}\n${current}`;
67
+ BEGIN_RE.lastIndex = 0;
68
+ if (existed && next === current) return 'unchanged';
69
+ mkdirSync(dirname(path), { recursive: true });
70
+ if (!existed) {
71
+ try { writeFileSync(path, next, { flag: 'wx' }); return 'created'; }
72
+ catch (error) { if (error.code === 'EEXIST') continue; throw error; }
73
+ }
74
+ const stage = mkdtempSync(join(dirname(path), '.telo3-write-'));
75
+ try {
76
+ const staged = join(stage, 'instructions');
77
+ writeFileSync(staged, next);
78
+ // If an editor changed the file while staging, rebuild from the new text.
79
+ if (readFileSync(path, 'utf8') !== current) continue;
80
+ assertSafePath(dirname(path), path);
81
+ renameSync(staged, path);
82
+ return 'updated';
83
+ } finally { rmSync(stage, { recursive: true, force: true }); }
84
+ }
85
+ throw new Error('Instructions changed concurrently; retry after other edits finish.');
86
+ }
87
+
88
+ function activateShared(path, block) { return updateInstruction(path, block); }
89
+ function activateDedicated(path, frontmatter, block) { return updateInstruction(path, block, frontmatter); }
90
+
91
+ export function activate(dir = '.', opts = {}) {
92
+ const projectRoot = resolve(dir);
93
+ const skillFile = opts.skillPath
94
+ ? resolve(opts.skillPath)
95
+ : join(SKILL_ROOT, 'SKILL.md');
96
+ const rendered = renderContract(projectRoot, skillFile);
97
+ const extras = opts.all ? ALL_EXTRA : DEFAULT_EXTRA;
98
+ const targets = [];
99
+ // Preflight every target before the first write, including linked parents.
100
+ for (const rel of [...SHARED_TARGETS, ...extras]) {
101
+ const path = assertSafePath(projectRoot, join(projectRoot, rel));
102
+ if (existsSync(path) && !lstatSync(path).isFile()) throw new Error('Instruction target must be a regular file.');
103
+ }
104
+ const bridge = rel => {
105
+ const link = toPosix(relative(dirname(join(projectRoot, rel)), join(projectRoot, 'AGENTS.md')));
106
+ return `<!-- TELO3:BEGIN v${getVersion()} -->\nApply the Telo3 block in project-root instructions: \`${link}\`. Read once per session/resume, not once per task.\n<!-- TELO3:END -->`;
107
+ };
108
+ for (const rel of SHARED_TARGETS) {
109
+ targets.push({ file: toPosix(rel), action: activateShared(join(projectRoot, rel), rel === 'AGENTS.md' ? rendered.block : bridge(rel)) });
110
+ }
111
+ for (const rel of extras) {
112
+ targets.push({ file: toPosix(rel), action: activateDedicated(join(projectRoot, rel), DEDICATED_TARGETS[rel], bridge(rel)) });
113
+ }
114
+ return { dir: projectRoot, ...rendered, targets };
115
+ }
116
+
117
+ export function deactivate(dir = '.') {
118
+ const projectRoot = resolve(dir);
119
+ const targets = [];
120
+
121
+ for (const rel of SHARED_TARGETS) {
122
+ const path = assertSafePath(projectRoot, join(projectRoot, rel));
123
+ if (!existsSync(path)) {
124
+ targets.push({ file: toPosix(rel), action: 'absent' });
125
+ continue;
126
+ }
127
+ const current = readFileSync(path, 'utf8');
128
+ if (!BEGIN_RE.test(current)) {
129
+ BEGIN_RE.lastIndex = 0;
130
+ targets.push({ file: toPosix(rel), action: 'absent' });
131
+ continue;
132
+ }
133
+ BEGIN_RE.lastIndex = 0;
134
+ const next = current.replace(/<!-- TELO3:BEGIN[^>]*-->[\s\S]*?<!-- TELO3:END -->\r?\n?/g, '');
135
+ writeFileSync(path, next);
136
+ targets.push({ file: toPosix(rel), action: 'removed' });
137
+ }
138
+
139
+ for (const rel of Object.keys(DEDICATED_TARGETS)) {
140
+ const path = assertSafePath(projectRoot, join(projectRoot, rel));
141
+ if (!existsSync(path)) {
142
+ targets.push({ file: toPosix(rel), action: 'absent' });
143
+ continue;
144
+ }
145
+ const current = readFileSync(path, 'utf8');
146
+ const managed = BEGIN_RE.test(current);
147
+ BEGIN_RE.lastIndex = 0;
148
+ if (!managed) {
149
+ targets.push({ file: toPosix(rel), action: 'skipped' });
150
+ continue;
151
+ }
152
+ const next = current.replace(/<!-- TELO3:BEGIN[^>]*-->[\s\S]*?<!-- TELO3:END -->\r?\n?/g, '');
153
+ if (next === '' || next === DEDICATED_TARGETS[rel]) rmSync(path);
154
+ else writeFileSync(path, next);
155
+ targets.push({ file: toPosix(rel), action: 'removed' });
156
+ }
157
+
158
+ return { dir: projectRoot, targets };
159
+ }
package/src/context.js ADDED
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Task-scoped context loading: read only what the task needs.
3
+ * memory.md is always first. Savings are measured, never claimed.
4
+ * @license MIT
5
+ */
6
+ import { existsSync, readFileSync } from 'node:fs';
7
+ import { join } from 'node:path';
8
+ import { fileURLToPath } from 'node:url';
9
+ import { CONTEXT_DOCS, findContextDir } from './find-context.js';
10
+ import { estimateTokens } from './tokens.js';
11
+ import { redactSecrets } from './validate.js';
12
+
13
+ const REFERENCE_DIR = new URL('../references/', import.meta.url);
14
+
15
+ const ALL_DOCS = ['memory.md', 'prd.md', 'architecture.md', 'rules.md', 'design.md', 'tasks.md'];
16
+
17
+ export const TASK_MAP = {
18
+ feature: { docs: ['memory.md', 'prd.md', 'architecture.md', 'rules.md'], refs: [] },
19
+ bugfix: { docs: ['memory.md', 'architecture.md', 'rules.md'], refs: [] },
20
+ design: { docs: ['memory.md', 'design.md', 'rules.md'], refs: ['ui-quality.md'] },
21
+ security: { docs: ['memory.md', 'architecture.md', 'rules.md'], refs: ['security-quality.md', 'privacy-compliance.md'] },
22
+ privacy: { docs: ['memory.md', 'architecture.md'], refs: ['privacy-compliance.md', 'legal-compliance.md'] },
23
+ legal: { docs: ['memory.md', 'prd.md'], refs: ['legal-compliance.md'] },
24
+ accessibility: { docs: ['memory.md', 'design.md'], refs: ['accessibility-quality.md'] },
25
+ seo: { docs: ['memory.md', 'design.md'], refs: ['seo-quality.md', 'performance-quality.md'] },
26
+ performance: { docs: ['memory.md', 'architecture.md'], refs: ['performance-quality.md'] },
27
+ docs: { docs: ['memory.md'], refs: ['document-specification.md'] },
28
+ audit: { docs: [...ALL_DOCS], refs: ['project-audit.md'] },
29
+ all: { docs: [...ALL_DOCS], refs: [] },
30
+ };
31
+
32
+ function readIfPresent(path) {
33
+ return existsSync(path) ? redactSecrets(readFileSync(path, 'utf8')) : null;
34
+ }
35
+
36
+ export function loadContext(dir, task = 'default') {
37
+ const contextDir = findContextDir(dir);
38
+ if (!contextDir) {
39
+ return { dir: null, docs: [], tokens: 0, totalTokens: 0, savingsPct: 0, referenceTokens: 0, contextTokens: 0, missing: ['project-context'] };
40
+ }
41
+
42
+ const entry = TASK_MAP[task] || { docs: ['memory.md'], refs: [] };
43
+ const docs = [];
44
+ const missing = [];
45
+
46
+ for (const name of entry.docs) {
47
+ const content = readIfPresent(join(contextDir, name));
48
+ if (content === null) { missing.push(name); continue; }
49
+ docs.push({ name, source: 'context', content, tokens: estimateTokens(content) });
50
+ }
51
+ for (const name of entry.refs) {
52
+ const content = readIfPresent(fileURLToPath(new URL(name, REFERENCE_DIR)));
53
+ if (content === null) { missing.push(`references/${name}`); continue; }
54
+ docs.push({ name, source: 'reference', content, tokens: estimateTokens(content) });
55
+ }
56
+
57
+ let totalTokens = 0;
58
+ for (const name of CONTEXT_DOCS) {
59
+ const content = readIfPresent(join(contextDir, name));
60
+ if (content !== null) totalTokens += estimateTokens(content);
61
+ }
62
+
63
+ const referenceTokens = docs.filter(d => d.source === 'reference').reduce((sum, d) => sum + d.tokens, 0);
64
+ const tokens = docs.reduce((sum, d) => sum + d.tokens, 0);
65
+ const contextTokens = tokens - referenceTokens;
66
+ // Compare selective docs + required refs with ALL docs + the SAME refs.
67
+ // This is an estimated loading reduction, not observed API token usage.
68
+ totalTokens += referenceTokens;
69
+ const savingsPct = totalTokens > 0 ? Math.round(((totalTokens - tokens) / totalTokens) * 100) : 0;
70
+
71
+ return { dir: contextDir, docs, tokens, totalTokens, savingsPct, referenceTokens, contextTokens, missing };
72
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Locate the project-context directory.
3
+ * Accepted locations: <root>/project-context, <root>/docs/project-context,
4
+ * or the six documents directly at <root>.
5
+ * @license MIT
6
+ */
7
+ import { existsSync, statSync } from 'node:fs';
8
+ import { join, resolve, dirname } from 'node:path';
9
+
10
+ export const CONTEXT_DOCS = [
11
+ 'prd.md',
12
+ 'architecture.md',
13
+ 'rules.md',
14
+ 'design.md',
15
+ 'tasks.md',
16
+ 'memory.md',
17
+ ];
18
+
19
+ export function findContextDir(dir) {
20
+ let root = resolve(dir);
21
+ // Prefer the most complete conventional context at the nearest project level.
22
+ // Ascend only within the repository; never scan recursively or load personal stores.
23
+ for (let level = 0; level < 12; level++) {
24
+ const candidates = [join(root, 'project-context'), join(root, 'docs', 'project-context'), root];
25
+ let best = null;
26
+ let count = 0;
27
+ for (const candidate of candidates) {
28
+ if (!existsSync(candidate) || !statSync(candidate).isDirectory()) continue;
29
+ const score = CONTEXT_DOCS.filter(doc => {
30
+ const file = join(candidate, doc);
31
+ return existsSync(file) && statSync(file).isFile();
32
+ }).length;
33
+ if (score > count) { best = candidate; count = score; }
34
+ }
35
+ if (best) return best;
36
+ const parent = dirname(root);
37
+ if (parent === root || existsSync(join(root, '.git'))) return null;
38
+ root = parent;
39
+ }
40
+ return null;
41
+ }
package/src/init.js ADDED
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Initialize the six context documents from lean templates.
3
+ * Never overwrites existing files.
4
+ * @license MIT
5
+ */
6
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
7
+ import { join, resolve } from 'node:path';
8
+ import { fileURLToPath } from 'node:url';
9
+ import { CONTEXT_DOCS } from './find-context.js';
10
+ import { assertSafePath } from './paths.js';
11
+
12
+ const TEMPLATE_DIR = new URL('../templates/', import.meta.url);
13
+
14
+ export function initContext(dir, opts = {}) {
15
+ const root = resolve(dir);
16
+ const contextDir = opts.contextDir ?? 'project-context';
17
+ const target = assertSafePath(root, contextDir === '.' ? root : resolve(root, contextDir));
18
+ mkdirSync(target, { recursive: true });
19
+
20
+ const created = [];
21
+ const skipped = [];
22
+ for (const doc of CONTEXT_DOCS) {
23
+ const dest = assertSafePath(root, join(target, doc));
24
+ if (existsSync(dest)) {
25
+ skipped.push(doc);
26
+ continue;
27
+ }
28
+ const template = readFileSync(fileURLToPath(new URL(doc, TEMPLATE_DIR)), 'utf8');
29
+ try {
30
+ writeFileSync(dest, template, { flag: 'wx' });
31
+ created.push(doc);
32
+ } catch (error) {
33
+ if (error.code === 'EEXIST') skipped.push(doc);
34
+ else throw error;
35
+ }
36
+ }
37
+ return { dir: target, created, skipped };
38
+ }
package/src/install.js ADDED
@@ -0,0 +1,128 @@
1
+ import { existsSync, lstatSync, readFileSync, readdirSync, mkdirSync, writeFileSync, renameSync, rmSync, mkdtempSync, cpSync } from 'node:fs';
2
+ import { join, resolve, dirname } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { createHash } from 'node:crypto';
5
+ import { assertSafePath } from './paths.js';
6
+
7
+ const SOURCE = resolve(dirname(fileURLToPath(import.meta.url)), '..');
8
+ const MARKER = '.telo3-install.json';
9
+ export const AGENT_DIRS = {
10
+ agents: '.agents/skills/telo3',
11
+ codex: '.agents/skills/telo3',
12
+ qoder: '.qoder/skills/telo3',
13
+ claude: '.claude/skills/telo3',
14
+ cursor: '.cursor/skills/telo3',
15
+ gemini: '.gemini/skills/telo3',
16
+ copilot: '.github/skills/telo3',
17
+ };
18
+ const FILES = ['SKILL.md', 'LICENSE', 'cli.js', 'index.js'];
19
+ const DIRS = ['bootstrap', 'src', 'templates', 'references', 'hooks'];
20
+ const hash = bytes => createHash('sha256').update(bytes).digest('hex');
21
+
22
+ function collect(source, rel, output) {
23
+ const path = join(source, rel);
24
+ const stat = lstatSync(path);
25
+ if (stat.isSymbolicLink()) throw new Error('Skill payload must not contain symlinks.');
26
+ if (stat.isDirectory()) {
27
+ for (const child of readdirSync(path)) collect(source, `${rel ? `${rel}/` : ''}${child}`, output);
28
+ } else if (stat.isFile()) output[rel] = readFileSync(path);
29
+ }
30
+
31
+ function detectAgent(root) {
32
+ for (const agent of ['qoder', 'claude', 'gemini', 'cursor']) {
33
+ if (existsSync(join(root, `.${agent}`))) return agent;
34
+ }
35
+ return 'agents';
36
+ }
37
+
38
+ /** Install a self-contained project skill, never dependent on the npx cache. */
39
+ export function installSkill(dir = '.', opts = {}) {
40
+ const root = resolve(dir);
41
+ if (!existsSync(root) || !lstatSync(root).isDirectory()) throw new Error('Project directory must exist.');
42
+ const agent = !opts.agent || opts.agent === 'auto' ? detectAgent(root) : opts.agent;
43
+ if (!Object.hasOwn(AGENT_DIRS, agent)) throw new Error(`Unknown agent. Choose auto, ${Object.keys(AGENT_DIRS).join(', ')}.`);
44
+ const target = assertSafePath(root, join(root, AGENT_DIRS[agent]));
45
+ // A manual/ZIP install can use its own runtime without copying over itself
46
+ // or inventing an ownership record. External installers still refuse unmanaged copies.
47
+ if (target === SOURCE) return { dir: target, agent, action: 'unchanged', skillPath: join(target, 'SKILL.md') };
48
+ const payload = {};
49
+ for (const rel of [...FILES, ...DIRS]) collect(SOURCE, rel, payload);
50
+ const pkg = JSON.parse(readFileSync(join(SOURCE, 'package.json'), 'utf8'));
51
+ // Installed skills need runtime metadata, not publishing scripts or marketing.
52
+ payload['package.json'] = Buffer.from(`${JSON.stringify({ name: pkg.name, version: pkg.version, type: 'module', main: 'index.js', bin: { telo3: './cli.js' }, engines: pkg.engines, license: pkg.license }, null, 2)}\n`);
53
+ const hashes = Object.fromEntries(Object.entries(payload).map(([path, bytes]) => [path, hash(bytes)]));
54
+ const marker = Buffer.from(`${JSON.stringify({ schema: 1, name: 'telo3', version: pkg.version, files: hashes }, null, 2)}\n`);
55
+
56
+ let owned = null;
57
+ if (existsSync(target)) {
58
+ const manifest = assertSafePath(root, join(target, MARKER));
59
+ if (!existsSync(manifest)) throw new Error('Unmanaged/user-owned skill directory; refusing to overwrite it.');
60
+ try { owned = JSON.parse(readFileSync(manifest, 'utf8')); } catch { throw new Error('Invalid install ownership record; refusing to overwrite.'); }
61
+ if (owned.schema !== 1 || owned.name !== 'telo3' || !owned.files) throw new Error('Unmanaged skill ownership record.');
62
+ for (const [rel, expected] of Object.entries(owned.files)) {
63
+ const path = assertSafePath(target, join(target, rel));
64
+ if (!existsSync(path) || !lstatSync(path).isFile() || hash(readFileSync(path)) !== expected) {
65
+ throw new Error(`Installed skill was modified (${rel}); preserve your customizations before updating.`);
66
+ }
67
+ }
68
+ if (Object.entries(hashes).every(([rel, expected]) => owned.files[rel] === expected) && Object.keys(owned.files).length === Object.keys(hashes).length) {
69
+ return { dir: target, agent, action: 'unchanged', skillPath: join(target, 'SKILL.md') };
70
+ }
71
+ }
72
+
73
+ const parent = dirname(target);
74
+ mkdirSync(parent, { recursive: true });
75
+ const lock = `${target}.install-lock`;
76
+ assertSafePath(root, lock);
77
+ try { mkdirSync(lock); } catch (error) {
78
+ if (error.code === 'EEXIST') throw new Error('Another skill installation is active; no files changed.');
79
+ throw error;
80
+ }
81
+ let stage;
82
+ let backup;
83
+ try {
84
+ stage = mkdtempSync(join(parent, '.telo3-stage-'));
85
+ let existing = null;
86
+ if (owned) {
87
+ existing = {};
88
+ collect(target, '', existing); // Reject links and preserve every user-owned extra file.
89
+ for (const [rel, expected] of Object.entries(owned.files)) {
90
+ if (!existing[rel] || hash(existing[rel]) !== expected) throw new Error('Installed skill changed during update.');
91
+ }
92
+ for (const rel of Object.keys(payload)) {
93
+ if (existing[rel] && !Object.hasOwn(owned.files, rel)) throw new Error(`User file conflicts with update: ${rel}`);
94
+ }
95
+ cpSync(target, stage, { recursive: true });
96
+ // Remove only obsolete, unchanged files that this installer owned.
97
+ for (const rel of Object.keys(owned.files)) {
98
+ if (!Object.hasOwn(payload, rel)) rmSync(join(stage, rel));
99
+ }
100
+ }
101
+ for (const [rel, bytes] of Object.entries({ ...payload, [MARKER]: marker })) {
102
+ const dest = join(stage, rel);
103
+ mkdirSync(dirname(dest), { recursive: true });
104
+ writeFileSync(dest, bytes);
105
+ }
106
+ if (owned) {
107
+ const latest = {};
108
+ collect(target, '', latest);
109
+ if (Object.keys(latest).length !== Object.keys(existing).length || Object.keys(existing).some(rel => !latest[rel] || hash(latest[rel]) !== hash(existing[rel]))) {
110
+ throw new Error('Skill files changed while staging; no update committed.');
111
+ }
112
+ backup = `${stage}.previous`;
113
+ renameSync(target, backup);
114
+ } else if (existsSync(target)) throw new Error('Destination appeared during installation; no files overwritten.');
115
+ try {
116
+ renameSync(stage, target);
117
+ } catch (error) {
118
+ if (backup) { renameSync(backup, target); backup = null; }
119
+ throw error;
120
+ }
121
+ if (backup) { rmSync(backup, { recursive: true, force: true }); backup = null; }
122
+ return { dir: target, agent, action: owned ? 'updated' : 'created', skillPath: join(target, 'SKILL.md') };
123
+ } finally {
124
+ if (stage) rmSync(stage, { recursive: true, force: true });
125
+ // If rollback itself fails, keep the backup intact for recovery.
126
+ rmSync(lock, { recursive: true, force: true });
127
+ }
128
+ }
package/src/paths.js ADDED
@@ -0,0 +1,20 @@
1
+ import { lstatSync } from 'node:fs';
2
+ import { resolve, relative, isAbsolute, sep, join } from 'node:path';
3
+
4
+ /** Reject traversal and linked ancestors before a write can escape its project. */
5
+ export function assertSafePath(root, target) {
6
+ const base = resolve(root);
7
+ const rel = relative(base, resolve(target));
8
+ if (isAbsolute(rel) || rel === '..' || rel.startsWith(`..${sep}`)) {
9
+ throw new Error('Destination must stay inside the project.');
10
+ }
11
+ let current = base;
12
+ for (const part of ['', ...rel.split(sep).filter(Boolean)]) {
13
+ current = part ? join(current, part) : current;
14
+ // existsSync follows links and misses dangling ones; lstat does not.
15
+ let stat;
16
+ try { stat = lstatSync(current); } catch (error) { if (error.code !== 'ENOENT') throw error; }
17
+ if (stat?.isSymbolicLink()) throw new Error('Refusing a symlink/junction in the destination path.');
18
+ }
19
+ return resolve(target);
20
+ }
package/src/status.js ADDED
@@ -0,0 +1,30 @@
1
+ /**
2
+ * One-line health summary per document, built on the real validator.
3
+ * @license MIT
4
+ */
5
+ import { statSync } from 'node:fs';
6
+ import { join } from 'node:path';
7
+ import { validateContext } from './validate.js';
8
+
9
+ export function getStatus(dir) {
10
+ const result = validateContext(dir);
11
+ if (!result.dir) {
12
+ return { dir: null, valid: false, errors: result.errors.length, warnings: result.warnings.length, docs: [] };
13
+ }
14
+ return {
15
+ dir: result.dir,
16
+ valid: result.valid,
17
+ errors: result.errors.length,
18
+ warnings: result.warnings.length,
19
+ docs: result.files.map((f) => ({
20
+ file: f.file,
21
+ exists: f.exists,
22
+ sections: f.sections,
23
+ missingSections: f.missingSections,
24
+ placeholders: f.placeholders,
25
+ secrets: f.secrets.length,
26
+ staleDays: f.stale,
27
+ lastUpdated: f.exists ? statSync(join(result.dir, f.file)).mtime.toISOString().slice(0, 10) : null,
28
+ })),
29
+ };
30
+ }
package/src/tokens.js ADDED
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Token estimation — deliberately simple and honest (~4 chars/token).
3
+ * Used for measured per-project savings, never marketing claims.
4
+ * @license MIT
5
+ */
6
+
7
+ export function estimateTokens(text) {
8
+ if (!text) return 0;
9
+ return Math.ceil(text.length / 4);
10
+ }