supipowers 1.5.3 → 2.0.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 (340) hide show
  1. package/README.md +14 -8
  2. package/bin/install.mjs +20 -5
  3. package/bin/install.ts +95 -0
  4. package/package.json +8 -4
  5. package/skills/context-mode/SKILL.md +17 -10
  6. package/skills/harness/SKILL.md +94 -0
  7. package/skills/ui-design/SKILL.md +63 -0
  8. package/skills/ui-design/sub-agent-templates/component-builder.md +29 -0
  9. package/skills/ui-design/sub-agent-templates/design-critic.md +46 -0
  10. package/skills/ui-design/sub-agent-templates/pencil/component-builder.md +29 -0
  11. package/skills/ui-design/sub-agent-templates/pencil/design-critic.md +42 -0
  12. package/skills/ui-design/sub-agent-templates/pencil/section-assembler.md +27 -0
  13. package/skills/ui-design/sub-agent-templates/section-assembler.md +27 -0
  14. package/skills/ultraplan-discover/SKILL.md +96 -0
  15. package/skills/ultraplan-intake/SKILL.md +89 -0
  16. package/skills/ultraplan-research/SKILL.md +129 -0
  17. package/skills/ultraplan-review/SKILL.md +86 -0
  18. package/skills/ultraplan-review-scope/SKILL.md +111 -0
  19. package/skills/ultraplan-review-structure/SKILL.md +120 -0
  20. package/skills/ultraplan-review-tdd/SKILL.md +142 -0
  21. package/skills/ultraplan-scout/SKILL.md +110 -0
  22. package/skills/ultraplan-synthesize/SKILL.md +124 -0
  23. package/src/{quality/ai-session.ts → ai/final-message.ts} +27 -0
  24. package/src/ai/schema-text.ts +129 -0
  25. package/src/ai/structured-output.ts +274 -0
  26. package/src/ai/template.ts +27 -0
  27. package/src/bootstrap.ts +63 -28
  28. package/src/commands/agents.ts +131 -42
  29. package/src/commands/ai-review.ts +251 -30
  30. package/src/commands/clear.ts +434 -0
  31. package/src/commands/commit.ts +1 -0
  32. package/src/commands/config.ts +242 -44
  33. package/src/commands/context.ts +55 -28
  34. package/src/commands/doctor.ts +234 -6
  35. package/src/commands/fix-pr.ts +306 -131
  36. package/src/commands/generate.ts +111 -21
  37. package/src/commands/memory.ts +192 -0
  38. package/src/commands/model-picker.ts +28 -21
  39. package/src/commands/model.ts +18 -8
  40. package/src/commands/optimize-context.ts +408 -29
  41. package/src/commands/plan.ts +2 -0
  42. package/src/commands/qa.ts +312 -137
  43. package/src/commands/release.ts +259 -76
  44. package/src/commands/review.ts +293 -59
  45. package/src/commands/status.ts +200 -13
  46. package/src/commands/supi.ts +3 -35
  47. package/src/commands/ui-design.ts +394 -0
  48. package/src/commands/ultraplan.ts +1518 -0
  49. package/src/commands/update.ts +86 -0
  50. package/src/config/defaults.ts +62 -0
  51. package/src/config/loader.ts +448 -60
  52. package/src/config/schema.ts +108 -2
  53. package/src/context/optimizer.ts +25 -33
  54. package/src/context/rule-renderer.ts +223 -0
  55. package/src/context/savings.ts +258 -0
  56. package/src/context/startup-check.ts +380 -0
  57. package/src/context/startup-optimizer.ts +355 -0
  58. package/src/context/tokenignore.ts +146 -0
  59. package/src/context-mode/cache-handle.ts +49 -0
  60. package/src/context-mode/cache-preview.ts +71 -0
  61. package/src/context-mode/cache-store.ts +738 -0
  62. package/src/context-mode/compressor.ts +131 -26
  63. package/src/context-mode/dedup.ts +108 -0
  64. package/src/context-mode/detector.ts +35 -4
  65. package/src/context-mode/event-extractor.ts +14 -12
  66. package/src/context-mode/event-store.ts +91 -36
  67. package/src/context-mode/hooks.ts +798 -56
  68. package/src/context-mode/knowledge/store.ts +255 -11
  69. package/src/context-mode/memory-store.ts +325 -0
  70. package/src/context-mode/metrics-recorder.ts +158 -0
  71. package/src/context-mode/metrics-store.ts +765 -0
  72. package/src/context-mode/model.ts +24 -0
  73. package/src/context-mode/processor-keys.ts +29 -0
  74. package/src/context-mode/processors/build.ts +66 -0
  75. package/src/context-mode/processors/docker.ts +57 -0
  76. package/src/context-mode/processors/git.ts +111 -0
  77. package/src/context-mode/processors/json.ts +112 -0
  78. package/src/context-mode/processors/k8s.ts +67 -0
  79. package/src/context-mode/processors/lint.ts +67 -0
  80. package/src/context-mode/processors/log.ts +86 -0
  81. package/src/context-mode/processors/registry.ts +116 -0
  82. package/src/context-mode/processors/test-runner.ts +102 -0
  83. package/src/context-mode/processors/types.ts +20 -0
  84. package/src/context-mode/repomap.ts +400 -0
  85. package/src/context-mode/routing.ts +97 -24
  86. package/src/context-mode/sandbox/runners.ts +5 -1
  87. package/src/context-mode/snapshot-builder.ts +106 -11
  88. package/src/context-mode/source-hash.ts +173 -0
  89. package/src/context-mode/tool-name.ts +11 -0
  90. package/src/context-mode/tools.ts +654 -22
  91. package/src/context-mode/web/fetcher.ts +31 -12
  92. package/src/debug/logger.ts +2 -1
  93. package/src/deps/registry.ts +1 -1
  94. package/src/discipline/failure-summarizer.ts +170 -0
  95. package/src/discipline/failure-taxonomy.ts +131 -0
  96. package/src/discipline/workflow-invariants.ts +125 -0
  97. package/src/discovery/index.ts +31 -0
  98. package/src/discovery/lsp.ts +87 -0
  99. package/src/discovery/rank.ts +144 -0
  100. package/src/discovery/sources.ts +89 -0
  101. package/src/discovery/workflow.ts +87 -0
  102. package/src/docs/contracts.ts +39 -0
  103. package/src/docs/drift.ts +117 -87
  104. package/src/fix-pr/assessment.ts +200 -0
  105. package/src/fix-pr/contracts.ts +47 -0
  106. package/src/fix-pr/fetch-comments.ts +80 -0
  107. package/src/fix-pr/prompt-builder.ts +58 -40
  108. package/src/fix-pr/scripts/exec.ts +34 -0
  109. package/src/fix-pr/scripts/trigger-review.ts +106 -0
  110. package/src/fix-pr/scripts/wait-and-check.ts +108 -0
  111. package/src/fix-pr/types.ts +4 -0
  112. package/src/git/branch-finish.ts +5 -0
  113. package/src/git/commit-contract.ts +83 -0
  114. package/src/git/commit.ts +121 -184
  115. package/src/git/status.ts +62 -8
  116. package/src/harness/anti_slop/architecture-parser.ts +210 -0
  117. package/src/harness/anti_slop/backend-factory.ts +30 -0
  118. package/src/harness/anti_slop/backend.ts +140 -0
  119. package/src/harness/anti_slop/desloppify-adapter.ts +319 -0
  120. package/src/harness/anti_slop/fallow-adapter.ts +305 -0
  121. package/src/harness/anti_slop/installer.ts +227 -0
  122. package/src/harness/anti_slop/queue.ts +216 -0
  123. package/src/harness/anti_slop/recommend.ts +84 -0
  124. package/src/harness/anti_slop/score.ts +180 -0
  125. package/src/harness/anti_slop/synthetic-edit-test.ts +128 -0
  126. package/src/harness/artifacts/agents-md.ts +88 -0
  127. package/src/harness/artifacts/checks-wiring.ts +57 -0
  128. package/src/harness/artifacts/docs-tree.ts +79 -0
  129. package/src/harness/artifacts/lint-configs.ts +136 -0
  130. package/src/harness/artifacts/review-agents.ts +67 -0
  131. package/src/harness/bare-entry.ts +108 -0
  132. package/src/harness/command.ts +1010 -0
  133. package/src/harness/default-agents/design.md +23 -0
  134. package/src/harness/default-agents/discover.md +18 -0
  135. package/src/harness/default-agents/implement.md +24 -0
  136. package/src/harness/default-agents/plan.md +19 -0
  137. package/src/harness/default-agents/research.md +21 -0
  138. package/src/harness/default-agents/validate.md +22 -0
  139. package/src/harness/gc/reporter.ts +28 -0
  140. package/src/harness/gc/runner.ts +136 -0
  141. package/src/harness/hooks/layer-context-inject.ts +155 -0
  142. package/src/harness/hooks/post-session-sweep.ts +130 -0
  143. package/src/harness/hooks/pre-edit-dupe-probe.ts +224 -0
  144. package/src/harness/hooks/register.ts +118 -0
  145. package/src/harness/model.ts +117 -0
  146. package/src/harness/pipeline.ts +348 -0
  147. package/src/harness/project-paths.ts +235 -0
  148. package/src/harness/stage-runner.ts +107 -0
  149. package/src/harness/stages/design.ts +386 -0
  150. package/src/harness/stages/discover.ts +454 -0
  151. package/src/harness/stages/implement.ts +162 -0
  152. package/src/harness/stages/plan.ts +335 -0
  153. package/src/harness/stages/research.ts +263 -0
  154. package/src/harness/stages/validate.ts +684 -0
  155. package/src/harness/storage.ts +467 -0
  156. package/src/harness/tools.ts +426 -0
  157. package/src/lsp/bridge.ts +56 -95
  158. package/src/lsp/capabilities.ts +108 -0
  159. package/src/lsp/contracts.ts +35 -0
  160. package/src/lsp/detector.ts +8 -12
  161. package/src/markdown-frontmatter.ts +68 -0
  162. package/src/mempalace/bridge.ts +129 -0
  163. package/src/mempalace/config.ts +75 -0
  164. package/src/mempalace/format.ts +163 -0
  165. package/src/mempalace/hooks.ts +370 -0
  166. package/src/mempalace/installer-helper.ts +194 -0
  167. package/src/mempalace/python/mempalace_bridge.py +440 -0
  168. package/src/mempalace/runtime.ts +565 -0
  169. package/src/mempalace/schema.ts +264 -0
  170. package/src/mempalace/session-summary.ts +198 -0
  171. package/src/mempalace/tool.ts +186 -0
  172. package/src/mempalace/uv.ts +256 -0
  173. package/src/migrate/runner.ts +354 -0
  174. package/src/planning/approval-flow.ts +206 -9
  175. package/src/planning/plan-writer-prompt.ts +4 -3
  176. package/src/planning/planning-ask-tool.ts +39 -0
  177. package/src/planning/render-markdown.ts +74 -0
  178. package/src/planning/spec.ts +42 -0
  179. package/src/planning/system-prompt.ts +11 -8
  180. package/src/planning/validate.ts +84 -0
  181. package/src/platform/omp.ts +15 -2
  182. package/src/platform/system-prompt.ts +37 -0
  183. package/src/platform/test-utils.ts +3 -0
  184. package/src/platform/types.ts +6 -1
  185. package/src/qa/config.ts +12 -6
  186. package/src/qa/detect-app-type.ts +13 -6
  187. package/src/qa/matrix.ts +12 -6
  188. package/src/qa/prompt-builder.ts +28 -30
  189. package/src/qa/scripts/dev-server-utils.ts +72 -0
  190. package/src/qa/scripts/run-e2e-tests.ts +226 -0
  191. package/src/qa/scripts/start-dev-server.ts +138 -0
  192. package/src/qa/scripts/stop-dev-server.ts +77 -0
  193. package/src/qa/session.ts +13 -7
  194. package/src/quality/ai-setup.ts +27 -25
  195. package/src/quality/contracts.ts +34 -0
  196. package/src/quality/gates/ai-review.ts +20 -58
  197. package/src/quality/gates/command.ts +249 -46
  198. package/src/quality/review-gates.ts +18 -2
  199. package/src/quality/runner.ts +63 -22
  200. package/src/quality/schemas.ts +37 -2
  201. package/src/quality/setup.ts +96 -16
  202. package/src/release/changelog.ts +1 -1
  203. package/src/release/channels/custom.ts +13 -3
  204. package/src/release/channels/types.ts +5 -0
  205. package/src/release/contracts.ts +90 -0
  206. package/src/release/executor.ts +122 -45
  207. package/src/release/prompt.ts +18 -2
  208. package/src/release/targets.ts +86 -0
  209. package/src/release/version.ts +96 -71
  210. package/src/review/agent-loader.ts +221 -109
  211. package/src/review/fixer.ts +10 -6
  212. package/src/review/multi-agent-runner.ts +114 -13
  213. package/src/review/output.ts +12 -139
  214. package/src/review/runner.ts +12 -6
  215. package/src/review/scope.ts +144 -24
  216. package/src/review/types.ts +1 -20
  217. package/src/review/validator.ts +12 -6
  218. package/src/storage/fix-pr-sessions.ts +21 -14
  219. package/src/storage/plans.ts +14 -5
  220. package/src/storage/qa-sessions.ts +25 -19
  221. package/src/storage/reliability-metrics.ts +180 -0
  222. package/src/storage/reports.ts +8 -7
  223. package/src/storage/review-sessions.ts +55 -20
  224. package/src/tool-catalog/active-tool-controller.ts +164 -0
  225. package/src/tool-catalog/active-tool-planner.ts +212 -0
  226. package/src/tool-catalog/tool-groups.ts +102 -0
  227. package/src/types.ts +1399 -5
  228. package/src/ui-design/backend-adapter.ts +78 -0
  229. package/src/ui-design/backends/local-html.ts +82 -0
  230. package/src/ui-design/backends/pencil-mcp.ts +111 -0
  231. package/src/ui-design/components-scanner.ts +124 -0
  232. package/src/ui-design/config.ts +55 -0
  233. package/src/ui-design/pen-scanner.ts +95 -0
  234. package/src/ui-design/pen-selector.ts +72 -0
  235. package/src/ui-design/prompt-builder.ts +73 -0
  236. package/src/ui-design/scanner.ts +136 -0
  237. package/src/ui-design/session.ts +974 -0
  238. package/src/ui-design/system-prompt.ts +312 -0
  239. package/src/ui-design/tokens-scanner.ts +181 -0
  240. package/src/ui-design/types.ts +96 -0
  241. package/src/ultraplan/agent-catalog.ts +522 -0
  242. package/src/ultraplan/authoring/agent-catalog.ts +310 -0
  243. package/src/ultraplan/authoring/authoring-tools.ts +552 -0
  244. package/src/ultraplan/authoring/command-handlers.ts +339 -0
  245. package/src/ultraplan/authoring/markdown.ts +510 -0
  246. package/src/ultraplan/authoring/model.ts +162 -0
  247. package/src/ultraplan/authoring/pipeline.ts +319 -0
  248. package/src/ultraplan/authoring/stage-runner.ts +141 -0
  249. package/src/ultraplan/authoring/stages/approve.ts +249 -0
  250. package/src/ultraplan/authoring/stages/discover.ts +289 -0
  251. package/src/ultraplan/authoring/stages/intake.ts +203 -0
  252. package/src/ultraplan/authoring/stages/research.ts +399 -0
  253. package/src/ultraplan/authoring/stages/review.ts +333 -0
  254. package/src/ultraplan/authoring/stages/scout.ts +188 -0
  255. package/src/ultraplan/authoring/stages/synthesize.ts +348 -0
  256. package/src/ultraplan/authoring/storage.ts +594 -0
  257. package/src/ultraplan/authoring/synth-gate.ts +165 -0
  258. package/src/ultraplan/authoring-draft.ts +653 -0
  259. package/src/ultraplan/authoring-persist.ts +180 -0
  260. package/src/ultraplan/authoring-tool.ts +608 -0
  261. package/src/ultraplan/authoring-wizard.ts +587 -0
  262. package/src/ultraplan/batch/merge.ts +98 -0
  263. package/src/ultraplan/batch/planner.ts +150 -0
  264. package/src/ultraplan/batch/presenter.ts +97 -0
  265. package/src/ultraplan/batch/storage.ts +420 -0
  266. package/src/ultraplan/batch/supervisor.ts +317 -0
  267. package/src/ultraplan/batch/worker.ts +26 -0
  268. package/src/ultraplan/batch/worktree.ts +110 -0
  269. package/src/ultraplan/contracts.ts +1593 -0
  270. package/src/ultraplan/default-agents/authoring/discoverer.md +12 -0
  271. package/src/ultraplan/default-agents/authoring/intake.md +12 -0
  272. package/src/ultraplan/default-agents/authoring/planner.md +12 -0
  273. package/src/ultraplan/default-agents/authoring/researcher.md +12 -0
  274. package/src/ultraplan/default-agents/authoring/scope-checker.md +12 -0
  275. package/src/ultraplan/default-agents/authoring/scout.md +12 -0
  276. package/src/ultraplan/default-agents/authoring/structure-checker.md +12 -0
  277. package/src/ultraplan/default-agents/authoring/tdd-checker.md +12 -0
  278. package/src/ultraplan/default-agents/backend-domain-reviewer.md +10 -0
  279. package/src/ultraplan/default-agents/backend-executor.md +10 -0
  280. package/src/ultraplan/default-agents/backend-stack-reviewer.md +10 -0
  281. package/src/ultraplan/default-agents/backend-tester.md +10 -0
  282. package/src/ultraplan/default-agents/frontend-domain-reviewer.md +10 -0
  283. package/src/ultraplan/default-agents/frontend-executor.md +10 -0
  284. package/src/ultraplan/default-agents/frontend-stack-reviewer.md +10 -0
  285. package/src/ultraplan/default-agents/frontend-tester.md +10 -0
  286. package/src/ultraplan/default-agents/infrastructure-domain-reviewer.md +10 -0
  287. package/src/ultraplan/default-agents/infrastructure-executor.md +10 -0
  288. package/src/ultraplan/default-agents/infrastructure-stack-reviewer.md +10 -0
  289. package/src/ultraplan/default-agents/infrastructure-tester.md +10 -0
  290. package/src/ultraplan/execution/contract.ts +71 -0
  291. package/src/ultraplan/execution/policy.ts +217 -0
  292. package/src/ultraplan/execution/runtime-tools.ts +107 -0
  293. package/src/ultraplan/execution/session-runner.ts +281 -0
  294. package/src/ultraplan/next-router.ts +85 -0
  295. package/src/ultraplan/presenter.ts +359 -0
  296. package/src/ultraplan/project-paths.ts +342 -0
  297. package/src/ultraplan/runtime/active-execution.ts +72 -0
  298. package/src/ultraplan/runtime/apply-mutation.ts +416 -0
  299. package/src/ultraplan/runtime/blockers.ts +243 -0
  300. package/src/ultraplan/runtime/hook-bridge.ts +486 -0
  301. package/src/ultraplan/runtime/launch-context.ts +207 -0
  302. package/src/ultraplan/runtime/migration.ts +524 -0
  303. package/src/ultraplan/runtime/normalize.ts +281 -0
  304. package/src/ultraplan/runtime/proof.ts +260 -0
  305. package/src/ultraplan/runtime/reducer.ts +416 -0
  306. package/src/ultraplan/runtime/repair.ts +251 -0
  307. package/src/ultraplan/runtime/tracker-storage.ts +368 -0
  308. package/src/ultraplan/session-selection.ts +291 -0
  309. package/src/ultraplan/storage.ts +374 -0
  310. package/src/utils/editor.ts +38 -0
  311. package/src/utils/executable.ts +80 -0
  312. package/src/utils/paths.ts +1 -20
  313. package/src/utils/shell.ts +31 -0
  314. package/src/visual/companion.ts +2 -1
  315. package/src/visual/scripts/frame-template.html +60 -0
  316. package/src/visual/scripts/index.js +59 -13
  317. package/src/visual/scripts/package.json +3 -0
  318. package/src/visual/start-server.ts +2 -1
  319. package/src/workspace/git-scope.ts +64 -0
  320. package/src/workspace/locks.ts +23 -0
  321. package/src/workspace/package-manager.ts +117 -0
  322. package/src/workspace/path-mapping.ts +75 -0
  323. package/src/workspace/project-slug.ts +92 -0
  324. package/src/workspace/repo-root.ts +137 -0
  325. package/src/workspace/selector.ts +115 -0
  326. package/src/workspace/state-paths.ts +118 -0
  327. package/src/workspace/targets.ts +313 -0
  328. package/src/fix-pr/scripts/diff-comments.sh +0 -33
  329. package/src/fix-pr/scripts/fetch-pr-comments.sh +0 -25
  330. package/src/fix-pr/scripts/trigger-review.sh +0 -36
  331. package/src/fix-pr/scripts/wait-and-check.sh +0 -37
  332. package/src/qa/scripts/detect-app-type.sh +0 -68
  333. package/src/qa/scripts/discover-routes.sh +0 -143
  334. package/src/qa/scripts/run-e2e-tests.sh +0 -131
  335. package/src/qa/scripts/start-dev-server.sh +0 -46
  336. package/src/qa/scripts/stop-dev-server.sh +0 -36
  337. package/src/review/prompts/fix-output-schema.md +0 -18
  338. package/src/review/prompts/review-output-schema.md +0 -38
  339. package/src/review/template.ts +0 -15
  340. /package/src/{review → ai}/prompts/invalid-output-retry.md +0 -0
