quiver-cli 0.7.0 → 1.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 (200) hide show
  1. package/README.md +110 -45
  2. package/bin/quiver-cli.mjs +3 -1
  3. package/dist/cli.js +733 -499
  4. package/package.json +2 -2
  5. package/template/.agents/AGENTS.md +4 -3
  6. package/template/.agents/config.json +7 -0
  7. package/template/.agents/plugins/opencode/rtk.ts +34 -0
  8. package/template/.agents/skills/agent-browser/SKILL.md +1 -0
  9. package/template/.agents/skills/apps/skybridge/SKILL.md +4 -0
  10. package/template/.agents/skills/design/impeccable/SKILL.md +36 -118
  11. package/template/.agents/skills/design/impeccable/reference/adapt.md +1 -0
  12. package/template/.agents/skills/design/impeccable/reference/adapt.native.md +58 -0
  13. package/template/.agents/skills/design/impeccable/reference/android.md +40 -0
  14. package/template/.agents/skills/design/impeccable/reference/animate.md +73 -188
  15. package/template/.agents/skills/design/impeccable/reference/audit.md +12 -9
  16. package/template/.agents/skills/design/impeccable/reference/audit.native.md +139 -0
  17. package/template/.agents/skills/design/impeccable/reference/bolder.md +19 -101
  18. package/template/.agents/skills/design/impeccable/reference/clarify.md +59 -253
  19. package/template/.agents/skills/design/impeccable/reference/colorize.md +51 -222
  20. package/template/.agents/skills/design/impeccable/reference/craft-floor.md +42 -0
  21. package/template/.agents/skills/design/impeccable/reference/craft.md +3 -121
  22. package/template/.agents/skills/design/impeccable/reference/critique.md +44 -23
  23. package/template/.agents/skills/design/impeccable/reference/degraded/asset-producer.md +90 -0
  24. package/template/.agents/skills/design/impeccable/reference/degraded/documenter.md +24 -0
  25. package/template/.agents/skills/design/impeccable/reference/degraded/finish-reviewer.md +37 -0
  26. package/template/.agents/skills/design/impeccable/reference/degraded/manual-edit-applier.md +92 -0
  27. package/template/.agents/skills/design/impeccable/reference/delight.md +47 -279
  28. package/template/.agents/skills/design/impeccable/reference/distill.md +2 -2
  29. package/template/.agents/skills/design/impeccable/reference/doctor.md +53 -0
  30. package/template/.agents/skills/design/impeccable/reference/document.md +60 -73
  31. package/template/.agents/skills/design/impeccable/reference/harden.md +1 -12
  32. package/template/.agents/skills/design/impeccable/reference/hooks.md +20 -5
  33. package/template/.agents/skills/design/impeccable/reference/init.md +72 -119
  34. package/template/.agents/skills/design/impeccable/reference/ios.md +45 -0
  35. package/template/.agents/skills/design/impeccable/reference/layout.md +54 -131
  36. package/template/.agents/skills/design/impeccable/reference/live-setup.md +102 -0
  37. package/template/.agents/skills/design/impeccable/reference/live.md +116 -511
  38. package/template/.agents/skills/design/impeccable/reference/new-work.md +105 -0
  39. package/template/.agents/skills/design/impeccable/reference/{product.md → operate.md} +6 -5
  40. package/template/.agents/skills/design/impeccable/reference/optimize.md +4 -4
  41. package/template/.agents/skills/design/impeccable/reference/overdrive.md +1 -4
  42. package/template/.agents/skills/design/impeccable/reference/polish.md +68 -212
  43. package/template/.agents/skills/design/impeccable/reference/quieter.md +3 -3
  44. package/template/.agents/skills/design/impeccable/reference/routing.md +18 -0
  45. package/template/.agents/skills/design/impeccable/reference/shape.md +38 -144
  46. package/template/.agents/skills/design/impeccable/reference/typeset.md +51 -250
  47. package/template/.agents/skills/design/impeccable/reference/visualize.md +47 -0
  48. package/template/.agents/skills/design/impeccable/scripts/command-metadata.json +1 -1
  49. package/template/.agents/skills/design/impeccable/scripts/concept-seed.mjs +558 -0
  50. package/template/.agents/skills/design/impeccable/scripts/context-signals.mjs +119 -10
  51. package/template/.agents/skills/design/impeccable/scripts/context.mjs +534 -45
  52. package/template/.agents/skills/design/impeccable/scripts/critique-storage.mjs +18 -47
  53. package/template/.agents/skills/design/impeccable/scripts/detector/browser/injected/index.mjs +96 -10
  54. package/template/.agents/skills/design/impeccable/scripts/detector/cli/main.mjs +174 -26
  55. package/template/.agents/skills/design/impeccable/scripts/detector/design-system.mjs +233 -0
  56. package/template/.agents/skills/design/impeccable/scripts/detector/detect-antipatterns-browser.js +3348 -203
  57. package/template/.agents/skills/design/impeccable/scripts/detector/engines/browser/detect-url.mjs +102 -7
  58. package/template/.agents/skills/design/impeccable/scripts/detector/engines/regex/detect-text.mjs +297 -97
  59. package/template/.agents/skills/design/impeccable/scripts/detector/engines/static-html/css-cascade.mjs +187 -16
  60. package/template/.agents/skills/design/impeccable/scripts/detector/engines/static-html/detect-html.mjs +41 -11
  61. package/template/.agents/skills/design/impeccable/scripts/detector/findings.mjs +7 -1
  62. package/template/.agents/skills/design/impeccable/scripts/detector/node/file-system.mjs +16 -2
  63. package/template/.agents/skills/design/impeccable/scripts/detector/registry/antipatterns.mjs +207 -38
  64. package/template/.agents/skills/design/impeccable/scripts/detector/rules/checks.mjs +3082 -173
  65. package/template/.agents/skills/design/impeccable/scripts/detector/shared/constants.mjs +11 -0
  66. package/template/.agents/skills/design/impeccable/scripts/detector/shared/fonts.mjs +30 -0
  67. package/template/.agents/skills/design/impeccable/scripts/doctor.mjs +336 -0
  68. package/template/.agents/skills/design/impeccable/scripts/embed-prompt.mjs +133 -0
  69. package/template/.agents/skills/design/impeccable/scripts/generate-image.mjs +240 -0
  70. package/template/.agents/skills/design/impeccable/scripts/hook-admin.mjs +98 -18
  71. package/template/.agents/skills/design/impeccable/scripts/hook-before-edit.mjs +46 -6
  72. package/template/.agents/skills/design/impeccable/scripts/hook-lib.mjs +518 -50
  73. package/template/.agents/skills/design/impeccable/scripts/hook.mjs +25 -8
  74. package/template/.agents/skills/design/impeccable/scripts/lib/artifact-schema.mjs +93 -0
  75. package/template/.agents/skills/design/impeccable/scripts/lib/composition-catalog.mjs +200 -0
  76. package/template/.agents/skills/design/impeccable/scripts/lib/concept-catalog.mjs +357 -0
  77. package/template/.agents/skills/design/impeccable/scripts/lib/impeccable-config.mjs +27 -7
  78. package/template/.agents/skills/design/impeccable/scripts/lib/impeccable-paths.mjs +17 -8
  79. package/template/.agents/skills/design/impeccable/scripts/lib/provider.mjs +5 -0
  80. package/template/.agents/skills/design/impeccable/scripts/lib/roll-selection.mjs +362 -0
  81. package/template/.agents/skills/design/impeccable/scripts/lib/staleness-deep.mjs +457 -0
  82. package/template/.agents/skills/design/impeccable/scripts/lib/staleness-notice.mjs +169 -0
  83. package/template/.agents/skills/design/impeccable/scripts/lib/staleness.mjs +457 -0
  84. package/template/.agents/skills/design/impeccable/scripts/lib/surface-briefs.mjs +151 -0
  85. package/template/.agents/skills/design/impeccable/scripts/lib/target-slug.mjs +33 -0
  86. package/template/.agents/skills/design/impeccable/scripts/lib/template-extensions.mjs +146 -0
  87. package/template/.agents/skills/design/impeccable/scripts/live/accept-css.mjs +617 -0
  88. package/template/.agents/skills/design/impeccable/scripts/live/accept-verify.mjs +60 -0
  89. package/template/.agents/skills/design/impeccable/scripts/live/browser-script-parts.mjs +7 -1
  90. package/template/.agents/skills/design/impeccable/scripts/live/completion.mjs +10 -1
  91. package/template/.agents/skills/design/impeccable/scripts/live/event-validation.mjs +67 -5
  92. package/template/.agents/skills/design/impeccable/scripts/live/frameworks/astro.mjs +47 -0
  93. package/template/.agents/skills/design/impeccable/scripts/live/frameworks/detect-utils.mjs +73 -0
  94. package/template/.agents/skills/design/impeccable/scripts/live/frameworks/index.mjs +143 -0
  95. package/template/.agents/skills/design/impeccable/scripts/live/frameworks/journal.mjs +197 -0
  96. package/template/.agents/skills/design/impeccable/scripts/live/frameworks/nextjs.mjs +49 -0
  97. package/template/.agents/skills/design/impeccable/scripts/live/frameworks/nuxt.mjs +161 -0
  98. package/template/.agents/skills/design/impeccable/scripts/live/frameworks/script-src.mjs +17 -0
  99. package/template/.agents/skills/design/impeccable/scripts/live/frameworks/static-html.mjs +26 -0
  100. package/template/.agents/skills/design/impeccable/scripts/live/frameworks/sveltekit.mjs +71 -0
  101. package/template/.agents/skills/design/impeccable/scripts/live/frameworks/tag-strategy.mjs +247 -0
  102. package/template/.agents/skills/design/impeccable/scripts/live/frameworks/tanstack-start.mjs +70 -0
  103. package/template/.agents/skills/design/impeccable/scripts/live/frameworks/vite-generic.mjs +42 -0
  104. package/template/.agents/skills/design/impeccable/scripts/live/generation-preflight.mjs +149 -0
  105. package/template/.agents/skills/design/impeccable/scripts/live/instructions.mjs +142 -0
  106. package/template/.agents/skills/design/impeccable/scripts/live/poll-lanes.mjs +14 -0
  107. package/template/.agents/skills/design/impeccable/scripts/live/roots.mjs +508 -0
  108. package/template/.agents/skills/design/impeccable/scripts/live/session-store.mjs +324 -50
  109. package/template/.agents/skills/design/impeccable/scripts/live/source-lock.mjs +105 -0
  110. package/template/.agents/skills/design/impeccable/scripts/live/source-search.mjs +105 -0
  111. package/template/.agents/skills/design/impeccable/scripts/live/svelte-ast.mjs +961 -0
  112. package/template/.agents/skills/design/impeccable/scripts/live/svelte-component.mjs +588 -72
  113. package/template/.agents/skills/design/impeccable/scripts/live/sveltekit-adapter.mjs +59 -17
  114. package/template/.agents/skills/design/impeccable/scripts/live/tanstack-adapter.mjs +280 -0
  115. package/template/.agents/skills/design/impeccable/scripts/live/vocabulary.mjs +135 -0
  116. package/template/.agents/skills/design/impeccable/scripts/live-accept.mjs +210 -68
  117. package/template/.agents/skills/design/impeccable/scripts/live-browser.js +1562 -223
  118. package/template/.agents/skills/design/impeccable/scripts/live-commit-manual-edits.mjs +3 -0
  119. package/template/.agents/skills/design/impeccable/scripts/live-complete.mjs +33 -1
  120. package/template/.agents/skills/design/impeccable/scripts/live-inject.mjs +175 -255
  121. package/template/.agents/skills/design/impeccable/scripts/live-insert.mjs +26 -6
  122. package/template/.agents/skills/design/impeccable/scripts/live-manual-edit-evidence.mjs +6 -1
  123. package/template/.agents/skills/design/impeccable/scripts/live-poll.mjs +61 -16
  124. package/template/.agents/skills/design/impeccable/scripts/live-resume.mjs +39 -10
  125. package/template/.agents/skills/design/impeccable/scripts/live-server.mjs +573 -47
  126. package/template/.agents/skills/design/impeccable/scripts/live-status.mjs +17 -7
  127. package/template/.agents/skills/design/impeccable/scripts/live-wrap.mjs +124 -91
  128. package/template/.agents/skills/design/impeccable/scripts/live.mjs +88 -26
  129. package/template/.agents/skills/design/impeccable/scripts/palette.mjs +76 -81
  130. package/template/.agents/skills/design/impeccable/scripts/pin.mjs +18 -11
  131. package/template/.agents/skills/design/impeccable/scripts/serve-question.mjs +932 -0
  132. package/template/.agents/skills/design/impeccable/scripts/surface-brief.mjs +74 -0
  133. package/template/.agents/skills/design/shadcn/SKILL.md +46 -11
  134. package/template/.agents/skills/design/shadcn/cli.md +49 -16
  135. package/template/.agents/skills/design/shadcn/customization.md +14 -7
  136. package/template/.agents/skills/design/shadcn/evals/evals.json +30 -0
  137. package/template/.agents/skills/design/shadcn/mcp.md +27 -16
  138. package/template/.agents/skills/design/shadcn/registry.md +277 -0
  139. package/template/.agents/skills/design/shadcn/rules/chat.md +224 -0
  140. package/template/.agents/skills/design/shadcn/rules/composition.md +20 -2
  141. package/template/.agents/skills/design/shadcn/rules/styling.md +23 -0
  142. package/template/.agents/skills/find-skills/SKILL.md +2 -3
  143. package/template/.agents/skills/hono/SKILL.md +579 -0
  144. package/template/.agents/skills/integrations/langfuse/SKILL.md +10 -6
  145. package/template/.agents/skills/integrations/langfuse/references/ci-cd.md +41 -0
  146. package/template/.agents/skills/integrations/langfuse/references/cli.md +8 -0
  147. package/template/.agents/skills/integrations/langfuse/references/error-analysis.md +15 -27
  148. package/template/.agents/skills/integrations/langfuse/references/instrumentation.md +39 -52
  149. package/template/.agents/skills/integrations/langfuse/references/judge-calibration.md +5 -2
  150. package/template/.agents/skills/integrations/langfuse/references/prompt-engineering.md +35 -0
  151. package/template/.agents/skills/integrations/langfuse/references/prompt-migration.md +41 -196
  152. package/template/.agents/skills/integrations/langfuse/references/skill-feedback.md +3 -0
  153. package/template/.agents/skills/integrations/langfuse/references/trace-evaluator-upgrade.md +76 -0
  154. package/template/.agents/skills/integrations/langfuse/references/user-feedback.md +4 -0
  155. package/template/.agents/skills/integrations/langfuse/references/v4-project-migration.md +73 -0
  156. package/template/.agents/skills/supabase/CHANGELOG.md +71 -0
  157. package/template/.agents/skills/supabase/SKILL.md +145 -0
  158. package/template/.agents/skills/supabase/assets/feedback-issue-template.md +17 -0
  159. package/template/.agents/skills/supabase/references/skill-feedback.md +17 -0
  160. package/template/.agents/skills/supabase-postgres-best-practices/CHANGELOG.md +73 -0
  161. package/template/.agents/skills/supabase-postgres-best-practices/SKILL.md +64 -0
  162. package/template/.agents/skills/supabase-postgres-best-practices/references/_contributing.md +170 -0
  163. package/template/.agents/skills/supabase-postgres-best-practices/references/_sections.md +39 -0
  164. package/template/.agents/skills/supabase-postgres-best-practices/references/_template.md +34 -0
  165. package/template/.agents/skills/supabase-postgres-best-practices/references/advanced-full-text-search.md +55 -0
  166. package/template/.agents/skills/supabase-postgres-best-practices/references/advanced-jsonb-indexing.md +49 -0
  167. package/template/.agents/skills/supabase-postgres-best-practices/references/conn-idle-timeout.md +46 -0
  168. package/template/.agents/skills/supabase-postgres-best-practices/references/conn-limits.md +44 -0
  169. package/template/.agents/skills/supabase-postgres-best-practices/references/conn-pooling.md +41 -0
  170. package/template/.agents/skills/supabase-postgres-best-practices/references/conn-prepared-statements.md +46 -0
  171. package/template/.agents/skills/supabase-postgres-best-practices/references/data-batch-inserts.md +54 -0
  172. package/template/.agents/skills/supabase-postgres-best-practices/references/data-n-plus-one.md +53 -0
  173. package/template/.agents/skills/supabase-postgres-best-practices/references/data-pagination.md +50 -0
  174. package/template/.agents/skills/supabase-postgres-best-practices/references/data-upsert.md +50 -0
  175. package/template/.agents/skills/supabase-postgres-best-practices/references/lock-advisory.md +56 -0
  176. package/template/.agents/skills/supabase-postgres-best-practices/references/lock-deadlock-prevention.md +68 -0
  177. package/template/.agents/skills/supabase-postgres-best-practices/references/lock-short-transactions.md +50 -0
  178. package/template/.agents/skills/supabase-postgres-best-practices/references/lock-skip-locked.md +54 -0
  179. package/template/.agents/skills/supabase-postgres-best-practices/references/monitor-explain-analyze.md +45 -0
  180. package/template/.agents/skills/supabase-postgres-best-practices/references/monitor-pg-stat-statements.md +55 -0
  181. package/template/.agents/skills/supabase-postgres-best-practices/references/monitor-vacuum-analyze.md +55 -0
  182. package/template/.agents/skills/supabase-postgres-best-practices/references/query-composite-indexes.md +44 -0
  183. package/template/.agents/skills/supabase-postgres-best-practices/references/query-covering-indexes.md +40 -0
  184. package/template/.agents/skills/supabase-postgres-best-practices/references/query-index-types.md +48 -0
  185. package/template/.agents/skills/supabase-postgres-best-practices/references/query-missing-indexes.md +43 -0
  186. package/template/.agents/skills/supabase-postgres-best-practices/references/query-partial-indexes.md +45 -0
  187. package/template/.agents/skills/supabase-postgres-best-practices/references/schema-constraints.md +80 -0
  188. package/template/.agents/skills/supabase-postgres-best-practices/references/schema-data-types.md +46 -0
  189. package/template/.agents/skills/supabase-postgres-best-practices/references/schema-foreign-key-indexes.md +59 -0
  190. package/template/.agents/skills/supabase-postgres-best-practices/references/schema-lowercase-identifiers.md +55 -0
  191. package/template/.agents/skills/supabase-postgres-best-practices/references/schema-partitioning.md +55 -0
  192. package/template/.agents/skills/supabase-postgres-best-practices/references/schema-primary-keys.md +61 -0
  193. package/template/.agents/skills/supabase-postgres-best-practices/references/security-privileges.md +54 -0
  194. package/template/.agents/skills/supabase-postgres-best-practices/references/security-rls-basics.md +50 -0
  195. package/template/.agents/skills/supabase-postgres-best-practices/references/security-rls-performance.md +63 -0
  196. package/template/.agents/upstreams.json +34 -12
  197. package/template/.agents/skills/design/impeccable/reference/brand.md +0 -108
  198. package/template/.agents/skills/design/impeccable/reference/codex.md +0 -105
  199. package/template/.agents/skills/design/impeccable/reference/interaction-design.md +0 -189
  200. package/template/.agents/skills/integrations/langfuse/references/sdk-upgrade.md +0 -175
