archgen-mcp 0.1.0 → 0.1.2

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.
@@ -10,6 +10,8 @@ import { readFileSync } from 'node:fs';
10
10
  import { registerTool, projectRootArg } from './_register.mjs';
11
11
  import { loadAsset } from '../context.mjs';
12
12
  import { invalidInput } from '../core/errors.mjs';
13
+ import { hint, teach } from '../core/guidance.mjs';
14
+ import { moreMeta, project, withMeta } from '../envelope.mjs';
13
15
  import {
14
16
  matchSkills,
15
17
  installSkill,
@@ -35,11 +37,16 @@ const schema = z.object({
35
37
  title: z.string().optional().describe('Task title (match).'),
36
38
  summary: z.string().optional().describe('Task summary (match).'),
37
39
  fileOwnership: z.array(z.string()).optional().describe('Task file_ownership globs (match).'),
38
- skillId: z.string().optional().describe('Registry skill id or slug (install, load, digest).'),
39
- section: z.string().optional().describe('One section of the skill (load). Omit for the whole skill.'),
40
+ skillId: z.string().optional().describe('Registry skill id or slug (install, load, digest, list).'),
41
+ section: z
42
+ .string()
43
+ .optional()
44
+ .describe('One section of the skill (load, digest). Omit for the section index.'),
40
45
  harness: z.string().optional().describe('Harness id to install for (install). Defaults to universal.'),
41
46
  global: z.boolean().optional().describe('Install for the user instead of the project (install).'),
42
47
  approve: z.boolean().optional().describe('Required to install. Without it you get the plan and nothing is written.'),
48
+ limit: z.number().int().positive().optional().describe('Max items to return. Default 50.'),
49
+ offset: z.number().int().nonnegative().optional().describe('Skip this many items.'),
43
50
  projectRoot: projectRootArg,
44
51
  });
45
52
 
@@ -59,21 +66,39 @@ export function registerSkillTools(server) {
59
66
  idempotent: true,
60
67
  handler: (ctx, args) => {
61
68
  const registry = loadAsset('skill-registry.json');
69
+ const bound = args.limit ?? 50;
70
+ const from = args.offset ?? 0;
62
71
 
63
72
  if (args.action === 'list') {
64
- return {
65
- version: registry.version,
66
- installer: registry.installer,
67
- skills: registry.skills.map((s) => ({
68
- id: s.id,
69
- slug: s.slug,
70
- family: s.family,
71
- repo: s.repo,
72
- purpose: s.purpose,
73
- policy: s.policy,
74
- matches: s.matches,
75
- })),
76
- };
73
+ // ONE row, or the whole registry — but never three copies of a row.
74
+ const rows = registry.skills.map((s) => ({
75
+ id: s.id,
76
+ slug: s.slug,
77
+ family: s.family,
78
+ repo: s.repo,
79
+ purpose: s.purpose,
80
+ policy: s.policy,
81
+ matches: s.matches,
82
+ }));
83
+ if (args.skillId) {
84
+ const one = rows.find((s) => s.id === args.skillId || s.slug === args.skillId);
85
+ if (!one) {
86
+ throw invalidInput(
87
+ teach(`unknown skill '${args.skillId}'`, 'the registry lists every id and slug', "archgen_skills{action:'list'}"),
88
+ hint({ action: "archgen_skills{action:'list'}", reason: 'the registry is the vocabulary.' }),
89
+ );
90
+ }
91
+ return { version: registry.version, installer: registry.installer, skill: one, skills: [one] };
92
+ }
93
+ const p = project(rows, {
94
+ limit: args.limit,
95
+ offset: args.offset,
96
+ next: `archgen_skills{action:'list',limit:${bound},offset:${from + bound}}`,
97
+ });
98
+ return withMeta(
99
+ { version: registry.version, installer: registry.installer, skills: p.items },
100
+ moreMeta({ total: p.total, returned: p.returned, next: p.next, available: { skills: p.total } }),
101
+ );
77
102
  }
78
103
 
79
104
  if (args.action === 'match') {
@@ -82,21 +107,47 @@ export function registerSkillTools(server) {
82
107
  summary: args.summary,
83
108
  fileOwnership: args.fileOwnership,
84
109
  });
85
- return {
86
- matched,
87
- required: matched.filter((m) => m.policy === 'required'),
88
- installed: matched.map((m) => {
89
- const found = findInstalledSkill(ctx.projectRoot, m.slug, { home: ctx.home });
90
- return { ...m, installed: found.installed, path: found.path, scope: found.scope };
110
+ // ONE copy of each row: the install state rides ON the row, instead of
111
+ // the same row being delivered three times under three keys.
112
+ const rows = matched.map((m) => {
113
+ const found = findInstalledSkill(ctx.projectRoot, m.slug, { home: ctx.home });
114
+ return { ...m, installed: found.installed, path: found.path, scope: found.scope };
115
+ });
116
+ const p = project(rows, {
117
+ limit: args.limit,
118
+ offset: args.offset,
119
+ next: `archgen_skills{action:'match',title:'${args.title ?? ''}',limit:${bound},offset:${from + bound}}`,
120
+ });
121
+ return withMeta(
122
+ {
123
+ matched: p.items,
124
+ // The required ones, by id — the rows are already above.
125
+ required: p.items.filter((m) => m.policy === 'required').map((m) => m.slug),
126
+ installed: p.items.filter((m) => m.installed).map((m) => m.slug),
127
+ note: matched.length
128
+ ? 'Install every required skill before dispatching the worker.'
129
+ : 'No capability domain matched — no external skill is needed.',
130
+ },
131
+ moreMeta({
132
+ total: p.total,
133
+ returned: p.returned,
134
+ next: p.next,
135
+ available: { skills: p.total, required: p.items.filter((m) => m.policy === 'required').length },
91
136
  }),
92
- note: matched.length
93
- ? 'Install every required skill before dispatching the worker.'
94
- : 'No capability domain matched — no external skill is needed.',
95
- };
137
+ );
96
138
  }
97
139
 
98
140
  if (args.action === 'installed') {
99
- return { installed: listInstalled({ projectRoot: ctx.projectRoot, registry, home: ctx.home }) };
141
+ const all = listInstalled({ projectRoot: ctx.projectRoot, registry, home: ctx.home });
142
+ const p = project(all.installed ?? [], {
143
+ limit: args.limit,
144
+ offset: args.offset,
145
+ next: `archgen_skills{action:'installed',limit:${bound},offset:${from + bound}}`,
146
+ });
147
+ return withMeta(
148
+ { installed: p.items },
149
+ moreMeta({ total: p.total, returned: p.returned, next: p.next, available: { skills: p.total } }),
150
+ );
100
151
  }
101
152
 
102
153
  // install | load
@@ -108,7 +159,14 @@ export function registerSkillTools(server) {
108
159
  ? registry.routing
109
160
  : null);
110
161
  if (!entry) {
111
- throw invalidInput(`unknown skill '${wanted}' — call archgen_skills { action: 'list' }`, { skillId: wanted });
162
+ throw invalidInput(
163
+ teach(
164
+ `unknown skill '${wanted}'`,
165
+ 'the registry lists every id and slug',
166
+ "archgen_skills{action:'list'}",
167
+ ),
168
+ hint({ action: "archgen_skills{action:'list'}", reason: 'the registry is the vocabulary.' }),
169
+ );
112
170
  }
113
171
 
114
172
  if (args.action === 'digest') {
@@ -122,17 +180,44 @@ export function registerSkillTools(server) {
122
180
  installed: false,
123
181
  sections: [],
124
182
  searchedPaths: found.searchedPaths ?? [],
125
- hint: 'not installed — call archgen_skills { action: "install" } first',
183
+ hint: 'not installed — install it first: archgen_skills{action:\'install\',skillId:\'' + entry.slug + '\',approve:true}',
126
184
  };
127
185
  }
128
186
  const content = readFileSync(found.path, 'utf8');
129
- return {
130
- skill: entry.slug,
131
- installed: true,
132
- path: found.path,
133
- scope: found.scope,
134
- sections: skillSections(content),
135
- };
187
+ let sections = skillSections(content);
188
+ // ONE section, when the caller named one.
189
+ if (args.section) {
190
+ const wanted = String(args.section).trim().toLowerCase();
191
+ const hit = sections.find(
192
+ (s) =>
193
+ s.slug === args.section ||
194
+ s.title.toLowerCase() === wanted ||
195
+ s.slug.replace(/-{2,}/g, '-') === wanted.replace(/-{2,}/g, '-'),
196
+ );
197
+ if (!hit) {
198
+ throw invalidInput(`no section '${args.section}' in skill '${entry.slug}'`, {
199
+ skillId: entry.slug,
200
+ section: args.section,
201
+ available: sections.map((s) => s.slug),
202
+ });
203
+ }
204
+ sections = [hit];
205
+ }
206
+ const p = project(sections, {
207
+ limit: args.limit,
208
+ offset: args.offset,
209
+ next: `archgen_skills{action:'digest',skillId:'${entry.slug}',limit:${bound},offset:${from + bound}}`,
210
+ });
211
+ return withMeta(
212
+ { skill: entry.slug, installed: true, path: found.path, scope: found.scope, sections: p.items },
213
+ moreMeta({
214
+ total: p.total,
215
+ returned: p.returned,
216
+ next: p.next,
217
+ available: { sections: p.total },
218
+ more: `archgen_skills{action:'load',skillId:'${entry.slug}',section:'<the section you need>'}`,
219
+ }),
220
+ );
136
221
  }
137
222
 
138
223
  if (args.action === 'load') {
@@ -144,9 +229,14 @@ export function registerSkillTools(server) {
144
229
  skill: entry.slug,
145
230
  purpose: entry.purpose,
146
231
  domain: entry.domain ?? entry.family ?? 'Capability',
147
- ...loaded,
148
- // The normative rules, and the section index — the agent decides
149
- // what to pull from here.
232
+ loaded: loaded.loaded,
233
+ path: loaded.path,
234
+ scope: loaded.scope,
235
+ // The RAW CONTENT IS NOT RETURNED. It used to be: the file arrived
236
+ // three times in one call — as `content`, as the extracted `rules`
237
+ // and as the section index. The rules are what the agent must obey
238
+ // and the index is what it may pull; the file itself is one call
239
+ // away (`section:'<slug>'`), and the agent is told where it is.
150
240
  rules: rules.rules,
151
241
  ruleCount: rules.count,
152
242
  sections: sections.map((s) => ({
@@ -157,8 +247,12 @@ export function registerSkillTools(server) {
157
247
  normative: s.normative,
158
248
  })),
159
249
  followInstruction: loaded.loaded ? followInstruction({ slug: entry.slug, path: loaded.path, domain: entry.domain }) : null,
160
- hint: loaded.loaded ? null : 'not installed — call archgen_skills { action: "install" } first',
250
+ hint: loaded.loaded
251
+ ? null
252
+ : 'not installed — install it first: archgen_skills{action:\'install\',skillId:\'' + entry.slug + '\',approve:true}',
161
253
  };
254
+ // Where the tool looked matters only when it found nothing.
255
+ if (!loaded.loaded) result.searchedPaths = loaded.searchedPaths ?? [];
162
256
 
163
257
  // ONE section — the agent's lens, the same way archgen_docs serves
164
258
  // one section of an artifact.
@@ -177,7 +271,21 @@ export function registerSkillTools(server) {
177
271
  const body = loaded.content.split('\n').slice(hit.line - 1, hit.endLine).join('\n');
178
272
  return { ...result, section: hit.slug, sectionTitle: hit.title, sectionText: body };
179
273
  }
180
- return result;
274
+ const p = project(result.sections, {
275
+ limit: args.limit,
276
+ offset: args.offset,
277
+ next: `archgen_skills{action:'load',skillId:'${entry.slug}',limit:${bound},offset:${from + bound}}`,
278
+ });
279
+ return withMeta(
280
+ { ...result, sections: p.items },
281
+ moreMeta({
282
+ total: p.total,
283
+ returned: p.returned,
284
+ next: p.next,
285
+ available: { sections: p.total },
286
+ more: `archgen_skills{action:'load',skillId:'${entry.slug}',section:'<the section you need>'}`,
287
+ }),
288
+ );
181
289
  }
182
290
 
183
291
  return installSkill({
@@ -11,6 +11,7 @@ import { loadAsset } from '../context.mjs';
11
11
  import { setTaskStatus, STATUSES } from '../core/status-mutator.mjs';
12
12
  import { writeResumeSnapshot } from '../core/resume-snapshot.mjs';
13
13
  import { invalidInput } from '../core/errors.mjs';
14
+ import { moreMeta, project, withMeta } from '../envelope.mjs';
14
15
  import { resolveSlugInput, ScopeError } from '../core/scope.mjs';
15
16
  import { dirname, join } from 'node:path';
16
17
 
@@ -40,13 +41,15 @@ const ACTION_DESC = 'next | set_status | list | snapshot | contract — see arch
40
41
  const schema = z.object({
41
42
  action: z.enum(['next', 'set_status', 'list', 'snapshot', 'contract']).describe(ACTION_DESC),
42
43
  tasksYaml: z.string().optional().describe('Absolute path to tasks.yaml. Required for next, set_status and list.'),
43
- taskId: z.string().optional().describe('Task id (set_status).'),
44
+ taskId: z.string().optional().describe('Task id (set_status, contract). ONE task (list, next).'),
44
45
  status: z.enum(STATUSES).optional().describe('New status (set_status).'),
45
46
  force: z.boolean().optional().describe('Allow reopening a done task (set_status).'),
46
47
  filterStatus: z.string().optional().describe('Filter by status (list).'),
47
48
  slugDir: z.string().optional().describe('The .archgen/<slug>/ directory (contract) — locates the plan docs.'),
48
49
  codeStandardsPath: z.string().optional().describe('Path to the code-standards reference (contract).'),
49
50
  module: z.string().optional().describe('Filter by module path fragment (list).'),
51
+ limit: z.number().int().positive().optional().describe('Max items to return. Default 50.'),
52
+ offset: z.number().int().nonnegative().optional().describe('Skip this many items.'),
50
53
  projectRoot: projectRootArg,
51
54
  });
52
55
 
@@ -74,10 +77,37 @@ export function registerTaskTools(server) {
74
77
  }
75
78
  case 'list': {
76
79
  if (!args.tasksYaml) throw invalidInput('tasksYaml is required for list', { action });
77
- return listTasks(ctx.contain(args.tasksYaml), {
80
+ const listed = listTasks(ctx.contain(args.tasksYaml), {
78
81
  status: args.filterStatus ?? null,
79
82
  module: args.module ?? null,
80
83
  });
84
+ // ONE task, when the caller named one.
85
+ if (args.taskId) {
86
+ const one = (listed.tasks ?? []).find((t) => t.id === args.taskId);
87
+ if (!one) {
88
+ throw invalidInput(`no task '${args.taskId}' in ${args.tasksYaml}`, {
89
+ action,
90
+ taskId: args.taskId,
91
+ available: (listed.tasks ?? []).map((t) => t.id),
92
+ });
93
+ }
94
+ return { task: one, tasks: [one] };
95
+ }
96
+ const p = project(listed.tasks ?? [], {
97
+ limit: args.limit,
98
+ offset: args.offset,
99
+ next: `archgen_tasks{action:'list',tasksYaml:'${args.tasksYaml}',limit:${args.limit ?? 50},offset:${(args.offset ?? 0) + (args.limit ?? 50)}}`,
100
+ });
101
+ return withMeta(
102
+ { ...listed, tasks: p.items },
103
+ moreMeta({
104
+ total: p.total,
105
+ returned: p.returned,
106
+ next: p.next,
107
+ available: { tasks: p.total },
108
+ more: `archgen_tasks{action:'list',tasksYaml:'${args.tasksYaml}',taskId:'<one task>'}`,
109
+ }),
110
+ );
81
111
  }
82
112
  case 'snapshot': {
83
113
  if (!args.tasksYaml) throw invalidInput('tasksYaml is required for snapshot', { action });
@@ -196,7 +226,42 @@ export function registerTaskTools(server) {
196
226
  }
197
227
  case 'next': {
198
228
  if (!args.tasksYaml) throw invalidInput('tasksYaml is required for next', { action });
199
- return resolveWaves(ctx.contain(args.tasksYaml));
229
+ const resolved = resolveWaves(ctx.contain(args.tasksYaml));
230
+ // ONE wave, when the caller named a task: "which wave is this in?"
231
+ if (args.taskId) {
232
+ const index = resolved.waves.findIndex((w) => w.some((t) => t.id === args.taskId));
233
+ if (index < 0) {
234
+ throw invalidInput(`no task '${args.taskId}' in ${args.tasksYaml}`, {
235
+ action,
236
+ taskId: args.taskId,
237
+ available: resolved.waves.flat().map((t) => t.id),
238
+ });
239
+ }
240
+ return {
241
+ waveIndex: index,
242
+ wave: resolved.waves[index],
243
+ waves: [resolved.waves[index]],
244
+ blockedByFailure: resolved.blockedByFailure,
245
+ };
246
+ }
247
+ // The waves are the plan's shape; a long plan would dump every task
248
+ // in every wave, so the wave COUNT is reported and the waves
249
+ // themselves are bounded.
250
+ const p = project(resolved.waves, {
251
+ limit: args.limit,
252
+ offset: args.offset,
253
+ next: `archgen_tasks{action:'next',tasksYaml:'${args.tasksYaml}',limit:${args.limit ?? 50},offset:${(args.offset ?? 0) + (args.limit ?? 50)}}`,
254
+ });
255
+ return withMeta(
256
+ { ...resolved, waves: p.items },
257
+ moreMeta({
258
+ total: p.total,
259
+ returned: p.returned,
260
+ next: p.next,
261
+ available: { waves: p.total, tasks: resolved.waves.flat().length },
262
+ more: `archgen_tasks{action:'next',tasksYaml:'${args.tasksYaml}',taskId:'<one task>'}`,
263
+ }),
264
+ );
200
265
  }
201
266
  default:
202
267
  throw invalidInput(`unknown action '${action}'`, { action });