@joenandez/academy 0.4.0 → 0.5.12

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 (121) hide show
  1. package/.claude-plugin/marketplace.json +5 -0
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +390 -0
  4. package/README.md +67 -122
  5. package/hooks/backstory/.claude-plugin/plugin.json +11 -0
  6. package/hooks/backstory/SOURCE +2 -0
  7. package/hooks/backstory/bin/backstory +9 -0
  8. package/hooks/backstory/hooks/hooks.json +45 -0
  9. package/hooks/backstory/scripts/knowledge.mjs +87 -0
  10. package/hooks/backstory/scripts/memory.mjs +685 -0
  11. package/hooks/backstory/scripts/tool-tick.mjs +1 -0
  12. package/hooks/backstory/skills/capture/SKILL.md +71 -0
  13. package/hooks/backstory/skills/capture/references/knowledge-capture-input.json +16 -0
  14. package/hooks/backstory/skills/capture/references/tagging-policy.md +29 -0
  15. package/hooks/backstory/skills/memory/SKILL.md +70 -0
  16. package/hooks/backstory/skills/setup/SKILL.md +85 -0
  17. package/hooks/context_metrics.mjs +67 -0
  18. package/hooks/hook_runtime.mjs +43 -2
  19. package/hooks/memory_bridge.mjs +286 -52
  20. package/hooks/memory_store.mjs +78 -26
  21. package/hooks/register_session.mjs +76 -14
  22. package/hooks/roque/.claude-plugin/plugin.json +6 -0
  23. package/hooks/roque/LICENSE +21 -0
  24. package/hooks/roque/SOURCE +1 -0
  25. package/hooks/roque/hooks.json +38 -0
  26. package/hooks/roque/package.json +3 -0
  27. package/hooks/roque/roque.js +1690 -0
  28. package/hooks/sync_memory.mjs +11 -7
  29. package/package.json +7 -3
  30. package/scripts/cli/agent-gate.mjs +70 -0
  31. package/scripts/cli/agent-id.mjs +37 -0
  32. package/scripts/cli/archive.mjs +39 -38
  33. package/scripts/cli/archived.mjs +4 -2
  34. package/scripts/cli/args-kinds.mjs +33 -0
  35. package/scripts/cli/args-notes.mjs +42 -0
  36. package/scripts/cli/args-owner.mjs +37 -0
  37. package/scripts/cli/args-run.mjs +38 -0
  38. package/scripts/cli/args.mjs +47 -104
  39. package/scripts/cli/beads.mjs +57 -0
  40. package/scripts/cli/codex.mjs +66 -12
  41. package/scripts/cli/core.mjs +117 -41
  42. package/scripts/cli/create.mjs +224 -76
  43. package/scripts/cli/curator-feed-codex.mjs +81 -0
  44. package/scripts/cli/curator-feed-observations.mjs +97 -0
  45. package/scripts/cli/curator-feed-scope.mjs +124 -0
  46. package/scripts/cli/curator-feed-sources.mjs +172 -0
  47. package/scripts/cli/curator-feed.mjs +318 -0
  48. package/scripts/cli/default-name.mjs +73 -0
  49. package/scripts/cli/doctor.mjs +67 -2
  50. package/scripts/cli/eventlog.mjs +6 -2
  51. package/scripts/cli/exit-code.mjs +9 -0
  52. package/scripts/cli/first-run.mjs +115 -0
  53. package/scripts/cli/heartbeat.mjs +135 -0
  54. package/scripts/cli/helm-skip.mjs +20 -0
  55. package/scripts/cli/hire-headless.mjs +37 -22
  56. package/scripts/cli/hire.mjs +8 -2
  57. package/scripts/cli/inspect.mjs +36 -18
  58. package/scripts/cli/lifecycle.mjs +95 -49
  59. package/scripts/cli/main.mjs +32 -2
  60. package/scripts/cli/memory-env.mjs +124 -0
  61. package/scripts/cli/migrate.mjs +13 -7
  62. package/scripts/cli/next-steps.mjs +13 -0
  63. package/scripts/cli/nightly-launch.mjs +118 -0
  64. package/scripts/cli/nightly.mjs +228 -0
  65. package/scripts/cli/notes.mjs +58 -8
  66. package/scripts/cli/owner-config.mjs +181 -0
  67. package/scripts/cli/owner-jobs.mjs +201 -0
  68. package/scripts/cli/prompt-append.mjs +135 -0
  69. package/scripts/cli/rename.mjs +90 -109
  70. package/scripts/cli/run-context.mjs +50 -0
  71. package/scripts/cli/run.mjs +236 -79
  72. package/scripts/cli/scaffold.mjs +119 -126
  73. package/scripts/cli/schedule-errors.mjs +70 -0
  74. package/scripts/cli/sessions.mjs +10 -3
  75. package/scripts/cli/skills-check.mjs +147 -0
  76. package/scripts/cli/skills.mjs +170 -0
  77. package/scripts/cli/start.mjs +201 -0
  78. package/scripts/cli/templates.mjs +119 -45
  79. package/scripts/cli/tldr.mjs +184 -0
  80. package/scripts/cli/toml-string.mjs +76 -0
  81. package/scripts/cli/user-work-view.mjs +48 -0
  82. package/scripts/cli/writable-dirs.mjs +23 -0
  83. package/scripts/cli/yaml.mjs +1 -1
  84. package/skills/hire/SKILL.md +112 -100
  85. package/skills/hire/references/owner.md +68 -0
  86. package/templates/agents/claude-code/knowledge-curator.md +5 -2
  87. package/templates/agents/codex/knowledge-curator.toml +2 -1
  88. package/templates/operating-model/base.md +78 -0
  89. package/templates/operating-model/owner.md +11 -0
  90. package/templates/operating-model/principal.md +9 -0
  91. package/templates/operating-model/specialist.md +9 -0
  92. package/templates/packs/owner/craft.md +32 -0
  93. package/templates/packs/owner/identity.md +13 -0
  94. package/templates/packs/owner/mandate.md +26 -0
  95. package/templates/packs/owner/owner/SKILL.md +118 -0
  96. package/templates/packs/owner/owner-feedback/SKILL.md +49 -0
  97. package/templates/packs/owner/owner-heartbeat/SKILL.md +35 -0
  98. package/templates/packs/owner/owner-nightly/SKILL.md +114 -0
  99. package/templates/packs/owner/owner-review/SKILL.md +70 -0
  100. package/templates/packs/owner/owner-work/SKILL.md +53 -0
  101. package/templates/packs/principal/craft.md +27 -0
  102. package/templates/packs/principal/identity.md +13 -0
  103. package/templates/packs/principal/mandate.md +23 -0
  104. package/templates/packs/principal/principal-brief/SKILL.md +44 -0
  105. package/templates/packs/principal/principal-intro/SKILL.md +95 -0
  106. package/templates/packs/principal/principal-nightly/SKILL.md +152 -0
  107. package/templates/skills/check-in/SKILL.md +34 -24
  108. package/templates/skills/knowledge-curation/SKILL.md +89 -48
  109. package/templates/skills/local-rules/SKILL.md +86 -0
  110. package/templates/skills/nightly-consolidation/SKILL.md +131 -100
  111. package/templates/skills/skill-method/SKILL.md +79 -0
  112. package/templates/skills/user-nightly/SKILL.md +143 -0
  113. package/templates/skills/work-graph/SKILL.md +75 -0
  114. package/conformance/README.md +0 -60
  115. package/conformance/discovery.test.mjs +0 -140
  116. package/conformance/envelope.test.mjs +0 -185
  117. package/conformance/error-codes.test.mjs +0 -125
  118. package/conformance/harness.mjs +0 -180
  119. package/conformance/identity.test.mjs +0 -125
  120. package/docs/integration-guide.md +0 -1042
  121. package/templates/skills/self-update/SKILL.md +0 -121
