@zerotal/arch 1.7.5 → 1.8.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.
@@ -1,5 +1,6 @@
1
1
  import { ServiceProvider } from "@zerotal/core";
2
2
  import type { AppEnvironment, DoctorCheck } from "@zerotal/core";
3
+ import type { ProjectShape } from "../install/shape.ts";
3
4
  import { SERVER_ENTRY_PATH } from "../install/mcpConfig.ts";
4
5
 
5
6
  /**
@@ -96,6 +97,153 @@ export class ArchProvider extends ServiceProvider {
96
97
  }
97
98
  },
98
99
  },
100
+ {
101
+ id: "arch-agents-current",
102
+ label: "Agent instructions",
103
+ run: async () => agentsFileCheck(process.cwd()),
104
+ },
99
105
  ];
100
106
  }
101
107
  }
108
+
109
+ /**
110
+ * Is the generated block still true?
111
+ *
112
+ * It was a description of the framework, which changed about as often as the
113
+ * framework did. It is now also a description of *this project* — which packages
114
+ * are installed, who owns the schema, which strictness flags are on — and every
115
+ * one of those moves without anyone thinking about `AGENTS.md`. Add a migration
116
+ * directory, turn `synchronize` off, install a package, upgrade the framework:
117
+ * the file still reads as current and is quietly describing the app you had.
118
+ *
119
+ * That is worse than having no file. Guidance nobody wrote is obviously absent;
120
+ * guidance that is confidently out of date gets followed.
121
+ *
122
+ * A warning rather than a failure: a stale instruction file misleads a person or
123
+ * an agent, and does not stop the application working. `doctor` earns the right
124
+ * to gate a deploy by failing only for things that would break one.
125
+ *
126
+ * Takes its root rather than reading `process.cwd()`, so it is testable without
127
+ * moving the process into a fixture.
128
+ *
129
+ * @internal
130
+ */
131
+ export async function agentsFileCheck(
132
+ root: string,
133
+ ): Promise<{ status: "ok" | "warn"; message: string; fix?: string }> {
134
+ const file = Bun.file(`${root}/AGENTS.md`);
135
+ if (!(await file.exists())) {
136
+ return {
137
+ status: "warn",
138
+ message:
139
+ "No AGENTS.md, so an agent working here has no instructions and none of the " +
140
+ "project-specific facts the MCP tools cannot infer.",
141
+ fix: "bun zt arch:install",
142
+ };
143
+ }
144
+
145
+ const [{ buildGuidelines }, { detectShape }, { installedPackages }, markers] = await Promise.all([
146
+ import("../install/guidelines.ts"),
147
+ import("../install/shape.ts"),
148
+ import("../probe/topics.ts"),
149
+ import("../install/markers.ts"),
150
+ ]);
151
+
152
+ const current = _blockOf(await file.text(), markers.BLOCK_START, markers.BLOCK_END);
153
+ if (current === undefined) {
154
+ return {
155
+ status: "warn",
156
+ message:
157
+ "AGENTS.md has no generated block, so nothing here describes the framework or how " +
158
+ "this app is set up.",
159
+ fix: "bun zt arch:install",
160
+ };
161
+ }
162
+
163
+ const packages = (await installedPackages(root)).map((pkg) => pkg.name);
164
+ const shape = await detectShape(root);
165
+ const expected = buildGuidelines({
166
+ packages,
167
+ serverName: await _serverName(root),
168
+ shape,
169
+ });
170
+
171
+ if (current.trim() !== expected.trim()) {
172
+ return {
173
+ status: "warn",
174
+ message:
175
+ "AGENTS.md describes a different project than this one — its packages, its setup or " +
176
+ "its framework version have moved since it was written.",
177
+ fix: "bun zt arch:update",
178
+ };
179
+ }
180
+
181
+ // Skills rot the same way and are easier to miss: nothing reads them until an
182
+ // agent decides one is relevant, and by then it is being followed.
183
+ const stale = await _staleSkills(root, packages, shape);
184
+ if (stale.length > 0) {
185
+ return {
186
+ status: "warn",
187
+ message: `AGENTS.md is current, but ${stale.length} skill file(s) are not: ${stale.join(", ")}.`,
188
+ fix: "bun zt arch:update",
189
+ };
190
+ }
191
+
192
+ return { status: "ok", message: "AGENTS.md and skills match this project" };
193
+ }
194
+
195
+ /** Skill files that are missing, or generated and no longer what they would be. */
196
+ async function _staleSkills(
197
+ root: string,
198
+ packages: string[],
199
+ shape: ProjectShape,
200
+ ): Promise<string[]> {
201
+ const { selectSkills, renderSkill, skillPaths, SKILL_MARKER } =
202
+ await import("../install/skills.ts");
203
+ const { detectAgents } = await import("../install/detect.ts");
204
+ const { agents } = await detectAgents(root);
205
+
206
+ const stale: string[] = [];
207
+ for (const skill of selectSkills(packages, shape)) {
208
+ for (const path of skillPaths(skill.name, agents)) {
209
+ const file = Bun.file(`${root}/${path}`);
210
+ if (!(await file.exists())) {
211
+ stale.push(path);
212
+ continue;
213
+ }
214
+ const text = await file.text();
215
+ // A file somebody took ownership of is theirs, current or not.
216
+ if (!text.includes(SKILL_MARKER)) continue;
217
+ if (text !== renderSkill(skill, shape)) stale.push(path);
218
+ }
219
+ }
220
+ return stale;
221
+ }
222
+
223
+ /** The generated body, unfenced, or `undefined` when there is no block. */
224
+ function _blockOf(text: string, start: string, end: string): string | undefined {
225
+ const from = text.indexOf(start);
226
+ const to = text.indexOf(end);
227
+ if (from === -1 || to === -1 || to < from) return undefined;
228
+ return text.slice(from + start.length, to);
229
+ }
230
+
231
+ /**
232
+ * The key the server is registered under, read back from `.mcp.json`.
233
+ *
234
+ * The block names it, so comparing against a guessed name would report every
235
+ * project that renamed its server as permanently stale. Read rather than
236
+ * `require`d, which would cache the first answer and miss a rename.
237
+ */
238
+ async function _serverName(root: string): Promise<string> {
239
+ try {
240
+ const document = (await Bun.file(`${root}/.mcp.json`).json()) as {
241
+ mcpServers?: Record<string, unknown>;
242
+ servers?: Record<string, unknown>;
243
+ };
244
+ const names = Object.keys(document.mcpServers ?? document.servers ?? {});
245
+ return names[0] ?? "zerotal";
246
+ } catch {
247
+ return "zerotal";
248
+ }
249
+ }