@lifeaitools/rdc-skills 0.24.38 → 0.24.41

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 (196) hide show
  1. package/.claude/settings.json +15 -15
  2. package/.claude-plugin/marketplace.json +21 -21
  3. package/.claude-plugin/plugin.json +1371 -1371
  4. package/.github/workflows/publish.yml +34 -34
  5. package/.github/workflows/self-test.yml +58 -58
  6. package/CHANGELOG.md +310 -310
  7. package/LICENSE +21 -21
  8. package/MANIFEST.md +221 -221
  9. package/README.md +376 -375
  10. package/README.sandbox.md +3 -3
  11. package/RELEASE.md +42 -0
  12. package/assets/watcher/viewer.html +164 -164
  13. package/bin/rdc-skills-mcp.mjs +316 -316
  14. package/commands/build.md +183 -183
  15. package/commands/collab.md +180 -180
  16. package/commands/deploy.md +152 -152
  17. package/commands/design.md +31 -31
  18. package/commands/edit.md +28 -28
  19. package/commands/fixit.md +124 -124
  20. package/commands/handoff.md +173 -173
  21. package/commands/help.md +95 -95
  22. package/commands/overnight.md +220 -220
  23. package/commands/plan.md +158 -158
  24. package/commands/preplan.md +131 -131
  25. package/commands/prototype.md +145 -145
  26. package/commands/release.md +49 -49
  27. package/commands/report.md +99 -99
  28. package/commands/review.md +120 -120
  29. package/commands/self-test.md +113 -113
  30. package/commands/status.md +86 -86
  31. package/commands/watch.md +98 -98
  32. package/commands/workitems.md +137 -137
  33. package/git-sha.json +1 -1
  34. package/guides/agent-bootstrap.md +295 -295
  35. package/guides/agents/backend.md +104 -104
  36. package/guides/agents/content.md +94 -94
  37. package/guides/agents/cs2.md +56 -56
  38. package/guides/agents/data.md +87 -87
  39. package/guides/agents/design.md +77 -77
  40. package/guides/agents/frontend.md +92 -92
  41. package/guides/agents/infrastructure.md +81 -81
  42. package/guides/agents/setup.md +281 -281
  43. package/guides/agents/verify.md +151 -151
  44. package/guides/agents/viz.md +106 -106
  45. package/guides/backend.md +146 -146
  46. package/guides/content.md +147 -147
  47. package/guides/cs2.md +190 -190
  48. package/guides/data.md +123 -123
  49. package/guides/design.md +116 -116
  50. package/guides/engineering-behavior.md +43 -43
  51. package/guides/escalation-protocol.md +125 -125
  52. package/guides/frontend.md +151 -151
  53. package/guides/history-md-spec.md +297 -297
  54. package/guides/infrastructure.md +179 -179
  55. package/guides/lessons-learned-spec.md +151 -145
  56. package/guides/output-contract.md +108 -108
  57. package/guides/publish-md-spec.md +289 -289
  58. package/guides/rdc-skills-startup.md +30 -30
  59. package/guides/verify.md +11 -11
  60. package/hooks/check-cwd.js +31 -31
  61. package/hooks/check-rdc-environment.js +164 -164
  62. package/hooks/check-services.js +6 -6
  63. package/hooks/check-stale-work-items.js +19 -19
  64. package/hooks/foreground-process-gate.js +128 -128
  65. package/hooks/gate-watchdog-selfcheck.js +257 -257
  66. package/hooks/hook-logger.js +25 -25
  67. package/hooks/lib/run-evidence-gate.mjs +241 -241
  68. package/hooks/no-stop-open-epics.js +127 -127
  69. package/hooks/post-tool-batch-gate.js +203 -203
  70. package/hooks/post-work-check.js +21 -21
  71. package/hooks/postcompact-log.js +13 -13
  72. package/hooks/precompact-log.js +13 -13
  73. package/hooks/rate-limit-retry.js +46 -46
  74. package/hooks/rdc-invocation-marker.js +157 -157
  75. package/hooks/rdc-output-contract-gate.js +94 -94
  76. package/hooks/require-work-item-on-commit.js +294 -294
  77. package/hooks/restart-brief.js +19 -19
  78. package/hooks/run-hidden-hook.ps1 +47 -47
  79. package/hooks/task-completed-gate.js +274 -274
  80. package/hooks/work-item-exit-gate.js +944 -944
  81. package/lib/catalog.mjs +236 -236
  82. package/lib/cloud-rewrite.mjs +155 -155
  83. package/package.json +57 -56
  84. package/rules/work-items-rpc.md +520 -520
  85. package/scaffold/templates/HISTORY.md.template +39 -39
  86. package/scaffold/templates/PUBLISH.md.template +21 -21
  87. package/scaffold/templates/brochure-studio-default.html +70 -70
  88. package/scripts/acceptance.mjs +502 -502
  89. package/scripts/fixtures/guides/bad-guide.md +15 -15
  90. package/scripts/fixtures/guides-clean/good-guide.md +16 -16
  91. package/scripts/install-rdc-skills.js +1289 -1289
  92. package/scripts/install.ps1 +202 -202
  93. package/scripts/install.sh +132 -132
  94. package/scripts/lib/assertions.mjs +287 -287
  95. package/scripts/lib/manifest-schema.mjs +754 -754
  96. package/scripts/lib/runner.mjs +465 -465
  97. package/scripts/lib/sandbox.mjs +435 -435
  98. package/scripts/prepack.mjs +32 -32
  99. package/scripts/rdc-brochure.mjs +482 -464
  100. package/scripts/rdc-design-cli.mjs +134 -134
  101. package/scripts/rebuild-mcp.mjs +107 -107
  102. package/scripts/self-test.mjs +1460 -1460
  103. package/scripts/stamp-git-sha.mjs +29 -29
  104. package/scripts/test-guide-validator.mjs +196 -196
  105. package/scripts/test-rdc-hooks.mjs +145 -145
  106. package/scripts/uninstall.ps1 +77 -77
  107. package/scripts/uninstall.sh +69 -69
  108. package/scripts/update.ps1 +43 -43
  109. package/scripts/update.sh +43 -43
  110. package/scripts/validate-place-histories.js +461 -461
  111. package/scripts/validate-publish-manifests.js +424 -424
  112. package/scripts/watch-init.mjs +100 -100
  113. package/skills/brochure/SKILL.md +107 -107
  114. package/skills/build/SKILL.md +563 -563
  115. package/skills/channel-formatter/SKILL.md +533 -533
  116. package/skills/co-develop/SKILL.md +196 -196
  117. package/skills/collab/SKILL.md +239 -239
  118. package/skills/convert/SKILL.md +140 -140
  119. package/skills/deploy/SKILL.md +541 -541
  120. package/skills/design/SKILL.md +211 -211
  121. package/skills/design/reference/ownership.md +16 -16
  122. package/skills/design/reference/rampa.md +92 -92
  123. package/skills/design/reference/studio-model.md +153 -153
  124. package/skills/edit/SKILL.md +98 -98
  125. package/skills/fixit/SKILL.md +165 -165
  126. package/skills/fs-mcp/SKILL.md +148 -148
  127. package/skills/handoff/SKILL.md +236 -200
  128. package/skills/help/SKILL.md +143 -143
  129. package/skills/housekeeping/SKILL.md +219 -160
  130. package/skills/lifeai-brochure-author/SKILL.md +340 -340
  131. package/skills/overnight/SKILL.md +251 -251
  132. package/skills/plan/SKILL.md +345 -345
  133. package/skills/preplan/SKILL.md +90 -90
  134. package/skills/prototype/SKILL.md +150 -150
  135. package/skills/rdc-brochurify/SKILL.md +245 -245
  136. package/skills/rdc-extract-verifier-rules/SKILL.md +191 -191
  137. package/skills/release/SKILL.md +140 -140
  138. package/skills/report/SKILL.md +100 -100
  139. package/skills/review/SKILL.md +152 -152
  140. package/skills/rpms-filemap/SKILL.cloud.md +111 -111
  141. package/skills/rpms-filemap/SKILL.md +111 -111
  142. package/skills/self-test/SKILL.md +132 -132
  143. package/skills/status/SKILL.md +99 -99
  144. package/skills/terminal-config/SKILL.md +62 -62
  145. package/skills/tests/MATRIX.md +54 -54
  146. package/skills/tests/README.md +47 -47
  147. package/skills/tests/rdc-brochure.test.json +34 -34
  148. package/skills/tests/rdc-build.test.json +36 -36
  149. package/skills/tests/rdc-channel-formatter.test.json +45 -45
  150. package/skills/tests/rdc-co-develop.test.json +29 -29
  151. package/skills/tests/rdc-collab.test.json +29 -29
  152. package/skills/tests/rdc-convert.test.json +35 -35
  153. package/skills/tests/rdc-deploy.test.json +30 -30
  154. package/skills/tests/rdc-design.test.json +27 -27
  155. package/skills/tests/rdc-edit.test.json +29 -29
  156. package/skills/tests/rdc-fixit.test.json +36 -36
  157. package/skills/tests/rdc-fs-mcp.test.json +36 -36
  158. package/skills/tests/rdc-handoff.test.json +28 -28
  159. package/skills/tests/rdc-help.test.json +29 -29
  160. package/skills/tests/rdc-housekeeping.test.json +32 -28
  161. package/skills/tests/rdc-lifeai-brochure-author.test.json +35 -35
  162. package/skills/tests/rdc-overnight.test.json +37 -37
  163. package/skills/tests/rdc-plan.test.json +27 -27
  164. package/skills/tests/rdc-preplan.test.json +31 -31
  165. package/skills/tests/rdc-prototype.test.json +28 -28
  166. package/skills/tests/rdc-rdc-brochurify.test.json +23 -23
  167. package/skills/tests/rdc-rdc-extract-verifier-rules.test.json +34 -34
  168. package/skills/tests/rdc-release.test.json +29 -29
  169. package/skills/tests/rdc-report.test.json +28 -28
  170. package/skills/tests/rdc-review.test.json +29 -29
  171. package/skills/tests/rdc-rpms-filemap.test.json +28 -28
  172. package/skills/tests/rdc-self-test.test.json +24 -24
  173. package/skills/tests/rdc-status.test.json +29 -29
  174. package/skills/tests/rdc-terminal-config.test.json +29 -29
  175. package/skills/tests/rdc-watch.test.json +24 -24
  176. package/skills/tests/rdc-workitems.test.json +27 -27
  177. package/skills/watch/SKILL.md +97 -97
  178. package/skills/workitems/SKILL.md +151 -151
  179. package/tests/acceptance.test.mjs +59 -59
  180. package/tests/channel-formatter.contract.test.mjs +251 -251
  181. package/tests/curl-surface.test.mjs +289 -289
  182. package/tests/harness-gates.test.mjs +325 -325
  183. package/tests/help-surface.test.mjs +61 -61
  184. package/tests/housekeeping-lessons-triage.test.mjs +49 -0
  185. package/tests/install-rdc-skills.test.mjs +49 -49
  186. package/tests/lessons-pipeline-contract.test.mjs +26 -0
  187. package/tests/manifest-contract-fields.test.mjs +78 -78
  188. package/tests/mcp.test.mjs +271 -271
  189. package/tests/rdc-brochure.test.mjs +125 -0
  190. package/tests/release-contract.test.mjs +16 -0
  191. package/tests/require-work-item-on-commit.test.mjs +162 -162
  192. package/tests/run-evidence-gate.test.mjs +82 -82
  193. package/tests/skill-test-matrix.test.mjs +66 -66
  194. package/tests/validate-skills.js +27 -27
  195. package/tests/work-item-exit-gate-l2.test.mjs +368 -368
  196. package/tests/work-item-exit-gate-l3.test.mjs +197 -197
