@thinkingsage/kanon 0.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.
Files changed (199) hide show
  1. package/CHANGELOG.md +410 -0
  2. package/LICENSE +21 -0
  3. package/README.md +168 -0
  4. package/bridge/mcp-server.cjs +14171 -0
  5. package/package.json +98 -0
  6. package/src/adapters/capabilities.ts +178 -0
  7. package/src/adapters/claude-code.ts +110 -0
  8. package/src/adapters/cline.ts +98 -0
  9. package/src/adapters/codex.ts +173 -0
  10. package/src/adapters/copilot.ts +106 -0
  11. package/src/adapters/cursor.ts +97 -0
  12. package/src/adapters/degradation.ts +95 -0
  13. package/src/adapters/index.ts +324 -0
  14. package/src/adapters/kiro-frontmatter.ts +139 -0
  15. package/src/adapters/kiro-inclusion.ts +86 -0
  16. package/src/adapters/kiro.ts +412 -0
  17. package/src/adapters/qdeveloper.ts +115 -0
  18. package/src/adapters/types.ts +81 -0
  19. package/src/adapters/windsurf.ts +96 -0
  20. package/src/admin.ts +283 -0
  21. package/src/asset-conventions.ts +118 -0
  22. package/src/attribution-backfill.ts +319 -0
  23. package/src/attribution-report.ts +95 -0
  24. package/src/attribution.ts +239 -0
  25. package/src/backends/github.ts +194 -0
  26. package/src/backends/http.ts +122 -0
  27. package/src/backends/index.ts +39 -0
  28. package/src/backends/local.ts +47 -0
  29. package/src/backends/s3.ts +157 -0
  30. package/src/backends/types.ts +59 -0
  31. package/src/base-cache.ts +270 -0
  32. package/src/browse-ui.ts +3754 -0
  33. package/src/browse.ts +1038 -0
  34. package/src/build.ts +1108 -0
  35. package/src/catalog.ts +204 -0
  36. package/src/cli-deprecated.ts +29 -0
  37. package/src/cli.ts +773 -0
  38. package/src/collection-admin.ts +287 -0
  39. package/src/collection-builder.ts +464 -0
  40. package/src/collections.ts +116 -0
  41. package/src/compatibility.ts +105 -0
  42. package/src/config.ts +743 -0
  43. package/src/eval/rubrics/kiro-progressive-steering.ts +841 -0
  44. package/src/eval.ts +1169 -0
  45. package/src/file-writer.ts +61 -0
  46. package/src/format-registry.ts +141 -0
  47. package/src/guild/auto-updater.ts +163 -0
  48. package/src/guild/backend-resolver.ts +49 -0
  49. package/src/guild/cli.ts +592 -0
  50. package/src/guild/collection-expander.ts +47 -0
  51. package/src/guild/global-cache.ts +247 -0
  52. package/src/guild/hook-generator.ts +100 -0
  53. package/src/guild/manifest.ts +154 -0
  54. package/src/guild/path-utils.ts +12 -0
  55. package/src/guild/sync.ts +622 -0
  56. package/src/guild/version-resolver.ts +42 -0
  57. package/src/help/metadata.ts +445 -0
  58. package/src/help/renderer.ts +265 -0
  59. package/src/help/typo-suggester.ts +25 -0
  60. package/src/hooks/expression.ts +493 -0
  61. package/src/hooks/pipeline.ts +141 -0
  62. package/src/import.ts +773 -0
  63. package/src/importers/claude-code.ts +134 -0
  64. package/src/importers/cline.ts +103 -0
  65. package/src/importers/codex.ts +140 -0
  66. package/src/importers/copilot.ts +103 -0
  67. package/src/importers/cursor.ts +105 -0
  68. package/src/importers/index.ts +390 -0
  69. package/src/importers/kiro.ts +110 -0
  70. package/src/importers/qdeveloper.ts +103 -0
  71. package/src/importers/types.ts +54 -0
  72. package/src/importers/windsurf.ts +104 -0
  73. package/src/install.ts +1005 -0
  74. package/src/manifest-admin.ts +306 -0
  75. package/src/mcp-bridge.ts +240 -0
  76. package/src/mutation/delta.ts +50 -0
  77. package/src/mutation/history.ts +66 -0
  78. package/src/mutation/operators.ts +524 -0
  79. package/src/mutation/runner.ts +332 -0
  80. package/src/new.ts +106 -0
  81. package/src/outcomes/collision.ts +127 -0
  82. package/src/outcomes/normalize.ts +208 -0
  83. package/src/outcomes/registry.ts +173 -0
  84. package/src/parser.ts +446 -0
  85. package/src/provenance-backfill-cli.ts +319 -0
  86. package/src/provenance-backfill.ts +520 -0
  87. package/src/publish.ts +354 -0
  88. package/src/reconcile-orchestrator.ts +502 -0
  89. package/src/reconcile-report-renderer.ts +176 -0
  90. package/src/resolve-body.ts +15 -0
  91. package/src/rosetta/builtins/compatibility-profiles.ts +297 -0
  92. package/src/rosetta/builtins/contracts.ts +1033 -0
  93. package/src/rosetta/builtins/pretty-printers/claude-code-native.ts +122 -0
  94. package/src/rosetta/builtins/pretty-printers/cline-native.ts +50 -0
  95. package/src/rosetta/builtins/pretty-printers/codex-native.ts +127 -0
  96. package/src/rosetta/builtins/pretty-printers/copilot-native.ts +50 -0
  97. package/src/rosetta/builtins/pretty-printers/cursor-native.ts +50 -0
  98. package/src/rosetta/builtins/pretty-printers/index.ts +81 -0
  99. package/src/rosetta/builtins/pretty-printers/kiro-native.ts +166 -0
  100. package/src/rosetta/builtins/pretty-printers/kiro-power.ts +108 -0
  101. package/src/rosetta/builtins/pretty-printers/kiro-skill.ts +88 -0
  102. package/src/rosetta/builtins/pretty-printers/qdeveloper-native.ts +51 -0
  103. package/src/rosetta/builtins/pretty-printers/superpowers.ts +97 -0
  104. package/src/rosetta/builtins/pretty-printers/windsurf-native.ts +50 -0
  105. package/src/rosetta/builtins/sources/claude-code-native.ts +348 -0
  106. package/src/rosetta/builtins/sources/cline-native.ts +176 -0
  107. package/src/rosetta/builtins/sources/codex-native.ts +343 -0
  108. package/src/rosetta/builtins/sources/copilot-native.ts +178 -0
  109. package/src/rosetta/builtins/sources/cursor-native.ts +176 -0
  110. package/src/rosetta/builtins/sources/index.ts +95 -0
  111. package/src/rosetta/builtins/sources/kiro-native.ts +462 -0
  112. package/src/rosetta/builtins/sources/kiro-power.ts +285 -0
  113. package/src/rosetta/builtins/sources/kiro-skill.ts +230 -0
  114. package/src/rosetta/builtins/sources/qdeveloper-native.ts +181 -0
  115. package/src/rosetta/builtins/sources/superpowers.ts +240 -0
  116. package/src/rosetta/builtins/sources/windsurf-native.ts +176 -0
  117. package/src/rosetta/builtins/targets/claude-code.ts +181 -0
  118. package/src/rosetta/builtins/targets/cline.ts +87 -0
  119. package/src/rosetta/builtins/targets/codex.ts +226 -0
  120. package/src/rosetta/builtins/targets/copilot.ts +103 -0
  121. package/src/rosetta/builtins/targets/cursor.ts +87 -0
  122. package/src/rosetta/builtins/targets/index.ts +60 -0
  123. package/src/rosetta/builtins/targets/kiro.ts +278 -0
  124. package/src/rosetta/builtins/targets/qdeveloper.ts +103 -0
  125. package/src/rosetta/builtins/targets/windsurf.ts +87 -0
  126. package/src/rosetta/canonical.ts +729 -0
  127. package/src/rosetta/compatibility.ts +432 -0
  128. package/src/rosetta/contracts.ts +329 -0
  129. package/src/rosetta/detector.ts +724 -0
  130. package/src/rosetta/diagnostics.ts +630 -0
  131. package/src/rosetta/engine-bootstrap.ts +103 -0
  132. package/src/rosetta/engine.ts +744 -0
  133. package/src/rosetta/index.ts +381 -0
  134. package/src/rosetta/inspection.ts +530 -0
  135. package/src/rosetta/plan.ts +448 -0
  136. package/src/rosetta/provenance-digest.ts +369 -0
  137. package/src/rosetta/reconcile.ts +812 -0
  138. package/src/rosetta/redaction.ts +467 -0
  139. package/src/rosetta/registry.ts +712 -0
  140. package/src/rosetta/renderers.ts +571 -0
  141. package/src/rosetta/request-guard.ts +335 -0
  142. package/src/rosetta/resolution.ts +419 -0
  143. package/src/rosetta/source-accounting.ts +233 -0
  144. package/src/rosetta/templates.ts +129 -0
  145. package/src/rosetta-cli.ts +717 -0
  146. package/src/rosetta-docs-generator.ts +793 -0
  147. package/src/rosetta-profiles-cli.ts +367 -0
  148. package/src/schemas.ts +1712 -0
  149. package/src/spec-coordination.ts +1141 -0
  150. package/src/temper.ts +747 -0
  151. package/src/template-bundle-loader.ts +312 -0
  152. package/src/template-engine.ts +53 -0
  153. package/src/translation-application-policy.ts +496 -0
  154. package/src/translation-orchestrator.ts +1013 -0
  155. package/src/translation-plan-applier.ts +473 -0
  156. package/src/tutorial.ts +305 -0
  157. package/src/validate.ts +1093 -0
  158. package/src/versioning.ts +553 -0
  159. package/src/wizard.ts +660 -0
  160. package/src/workspace.ts +237 -0
  161. package/templates/eval-contexts/claude-code.md.njk +6 -0
  162. package/templates/eval-contexts/cline.md.njk +6 -0
  163. package/templates/eval-contexts/copilot.md.njk +6 -0
  164. package/templates/eval-contexts/cursor.md.njk +6 -0
  165. package/templates/eval-contexts/kiro.md.njk +10 -0
  166. package/templates/eval-contexts/qdeveloper.md.njk +6 -0
  167. package/templates/eval-contexts/windsurf.md.njk +6 -0
  168. package/templates/harness-adapters/_base/attribution-footer.md.njk +17 -0
  169. package/templates/harness-adapters/_base/base.md.njk +16 -0
  170. package/templates/harness-adapters/claude-code/claude.md.njk +1 -0
  171. package/templates/harness-adapters/claude-code/mcp.json.njk +1 -0
  172. package/templates/harness-adapters/claude-code/settings.json.njk +1 -0
  173. package/templates/harness-adapters/claude-code/skill-library-index.md.njk +13 -0
  174. package/templates/harness-adapters/claude-code/skill.md.njk +19 -0
  175. package/templates/harness-adapters/cline/hook.sh.njk +4 -0
  176. package/templates/harness-adapters/cline/mcp.json.njk +1 -0
  177. package/templates/harness-adapters/cline/rule.md.njk +1 -0
  178. package/templates/harness-adapters/codex/agents-md.md.njk +6 -0
  179. package/templates/harness-adapters/codex/agents-pointer.md.njk +16 -0
  180. package/templates/harness-adapters/codex/skill.md.njk +27 -0
  181. package/templates/harness-adapters/copilot/agents.md.njk +1 -0
  182. package/templates/harness-adapters/copilot/instructions.md.njk +1 -0
  183. package/templates/harness-adapters/copilot/scoped.md.njk +6 -0
  184. package/templates/harness-adapters/cursor/mcp.json.njk +1 -0
  185. package/templates/harness-adapters/cursor/rule.md.njk +6 -0
  186. package/templates/harness-adapters/kiro/hook.json.njk +1 -0
  187. package/templates/harness-adapters/kiro/mcp.json.njk +1 -0
  188. package/templates/harness-adapters/kiro/power-steering.md.njk +3 -0
  189. package/templates/harness-adapters/kiro/power.md.njk +12 -0
  190. package/templates/harness-adapters/kiro/steering.md.njk +16 -0
  191. package/templates/harness-adapters/qdeveloper/agent.md.njk +1 -0
  192. package/templates/harness-adapters/qdeveloper/mcp.json.njk +1 -0
  193. package/templates/harness-adapters/qdeveloper/rule.md.njk +1 -0
  194. package/templates/harness-adapters/windsurf/mcp.json.njk +1 -0
  195. package/templates/harness-adapters/windsurf/rule.md.njk +1 -0
  196. package/templates/harness-adapters/windsurf/workflow.md.njk +1 -0
  197. package/templates/knowledge/hooks.yaml.njk +4 -0
  198. package/templates/knowledge/knowledge.md.njk +53 -0
  199. package/templates/knowledge/mcp-servers.yaml.njk +2 -0