@@ -1,53 +1,82 @@
1
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
- import { dirname, join, resolve } from 'node:path';
1
+ import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
2
+ import { basename, dirname, join, resolve } from 'node:path';
3
3
  import {
4
4
  ACADEMY_ROOT,
5
5
  ACADEMY_SYSTEM_PROMPT,
6
- SURFACES,
7
- UNIVERSAL_SKILLS,
6
+ KIND_PROFILES,
8
7
  ensureAcademyGitignore,
9
8
  ensureSymlink,
10
- memoryBridgeEnabled,
9
+ kindOf,
11
10
  projectPluginDir,
11
+ surfacesFor,
12
12
  } from './core.mjs';
13
+ import { ownerYamlBlock } from './owner-config.mjs';
14
+ import { renderPackageSkills, renderTemplate, universalSkillValues } from './skills.mjs';
13
15
  import { TEMPLATES } from './templates.mjs';
14
- import {
15
- MEMORY_BRIDGE_PERMISSIONS,
16
- MEMORY_BRIDGE_SKILL_GUIDANCE,
17
- } from '../../hooks/memory_bridge.mjs';
18
-
19
- export function scaffoldBootFiles(dir, name) {
20
- const today = new Date().toISOString().slice(0, 10);
21
- for (const surface of SURFACES) {
22
- const filename = `${surface}.md`;
23
- const path = join(dir, filename);
24
- if (existsSync(path)) continue;
25
- writeFileSync(path, TEMPLATES[filename](name, today));
16
+ import { filterUserWork } from './user-work-view.mjs';
17
+ import { tokenRecord } from '../../hooks/context_metrics.mjs';
18
+ import { MEMORY_BRIDGE_PERMISSIONS } from '../../hooks/memory_bridge.mjs';
19
+
20
+ export function scaffoldBootFiles(dir, name, today = new Date().toISOString().slice(0, 10)) {
21
+ // Only surfaces with a scaffold template are written, and never over an
22
+ // existing file: the shared ACADEMY_HOME surfaces belong to the user once
23
+ // they exist. The `wx` flag closes the gap between the check and the write,
24
+ // so a racing create or `academy notes add --user` is never overwritten.
25
+ const kind = kindOf(dir);
26
+ for (const { name: surface, path } of surfacesFor(dir)) {
27
+ const template = bootTemplate(kind, surface);
28
+ if (!template || existsSync(path)) continue;
29
+ mkdirSync(dirname(path), { recursive: true });
30
+ writeSeedOnce(path, template(name, today));
26
31
  }
27
32
  return today;
28
33
  }
29
34
 
30
- export function writeAgentYaml(dir, name, today) {
35
+ function writeSeedOnce(path, text) {
36
+ try {
37
+ writeFileSync(path, text, { flag: 'wx' });
38
+ } catch (error) {
39
+ if (error.code !== 'EEXIST') throw error;
40
+ }
41
+ }
42
+
43
+ // Agents created before local rules get the empty file on their next run. An
44
+ // existing file holds the user's rules and is never rewritten.
45
+ export function scaffoldLocalRules(dir, name) {
46
+ const path = join(dir, 'local-rules.md');
47
+ if (existsSync(path)) return;
48
+ writeFileSync(path, TEMPLATES['local-rules.md'](name, new Date().toISOString().slice(0, 10)));
49
+ }
50
+
51
+ // A kind's pack may supply a surface's first content (a Principal's craft.md).
52
+ // It is a seed: written at create, then the agent's own, never re-rendered.
53
+ function bootTemplate(kind, surface) {
54
+ const packTemplate = join(ACADEMY_ROOT, 'templates', 'packs', kind, `${surface}.md`);
55
+ if (!existsSync(packTemplate)) return TEMPLATES[`${surface}.md`];
56
+ const template = readFileSync(packTemplate, 'utf8');
57
+ return (name, today) => renderTemplate(template, { agent_name: name, created: today });
58
+ }
59
+
60
+ export function writeAgentYaml(dir, name, today, kind = 'specialist') {
61
+ const { surfaces } = KIND_PROFILES[kind];
62
+ const totalCap = surfaces.reduce((sum, { cap }) => sum + cap, 0).toLocaleString('en-US');
63
+ const surfaceList = surfaces.map(({ name: surface }) => ` - ${surface}.md`).join('\n');
31
64
  const yaml = `# Agent metadata. Hand-edit role and objective once the hire flow runs.
32
65
  name: ${name}
33
66
  created: ${today}
34
67
  # Launch runtime — claude_code or codex. \`academy run --agent\` rewrites it.
35
68
  runtime: claude_code
36
- role: ""
69
+ # Agent kind — specialist, principal or owner. Selects its surfaces and skills.
70
+ kind: ${kind}
71
+ ${kind === 'owner' ? ownerYamlBlock() : ''}role: ""
37
72
  objective: ""
38
73
 
39
- # Boot context — 8 surfaces, ~5–6k tokens combined (see scope §3).
40
- # Files live alongside this yaml; academy run compiles them into the generated
41
- # .academy/generated/${ACADEMY_SYSTEM_PROMPT} before launching an agent runtime.
74
+ # Boot context — the ${kind} surfaces, capped at ${totalCap} estimated tokens combined.
75
+ # Agent files live alongside this yaml, shared ones under ACADEMY_HOME; academy run
76
+ # compiles them into the generated .academy/generated/${ACADEMY_SYSTEM_PROMPT}
77
+ # before launching an agent runtime.
42
78
  surfaces:
43
- - identity.md
44
- - role.md
45
- - knowledge.md
46
- - goals.md
47
- - priorities.md
48
- - threads.md
49
- - notes.md
50
- - dailys.md
79
+ ${surfaceList}
51
80
  `;
52
81
  writeFileSync(join(dir, 'agent.yaml'), yaml);
53
82
  }
@@ -57,10 +86,9 @@ export function writeAgentClaudeMd(dir, name) {
57
86
  // user instructions channel can grow over time but starts effectively empty.
58
87
  const md = `# ${name}
59
88
 
60
- User instructions for this agent. The 8 boot surfaces (\`identity.md\`,
61
- \`role.md\`, \`knowledge.md\`, \`goals.md\`, \`priorities.md\`, \`threads.md\`,
62
- \`notes.md\`, \`dailys.md\`) are compiled into \`.academy/generated/${ACADEMY_SYSTEM_PROMPT}\`
63
- when \`academy run ${name}\` launches an agent runtime.
89
+ User instructions for this agent. The boot surfaces listed in \`agent.yaml\` are
90
+ compiled into \`.academy/generated/${ACADEMY_SYSTEM_PROMPT}\` when
91
+ \`academy run ${name}\` launches an agent runtime.
64
92
 
65
93
  Add user-driven instructions below as they come up.
66
94
  `;
@@ -129,7 +157,7 @@ export function writeProjectCodexSkillBridge(projectDir, dir) {
129
157
  const projectSkillsDir = join(projectDir, '.agents', 'skills');
130
158
  mkdirSync(projectSkillsDir, { recursive: true });
131
159
 
132
- for (const skillName of UNIVERSAL_SKILLS) {
160
+ for (const skillName of KIND_PROFILES[kindOf(dir)].skills) {
133
161
  const source = join(agentSkillsDir, skillName);
134
162
  const target = join(projectSkillsDir, skillName);
135
163
  if (!existsSync(source) || existsSync(target)) continue;
@@ -137,68 +165,17 @@ export function writeProjectCodexSkillBridge(projectDir, dir) {
137
165
  }
138
166
  }
139
167
 
140
- function renderTemplate(template, values) {
141
- const rendered = template.replace(/\{\{([a-z_]+)\}\}/g, (match, key) => {
142
- if (!(key in values)) throw new Error(`Unknown template variable: ${match}`);
143
- return values[key];
144
- });
145
- // An optional section that renders empty must not leave a hole in a surface
146
- // Academy charges a token budget for.
147
- return rendered.replace(/\n{3,}/g, '\n\n');
148
- }
149
-
150
- function universalSkillValues(dir, name, skillsDir) {
151
- const dreamsDir = join(dir, 'dreams');
152
- return {
153
- agent_name: name,
154
- agent_dir: dir,
155
- identity_path: join(dir, 'identity.md'),
156
- role_path: join(dir, 'role.md'),
157
- knowledge_path: join(dir, 'knowledge.md'),
158
- goals_path: join(dir, 'goals.md'),
159
- priorities_path: join(dir, 'priorities.md'),
160
- threads_path: join(dir, 'threads.md'),
161
- notes_path: join(dir, 'notes.md'),
162
- dailys_path: join(dir, 'dailys.md'),
163
- memory_observations_path: join(dir, 'memory', 'observations'),
164
- memory_bridge_guidance: memoryBridgeEnabled() ? MEMORY_BRIDGE_SKILL_GUIDANCE : '',
165
- dreams_dir: dreamsDir,
166
- skills_surface: skillsDir.endsWith(join('.agents', 'skills'))
167
- ? '.agents/skills'
168
- : '.claude/skills',
169
- skills_dir: skillsDir,
170
- check_in_path: join(skillsDir, 'check-in', 'SKILL.md'),
171
- self_update_path: join(skillsDir, 'self-update', 'SKILL.md'),
172
- nightly_consolidation_path: join(skillsDir, 'nightly-consolidation', 'SKILL.md'),
173
- knowledge_curation_path: join(skillsDir, 'knowledge-curation', 'SKILL.md'),
174
- };
175
- }
176
-
177
- function writeUniversalSkill(dir, name, skillName, skillsDir) {
178
- const skillDir = join(skillsDir, skillName);
179
- const templatePath = join(ACADEMY_ROOT, 'templates', 'skills', skillName, 'SKILL.md');
180
- const template = readFileSync(templatePath, 'utf8');
181
-
182
- mkdirSync(skillDir, { recursive: true });
183
- writeFileSync(
184
- join(skillDir, 'SKILL.md'),
185
- renderTemplate(template, universalSkillValues(dir, name, skillsDir)),
186
- );
187
- }
188
-
189
168
  export function writeSkillsScaffold(dir, name) {
190
- // Universal Academy skills are copied into each agent with agent-specific
191
- // paths. Agent/domain skills can be added later by hire or self-update.
169
+ // The kind's package skills are rendered into each agent with agent-specific
170
+ // paths. Agent/domain skills can be added later by hire or the skill-method skill.
192
171
  writeDreamsDir(dir);
193
172
  writeMemoryScaffold(dir);
194
- for (const skillsDir of [join(dir, '.claude', 'skills'), join(dir, '.agents', 'skills')]) {
195
- mkdirSync(skillsDir, { recursive: true });
196
- for (const skillName of UNIVERSAL_SKILLS) writeUniversalSkill(dir, name, skillName, skillsDir);
197
- }
198
- writeKnowledgeCuratorAgents(dir, name);
173
+ const kind = kindOf(dir);
174
+ renderPackageSkills(dir, name, { kind, skills: KIND_PROFILES[kind].skills });
175
+ writeKnowledgeCuratorAgents(dir, name, kind);
199
176
  }
200
177
 
201
- function writeKnowledgeCuratorAgents(dir, name) {
178
+ function writeKnowledgeCuratorAgents(dir, name, kind) {
202
179
  const definitions = [
203
180
  {
204
181
  source: join(ACADEMY_ROOT, 'templates', 'agents', 'claude-code', 'knowledge-curator.md'),
@@ -212,11 +189,21 @@ function writeKnowledgeCuratorAgents(dir, name) {
212
189
  },
213
190
  ];
214
191
 
192
+ // The curator exists to run the knowledge-curation skill. A kind without that
193
+ // skill has no curator, and one left from an earlier kind is removed.
194
+ const curates = KIND_PROFILES[kind].skills.includes('knowledge-curation');
215
195
  for (const { source, target, skillsDir } of definitions) {
196
+ if (!curates) {
197
+ rmSync(target, { force: true });
198
+ continue;
199
+ }
216
200
  mkdirSync(dirname(target), { recursive: true });
217
201
  writeFileSync(
218
202
  target,
219
- renderTemplate(readFileSync(source, 'utf8'), universalSkillValues(dir, name, skillsDir)),
203
+ renderTemplate(
204
+ readFileSync(source, 'utf8'),
205
+ universalSkillValues(dir, name, skillsDir, kind),
206
+ ),
220
207
  );
221
208
  }
222
209
  }
@@ -225,41 +212,50 @@ export function academySystemPromptPath(dir) {
225
212
  return join(dir, '.academy', 'generated', ACADEMY_SYSTEM_PROMPT);
226
213
  }
227
214
 
215
+ export { userWorkView } from './user-work-view.mjs';
216
+
228
217
  function surfaceTitle(surface) {
229
218
  return `${surface[0].toUpperCase()}${surface.slice(1)}`;
230
219
  }
231
220
 
232
- function readSurface(dir, surface) {
233
- const surfaceFile = join(dir, `${surface}.md`);
221
+ function readSurface(surface, surfaceFile) {
234
222
  if (!existsSync(surfaceFile))
235
223
  return `# ${surfaceTitle(surface)}\n\n_(No ${surface}.md present yet.)_`;
236
224
  const body = readFileSync(surfaceFile, 'utf8').trimEnd();
237
225
  return body || `# ${surfaceTitle(surface)}\n\n_(${surface}.md is empty.)_`;
238
226
  }
239
227
 
228
+ // The operating model is package-owned text, not an agent file: one base for
229
+ // every agent and one section for its kind, rendered with the same values the
230
+ // skills read. It opens the prompt so the harness can cache it.
231
+ function operatingModel(dir, name, kind) {
232
+ const values = universalSkillValues(dir, name, join(dir, '.claude', 'skills'), kind);
233
+ return ['base', kind].map((part) =>
234
+ renderTemplate(
235
+ readFileSync(join(ACADEMY_ROOT, 'templates', 'operating-model', `${part}.md`), 'utf8'),
236
+ values,
237
+ ).trimEnd(),
238
+ );
239
+ }
240
+
240
241
  export function buildAcademySystemPrompt(dir, name) {
241
242
  const intro = [
242
243
  '<!-- Generated by `academy`. Do not edit this file directly. Edit the source surfaces instead. -->',
243
244
  '',
244
- `# Academy Agent: ${name}`,
245
- '',
246
- 'You are an Academy v3 agent. Treat the following surfaces as your durable identity, role, knowledge, goals, priorities, active threads, notes, and recent daily context.',
247
- '',
248
- 'Capture transient steering cheaply with `academy notes add "..."` — it appends a timestamped bullet to your notes.md without reading the whole file. Use it for corrections, stakeholder facts, caveats, and raw learnings before they are durable enough for another surface; review with `academy notes list`. The self-update skill explains when a note should graduate or expire.',
249
- '',
245
+ ...operatingModel(dir, name, kindOf(dir)).flatMap((part) => [part, '']),
250
246
  ];
251
- const surfaces = SURFACES.map((surface) => {
252
- const path = join(dir, `${surface}.md`);
253
- const marker = `<!-- academy:surface:${surface} -->`;
254
- const content = readSurface(dir, surface);
255
- return {
247
+ const surfaces = surfacesFor(dir).map(({ name: surface, path }) => {
248
+ const entry = {
256
249
  name: surface,
257
250
  file: `${surface}.md`,
258
251
  path,
259
252
  exists: existsSync(path),
260
- marker,
261
- content,
253
+ marker: `<!-- academy:surface:${surface} -->`,
254
+ content: readSurface(surface, path),
262
255
  };
256
+ if (surface !== 'user-work') return entry;
257
+ const { view, ...counts } = filterUserWork(entry.content, name, basename(dir));
258
+ return { ...entry, content: view, ...counts };
263
259
  });
264
260
  const prompt = [
265
261
  ...intro,
@@ -274,25 +270,22 @@ export function buildAcademySystemPrompt(dir, name) {
274
270
 
275
271
  export function renderAcademySystemPrompt(dir, name) {
276
272
  mkdirSync(dirname(academySystemPromptPath(dir)), { recursive: true });
277
- const { prompt } = buildAcademySystemPrompt(dir, name);
273
+ const { prompt, surfaces } = buildAcademySystemPrompt(dir, name);
278
274
  const promptPath = academySystemPromptPath(dir);
279
- writeFileSync(promptPath, prompt);
280
- return promptPath;
281
- }
282
-
283
- function estimateTokens(text) {
284
- const normalized = text.trim();
285
- if (!normalized) return 0;
286
- const chunks = normalized.match(/[\p{L}\p{N}]+|[^\s\p{L}\p{N}]/gu) ?? [];
287
- return Math.max(chunks.length, Math.ceil(text.length / 4));
275
+ // Replaced whole: a provider that starts during a concurrent render of the
276
+ // same agent reads the old prompt or the new one, never half of one.
277
+ const tempPath = join(dirname(promptPath), `.${ACADEMY_SYSTEM_PROMPT}.${process.pid}.tmp`);
278
+ try {
279
+ writeFileSync(tempPath, prompt);
280
+ renameSync(tempPath, promptPath);
281
+ } catch (err) {
282
+ rmSync(tempPath, { force: true });
283
+ throw err;
284
+ }
285
+ return { promptPath, surfaces };
288
286
  }
289
287
 
290
- export function tokenRecord(text) {
291
- return {
292
- estimatedTokens: estimateTokens(text),
293
- chars: text.length,
294
- };
295
- }
288
+ export { tokenRecord };
296
289
 
297
290
  function writeDreamsDir(dir) {
298
291
  mkdirSync(join(dir, 'dreams'), { recursive: true });
@@ -312,8 +305,8 @@ export function writeSettingsLocal(dir) {
312
305
  allow: [
313
306
  'Bash(academy:*)',
314
307
  'Bash(helm:*)',
315
- 'Bash(helm-tasks:*)',
316
- ...(memoryBridgeEnabled() ? MEMORY_BRIDGE_PERMISSIONS : []),
308
+ 'Bash(tldr-agents tasks:*)',
309
+ ...MEMORY_BRIDGE_PERMISSIONS,
317
310
  'Bash(date:*)',
318
311
  'Read',
319
312
  'Edit',
@@ -0,0 +1,70 @@
1
+ import { tldrTimeoutMs } from './tldr.mjs';
2
+
3
+ // tldr writes info log lines to stderr and its JSON envelope to stdout, so a
4
+ // failure is read from the envelope first — stdout, then stderr — and only
5
+ // falls back to raw text when neither stream carries one.
6
+
7
+ // No --replace fallback: --replace overwrites the operator's pause, conditions,
8
+ // env, and misfire grace, which is the loss --merge exists to prevent.
9
+ const TLDR_WITHOUT_MERGE_REASON =
10
+ 'tldr-agents does not support "schedule --merge"; install tldr 0.1.1-local.f6cac003eb94 or later';
11
+
12
+ export function scheduleFailureReason(result) {
13
+ const reason = helmFailureReason(result);
14
+ return rejectsMerge(result) ? `${TLDR_WITHOUT_MERGE_REASON} (${reason})` : reason;
15
+ }
16
+
17
+ // An older tldr parses unknown flags loosely, so it ignores --merge and then
18
+ // refuses the existing id as a duplicate; a current tldr never returns
19
+ // duplicate_id under --merge. A strict parser names the flag instead.
20
+ function rejectsMerge({ stdout, stderr }) {
21
+ const text = `${stdout ?? ''}\n${stderr ?? ''}`;
22
+ return text.includes('duplicate_id') || text.includes('--merge');
23
+ }
24
+
25
+ // A spawn `error` (a timeout is ETIMEDOUT, with a null status) is the failure.
26
+ export function helmFailureReason({ stdout, stderr, status, error }) {
27
+ if (error) {
28
+ return error.code === 'ETIMEDOUT'
29
+ ? `tldr-agents timed out after ${tldrTimeoutMs()} ms`
30
+ : error.message;
31
+ }
32
+ const envelopeReason = reasonFromEnvelope(parseEnvelope(stdout) ?? parseEnvelope(stderr));
33
+ if (envelopeReason) return envelopeReason;
34
+ const text = (stderr || stdout)?.trim();
35
+ if (!text) return `tldr-agents tasks exited ${status}`;
36
+ return text.length > 240 ? `${text.slice(0, 237)}...` : text;
37
+ }
38
+
39
+ function reasonFromEnvelope(envelope) {
40
+ if (!envelope) return null;
41
+ const message = envelope.errors?.[0]?.message || envelope.data?.activation?.health?.reason;
42
+ const validationErrors = Array.isArray(envelope.validation_errors)
43
+ ? envelope.validation_errors.join(', ')
44
+ : '';
45
+ if (!message) return validationErrors ? `validation errors: ${validationErrors}` : null;
46
+ return validationErrors ? `${message} (validation errors: ${validationErrors})` : message;
47
+ }
48
+
49
+ // The whole stream first; else the last line that parses, since a stream may
50
+ // carry log lines ahead of the envelope.
51
+ function parseEnvelope(output) {
52
+ const text = output?.trim();
53
+ if (!text) return null;
54
+ const candidates = [text, ...text.split('\n').reverse()];
55
+ for (const candidate of candidates) {
56
+ const parsed = parseObject(candidate.trim());
57
+ if (parsed) return parsed;
58
+ }
59
+ return null;
60
+ }
61
+
62
+ function parseObject(text) {
63
+ if (!text.startsWith('{')) return null;
64
+ try {
65
+ const parsed = JSON.parse(text);
66
+ return parsed && typeof parsed === 'object' ? parsed : null;
67
+ } catch {
68
+ return null;
69
+ }
70
+ }
@@ -9,6 +9,8 @@ import {
9
9
  isInside,
10
10
  validateName,
11
11
  } from './core.mjs';
12
+ import { resolvedAgentDir } from '../../hooks/hook_runtime.mjs';
13
+ import { agentIdOf } from './agent-id.mjs';
12
14
 
13
15
  // The session index is global across installs — the specialists work needs one
14
16
  // file a client can read whichever root it drives. Attribution is therefore a
@@ -55,9 +57,11 @@ export function unattributableSessionCount() {
55
57
  return readIndex().filter((row) => !row.agentDir).length;
56
58
  }
57
59
 
58
- function attributed(row, rootReal, agent) {
60
+ // `--agent` filters by the directory the id or current name resolves to, so a
61
+ // session recorded under an earlier name still belongs to the agent.
62
+ function attributed(row, rootReal, agentHome) {
59
63
  if (!row.agentDir) return false;
60
- if (agent && row.agentName !== agent) return false;
64
+ if (agentHome && resolvedAgentDir(row.agentDir) !== agentHome) return false;
61
65
  return isInside(row.agentDir, rootReal);
62
66
  }
63
67
 
@@ -76,8 +80,11 @@ export function readSessions({ json, agent, invalidOption }) {
76
80
  if (agent !== undefined) validateName(agent, json);
77
81
  checkAgentsRoot(json);
78
82
  const agentsRoot = resolve(AGENTS_ROOT);
83
+ // The hook records a realpath; the agent's folder may have moved since.
84
+ const agentHome =
85
+ agent === undefined ? null : join(resolvedAgentDir(AGENTS_ROOT), agentIdOf(agent));
79
86
  const sessions = readIndex()
80
- .filter((row) => attributed(row, agentsRoot, agent))
87
+ .filter((row) => attributed(row, agentsRoot, agentHome))
81
88
  .map(publishedRow);
82
89
 
83
90
  if (json) {
@@ -0,0 +1,147 @@
1
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
2
+ import { join, resolve } from 'node:path';
3
+ import { agentDir, checkAgentsRoot, contractOk, exitJsonError, validateName } from './core.mjs';
4
+ import { assertNotArchived } from './archived.mjs';
5
+ import { assertContainedAgentDir } from './lifecycle.mjs';
6
+ import { tokenRecord } from './scaffold.mjs';
7
+
8
+ // The skill method's hard ceiling per file. A file at 80% of it must be split
9
+ // before it grows further, so the check warns there and fails only over it.
10
+ export const SKILL_TOKEN_CEILING = 2000;
11
+ const SKILL_WARN_AT = Math.round(SKILL_TOKEN_CEILING * 0.8);
12
+ const SKILL_SURFACES = ['.claude/skills', '.agents/skills'];
13
+ const TOKENIZER = 'estimated:chars-and-words-v1';
14
+
15
+ function skillFileStatus(estimatedTokens) {
16
+ if (estimatedTokens > SKILL_TOKEN_CEILING) return 'fail';
17
+ if (estimatedTokens >= SKILL_WARN_AT) return 'warn';
18
+ return 'ok';
19
+ }
20
+
21
+ function markdownFiles(skillDir) {
22
+ return readdirSync(skillDir, { recursive: true })
23
+ .filter((file) => file.endsWith('.md') && statSync(join(skillDir, file)).isFile())
24
+ .sort();
25
+ }
26
+
27
+ // Every SKILL.md and reference file under each skill in one skills dir. A skills
28
+ // dir that does not exist measures as empty, not as an error.
29
+ export function measureSkillsRoot(skillsRoot) {
30
+ if (!existsSync(skillsRoot)) return [];
31
+ const skills = readdirSync(skillsRoot)
32
+ .filter((skill) => statSync(join(skillsRoot, skill)).isDirectory())
33
+ .sort();
34
+ return skills.flatMap((skill) =>
35
+ markdownFiles(join(skillsRoot, skill)).map((file) =>
36
+ measureFile(skill, file, join(skillsRoot, skill, file)),
37
+ ),
38
+ );
39
+ }
40
+
41
+ function measureFile(skill, file, path) {
42
+ const { estimatedTokens, chars } = tokenRecord(readFileSync(path, 'utf8'));
43
+ return {
44
+ skill,
45
+ file,
46
+ path,
47
+ estimatedTokens,
48
+ chars,
49
+ status: skillFileStatus(estimatedTokens),
50
+ overBy: Math.max(0, estimatedTokens - SKILL_TOKEN_CEILING),
51
+ };
52
+ }
53
+
54
+ // <home>/references/*.md: linked facts held at the same ceiling. Top level only;
55
+ // a home without the dir measures as empty.
56
+ function measureReferences(dir) {
57
+ const root = join(dir, 'references');
58
+ if (!existsSync(root)) return [];
59
+ return readdirSync(root)
60
+ .filter((file) => file.endsWith('.md') && statSync(join(root, file)).isFile())
61
+ .sort()
62
+ .map((file) => ({ surface: 'references', ...measureFile('', file, join(root, file)) }));
63
+ }
64
+
65
+ function refuseSkills(message, fields, json) {
66
+ if (json) exitJsonError('invalid_spec', message, fields);
67
+ console.error(`Error: ${message}`);
68
+ process.exit(1);
69
+ }
70
+
71
+ function resolveCheckedAgent(name, json) {
72
+ validateName(name);
73
+ checkAgentsRoot();
74
+ assertNotArchived(name);
75
+ const dir = resolve(agentDir(name));
76
+ if (!existsSync(dir)) {
77
+ const message = `Agent "${name}" not found at ${dir}`;
78
+ if (json) exitJsonError('agent_not_found', message, { name });
79
+ console.error(message);
80
+ process.exit(1);
81
+ }
82
+ assertContainedAgentDir(name, dir);
83
+ return dir;
84
+ }
85
+
86
+ function skillsReport(name, dir) {
87
+ const files = SKILL_SURFACES.flatMap((surface) =>
88
+ measureSkillsRoot(join(dir, surface)).map((file) => ({ surface, ...file })),
89
+ ).concat(measureReferences(dir));
90
+ const counts = { ok: 0, warn: 0, fail: 0 };
91
+ for (const file of files) counts[file.status] += 1;
92
+ return {
93
+ agent: name,
94
+ dir,
95
+ tokenizer: TOKENIZER,
96
+ ceiling: SKILL_TOKEN_CEILING,
97
+ warnAt: SKILL_WARN_AT,
98
+ passed: counts.fail === 0,
99
+ counts,
100
+ files,
101
+ };
102
+ }
103
+
104
+ function statusLabel(file) {
105
+ if (file.status === 'fail') return `FAIL over by ${file.overBy}`;
106
+ if (file.status === 'warn') return `WARN split before ${SKILL_TOKEN_CEILING}`;
107
+ return 'OK';
108
+ }
109
+
110
+ function printSkillsReport(report) {
111
+ console.log(`${report.agent} skills check`);
112
+ console.log(`tokenizer: ${report.tokenizer}`);
113
+ console.log(`ceiling: ${report.ceiling} tokens per file, warn at ${report.warnAt}`);
114
+ console.log('');
115
+ for (const file of report.files) {
116
+ const label = [file.surface, file.skill, file.file].filter(Boolean).join('/');
117
+ console.log(
118
+ `${label.padEnd(48)} ${String(file.estimatedTokens).padStart(5)} / ${report.ceiling} tokens ${statusLabel(file)}`,
119
+ );
120
+ }
121
+ console.log('');
122
+ console.log(report.passed ? 'PASS' : 'FAIL');
123
+ }
124
+
125
+ // `skills check <agent> [--json]`. A failing file is a finding, not a fault of
126
+ // the command: the report still publishes with ok: true, and the exit code
127
+ // carries the verdict so a nightly run can branch on it.
128
+ export function checkSkills({ action, name, json, invalidOption }) {
129
+ if (invalidOption !== undefined) {
130
+ refuseSkills(
131
+ `Unknown skills option: ${invalidOption}. Use skills check <agent> [--json].`,
132
+ { option: invalidOption },
133
+ json,
134
+ );
135
+ }
136
+ if (action !== 'check') {
137
+ refuseSkills(
138
+ `Unknown skills action: ${action ?? '(none)'}. Use skills check <agent> [--json].`,
139
+ { action: action ?? null },
140
+ json,
141
+ );
142
+ }
143
+ const report = skillsReport(name, resolveCheckedAgent(name, json));
144
+ if (json) contractOk('skills', report);
145
+ else printSkillsReport(report);
146
+ process.exitCode = report.passed ? 0 : 1;
147
+ }