@monoes/monodesign 1.2.18 → 1.2.22

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 (104) hide show
  1. package/README.md +3 -3
  2. package/cli/engine/design-system-checks.mjs +381 -0
  3. package/cli/engine/design-system-parse.mjs +433 -0
  4. package/cli/engine/design-system.mjs +20 -736
  5. package/cli/engine/detect-antipatterns-browser.js +12 -1
  6. package/cli/engine/engines/regex/detect-text-matchers.mjs +371 -0
  7. package/cli/engine/engines/regex/detect-text-run.mjs +186 -0
  8. package/cli/engine/engines/regex/detect-text.mjs +11 -523
  9. package/cli/engine/engines/static-html/css-cascade-color.mjs +219 -0
  10. package/cli/engine/engines/static-html/css-cascade-static-data.mjs +138 -0
  11. package/cli/engine/engines/static-html/css-cascade-static-dom.mjs +132 -0
  12. package/cli/engine/engines/static-html/css-cascade-static-parse.mjs +393 -0
  13. package/cli/engine/engines/static-html/css-cascade.mjs +24 -837
  14. package/cli/engine/registry/antipatterns-provider.mjs +60 -0
  15. package/cli/engine/registry/antipatterns-quality.mjs +218 -0
  16. package/cli/engine/registry/antipatterns-slop.mjs +226 -0
  17. package/cli/engine/registry/antipatterns.mjs +7 -494
  18. package/cli/engine/rules/checks-background.mjs +155 -0
  19. package/cli/engine/rules/checks-css-values.mjs +137 -0
  20. package/cli/engine/rules/checks-dom-effects.mjs +243 -0
  21. package/cli/engine/rules/checks-dom.mjs +148 -0
  22. package/cli/engine/rules/checks-element.mjs +286 -0
  23. package/cli/engine/rules/checks-html.mjs +204 -0
  24. package/cli/engine/rules/checks-overflow.mjs +263 -0
  25. package/cli/engine/rules/checks-page.mjs +465 -0
  26. package/cli/engine/rules/checks-pure.mjs +453 -0
  27. package/cli/engine/rules/checks-quality.mjs +447 -0
  28. package/cli/engine/rules/checks-stylesheet.mjs +324 -0
  29. package/cli/engine/rules/checks.mjs +98 -2939
  30. package/cli/lib/monodesign-config-consent.mjs +93 -0
  31. package/cli/lib/monodesign-config-core.mjs +37 -0
  32. package/cli/lib/monodesign-config-detection.mjs +133 -0
  33. package/cli/lib/monodesign-config-filter.mjs +198 -0
  34. package/cli/lib/monodesign-config-ignore-value.mjs +212 -0
  35. package/cli/lib/monodesign-config.mjs +21 -623
  36. package/package.json +2 -2
  37. package/skill/scripts/context-fields.mjs +65 -0
  38. package/skill/scripts/context-resolve.mjs +425 -0
  39. package/skill/scripts/context-update.mjs +151 -0
  40. package/skill/scripts/context-utils.mjs +68 -0
  41. package/skill/scripts/context-workspace.mjs +245 -0
  42. package/skill/scripts/context.mjs +17 -891
  43. package/skill/scripts/hook-admin-config.mjs +152 -0
  44. package/skill/scripts/hook-admin-ignore.mjs +131 -0
  45. package/skill/scripts/hook-admin-manifest.mjs +292 -0
  46. package/skill/scripts/hook-admin.mjs +8 -541
  47. package/skill/scripts/hook-before-edit-shell.mjs +184 -0
  48. package/skill/scripts/hook-before-edit.mjs +9 -165
  49. package/skill/scripts/hook-lib-cache.mjs +145 -0
  50. package/skill/scripts/hook-lib-config.mjs +292 -0
  51. package/skill/scripts/hook-lib-detector.mjs +72 -0
  52. package/skill/scripts/hook-lib-events.mjs +182 -0
  53. package/skill/scripts/hook-lib-findings.mjs +201 -0
  54. package/skill/scripts/hook-lib-ignore.mjs +205 -0
  55. package/skill/scripts/hook-lib-render.mjs +273 -0
  56. package/skill/scripts/hook-lib-run.mjs +281 -0
  57. package/skill/scripts/hook-lib-scan-targets.mjs +128 -0
  58. package/skill/scripts/hook-lib.mjs +71 -1747
  59. package/skill/scripts/lib/design-parser-colors.mjs +182 -0
  60. package/skill/scripts/lib/design-parser-extract.mjs +333 -0
  61. package/skill/scripts/lib/design-parser-sections.mjs +214 -0
  62. package/skill/scripts/lib/design-parser-yaml.mjs +135 -0
  63. package/skill/scripts/lib/design-parser.mjs +10 -783
  64. package/skill/scripts/live/manual-apply-chunk.mjs +151 -0
  65. package/skill/scripts/live/manual-apply-compact.mjs +122 -0
  66. package/skill/scripts/live/manual-apply-evidence.mjs +115 -0
  67. package/skill/scripts/live/manual-apply-log.mjs +179 -0
  68. package/skill/scripts/live/manual-apply-transaction.mjs +122 -0
  69. package/skill/scripts/live/manual-apply.mjs +65 -640
  70. package/skill/scripts/live/svelte-component-accept.mjs +248 -0
  71. package/skill/scripts/live/svelte-component-css.mjs +230 -0
  72. package/skill/scripts/live/svelte-component-markup.mjs +104 -0
  73. package/skill/scripts/live/svelte-component-scaffold.mjs +118 -0
  74. package/skill/scripts/live/svelte-component-session.mjs +158 -0
  75. package/skill/scripts/live/svelte-component.mjs +32 -792
  76. package/skill/scripts/live-accept-mutate.mjs +152 -0
  77. package/skill/scripts/live-accept-parse.mjs +370 -0
  78. package/skill/scripts/live-accept-scrub.mjs +59 -0
  79. package/skill/scripts/live-accept-search.mjs +58 -0
  80. package/skill/scripts/live-accept.mjs +15 -631
  81. package/skill/scripts/live-commit-manual-edits-paths.mjs +27 -0
  82. package/skill/scripts/live-commit-manual-edits-repair.mjs +166 -0
  83. package/skill/scripts/live-commit-manual-edits-rollback.mjs +231 -0
  84. package/skill/scripts/live-commit-manual-edits-utils.mjs +167 -0
  85. package/skill/scripts/live-commit-manual-edits-verify.mjs +356 -0
  86. package/skill/scripts/live-commit-manual-edits.mjs +24 -881
  87. package/skill/scripts/live-copy-edit-agent.mjs +16 -551
  88. package/skill/scripts/live-copy-edit-compact.mjs +137 -0
  89. package/skill/scripts/live-copy-edit-post-apply.mjs +175 -0
  90. package/skill/scripts/live-copy-edit-runners.mjs +297 -0
  91. package/skill/scripts/live-inject-csp.mjs +132 -0
  92. package/skill/scripts/live-inject-files.mjs +229 -0
  93. package/skill/scripts/live-inject-tag.mjs +114 -0
  94. package/skill/scripts/live-inject.mjs +13 -440
  95. package/skill/scripts/live-server-poll.mjs +232 -0
  96. package/skill/scripts/live-server-routes.mjs +449 -0
  97. package/skill/scripts/live-server-state.mjs +361 -0
  98. package/skill/scripts/live-server.mjs +10 -946
  99. package/skill/scripts/live-wrap-css.mjs +132 -0
  100. package/skill/scripts/live-wrap-manual-edit.mjs +153 -0
  101. package/skill/scripts/live-wrap-search.mjs +247 -0
  102. package/skill/scripts/live-wrap.mjs +21 -484
  103. package/skill/scripts/palette-seeds.mjs +401 -0
  104. package/skill/scripts/palette.mjs +1 -395
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Reading and writing the `hook`/`detector` subtrees of
3
+ * .monodesign/config.json / .monodesign/config.local.json for hook-admin.mjs.
4
+ *
5
+ * Split out of hook-admin.mjs. See that file for context.
6
+ */
7
+ import fs from 'node:fs';
8
+ import path from 'node:path';
9
+ import {
10
+ getConfigPath,
11
+ getLocalConfigPath,
12
+ DEFAULT_CONFIG,
13
+ ensureHookGitExcludes,
14
+ normalizeIgnoreValueEntries,
15
+ } from './hook-lib.mjs';
16
+
17
+ const DETECTOR_CONFIG_KEYS = new Set(['ignoreRules', 'ignoreFiles', 'ignoreValues', 'designSystem']);
18
+
19
+ export function readRawConfigFile(filePath) {
20
+ if (!fs.existsSync(filePath)) return { exists: false, malformed: false, raw: null };
21
+ try {
22
+ return { exists: true, malformed: false, raw: JSON.parse(fs.readFileSync(filePath, 'utf-8')) };
23
+ } catch {
24
+ return { exists: true, malformed: true, raw: null };
25
+ }
26
+ }
27
+
28
+
29
+ function hookSection(unified) {
30
+ return unified && typeof unified === 'object' && !Array.isArray(unified) && unified.hook && typeof unified.hook === 'object' && !Array.isArray(unified.hook)
31
+ ? unified.hook
32
+ : null;
33
+ }
34
+
35
+ function detectorSection(unified) {
36
+ return unified && typeof unified === 'object' && !Array.isArray(unified) && unified.detector && typeof unified.detector === 'object' && !Array.isArray(unified.detector)
37
+ ? unified.detector
38
+ : null;
39
+ }
40
+
41
+ export function readRawHookConfig(cwd, opts = {}) {
42
+ const unified = readRawConfigFile(opts.local ? getLocalConfigPath(cwd) : getConfigPath(cwd)).raw;
43
+ return hookSection(unified);
44
+ }
45
+
46
+ export function readRawDetectorConfig(cwd, opts = {}) {
47
+ const unified = readRawConfigFile(opts.local ? getLocalConfigPath(cwd) : getConfigPath(cwd)).raw;
48
+ const merged = mergeDetectorConfig(hookSection(unified));
49
+ return mergeDetectorConfig(detectorSection(unified), merged);
50
+ }
51
+
52
+ function stripDetectorKeys(raw) {
53
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return {};
54
+ const out = {};
55
+ for (const [key, value] of Object.entries(raw)) {
56
+ if (!DETECTOR_CONFIG_KEYS.has(key)) out[key] = value;
57
+ }
58
+ return out;
59
+ }
60
+
61
+ // Write hook runtime config under `hook`, leaving detector filters in
62
+ // `detector` and preserving sibling keys such as updateCheck.
63
+ export function writeHookConfig(cwd, hookConfig, opts = {}) {
64
+ const filePath = opts.local ? getLocalConfigPath(cwd) : getConfigPath(cwd);
65
+ if (opts.local) ensureHookGitExcludes(cwd);
66
+ const existingRaw = readRawConfigFile(filePath).raw;
67
+ const existing = existingRaw && typeof existingRaw === 'object' && !Array.isArray(existingRaw) ? existingRaw : {};
68
+ const existingHook = stripDetectorKeys(hookSection(existing));
69
+ // Merge over the existing hook object so fields the merge helpers don't manage
70
+ // (consent, quiet, auditLog) survive an Monodesign hooks edit.
71
+ const next = { ...existing, hook: { ...existingHook, ...hookConfig } };
72
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
73
+ fs.writeFileSync(filePath, `${JSON.stringify(next, null, 2)}\n`);
74
+ return filePath;
75
+ }
76
+
77
+ export function writeDetectorConfig(cwd, detectorConfig, opts = {}) {
78
+ const filePath = opts.local ? getLocalConfigPath(cwd) : getConfigPath(cwd);
79
+ if (opts.local) ensureHookGitExcludes(cwd);
80
+ const existingRaw = readRawConfigFile(filePath).raw;
81
+ const existing = existingRaw && typeof existingRaw === 'object' && !Array.isArray(existingRaw) ? existingRaw : {};
82
+ const nextHook = stripDetectorKeys(hookSection(existing));
83
+ const existingDetector = mergeDetectorConfig(detectorSection(existing));
84
+ const next = {
85
+ ...existing,
86
+ detector: mergeDetectorConfig(detectorConfig, existingDetector),
87
+ };
88
+ if (Object.keys(nextHook).length > 0) next.hook = nextHook;
89
+ else delete next.hook;
90
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
91
+ fs.writeFileSync(filePath, `${JSON.stringify(next, null, 2)}\n`);
92
+ return filePath;
93
+ }
94
+
95
+ export function mergeHookConfig(existing) {
96
+ const base = existing && typeof existing === 'object' ? existing : {};
97
+ return {
98
+ enabled: base.enabled !== false,
99
+ limits: {
100
+ maxFindings: Number.isFinite(base?.limits?.maxFindings) ? base.limits.maxFindings : DEFAULT_CONFIG.limits.maxFindings,
101
+ maxChars: Number.isFinite(base?.limits?.maxChars) ? base.limits.maxChars : DEFAULT_CONFIG.limits.maxChars,
102
+ },
103
+ };
104
+ }
105
+
106
+ export function mergeDetectorConfig(existing, seed = null) {
107
+ const base = existing && typeof existing === 'object' ? existing : {};
108
+ const out = seed ? {
109
+ ignoreRules: [...seed.ignoreRules],
110
+ ignoreFiles: [...seed.ignoreFiles],
111
+ ignoreValues: normalizeIgnoreValueEntries(seed.ignoreValues),
112
+ } : {
113
+ ignoreRules: [],
114
+ ignoreFiles: [],
115
+ ignoreValues: [],
116
+ };
117
+ if (seed?.designSystem && typeof seed.designSystem === 'object' && !Array.isArray(seed.designSystem)) {
118
+ out.designSystem = { ...seed.designSystem };
119
+ }
120
+ if (base.designSystem && typeof base.designSystem === 'object' && !Array.isArray(base.designSystem)) {
121
+ out.designSystem = {
122
+ ...(out.designSystem || {}),
123
+ enabled: base.designSystem.enabled !== false,
124
+ };
125
+ }
126
+ if (Array.isArray(base.ignoreRules)) {
127
+ out.ignoreRules = Array.from(new Set([...out.ignoreRules, ...base.ignoreRules.map(String)]));
128
+ }
129
+ if (Array.isArray(base.ignoreFiles)) {
130
+ out.ignoreFiles = Array.from(new Set([...out.ignoreFiles, ...base.ignoreFiles.map(String)]));
131
+ }
132
+ if (Array.isArray(base.ignoreValues)) {
133
+ out.ignoreValues = mergeIgnoreValueEntries(out.ignoreValues, base.ignoreValues);
134
+ }
135
+ return out;
136
+ }
137
+
138
+ function mergeIgnoreValueEntries(existing, incoming) {
139
+ const map = new Map();
140
+ for (const entry of normalizeIgnoreValueEntries(existing)) {
141
+ map.set(ignoreValueEntryKey(entry), entry);
142
+ }
143
+ for (const entry of normalizeIgnoreValueEntries(incoming)) {
144
+ map.set(ignoreValueEntryKey(entry), entry);
145
+ }
146
+ return Array.from(map.values());
147
+ }
148
+
149
+ function ignoreValueEntryKey(entry) {
150
+ const files = Array.isArray(entry.files) && entry.files.length > 0 ? entry.files.join('\x1f') : '';
151
+ return `${entry.rule}\0${entry.value}\0${files}`;
152
+ }
@@ -0,0 +1,131 @@
1
+ /**
2
+ * The `ignore-rule` / `ignore-file` / `ignore-value` hook-admin.mjs commands:
3
+ * parsing their CLI args and merging the result into detector config.
4
+ *
5
+ * Split out of hook-admin.mjs. See that file for context.
6
+ */
7
+ import path from 'node:path';
8
+ import { MONODESIGN_COMMAND } from './lib/provider.mjs';
9
+ import { normalizeIgnoreValue } from './hook-lib.mjs';
10
+ import {
11
+ mergeDetectorConfig,
12
+ readRawDetectorConfig,
13
+ writeDetectorConfig,
14
+ } from './hook-admin-config.mjs';
15
+
16
+ function normalizeRuleId(rule) {
17
+ return String(rule || '').trim().toLowerCase();
18
+ }
19
+
20
+ function parseIgnoreRuleArgs(args) {
21
+ const positionals = [];
22
+ let allValues = false;
23
+
24
+ for (let i = 0; i < args.length; i++) {
25
+ const arg = String(args[i] || '');
26
+ if (arg === '--all-values') {
27
+ allValues = true;
28
+ } else if (arg === '--reason') {
29
+ while (i + 1 < args.length && !String(args[i + 1]).startsWith('--')) i++;
30
+ } else if (arg.startsWith('--reason=')) {
31
+ // Accepted for command symmetry; ignoreRules stores rule ids only.
32
+ } else if (arg.startsWith('--')) {
33
+ throw new Error(`Unknown ignore-rule flag: ${arg}`);
34
+ } else {
35
+ positionals.push(arg);
36
+ }
37
+ }
38
+
39
+ return {
40
+ rule: normalizeRuleId(positionals[0]),
41
+ allValues,
42
+ };
43
+ }
44
+
45
+ export function addIgnoreRule(cwd, args) {
46
+ const parsed = parseIgnoreRuleArgs(args);
47
+ const rule = parsed.rule;
48
+ if (!rule) throw new Error(`Pass a rule id, e.g. ${MONODESIGN_COMMAND} hooks ignore-rule side-tab`);
49
+ if (rule === 'overused-font' && !parsed.allValues) {
50
+ throw new Error(`overused-font is value-specific by default. Use ${MONODESIGN_COMMAND} hooks ignore-value overused-font <font> for a confirmed font, or ${MONODESIGN_COMMAND} hooks ignore-rule overused-font --all-values only when the user asked to ignore overused fonts generally.`);
51
+ }
52
+ const config = mergeDetectorConfig(readRawDetectorConfig(cwd));
53
+ if (!config.ignoreRules.includes(rule)) config.ignoreRules.push(rule);
54
+ writeDetectorConfig(cwd, config);
55
+ return `Added "${rule}" to detector.ignoreRules. Current: ${config.ignoreRules.join(', ')}`;
56
+ }
57
+
58
+ export function addIgnoreFile(cwd, glob) {
59
+ if (!glob) throw new Error(`Pass a glob, e.g. ${MONODESIGN_COMMAND} hooks ignore-file "src/legacy/**"`);
60
+ const config = mergeDetectorConfig(readRawDetectorConfig(cwd));
61
+ if (!config.ignoreFiles.includes(glob)) config.ignoreFiles.push(glob);
62
+ writeDetectorConfig(cwd, config);
63
+ return `Added "${glob}" to detector.ignoreFiles. Current: ${config.ignoreFiles.join(', ')}`;
64
+ }
65
+
66
+ function parseIgnoreValueArgs(args) {
67
+ const positionals = [];
68
+ let shared = false;
69
+ let local = false;
70
+ let reason = '';
71
+
72
+ for (let i = 0; i < args.length; i++) {
73
+ const arg = args[i];
74
+ if (arg === '--shared') {
75
+ shared = true;
76
+ } else if (arg === '--local') {
77
+ local = true;
78
+ } else if (arg === '--reason') {
79
+ const chunks = [];
80
+ while (i + 1 < args.length && !String(args[i + 1]).startsWith('--')) {
81
+ chunks.push(args[++i]);
82
+ }
83
+ reason = chunks.join(' ').trim();
84
+ } else if (String(arg).startsWith('--reason=')) {
85
+ reason = String(arg).slice('--reason='.length).trim();
86
+ } else {
87
+ positionals.push(arg);
88
+ }
89
+ }
90
+
91
+ const [rule, ...valueParts] = positionals;
92
+ return {
93
+ rule: String(rule || '').trim().toLowerCase(),
94
+ value: normalizeIgnoreValue(valueParts.join(' ')),
95
+ shared,
96
+ local,
97
+ reason,
98
+ };
99
+ }
100
+
101
+ export function addIgnoreValue(cwd, args) {
102
+ const parsed = parseIgnoreValueArgs(args);
103
+ if (!parsed.rule || !parsed.value) {
104
+ throw new Error(`Pass a rule id and value, e.g. ${MONODESIGN_COMMAND} hooks ignore-value overused-font Inter`);
105
+ }
106
+
107
+ if (parsed.shared && parsed.local) {
108
+ throw new Error('Pass only one scope flag: --shared or --local');
109
+ }
110
+
111
+ const local = parsed.local;
112
+ const config = mergeDetectorConfig(readRawDetectorConfig(cwd, { local }));
113
+ const key = `${parsed.rule}\0${parsed.value}`;
114
+ const existing = config.ignoreValues.find((entry) => `${entry.rule}\0${entry.value}` === key);
115
+
116
+ if (existing) {
117
+ if (parsed.reason) existing.reason = parsed.reason;
118
+ } else {
119
+ const entry = {
120
+ rule: parsed.rule,
121
+ value: parsed.value,
122
+ createdAt: new Date().toISOString(),
123
+ };
124
+ if (parsed.reason) entry.reason = parsed.reason;
125
+ config.ignoreValues.push(entry);
126
+ }
127
+
128
+ const target = writeDetectorConfig(cwd, config, { local });
129
+ const scope = local ? 'local detector.ignoreValues' : 'shared detector.ignoreValues';
130
+ return `Added ${parsed.rule}=${parsed.value} to ${scope} (${path.relative(cwd, target).split(path.sep).join('/') || target}).`;
131
+ }
@@ -0,0 +1,292 @@
1
+ /**
2
+ * Installing/repairing the design-hook manifest for each supported provider
3
+ * (.claude, .agents/Codex, .cursor, .github) for hook-admin.mjs.
4
+ *
5
+ * Split out of hook-admin.mjs. See that file for context.
6
+ */
7
+ import fs from 'node:fs';
8
+ import path from 'node:path';
9
+
10
+ const MONODESIGN_HOOK_COMMAND_MARKERS = [
11
+ 'skills/monodesign/scripts/hook-probe.mjs',
12
+ 'skills/monodesign/scripts/hook.mjs',
13
+ 'skills/monodesign/scripts/hook-before-edit.mjs',
14
+ 'skills/monodesign/scripts/hook-after-edit.mjs',
15
+ 'skills/monodesign/scripts/hook-stop.mjs',
16
+ ];
17
+ const TIMEOUT_SECONDS = 5;
18
+ const STATUS_MESSAGE = 'Checking UI changes';
19
+
20
+ const HOOK_MANIFEST_TARGETS = [
21
+ {
22
+ provider: '.claude',
23
+ skillRel: '.claude/skills/monodesign',
24
+ destRel: '.claude/settings.local.json',
25
+ sharedDestRel: '.claude/settings.json',
26
+ manifest: () => ({
27
+ description: 'Monodesign design detector: runs after Edit/Write/MultiEdit on UI files and surfaces findings as system reminders.',
28
+ hooks: {
29
+ PostToolUse: [
30
+ {
31
+ matcher: 'Edit|Write|MultiEdit',
32
+ hooks: [
33
+ {
34
+ type: 'command',
35
+ command: 'node "${CLAUDE_PROJECT_DIR}/.claude/skills/monodesign/scripts/hook.mjs"',
36
+ timeout: TIMEOUT_SECONDS,
37
+ statusMessage: STATUS_MESSAGE,
38
+ },
39
+ ],
40
+ },
41
+ ],
42
+ },
43
+ }),
44
+ },
45
+ {
46
+ provider: '.agents',
47
+ skillRel: '.agents/skills/monodesign',
48
+ destRel: '.codex/config.toml',
49
+ format: 'toml',
50
+ manifest: () => [
51
+ '# monodesign:start native-hook',
52
+ '[[hooks.PostToolUse]]',
53
+ 'matcher = "Edit|Write|apply_patch"',
54
+ '[[hooks.PostToolUse.hooks]]',
55
+ 'type = "command"',
56
+ 'command = "node .agents/skills/monodesign/scripts/hook.mjs"',
57
+ `timeout = ${TIMEOUT_SECONDS}`,
58
+ `statusMessage = "${STATUS_MESSAGE}"`,
59
+ '# monodesign:end native-hook',
60
+ '',
61
+ ].join('\n'),
62
+ },
63
+ {
64
+ provider: '.cursor',
65
+ skillRel: '.cursor/skills/monodesign',
66
+ destRel: '.cursor/hooks.json',
67
+ manifest: () => ({
68
+ version: 1,
69
+ hooks: {
70
+ preToolUse: [
71
+ {
72
+ command: 'node ".cursor/skills/monodesign/scripts/hook-before-edit.mjs"',
73
+ timeout: TIMEOUT_SECONDS,
74
+ },
75
+ ],
76
+ },
77
+ }),
78
+ },
79
+ {
80
+ // GitHub Copilot reads repo-level hooks from `.github/hooks/*.json`. The same
81
+ // manifest is honored by the CLI (once committed to the default branch) and
82
+ // the cloud/app agent. Schema differs: lowercase `postToolUse`, flat entries,
83
+ // `bash`/`timeoutSec`, and a `matcher` regex against the `edit`/`create` tools.
84
+ provider: '.github',
85
+ skillRel: '.github/skills/monodesign',
86
+ destRel: '.github/hooks/monodesign.json',
87
+ manifest: () => ({
88
+ version: 1,
89
+ hooks: {
90
+ postToolUse: [
91
+ {
92
+ type: 'command',
93
+ matcher: 'edit|create|apply_patch',
94
+ bash: 'node "$(git rev-parse --show-toplevel)/.github/skills/monodesign/scripts/hook.mjs"',
95
+ timeoutSec: TIMEOUT_SECONDS,
96
+ },
97
+ ],
98
+ },
99
+ }),
100
+ },
101
+ ];
102
+
103
+ export function repairHookManifests(cwd) {
104
+ const result = { written: [], already: [], backups: [] };
105
+ for (const target of HOOK_MANIFEST_TARGETS) {
106
+ if (!fs.existsSync(path.join(cwd, target.skillRel))) continue;
107
+ const dest = path.join(cwd, target.destRel);
108
+ const sharedDest = target.sharedDestRel ? path.join(cwd, target.sharedDestRel) : null;
109
+
110
+ if (target.format === 'toml') {
111
+ const current = fs.existsSync(dest) ? safeReadText(dest) : '';
112
+ const next = mergeCodexHookConfig(current || '', target.manifest());
113
+ if (current === next) {
114
+ result.already.push(target.provider);
115
+ continue;
116
+ }
117
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
118
+ fs.writeFileSync(dest, next);
119
+ result.written.push(target.provider);
120
+ continue;
121
+ }
122
+
123
+ if (sharedDest && fileHasMonodesignHookMarker(sharedDest)) {
124
+ pruneMonodesignHookFromManifest(dest);
125
+ result.already.push(target.provider);
126
+ continue;
127
+ }
128
+
129
+ const fresh = target.manifest();
130
+ let next = fresh;
131
+ if (fs.existsSync(dest)) {
132
+ try {
133
+ next = mergeHookManifests(JSON.parse(fs.readFileSync(dest, 'utf-8')), fresh);
134
+ } catch {
135
+ const backup = `${dest}.bak`;
136
+ fs.copyFileSync(dest, backup);
137
+ result.backups.push(backup);
138
+ }
139
+ }
140
+
141
+ const serialized = `${JSON.stringify(next, null, 2)}\n`;
142
+ const current = fs.existsSync(dest) ? safeReadText(dest) : null;
143
+ if (current === serialized) {
144
+ result.already.push(target.provider);
145
+ continue;
146
+ }
147
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
148
+ fs.writeFileSync(dest, serialized);
149
+ result.written.push(target.provider);
150
+ }
151
+ return result;
152
+ }
153
+
154
+ function mergeCodexHookConfig(existing, hookBlock) {
155
+ const completeBlock = /# monodesign:start native-hook[\s\S]*?# monodesign:end native-hook\n?/m;
156
+ const incompleteBlock = /# monodesign:start native-hook[\s\S]*$/m;
157
+ let merged = existing;
158
+ if (completeBlock.test(merged)) merged = merged.replace(completeBlock, hookBlock);
159
+ else if (incompleteBlock.test(merged)) merged = merged.replace(incompleteBlock, hookBlock);
160
+ else merged = `${merged.trimEnd()}${merged.trim() ? '\n\n' : ''}${hookBlock}`;
161
+ return enableCodexHooks(merged);
162
+ }
163
+
164
+ function enableCodexHooks(config) {
165
+ if (!/^\[features\]\s*$/m.test(config)) {
166
+ return `[features]\nhooks = true\n\n${config}`;
167
+ }
168
+ const lines = config.split(/\r?\n/);
169
+ const start = lines.findIndex((line) => /^\[features\]\s*$/.test(line));
170
+ let end = start + 1;
171
+ while (end < lines.length && !/^\[\[?[^\]]+\]\]?\s*$/.test(lines[end])) end++;
172
+ const hookIndex = lines.slice(start + 1, end).findIndex((line) => /^hooks\s*=/.test(line));
173
+ if (hookIndex === -1) lines.splice(end, 0, 'hooks = true');
174
+ else lines[start + 1 + hookIndex] = 'hooks = true';
175
+ return lines.join('\n');
176
+ }
177
+
178
+ function safeReadText(filePath) {
179
+ try {
180
+ return fs.readFileSync(filePath, 'utf-8');
181
+ } catch {
182
+ return null;
183
+ }
184
+ }
185
+
186
+ function mergeHookManifests(existing, fresh) {
187
+ const existingObject = existing && typeof existing === 'object' && !Array.isArray(existing) ? existing : {};
188
+ const freshObject = fresh && typeof fresh === 'object' && !Array.isArray(fresh) ? fresh : {};
189
+ const existingHooks = existingObject.hooks && typeof existingObject.hooks === 'object' && !Array.isArray(existingObject.hooks)
190
+ ? existingObject.hooks
191
+ : {};
192
+ const freshHooks = freshObject.hooks && typeof freshObject.hooks === 'object' && !Array.isArray(freshObject.hooks)
193
+ ? freshObject.hooks
194
+ : {};
195
+
196
+ const merged = { ...existingObject, hooks: {} };
197
+ if (freshObject.version !== undefined) merged.version = freshObject.version;
198
+ if (freshObject.description !== undefined) merged.description = freshObject.description;
199
+
200
+ const hookEvents = new Set([...Object.keys(existingHooks), ...Object.keys(freshHooks)]);
201
+ for (const event of hookEvents) {
202
+ const preserved = stripMonodesignHookEntries(existingHooks[event]);
203
+ const added = Array.isArray(freshHooks[event]) ? freshHooks[event] : [];
204
+ const mergedEntries = [...preserved, ...added];
205
+ if (mergedEntries.length > 0) merged.hooks[event] = mergedEntries;
206
+ }
207
+ return merged;
208
+ }
209
+
210
+ function fileHasMonodesignHookMarker(filePath) {
211
+ if (!fs.existsSync(filePath)) return false;
212
+ let parsed;
213
+ try {
214
+ parsed = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
215
+ } catch {
216
+ return false;
217
+ }
218
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return false;
219
+ if (!parsed.hooks || typeof parsed.hooks !== 'object') return false;
220
+ return valueHasMonodesignHookMarker(parsed.hooks);
221
+ }
222
+
223
+ function valueHasMonodesignHookMarker(value) {
224
+ if (typeof value === 'string') {
225
+ return MONODESIGN_HOOK_COMMAND_MARKERS.some((marker) => value.includes(marker));
226
+ }
227
+ if (Array.isArray(value)) return value.some(valueHasMonodesignHookMarker);
228
+ if (value && typeof value === 'object') return Object.values(value).some(valueHasMonodesignHookMarker);
229
+ return false;
230
+ }
231
+
232
+ function stripMonodesignHookEntry(entry) {
233
+ if (!entry || typeof entry !== 'object') return entry;
234
+ // `command`/`args`: Claude/Codex/Cursor. `bash`/`powershell`: GitHub Copilot's
235
+ // flat entry shape, where the marker lives under the shell-command keys.
236
+ if (valueHasMonodesignHookMarker(entry.command) || valueHasMonodesignHookMarker(entry.args)
237
+ || valueHasMonodesignHookMarker(entry.bash) || valueHasMonodesignHookMarker(entry.powershell)) {
238
+ return null;
239
+ }
240
+ if (!Array.isArray(entry.hooks)) return entry;
241
+
242
+ const strippedHooks = entry.hooks
243
+ .map(stripMonodesignHookEntry)
244
+ .filter(Boolean);
245
+
246
+ if (strippedHooks.length === 0 && entry.hooks.some(valueHasMonodesignHookMarker)) {
247
+ return null;
248
+ }
249
+ return { ...entry, hooks: strippedHooks };
250
+ }
251
+
252
+ function stripMonodesignHookEntries(entries) {
253
+ if (!Array.isArray(entries)) return [];
254
+ return entries
255
+ .map(stripMonodesignHookEntry)
256
+ .filter(Boolean);
257
+ }
258
+
259
+ function pruneMonodesignHookFromManifest(manifestPath) {
260
+ if (!fileHasMonodesignHookMarker(manifestPath)) return false;
261
+ let parsed;
262
+ try {
263
+ parsed = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
264
+ } catch {
265
+ return false;
266
+ }
267
+
268
+ const existingHooks = parsed.hooks && typeof parsed.hooks === 'object' && !Array.isArray(parsed.hooks)
269
+ ? parsed.hooks
270
+ : {};
271
+ const cleanedHooks = {};
272
+ for (const [event, entries] of Object.entries(existingHooks)) {
273
+ const kept = stripMonodesignHookEntries(entries);
274
+ if (kept.length > 0) cleanedHooks[event] = kept;
275
+ }
276
+
277
+ const next = { ...parsed };
278
+ if (Object.keys(cleanedHooks).length > 0) {
279
+ next.hooks = cleanedHooks;
280
+ } else {
281
+ delete next.hooks;
282
+ delete next.description;
283
+ delete next.version;
284
+ }
285
+
286
+ if (Object.keys(next).length === 0) {
287
+ fs.rmSync(manifestPath, { force: true });
288
+ } else {
289
+ fs.writeFileSync(manifestPath, `${JSON.stringify(next, null, 2)}\n`);
290
+ }
291
+ return true;
292
+ }