@@ -1,754 +1,754 @@
1
- // manifest-schema.mjs — Tier 2 test manifest schema + validator.
2
- //
3
- // Source of truth: .rdc/plans/skill-self-test-tier-2.md decision D3.
4
- //
5
- // A manifest describes one behavioral test for an installed skill. The Tier 2
6
- // runner loads a manifest, validates it here, then uses it to drive a headless
7
- // Claude invocation inside a sandbox and assert on real artifacts.
8
- //
9
- // Exports:
10
- // MANIFEST_VERSION — currently 1
11
- // validateManifest(obj) → { ok, manifest, errors, warnings }
12
- // loadManifest(filepath) → same shape, with io/parse errors
13
- // loadAllManifests(dir?) → [{ file, ok, manifest?, errors?, warnings? }]
14
- //
15
- // Error shape: { path, code, msg }. `ok` is false iff errors.length > 0.
16
- // Warnings (e.g. unknown-field) do not fail the manifest.
17
-
18
- import { readFileSync, readdirSync, existsSync } from "node:fs";
19
- import { join, dirname, resolve, basename } from "node:path";
20
- import { fileURLToPath } from "node:url";
21
-
22
- export const MANIFEST_VERSION = 1;
23
-
24
- const VALID_STATUSES = new Set([
25
- "todo",
26
- "in_progress",
27
- "blocked",
28
- "review",
29
- "done",
30
- "archived",
31
- ]);
32
-
33
- const TOP_LEVEL_FIELDS = new Set([
34
- "manifest_version",
35
- "skill",
36
- "description",
37
- "fixture",
38
- "assertions",
39
- "acceptance",
40
- "teardown",
41
- ]);
42
-
43
- const FIXTURE_FIELDS = new Set(["prompt", "precondition_files", "env", "slow"]);
44
-
45
- const ASSERTION_FIELDS = new Set([
46
- "exit_code",
47
- "work_items_created",
48
- "files_modified",
49
- "commits_made",
50
- "stderr_empty",
51
- "stdout_contains",
52
- "stdout_not_contains",
53
- ]);
54
-
55
- const WIC_FIELDS = new Set(["min", "max", "status", "labels_include"]);
56
- const COMMITS_FIELDS = new Set(["min", "message_matches"]);
57
- const TEARDOWN_FIELDS = new Set(["reset_branch"]);
58
- const ACCEPTANCE_FIELDS = new Set([
59
- "output_contains",
60
- "output_not_contains",
61
- "tool_calls_include_any",
62
- "tool_calls_include_all",
63
- "tool_calls_argument_matches",
64
- ]);
65
- const TOOL_CALL_MATCHER_FIELDS = new Set(["tools", "pattern"]);
66
-
67
- const SKILL_RE = /^(rdc:)?[a-z][a-z0-9-]*$/;
68
-
69
- function isPlainObject(v) {
70
- return v !== null && typeof v === "object" && !Array.isArray(v);
71
- }
72
-
73
- function isInt(v) {
74
- return typeof v === "number" && Number.isInteger(v);
75
- }
76
-
77
- function isRelativePath(p) {
78
- if (typeof p !== "string" || p.length === 0) return false;
79
- if (p.startsWith("/") || p.startsWith("\\")) return false;
80
- if (/^[a-zA-Z]:[\\/]/.test(p)) return false; // Windows drive letter
81
- // Split on either slash and reject any '..' segment
82
- const parts = p.split(/[\\/]/);
83
- if (parts.some((seg) => seg === "..")) return false;
84
- return true;
85
- }
86
-
87
- function err(errors, path, code, msg) {
88
- errors.push({ path, code, msg });
89
- }
90
-
91
- function warn(warnings, path, code, msg) {
92
- warnings.push({ path, code, msg });
93
- }
94
-
95
- function validateFixture(fx, errors, warnings) {
96
- if (!isPlainObject(fx)) {
97
- err(errors, "fixture", "type", "fixture must be an object");
98
- return;
99
- }
100
- // prompt
101
- if (typeof fx.prompt !== "string" || fx.prompt.trim().length === 0) {
102
- err(errors, "fixture.prompt", "missing", "fixture.prompt must be a non-empty string");
103
- } else if (!SKILL_RE.test(fx.prompt.split(/\s+/, 1)[0] || "")) {
104
- err(
105
- errors,
106
- "fixture.prompt",
107
- "prompt-not-skill",
108
- `fixture.prompt must start with a valid skill name (got "${fx.prompt.slice(0, 20)}...")`,
109
- );
110
- }
111
-
112
- // precondition_files (optional, default [])
113
- if (fx.precondition_files !== undefined) {
114
- if (!Array.isArray(fx.precondition_files)) {
115
- err(
116
- errors,
117
- "fixture.precondition_files",
118
- "type",
119
- "precondition_files must be an array",
120
- );
121
- } else {
122
- fx.precondition_files.forEach((pf, i) => {
123
- const p = `fixture.precondition_files[${i}]`;
124
- if (!isPlainObject(pf)) {
125
- err(errors, p, "type", "precondition_file must be an object");
126
- return;
127
- }
128
- if (typeof pf.path !== "string" || pf.path.length === 0) {
129
- err(errors, `${p}.path`, "missing", "path must be a non-empty string");
130
- } else if (!isRelativePath(pf.path)) {
131
- err(
132
- errors,
133
- `${p}.path`,
134
- "path-not-relative",
135
- `path "${pf.path}" must be relative (no leading /, no .., no drive letter)`,
136
- );
137
- }
138
- if (typeof pf.content !== "string") {
139
- err(errors, `${p}.content`, "missing", "content must be a string");
140
- }
141
- });
142
- }
143
- }
144
-
145
- // env
146
- if (!isPlainObject(fx.env)) {
147
- err(errors, "fixture.env", "missing", "fixture.env must be an object");
148
- } else {
149
- if (fx.env.RDC_TEST !== "1") {
150
- err(
151
- errors,
152
- "fixture.env.RDC_TEST",
153
- "rdc-test-not-set",
154
- `fixture.env.RDC_TEST must be the literal string "1" (sandbox contract)`,
155
- );
156
- }
157
- for (const [k, v] of Object.entries(fx.env)) {
158
- if (typeof v !== "string") {
159
- err(
160
- errors,
161
- `fixture.env.${k}`,
162
- "type",
163
- `env var "${k}" must be a string (got ${typeof v})`,
164
- );
165
- }
166
- }
167
- }
168
-
169
- // unknown fields in fixture
170
- for (const k of Object.keys(fx)) {
171
- if (!FIXTURE_FIELDS.has(k)) {
172
- warn(warnings, `fixture.${k}`, "unknown-field", `unknown fixture field "${k}"`);
173
- }
174
- }
175
- }
176
-
177
- function validateWorkItems(wic, errors, warnings) {
178
- if (!isPlainObject(wic)) {
179
- err(errors, "assertions.work_items_created", "type", "must be an object");
180
- return;
181
- }
182
- let min, max;
183
- if (wic.min !== undefined) {
184
- if (!isInt(wic.min) || wic.min < 0) {
185
- err(
186
- errors,
187
- "assertions.work_items_created.min",
188
- "type",
189
- "min must be an integer >= 0",
190
- );
191
- } else {
192
- min = wic.min;
193
- }
194
- }
195
- if (wic.max !== undefined) {
196
- if (!isInt(wic.max) || wic.max < 0) {
197
- err(
198
- errors,
199
- "assertions.work_items_created.max",
200
- "type",
201
- "max must be an integer >= 0",
202
- );
203
- } else {
204
- max = wic.max;
205
- }
206
- }
207
- if (min !== undefined && max !== undefined && min > max) {
208
- err(
209
- errors,
210
- "assertions.work_items_created",
211
- "range-invalid",
212
- `min (${min}) must be <= max (${max})`,
213
- );
214
- }
215
- if (wic.status !== undefined) {
216
- if (typeof wic.status !== "string" || !VALID_STATUSES.has(wic.status)) {
217
- err(
218
- errors,
219
- "assertions.work_items_created.status",
220
- "status-invalid",
221
- `status must be one of: ${[...VALID_STATUSES].join(", ")}`,
222
- );
223
- }
224
- }
225
- if (wic.labels_include !== undefined) {
226
- if (!Array.isArray(wic.labels_include)) {
227
- err(
228
- errors,
229
- "assertions.work_items_created.labels_include",
230
- "type",
231
- "labels_include must be an array",
232
- );
233
- } else {
234
- wic.labels_include.forEach((lbl, i) => {
235
- if (typeof lbl !== "string") {
236
- err(
237
- errors,
238
- `assertions.work_items_created.labels_include[${i}]`,
239
- "type",
240
- "label must be a string",
241
- );
242
- }
243
- });
244
- }
245
- }
246
- for (const k of Object.keys(wic)) {
247
- if (!WIC_FIELDS.has(k)) {
248
- warn(
249
- warnings,
250
- `assertions.work_items_created.${k}`,
251
- "unknown-field",
252
- `unknown field "${k}"`,
253
- );
254
- }
255
- }
256
- }
257
-
258
- function validateCommits(cm, errors, warnings) {
259
- if (!isPlainObject(cm)) {
260
- err(errors, "assertions.commits_made", "type", "must be an object");
261
- return;
262
- }
263
- if (cm.min !== undefined && (!isInt(cm.min) || cm.min < 0)) {
264
- err(errors, "assertions.commits_made.min", "type", "min must be an integer >= 0");
265
- }
266
- if (cm.message_matches !== undefined) {
267
- if (typeof cm.message_matches !== "string") {
268
- err(
269
- errors,
270
- "assertions.commits_made.message_matches",
271
- "type",
272
- "message_matches must be a string (regex source)",
273
- );
274
- } else {
275
- try {
276
- new RegExp(cm.message_matches);
277
- } catch (e) {
278
- err(
279
- errors,
280
- "assertions.commits_made.message_matches",
281
- "invalid-regex",
282
- `invalid regex: ${e.message}`,
283
- );
284
- }
285
- }
286
- }
287
- for (const k of Object.keys(cm)) {
288
- if (!COMMITS_FIELDS.has(k)) {
289
- warn(warnings, `assertions.commits_made.${k}`, "unknown-field", `unknown field "${k}"`);
290
- }
291
- }
292
- }
293
-
294
- function validateAssertions(a, errors, warnings) {
295
- if (!isPlainObject(a)) {
296
- err(errors, "assertions", "type", "assertions must be an object");
297
- return;
298
- }
299
- if (Object.keys(a).length === 0) {
300
- warn(
301
- warnings,
302
- "assertions",
303
- "empty-assertions",
304
- "assertions object is empty — test will always pass",
305
- );
306
- }
307
- if (a.exit_code !== undefined) {
308
- if (!isInt(a.exit_code) || a.exit_code < 0) {
309
- err(errors, "assertions.exit_code", "type", "exit_code must be an integer >= 0");
310
- }
311
- }
312
- if (a.work_items_created !== undefined) {
313
- validateWorkItems(a.work_items_created, errors, warnings);
314
- }
315
- if (a.files_modified !== undefined) {
316
- if (!Array.isArray(a.files_modified)) {
317
- err(errors, "assertions.files_modified", "type", "files_modified must be an array");
318
- } else {
319
- a.files_modified.forEach((p, i) => {
320
- if (typeof p !== "string") {
321
- err(errors, `assertions.files_modified[${i}]`, "type", "path must be a string");
322
- } else if (!isRelativePath(p)) {
323
- err(
324
- errors,
325
- `assertions.files_modified[${i}]`,
326
- "path-not-relative",
327
- `path "${p}" must be relative`,
328
- );
329
- }
330
- });
331
- }
332
- }
333
- if (a.commits_made !== undefined) {
334
- validateCommits(a.commits_made, errors, warnings);
335
- }
336
- if (a.stderr_empty !== undefined && typeof a.stderr_empty !== "boolean") {
337
- err(errors, "assertions.stderr_empty", "type", "stderr_empty must be a boolean");
338
- }
339
- if (a.stdout_contains !== undefined) {
340
- if (!Array.isArray(a.stdout_contains)) {
341
- err(errors, "assertions.stdout_contains", "type", "stdout_contains must be an array");
342
- } else {
343
- a.stdout_contains.forEach((s, i) => {
344
- if (typeof s !== "string") {
345
- err(errors, `assertions.stdout_contains[${i}]`, "type", "entry must be a string");
346
- }
347
- });
348
- }
349
- }
350
- if (a.stdout_not_contains !== undefined) {
351
- if (!Array.isArray(a.stdout_not_contains)) {
352
- err(errors, "assertions.stdout_not_contains", "type", "stdout_not_contains must be an array");
353
- } else {
354
- a.stdout_not_contains.forEach((s, i) => {
355
- if (typeof s !== "string") {
356
- err(errors, `assertions.stdout_not_contains[${i}]`, "type", "entry must be a string");
357
- }
358
- });
359
- }
360
- }
361
- for (const k of Object.keys(a)) {
362
- if (!ASSERTION_FIELDS.has(k)) {
363
- warn(warnings, `assertions.${k}`, "unknown-field", `unknown assertion "${k}"`);
364
- }
365
- }
366
- }
367
-
368
- function validateTeardown(t, errors, warnings) {
369
- if (!isPlainObject(t)) {
370
- err(errors, "teardown", "type", "teardown must be an object");
371
- return;
372
- }
373
- if (t.reset_branch !== undefined && typeof t.reset_branch !== "boolean") {
374
- err(errors, "teardown.reset_branch", "type", "reset_branch must be a boolean");
375
- }
376
- for (const k of Object.keys(t)) {
377
- if (!TEARDOWN_FIELDS.has(k)) {
378
- warn(warnings, `teardown.${k}`, "unknown-field", `unknown teardown field "${k}"`);
379
- }
380
- }
381
- }
382
-
383
- function validateStringArray(value, path, errors) {
384
- if (!Array.isArray(value)) {
385
- err(errors, path, "type", `${path} must be an array`);
386
- return;
387
- }
388
- value.forEach((s, i) => {
389
- if (typeof s !== "string" || s.length === 0) {
390
- err(errors, `${path}[${i}]`, "type", "entry must be a non-empty string");
391
- }
392
- });
393
- }
394
-
395
- function validateAcceptance(a, errors, warnings) {
396
- if (!isPlainObject(a)) {
397
- err(errors, "acceptance", "type", "acceptance must be an object");
398
- return;
399
- }
400
-
401
- if (a.output_contains !== undefined) {
402
- validateStringArray(a.output_contains, "acceptance.output_contains", errors);
403
- }
404
- if (a.output_not_contains !== undefined) {
405
- validateStringArray(a.output_not_contains, "acceptance.output_not_contains", errors);
406
- }
407
- if (a.tool_calls_include_any !== undefined) {
408
- validateStringArray(a.tool_calls_include_any, "acceptance.tool_calls_include_any", errors);
409
- }
410
- if (a.tool_calls_include_all !== undefined) {
411
- validateStringArray(a.tool_calls_include_all, "acceptance.tool_calls_include_all", errors);
412
- }
413
-
414
- if (a.tool_calls_argument_matches !== undefined) {
415
- if (!Array.isArray(a.tool_calls_argument_matches)) {
416
- err(
417
- errors,
418
- "acceptance.tool_calls_argument_matches",
419
- "type",
420
- "tool_calls_argument_matches must be an array",
421
- );
422
- } else {
423
- a.tool_calls_argument_matches.forEach((matcher, i) => {
424
- const p = `acceptance.tool_calls_argument_matches[${i}]`;
425
- if (!isPlainObject(matcher)) {
426
- err(errors, p, "type", "matcher must be an object");
427
- return;
428
- }
429
- validateStringArray(matcher.tools, `${p}.tools`, errors);
430
- if (typeof matcher.pattern !== "string" || matcher.pattern.length === 0) {
431
- err(errors, `${p}.pattern`, "type", "pattern must be a non-empty regex string");
432
- } else {
433
- try {
434
- new RegExp(matcher.pattern);
435
- } catch (e) {
436
- err(errors, `${p}.pattern`, "invalid-regex", `invalid regex: ${e.message}`);
437
- }
438
- }
439
- for (const k of Object.keys(matcher)) {
440
- if (!TOOL_CALL_MATCHER_FIELDS.has(k)) {
441
- warn(warnings, `${p}.${k}`, "unknown-field", `unknown matcher field "${k}"`);
442
- }
443
- }
444
- });
445
- }
446
- }
447
-
448
- for (const k of Object.keys(a)) {
449
- if (!ACCEPTANCE_FIELDS.has(k)) {
450
- warn(warnings, `acceptance.${k}`, "unknown-field", `unknown acceptance field "${k}"`);
451
- }
452
- }
453
- }
454
-
455
- export function validateManifest(raw) {
456
- const errors = [];
457
- const warnings = [];
458
-
459
- if (!isPlainObject(raw)) {
460
- return {
461
- ok: false,
462
- manifest: null,
463
- errors: [{ path: "", code: "type", msg: "manifest must be a JSON object" }],
464
- warnings: [],
465
- };
466
- }
467
-
468
- // manifest_version
469
- if (raw.manifest_version === undefined) {
470
- err(errors, "manifest_version", "missing", "manifest_version is required");
471
- } else if (!isInt(raw.manifest_version) || raw.manifest_version !== MANIFEST_VERSION) {
472
- err(
473
- errors,
474
- "manifest_version",
475
- "version-unsupported",
476
- `manifest_version must be ${MANIFEST_VERSION} (got ${JSON.stringify(raw.manifest_version)})`,
477
- );
478
- }
479
-
480
- // skill
481
- if (raw.skill === undefined) {
482
- err(errors, "skill", "missing", "skill is required");
483
- } else if (typeof raw.skill !== "string" || !SKILL_RE.test(raw.skill)) {
484
- err(
485
- errors,
486
- "skill",
487
- "skill-invalid",
488
- `skill must be a slash skill like "rdc:build" or bare skill like "lifeai-brochure-author" (got ${JSON.stringify(raw.skill)})`,
489
- );
490
- }
491
-
492
- // description
493
- if (raw.description === undefined) {
494
- err(errors, "description", "missing", "description is required");
495
- } else if (typeof raw.description !== "string" || raw.description.trim().length === 0) {
496
- err(errors, "description", "type", "description must be a non-empty string");
497
- }
498
-
499
- // fixture
500
- if (raw.fixture === undefined) {
501
- err(errors, "fixture", "missing", "fixture is required");
502
- } else {
503
- validateFixture(raw.fixture, errors, warnings);
504
- }
505
-
506
- // assertions
507
- if (raw.assertions === undefined) {
508
- err(errors, "assertions", "missing", "assertions is required");
509
- } else {
510
- validateAssertions(raw.assertions, errors, warnings);
511
- }
512
-
513
- if (raw.acceptance !== undefined) {
514
- validateAcceptance(raw.acceptance, errors, warnings);
515
- }
516
-
517
- // teardown (optional)
518
- if (raw.teardown !== undefined) {
519
- validateTeardown(raw.teardown, errors, warnings);
520
- }
521
-
522
- // unknown top-level fields → warning
523
- for (const k of Object.keys(raw)) {
524
- if (!TOP_LEVEL_FIELDS.has(k)) {
525
- warn(warnings, k, "unknown-field", `unknown top-level field "${k}"`);
526
- }
527
- }
528
-
529
- const ok = errors.length === 0;
530
- return { ok, manifest: ok ? raw : null, errors, warnings };
531
- }
532
-
533
- export function loadManifest(filepath) {
534
- let text;
535
- try {
536
- text = readFileSync(filepath, "utf8");
537
- } catch (e) {
538
- return {
539
- ok: false,
540
- manifest: null,
541
- errors: [{ path: "", code: "io", msg: `cannot read ${filepath}: ${e.message}` }],
542
- warnings: [],
543
- };
544
- }
545
- let parsed;
546
- try {
547
- parsed = JSON.parse(text);
548
- } catch (e) {
549
- return {
550
- ok: false,
551
- manifest: null,
552
- errors: [{ path: "", code: "parse", msg: `invalid JSON in ${filepath}: ${e.message}` }],
553
- warnings: [],
554
- };
555
- }
556
- return validateManifest(parsed);
557
- }
558
-
559
- export function loadAllManifests(dir) {
560
- const __dirname = dirname(fileURLToPath(import.meta.url));
561
- const REPO_ROOT = resolve(__dirname, "..", "..");
562
- const targetDir = dir || join(REPO_ROOT, "skills", "tests");
563
- if (!existsSync(targetDir)) return [];
564
- let files;
565
- try {
566
- files = readdirSync(targetDir).filter((f) => f.endsWith(".test.json"));
567
- } catch {
568
- return [];
569
- }
570
- return files.map((f) => {
571
- const full = join(targetDir, f);
572
- const res = loadManifest(full);
573
- return { file: `skills/tests/${basename(full)}`, ...res };
574
- });
575
- }
576
-
577
- // ─── Self-test ──────────────────────────────────────────────────────────────
578
- const __isMain = (() => {
579
- try {
580
- return fileURLToPath(import.meta.url) === resolve(process.argv[1] || "");
581
- } catch {
582
- return false;
583
- }
584
- })();
585
- if (__isMain) {
586
- const validMinimal = {
587
- manifest_version: 1,
588
- skill: "rdc:fixit",
589
- description: "minimal valid",
590
- fixture: {
591
- prompt: "rdc:fixit fix typo",
592
- env: { RDC_TEST: "1" },
593
- },
594
- assertions: { exit_code: 0 },
595
- };
596
-
597
- const cases = [
598
- {
599
- n: 1,
600
- desc: "valid minimal manifest",
601
- input: validMinimal,
602
- expect: (r) => r.ok && r.errors.length === 0 && r.warnings.length === 0,
603
- expectStr: "ok=true, 0 errors, 0 warnings",
604
- },
605
- {
606
- n: 2,
607
- desc: "missing skill",
608
- input: { ...validMinimal, skill: undefined },
609
- expect: (r) =>
610
- !r.ok && r.errors.some((e) => e.path === "skill" && e.code === "missing"),
611
- expectStr: "error code=missing on skill",
612
- },
613
- {
614
- n: 3,
615
- desc: "manifest_version: 2",
616
- input: { ...validMinimal, manifest_version: 2 },
617
- expect: (r) =>
618
- !r.ok && r.errors.some((e) => e.code === "version-unsupported"),
619
- expectStr: "error code=version-unsupported",
620
- },
621
- {
622
- n: 4,
623
- desc: 'fixture.env.RDC_TEST: "0"',
624
- input: {
625
- ...validMinimal,
626
- fixture: { ...validMinimal.fixture, env: { RDC_TEST: "0" } },
627
- },
628
- expect: (r) => !r.ok && r.errors.some((e) => e.code === "rdc-test-not-set"),
629
- expectStr: "error code=rdc-test-not-set",
630
- },
631
- {
632
- n: 4.5,
633
- desc: "valid bare installed skill",
634
- input: {
635
- ...validMinimal,
636
- skill: "lifeai-brochure-author",
637
- fixture: { ...validMinimal.fixture, prompt: "lifeai-brochure-author review fixture" },
638
- },
639
- expect: (r) => r.ok && r.errors.length === 0,
640
- expectStr: "ok=true, 0 errors",
641
- },
642
- {
643
- n: 5,
644
- desc: "invalid regex in commits_made.message_matches",
645
- input: {
646
- ...validMinimal,
647
- assertions: {
648
- exit_code: 0,
649
- commits_made: { min: 1, message_matches: "[unclosed" },
650
- },
651
- },
652
- expect: (r) => !r.ok && r.errors.some((e) => e.code === "invalid-regex"),
653
- expectStr: "error code=invalid-regex",
654
- },
655
- {
656
- n: 6,
657
- desc: "unknown top-level field `extra: 1`",
658
- input: { ...validMinimal, extra: 1 },
659
- expect: (r) =>
660
- r.ok &&
661
- r.warnings.length === 1 &&
662
- r.warnings[0].code === "unknown-field" &&
663
- r.warnings[0].path === "extra",
664
- expectStr: "ok=true, 1 warning code=unknown-field",
665
- },
666
- {
667
- n: 7,
668
- desc: "work_items_created.min > max",
669
- input: {
670
- ...validMinimal,
671
- assertions: {
672
- exit_code: 0,
673
- work_items_created: { min: 5, max: 2 },
674
- },
675
- },
676
- expect: (r) => !r.ok && r.errors.some((e) => e.code === "range-invalid"),
677
- expectStr: "error code=range-invalid",
678
- },
679
- {
680
- n: 8,
681
- desc: 'files_modified: ["/abs/path"]',
682
- input: {
683
- ...validMinimal,
684
- assertions: { exit_code: 0, files_modified: ["/abs/path"] },
685
- },
686
- expect: (r) => !r.ok && r.errors.some((e) => e.code === "path-not-relative"),
687
- expectStr: "error code=path-not-relative",
688
- },
689
- {
690
- n: 9,
691
- desc: "valid acceptance tool matcher",
692
- input: {
693
- ...validMinimal,
694
- acceptance: {
695
- output_contains: ["PASS"],
696
- output_not_contains: ["fabricated"],
697
- tool_calls_include_any: ["Grep", "Glob"],
698
- tool_calls_argument_matches: [{ tools: ["Grep"], pattern: "global-corpus|web-search" }],
699
- },
700
- },
701
- expect: (r) => r.ok && r.errors.length === 0,
702
- expectStr: "ok=true, 0 errors",
703
- },
704
- {
705
- n: 10,
706
- desc: "invalid acceptance matcher regex",
707
- input: {
708
- ...validMinimal,
709
- acceptance: {
710
- tool_calls_argument_matches: [{ tools: ["Grep"], pattern: "[unclosed" }],
711
- },
712
- },
713
- expect: (r) => !r.ok && r.errors.some((e) => e.path.endsWith(".pattern") && e.code === "invalid-regex"),
714
- expectStr: "error code=invalid-regex",
715
- },
716
- {
717
- n: 11,
718
- desc: "unknown acceptance field",
719
- input: {
720
- ...validMinimal,
721
- acceptance: { made_up: true },
722
- },
723
- expect: (r) =>
724
- r.ok &&
725
- r.warnings.some((w) => w.path === "acceptance.made_up" && w.code === "unknown-field"),
726
- expectStr: "ok=true, warning code=unknown-field",
727
- },
728
- ];
729
-
730
- const pad = (s, n) => String(s) + " ".repeat(Math.max(0, n - String(s).length));
731
- console.log("\nmanifest-schema self-test\n");
732
- console.log(pad("#", 3) + pad("description", 44) + pad("expected", 36) + pad("actual", 36) + "result");
733
- console.log("─".repeat(130));
734
-
735
- let passed = 0;
736
- for (const c of cases) {
737
- const r = validateManifest(c.input);
738
- const ok = c.expect(r);
739
- const actual = `ok=${r.ok}, ${r.errors.length}e/${r.warnings.length}w${
740
- r.errors[0] ? ` [${r.errors[0].code}]` : ""
741
- }${r.warnings[0] && r.errors.length === 0 ? ` [${r.warnings[0].code}]` : ""}`;
742
- console.log(
743
- pad(c.n, 3) +
744
- pad(c.desc, 44) +
745
- pad(c.expectStr, 36) +
746
- pad(actual, 36) +
747
- (ok ? "PASS" : "FAIL"),
748
- );
749
- if (ok) passed++;
750
- }
751
- console.log("─".repeat(130));
752
- console.log(`\n${passed}/${cases.length} passed\n`);
753
- process.exit(passed === cases.length ? 0 : 1);
754
- }
1
+ // manifest-schema.mjs — Tier 2 test manifest schema + validator.
2
+ //
3
+ // Source of truth: .rdc/plans/skill-self-test-tier-2.md decision D3.
4
+ //
5
+ // A manifest describes one behavioral test for an installed skill. The Tier 2
6
+ // runner loads a manifest, validates it here, then uses it to drive a headless
7
+ // Claude invocation inside a sandbox and assert on real artifacts.
8
+ //
9
+ // Exports:
10
+ // MANIFEST_VERSION — currently 1
11
+ // validateManifest(obj) → { ok, manifest, errors, warnings }
12
+ // loadManifest(filepath) → same shape, with io/parse errors
13
+ // loadAllManifests(dir?) → [{ file, ok, manifest?, errors?, warnings? }]
14
+ //
15
+ // Error shape: { path, code, msg }. `ok` is false iff errors.length > 0.
16
+ // Warnings (e.g. unknown-field) do not fail the manifest.
17
+
18
+ import { readFileSync, readdirSync, existsSync } from "node:fs";
19
+ import { join, dirname, resolve, basename } from "node:path";
20
+ import { fileURLToPath } from "node:url";
21
+
22
+ export const MANIFEST_VERSION = 1;
23
+
24
+ const VALID_STATUSES = new Set([
25
+ "todo",
26
+ "in_progress",
27
+ "blocked",
28
+ "review",
29
+ "done",
30
+ "archived",
31
+ ]);
32
+
33
+ const TOP_LEVEL_FIELDS = new Set([
34
+ "manifest_version",
35
+ "skill",
36
+ "description",
37
+ "fixture",
38
+ "assertions",
39
+ "acceptance",
40
+ "teardown",
41
+ ]);
42
+
43
+ const FIXTURE_FIELDS = new Set(["prompt", "precondition_files", "env", "slow"]);
44
+
45
+ const ASSERTION_FIELDS = new Set([
46
+ "exit_code",
47
+ "work_items_created",
48
+ "files_modified",
49
+ "commits_made",
50
+ "stderr_empty",
51
+ "stdout_contains",
52
+ "stdout_not_contains",
53
+ ]);
54
+
55
+ const WIC_FIELDS = new Set(["min", "max", "status", "labels_include"]);
56
+ const COMMITS_FIELDS = new Set(["min", "message_matches"]);
57
+ const TEARDOWN_FIELDS = new Set(["reset_branch"]);
58
+ const ACCEPTANCE_FIELDS = new Set([
59
+ "output_contains",
60
+ "output_not_contains",
61
+ "tool_calls_include_any",
62
+ "tool_calls_include_all",
63
+ "tool_calls_argument_matches",
64
+ ]);
65
+ const TOOL_CALL_MATCHER_FIELDS = new Set(["tools", "pattern"]);
66
+
67
+ const SKILL_RE = /^(rdc:)?[a-z][a-z0-9-]*$/;
68
+
69
+ function isPlainObject(v) {
70
+ return v !== null && typeof v === "object" && !Array.isArray(v);
71
+ }
72
+
73
+ function isInt(v) {
74
+ return typeof v === "number" && Number.isInteger(v);
75
+ }
76
+
77
+ function isRelativePath(p) {
78
+ if (typeof p !== "string" || p.length === 0) return false;
79
+ if (p.startsWith("/") || p.startsWith("\\")) return false;
80
+ if (/^[a-zA-Z]:[\\/]/.test(p)) return false; // Windows drive letter
81
+ // Split on either slash and reject any '..' segment
82
+ const parts = p.split(/[\\/]/);
83
+ if (parts.some((seg) => seg === "..")) return false;
84
+ return true;
85
+ }
86
+
87
+ function err(errors, path, code, msg) {
88
+ errors.push({ path, code, msg });
89
+ }
90
+
91
+ function warn(warnings, path, code, msg) {
92
+ warnings.push({ path, code, msg });
93
+ }
94
+
95
+ function validateFixture(fx, errors, warnings) {
96
+ if (!isPlainObject(fx)) {
97
+ err(errors, "fixture", "type", "fixture must be an object");
98
+ return;
99
+ }
100
+ // prompt
101
+ if (typeof fx.prompt !== "string" || fx.prompt.trim().length === 0) {
102
+ err(errors, "fixture.prompt", "missing", "fixture.prompt must be a non-empty string");
103
+ } else if (!SKILL_RE.test(fx.prompt.split(/\s+/, 1)[0] || "")) {
104
+ err(
105
+ errors,
106
+ "fixture.prompt",
107
+ "prompt-not-skill",
108
+ `fixture.prompt must start with a valid skill name (got "${fx.prompt.slice(0, 20)}...")`,
109
+ );
110
+ }
111
+
112
+ // precondition_files (optional, default [])
113
+ if (fx.precondition_files !== undefined) {
114
+ if (!Array.isArray(fx.precondition_files)) {
115
+ err(
116
+ errors,
117
+ "fixture.precondition_files",
118
+ "type",
119
+ "precondition_files must be an array",
120
+ );
121
+ } else {
122
+ fx.precondition_files.forEach((pf, i) => {
123
+ const p = `fixture.precondition_files[${i}]`;
124
+ if (!isPlainObject(pf)) {
125
+ err(errors, p, "type", "precondition_file must be an object");
126
+ return;
127
+ }
128
+ if (typeof pf.path !== "string" || pf.path.length === 0) {
129
+ err(errors, `${p}.path`, "missing", "path must be a non-empty string");
130
+ } else if (!isRelativePath(pf.path)) {
131
+ err(
132
+ errors,
133
+ `${p}.path`,
134
+ "path-not-relative",
135
+ `path "${pf.path}" must be relative (no leading /, no .., no drive letter)`,
136
+ );
137
+ }
138
+ if (typeof pf.content !== "string") {
139
+ err(errors, `${p}.content`, "missing", "content must be a string");
140
+ }
141
+ });
142
+ }
143
+ }
144
+
145
+ // env
146
+ if (!isPlainObject(fx.env)) {
147
+ err(errors, "fixture.env", "missing", "fixture.env must be an object");
148
+ } else {
149
+ if (fx.env.RDC_TEST !== "1") {
150
+ err(
151
+ errors,
152
+ "fixture.env.RDC_TEST",
153
+ "rdc-test-not-set",
154
+ `fixture.env.RDC_TEST must be the literal string "1" (sandbox contract)`,
155
+ );
156
+ }
157
+ for (const [k, v] of Object.entries(fx.env)) {
158
+ if (typeof v !== "string") {
159
+ err(
160
+ errors,
161
+ `fixture.env.${k}`,
162
+ "type",
163
+ `env var "${k}" must be a string (got ${typeof v})`,
164
+ );
165
+ }
166
+ }
167
+ }
168
+
169
+ // unknown fields in fixture
170
+ for (const k of Object.keys(fx)) {
171
+ if (!FIXTURE_FIELDS.has(k)) {
172
+ warn(warnings, `fixture.${k}`, "unknown-field", `unknown fixture field "${k}"`);
173
+ }
174
+ }
175
+ }
176
+
177
+ function validateWorkItems(wic, errors, warnings) {
178
+ if (!isPlainObject(wic)) {
179
+ err(errors, "assertions.work_items_created", "type", "must be an object");
180
+ return;
181
+ }
182
+ let min, max;
183
+ if (wic.min !== undefined) {
184
+ if (!isInt(wic.min) || wic.min < 0) {
185
+ err(
186
+ errors,
187
+ "assertions.work_items_created.min",
188
+ "type",
189
+ "min must be an integer >= 0",
190
+ );
191
+ } else {
192
+ min = wic.min;
193
+ }
194
+ }
195
+ if (wic.max !== undefined) {
196
+ if (!isInt(wic.max) || wic.max < 0) {
197
+ err(
198
+ errors,
199
+ "assertions.work_items_created.max",
200
+ "type",
201
+ "max must be an integer >= 0",
202
+ );
203
+ } else {
204
+ max = wic.max;
205
+ }
206
+ }
207
+ if (min !== undefined && max !== undefined && min > max) {
208
+ err(
209
+ errors,
210
+ "assertions.work_items_created",
211
+ "range-invalid",
212
+ `min (${min}) must be <= max (${max})`,
213
+ );
214
+ }
215
+ if (wic.status !== undefined) {
216
+ if (typeof wic.status !== "string" || !VALID_STATUSES.has(wic.status)) {
217
+ err(
218
+ errors,
219
+ "assertions.work_items_created.status",
220
+ "status-invalid",
221
+ `status must be one of: ${[...VALID_STATUSES].join(", ")}`,
222
+ );
223
+ }
224
+ }
225
+ if (wic.labels_include !== undefined) {
226
+ if (!Array.isArray(wic.labels_include)) {
227
+ err(
228
+ errors,
229
+ "assertions.work_items_created.labels_include",
230
+ "type",
231
+ "labels_include must be an array",
232
+ );
233
+ } else {
234
+ wic.labels_include.forEach((lbl, i) => {
235
+ if (typeof lbl !== "string") {
236
+ err(
237
+ errors,
238
+ `assertions.work_items_created.labels_include[${i}]`,
239
+ "type",
240
+ "label must be a string",
241
+ );
242
+ }
243
+ });
244
+ }
245
+ }
246
+ for (const k of Object.keys(wic)) {
247
+ if (!WIC_FIELDS.has(k)) {
248
+ warn(
249
+ warnings,
250
+ `assertions.work_items_created.${k}`,
251
+ "unknown-field",
252
+ `unknown field "${k}"`,
253
+ );
254
+ }
255
+ }
256
+ }
257
+
258
+ function validateCommits(cm, errors, warnings) {
259
+ if (!isPlainObject(cm)) {
260
+ err(errors, "assertions.commits_made", "type", "must be an object");
261
+ return;
262
+ }
263
+ if (cm.min !== undefined && (!isInt(cm.min) || cm.min < 0)) {
264
+ err(errors, "assertions.commits_made.min", "type", "min must be an integer >= 0");
265
+ }
266
+ if (cm.message_matches !== undefined) {
267
+ if (typeof cm.message_matches !== "string") {
268
+ err(
269
+ errors,
270
+ "assertions.commits_made.message_matches",
271
+ "type",
272
+ "message_matches must be a string (regex source)",
273
+ );
274
+ } else {
275
+ try {
276
+ new RegExp(cm.message_matches);
277
+ } catch (e) {
278
+ err(
279
+ errors,
280
+ "assertions.commits_made.message_matches",
281
+ "invalid-regex",
282
+ `invalid regex: ${e.message}`,
283
+ );
284
+ }
285
+ }
286
+ }
287
+ for (const k of Object.keys(cm)) {
288
+ if (!COMMITS_FIELDS.has(k)) {
289
+ warn(warnings, `assertions.commits_made.${k}`, "unknown-field", `unknown field "${k}"`);
290
+ }
291
+ }
292
+ }
293
+
294
+ function validateAssertions(a, errors, warnings) {
295
+ if (!isPlainObject(a)) {
296
+ err(errors, "assertions", "type", "assertions must be an object");
297
+ return;
298
+ }
299
+ if (Object.keys(a).length === 0) {
300
+ warn(
301
+ warnings,
302
+ "assertions",
303
+ "empty-assertions",
304
+ "assertions object is empty — test will always pass",
305
+ );
306
+ }
307
+ if (a.exit_code !== undefined) {
308
+ if (!isInt(a.exit_code) || a.exit_code < 0) {
309
+ err(errors, "assertions.exit_code", "type", "exit_code must be an integer >= 0");
310
+ }
311
+ }
312
+ if (a.work_items_created !== undefined) {
313
+ validateWorkItems(a.work_items_created, errors, warnings);
314
+ }
315
+ if (a.files_modified !== undefined) {
316
+ if (!Array.isArray(a.files_modified)) {
317
+ err(errors, "assertions.files_modified", "type", "files_modified must be an array");
318
+ } else {
319
+ a.files_modified.forEach((p, i) => {
320
+ if (typeof p !== "string") {
321
+ err(errors, `assertions.files_modified[${i}]`, "type", "path must be a string");
322
+ } else if (!isRelativePath(p)) {
323
+ err(
324
+ errors,
325
+ `assertions.files_modified[${i}]`,
326
+ "path-not-relative",
327
+ `path "${p}" must be relative`,
328
+ );
329
+ }
330
+ });
331
+ }
332
+ }
333
+ if (a.commits_made !== undefined) {
334
+ validateCommits(a.commits_made, errors, warnings);
335
+ }
336
+ if (a.stderr_empty !== undefined && typeof a.stderr_empty !== "boolean") {
337
+ err(errors, "assertions.stderr_empty", "type", "stderr_empty must be a boolean");
338
+ }
339
+ if (a.stdout_contains !== undefined) {
340
+ if (!Array.isArray(a.stdout_contains)) {
341
+ err(errors, "assertions.stdout_contains", "type", "stdout_contains must be an array");
342
+ } else {
343
+ a.stdout_contains.forEach((s, i) => {
344
+ if (typeof s !== "string") {
345
+ err(errors, `assertions.stdout_contains[${i}]`, "type", "entry must be a string");
346
+ }
347
+ });
348
+ }
349
+ }
350
+ if (a.stdout_not_contains !== undefined) {
351
+ if (!Array.isArray(a.stdout_not_contains)) {
352
+ err(errors, "assertions.stdout_not_contains", "type", "stdout_not_contains must be an array");
353
+ } else {
354
+ a.stdout_not_contains.forEach((s, i) => {
355
+ if (typeof s !== "string") {
356
+ err(errors, `assertions.stdout_not_contains[${i}]`, "type", "entry must be a string");
357
+ }
358
+ });
359
+ }
360
+ }
361
+ for (const k of Object.keys(a)) {
362
+ if (!ASSERTION_FIELDS.has(k)) {
363
+ warn(warnings, `assertions.${k}`, "unknown-field", `unknown assertion "${k}"`);
364
+ }
365
+ }
366
+ }
367
+
368
+ function validateTeardown(t, errors, warnings) {
369
+ if (!isPlainObject(t)) {
370
+ err(errors, "teardown", "type", "teardown must be an object");
371
+ return;
372
+ }
373
+ if (t.reset_branch !== undefined && typeof t.reset_branch !== "boolean") {
374
+ err(errors, "teardown.reset_branch", "type", "reset_branch must be a boolean");
375
+ }
376
+ for (const k of Object.keys(t)) {
377
+ if (!TEARDOWN_FIELDS.has(k)) {
378
+ warn(warnings, `teardown.${k}`, "unknown-field", `unknown teardown field "${k}"`);
379
+ }
380
+ }
381
+ }
382
+
383
+ function validateStringArray(value, path, errors) {
384
+ if (!Array.isArray(value)) {
385
+ err(errors, path, "type", `${path} must be an array`);
386
+ return;
387
+ }
388
+ value.forEach((s, i) => {
389
+ if (typeof s !== "string" || s.length === 0) {
390
+ err(errors, `${path}[${i}]`, "type", "entry must be a non-empty string");
391
+ }
392
+ });
393
+ }
394
+
395
+ function validateAcceptance(a, errors, warnings) {
396
+ if (!isPlainObject(a)) {
397
+ err(errors, "acceptance", "type", "acceptance must be an object");
398
+ return;
399
+ }
400
+
401
+ if (a.output_contains !== undefined) {
402
+ validateStringArray(a.output_contains, "acceptance.output_contains", errors);
403
+ }
404
+ if (a.output_not_contains !== undefined) {
405
+ validateStringArray(a.output_not_contains, "acceptance.output_not_contains", errors);
406
+ }
407
+ if (a.tool_calls_include_any !== undefined) {
408
+ validateStringArray(a.tool_calls_include_any, "acceptance.tool_calls_include_any", errors);
409
+ }
410
+ if (a.tool_calls_include_all !== undefined) {
411
+ validateStringArray(a.tool_calls_include_all, "acceptance.tool_calls_include_all", errors);
412
+ }
413
+
414
+ if (a.tool_calls_argument_matches !== undefined) {
415
+ if (!Array.isArray(a.tool_calls_argument_matches)) {
416
+ err(
417
+ errors,
418
+ "acceptance.tool_calls_argument_matches",
419
+ "type",
420
+ "tool_calls_argument_matches must be an array",
421
+ );
422
+ } else {
423
+ a.tool_calls_argument_matches.forEach((matcher, i) => {
424
+ const p = `acceptance.tool_calls_argument_matches[${i}]`;
425
+ if (!isPlainObject(matcher)) {
426
+ err(errors, p, "type", "matcher must be an object");
427
+ return;
428
+ }
429
+ validateStringArray(matcher.tools, `${p}.tools`, errors);
430
+ if (typeof matcher.pattern !== "string" || matcher.pattern.length === 0) {
431
+ err(errors, `${p}.pattern`, "type", "pattern must be a non-empty regex string");
432
+ } else {
433
+ try {
434
+ new RegExp(matcher.pattern);
435
+ } catch (e) {
436
+ err(errors, `${p}.pattern`, "invalid-regex", `invalid regex: ${e.message}`);
437
+ }
438
+ }
439
+ for (const k of Object.keys(matcher)) {
440
+ if (!TOOL_CALL_MATCHER_FIELDS.has(k)) {
441
+ warn(warnings, `${p}.${k}`, "unknown-field", `unknown matcher field "${k}"`);
442
+ }
443
+ }
444
+ });
445
+ }
446
+ }
447
+
448
+ for (const k of Object.keys(a)) {
449
+ if (!ACCEPTANCE_FIELDS.has(k)) {
450
+ warn(warnings, `acceptance.${k}`, "unknown-field", `unknown acceptance field "${k}"`);
451
+ }
452
+ }
453
+ }
454
+
455
+ export function validateManifest(raw) {
456
+ const errors = [];
457
+ const warnings = [];
458
+
459
+ if (!isPlainObject(raw)) {
460
+ return {
461
+ ok: false,
462
+ manifest: null,
463
+ errors: [{ path: "", code: "type", msg: "manifest must be a JSON object" }],
464
+ warnings: [],
465
+ };
466
+ }
467
+
468
+ // manifest_version
469
+ if (raw.manifest_version === undefined) {
470
+ err(errors, "manifest_version", "missing", "manifest_version is required");
471
+ } else if (!isInt(raw.manifest_version) || raw.manifest_version !== MANIFEST_VERSION) {
472
+ err(
473
+ errors,
474
+ "manifest_version",
475
+ "version-unsupported",
476
+ `manifest_version must be ${MANIFEST_VERSION} (got ${JSON.stringify(raw.manifest_version)})`,
477
+ );
478
+ }
479
+
480
+ // skill
481
+ if (raw.skill === undefined) {
482
+ err(errors, "skill", "missing", "skill is required");
483
+ } else if (typeof raw.skill !== "string" || !SKILL_RE.test(raw.skill)) {
484
+ err(
485
+ errors,
486
+ "skill",
487
+ "skill-invalid",
488
+ `skill must be a slash skill like "rdc:build" or bare skill like "lifeai-brochure-author" (got ${JSON.stringify(raw.skill)})`,
489
+ );
490
+ }
491
+
492
+ // description
493
+ if (raw.description === undefined) {
494
+ err(errors, "description", "missing", "description is required");
495
+ } else if (typeof raw.description !== "string" || raw.description.trim().length === 0) {
496
+ err(errors, "description", "type", "description must be a non-empty string");
497
+ }
498
+
499
+ // fixture
500
+ if (raw.fixture === undefined) {
501
+ err(errors, "fixture", "missing", "fixture is required");
502
+ } else {
503
+ validateFixture(raw.fixture, errors, warnings);
504
+ }
505
+
506
+ // assertions
507
+ if (raw.assertions === undefined) {
508
+ err(errors, "assertions", "missing", "assertions is required");
509
+ } else {
510
+ validateAssertions(raw.assertions, errors, warnings);
511
+ }
512
+
513
+ if (raw.acceptance !== undefined) {
514
+ validateAcceptance(raw.acceptance, errors, warnings);
515
+ }
516
+
517
+ // teardown (optional)
518
+ if (raw.teardown !== undefined) {
519
+ validateTeardown(raw.teardown, errors, warnings);
520
+ }
521
+
522
+ // unknown top-level fields → warning
523
+ for (const k of Object.keys(raw)) {
524
+ if (!TOP_LEVEL_FIELDS.has(k)) {
525
+ warn(warnings, k, "unknown-field", `unknown top-level field "${k}"`);
526
+ }
527
+ }
528
+
529
+ const ok = errors.length === 0;
530
+ return { ok, manifest: ok ? raw : null, errors, warnings };
531
+ }
532
+
533
+ export function loadManifest(filepath) {
534
+ let text;
535
+ try {
536
+ text = readFileSync(filepath, "utf8");
537
+ } catch (e) {
538
+ return {
539
+ ok: false,
540
+ manifest: null,
541
+ errors: [{ path: "", code: "io", msg: `cannot read ${filepath}: ${e.message}` }],
542
+ warnings: [],
543
+ };
544
+ }
545
+ let parsed;
546
+ try {
547
+ parsed = JSON.parse(text);
548
+ } catch (e) {
549
+ return {
550
+ ok: false,
551
+ manifest: null,
552
+ errors: [{ path: "", code: "parse", msg: `invalid JSON in ${filepath}: ${e.message}` }],
553
+ warnings: [],
554
+ };
555
+ }
556
+ return validateManifest(parsed);
557
+ }
558
+
559
+ export function loadAllManifests(dir) {
560
+ const __dirname = dirname(fileURLToPath(import.meta.url));
561
+ const REPO_ROOT = resolve(__dirname, "..", "..");
562
+ const targetDir = dir || join(REPO_ROOT, "skills", "tests");
563
+ if (!existsSync(targetDir)) return [];
564
+ let files;
565
+ try {
566
+ files = readdirSync(targetDir).filter((f) => f.endsWith(".test.json"));
567
+ } catch {
568
+ return [];
569
+ }
570
+ return files.map((f) => {
571
+ const full = join(targetDir, f);
572
+ const res = loadManifest(full);
573
+ return { file: `skills/tests/${basename(full)}`, ...res };
574
+ });
575
+ }
576
+
577
+ // ─── Self-test ──────────────────────────────────────────────────────────────
578
+ const __isMain = (() => {
579
+ try {
580
+ return fileURLToPath(import.meta.url) === resolve(process.argv[1] || "");
581
+ } catch {
582
+ return false;
583
+ }
584
+ })();
585
+ if (__isMain) {
586
+ const validMinimal = {
587
+ manifest_version: 1,
588
+ skill: "rdc:fixit",
589
+ description: "minimal valid",
590
+ fixture: {
591
+ prompt: "rdc:fixit fix typo",
592
+ env: { RDC_TEST: "1" },
593
+ },
594
+ assertions: { exit_code: 0 },
595
+ };
596
+
597
+ const cases = [
598
+ {
599
+ n: 1,
600
+ desc: "valid minimal manifest",
601
+ input: validMinimal,
602
+ expect: (r) => r.ok && r.errors.length === 0 && r.warnings.length === 0,
603
+ expectStr: "ok=true, 0 errors, 0 warnings",
604
+ },
605
+ {
606
+ n: 2,
607
+ desc: "missing skill",
608
+ input: { ...validMinimal, skill: undefined },
609
+ expect: (r) =>
610
+ !r.ok && r.errors.some((e) => e.path === "skill" && e.code === "missing"),
611
+ expectStr: "error code=missing on skill",
612
+ },
613
+ {
614
+ n: 3,
615
+ desc: "manifest_version: 2",
616
+ input: { ...validMinimal, manifest_version: 2 },
617
+ expect: (r) =>
618
+ !r.ok && r.errors.some((e) => e.code === "version-unsupported"),
619
+ expectStr: "error code=version-unsupported",
620
+ },
621
+ {
622
+ n: 4,
623
+ desc: 'fixture.env.RDC_TEST: "0"',
624
+ input: {
625
+ ...validMinimal,
626
+ fixture: { ...validMinimal.fixture, env: { RDC_TEST: "0" } },
627
+ },
628
+ expect: (r) => !r.ok && r.errors.some((e) => e.code === "rdc-test-not-set"),
629
+ expectStr: "error code=rdc-test-not-set",
630
+ },
631
+ {
632
+ n: 4.5,
633
+ desc: "valid bare installed skill",
634
+ input: {
635
+ ...validMinimal,
636
+ skill: "lifeai-brochure-author",
637
+ fixture: { ...validMinimal.fixture, prompt: "lifeai-brochure-author review fixture" },
638
+ },
639
+ expect: (r) => r.ok && r.errors.length === 0,
640
+ expectStr: "ok=true, 0 errors",
641
+ },
642
+ {
643
+ n: 5,
644
+ desc: "invalid regex in commits_made.message_matches",
645
+ input: {
646
+ ...validMinimal,
647
+ assertions: {
648
+ exit_code: 0,
649
+ commits_made: { min: 1, message_matches: "[unclosed" },
650
+ },
651
+ },
652
+ expect: (r) => !r.ok && r.errors.some((e) => e.code === "invalid-regex"),
653
+ expectStr: "error code=invalid-regex",
654
+ },
655
+ {
656
+ n: 6,
657
+ desc: "unknown top-level field `extra: 1`",
658
+ input: { ...validMinimal, extra: 1 },
659
+ expect: (r) =>
660
+ r.ok &&
661
+ r.warnings.length === 1 &&
662
+ r.warnings[0].code === "unknown-field" &&
663
+ r.warnings[0].path === "extra",
664
+ expectStr: "ok=true, 1 warning code=unknown-field",
665
+ },
666
+ {
667
+ n: 7,
668
+ desc: "work_items_created.min > max",
669
+ input: {
670
+ ...validMinimal,
671
+ assertions: {
672
+ exit_code: 0,
673
+ work_items_created: { min: 5, max: 2 },
674
+ },
675
+ },
676
+ expect: (r) => !r.ok && r.errors.some((e) => e.code === "range-invalid"),
677
+ expectStr: "error code=range-invalid",
678
+ },
679
+ {
680
+ n: 8,
681
+ desc: 'files_modified: ["/abs/path"]',
682
+ input: {
683
+ ...validMinimal,
684
+ assertions: { exit_code: 0, files_modified: ["/abs/path"] },
685
+ },
686
+ expect: (r) => !r.ok && r.errors.some((e) => e.code === "path-not-relative"),
687
+ expectStr: "error code=path-not-relative",
688
+ },
689
+ {
690
+ n: 9,
691
+ desc: "valid acceptance tool matcher",
692
+ input: {
693
+ ...validMinimal,
694
+ acceptance: {
695
+ output_contains: ["PASS"],
696
+ output_not_contains: ["fabricated"],
697
+ tool_calls_include_any: ["Grep", "Glob"],
698
+ tool_calls_argument_matches: [{ tools: ["Grep"], pattern: "global-corpus|web-search" }],
699
+ },
700
+ },
701
+ expect: (r) => r.ok && r.errors.length === 0,
702
+ expectStr: "ok=true, 0 errors",
703
+ },
704
+ {
705
+ n: 10,
706
+ desc: "invalid acceptance matcher regex",
707
+ input: {
708
+ ...validMinimal,
709
+ acceptance: {
710
+ tool_calls_argument_matches: [{ tools: ["Grep"], pattern: "[unclosed" }],
711
+ },
712
+ },
713
+ expect: (r) => !r.ok && r.errors.some((e) => e.path.endsWith(".pattern") && e.code === "invalid-regex"),
714
+ expectStr: "error code=invalid-regex",
715
+ },
716
+ {
717
+ n: 11,
718
+ desc: "unknown acceptance field",
719
+ input: {
720
+ ...validMinimal,
721
+ acceptance: { made_up: true },
722
+ },
723
+ expect: (r) =>
724
+ r.ok &&
725
+ r.warnings.some((w) => w.path === "acceptance.made_up" && w.code === "unknown-field"),
726
+ expectStr: "ok=true, warning code=unknown-field",
727
+ },
728
+ ];
729
+
730
+ const pad = (s, n) => String(s) + " ".repeat(Math.max(0, n - String(s).length));
731
+ console.log("\nmanifest-schema self-test\n");
732
+ console.log(pad("#", 3) + pad("description", 44) + pad("expected", 36) + pad("actual", 36) + "result");
733
+ console.log("─".repeat(130));
734
+
735
+ let passed = 0;
736
+ for (const c of cases) {
737
+ const r = validateManifest(c.input);
738
+ const ok = c.expect(r);
739
+ const actual = `ok=${r.ok}, ${r.errors.length}e/${r.warnings.length}w${
740
+ r.errors[0] ? ` [${r.errors[0].code}]` : ""
741
+ }${r.warnings[0] && r.errors.length === 0 ? ` [${r.warnings[0].code}]` : ""}`;
742
+ console.log(
743
+ pad(c.n, 3) +
744
+ pad(c.desc, 44) +
745
+ pad(c.expectStr, 36) +
746
+ pad(actual, 36) +
747
+ (ok ? "PASS" : "FAIL"),
748
+ );
749
+ if (ok) passed++;
750
+ }
751
+ console.log("─".repeat(130));
752
+ console.log(`\n${passed}/${cases.length} passed\n`);
753
+ process.exit(passed === cases.length ? 0 : 1);
754
+ }