@@ -0,0 +1,841 @@
1
+ /**
2
+ * Kiro Progressive Steering rubric grader module.
3
+ *
4
+ * This module provides the tokenizer and scoring logic for the
5
+ * progressive-steering eval rubric. The tokenizer is pinned to
6
+ * cl100k_base via tiktoken for reproducibility.
7
+ *
8
+ * Design §6 (reproducibility contract): both the tiktoken path and the
9
+ * chars/4 fallback are pure functions of the input string — no network,
10
+ * no locale dependence, no environment reads.
11
+ *
12
+ * Design §2 (AOCW data source): token counts feed the AOCW metric.
13
+ */
14
+
15
+ import { readdir, readFile } from "node:fs/promises";
16
+ import { join } from "node:path";
17
+ import { Glob } from "bun";
18
+ import cl100k_base from "tiktoken/encoders/cl100k_base";
19
+ import { Tiktoken } from "tiktoken/lite";
20
+ import { parseKiroSteeringFile } from "../../adapters/kiro-frontmatter";
21
+ import {
22
+ type KiroInclusionMode,
23
+ resolveKiroInclusion,
24
+ } from "../../adapters/kiro-inclusion";
25
+ import type { KnowledgeArtifact } from "../../schemas";
26
+
27
+ // --- Public types ---
28
+
29
+ export interface Workload {
30
+ promptId: string;
31
+ openedFiles: string[];
32
+ userReferences: string[];
33
+ expectedFired: string[];
34
+ }
35
+
36
+ export interface ProgressiveSteeringMetrics {
37
+ AOCW: number;
38
+ PR: number;
39
+ FMP: number;
40
+ MD: number;
41
+ DER: number;
42
+ WCA: number;
43
+ }
44
+
45
+ export interface ProgressiveSteeringDetails {
46
+ perFileMatchFile: Array<{
47
+ name: string;
48
+ firesNeeded: number;
49
+ firesTotal: number;
50
+ }>;
51
+ perManualFile: Array<{
52
+ name: string;
53
+ top5Tokens: string[];
54
+ covered: boolean;
55
+ }>;
56
+ defaultSourceArtifacts: string[];
57
+ misalignedWizardArtifacts: string[];
58
+ }
59
+
60
+ /**
61
+ * Parsed steering file information used internally by metric functions.
62
+ */
63
+ export interface ParsedSteeringFile {
64
+ name: string;
65
+ inclusion: KiroInclusionMode;
66
+ fileMatchPattern?: string;
67
+ body: string;
68
+ }
69
+
70
+ /**
71
+ * Extract the artifact name from a relative file path.
72
+ * Convention: `<artifact-name>/<artifact-name>.md` → `<artifact-name>`.
73
+ * Falls back to the filename without extension if no directory component.
74
+ */
75
+ function extractArtifactName(relativePath: string): string {
76
+ const firstSlash = relativePath.indexOf("/");
77
+ if (firstSlash > 0) {
78
+ return relativePath.slice(0, firstSlash);
79
+ }
80
+ // No directory component: strip extension
81
+ return relativePath.replace(/\.md$/, "");
82
+ }
83
+
84
+ // --- Tokenizer ---
85
+
86
+ let tiktokenWarningEmitted = false;
87
+ let encoder: Tiktoken | null = null;
88
+
89
+ function getEncoder(): Tiktoken | null {
90
+ if (encoder) return encoder;
91
+ try {
92
+ encoder = new Tiktoken(
93
+ cl100k_base.bpe_ranks,
94
+ cl100k_base.special_tokens,
95
+ cl100k_base.pat_str,
96
+ );
97
+ return encoder;
98
+ } catch {
99
+ return null;
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Count the number of tokens in a text string using the cl100k_base encoding.
105
+ *
106
+ * Falls back to `max(1, ceil(chars / 4))` when tiktoken is unavailable,
107
+ * emitting a one-line warning to stderr on first use of the fallback path.
108
+ *
109
+ * Both paths are pure functions of the input string: same input always
110
+ * produces the same output. No network calls, no locale dependence,
111
+ * no environment variable reads.
112
+ */
113
+ export function countTokens(text: string): number {
114
+ const enc = getEncoder();
115
+ if (enc) {
116
+ return enc.encode(text).length;
117
+ }
118
+ // Fallback: chars / 4
119
+ if (!tiktokenWarningEmitted) {
120
+ process.stderr.write(
121
+ "Warning: tiktoken not available — using chars/4 approximation for token counting\n",
122
+ );
123
+ tiktokenWarningEmitted = true;
124
+ }
125
+ return Math.max(1, Math.ceil(text.length / 4));
126
+ }
127
+
128
+ // --- Helper: extract body from steering file content ---
129
+
130
+ /**
131
+ * Extract the body (content after frontmatter) from a steering file's raw content.
132
+ * Strips the leading YAML frontmatter block (between `---` delimiters).
133
+ */
134
+ function extractBody(content: string): string {
135
+ const trimmed = content.trimStart();
136
+ if (!trimmed.startsWith("---")) return content;
137
+ const endIdx = trimmed.indexOf("---", 3);
138
+ if (endIdx === -1) return content;
139
+ // Skip past the closing `---` and any trailing newline
140
+ const afterFrontmatter = trimmed.slice(endIdx + 3);
141
+ return afterFrontmatter.startsWith("\n")
142
+ ? afterFrontmatter.slice(1)
143
+ : afterFrontmatter;
144
+ }
145
+
146
+ // --- Helper: parse compiled steering files ---
147
+
148
+ /**
149
+ * Parse an array of compiled steering file contents into ParsedSteeringFile[].
150
+ * Each entry is `{ name, content }` where `name` is the filename/relative path.
151
+ *
152
+ * Parse failures and missing `inclusion` are treated as `always` per the
153
+ * install-scanner contract.
154
+ */
155
+ export function parseSteeringFiles(
156
+ files: Array<{ name: string; content: string }>,
157
+ ): ParsedSteeringFile[] {
158
+ const results: ParsedSteeringFile[] = [];
159
+ for (const file of files) {
160
+ const parseResult = parseKiroSteeringFile(file.content, file.name);
161
+ let inclusion: KiroInclusionMode = "always";
162
+ let fileMatchPattern: string | undefined;
163
+
164
+ if (parseResult.ok && parseResult.frontmatter) {
165
+ inclusion = parseResult.frontmatter.inclusion;
166
+ fileMatchPattern = parseResult.frontmatter.fileMatchPattern;
167
+ }
168
+ // Parse failures and missing inclusion → treat as "always"
169
+
170
+ const body = extractBody(file.content);
171
+ results.push({ name: file.name, inclusion, fileMatchPattern, body });
172
+ }
173
+ return results;
174
+ }
175
+
176
+ // --- Metric: AOCW (Always-on Context Weight) ---
177
+
178
+ /**
179
+ * Compute the Always-on Context Weight metric.
180
+ *
181
+ * AOCW = sum(tokens(body) for sf if sf.inclusion == "always") / sum(tokens(body) for all sf)
182
+ *
183
+ * Returns 0 when there are no installed files (avoids division by zero).
184
+ */
185
+ export function computeAOCW(steeringFiles: ParsedSteeringFile[]): number {
186
+ if (steeringFiles.length === 0) return 0;
187
+
188
+ let alwaysTokens = 0;
189
+ let totalTokens = 0;
190
+
191
+ for (const sf of steeringFiles) {
192
+ const tokens = countTokens(sf.body);
193
+ totalTokens += tokens;
194
+ if (sf.inclusion === "always") {
195
+ alwaysTokens += tokens;
196
+ }
197
+ }
198
+
199
+ if (totalTokens === 0) return 0;
200
+ return alwaysTokens / totalTokens;
201
+ }
202
+
203
+ // --- Metric: PR (Progressive Ratio) ---
204
+
205
+ export interface PRResult {
206
+ value: number;
207
+ warning?: string;
208
+ }
209
+
210
+ /**
211
+ * Compute the Progressive Ratio metric.
212
+ *
213
+ * PR = count(sf where sf.inclusion in {"fileMatch","manual"}) / count(installed)
214
+ *
215
+ * Returns 0 with a warning when count(installed) === 0.
216
+ */
217
+ export function computePR(steeringFiles: ParsedSteeringFile[]): PRResult {
218
+ if (steeringFiles.length === 0) {
219
+ return {
220
+ value: 0,
221
+ warning: "No installed steering files found; PR defaults to 0",
222
+ };
223
+ }
224
+
225
+ const progressiveCount = steeringFiles.filter(
226
+ (sf) => sf.inclusion === "fileMatch" || sf.inclusion === "manual",
227
+ ).length;
228
+
229
+ return { value: progressiveCount / steeringFiles.length };
230
+ }
231
+
232
+ // --- Metric: FMP (FileMatch Hit Precision) ---
233
+
234
+ export interface FMPResult {
235
+ value: number;
236
+ warning?: string;
237
+ perFileMatchFile: Array<{
238
+ name: string;
239
+ firesNeeded: number;
240
+ firesTotal: number;
241
+ }>;
242
+ }
243
+
244
+ /**
245
+ * Test whether a glob pattern matches a file path.
246
+ * Uses Bun.Glob for proper glob semantics.
247
+ */
248
+ function globMatches(pattern: string, filePath: string): boolean {
249
+ const glob = new Glob(pattern);
250
+ return glob.match(filePath);
251
+ }
252
+
253
+ /**
254
+ * Compute the FileMatch Hit Precision metric.
255
+ *
256
+ * For each fileMatch-mode steering file:
257
+ * - firesNeeded = count of workload prompts that list this file in expectedFired[]
258
+ * - firesTotal = count of workload prompts where the file's glob matches any of openedFiles[]
259
+ *
260
+ * FMP = mean(firesNeeded / firesTotal) across fileMatch files.
261
+ *
262
+ * When workload lacks expectedFired[] labels, FMP = 0 with a warning.
263
+ * When there are no fileMatch files, FMP = 0.
264
+ */
265
+ export function computeFMP(
266
+ steeringFiles: ParsedSteeringFile[],
267
+ workload: Workload[],
268
+ ): FMPResult {
269
+ const fileMatchFiles = steeringFiles.filter(
270
+ (sf) => sf.inclusion === "fileMatch" && sf.fileMatchPattern,
271
+ );
272
+
273
+ if (fileMatchFiles.length === 0) {
274
+ return { value: 0, perFileMatchFile: [] };
275
+ }
276
+
277
+ // Check if workload has expectedFired labels
278
+ const hasLabels = workload.some(
279
+ (w) => w.expectedFired && w.expectedFired.length > 0,
280
+ );
281
+ if (!hasLabels) {
282
+ return {
283
+ value: 0,
284
+ warning: "Workload lacks expectedFired[] labels; FMP defaults to 0",
285
+ perFileMatchFile: fileMatchFiles.map((sf) => ({
286
+ name: sf.name,
287
+ firesNeeded: 0,
288
+ firesTotal: 0,
289
+ })),
290
+ };
291
+ }
292
+
293
+ const perFileMatchFile: Array<{
294
+ name: string;
295
+ firesNeeded: number;
296
+ firesTotal: number;
297
+ }> = [];
298
+
299
+ let ratioSum = 0;
300
+
301
+ for (const sf of fileMatchFiles) {
302
+ // biome-ignore lint/style/noNonNullAssertion: fileMatchFiles are pre-filtered to have fileMatchPattern
303
+ const pattern = sf.fileMatchPattern!;
304
+ const artifactName = extractArtifactName(sf.name);
305
+ let firesNeeded = 0;
306
+ let firesTotal = 0;
307
+
308
+ for (const prompt of workload) {
309
+ // Does the glob match any of the opened files in this prompt?
310
+ const matches = prompt.openedFiles.some((file) =>
311
+ globMatches(pattern, file),
312
+ );
313
+ if (matches) {
314
+ firesTotal++;
315
+ }
316
+
317
+ // Is this file listed in expectedFired for this prompt?
318
+ if (prompt.expectedFired.includes(artifactName)) {
319
+ firesNeeded++;
320
+ }
321
+ }
322
+
323
+ perFileMatchFile.push({ name: sf.name, firesNeeded, firesTotal });
324
+
325
+ // Avoid division by zero: if firesTotal is 0, treat ratio as 0
326
+ if (firesTotal > 0) {
327
+ ratioSum += firesNeeded / firesTotal;
328
+ }
329
+ }
330
+
331
+ const value = ratioSum / fileMatchFiles.length;
332
+ return { value, perFileMatchFile };
333
+ }
334
+
335
+ // --- Metric: MD (Manual Discoverability) ---
336
+
337
+ export interface MDResult {
338
+ value: number;
339
+ perManualFile: Array<{
340
+ name: string;
341
+ top5Tokens: string[];
342
+ covered: boolean;
343
+ }>;
344
+ }
345
+
346
+ /**
347
+ * Tokenize a text body into lowercase words, stripping punctuation.
348
+ */
349
+ function tokenizeText(text: string): string[] {
350
+ return text
351
+ .toLowerCase()
352
+ .split(/\s+/)
353
+ .map((word) => word.replace(/[^\w]/g, ""))
354
+ .filter((word) => word.length > 0);
355
+ }
356
+
357
+ /**
358
+ * Common English stopwords filtered from TF-IDF scoring for MD metric.
359
+ * These words carry no topical signal and would pollute top-N token lists.
360
+ */
361
+ const STOPWORDS = new Set([
362
+ "a",
363
+ "an",
364
+ "and",
365
+ "are",
366
+ "as",
367
+ "at",
368
+ "be",
369
+ "been",
370
+ "but",
371
+ "by",
372
+ "can",
373
+ "do",
374
+ "for",
375
+ "from",
376
+ "had",
377
+ "has",
378
+ "have",
379
+ "he",
380
+ "her",
381
+ "his",
382
+ "how",
383
+ "i",
384
+ "if",
385
+ "in",
386
+ "into",
387
+ "is",
388
+ "it",
389
+ "its",
390
+ "just",
391
+ "my",
392
+ "no",
393
+ "not",
394
+ "of",
395
+ "on",
396
+ "only",
397
+ "or",
398
+ "other",
399
+ "our",
400
+ "out",
401
+ "over",
402
+ "own",
403
+ "s",
404
+ "same",
405
+ "she",
406
+ "should",
407
+ "so",
408
+ "some",
409
+ "such",
410
+ "t",
411
+ "than",
412
+ "that",
413
+ "the",
414
+ "their",
415
+ "them",
416
+ "then",
417
+ "there",
418
+ "these",
419
+ "they",
420
+ "this",
421
+ "those",
422
+ "through",
423
+ "to",
424
+ "too",
425
+ "under",
426
+ "up",
427
+ "very",
428
+ "was",
429
+ "we",
430
+ "were",
431
+ "what",
432
+ "when",
433
+ "where",
434
+ "which",
435
+ "while",
436
+ "who",
437
+ "whom",
438
+ "why",
439
+ "will",
440
+ "with",
441
+ "would",
442
+ "you",
443
+ "your",
444
+ "all",
445
+ "also",
446
+ "any",
447
+ "both",
448
+ "each",
449
+ "few",
450
+ "more",
451
+ "most",
452
+ "no",
453
+ "nor",
454
+ "every",
455
+ "must",
456
+ "use",
457
+ ]);
458
+
459
+ /**
460
+ * Compute TF-IDF scores for a corpus of documents.
461
+ * Returns a map from docIndex → array of {token, score} sorted by score descending.
462
+ */
463
+ function computeTfIdf(
464
+ documents: string[][],
465
+ ): Map<number, Array<{ token: string; score: number }>> {
466
+ const N = documents.length;
467
+ if (N === 0) return new Map();
468
+
469
+ // Compute document frequency for each token
470
+ const df = new Map<string, number>();
471
+ for (const doc of documents) {
472
+ const uniqueTokens = new Set(doc);
473
+ for (const token of uniqueTokens) {
474
+ df.set(token, (df.get(token) ?? 0) + 1);
475
+ }
476
+ }
477
+
478
+ // Compute TF-IDF per document
479
+ const result = new Map<number, Array<{ token: string; score: number }>>();
480
+ for (let i = 0; i < N; i++) {
481
+ const doc = documents[i];
482
+ const termFreq = new Map<string, number>();
483
+ for (const token of doc) {
484
+ termFreq.set(token, (termFreq.get(token) ?? 0) + 1);
485
+ }
486
+
487
+ const scores: Array<{ token: string; score: number }> = [];
488
+ for (const [token, tf] of termFreq) {
489
+ const idf = Math.log(N / (df.get(token) ?? 1));
490
+ scores.push({ token, score: tf * idf });
491
+ }
492
+
493
+ // Sort by score descending, break ties lexicographically
494
+ scores.sort((a, b) => b.score - a.score || a.token.localeCompare(b.token));
495
+ result.set(i, scores);
496
+ }
497
+
498
+ return result;
499
+ }
500
+
501
+ /**
502
+ * Compute the Manual Discoverability metric.
503
+ *
504
+ * For each manual-mode file, take its top-5 TF-IDF tokens and check if all
505
+ * appear in the union of always-mode bodies.
506
+ *
507
+ * MD = count(covered manual files) / count(manual)
508
+ * Defined as 1.0 when there are no manual files.
509
+ */
510
+ export function computeMD(steeringFiles: ParsedSteeringFile[]): MDResult {
511
+ const manualFiles = steeringFiles.filter((sf) => sf.inclusion === "manual");
512
+ const alwaysFiles = steeringFiles.filter((sf) => sf.inclusion === "always");
513
+
514
+ if (manualFiles.length === 0) {
515
+ return { value: 1.0, perManualFile: [] };
516
+ }
517
+
518
+ // Build the union of tokens from all always-mode bodies
519
+ const alwaysTokenSet = new Set<string>();
520
+ for (const sf of alwaysFiles) {
521
+ const tokens = tokenizeText(sf.body);
522
+ for (const token of tokens) {
523
+ alwaysTokenSet.add(token);
524
+ }
525
+ }
526
+
527
+ // Compute TF-IDF over the full corpus (all installed files)
528
+ const allDocuments = steeringFiles.map((sf) => tokenizeText(sf.body));
529
+ const tfidfScores = computeTfIdf(allDocuments);
530
+
531
+ // For each manual file, get top-5 TF-IDF tokens
532
+ const perManualFile: Array<{
533
+ name: string;
534
+ top5Tokens: string[];
535
+ covered: boolean;
536
+ }> = [];
537
+
538
+ let coveredCount = 0;
539
+
540
+ for (const manualFile of manualFiles) {
541
+ const manualIdx = steeringFiles.indexOf(manualFile);
542
+ const scores = tfidfScores.get(manualIdx) ?? [];
543
+ // Filter out stopwords before selecting top-5 topical tokens
544
+ const filteredScores = scores.filter(
545
+ (s) => !STOPWORDS.has(s.token) && s.token.length > 2,
546
+ );
547
+ const top5Tokens = filteredScores.slice(0, 5).map((s) => s.token);
548
+
549
+ // Check discoverability: manual file is "covered" if either:
550
+ // 1. The manual file's artifact name appears in the always-mode body union, OR
551
+ // 2. At least 3 of the top-5 TF-IDF tokens appear in the always-mode body union
552
+ const artifactName = extractArtifactName(manualFile.name);
553
+ const nameInAlways =
554
+ alwaysTokenSet.has(artifactName) ||
555
+ alwaysTokenSet.has(artifactName.replace(/-/g, ""));
556
+ const tokenOverlap = top5Tokens.filter((token) =>
557
+ alwaysTokenSet.has(token),
558
+ ).length;
559
+ const covered =
560
+ nameInAlways ||
561
+ (top5Tokens.length > 0 &&
562
+ tokenOverlap >= Math.ceil(top5Tokens.length * 0.6));
563
+
564
+ if (covered) coveredCount++;
565
+ perManualFile.push({ name: manualFile.name, top5Tokens, covered });
566
+ }
567
+
568
+ return {
569
+ value: coveredCount / manualFiles.length,
570
+ perManualFile,
571
+ };
572
+ }
573
+
574
+ // --- Metric: DER (Default Escape Rate) ---
575
+
576
+ export interface DERResult {
577
+ value: number;
578
+ defaultSourceArtifacts: string[];
579
+ }
580
+
581
+ /** Regex for parsing the audit comment from compiled files */
582
+ const AUDIT_COMMENT_RE =
583
+ /^<!-- forge:kiro-inclusion: (always|fileMatch|manual)(?:\s+fileMatchPattern=(.+))? -->$/m;
584
+
585
+ /**
586
+ * Parse the source from an audit comment in a compiled file.
587
+ * Returns "default" if no audit comment is found (conservative assumption).
588
+ */
589
+ function parseAuditCommentSource(content: string): "default" | "explicit" {
590
+ // The audit comment itself doesn't encode source directly.
591
+ // However, if the comment is present, the file was processed by the adapter.
592
+ // We look for explicit source markers. Since the audit comment format is
593
+ // `<!-- forge:kiro-inclusion: <mode> -->`, we can't distinguish source from it alone.
594
+ // The fallback path parses this when source artifacts aren't available.
595
+ // If no audit comment exists, we consider the source "default".
596
+ const match = content.match(AUDIT_COMMENT_RE);
597
+ if (!match) return "default";
598
+ return "explicit";
599
+ }
600
+
601
+ /**
602
+ * Compute the Default Escape Rate metric.
603
+ *
604
+ * DER = count(artifacts where source === "default") / count(kiro artifacts)
605
+ *
606
+ * When source artifacts are provided, uses resolveKiroInclusion directly.
607
+ * Falls back to parsing the audit comment from compiled files.
608
+ *
609
+ * Populates details.defaultSourceArtifacts[] with names of default-source artifacts,
610
+ * stable-sorted.
611
+ */
612
+ export function computeDER(
613
+ sourceArtifacts?: KnowledgeArtifact[],
614
+ compiledFiles?: Array<{ name: string; content: string }>,
615
+ ): DERResult {
616
+ // Prefer source artifacts when available
617
+ if (sourceArtifacts && sourceArtifacts.length > 0) {
618
+ const kiroArtifacts = sourceArtifacts.filter((a) =>
619
+ a.frontmatter.harnesses.includes("kiro"),
620
+ );
621
+
622
+ if (kiroArtifacts.length === 0) {
623
+ return { value: 0, defaultSourceArtifacts: [] };
624
+ }
625
+
626
+ const defaultArtifacts: string[] = [];
627
+ for (const artifact of kiroArtifacts) {
628
+ const resolved = resolveKiroInclusion(artifact);
629
+ if (resolved.source === "default") {
630
+ defaultArtifacts.push(artifact.name);
631
+ }
632
+ }
633
+
634
+ defaultArtifacts.sort();
635
+ return {
636
+ value: defaultArtifacts.length / kiroArtifacts.length,
637
+ defaultSourceArtifacts: defaultArtifacts,
638
+ };
639
+ }
640
+
641
+ // Fallback: parse audit comments from compiled files
642
+ if (compiledFiles && compiledFiles.length > 0) {
643
+ const defaultArtifacts: string[] = [];
644
+ for (const file of compiledFiles) {
645
+ const source = parseAuditCommentSource(file.content);
646
+ if (source === "default") {
647
+ defaultArtifacts.push(file.name);
648
+ }
649
+ }
650
+
651
+ defaultArtifacts.sort();
652
+ const total = compiledFiles.length;
653
+ return {
654
+ value: total === 0 ? 0 : defaultArtifacts.length / total,
655
+ defaultSourceArtifacts: defaultArtifacts,
656
+ };
657
+ }
658
+
659
+ return { value: 0, defaultSourceArtifacts: [] };
660
+ }
661
+
662
+ // --- Metric: WCA (Wizard-Convention Alignment) ---
663
+
664
+ export interface WCAResult {
665
+ value: number;
666
+ misalignedWizardArtifacts: string[];
667
+ }
668
+
669
+ /**
670
+ * Compute the Wizard-Convention Alignment metric.
671
+ *
672
+ * Filters source artifacts to type ∈ {"power","reference-pack"}.
673
+ * Computes count(those where resolveKiroInclusion(a).mode !== "always") / count(those).
674
+ *
675
+ * WCA = 1.0 when the denominator is 0 (no power/reference-pack artifacts).
676
+ * Populates details.misalignedWizardArtifacts[] with names of artifacts where
677
+ * the mode IS "always" (i.e., misaligned with the wizard convention), stable-sorted.
678
+ */
679
+ export function computeWCA(sourceArtifacts: KnowledgeArtifact[]): WCAResult {
680
+ const wizardTypes = new Set(["power", "reference-pack"]);
681
+ const relevantArtifacts = sourceArtifacts.filter(
682
+ (a) =>
683
+ wizardTypes.has(a.frontmatter.type) &&
684
+ a.frontmatter.harnesses.includes("kiro"),
685
+ );
686
+
687
+ if (relevantArtifacts.length === 0) {
688
+ return { value: 1.0, misalignedWizardArtifacts: [] };
689
+ }
690
+
691
+ const misaligned: string[] = [];
692
+ let alignedCount = 0;
693
+
694
+ for (const artifact of relevantArtifacts) {
695
+ const resolved = resolveKiroInclusion(artifact);
696
+ if (resolved.mode !== "always") {
697
+ alignedCount++;
698
+ } else {
699
+ misaligned.push(artifact.name);
700
+ }
701
+ }
702
+
703
+ misaligned.sort();
704
+ return {
705
+ value: alignedCount / relevantArtifacts.length,
706
+ misalignedWizardArtifacts: misaligned,
707
+ };
708
+ }
709
+
710
+ // --- Result type ---
711
+
712
+ export interface ProgressiveSteeringResult {
713
+ score: number; // 0..100
714
+ rating: "green" | "yellow" | "red";
715
+ metrics: ProgressiveSteeringMetrics;
716
+ details: ProgressiveSteeringDetails;
717
+ }
718
+
719
+ // --- Scoring weights ---
720
+
721
+ const W1_AOCW = 0.3;
722
+ const W2_PR = 0.15;
723
+ const W3_FMP = 0.25;
724
+ const W4_MD = 0.1;
725
+ const W5_DER = 0.1;
726
+ const W6_WCA = 0.1;
727
+
728
+ // --- Rating thresholds ---
729
+
730
+ function computeRating(
731
+ score: number,
732
+ aocw: number,
733
+ fmp: number,
734
+ ): "green" | "yellow" | "red" {
735
+ // Green: Score >= 80 AND AOCW <= 0.40 AND FMP >= 0.75
736
+ if (score >= 80 && aocw <= 0.4 && fmp >= 0.75) {
737
+ return "green";
738
+ }
739
+ // Yellow: Score >= 60 AND AOCW <= 0.60 AND not Green
740
+ if (score >= 60 && aocw <= 0.6) {
741
+ return "yellow";
742
+ }
743
+ // Red: anything below Yellow
744
+ return "red";
745
+ }
746
+
747
+ // --- File discovery helper ---
748
+
749
+ /**
750
+ * Recursively find all `.md` files under a directory.
751
+ */
752
+ async function findMdFiles(dir: string): Promise<string[]> {
753
+ const results: string[] = [];
754
+ const entries = await readdir(dir, { withFileTypes: true, recursive: true });
755
+ for (const entry of entries) {
756
+ if (entry.isFile() && entry.name.endsWith(".md")) {
757
+ // entry.parentPath gives the directory containing this entry (Bun/Node 20+)
758
+ const parentPath = (entry as { parentPath?: string }).parentPath ?? dir;
759
+ results.push(join(parentPath, entry.name));
760
+ }
761
+ }
762
+ return results;
763
+ }
764
+
765
+ // --- Main grading function ---
766
+
767
+ /**
768
+ * Grade a compiled Kiro build for Progressive Steering quality.
769
+ *
770
+ * Reads all `.md` files from `buildDir`, parses them as steering files,
771
+ * computes the six metrics (AOCW, PR, FMP, MD, DER, WCA), applies the
772
+ * weighted scoring formula, determines the rating, and returns a stable,
773
+ * deterministic result.
774
+ *
775
+ * @param buildDir - Path to the compiled build directory (e.g. `dist/kiro/`)
776
+ * @param workload - Array of workload prompts with ground-truth labels
777
+ * @param sourceArtifacts - Optional source artifacts for DER/WCA metrics
778
+ */
779
+ export async function gradeProgressiveSteering(
780
+ buildDir: string,
781
+ workload: Workload[],
782
+ sourceArtifacts?: KnowledgeArtifact[],
783
+ ): Promise<ProgressiveSteeringResult> {
784
+ // 1. Discover and read all .md files from buildDir
785
+ const mdPaths = await findMdFiles(buildDir);
786
+ const files: Array<{ name: string; content: string }> = [];
787
+
788
+ for (const filePath of mdPaths) {
789
+ const content = await readFile(filePath, "utf-8");
790
+ // Use relative path from buildDir as the name
791
+ const relativePath = filePath.slice(buildDir.length).replace(/^\//, "");
792
+ files.push({ name: relativePath, content });
793
+ }
794
+
795
+ // 2. Parse steering files
796
+ const steeringFiles = parseSteeringFiles(files);
797
+
798
+ // 3. Compute each metric
799
+ const aocw = computeAOCW(steeringFiles);
800
+ const prResult = computePR(steeringFiles);
801
+ const fmpResult = computeFMP(steeringFiles, workload);
802
+ const mdResult = computeMD(steeringFiles);
803
+ const derResult = computeDER(sourceArtifacts, files);
804
+ const wcaResult = computeWCA(sourceArtifacts ?? []);
805
+
806
+ const metrics: ProgressiveSteeringMetrics = {
807
+ AOCW: aocw,
808
+ PR: prResult.value,
809
+ FMP: fmpResult.value,
810
+ MD: mdResult.value,
811
+ DER: derResult.value,
812
+ WCA: wcaResult.value,
813
+ };
814
+
815
+ // 4. Compute composite score
816
+ const score =
817
+ 100 *
818
+ (W1_AOCW * (1 - metrics.AOCW) +
819
+ W2_PR * metrics.PR +
820
+ W3_FMP * metrics.FMP +
821
+ W4_MD * metrics.MD +
822
+ W5_DER * (1 - metrics.DER) +
823
+ W6_WCA * metrics.WCA);
824
+
825
+ // 5. Determine rating
826
+ const rating = computeRating(score, metrics.AOCW, metrics.FMP);
827
+
828
+ // 6. Build details with stable-sorted list fields
829
+ const details: ProgressiveSteeringDetails = {
830
+ perFileMatchFile: [...fmpResult.perFileMatchFile].sort((a, b) =>
831
+ a.name.localeCompare(b.name),
832
+ ),
833
+ perManualFile: [...mdResult.perManualFile].sort((a, b) =>
834
+ a.name.localeCompare(b.name),
835
+ ),
836
+ defaultSourceArtifacts: [...derResult.defaultSourceArtifacts].sort(),
837
+ misalignedWizardArtifacts: [...wcaResult.misalignedWizardArtifacts].sort(),
838
+ };
839
+
840
+ return { score, rating, metrics, details };
841
+ }