@@ -0,0 +1,457 @@
1
+ /**
2
+ * Tier 2 staleness checks: the ones that cost too much to run on every session
3
+ * boot. Shelling out to git, walking workspaces, resolving hook script paths,
4
+ * and validating ignore lists against the live rule registry all belong here.
5
+ *
6
+ * The boot tier answers "did an older Impeccable write this". This tier also
7
+ * asks "does it still describe the code", which no file comparison can settle
8
+ * on its own. Where the answer needs judgment, the finding reports a measured
9
+ * proxy and says it is a proxy. It never claims a document is wrong because a
10
+ * number is large.
11
+ *
12
+ * Same finding shape and severities as lib/staleness.mjs.
13
+ */
14
+
15
+ import fs from 'node:fs';
16
+ import path from 'node:path';
17
+ import { execFileSync } from 'node:child_process';
18
+ import { fileURLToPath, pathToFileURL } from 'node:url';
19
+
20
+ const VISUAL_SOURCE_DIRS = ['src', 'app', 'pages', 'components', 'site', 'styles', 'public'];
21
+
22
+ const HOOK_MANIFESTS_BY_PROVIDER = Object.freeze({
23
+ 'claude-code': ['.claude/settings.local.json', '.claude/settings.json'],
24
+ codex: ['.codex/hooks.json'],
25
+ agents: ['.codex/hooks.json'],
26
+ cursor: ['.cursor/hooks.json'],
27
+ github: ['.github/hooks/impeccable.json'],
28
+ grok: ['.grok/hooks/impeccable.json'],
29
+ });
30
+
31
+ const HOOK_SCRIPT_MARKERS = [
32
+ 'skills/impeccable/scripts/hook.mjs',
33
+ 'skills/impeccable/scripts/hook-before-edit.mjs',
34
+ ];
35
+
36
+ // Retired live-mode state locations. impeccable-paths still reads these as
37
+ // fallbacks; reporting them is what eventually lets the fallbacks go.
38
+ const LEGACY_LIVE_PATHS = ['.impeccable-live.json', '.impeccable-live'];
39
+
40
+ function finding({ id, artifact, filePath = null, severity, summary, fix }) {
41
+ return { id, artifact, path: filePath, severity, summary, fix };
42
+ }
43
+
44
+ function readJson(filePath) {
45
+ try {
46
+ return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
47
+ } catch {
48
+ return null;
49
+ }
50
+ }
51
+
52
+ function toRelative(filePath, root) {
53
+ if (!filePath) return null;
54
+ const rel = path.relative(root, filePath);
55
+ return rel && !rel.startsWith('..') && !path.isAbsolute(rel)
56
+ ? rel.split(path.sep).join('/')
57
+ : filePath;
58
+ }
59
+
60
+ function git(args, cwd) {
61
+ try {
62
+ return execFileSync('git', args, {
63
+ cwd,
64
+ encoding: 'utf-8',
65
+ stdio: ['ignore', 'pipe', 'ignore'],
66
+ timeout: 5000,
67
+ }).trim();
68
+ } catch {
69
+ return null;
70
+ }
71
+ }
72
+
73
+ // ─── DESIGN.md truth drift ─────────────────────────────────────────────────
74
+
75
+ /**
76
+ * How much UI work has landed since DESIGN.md was last touched, measured in
77
+ * commits to the visual source directories. A proxy, and reported as one: a
78
+ * large number means the document is worth re-reading, not that it is wrong.
79
+ * Silent outside a git repo, on an untracked DESIGN.md, and when the count is
80
+ * small enough to be ordinary maintenance.
81
+ */
82
+ export function checkDesignDrift({ designPath, projectRoot, threshold = 25 }) {
83
+ if (!designPath || !projectRoot) return [];
84
+ if (!git(['rev-parse', '--is-inside-work-tree'], projectRoot)) return [];
85
+
86
+ const relDesign = toRelative(designPath, projectRoot);
87
+ const lastDesignCommit = git(['log', '-1', '--format=%H', '--', relDesign], projectRoot);
88
+ if (!lastDesignCommit) return [];
89
+
90
+ const dirs = VISUAL_SOURCE_DIRS.filter((dir) => fs.existsSync(path.join(projectRoot, dir)));
91
+ if (!dirs.length) return [];
92
+
93
+ const log = git(
94
+ ['log', '--oneline', `${lastDesignCommit}..HEAD`, '--', ...dirs],
95
+ projectRoot,
96
+ );
97
+ if (log === null) return [];
98
+ const commits = log ? log.split('\n').filter(Boolean).length : 0;
99
+ if (commits < threshold) return [];
100
+
101
+ const when = git(['log', '-1', '--format=%ad', '--date=short', '--', relDesign], projectRoot);
102
+ return [finding({
103
+ id: 'design-md-drift',
104
+ artifact: 'DESIGN.md',
105
+ filePath: relDesign,
106
+ severity: 'route',
107
+ summary: `${commits} commits have touched ${dirs.join(', ')} since ${relDesign} was last edited`
108
+ + `${when ? ` (${when})` : ''}. This counts commits, not contradictions: it says the document is worth `
109
+ + 're-reading, not that it is wrong.',
110
+ fix: 'Read DESIGN.md against the current tokens and components before trusting it as authority. '
111
+ + 'If it has genuinely drifted, `document` regenerates it from the code.',
112
+ })];
113
+ }
114
+
115
+ /**
116
+ * Canonical DESIGN.md sections that carry nothing. Distinct from truth drift:
117
+ * a section can be absent because it never applied, so this is reported as a
118
+ * documentation gap for a human to judge, never as an error.
119
+ */
120
+ export function checkDesignCoverage({ design, designPath, parseDesignMd }) {
121
+ if (!design || typeof parseDesignMd !== 'function') return [];
122
+ let model;
123
+ try {
124
+ model = parseDesignMd(design);
125
+ } catch {
126
+ return [];
127
+ }
128
+ const missing = ['colors', 'typography', 'components']
129
+ .filter((section) => !model[section]);
130
+ if (!missing.length) return [];
131
+ return [finding({
132
+ id: 'design-md-coverage',
133
+ artifact: 'DESIGN.md',
134
+ filePath: designPath,
135
+ severity: 'mention',
136
+ summary: `${designPath || 'DESIGN.md'} has no ${missing.join(', ')} section. `
137
+ + 'Agents generating new screens get no normative guidance for those, and the live design panel renders '
138
+ + 'generic approximations in their place.',
139
+ fix: 'Ask whether the section never applied or was never written. `document` fills it from the code if the '
140
+ + 'project has the answer in its CSS.',
141
+ })];
142
+ }
143
+
144
+ // ─── detector ignore lists ─────────────────────────────────────────────────
145
+
146
+ /**
147
+ * Ignore entries that no longer match anything: rule ids the engine dropped or
148
+ * renamed, and file paths that are gone. Both read as working suppressions
149
+ * until someone checks, and a dead rule ignore also hides that the rule left.
150
+ */
151
+ export function checkDetectorIgnores({ projectRoot, knownRuleIds = null }) {
152
+ const findings = [];
153
+ if (!projectRoot) return findings;
154
+
155
+ for (const name of ['config.json', 'config.local.json']) {
156
+ const filePath = path.join(projectRoot, '.impeccable', name);
157
+ const raw = readJson(filePath);
158
+ const detector = raw?.detector;
159
+ if (!detector || typeof detector !== 'object') continue;
160
+ const rel = toRelative(filePath, projectRoot);
161
+
162
+ if (knownRuleIds && Array.isArray(detector.ignoreRules)) {
163
+ const unknown = detector.ignoreRules
164
+ .map((rule) => String(rule || '').trim().toLowerCase())
165
+ .filter((rule) => rule && rule !== '*' && !knownRuleIds.has(rule));
166
+ if (unknown.length) {
167
+ findings.push(finding({
168
+ id: 'detector-ignore-rules-unknown',
169
+ artifact: 'config.json',
170
+ filePath: rel,
171
+ severity: 'mention',
172
+ summary: `${rel} ignores rule id(s) the detector does not have: `
173
+ + `${unknown.map((rule) => `\`${rule}\``).join(', ')}. Either the rule was renamed or removed, or the `
174
+ + 'id was mistyped and has never suppressed anything.',
175
+ fix: 'Report the exact ids. Removing them is safe; keeping a dead ignore hides that the rule is gone.',
176
+ }));
177
+ }
178
+ }
179
+
180
+ if (Array.isArray(detector.ignoreFiles)) {
181
+ const missing = detector.ignoreFiles
182
+ .map((entry) => String(entry || '').trim())
183
+ .filter((entry) => entry && !entry.includes('*') && !fs.existsSync(path.join(projectRoot, entry)));
184
+ if (missing.length) {
185
+ findings.push(finding({
186
+ id: 'detector-ignore-files-missing',
187
+ artifact: 'config.json',
188
+ filePath: rel,
189
+ severity: 'mention',
190
+ summary: `${rel} ignores file path(s) that no longer exist: `
191
+ + `${missing.map((entry) => `\`${entry}\``).join(', ')}.`,
192
+ fix: 'Ask whether the file moved (repoint the entry) or was deleted (drop it). '
193
+ + 'A stale entry silently stops covering the file that replaced it.',
194
+ }));
195
+ }
196
+ }
197
+ }
198
+ return findings;
199
+ }
200
+
201
+ // ─── hook installation ─────────────────────────────────────────────────────
202
+
203
+ function collectHookCommands(value, out = []) {
204
+ if (typeof value === 'string') {
205
+ if (HOOK_SCRIPT_MARKERS.some((marker) => value.includes(marker))) out.push(value);
206
+ return out;
207
+ }
208
+ if (Array.isArray(value)) {
209
+ for (const entry of value) collectHookCommands(entry, out);
210
+ return out;
211
+ }
212
+ if (value && typeof value === 'object') {
213
+ for (const entry of Object.values(value)) collectHookCommands(entry, out);
214
+ }
215
+ return out;
216
+ }
217
+
218
+ const HOOK_MARKER = /skills\/impeccable\/scripts\/hook(?:-before-edit)?\.mjs/;
219
+
220
+ // Pull the script-path token out of a hook command line, placeholders intact.
221
+ // The forms our manifests ship:
222
+ // * bare: node "${CLAUDE_PROJECT_DIR}/.../hook.mjs"
223
+ // * bundle-relative: node ".agents/.../hook.mjs"
224
+ // * legacy unquoted: node .claude/.../hook.mjs
225
+ // * guarded (#399): [ ! -f "PATH" ] || node "PATH" (PATH twice, identical)
226
+ // * absolute: node "/Users/.../hook.mjs" (user-level installs)
227
+ // * github portable: node "$(git rev-parse --show-toplevel)/.../hook.mjs"
228
+ // A quoted path wins; the guard's two occurrences are identical, so the first
229
+ // quoted match is the path. Otherwise fall back to the whitespace/metachar-
230
+ // delimited token that ends at the marker, so we don't absorb `node`, `[`, `!`
231
+ // or `||`. Returns the token verbatim; resolution happens separately.
232
+ function hookScriptTokenFrom(command) {
233
+ const str = String(command);
234
+ if (!HOOK_MARKER.test(str)) return null;
235
+ const quoted = str.match(/"([^"]*skills\/impeccable\/scripts\/hook(?:-before-edit)?\.mjs)"/);
236
+ if (quoted) return quoted[1];
237
+ const bare = str.match(/([^\s"'|&;()]*skills\/impeccable\/scripts\/hook(?:-before-edit)?\.mjs)/);
238
+ return bare ? bare[1] : null;
239
+ }
240
+
241
+ // Resolve a script token to an absolute path the doctor can existsSync, or null
242
+ // when the doctor cannot know where it points — in which case the caller must
243
+ // NOT report it missing (a doctor never asserts a negative it cannot verify).
244
+ //
245
+ // Per-placeholder policy, mirroring what each runtime actually expands:
246
+ // ${CLAUDE_PROJECT_DIR} → the project root being scanned. This is exactly the
247
+ // runtime mapping (Claude Code sets it to the project
248
+ // dir at hook time), so we EXPAND it against `root`.
249
+ // Not doing so was the #402 bug: the literal
250
+ // `${CLAUDE_PROJECT_DIR}/...` string never exists.
251
+ // ${CLAUDE_PLUGIN_ROOT} → plugin-package install dir, set by the harness to
252
+ // ${PLUGIN_ROOT} wherever the plugin/codex/grok bundle was unpacked
253
+ // ${GROK_PLUGIN_ROOT} (grok aliases CLAUDE_PLUGIN_ROOT). The doctor has no
254
+ // way to know that location → SKIP (return null).
255
+ // $(...) / backticks → command substitution, e.g. GitHub's
256
+ // `$(git rev-parse --show-toplevel)`. Not statically
257
+ // resolvable → SKIP.
258
+ // any other ${VAR}/$VAR → unknown to the doctor → SKIP.
259
+ // A token with no placeholder is a literal path: absolute as-is, else relative
260
+ // to `root`.
261
+ function resolveHookScriptPath(token, root) {
262
+ if (!token) return null;
263
+ // Command substitution or backtick expansion we can't evaluate.
264
+ if (token.includes('$(') || token.includes('`')) return null;
265
+ const expanded = token.replace(/\$\{CLAUDE_PROJECT_DIR\}/g, root);
266
+ // Any placeholder or shell variable still present is one we can't map.
267
+ if (/\$\{[^}]*\}|\$[A-Za-z_]/.test(expanded)) return null;
268
+ return path.isAbsolute(expanded) ? expanded : path.join(root, expanded);
269
+ }
270
+
271
+ /**
272
+ * A hook whose script path does not resolve is a silent no-op, and the user
273
+ * believes the project is covered. Also catches the contradiction of an
274
+ * installed manifest against `hook.enabled: false`.
275
+ */
276
+ export function checkHookInstallation({ projectRoot, repoRoot, providerId }) {
277
+ const findings = [];
278
+ const manifests = HOOK_MANIFESTS_BY_PROVIDER[providerId] || [];
279
+ if (!manifests.length) return findings;
280
+
281
+ const roots = [...new Set([projectRoot, repoRoot].filter(Boolean).map((root) => path.resolve(root)))];
282
+ let installedAt = null;
283
+
284
+ for (const root of roots) {
285
+ for (const rel of manifests) {
286
+ const manifestPath = path.join(root, rel);
287
+ const raw = readJson(manifestPath);
288
+ if (!raw?.hooks) continue;
289
+ const commands = collectHookCommands(raw.hooks);
290
+ if (!commands.length) continue;
291
+ installedAt = toRelative(manifestPath, projectRoot || root);
292
+
293
+ const broken = commands.filter((command) => {
294
+ const token = hookScriptTokenFrom(command);
295
+ if (!token) return false;
296
+ const abs = resolveHookScriptPath(token, root);
297
+ // Unresolvable placeholder or command substitution: never assert missing.
298
+ if (!abs) return false;
299
+ return !fs.existsSync(abs);
300
+ });
301
+ if (broken.length) {
302
+ findings.push(finding({
303
+ id: 'hook-script-missing',
304
+ artifact: 'hook manifest',
305
+ filePath: installedAt,
306
+ severity: 'mention',
307
+ summary: `${installedAt} installs the design hook, but its script path does not exist: `
308
+ + `${broken.map((command) => `\`${command}\``).join(', ')}. The hook runs as a no-op, so UI edits `
309
+ + 'have been going unscanned while the project looks covered.',
310
+ fix: `Reinstall with \`impeccable hooks on\`, which rewrites the manifest against the skill's current location.`,
311
+ }));
312
+ }
313
+ }
314
+ }
315
+
316
+ if (installedAt) {
317
+ for (const root of roots) {
318
+ for (const name of ['config.json', 'config.local.json']) {
319
+ const raw = readJson(path.join(root, '.impeccable', name));
320
+ if (raw?.hook && raw.hook.enabled === false) {
321
+ findings.push(finding({
322
+ id: 'hook-enabled-conflict',
323
+ artifact: 'config.json',
324
+ filePath: toRelative(path.join(root, '.impeccable', name), projectRoot || root),
325
+ severity: 'mention',
326
+ summary: `${installedAt} installs the design hook while this config sets \`hook.enabled: false\`, `
327
+ + 'so the hook fires and then declines to scan.',
328
+ fix: 'Ask which was intended: `impeccable hooks on` to enable, or `impeccable hooks off` to uninstall '
329
+ + 'the manifest entry as well.',
330
+ }));
331
+ return findings;
332
+ }
333
+ }
334
+ }
335
+ }
336
+
337
+ return findings;
338
+ }
339
+
340
+ // ─── retired locations ─────────────────────────────────────────────────────
341
+
342
+ export function checkLegacyLiveState({ projectRoot }) {
343
+ if (!projectRoot) return [];
344
+ const present = LEGACY_LIVE_PATHS.filter((rel) => fs.existsSync(path.join(projectRoot, rel)));
345
+ if (!present.length) return [];
346
+ return [finding({
347
+ id: 'legacy-live-state',
348
+ artifact: 'live state',
349
+ filePath: present.join(', '),
350
+ severity: 'auto',
351
+ summary: `Live-mode state sits in retired location(s): ${present.map((rel) => `\`${rel}\``).join(', ')}. `
352
+ + 'Current live mode writes under `.impeccable/live/`.',
353
+ fix: 'These are read only through backward-compatible fallbacks and are safe to delete once no live session '
354
+ + 'is running. No user decision is needed.',
355
+ })];
356
+ }
357
+
358
+ // ─── monorepo sweep ────────────────────────────────────────────────────────
359
+
360
+ /**
361
+ * Per-workspace context, plus the case worth acting on: a workspace with
362
+ * native build files inheriting a repo-root PRODUCT.md that says web. Each
363
+ * such app gets web guidance and never loads the native references, and
364
+ * nothing at boot reports it because the root record parses cleanly.
365
+ *
366
+ * `candidates` comes from context.mjs's discovery so the walk is not repeated.
367
+ */
368
+ export function checkWorkspaces({ repoRoot, candidates = [], checkNativePlatformEvidence, extractPlatform, readFile }) {
369
+ if (!repoRoot || !candidates.length) return { findings: [], workspaces: [] };
370
+ const findings = [];
371
+ const workspaces = [];
372
+
373
+ for (const candidate of candidates) {
374
+ const workspaceRoot = path.join(repoRoot, candidate.path);
375
+ const productPath = candidate.productPath ? path.join(repoRoot, candidate.productPath) : null;
376
+ const product = productPath && readFile ? readFile(productPath) : null;
377
+ const platform = extractPlatform ? extractPlatform(product) : null;
378
+
379
+ workspaces.push({
380
+ name: candidate.name,
381
+ path: candidate.path,
382
+ productStatus: candidate.productStatus,
383
+ productPath: candidate.productPath,
384
+ designStatus: candidate.designStatus,
385
+ designPath: candidate.designPath,
386
+ platform: platform || (product ? 'web (default)' : null),
387
+ });
388
+
389
+ if (!checkNativePlatformEvidence) continue;
390
+ const native = checkNativePlatformEvidence({
391
+ projectRoot: workspaceRoot,
392
+ platform,
393
+ product,
394
+ productPath: candidate.productPath,
395
+ });
396
+ for (const entry of native) {
397
+ findings.push(finding({
398
+ id: 'workspace-platform-native-evidence',
399
+ artifact: 'PRODUCT.md',
400
+ filePath: candidate.productPath || `${candidate.path}/PRODUCT.md`,
401
+ severity: 'mention',
402
+ summary: `Workspace \`${candidate.path}\` ${
403
+ candidate.productStatus === 'inherited'
404
+ ? 'inherits the repo-root PRODUCT.md'
405
+ : 'has a PRODUCT.md'
406
+ } that resolves to web, but the workspace itself carries native build files. ${entry.summary}`,
407
+ fix: candidate.productStatus === 'inherited'
408
+ ? `Give \`${candidate.path}\` its own PRODUCT.md with the right \`## Platform\`. `
409
+ + 'An inherited record cannot describe two platforms at once.'
410
+ : entry.fix,
411
+ }));
412
+ }
413
+ }
414
+
415
+ const inherited = workspaces.filter((entry) => entry.productStatus === 'inherited');
416
+ if (inherited.length) {
417
+ findings.push(finding({
418
+ id: 'workspace-context-inherited',
419
+ artifact: 'PRODUCT.md',
420
+ filePath: null,
421
+ severity: 'mention',
422
+ summary: `${inherited.length} of ${workspaces.length} workspace(s) inherit the repo-root PRODUCT.md: `
423
+ + `${inherited.map((entry) => `\`${entry.path}\``).join(', ')}. Inheritance is intended; whether one `
424
+ + 'record truthfully describes these apps is not something this check can tell.',
425
+ fix: 'Ask the user whether the inherited record describes each app. Where it does not, `init` in that '
426
+ + 'workspace writes a child PRODUCT.md that overrides it.',
427
+ }));
428
+ }
429
+
430
+ return { findings, workspaces };
431
+ }
432
+
433
+ // ─── rule registry ─────────────────────────────────────────────────────────
434
+
435
+ /**
436
+ * Rule ids from the bundled detector, or null when it cannot be resolved (a
437
+ * partial install, or a harness that ships the skill without the engine).
438
+ * Null means "cannot check", which the ignore-rule check treats as skip rather
439
+ * than as every id being unknown.
440
+ */
441
+ export async function loadKnownRuleIds(scriptsDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')) {
442
+ // Same two locations detect.mjs resolves: the bundled copy in an installed
443
+ // skill, then the source-repo engine when running from a checkout.
444
+ const candidates = [
445
+ path.join(scriptsDir, 'detector', 'detect-antipatterns.mjs'),
446
+ path.join(scriptsDir, '..', '..', 'cli', 'engine', 'detect-antipatterns.mjs'),
447
+ ];
448
+ const detectorPath = candidates.find((candidate) => fs.existsSync(candidate));
449
+ if (!detectorPath) return null;
450
+ try {
451
+ const { ANTIPATTERNS } = await import(pathToFileURL(detectorPath).href);
452
+ if (!Array.isArray(ANTIPATTERNS)) return null;
453
+ return new Set(ANTIPATTERNS.map((rule) => String(rule.id).toLowerCase()));
454
+ } catch {
455
+ return null;
456
+ }
457
+ }
@@ -0,0 +1,169 @@
1
+ /**
2
+ * Notice throttling and directive rendering for staleness findings.
3
+ *
4
+ * The boot path already carries PRODUCT.md, DESIGN.md, a surface brief,
5
+ * RESOLVED_CONTEXT, the detector fallback, native platform references, and the
6
+ * update directive. An unthrottled staleness block would push real context out
7
+ * of attention and train the agent to open every session with housekeeping, so
8
+ * the rules here are deliberately strict:
9
+ *
10
+ * - One directive for the whole set, never one per finding.
11
+ * - A 'mention' or 'route' finding surfaces at most once a week per project,
12
+ * mirroring the update check's anti-nag window. A finding the user has
13
+ * already declined to act on must not reappear tomorrow.
14
+ * - 'auto' findings are not throttled and are not shown to the user. They are
15
+ * migrations the next write performs anyway, so the agent needs the note
16
+ * every session until the write happens, and the user needs it never.
17
+ *
18
+ * State lives in the user's home dir alongside the update cache rather than in
19
+ * the project, so no gitignore entry is owed and a clone does not inherit
20
+ * someone else's dismissals.
21
+ */
22
+
23
+ import fs from 'node:fs';
24
+ import os from 'node:os';
25
+ import path from 'node:path';
26
+
27
+ const RENOTIFY_INTERVAL_MS = 7 * 24 * 60 * 60 * 1000;
28
+
29
+ // Resolved per call rather than at import so a test (or a sandboxed run) can
30
+ // redirect the cache without reloading the module.
31
+ function cachePath() {
32
+ return process.env.IMPECCABLE_STALENESS_CACHE
33
+ || path.join(os.homedir(), '.impeccable', 'staleness-check.json');
34
+ }
35
+
36
+ function readCache() {
37
+ try {
38
+ const raw = JSON.parse(fs.readFileSync(cachePath(), 'utf-8'));
39
+ return raw && typeof raw === 'object' && raw.projects ? raw : { projects: {} };
40
+ } catch {
41
+ return { projects: {} };
42
+ }
43
+ }
44
+
45
+ /**
46
+ * Drop project entries whose newest stamp has aged past the renotify window.
47
+ * They would be re-notified on the next boot anyway, so keeping them only lets
48
+ * the file accumulate one entry per directory Impeccable has ever booted in
49
+ * (scratch dirs and test fixtures included).
50
+ */
51
+ function pruneCache(cache, now) {
52
+ const projects = {};
53
+ for (const [key, entries] of Object.entries(cache.projects || {})) {
54
+ if (!entries || typeof entries !== 'object') continue;
55
+ const stamps = Object.values(entries).filter((value) => typeof value === 'number');
56
+ if (stamps.length && now - Math.max(...stamps) < RENOTIFY_INTERVAL_MS) projects[key] = entries;
57
+ }
58
+ return { projects };
59
+ }
60
+
61
+ function writeCache(cache) {
62
+ try {
63
+ const filePath = cachePath();
64
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
65
+ fs.writeFileSync(filePath, JSON.stringify(cache));
66
+ } catch {
67
+ // Best-effort. A read-only home dir means the notice repeats next session,
68
+ // which is strictly better than failing the boot.
69
+ }
70
+ }
71
+
72
+ function readJson(filePath) {
73
+ try {
74
+ return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
75
+ } catch {
76
+ return null;
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Opt out with IMPECCABLE_NO_STALENESS_CHECK=1 or `"stalenessCheck": false` in
82
+ * .impeccable/config.json. Local config overrides shared, matching how
83
+ * updateCheck resolves.
84
+ */
85
+ export function stalenessCheckDisabled(roots = [process.cwd()]) {
86
+ if (process.env.IMPECCABLE_NO_STALENESS_CHECK) return true;
87
+ let value;
88
+ for (const root of roots) {
89
+ if (!root) continue;
90
+ for (const name of ['config.json', 'config.local.json']) {
91
+ const raw = readJson(path.join(root, '.impeccable', name));
92
+ if (raw && typeof raw === 'object' && typeof raw.stalenessCheck === 'boolean') {
93
+ value = raw.stalenessCheck;
94
+ }
95
+ }
96
+ }
97
+ return value === false;
98
+ }
99
+
100
+ /**
101
+ * Drop findings already surfaced for this project inside the renotify window,
102
+ * and stamp the ones that survive. 'auto' findings pass through untouched and
103
+ * unstamped: they are for the agent, not the user, and repeat until fixed.
104
+ */
105
+ export function filterFreshFindings(findings, { projectRoot, now = Date.now() } = {}) {
106
+ if (!findings.length) return [];
107
+ const auto = findings.filter((entry) => entry.severity === 'auto');
108
+ const notifiable = findings.filter((entry) => entry.severity !== 'auto');
109
+ if (!notifiable.length) return auto;
110
+
111
+ const key = path.resolve(projectRoot || process.cwd());
112
+ const cache = readCache();
113
+ const seen = cache.projects[key] && typeof cache.projects[key] === 'object' ? cache.projects[key] : {};
114
+
115
+ const fresh = notifiable.filter((entry) => {
116
+ const last = seen[entry.id];
117
+ return !(typeof last === 'number' && now - last < RENOTIFY_INTERVAL_MS);
118
+ });
119
+
120
+ // Forget stamps for findings that no longer fire, so a recurrence after a
121
+ // real fix is reported again instead of being suppressed by an old stamp.
122
+ // This has to run even when nothing is fresh: the common shape is one
123
+ // finding fixed while another is still inside its window.
124
+ const live = new Set(notifiable.map((entry) => entry.id));
125
+ const next = Object.fromEntries(
126
+ Object.entries(seen).filter(([id]) => live.has(id)),
127
+ );
128
+ for (const entry of fresh) next[entry.id] = now;
129
+
130
+ const changed = JSON.stringify(next) !== JSON.stringify(seen);
131
+ if (changed) {
132
+ const pruned = pruneCache(cache, now);
133
+ pruned.projects[key] = next;
134
+ writeCache(pruned);
135
+ }
136
+ return [...auto, ...fresh];
137
+ }
138
+
139
+ /**
140
+ * Render the single boot directive, or null when nothing survived throttling.
141
+ */
142
+ export function buildStalenessDirective(findings) {
143
+ if (!findings.length) return null;
144
+ const payload = findings.map((entry) => ({
145
+ id: entry.id,
146
+ artifact: entry.artifact,
147
+ path: entry.path,
148
+ severity: entry.severity,
149
+ summary: entry.summary,
150
+ fix: entry.fix,
151
+ }));
152
+
153
+ const hasReportable = findings.some((entry) => entry.severity !== 'auto');
154
+ const lines = [
155
+ `CONTEXT_STALE:\n${JSON.stringify(payload, null, 2)}`,
156
+ "Impeccable's own project files have drifted from what this version reads. "
157
+ + 'Do not stop, reorder, or expand the requested task for any of this.',
158
+ 'By severity: `auto` is a migration the next write to that file performs anyway, so apply it then and do not '
159
+ + 'raise it with the user. `mention` gets one short line in your reply with the offered fix. `route` names the '
160
+ + 'command that owns the repair; offer it, and run it only if the user asks.',
161
+ 'A finding that reports a deprecated field is binding: treat that field as absent for every decision in this '
162
+ + 'session, whatever value it holds.',
163
+ ];
164
+ if (hasReportable) {
165
+ lines.push('Surface the reportable findings once, after the task response, in at most two sentences. '
166
+ + 'They are already throttled, so say them plainly rather than hedging about whether they matter.');
167
+ }
168
+ return lines.join(' ');
169
+ }