@@ -0,0 +1,227 @@
1
+ /**
2
+ * Anti-slop backend installer.
3
+ *
4
+ * Writes the per-backend config, ensures `.desloppify/` is gitignored when desloppify is
5
+ * the chosen backend, and triggers `desloppify update-skill <client>` for each agent-skill
6
+ * distribution target the user opted into during Design.
7
+ *
8
+ * Idempotent: running the installer twice with the same inputs is a no-op (existing config
9
+ * files are read first, then either overwritten with identical content or left untouched).
10
+ */
11
+
12
+ import * as fs from "node:fs";
13
+ import * as path from "node:path";
14
+
15
+ import type { Platform, PlatformPaths } from "../../platform/types.js";
16
+ import type { HarnessAntiSlopBackend, HarnessLayerRule } from "../../types.js";
17
+ import {
18
+ getHarnessFallowConfigPath,
19
+ } from "../project-paths.js";
20
+
21
+ export interface InstallInput {
22
+ cwd: string;
23
+ backend: HarnessAntiSlopBackend;
24
+ /** Layer rules from Design — used to seed fallow's architecture-boundaries section. */
25
+ layerRules: readonly HarnessLayerRule[];
26
+ /** Agent-skill distribution targets (e.g. "claude", "cursor"). */
27
+ skillTargets: readonly string[];
28
+ /** Detected entry points (binaries, packages). Used by fallow's entry section. */
29
+ entryPoints: readonly string[];
30
+ /** When false, the installer dry-runs (writes nothing; returns the planned actions). */
31
+ apply: boolean;
32
+ }
33
+
34
+ export interface InstallResult {
35
+ ok: boolean;
36
+ actions: string[];
37
+ warnings: string[];
38
+ }
39
+
40
+ /**
41
+ * Build the .fallowrc.json content from layer rules + entry points. The schema is a
42
+ * loose JSON object; fallow tolerates unknown keys, so we keep the shape forward-compatible.
43
+ */
44
+ export function buildFallowConfig(input: {
45
+ layerRules: readonly HarnessLayerRule[];
46
+ entryPoints: readonly string[];
47
+ }): Record<string, unknown> {
48
+ const architecture = input.layerRules.map((rule) => ({
49
+ layer: rule.layer,
50
+ files: rule.globs,
51
+ allowed: rule.allowedImports,
52
+ forbidden: rule.forbiddenImports,
53
+ ...(rule.description ? { description: rule.description } : {}),
54
+ }));
55
+ return {
56
+ "$schema": "https://fallow.dev/schema/v1/.fallowrc.json",
57
+ entryPoints: [...input.entryPoints],
58
+ architecture,
59
+ audits: {
60
+ duplicates: { enabled: true, threshold: 0.85, minTokens: 30 },
61
+ deadCode: { enabled: true },
62
+ complexity: { enabled: true, fileLineLimit: 600 },
63
+ circularDependencies: { enabled: true },
64
+ },
65
+ };
66
+ }
67
+
68
+ /**
69
+ * Install the fallow side: writes `.fallowrc.json` with detected entry points + layer
70
+ * boundaries.
71
+ */
72
+ export async function installFallow(
73
+ paths: PlatformPaths,
74
+ input: InstallInput,
75
+ ): Promise<InstallResult> {
76
+ const actions: string[] = [];
77
+ const warnings: string[] = [];
78
+ const configPath = getHarnessFallowConfigPath(paths, input.cwd);
79
+ const config = buildFallowConfig({
80
+ layerRules: input.layerRules,
81
+ entryPoints: input.entryPoints,
82
+ });
83
+ const serialized = `${JSON.stringify(config, null, 2)}\n`;
84
+
85
+ let existing: string | null = null;
86
+ try {
87
+ if (fs.existsSync(configPath)) existing = fs.readFileSync(configPath, "utf8");
88
+ } catch (error) {
89
+ warnings.push(`unable to read existing ${configPath}: ${error instanceof Error ? error.message : String(error)}`);
90
+ }
91
+
92
+ if (existing === serialized) {
93
+ actions.push(`.fallowrc.json already up-to-date (no write)`);
94
+ return { ok: true, actions, warnings };
95
+ }
96
+
97
+ if (!input.apply) {
98
+ actions.push(`would write ${configPath}`);
99
+ return { ok: true, actions, warnings };
100
+ }
101
+
102
+ try {
103
+ fs.mkdirSync(path.dirname(configPath), { recursive: true });
104
+ fs.writeFileSync(configPath, serialized);
105
+ actions.push(`wrote ${configPath}`);
106
+ } catch (error) {
107
+ return {
108
+ ok: false,
109
+ actions,
110
+ warnings: [...warnings, `failed to write ${configPath}: ${error instanceof Error ? error.message : String(error)}`],
111
+ };
112
+ }
113
+ return { ok: true, actions, warnings };
114
+ }
115
+
116
+ /** Ensure `.desloppify/` is gitignored. */
117
+ export async function ensureDesloppifyGitignore(input: { cwd: string; apply: boolean }): Promise<InstallResult> {
118
+ const actions: string[] = [];
119
+ const warnings: string[] = [];
120
+ const gitignorePath = path.join(input.cwd, ".gitignore");
121
+ const desiredEntry = ".desloppify/";
122
+
123
+ let content = "";
124
+ try {
125
+ if (fs.existsSync(gitignorePath)) content = fs.readFileSync(gitignorePath, "utf8");
126
+ } catch (error) {
127
+ warnings.push(`unable to read .gitignore: ${error instanceof Error ? error.message : String(error)}`);
128
+ }
129
+
130
+ const lines = content.split(/\r?\n/);
131
+ const has = lines.some((line) => line.trim() === desiredEntry || line.trim() === ".desloppify");
132
+ if (has) {
133
+ actions.push(`.gitignore already contains ${desiredEntry}`);
134
+ return { ok: true, actions, warnings };
135
+ }
136
+ if (!input.apply) {
137
+ actions.push(`would append ${desiredEntry} to .gitignore`);
138
+ return { ok: true, actions, warnings };
139
+ }
140
+ try {
141
+ const append = content.length === 0 || content.endsWith("\n")
142
+ ? `${desiredEntry}\n`
143
+ : `\n${desiredEntry}\n`;
144
+ fs.writeFileSync(gitignorePath, content + append);
145
+ actions.push(`appended ${desiredEntry} to .gitignore`);
146
+ } catch (error) {
147
+ return {
148
+ ok: false,
149
+ actions,
150
+ warnings: [...warnings, `failed to update .gitignore: ${error instanceof Error ? error.message : String(error)}`],
151
+ };
152
+ }
153
+ return { ok: true, actions, warnings };
154
+ }
155
+
156
+ /**
157
+ * Run `desloppify update-skill <client>` for each agent-skill distribution target. We
158
+ * never abort on a single client failure — we report the warning and continue so a missing
159
+ * client doesn't block the rest of the install.
160
+ */
161
+ export async function distributeAgentSkills(
162
+ platform: Platform,
163
+ input: { cwd: string; targets: readonly string[]; apply: boolean },
164
+ ): Promise<InstallResult> {
165
+ const actions: string[] = [];
166
+ const warnings: string[] = [];
167
+ if (input.targets.length === 0) {
168
+ actions.push("no skill targets selected; nothing to distribute");
169
+ return { ok: true, actions, warnings };
170
+ }
171
+
172
+ if (!input.apply) {
173
+ for (const target of input.targets) {
174
+ actions.push(`would run desloppify update-skill ${target}`);
175
+ }
176
+ return { ok: true, actions, warnings };
177
+ }
178
+
179
+ for (const target of input.targets) {
180
+ try {
181
+ const result = await platform.exec(
182
+ "desloppify",
183
+ ["update-skill", target],
184
+ { cwd: input.cwd, timeout: 30_000 },
185
+ );
186
+ if (result.code !== 0) {
187
+ warnings.push(`desloppify update-skill ${target} exited ${result.code}: ${result.stderr.trim()}`);
188
+ continue;
189
+ }
190
+ actions.push(`desloppify update-skill ${target} ok`);
191
+ } catch (error) {
192
+ warnings.push(
193
+ `desloppify update-skill ${target} threw: ${error instanceof Error ? error.message : String(error)}`,
194
+ );
195
+ }
196
+ }
197
+ return { ok: warnings.length === 0, actions, warnings };
198
+ }
199
+
200
+ /**
201
+ * Compose the per-backend install steps. Stage callers should pass `apply: true`; design
202
+ * preview can pass `apply: false` to display the plan.
203
+ */
204
+ export async function installAntiSlopBackend(
205
+ platform: Platform,
206
+ paths: PlatformPaths,
207
+ input: InstallInput,
208
+ ): Promise<InstallResult> {
209
+ const aggregated: InstallResult = { ok: true, actions: [], warnings: [] };
210
+ const fold = (step: InstallResult) => {
211
+ aggregated.actions.push(...step.actions);
212
+ aggregated.warnings.push(...step.warnings);
213
+ if (!step.ok) aggregated.ok = false;
214
+ };
215
+
216
+ if (input.backend === "fallow" || input.backend === "hybrid") {
217
+ fold(await installFallow(paths, input));
218
+ }
219
+ if (input.backend === "desloppify" || input.backend === "hybrid") {
220
+ fold(await ensureDesloppifyGitignore({ cwd: input.cwd, apply: input.apply }));
221
+ fold(await distributeAgentSkills(platform, { cwd: input.cwd, targets: input.skillTargets, apply: input.apply }));
222
+ }
223
+ if (input.backend === "supi-native") {
224
+ aggregated.actions.push("supi-native backend selected; no external CLI install required");
225
+ }
226
+ return aggregated;
227
+ }
@@ -0,0 +1,216 @@
1
+ /**
2
+ * Persistent slop queue.
3
+ *
4
+ * One JSONL file per project, keyed by violation id. The id is content-addressed (hash
5
+ * over normalized `(file, range, kind, source-rule)`) so the same violation reported by
6
+ * fallow and desloppify collapses to one entry — that's the deduplication strategy for the
7
+ * `hybrid` backend.
8
+ *
9
+ * Operations:
10
+ * - `appendOpen(entry)` — atomic append-only write of a fresh `state: "open"` entry.
11
+ * - `resolve(id)` / `markWontfix(id)` — atomic rewrite of the file with the entry's state
12
+ * updated. (We rewrite rather than append because the queue is conceptually a set keyed
13
+ * by id, not a log.)
14
+ * - `next()` — returns the highest-severity, oldest-`open` entry (FIFO within severity).
15
+ * - `backlog(filter?)` — every open entry matching the filter.
16
+ * - `findById(id)` — single-entry lookup.
17
+ *
18
+ * Crash semantics: writes go through `temp + rename`. Readers tolerate one trailing partial
19
+ * line (the storage layer already strips it).
20
+ */
21
+
22
+ import { createHash } from "node:crypto";
23
+
24
+ import type { PlatformPaths } from "../../platform/types.js";
25
+ import type {
26
+ HarnessSlopQueueEntry,
27
+ HarnessSlopSource,
28
+ HarnessSlopState,
29
+ HarnessSlopViolationKind,
30
+ UltraPlanStorageResult,
31
+ } from "../../types.js";
32
+ import {
33
+ appendSlopQueueEntry,
34
+ readSlopQueue,
35
+ rewriteSlopQueue,
36
+ } from "../storage.js";
37
+
38
+ // ---------------------------------------------------------------------------
39
+ // Identity helpers
40
+ // ---------------------------------------------------------------------------
41
+
42
+ /**
43
+ * Compute a stable, content-addressed id for a violation. Two backends reporting the same
44
+ * `(file, range, kind, source-rule)` collapse to the same id, which lets the hybrid backend
45
+ * deduplicate transparently.
46
+ */
47
+ export function computeQueueEntryId(input: {
48
+ kind: HarnessSlopViolationKind;
49
+ file: string;
50
+ range: HarnessSlopQueueEntry["range"];
51
+ /** Optional rule id from the source backend (e.g. fallow rule slug). */
52
+ ruleHint?: string;
53
+ }): string {
54
+ const range = input.range
55
+ ? `${input.range.startLine}-${input.range.endLine}`
56
+ : "*";
57
+ const fingerprint = `${input.kind}|${input.file}|${range}|${input.ruleHint ?? ""}`;
58
+ return createHash("sha256").update(fingerprint).digest("hex").slice(0, 16);
59
+ }
60
+
61
+ // ---------------------------------------------------------------------------
62
+ // Queue operations
63
+ // ---------------------------------------------------------------------------
64
+
65
+ export interface BacklogFilter {
66
+ kind?: HarnessSlopViolationKind;
67
+ source?: HarnessSlopSource;
68
+ state?: HarnessSlopState;
69
+ file?: string;
70
+ }
71
+
72
+ const SEVERITY_ORDER: Record<HarnessSlopQueueEntry["severity"], number> = {
73
+ blocker: 0,
74
+ warning: 1,
75
+ info: 2,
76
+ };
77
+
78
+ /**
79
+ * Append a fresh entry. Existing entries with the same id are NOT updated by this call —
80
+ * use `resolve`/`markWontfix` for state transitions. Re-appending the same id is a no-op
81
+ * at the consumer level (we deduplicate on read).
82
+ */
83
+ export function appendOpen(
84
+ paths: PlatformPaths,
85
+ cwd: string,
86
+ entry: Omit<HarnessSlopQueueEntry, "state"> & { state?: HarnessSlopState },
87
+ ): UltraPlanStorageResult<string> {
88
+ const filled: HarnessSlopQueueEntry = {
89
+ ...entry,
90
+ state: entry.state ?? "open",
91
+ };
92
+ return appendSlopQueueEntry(paths, cwd, filled);
93
+ }
94
+
95
+ /**
96
+ * Read every entry. Duplicate ids are collapsed: the most recent record wins so resolve /
97
+ * wontfix appended after the original takes precedence even when no rewrite happened.
98
+ */
99
+ export function readAll(
100
+ paths: PlatformPaths,
101
+ cwd: string,
102
+ ): UltraPlanStorageResult<HarnessSlopQueueEntry[]> {
103
+ const result = readSlopQueue(paths, cwd);
104
+ if (!result.ok) return result;
105
+ const seen = new Map<string, HarnessSlopQueueEntry>();
106
+ for (const entry of result.value) {
107
+ seen.set(entry.id, entry);
108
+ }
109
+ return { ok: true, value: [...seen.values()] };
110
+ }
111
+
112
+ /** Pop the highest-severity, oldest open entry. Returns null when the queue is empty. */
113
+ export function next(
114
+ paths: PlatformPaths,
115
+ cwd: string,
116
+ ): UltraPlanStorageResult<HarnessSlopQueueEntry | null> {
117
+ const all = readAll(paths, cwd);
118
+ if (!all.ok) return all;
119
+ const open = all.value.filter((e) => e.state === "open");
120
+ if (open.length === 0) return { ok: true, value: null };
121
+ open.sort((a, b) => {
122
+ const sev = SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity];
123
+ if (sev !== 0) return sev;
124
+ return a.ts.localeCompare(b.ts);
125
+ });
126
+ return { ok: true, value: open[0] };
127
+ }
128
+
129
+ /** Every open entry matching the filter. */
130
+ export function backlog(
131
+ paths: PlatformPaths,
132
+ cwd: string,
133
+ filter: BacklogFilter = {},
134
+ ): UltraPlanStorageResult<HarnessSlopQueueEntry[]> {
135
+ const all = readAll(paths, cwd);
136
+ if (!all.ok) return all;
137
+ const targetState = filter.state ?? "open";
138
+ return {
139
+ ok: true,
140
+ value: all.value.filter((e) => {
141
+ if (e.state !== targetState) return false;
142
+ if (filter.kind && e.kind !== filter.kind) return false;
143
+ if (filter.source && e.source !== filter.source) return false;
144
+ if (filter.file && e.file !== filter.file) return false;
145
+ return true;
146
+ }),
147
+ };
148
+ }
149
+
150
+ export function findById(
151
+ paths: PlatformPaths,
152
+ cwd: string,
153
+ id: string,
154
+ ): UltraPlanStorageResult<HarnessSlopQueueEntry | null> {
155
+ const all = readAll(paths, cwd);
156
+ if (!all.ok) return all;
157
+ return { ok: true, value: all.value.find((e) => e.id === id) ?? null };
158
+ }
159
+
160
+ function setState(
161
+ paths: PlatformPaths,
162
+ cwd: string,
163
+ id: string,
164
+ state: HarnessSlopState,
165
+ resolvedAt?: string,
166
+ ): UltraPlanStorageResult<HarnessSlopQueueEntry | null> {
167
+ const all = readAll(paths, cwd);
168
+ if (!all.ok) return all;
169
+ let updated: HarnessSlopQueueEntry | null = null;
170
+ const next: HarnessSlopQueueEntry[] = all.value.map((entry) => {
171
+ if (entry.id !== id) return entry;
172
+ updated = {
173
+ ...entry,
174
+ state,
175
+ resolvedAt: resolvedAt ?? new Date().toISOString(),
176
+ };
177
+ return updated;
178
+ });
179
+ if (!updated) return { ok: true, value: null };
180
+ const written = rewriteSlopQueue(paths, cwd, next);
181
+ if (!written.ok) return written;
182
+ return { ok: true, value: updated };
183
+ }
184
+
185
+ export function resolve(
186
+ paths: PlatformPaths,
187
+ cwd: string,
188
+ id: string,
189
+ ): UltraPlanStorageResult<HarnessSlopQueueEntry | null> {
190
+ return setState(paths, cwd, id, "resolved");
191
+ }
192
+
193
+ export function markWontfix(
194
+ paths: PlatformPaths,
195
+ cwd: string,
196
+ id: string,
197
+ ): UltraPlanStorageResult<HarnessSlopQueueEntry | null> {
198
+ return setState(paths, cwd, id, "wontfix");
199
+ }
200
+
201
+ /**
202
+ * Clear resolved entries from the queue. Returns the number of entries removed. Used
203
+ * during GC to keep the file from growing without bound.
204
+ */
205
+ export function compact(
206
+ paths: PlatformPaths,
207
+ cwd: string,
208
+ ): UltraPlanStorageResult<{ removed: number }> {
209
+ const all = readAll(paths, cwd);
210
+ if (!all.ok) return all;
211
+ const before = all.value.length;
212
+ const kept = all.value.filter((e) => e.state !== "resolved");
213
+ const written = rewriteSlopQueue(paths, cwd, kept);
214
+ if (!written.ok) return written;
215
+ return { ok: true, value: { removed: before - kept.length } };
216
+ }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Anti-slop backend recommendation.
3
+ *
4
+ * Inputs come from Discover (`HarnessDiscoverArtifact.languageCoverage`). We pick a
5
+ * backend per the algorithm spelled out in the plan §3.5:
6
+ *
7
+ * languages = unique(detect_languages(repo))
8
+ * if languages == {typescript, javascript}:
9
+ * recommend "fallow + supi-native"
10
+ * elif languages.size >= 3 OR python in languages OR rust in languages OR go in languages:
11
+ * recommend "desloppify under the hood"
12
+ * elif languages == {typescript} but pkg has subdirs in other languages:
13
+ * recommend "hybrid"
14
+ * else:
15
+ * recommend "supi-native only"
16
+ *
17
+ * The "subdirs in other languages" check uses the `languageCoverage` array — when the
18
+ * dominant language has share <90% AND a non-TS-family language has >0 files, the repo is
19
+ * polyglot enough to warrant the hybrid backend.
20
+ */
21
+
22
+ import type { HarnessAntiSlopBackend } from "../../types.js";
23
+
24
+ const TS_FAMILY = new Set(["typescript", "javascript", "tsx", "jsx"]);
25
+ const HEAVY_LANGUAGES = new Set(["python", "rust", "go"]);
26
+
27
+ export interface RecommendInput {
28
+ languageCoverage: { language: string; fileCount: number; share: number }[];
29
+ }
30
+
31
+ export interface Recommendation {
32
+ backend: HarnessAntiSlopBackend;
33
+ reason: string;
34
+ }
35
+
36
+ export function recommendBackend(input: RecommendInput): Recommendation {
37
+ const languages = input.languageCoverage
38
+ .map((c) => c.language.toLowerCase())
39
+ .filter((l) => l.length > 0);
40
+
41
+ if (languages.length === 0) {
42
+ return {
43
+ backend: "supi-native",
44
+ reason: "no detectable language coverage; defer to manual lint/dupe per stack",
45
+ };
46
+ }
47
+
48
+ const langSet = new Set(languages);
49
+
50
+ // TS-family-only repo → fallow (deepest supipowers integration, no Python dep).
51
+ const allTsFamily = languages.every((lang) => TS_FAMILY.has(lang));
52
+ if (allTsFamily && langSet.size <= 2) {
53
+ return {
54
+ backend: "fallow",
55
+ reason: "TS/JS-only repo; fallow ships native binaries for all three OSes",
56
+ };
57
+ }
58
+
59
+ // ≥3 languages or any heavy non-TS language → desloppify (29-language coverage).
60
+ const heavyPresent = languages.some((lang) => HEAVY_LANGUAGES.has(lang));
61
+ if (langSet.size >= 3 || heavyPresent) {
62
+ return {
63
+ backend: "desloppify",
64
+ reason:
65
+ langSet.size >= 3
66
+ ? "polyglot repo (≥3 languages); desloppify covers 29 languages with one CLI"
67
+ : `non-TS heavy language present (${[...langSet].filter((l) => HEAVY_LANGUAGES.has(l)).join(", ")}); desloppify covers it`,
68
+ };
69
+ }
70
+
71
+ // TS-dominant but with non-TS subtrees → hybrid.
72
+ const dominant = [...input.languageCoverage].sort((a, b) => b.share - a.share)[0];
73
+ if (dominant && TS_FAMILY.has(dominant.language.toLowerCase()) && dominant.share < 0.9) {
74
+ return {
75
+ backend: "hybrid",
76
+ reason: `TS-dominant (${Math.round(dominant.share * 100)}%) with non-TS subtrees; fallow on TS, desloppify on the rest`,
77
+ };
78
+ }
79
+
80
+ return {
81
+ backend: "supi-native",
82
+ reason: "no clear backend match; defer to manual lint/dupe per stack",
83
+ };
84
+ }
@@ -0,0 +1,180 @@
1
+ /**
2
+ * Lenient + strict score computation.
3
+ *
4
+ * Score is computed from the persistent slop queue and an optional set of additional
5
+ * dimensions (file health, test health, etc.). Each dimension contributes a 0–100 number
6
+ * to both lenient and strict; the aggregate is the unweighted average.
7
+ *
8
+ * - **Lenient** treats `wontfix` items as resolved — useful for "are we converging?".
9
+ * - **Strict** treats `wontfix` items as cost — closes the gaming loophole. Release
10
+ * blocking and CI gates use strict, never lenient.
11
+ *
12
+ * The same input always produces the same output (no Date.now() in the body), so callers
13
+ * who need an `_at` timestamp pass it through `computedAt`.
14
+ */
15
+
16
+ import type {
17
+ HarnessScore,
18
+ HarnessScoreDimension,
19
+ HarnessSlopQueueEntry,
20
+ } from "../../types.js";
21
+
22
+ // ---------------------------------------------------------------------------
23
+ // Dimension builders
24
+ // ---------------------------------------------------------------------------
25
+
26
+ const DIMENSION_NAMES = ["duplicates", "deadCode", "layerViolations", "other"] as const;
27
+
28
+ interface DimensionBucket {
29
+ open: number;
30
+ resolved: number;
31
+ wontfix: number;
32
+ total: number;
33
+ }
34
+
35
+ function emptyBucket(): DimensionBucket {
36
+ return { open: 0, resolved: 0, wontfix: 0, total: 0 };
37
+ }
38
+
39
+ /**
40
+ * Bucket queue entries into the four canonical dimensions. Unknown kinds map to `other`.
41
+ */
42
+ function bucketize(entries: readonly HarnessSlopQueueEntry[]): Record<string, DimensionBucket> {
43
+ const buckets: Record<string, DimensionBucket> = {
44
+ duplicates: emptyBucket(),
45
+ deadCode: emptyBucket(),
46
+ layerViolations: emptyBucket(),
47
+ other: emptyBucket(),
48
+ };
49
+
50
+ for (const entry of entries) {
51
+ const bucket = (() => {
52
+ switch (entry.kind) {
53
+ case "duplicate":
54
+ return buckets.duplicates;
55
+ case "dead-code":
56
+ return buckets.deadCode;
57
+ case "layer-violation":
58
+ return buckets.layerViolations;
59
+ default:
60
+ return buckets.other;
61
+ }
62
+ })();
63
+ bucket.total += 1;
64
+ if (entry.state === "open") bucket.open += 1;
65
+ else if (entry.state === "resolved") bucket.resolved += 1;
66
+ else if (entry.state === "wontfix") bucket.wontfix += 1;
67
+ }
68
+
69
+ return buckets;
70
+ }
71
+
72
+ /**
73
+ * Per-dimension score: 100 - 100 * (cost / max(total, 1)). Lenient excludes wontfix from
74
+ * cost; strict counts wontfix as cost.
75
+ */
76
+ function dimensionScores(bucket: DimensionBucket): { lenient: number; strict: number } {
77
+ const total = Math.max(bucket.total, 1);
78
+ const lenientCost = bucket.open;
79
+ const strictCost = bucket.open + bucket.wontfix;
80
+ return {
81
+ lenient: Math.round(100 * (1 - lenientCost / total)),
82
+ strict: Math.round(100 * (1 - strictCost / total)),
83
+ };
84
+ }
85
+
86
+ // ---------------------------------------------------------------------------
87
+ // Public API
88
+ // ---------------------------------------------------------------------------
89
+
90
+ export interface ComputeScoreInput {
91
+ computedAt: string;
92
+ entries: readonly HarnessSlopQueueEntry[];
93
+ /** Optional trend rows (oldest first). Forwarded to the result unchanged. */
94
+ trend?: { ts: string; lenient: number; strict: number }[];
95
+ }
96
+
97
+ export function computeScore(input: ComputeScoreInput): HarnessScore {
98
+ const buckets = bucketize(input.entries);
99
+
100
+ const dimensions: HarnessScoreDimension[] = DIMENSION_NAMES.map((name) => {
101
+ const bucket = buckets[name];
102
+ const { lenient, strict } = dimensionScores(bucket);
103
+ return {
104
+ name,
105
+ lenient,
106
+ strict,
107
+ total: bucket.total,
108
+ open: bucket.open,
109
+ resolved: bucket.resolved,
110
+ wontfix: bucket.wontfix,
111
+ };
112
+ });
113
+
114
+ // When no entries exist at all, every dimension is 100 — the harness has no recorded
115
+ // failures. We compute an unweighted average; rounding is post-hoc to keep the output
116
+ // close to integer scores even when the input is uniformly empty.
117
+ const lenient = Math.round(
118
+ dimensions.reduce((sum, d) => sum + d.lenient, 0) / dimensions.length,
119
+ );
120
+ const strict = Math.round(
121
+ dimensions.reduce((sum, d) => sum + d.strict, 0) / dimensions.length,
122
+ );
123
+
124
+ return {
125
+ computedAt: input.computedAt,
126
+ lenient,
127
+ strict,
128
+ dimensions,
129
+ ...(input.trend ? { trend: input.trend } : {}),
130
+ };
131
+ }
132
+
133
+ /**
134
+ * Compute whether the score passes the configured floor. Strict floor is the binding one
135
+ * (lenient is informational). When `release_blocking` is true and strict < strictFloor, the
136
+ * caller should exit non-zero (CI integration).
137
+ */
138
+ export function scoreFloorPassed(
139
+ score: HarnessScore,
140
+ floor: { strict: number; lenient: number },
141
+ ): { passed: boolean; reason: string } {
142
+ if (score.strict < floor.strict) {
143
+ return {
144
+ passed: false,
145
+ reason: `strict score ${score.strict} below floor ${floor.strict}`,
146
+ };
147
+ }
148
+ if (score.lenient < floor.lenient) {
149
+ return {
150
+ passed: false,
151
+ reason: `lenient score ${score.lenient} below floor ${floor.lenient}`,
152
+ };
153
+ }
154
+ return { passed: true, reason: "score floor satisfied" };
155
+ }
156
+
157
+ // ---------------------------------------------------------------------------
158
+ // Badge rendering
159
+ // ---------------------------------------------------------------------------
160
+
161
+ /**
162
+ * Render a tiny SVG shield-style badge. Tests assert deterministic output (no fonts, no
163
+ * external assets). Width is fixed; the strict score determines the fill color.
164
+ */
165
+ export function renderScoreBadge(score: HarnessScore): string {
166
+ const color =
167
+ score.strict >= 90 ? "#3fb950"
168
+ : score.strict >= 75 ? "#9e6a03"
169
+ : "#cf222e";
170
+ const text = `harness ${score.strict}`;
171
+ // 88 px width, 20 px height — same as shields.io flat badges.
172
+ return [
173
+ '<svg xmlns="http://www.w3.org/2000/svg" width="120" height="20" role="img" aria-label="harness score">',
174
+ `<rect width="120" height="20" fill="${color}" />`,
175
+ '<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11">',
176
+ `<text x="60" y="14">${text}</text>`,
177
+ "</g>",
178
+ "</svg>",
179
+ ].join("");
180
+ }